简介:面向高校C语言期末备考场景的详解题库合集,共305道题目,覆盖选择题、填空题、判断题、读程序写结果与压轴编程大题五种题型,适合正在系统复习基础语法、准备期末考试的学生,也可作为教师组卷与课堂讲评的参考素材。
题目均配逐题解析,围绕函数与main函数执行起点、标识符命名规则、大小写敏感、顺序选择循环三种控制结构、整型实型字符型数据、求余运算、三目条件表达式、隐式类型提升与强制类型转换、一维数组声明等高频考点展开,解析中同步点明易错项与判断依据,帮助读者在做题中理清概念边界,而非死记答案。
包内为1个PDF文件,约1.56MB,按题型分章编排,便于专项检索与集中突破。目前已有428人学习下载,适合需要借大量练习加详解快速查漏补缺的中初级学习者。
1. 305 道题背后其实只有六七条硬规则
C 语言期末题库里那 305 道题,最容易让人误判的地方是:它看起来是 305 个彼此独立的知识点,实际上背后只有六七条底层规则在反复被考。一份带详解的题库真正有价值的地方,不在于答案对不对,而在于解析有没有把话说准;不少解析只说到“记住就行”这一层,于是备考就变成了背结论,换个问法就翻车。
举个最像玄学的例子:char x[]="hello"和char y[5]={'h','e','l','l','o'}长得几乎一样,但sizeof一个给 6、一个给 5,用%s打印前者正常、后者可能吐出乱码。这不是运气问题,是字符串字面量在编译期被偷偷补了一个\0。
选择、填空、判断、读程序写结果、压轴编程这五类题型,恰好对应编译流水线的不同阶段:词法、语法、类型检查、求值、运行时内存布局。搞懂这条线,题库就从 305 个记忆点变成一张可以自己推导的地图。
2. 词法分析这一关:为什么-a1、3ax、int连编译都过不去
题库里第 7 到第 10 题连着四道都在问“下列哪个标识符合法”,选项里塞满了-a1、a[i]、3ax、case、union、%x、a+b、I\am、start$it、My->book、line#、His.age。第一次做会觉得出题人在为难人,其实这四道是同一道题,考的是同一张表。
2.1 标识符的字符集:词法分析器只认三种字符
C 标准里标识符的产生式非常短:首字符必须是下划线或字母,后续字符是字母、数字或下划线。就这一句话,把选项里所有-、[、]、%、+、\、$、->、#、.全部判死。剩下的case、union、int、while属于另一类:字符集合法,但被语言预留成了关键字。
关键在于理解“最长匹配”这个扫描原则。编译器读字符流时,看到一个能构成 token 的前缀就会一路吃到底,不会中途反悔。所以3ax并不是“一个非法标识符”,它被切成常量3和标识符ax两个 token,紧接着语法分析器发现两个 token 之间少了个运算符,报的错往往是expected ';' before 'ax'之类,跟你以为的“标识符写错了”完全不是一回事。这种错位感,就是很多人觉得 C 报错“看不懂”的来源。
写个二十行的状态机,把这件事跑一遍就彻底清楚了:
# 用最小状态机模拟 C 词法分析器切分 token 的过程 KEYWORDS = {"int", "float", "char", "if", "else", "while", "for", "return", "struct", "union", "enum", "typedef", "static", "auto", "case"} def scan(src): i, n, out = 0, len(src), [] while i < n: c = src[i] if c.isalpha() or c == "_": # 首字符:字母或下划线 j = i while j < n and (src[j].isalnum() or src[j] == "_"): j += 1 # 最长匹配,一路吃到底 word = src[i:j] out.append(("keyword" if word in KEYWORDS else "ident", word)) i = j elif c.isdigit(): # 数字开头,走常量分支 j = i while j < n and src[j].isdigit(): j += 1 out.append(("number", src[i:j])) i = j else: # 运算符、分隔符 out.append(("punct", c)) i += 1 return out for t, v in scan("int 3ax; int a2_i; int -a1;"): print(t, v)这段代码没有任何编译原理的额外依赖,scan接收源码字符串,返回(类型, 词素)序列。KEYWORDS集合就是语言预留的保留字表,isalpha()和isdigit()用来判断字符类别,两个while负责贪心地把一个 token 吃满。跑出来的结果会告诉你:3ax变成number 3加ident ax,-a1变成punct -加ident a1,只有a2_i是完整的一个ident。题库里那道“下列四组字符串都可以用作标识符的是”,答案选print _3d db8 aBc,正是因为这四个词素在第一关就全部通过。
2.2 大小写敏感不是“规定”,是符号表按字节比较
a在 ASCII 表里是 97,A是 65,相差 32。编译器的符号表本质是一张“名字 → 条目”的哈希表或有序表,比较名字时按字节比,不会做任何大小写折叠。所以main、Main、MAIN在编译器眼里是三个毫不相干的符号。
这一层认知能拆掉题库里一个很隐蔽的坑。判断题第一题说“C 语言是严格区分大小写的,因此主函数不能写成 main() 以外的其他形式”,很多人打勾。前半句对,后半句把两件事混成了一件。int MAIN(void){return 0;}在语法上完全合法,编译单元能顺利编译成目标文件,真正出问题的是链接阶段:
gcc -c demo.c -o demo.o nm demo.o # 0000000000000000 T main <- T 表示已定义的全局符号,位于代码段 # U printf <- U 表示未定义,留给链接器去别处找nm列出的是目标文件的符号表,T是已定义文本符号,U是未定义符号。把函数名改成MAIN再跑一遍,T main就消失了,取而代之的是T MAIN,而链接器要找的入口符号永远叫main,于是报undefined reference to 'main'。语法检查和符号解析是两道关,题库里大量“对不对”的题目,其实是在问“错在哪一关”。
2.3 保留字与预处理:两个阶段的两套规则
int int;过不去,是因为int在词法阶段就被打了关键字标签,语法分析器在“此处需要一个标识符”的位置看到一个关键字,直接判错。而#define N 10之后写int a[N];却能过,这个差别很多人只记结论不理解原因。
预处理发生在真正的编译之前,做的是纯文本替换。a[N]在被编译器看到之前已经变成了a[10],所以编译器眼里的数组长度就是一个字面常量。可如果写成int n = 10; int a[n];,编译器看到的是变量,而 C89 要求数组长度必须是整型常量表达式,这就是题库第 22 题选#define N 10的真正理由。C99 引入变长数组之后这条限制松了口子,但“宏是文本替换、变量是运行期对象”这个阶段差异没有变。
同一条“阶段顺序”思路还能解释判断题第六题:int a=b=c=23;为什么错。在语句位置,a=b=c=23是合法的赋值表达式,靠右结合从右往左算;可在声明位置,=后面跟的是初始化器,b和c这时候还没被声明过,编译器根本不知道它们是什么。同样的字符序列,放在声明里和放在语句里,走的是两条完全不同的语法路径——这类题在题库里不止一道,看穿一次就能横扫一片。
3. 执行模型:main 凭什么成为程序唯一入口
“C 程序从 main 函数开始执行”这句话,题库里至少出现了四遍,选择、判断都考。但绝大多数解析只停在“记住从 main 开始”,没人往下问一句:编译器怎么知道该从哪儿开始?换句话说,main这个名字是被谁认出来的?
3.1 从源文件到可执行文件:main 只是链接器认识的一个名字
一个.c文件叫一个编译单元,独立编译成.o目标文件。编译阶段对“别的文件里定义的函数”一无所知,它只在符号表里记一笔“这里有个叫helper的符号我暂时找不到,标成未定义”。真正把缺口补上的是链接器。
# helper.c: int helper(void) { return 42; } # main.c: int helper(void); # int main(void) { return helper(); } gcc -c main.c -o main.o # 编译通过:helper 只是符号表里一个 U nm main.o | grep helper # U helper gcc main.o -o app # 链接失败:undefined reference to `helper' gcc main.c helper.c -o app # 两个单元一起交给链接器,符号解析成功-c参数的意思是“只编译不链接”,产出目标文件。链接器的工作是把所有目标文件的符号表拼起来,把每个U和某个T对上号,对不上就报 undefined reference。main的特殊性完全来自这里:启动代码(常见实现是crt0或Scrt1.o)里硬编码了对main的引用,链接器必须找到一个叫这个名字的符号,否则整个程序拼不起来。
再往深一层,内核真正跳转到的入口并不是main,而是启动文件里的_start。它先把栈指针、命令行参数、环境变量这些运行时前置条件准备好,再调用 C 运行库的初始化函数,最后由初始化函数去调main。所以在调试器里看调用栈,main底下还压着两层。这也解释了判断题里那句“C 程序的执行是从主函数的第一行开始,一直到主函数的最后一行结束”——作为考试结论它是对的,作为机制描述它是被简化过的。
3.2 存储类别:auto 是默认值,static 改的是存储期不是作用域
题库里连续几道填空和选择都在问auto、static、extern、register,答案是“未指定存储类别时默认为 auto”。这条结论背后是变量的两个独立属性:作用域(名字在源码里可见的范围)和存储期(对象在内存里活着的时间)。初学者最容易把这两个属性混成一个。
auto修饰的局部变量具有自动存储期,随函数栈帧创建和销毁,函数一返回就没了。static修饰的局部变量则把存储期改成静态:对象被放进数据段,程序启动时就分配好,生命周期贯穿整个进程,但名字的可见范围仍然只在那个块里。两个属性一拆开,行为就很好推了:
#include <stdio.h> int next_id(void) { static int id = 0; /* 只初始化一次,对象存放在数据段 */ return ++id; } int main(void) { for (int i = 0; i < 3; i++) printf("%d ", next_id()); /* 输出 1 2 3,而不是 1 1 1 */ return 0; }static int id = 0;里的初始化表达式只在程序启动、进入main之前执行一次,之后每次调用next_id都沿用上一次留下的值。这就是为什么它能当计数器用。如果去掉static,id变成栈上的自动变量,每次进入函数都重新置 0,输出会变成1 1 1。
extern和register各有各的边界。extern是声明而非定义,告诉编译器“这个符号在别处有定义,别在这儿分配空间”;register只是给编译器的建议,现代优化器基本不理会,而且对被register修饰的变量取地址在标准里是不允许的。这些细节在选择题里出现时往往就是干扰项。
3.3 值传递与返回值类型:形参是副本,return 会做一次隐式转换
题库第 33、34 题反复问“函数返回值的类型由什么决定”,答案是函数定义时指定的类型。这句话的技术含义是:return后面的表达式会先求值,再按赋值规则转换成函数声明的返回类型,最后才交回调用处。
int f(void) { return 3.9; } /* 返回 3,小数部分被截断 */ double g(void) { return 7; } /* 返回 7.000000,整型被提升 */f返回的是int,3.9先被截断成3;g返回double,7被提升成7.0。这里没有四舍五入,只有截断,方向和第 4 章要讲的整数除法完全一致。
第 35 题问“实参和形参可以同名吗”,答案可以。因为形参是函数作用域内的局部对象,实参是调用者作用域内的对象,两者在不同的作用域里,名字相同互不干扰。真正需要留意的是传递方向:C 只有单向值传递,形参拿到的是实参的副本,函数内部改形参不会影响调用者。想改调用者的变量只能传地址——这也解释了为什么scanf后面必须跟&。
4. 表达式求值:13.8 % 3为什么要先截断成 13
题库第 19 题:int n; float f=13.8;执行n=(int)f%3之后n是多少。选项里有1、4、4.333333、4.6,正确答案是1。这道题同时考了三件事:%的操作数类型约束、强制转换的优先级、以及转换发生的时机。
4.1 取余与整除:%的操作数必须是整数类型
%在 C 里的定义域只有整数类型,写成f % 3(f是float)编译器直接报错,因为取余的语义建立在整数除法之上。所以(int)f % 3里的强制转换不是为了“顺便转一下”,而是语法上必须先把浮点数变成整数,%才有意义。
优先级在这里起了决定作用。强制类型转换属于单目运算符,优先级高于乘除取余,所以(int)f % 3等价于((int)f) % 3,先截断成13,再算13 % 3得1。如果误以为先算f % 3再转换,就会掉进4.333333那个坑里。
整数除法的截断方向也值得记一笔。C99 起明确规定向零截断:11/3是3,-11/3是-3,不是向下取整的-4。取余则满足恒等式(a/b)*b + a%b == a,由此可以推出两个常考结论:5%2是1;被除数小于除数时2%5是2,因为商是0,余数只能是它自己。题库第 12、13 题考的就是这两条。
#include <stdio.h> int main(void) { printf("%d\n", 11 / 3); /* 3 整除,向零截断 */ printf("%.6f\n", 11.0 / 3); /* 3.666667 一边是 double,另一边被提升 */ printf("%d\n", (int)(11.0 / 3 + 0.5)); /* 4 手工模拟四舍五入 */ printf("%.6f\n", (float)(1 / 2)); /* 0.000000 先整除得 0,再转 float */ printf("%.6f\n", 1 / (float)2); /* 0.500000 先转再除,结果才带小数 */ printf("%d\n", 2 % 5); /* 2 被除数小于除数,余数是它自己 */ return 0; }这段程序的每个printf都对应题库里的一道题,注释里写的是实际输出。第四个和第五个之间只差了括号位置,结果却从0.000000变成0.500000,这就是下一节要讲的求值时机问题。
4.2 隐式类型提升:混算时窄类型往宽类型靠
当一个表达式里同时出现整型和浮点型,C 会按“通常算术转换”的规则把所有操作数提升到最宽的那个类型再运算。11/3两边都是int,走整数除法得3;11.0/3有一边是double,3被提升成3.0,结果是3.666667;(float)11/3把11显式转成float,效果和上一条一样。
题库第 18 题是这类题里最典型的一道:int a = 2;,问哪个语句执行后float b的值不为0.5。答案是b = (float)(1/a),因为括号里的1/a两边都是int,先算出0,再被(float)转成0.000000。转换是“事后”发生的,它改变不了已经算完的结果。想得到0.5,必须让提升发生在除法之前:1.0/a、1/(float)a、1/(a*1.0)都行。
提示:
(int)(x + 0.5)这个写法只对正数成立。-3.5 + 0.5得-3.0,截断后是-3,而真正的四舍五入应该是-4。要用在对负数也成立的场合,得按符号分别处理。
判断题里“强制类型转换不会改变原变量的原有数据类型”也是同一个机制的另一面。(float)i产生的是一个临时的float值,i本身在内存里仍然是int,一个字节都没变。转换操作作用在表达式的值上,不作用在对象上。
4.3 三目运算符与逻辑值:关系表达式的值域只有 {0,1}
题库第 14、15、16 三题连着考三目运算符和关系表达式的取值。a < b ? a : b的执行顺序是:先算a < b,结果为真就取问号后面那个,为假就取冒号后面那个。int a=3,b=4时3 < 4为真,结果是3。
? :是 C 里唯一的三目运算符,结合方向从右往左,优先级低于关系运算符但高于赋值。所以m = a < b ? a : b会被解析成m = ((a < b) ? a : b),而不是(m = a < b) ? a : b。
更底层的一点是:关系运算符和逻辑运算符的结果类型是int,值域只有{0, 1},没有别的可能。C 里没有原生的布尔类型(C99 才引入_Bool和stdbool.h),真假判断一律按“非 0 即真”处理。这一条直接推出一串判断题的答案:
while(!E);里的!E等价于E == 0while(E);里的E等价于E != 0x = 10; t = (x && x > 10);中x非 0 为真,x > 10为假,&&短路求值整体为假,t得0printf("%d", 3 < 5)输出1而不是true
短路求值还带来一个副作用:&&左边为假时右边根本不执行。如果右边带了副作用(自增、函数调用、赋值),行为差异会非常明显。这类题在读程序写结果那一部分最容易拉开分差。
5. 避坑:数组、指针与字符串的五个翻车现场
这一章的五条,全部来自题库里出错率最高的题型。每一条都按“看到什么现象 → 底层为什么这样 → 怎么绕开”的顺序说,因为只看结论记不住,看过一次机制才不会再犯。
5.1 数组越界不报错:a[10]为什么有时候还能跑
现象:int a[10];之后读写a[10],编译器不吭声,运行时有时正常、有时莫名其妙崩溃或改掉别的变量。
原因:C 的数组下标本质是指针算术,a[i]在语义上等价于*(a + i)。编译器只负责按a的基址加上i * sizeof(int)算出地址,不做任何边界检查。a[10]算出来的地址刚好越过了数组尾端,那块内存里可能是别的局部变量、可能是对齐填充、也可能压根不可访问。跑不跑得通纯看内存布局,这也是为什么同样的代码换个编译器或换个优化等级结果就变。
解决:长度用sizeof(a) / sizeof(a[0])求,但这个写法只在数组还在当前作用域时有效:
#include <stdio.h> void f(int a[]) { /* 形参写成数组,实际类型退化成 int * */ printf("%zu\n", sizeof(a)); /* 8,64 位下的指针大小,不是 40 */ } int main(void) { int a[10]; printf("%zu\n", sizeof(a) / sizeof(a[0])); /* 10,仅限本作用域 */ f(a); return 0; }sizeof(a)在main里是 40(10 个int),在f里是 8,因为数组名作为实参传进去时退化成了指向首元素的指针,长度信息在传递过程中丢失了。想让函数知道长度,就得额外传一个参数,这是唯一的办法。
5.2 字符串长度:char x[]="hello"是 6 不是 5
现象:char x[] = "hello";和char y[5] = {'h','e','l','l','o'};看起来一样,sizeof(x)是 6,sizeof(y)是 5,用printf("%s", y)还可能打出乱码。
原因:字符串字面量"hello"在编译期被展开成一个 6 字节的字符数组,末尾自动补\0作为结束标志。用字面量初始化字符数组时,这个\0会被一并拷进去,所以x的长度是 6。而y是逐个字符初始化的,初始化列表里没有\0,它就只是一个“字符数组”,不是“字符串”。%s转换说明要求参数指向以\0结尾的字符序列,y后面几个字节是什么完全不确定,于是会接着往下读,直到偶然遇到一个 0 为止。
解决:区分“字符数组”和“字符串”这两个概念,判据就是有没有\0。strlen是从给定地址开始数到\0为止的字符个数,不含\0;sizeof是整个数组占多少字节。char a[] = "student"的sizeof是 8,strlen是 7,判断题里“数组 a 的长度为 7”之所以错,就是因为把这两个量混了。
5.3 scanf 的取地址:%s不加&,%d必须加
现象:char a[20], b[20]; scanf("%s%s", a, b);正常,改成scanf("%s%s", &a, &b);有时候也能跑,但代码被人指出来说不对。
原因:scanf是变参函数,格式串后面的参数不带类型信息,编译器不会检查你传的类型对不对。%d期望一个int *,所以必须写&n;%s期望一个char *,而数组名本身就代表首元素地址,再加&类型就变成了char (*)[20]。两者在数值上恰好相同,所以运行时往往“看起来正常”,这是最典型的玄学现场。
解决:按格式说明符期望的类型传参。%d、%f、%c要&,%s直接用数组名。想让编译器帮忙看,加上-Wformat=2,很多这类错误在编译期就能被抓出来。题库第 30 题里gets(a, b)也是错的,因为gets一次只能读一个字符数组,这一点看函数原型就清楚了。
5.4 类型转换的位置:括号放错,结果差一倍
现象:想算1/2得0.5,写(float)(1/a)拿到0.000000,写1/(float)a才对。
原因:括号决定了求值顺序,强制转换只作用于括号里那个已经算完的值。(float)(1/a)的执行序列是“先整数除法得 0,再转成 float”,转换来得太晚。这和 4.2 节讲的是同一件事,但它在实际编码里出现的频率远高于考试题。
解决:把转换放在除法之前,或者直接写1.0/a让隐式提升生效。凡是“先算完再转”能得出正确答案的场合,都是巧合;判断依据只有一个——运算发生时,两边是不是已经有一边是浮点型。
5.5 指针初始化:int *p = &i, i;为什么编译不过
现象:写int *p = &i, i;编译器报'i' undeclared,明明下一段就声明了。
原因:C 里标识符的作用域从声明符结束的位置开始,不是从整条声明的开头开始。&i出现在i的声明
本文还有配套的精品资源,点击获取