C语言笔试高频考点:内存分区、static与指针数组
简介:《C语言笔试大全》面向准备C语言笔试、校招基础面试和课程复习的读者,用于集中梳理高频概念题与常见程序分析题。内容以问答和例题形式展开,涉及static关键字的作用域与存储域、引用与指针的区别、全局变量和局部变量在内存中的位置、平衡二叉树定义、堆栈溢出原因、不能声明为虚函数的构造函数、冒泡排序时间复杂度、float与零值比较、TCP/IP层次结构、ARP地址转换及IP地址编码等基础考点;同时包含循环链表报数、switch参数类型、局部变量与全局变量重名、extern引用、do…while与while…do差异、sizeof联合体/结构体计算、队列与栈区别、字符串倒序查错等练习,方便读者边看边查漏补缺。资源包共1个doc文件,约225KB,便于离线阅读和打印。已有259人学习,可作为笔试前的知识点速查与错题复盘材料。
1. C语言笔试不是背题:一张被反复折叠的知识地图
「C语言笔试」这几个字,第一次看见的人多半会把它等同于背题。真做几套就会发现,题目翻来覆去就那么几类:static 有什么用、指针和数组差在哪、sizeof 算出来为什么不是想的那样、float 能不能直接和 0 比大小。它们不像一堆积木,更像一张被反复折叠的地图,折痕都落在同样几个路口。
这份资料的价值不在把答案抄一遍,而在于把那些路口标出来:变量住在内存的哪一格、指针加一到底加了几个字节、数组传到函数里为什么就“变短”了。搞懂这几处,同一道题换个问法也不会慌。下面按「东西放在哪 → 指针怎么走 → 容易混的几组 → 常考原理」的顺序走一遍。
2. static 与内存分区:变量到底住在哪一格
2.1 栈、堆、静态区、常量区:四个抽屉
理解 C 语言笔试里一大半题目的钥匙,是先搞清楚一个变量在内存里归谁管。可以把它想成四个抽屉:栈是门口那个临时鞋柜,进出门时自动开合;堆是储物间,得自己租、自己退;静态区是长期仓库,程序一启动就开门,程序结束才关;常量区是上锁的展柜,东西放进去就不能动。
| 区域 | 谁在里面 | 谁分配、谁释放 | 典型问题 |
|---|---|---|---|
| 栈(stack) | 局部变量、函数参数、返回地址 | 编译器自动分配和回收 | 空间有限,递归太深会撑爆 |
| 堆(heap) | malloc / calloc / realloc 申请的内存 | 程序员手动 free | 忘记释放会泄漏,反复申请释放会有碎片 |
| 静态区 | 全局变量、static 变量 | 程序启动时分配,结束时回收 | 未初始化会自动清零 |
| 常量区 | 字符串字面量、只读数据 | 编译期确定,只读 | 试图改写会直接崩 |
把这段对应关系落到代码上会更直观:
#include <stdio.h> #include <stdlib.h> int g_inited = 1; /* 已初始化的全局变量:静态区 */ int g_zero; /* 未初始化的全局变量:静态区,自动为 0 */ static int s_val = 2; /* 静态变量:同样在静态区 */ int main(void) { int local = 3; /* 局部变量:栈 */ int *heap = malloc(sizeof(int)); /* 申请到的空间:堆 */ const char *lit = "hello"; /* 指针本身在栈,指向常量区的字面量 */ *heap = 42; printf("%d %d %d %d %s\n", local, g_inited, g_zero, *heap, lit); free(heap); /* 堆上的内存必须手动归还 */ return 0; }这段代码里,local和指针变量本身都在栈上,函数一返回就自动失效;heap指向的那 4 个字节在堆上,函数返回也不会消失,只能靠free收回;g_inited、g_zero、s_val从程序启动一直活到结束,其中g_zero没写初值也会是 0,这是静态区自动清零的规则,栈上变量可没有这个待遇。
很多笔试题问“局部变量存在于哪、全局变量存在于哪、动态申请的数据存在于哪”,标准答案就是栈、静态区、堆——它考的并不是背诵,而是你是否知道这三类东西的生死由谁决定。
2.2 static 的三副面孔:局部变量、全局变量、函数
static是笔试里出现频率最高的关键字之一,因为它在三个位置上有三种完全不同的效果,很多人只记住“静态”两个字就上考场,一换场景就答错。
第一种,修饰局部变量。它把变量的“寿命”从函数调用期间拉长到整个程序运行期间,但“活动范围”一点没变,仍然只在这个函数里能被叫到名字。而且初始化只做一次,下次进函数时它还是上次离开时的值。
#include <stdio.h> void tick(void) { static int count = 0; /* 只初始化一次,函数退出后值仍保留 */ count++; printf("%d\n", count); } int main(void) { tick(); /* 输出 1 */ tick(); /* 输出 2 */ tick(); /* 输出 3 */ return 0; }如果把static去掉,每次进tick都会重新把count置 0,三次输出全是 1。这一条经常被包装成“用 static 实现计数器”或“单例”的题目。
第二种,修饰全局变量。这时变量仍然在静态区、仍然从程序启动活到结束,变的是“可见范围”:普通全局变量在整个程序的所有源文件里都能用,加了 static 就只在自己这个源文件里有效。第三种,修饰函数,作用完全一样,函数被限制在本文件内,别的文件链接不到它。
可以这样记:static 用在局部变量身上,改的是“能活多久”;用在全局变量和函数身上,改的是“谁能看见”。一个是延寿,一个是关灯。
2.3 一道 static 题为什么能问出三层功底
面试官偏爱 static,是因为这一个关键字同时牵出四件事:作用域、生命周期、链接属性、内存布局。能把它讲清楚的人,通常也就理解了变量在内存里的位置和 C 程序的编译链接过程。
换个说法,普通局部变量像出差住的酒店,退房就跟你没关系了;加了 static 的局部变量像在公司租了个长期工位,人走了桌子还在,下次回来东西还摆着。而给全局变量加 static,相当于把公司大门口的招牌改成内部告示,楼道里的人还看得到,外面的人找不着。
真正的踩坑点在于把两件事混着答。有人被问“static 有什么用途”时只答“静态变量”,说不清静态在哪、对谁静态;有人背了“static 变量只初始化一次”,却答不出普通全局变量其实也只初始化一次,区别根本不在初始化次数。抓住“改寿命还是改可见范围”这一条,这两类题基本不会再翻车。
3. 指针与数组:C语言笔试里最容易翻车的一段路
3.1 指针加一,加的是「一个类型」的宽度
指针加法最常见的误解,是以为p + 1就是地址加 1 个字节。实际上指针加法的单位是“它所指向的类型有多大”,编译器会自动乘上这个宽度。int *加一是往后跳 4 个字节,char *加一是跳 1 个字节,double *加一是跳 8 个字节。
#include <stdio.h> int main(void) { int a[5] = {1, 2, 3, 4, 5}; int *p = a; char *c = (char *)a; printf("%d\n", *(p + 1)); /* 2:跳过 4 字节,正好是 a[1] */ printf("%td\n", (char *)(p + 1) - (char *)p); /* 4:地址实际相差 4 字节 */ printf("%td\n", (c + 1) - c); /* 1:char 指针只跳 1 字节 */ return 0; }这段代码里p + 1的值等于a[1],因为p的类型是int *;同样一块内存交给char *去看,加一就只挪一个字节。指针的类型不是装饰,它决定了“一步迈多大”。
同一道题的高阶版本是&a + 1。数组名a在多数表达式里会退化成指向首元素的指针,类型是int *;但&a取的是整个数组的地址,类型是int (*)[5],加一就是整整跳过 5 个 int。
#include <stdio.h> int main(void) { int a[5] = {1, 2, 3, 4, 5}; int *ptr = (int *)(&a + 1); /* 跳过整个数组,指向 a[5] 的位置 */ printf("%d %d\n", *(a + 1), *(ptr - 1)); /* 输出 2 5 */ return 0; }a和&a的地址数值相同,含义却不同:前者是首元素地址,后者是整个数组的地址。这种“值一样、类型不一样”的地方,正是笔试最爱设卡的位置。
3.2 数组名什么时候会「退化」成指针
数组作为函数参数时,会退化成指向首元素的指针,数组长度这个信息就丢了。这是 sizeof 类题目翻车的根源。
#include <stdio.h> /* 参数写成数组形式,实际等价于 char *str */ void upper(char str[]) { /* 这里 sizeof(str) 得到的是指针大小,不是数组总字节数 */ size_t n = sizeof(str) / sizeof(str[0]); printf("函数内 n = %zu\n", n); /* 64 位下通常是 8 */ } int main(void) { char s[] = "aBcDe"; printf("函数外 n = %zu\n", sizeof(s) / sizeof(s[0])); /* 6,含结尾 '\0' */ upper(s); return 0; }在main里,s是货真价实的数组,sizeof(s)是 6 个字节(5 个字符加结尾的'\0')。传进upper之后,str只是个指针,sizeof(str)变成 8(64 位)或 4(32 位),再除以 1 得到 8 或 4,跟字符串长度毫无关系。要正确遍历,必须额外传一个长度参数,或者在函数内部用strlen(前提是字符串以'\0'结尾)。
顺带说一句,字符串长度和数组大小不是一回事:char s[] = "aBcDe"的大小是 6,长度是 5,差的那一个就是结束符。sizeof 算的是“占多少字节”,strlen 数的是“到结束符为止有几个字符”,这两个概念在笔试卷子里经常被故意混着问。
3.3 const 的位置不同,保护的到底是谁
const和指针放一起时有三种写法,看const在*的哪一边,就知道它锁的是谁。
| 写法 | 谁不能改 | 读法 |
|---|---|---|
const char *p | 指向的内容 | 指向常量的指针 |
char const *p | 指向的内容(与上一种等价) | 同上 |
char * const p | 指针本身 | 常量指针,必须初始化 |
const char * const p | 两者都不能改 | 指向常量的常量指针 |
#include <stdio.h> int main(void) { char buf[8] = "abc"; const char *p1 = buf; /* 不能通过 p1 改内容,但 p1 可以指向别处 */ char * const p2 = buf; /* p2 不能改指向,但可以改 buf 的内容 */ p1 = "xyz"; /* 合法:改的是指针 */ /* *p1 = 'X'; */ /* 非法:内容是只读的 */ *p2 = 'A'; /* 合法:改的是内容 */ /* p2 = "xyz"; */ /* 非法:指针本身是只读的 */ printf("%s %s\n", p1, p2); return 0; }口诀是“const 在星号左边锁内容,在星号右边锁指针”。至于为什么字符串字面量不能改——它待在常量区,是只读的,char *s = "AAA"; s[0] = 'B';这种写法在编译期可能只给个警告,运行时却直接崩,所以正确写法应该带上const。
3.4 一道字符串倒序题,串起四五个考点
“把 hello,world 倒过来”看起来是个小练习,实际上把 strlen、malloc、指针遍历、结束符、free 全考了一遍。笔试里这道题的错误版本往往长这样:malloc(len)少分配一个字节、循环从src[len]开始(那是结束符不是最后一个字符)、最后忘了补'\0'、用完不 free。
#include <stdio.h> #include <string.h> #include <stdlib.h> int main(void) { const char *src = "hello,world"; size_t len = strlen(src); char *dest = (char *)malloc(len + 1); /* 多留 1 字节放结尾的 '\0' */ if (dest == NULL) { return 1; /* 申请失败要及时返回 */ } char *d = dest; const char *s = src + len - 1; /* 指向最后一个有效字符 */ while (len-- != 0) { *d++ = *s--; /* 从后往前逐个搬 */ } *d = '\0'; /* 手动补上结束符 */ printf("%s\n", dest); /* dlrow,olleh */ free(dest); /* 申请和释放必须配对 */ return 0; }四个参数值得单独说:len + 1是因为strlen不算结束符,而字符串必须以'\0'收尾;src + len - 1才是最后一个字符的位置;while (len-- != 0)用的是后置自减,先判断再减一,正好搬 len 次;free(dest)释放的必须是 malloc 返回的那个原始指针,不能是搬动过的d。
如果不许申请新内存,可以在原数组上首尾对调,用两个下标从两端往中间走,交换len / 2次即可,连结束符都不用管。这也是一道题两种解法背后的取舍:一个换空间省事,一个省空间但要处理原地交换的边界。
4. 易混概念辨析:五组看着像、实际差很远的东西
4.1 堆与栈:不是同一个「堆栈」
易混点:中文里“堆栈”常被当成一个词用,很多人以为它指一块内存。
为什么混:栈的英文是 stack,堆是 heap,早期翻译把 stack 译成“堆栈”,于是“堆”和“栈”被粘在了一起,考试里问“堆栈溢出”时,有人答堆、有人答栈,谁也说不清。
怎么区分:看谁负责分配和回收。栈由编译器自动管理,函数一进一出就自动伸缩,容量通常只有几 MB;堆由 malloc / free 手动管理,容量大得多,但申请释放都要自己负责。栈上的变量地址通常从高往低增长,堆从低往高增长,两者在内存里是两块不同的区域。所以“堆栈溢出”在多数语境下说的是栈溢出(stack overflow),而“内存泄漏”说的是堆上的内存没归还,这是两码事。
4.2 栈溢出与内存泄漏:一个当场倒下,一个慢慢耗尽
易混点:都表现为“程序出问题了”,有人干脆把原因归成一句“垃圾没回收”。
为什么混:两类问题都会让程序崩溃或变慢,现象上不好区分;再加上早期教材里“垃圾回收”这个词被泛化使用,容易把所有内存问题都往里装。
怎么区分:栈溢出是栈空间被用光,典型触发是递归层数太深、函数里开了超大局部数组,程序往往当场崩溃,报错信息里常带 stack overflow。内存泄漏是 malloc 出去的内存没有 free,进程占用的内存随运行时间一路上涨,短期不一定报错,跑久了才被系统杀掉。前者是“地方不够用”,后者是“借了不还”。修法也完全不同:栈溢出要减递归深度或改堆分配,内存泄漏要找配对缺失的 free。
4.3 引用与指针:C 里根本没有引用
易混点:笔试资料里经常并列问“引用与指针有什么区别”,让人以为 C 语言里也有引用。
为什么混:引用是 C++ 的语法,很多题库是 C 和 C++ 混着出的,题目本身没标明语言边界。
怎么区分:C 语言只有指针,没有引用。C++ 里引用是变量的别名,必须在定义时初始化,初始化后不能改绑到别的对象,也不存在“空引用”;指针可以为空、可以改指向、可以参与算术运算。底层实现上引用常被做成常量指针,但语义不同:引用在语法层面就像对象本身,不需要解引用符号。看到这类题先确认语言,答起来才不会被绕进去。
4.4 宏与 inline:一个在编译前替换,一个在编译时展开
易混点:都说“短小函数用宏或内联”,好像两者可以互换。
为什么混:目的确实相似——避免函数调用的开销,尤其在嵌入式场景里很常见。
怎么区分:宏是预处理阶段的纯文本替换,没有类型检查,参数会被求值多次,容易踩副作用。内联函数是编译器处理的,有类型检查,参数只求值一次,编译器还可以自己决定展不展开。
#include <stdio.h> #define SQUARE(a) ((a) * (a)) /* 纯文本替换,参数被写了两遍 */ int main(void) { int a = 5; int b = SQUARE(a++); /* 展开后 a++ 出现两次,属于未定义行为 */ printf("%d %d\n", a, b); /* 结果依赖编译器,不要这么写 */ return 0; }正确做法要么给宏参数加括号并避免副作用,要么改用static inline函数。括号这一条也别小看:#define SQUARE(a) a * a遇到SQUARE(x + 1)会展开成x + 1 * x + 1,结果完全不对。
4.5 struct 与 union:一个相加,一个取最大
易混点:两者都能把多个成员打包在一起,sizeof的结果却差很远。
为什么混:定义语法几乎一样,只是关键字不同,平时又很少用 union。
怎么区分:struct 的成员各占各的空间,总大小是各成员之和再按对齐规则补齐;union 的所有成员共用同一块内存,大小取最大那个成员,再按对齐要求向上取整。用一段代码看得最清楚:
#include <stdio.h> typedef union { long i; /* 4 字节(32 位下) */ int k[5]; /* 20 字节 */ char c; /* 1 字节 */ } DATE; struct data { int cat; /* 4 字节 */ DATE cow; /* 20 字节 */ double dog; /* 8 字节 */ }; int main(void) { printf("%zu %zu\n", sizeof(DATE), sizeof(struct data)); return 0; }DATE取最大成员int[5]的 20 字节;struct data是 4 + 20 + 8 = 32 字节(在 double 按 8 字节对齐、前面 24 已是 8 的倍数时不需要额外填充)。所以两者相加是 52。注意具体数字跟编译器和平台有关,32 位和 64 位下 long 的宽度不同,答案也会变,答题时最好说明前提。
5. 背答案的题也要懂原理:排序、浮点、网络分层与死锁
5.1 冒泡排序的 O(n²) 是怎么数出来的
复杂度不是一个需要背的符号,它是数出来的。冒泡排序每一轮把当前最大的元素“冒”到末尾,第 i 轮需要比较 n-1-i 次,总比较次数是 (n-1) + (n-2) + … + 1 = n(n-1)/2,去掉常数和低阶项就是 O(n²)。
void bubble(int a[], int n) { for (int i = 0; i < n - 1; i++) { int swapped = 0; /* 记录本轮有没有交换 */ for (int j = 0; j < n - 1 - i; j++) { /* 末尾 i 个已经有序,不用再比 */ if (a[j] > a[j + 1]) { int t = a[j]; a[j] = a[j + 1]; a[j + 1] = t; swapped = 1; } } if (!swapped) { break; /* 一趟下来没动过,说明已经有序 */ } } }内层循环上界写成n - 1 - i,是因为每轮结束末尾就多一个排好的元素;swapped标记是提前退出优化,最好情况(数组本来有序)只需一趟比较,复杂度降到 O(n)。这些细节经常被追问:为什么是 n-1 轮、为什么内层不减 i 会多比、加了标记之后最坏情况变不变。答案是最坏和平均仍是 O(n²),只是常数小了、最好情况变好了。
5.2 float 与零值比较:为什么不能写 ==
浮点数在计算机里是二进制近似值,0.1 这种十进制小数根本存不精确,两次本该相等的运算结果可能差一个极小量。所以if (x == 0.0)这种写法在工程上不可靠,正确做法是引入一个误差范围。
#include <math.h> #define EPS 1e-6 int is_zero(double x) { return fabs(x) < EPS; /* 落在误差范围内就认为等于 0 */ } int main(void) { double x = 0.1 + 0.2 - 0.3; /* 数学上是 0,实际存的是一个极小非零值 */ if (is_zero(x)) { /* 这里会进来 */ } return 0; }EPS取多大要看业务精度:单精度 float 大约 7 位有效数字,双精度 double 大约 15 位,取 1e-6 是常见折中。要注意别把判断写成两个方向的不等式用&&连起来,那样条件恒为假,等于什么都没判断。比较两个浮点数是否相等,也是同样的思路:看差的绝对值是否小于 EPS,而不是直接比==。
5.3 TCP/IP 分层与 ARP:一个地址怎么找到另一个地址
笔试里网络部分最爱问两件事:Internet 用什么协议、分几层;IP 地址和物理地址怎么转换。答案分别是以 TCP/IP 为核心的四层或五层模型,以及 ARP 协议。
| 层次 | 干什么 | 典型协议 |
|---|---|---|
| 应用层 | 直接面向具体应用 | HTTP、DNS、FTP |
| 传输层 | 端到端的连接与可靠性 | TCP、UDP |
| 网络层 | 寻址和路由,把包送到目标网络 | IP、ICMP、ARP |
| 数据链路层 | 同一局域网内按物理地址传帧 | 以太网 |
| 物理层 | 比特流在线缆和无线介质上传输 | — |
ARP 干的事很具体:已知对方的 IP 地址,想知道它的物理地址(MAC),就在局域网内广播一句“谁是这个 IP”,对应的一方回应自己的 MAC。它只在同一网段内有效,跨网段要靠路由器的 IP 地址逐跳转发。IP 地址本身由网络号和主机号两部分组成,光看地址分不出边界,得和子网掩码按位与一下,才知道哪几位是网络、哪几位是主机。
TCP 和 UDP 的差别也常被一起问:TCP 有连接、有确认重传和流量控制,开销大但可靠;UDP 无连接、头部小、不保证送达,适合对时延敏感、能容忍少量丢包的场景。两者都在传输层,这个位置别答错。
5.4 死锁的四个必要条件:少一个都锁不住
死锁是指两个或多个执行单元互相等着对方手里的资源,谁也不肯先放。它成立需要四个条件同时满足:互斥(资源一次只能一个人用)、请求并保持(拿着手里的还想要新的)、不可剥夺(别人不能强行抢走)、循环等待(A 等 B、B 等 A 形成环)。
这四条的价值在于“破一条就能防住”。比如给所有资源编号,规定只能按编号从小到大申请,循环等待就断了;或者申请不到时一次性释放已占有的资源,请求并保持就断了。实际工程里最常用的是固定加锁顺序,简单又不容易出错。
6. 什么时候该刷这份题库,什么时候该放下它
刷题这件事,收益差别很大,取决于你处在哪个阶段。我的判断标准很简单,看下面这张表:
| 你的处境 | 建议 | 原因 |
|---|---|---|
| 刚学完语法,准备第一次笔试 | 按知识点分类刷,一类一类过 | 这类题重复率高,能把零散语法串成体系 |
| 已经能写项目,但答不准确 | 只刷错题和易混点 | 缺的不是广度,是边界和精确表述 |
| 想靠背答案应付面试 | 不建议 | 换个问法就露馅,追问两句就崩 |
| 想深挖编译器、操作系统原理 | 题库不够用 | 它给的是结论,不给推导过程 |
真正值得投入的,是那些“答案只有一行、但能追问出三层”的题。比如 static,答“静态变量”是零分水平,答出“改局部变量的寿命、改全局变量和函数的可见范围”才算过关,能再说清它在内存哪一区、初始化发生在什么时候,才算真的懂。反过来,纯记数字的题——某个结构体在某个平台下占多少字节——知道方法就行,不必死背,因为换台机器答案就变了。
我自己早年吃过一次亏:把一份题库从头到尾背了一遍,自以为准备充分,结果被问到“为什么函数内的 sizeof 算不出数组长度”时,张口就是“因为数组退化成指针”,再往下问“那什么时候不退化成指针”,就卡住了。从那以后我每次看这类题,都强制自己多问一句“为什么”,问不出来就说明没懂,回头补那一块,而不是继续往下刷。这份资料里那些带注释的代码片段,正好适合拿来验证自己是不是真懂——把注释遮住,看能不能预测输出。
希望它能帮你把这张地图上的折痕,一条条捋平。
本文还有配套的精品资源,点击获取