简介:这份C语言笔试试题汇编面向准备校招、社招及嵌入式/后端岗位笔试的开发者,聚焦指针与数组的sizeof、strlen差异、栈与堆内存布局、大小端存储、结构体指针偏移、数组地址偏移以及const、static、volatile等高频考点,适合用于查漏补缺和面试前强化。压缩包共1个doc文件,约122KB,内容以题目、答案解析和概念辨析为主,便于直接检索与打印复习。已有320人学习下载,说明其选题具有较好的参考价值。读者可从中获得典型笔试题的解题思路、内存管理易错点梳理和关键字语义对比,也能借助结构体偏移、线程间通信机制等专题建立知识框架,提升笔试答题的准确性与面试表达条理性。
1. sizeof 与 strlen:数组一进函数就“缩水”,长度被谁拿走了
先看一组不会变的结果:32 位机器上,char str1[] = "Hello"的sizeof是 6、strlen是 5;char *p1 = "Hello"的sizeof却只有 4。同一份数据,两种声明,长度差了一个“指针的宽度”。真正让人翻车的是把str1传进函数之后:sizeof从 6 掉成 4,strlen却依然是 5。原因并不玄学,sizeof是编译期由类型决定的运算符,数组名一旦作为实参传递,类型就从char[6]退化成char *,编译器手里只剩下一个地址,长度信息在调用点就已经丢了。strlen走的是另一条路——运行时从首字节一路数到\0,只要内存里还有终止符,它就能算出长度;一旦没有\0,它同样会一路读下去,直到踩进非法页。理解这条分界线,比背下那串数字有用得多。
2. 内存分区:栈、堆、静态区、常量区是怎么被划出来的
一道题问“int a;、int *p1;、p2 = malloc(100);三者分别落在哪”,答“都在内存里”不算错,但没法解释为什么malloc忘了free会泄漏,而局部变量不会。分区的意义在于:由谁负责释放、何时释放、生命周期多长,这三件事决定了变量的落点。
2.1 五段式布局与变量的真实归属
一个由 C 编译出来的可执行文件,加载后大致被划分为五块:
| 区域 | 存放内容 | 管理者 | 生命周期 |
|---|---|---|---|
| 栈区 | 函数参数、局部变量、返回地址 | 编译器自动 | 函数返回即销毁 |
| 堆区 | malloc/new得到的块 | 程序员手动 | 显式释放或进程结束 |
| 全局/静态区 | 全局变量、static变量 | 编译器/装载器 | 整个进程 |
| 文字常量区 | 字符串字面量、const常量 | 只读映射 | 整个进程 |
| 代码区 | 函数体的机器指令 | 只读映射 | 整个进程 |
全局区还要再分两半:已初始化的和未初始化的。未初始化的那部分在可执行文件里不占实际空间,只在段头记一个长度,装载时由系统清零,这就是 BSS 段能“免费”变大的原因。
看一段能直接跑出归属的代码:
#include <stdio.h> #include <stdlib.h> #include <string.h> int g_init = 0; /* 全局已初始化区 */ int g_uninit; /* 全局未初始化区(BSS) */ static int s_init = 1; /* 静态已初始化区 */ int main(void) { int local = 1; /* 栈: 随函数返回销毁 */ char s[] = "abc"; /* 栈: 4 字节, 内容从常量区拷来 */ const char *p = "abc"; /* 指针在栈, "abc" 在只读常量区 */ char *h = (char *)malloc(16); /* 指针在栈, 16 字节在堆 */ strcpy(h, "abc"); printf("%p %p %p %p\n", (void *)&g_init, (void *)&local, (void *)p, (void *)h); /* 地址段通常依次拉开 */ free(h); return 0; }char s[] = "abc"和const char *p = "abc"是最容易被混淆的一对。前者在栈上开出 4 个字节,把常量区的 4 个字节(含\0)拷贝进去,所以s[0] = 'x'合法;后者只在栈上放一个指针,指向只读段,写p[0]在多数平台上直接触发段错误。很多人以为字符串常量“在栈上”,其实栈上只有那个指针。
还有一个细节常被忽略:同一个字面量出现两次,编译器很可能把它们合并成一份。所以strcpy(p1, "123456")之后p1和p3指向的地址有时会重合,这跟“拷贝”的直觉相反——p1指向的堆块里确实存了字符串,但编译器优化的是那份常量源。
2.2 栈与堆:不是数据结构,是两条分配路径
“堆栈”这个词在口语里常被当成栈的同义词,严格说来是两个东西。栈的分配是一条指令的事:把栈指针向下挪一段,返回时挪回来。堆则要走一条更长的路——运行时维护一条空闲块链表,申请时遍历链表找第一个足够大的块(first-fit),切下来返回,剩下的挂回去。这个过程和数据结构课上的“堆”毫无关系,倒是更像链表。
差异体现在三个地方。第一是速度:栈分配几乎是零成本,堆分配要查表、可能要系统调用扩容、可能触发碎片整理。第二是大小:栈通常被限制在几 MB,递归过深或局部数组过大就会栈溢出;堆的上限接近进程可用的虚拟地址空间。第三是所有权:栈由编译器按作用域管,堆只能靠人管,忘free就是泄漏,free两次就是未定义行为。
提示:堆向高地址增长、栈向低地址增长,这是多数主流实现的做法,但标准并没有规定方向,写代码时不要依赖两个变量的地址大小关系来判断“谁先分配”。
2.3 大小端:0x12345678 在内存里的两种排法
多字节数据放进内存时,先放高位还是先放低位,不同处理器给了不同答案。大端把最高有效字节放在最低地址,小端反过来:
| 地址偏移 | 大端 | 小端 |
|---|---|---|
| 0x0000 | 12 | 78 |
| 0x0001 | 34 | 56 |
| 0x0002 | 56 | 34 |
| 0x0003 | 78 | 12 |
判定方式一行就够,不需要union:
int is_little_endian(void) { unsigned int v = 0x12345678u; unsigned char *p = (unsigned char *)&v; return p[0] == 0x78; /* 低地址存低字节 => 小端 */ }union版本也常见:把int和char放进同一个union,写int读char,看拿到的是哪一端。两种写法原理相同——都是取一个字节的视角去看多字节对象的存储顺序。
端模式真正 bites 人的地方在跨机通信和文件格式解析。网络字节序规定为大端,小端机器发数据前要htonl/htons转换;解析一个二进制头时,如果按结构体直接memcpy到本地变量,大小端不一致就会读出完全离谱的数值。写协议解析代码时,逐字节拼装比结构体覆盖安全得多,虽然麻烦,但至少不吃端序的亏。
3. 指针算术与结构体对齐:p+0x200 到底加了几个字节
这类题在卷面上出现频率极高:struct BBB *p = 0x1000000;问p+0x200、(unsigned long)p+0x200、(char *)p+0x200各是多少。三个式子里加的是同一个0x200,结果却不同,差别只在“加法的单位是谁”。
3.1 指针加法的步长由被指类型决定
指针加法不是字节加法,是元素加法。p + n的语义是“往后移动 n 个sizeof(*p)”,编译器在生成指令时会把 n 乘以元素大小。
struct BBB { long num; /* 32 位平台 4 字节 */ int index; /* 4 字节 */ char *name; /* 指针 4 字节 */ }; /* sizeof(struct BBB) = 12 */ struct BBB *p = (struct BBB *)0x1000000; /* 1) 元素加法: 0x200 个 BBB, 每块 12 字节 */ uintptr_t a = (uintptr_t)(p + 0x200); /* 0x1000000 + 0x1800 = 0x1001800 */ /* 2) 转成整数再加: 纯字节运算 */ uintptr_t b = (uintptr_t)p + 0x200; /* 0x1000200 */ /* 3) 转成 char* 再加: 步长 1 字节 */ uintptr_t c = (uintptr_t)((char *)p + 0x200); /* 0x1000200 */三种写法的区别就在这张表里:
| 表达式 | 加法单位 | 结果 |
|---|---|---|
p + 0x200 | sizeof(struct BBB)= 12 | 0x1001800 |
(unsigned long)p + 0x200 | 1 字节 | 0x1000200 |
(char *)p + 0x200 | 1 字节 | 0x1000200 |
后两种数值相同,但类型不同:一个是整数,一个是char *,后者还能继续参与指针运算和解引用。把指针强转成整数再运算,在某些平台上会丢失高位(如果long宽度小于指针),所以真正需要“按字节偏移”时,转char *比转整数更稳妥。
3.2 结构体对齐:为什么 sizeof 不等于成员之和
上面sizeof(struct BBB)恰好等于 4+4+4=12,是因为三个成员都是 4 字节,天然对齐。换一个顺序结果就变了:
struct A { char c; /* 1 字节, 偏移 0 */ int i; /* 4 字节, 偏移需对齐到 4 => 偏移 4 */ char d; /* 1 字节, 偏移 8 */ }; /* 大小补齐到 4 的倍数 => 12, 而不是 6 */ struct B { int i; /* 偏移 0 */ char c; /* 偏移 4 */ char d; /* 偏移 5 */ }; /* 补齐到 4 的倍数 => 8 */struct A里c后面空了 3 个字节,struct B只浪费了 2 个字节,成员内容完全一样。对齐规则一般归结为三条:每个成员的偏移必须是自身大小的整数倍(char按 1,int按 4);结构体总大小必须是最大成员对齐值的整数倍;如果用了#pragma pack或__attribute__((packed)),就按指定值来。
对齐的动机是硬件:多数处理器访问 4 字节整数时要求地址是 4 的倍数,非对齐访问要么慢,要么直接触发异常。所以编译器宁愿浪费几个字节,也要保证每次访问都在一个总线周期内完成。代价是结构体跨平台时大小可能不同——把一个含long的结构体直接写进二进制文件,换台机器读出来字段可能错位。稳妥的做法是显式用固定宽度类型(uint32_t/uint16_t),并约定好打包规则。
不给结构体定义也能算偏移,靠空指针技巧:
#include <stddef.h> #define FPOS(type, field) ((size_t) &(((type *)0)->field)) #define FSIZ(type, field) (sizeof(((type *)0)->field)) /* FPOS(struct A, i) == 4, FSIZ(struct A, i) == 4 */((type *)0)->field取的是从地址 0 起算的字段地址,本身不解引用任何东西,所以不会崩。标准库的offsetof就是这么来的。
3.3 数组下标与指针的等价换算
一维数组a[i]等价于*(a + i),这个大家都知道;二维数组才是分水岭。int a[2][3]里,a的类型是int (*)[3]——指向“含 3 个 int 的数组”的指针,所以a + 1跳过的是 3 个 int,不是 1 个。
int a[2][3] = {1,2,3,4,5,6}; /* a -> int (*)[3], 指向第 0 行 */ /* a + 1 -> 指向第 1 行, 偏移 3*4 = 12 字节 */ /* *(a + 1) -> int *, 指向 a[1][0] */ /* *(a+1)+1 -> int *, 指向 a[1][1] */ int v = *(*(a + 1) + 1); /* 5 */字符串数组同理。char *p[] = {"ABC","DEF"}是“指针的数组”,每个元素是一个char *;char p[][3] = {"ABC","DEF"}是“二维字符数组”,每行占 3 个字节。表达式*(*(p + 1) + 1)在两种声明下都能取到'E',但内存布局完全不同:前者存的是两个指针,后者存的是 6 个连续字节。知道这层区别,才不会在函数传参时写错类型。
3.4 指针遍历的两个范本:strcpy 与 memcpy
笔试要求手写这两个函数,考的不是背代码,是“终止条件由谁决定”。
/* 以 '\0' 为终止条件, 只能拷字符串 */ char *strcpy(char *dst, const char *src) { char *ret = dst; while ((*dst++ = *src++) != '\0') { /* 先赋值, 再判断 */ ; /* 空循环体 */ } return ret; /* 返回原始 dst, 支持链式调用 */ } /* 长度由调用方给出, 拷贝任意类型的数据 */ void *memcpy(void *dst, const void *src, unsigned int n) { unsigned char *d = (unsigned char *)dst; const unsigned char *s = (const unsigned char *)src; while (n-- > 0) { *d++ = *s++; } return dst; }strcpy的循环条件把“赋值”和“判断”压在一个表达式里:先把src当前字节写到dst,两个指针各自后移,再用刚写入的值判断是否到了\0。返回原始dst而不是末尾,是为了让strcpy(a, strcpy(b, c))这种写法可用。
memcpy用unsigned char *遍历,原因是不管源是什么类型,标准化成字节访问不会违反严格别名规则,也不会有对齐要求。参数类型是void *,进出都要转换。
两个函数都不检查重叠。memcpy(dst, dst + 1, n)在源和目的区间重叠时结果是未定义的,标准另外提供了memmove,实现方式通常是判断方向:目的地址高于源地址就从后往前拷,否则从前往后拷。这是“性能让位于正确性”的取舍——绝大多数拷贝场景不重叠,把判断留给需要的人,memcpy就能少一次分支。
注意:手写这两个函数时,参数用
const修饰源、返回值返回目的首地址,都是评分点。少了const,说明没理解“源只读”这个契约。
4. 关键字语义:const、static、volatile、宏在编译器眼里是什么
这四个东西常被归为“八股”,但它们回答的是同一个问题:这个符号的哪些性质在编译期就确定了,哪些要留到运行期。想清楚这一层,题目怎么变都能推导。
4.1 const 修饰的是“谁不可变”
const的读法是从变量名往外读:
const int a; /* a 是常整型, 值不可改 */ int const a; /* 同上, 写法等价 */ const int *p; /* p 指向常整型: *p 不可改, p 可改 */ int * const p; /* p 是常指针: p 不可改, *p 可改 */ const int * const p; /* 两者都不可改 */判断诀窍是看const贴在谁右边:贴在*左边,锁的是被指对象;贴在变量名左边,锁的是指针本身。const int *p里p可以重新指向别的地址,但通过p不能写;int * const p里p一旦初始化就不能改指向,但*p = 1合法。
const的价值不在“防止自己写错”,而在“把契约写进类型系统”。函数声明size_t strlen(const char *s),一眼就知道它不会改你的字符串,调用方不用先拷一份再传。编译器也能借此把只读数据放进只读段,写入直接触发异常,比运行到一半才发现越界写更早暴露问题。
4.2 static 的三层作用域
static在不同位置含义不同,这也是它常被答不完整的原因:
static int g; /* 1) 文件作用域: 只在本编译单元可见 */ void f(void) { static int cnt = 0; /* 2) 局部静态: 只初始化一次, 跨调用保持 */ cnt++; } static void helper(void) /* 3) 静态函数: 只在本编译单元可调用 */ { }第一层是链接属性,把外部链接改成内部链接,避免多个编译单元里同名全局符号冲突;第二层是存储期,变量从栈挪到静态区,函数返回也不销毁,副作用是它不再是线程安全的;第三层限制函数可见性,等价于给函数加了“私有”标记。
局部静态变量的初始化发生在程序启动阶段(对 POD 类型而言),不是每次进函数都执行。所以static int cnt = 0;里的= 0实际上只跑一次,后面的自增才能累加。这一点如果理解成“每次进函数都初始化”,就会得出cnt永远是 1 的错误结论。
4.3 volatile 与三次读取陷阱
volatile告诉编译器:这个变量可能在你不知道的时候被改掉,每次用到都必须重新从内存读,不许拿寄存器里的副本做优化。典型场景有三类:硬件状态寄存器、中断服务程序里改的全局变量、多线程共享的标志位。
一道经典题把它的影响暴露得很清楚:
/* 错误版本: 编译器可能读两次 *ptr */ int square_bad(volatile int *ptr) { return *ptr * *ptr; /* 两次读取之间值可能变化 */ } /* 正确版本: 只读一次, 用局部变量保存 */ long square_ok(volatile int *ptr) { int a = *ptr; return (long)a * a; }由于*ptr是volatile限定,编译器不允许把它缓存进寄存器复用,于是*ptr * *ptr可能生成两条加载指令。如果两次读之间硬件改了值,算出来的就不是平方。正确做法是先读进普通局部变量,再参与运算。这个例子说明volatile保证的是“每次访问都真实发生”,而不是“访问是原子的”——它不解决竞态,只解决优化器。
const和volatile可以同时修饰一个变量,典型例子是只读状态寄存器:硬件会改,所以是volatile;程序不该写,所以是const。指针本身也可以是volatile,表示这个指针变量会被异步修改,读取指针值时同样不能走缓存。
4.4 typedef 与宏:一个进编译器,一个进预处理器
typedef和#define都能起别名,但生效阶段不同。typedef是编译过程的一部分,受作用域约束,有类型检查;#define在预处理阶段做纯文本替换,没有类型概念。
typedef char *PSTR; #define PSTR_D char * PSTR a, b; /* a、b 都是 char * */ PSTR_D a, b; /* 展开成 char * a, b; => b 是 char, 不是指针 */这个例子说明为什么定义指针别名必须用typedef。宏替换不理解声明语法,它只是把PSTR_D换成char *,于是b前面的*被吃掉了。
宏本身也有它的价值,尤其是需要“类型无关”或“编译期常量”的场合:
#define MAX(x, y) (((x) > (y)) ? (x) : (y)) /* 每个参数都加括号 */ #define ARR_SIZE(a) (sizeof(a) / sizeof((a)[0])) /* 数组元素个数 */ #define RND8(x) ((((x) + 7) / 8) * 8) /* 向上取整到 8 的倍数 */ #define MOD_POW2(v, m) ((unsigned int)(v) & ((m) - 1)) /* v % 2^n */ /* 多语句宏必须包在 do-while(0) 里 */ #define SWAP(x, y) do { \ (x) = (x) + (y); \ (y) = (x) - (y); \ (x) = (x) - (y); \ } while (0)MAX的括号是血泪经验:写成#define MAX(x,y) x > y ? x : y,遇到MAX(a & 0xf, b)就会因为优先级错乱算出错误结果。SWAP用do-while(0)包裹,是为了让它在if (...) SWAP(a,b); else ...里作为一个语句整体出现,不会因为多出分号把else顶飞。至于SWAP的加减法实现,只对整数成立且有溢出风险,工程里更稳妥的写法是引入临时变量或直接用异或(同样有x == y时归零的坑)。
ARR_SIZE有个前提:a必须是真正的数组。传给函数后的形参是指针,sizeof(a)会变成 4 或 8,算出来就是错的——这和第一章那条退化规则是同一个根。
5. 常见问题排查:五类在笔试和现场都容易翻车的写法
这一章记录的是我自己和周围人真实踩过的坑,每一条按“现象、原因、解决”写。题目在卷面上看起来都对,一到机器上跑就出问题,基本都能归到这五类。
5.1 现象:strlen 算出几万的长度,程序随机崩
原因几乎总是数组末尾没有\0。char str[5] = {'H','e','l','l','o'};的五个字节全被占满,strlen从首地址往后一路找终止符,越界读到栈上的其他内容,直到碰巧遇到一个 0 才停。长度是随机的,行为是未定义的。
解决方式有两种:要么把数组开大一个字节(char str[6] = "Hello"自动带\0),要么明确知道这个缓冲区是“定长字节块”而非“字符串”,后续一律用长度参数传递,不碰strlen。凡是打算打印、拼接、比较的字符数组,都必须留出终止符的位置。
5.2 现象:函数里 sizeof 出来是 4,外面明明是 20
原因在数组退化为指针。void f(char buf[20])和void f(char buf[])在编译器眼里都等价于void f(char *buf),形参类型是指针,sizeof(buf)自然是指针宽度。这是 C 语言传参机制决定的:数组不整体拷贝,只传首地址。
解决方式是长度随参数一起传:void f(char *buf, size_t len)。如果确实需要在函数内知道大小,要么用全局常量、要么把数组包装进结构体按值传递(结构体可以整体拷贝),要么定义一个结尾哨兵。指望sizeof在函数里“记住”数组长度,方向从一开始就错了。
5.3 现象:malloc 之后用 sizeof 求块大小,得到 4 或 8
char *p = malloc(100);之后sizeof(p)是指针大小,不是 100。malloc返回的是地址,分配出去的长度只有调用方自己知道,运行时库把它记在块头里,但没有标准接口能读回来。
解决办法是“分配长度自己存一份”。常见写法是用一个变量记住申请的长度,或者把长度和指针一起封装成结构体。这也解释了为什么reallocต้อง传新长度——运行时无法替你查旧账。对缓冲区操作时始终坚持“指针 + 长度”成对出现,是这个坑唯一可靠的解法。
5.4 现象:宏在 if-else 里展开后编译报错,或者结果多算了一次
两种典型。第一种是宏体没加括号,#define DOUBLE(x) x * 2,DOUBLE(a + 1)展开成a + 1 * 2。第二种是宏体有多条语句却没有包裹,#define INIT(a,b) a = 0; b = 0;,用在if (flag) INIT(x, y);里,展开成if (flag) a = 0; b = 0;,第二条语句无论条件真假都会执行。
解决方式是双保险:所有参数和整个宏体都加括号,多语句宏用do { ... } while (0)包住。这两个习惯看起来是小事,但涉及宏的 bug 往往极难定位,因为调试器看到的是展开后的行号,跟源码对不上。
5.5 现象:结构体 sizeof 比成员之和多了几个字节,序列化后字段错位
原因是内存对齐填充。struct { char c; int i; }在 32 位下大小是 8,不是 5,因为i必须落在 4 的倍数偏移上。把这样的结构体直接write到文件或用memcpy发出去,接收端如果对齐规则不同,读出来的i就是垃圾。
解决方式分场景:只在同构环境里传,可以直接覆盖;要跨平台或跨编译器,就不要用结构体直接映射二进制布局,改为字段级序列化——逐个写出每个字段并用固定宽度类型,或者约定打包规则后显式指定。判断结构体实际布局,用offsetof打印每个成员的偏移,比凭直觉数字节可靠得多。
6. 从关键字到线程:几处设计取舍的再思考
把 C 语言的这些“考点”串起来看,会发现它们都在回答同一类问题:哪部分信息在编译期就能定下来,哪部分必须推迟到运行期。sizeof站在编译期这一侧,所以能作用于类型;strlen站在运行期那一侧,所以能处理任意长度的字符串。const把契约写进类型,让错误提前到编译阶段;volatile反过来约束优化器,逼它尊重运行期的变化。数组传参退化,本质上也是编译器不愿意为一次调用复制整块内存所付出的信息代价。
进程与线程的分野,是同一种取舍在系统层面的体现。进程独享地址空间,一个崩了不会波及别人,代价是创建、切换都要动页表;线程共享地址空间,切换只换寄存器和栈,代价是一个线程踩坏堆,整个进程一起完蛋。题目里那句“多进程更健壮、多线程更高效”,说的不是优劣,而是把隔离性和开销放在了天平两端。
通信机制的选择遵循同样的逻辑。管道简单但有亲缘关系限制;消息队列解耦了读写双方,但有内核拷贝开销;共享内存最快,但必须自己配信号量做同步,否则就是数据竞争。选哪个不看哪个“高级”,看的是你愿意为哪一项付出代价。
我自己的教训来自一次结构体直接落盘。当时图省事,把含int和char的配置结构体整体写文件,本地一切正常,换到另一台机器读出来字段全错位,排查了大半天才想到对齐。从那以后,凡是跨边界的二进制数据,我都强制走一遍字段级序列化,并写个offsetof断言打印出来核对布局;凡是头文件里共享的全局,一律加static收窄可见性。这些习惯不高级,但真的省下了很多返工。
这套试题集的价值也在这里:它把 sizeof、对齐、大小端、关键字语义、线程模型这些散点放在一起,逼你回答“为什么是这样”。对着答案背一遍很快,但只有把每个结论背后的机制推一遍,下次遇到变形题才不至于卡住。希望帮到你。
本文还有配套的精品资源,点击获取