1. 从一道"送分题"说起:为什么指针和内存是面试官最爱挖的坑
"C语言面试题每日一练"这个系列我打算做成一个长期更新的栏目,原因很简单:市面上大部分C语言面试题解析要么太散,要么只给答案不讲推导过程,读者看完还是不会。我自己带过几轮校招和社招的技术面,也做过被面的一方,深知一个规律——面试官问指针和内存,从来不是想听你背定义,而是想看你能不能把一段代码在脑子里"跑"一遍。
这篇是第一练,我挑了一道看起来像送分题、实际上能筛掉一半候选人的题目。它涉及指针运算、数组退化、sizeof与strlen的区别、以及栈上变量的生命周期。这几个点几乎覆盖了C语言面试中80%的基础追问。如果你正在准备面试,或者想重新把C语言的底层逻辑捋一遍,这篇内容适合你;如果你已经工作几年但一直靠"经验"写代码,从没认真想过这些细节,那更应该看看——很多线上事故的根因,就藏在这些"送分题"里。
先看题目。下面这段代码,问输出结果是什么:
#include <stdio.h> #include <string.h> void func(char arr[]) { printf("func sizeof: %zu\n", sizeof(arr)); } int main(void) { char str[] = "hello"; char *p = str; int a[5] = {1, 2, 3, 4, 5}; int *q = a; printf("str sizeof: %zu\n", sizeof(str)); printf("str strlen: %zu\n", strlen(str)); printf("p sizeof: %zu\n", sizeof(p)); printf("a sizeof: %zu\n", sizeof(a)); printf("q sizeof: %zu\n", sizeof(q)); printf("a+1: %p, &a+1: %p\n", (void*)(a+1), (void*)(&a+1)); func(str); return 0; }这道题我面过不下二十个人,能一次性全答对的不到三成。大部分人卡在两个地方:sizeof(arr)在函数里为什么变了,以及a+1和&a+1到底差多少字节。下面我逐层拆开讲,不只是给答案,而是把背后的规则讲透,让你下次遇到变体也能推出来。
2. 逐行拆解:sizeof、strlen与数组退化的真实行为
2.1 sizeof是编译期运算符,不是函数
很多人下意识把sizeof当成函数调用,这是第一个认知偏差。sizeof是编译期求值的运算符,它返回的是类型或对象在内存中占用的字节数,单位是size_t。正因为它在编译期就确定了,所以它根本不关心运行时变量里存的是什么值。
sizeof(str)里str是char[6]("hello"加结尾的\0),所以结果是6。注意这里有个细节:字符串字面量"hello"本身类型是char[6],用它初始化数组时,数组大小就是6,不是5。如果你写成char str[5] = "hello";,在C里是合法的(会丢掉\0),但后续用strlen就会越界读,这是个经典坑。
sizeof(p)里p是char*,在64位平台上指针是8字节,32位平台是4字节。所以这个结果依赖平台,面试时一定要主动说明"在64位环境下是8"。我见过候选人直接答4,然后被追问"你确定吗",其实他平时在32位单片机上写代码,答4也没错,但没说明前提就显得不严谨。
sizeof(a)里a是int[5],每个int通常4字节,所以是20。sizeof(q)是指针,8字节。这几个都还算基础。
2.2 strlen是运行期函数,靠找\0停
strlen(str)返回的是从起始地址到第一个\0之前的字符数,不含\0,所以是5。它和sizeof的本质区别在于:sizeof看类型,strlen看内容。这也是为什么strlen不能用于没有\0结尾的字符数组——它会一直往后读,直到撞上内存里某个恰好为0的字节,结果不可预测,甚至直接段错误。
这里有个实操经验:在嵌入式或者处理网络报文时,经常拿到的是"带长度字段的缓冲区"而不是以\0结尾的字符串。这时候用strlen就是灾难。正确做法是用memcpy配合已知长度,或者手动补\0。我在一个通信项目里就见过同事对着一块recv回来的数据调strlen,结果程序随机崩溃,排查了两天才定位到。
2.3 数组作为函数参数会"退化"成指针
这是本题的核心考点。func(char arr[])这个写法,arr看起来是数组,但在函数参数位置上,数组类型会自动退化为指针类型,等价于func(char *arr)。所以sizeof(arr)在函数内部得到的是指针大小(8字节),而不是数组大小。
为什么会这样?因为C语言设计时,数组传参如果按值传递整个数组,开销太大,所以规定传的是首元素地址。这个规则叫"数组到指针的退化"(array-to-pointer decay)。它只在三个场景下不发生退化:sizeof的操作数、&取地址的操作数、以及用字符串字面量初始化数组时。
所以func里打印的是8,不是6。这个点面试官几乎必问,因为它直接反映你是否理解C的传参机制。想拿到真实数组长度,必须在调用方把长度一起传进来,比如func(str, sizeof(str)),或者用strlen(前提是有\0)。
2.4 a+1和&a+1:指针算术的单位是"元素"不是"字节"
这是最容易翻车的一问。a是int[5],在表达式里a退化为int*,指向首元素。a+1就是往后移动一个int,也就是4字节,指向a[1]。
而&a的类型是int(*)[5],是指向整个数组的指针。&a+1移动的是一整个数组,也就是5 * 4 = 20字节,直接跳到数组末尾之后。
所以如果a的地址是0x1000,那么a+1是0x1004,&a+1是0x1014。两者相差16字节(0x1014 - 0x1004 = 0x10 = 16),而不是很多人以为的"差一个元素"。这个知识点在面试里经常以"打印两个指针差值"的形式出现,答错基本就暴露了对指针类型理解不深。
我把完整输出整理成表格,方便对照:
| 表达式 | 类型 | 结果(64位) | 说明 |
|---|---|---|---|
| sizeof(str) | size_t | 6 | 数组含\0 |
| strlen(str) | size_t | 5 | 不含\0 |
| sizeof(p) | size_t | 8 | 指针大小 |
| sizeof(a) | size_t | 20 | 5个int |
| sizeof(q) | size_t | 8 | 指针大小 |
| func内sizeof(arr) | size_t | 8 | 退化为指针 |
| a+1 | int* | 首地址+4 | 移动一个int |
| &a+1 | int(*)[5] | 首地址+20 | 移动整个数组 |
3. 面试官真正想筛的是什么:三个隐藏的追问方向
3.1 追问一:如果数组在结构体里,还会退化吗
会有人接着问:struct { int a[5]; } s;,那sizeof(s.a)是多少?答案是20,因为sizeof的操作数不退化。但如果把s.a传给函数,照样退化成指针。这个追问的目的是确认你理解"退化只发生在特定上下文",而不是死记"数组就是指针"这种错误结论。
数组和指针在C里是两种不同的类型,只是在大多数表达式里数组会转换。这个区别在extern声明时尤其重要:extern int a[];和extern int *a;是完全不同的东西,前者告诉编译器"这里有个数组,大小未知",后者说"这里有个指针变量"。链接时如果搞混,行为未定义。
3.2 追问二:sizeof返回size_t,用%d打印会怎样
size_t是无符号整数类型,在64位平台通常是unsigned long。如果你用%d打印,格式不匹配,行为未定义,实际可能打印出奇怪的值。正确做法是用%zu。这个细节在面试里是加分项,说明你写代码严谨。
我在代码review里见过有人写printf("%d", sizeof(x)),在32位机器上碰巧能跑对,一上64位就出问题。这类"平台相关"的bug最难查,因为本地复现不了。
3.3 追问三:strlen的返回值能直接相减吗
strlen返回size_t,两个size_t相减如果结果是负数,会回绕成一个巨大的正数。比如strlen(a) - strlen(b),如果a比b短,结果不是负数而是接近SIZE_MAX的值。正确写法是先转成int或ptrdiff_t再比较。这个坑在写字符串处理逻辑时非常常见,尤其是判断"哪个字符串更长"的时候。
// 错误示范 if (strlen(a) - strlen(b) > 0) { /* 永远为真 */ } // 正确写法 if (strlen(a) > strlen(b)) { /* 直接比较 */ }4. 从这道题延伸出的四个高频变体
4.1 变体一:二维数组的退化
int a[3][4];,a退化成int(*)[4],不是int**。a+1移动16字节(一行)。sizeof(a)是48,sizeof(a[0])是16,sizeof(a[0][0])是4。面试官常问"int**能不能接收二维数组",答案是不能,类型不兼容。要传二维数组,函数签名得写void f(int a[][4])或void f(int (*a)[4])。
4.2 变体二:字符串字面量的存储位置
char *p = "hello";里的"hello"存在只读数据段,p指向它。试图p[0] = 'H'是未定义行为,通常直接崩溃。而char str[] = "hello";是把内容拷贝到栈上,可以修改。这个区别在面试里经常以"下面代码有什么问题"的形式出现。
4.3 变体三:函数返回局部数组
char* getStr(void) { char buf[10] = "hi"; return buf; // 危险 }buf在栈上,函数返回后栈帧失效,返回的指针是悬垂指针。这是C语言最经典的错误之一。正确做法是用static、malloc,或者让调用方传缓冲区进来。我倾向于最后一种,因为内存归属清晰,不容易泄漏。
4.4 变体四:指针与整数的加减
p + n移动n * sizeof(*p)字节。如果p是void*,GCC会当作char*处理(移动n字节),但这是编译器扩展,标准C里void*的算术是未定义的。写可移植代码时,先把void*转成char*或unsigned char*再做算术。
5. 我踩过的坑和给面试者的实操建议
5.1 别背答案,学会在纸上画内存图
我刚开始准备面试时也背过"数组退化为指针"这种结论,但一遇到变体就懵。后来我养成了一个习惯:拿到指针题先在纸上画内存布局,标出每个变量的地址、类型、占多少字节。画完图,a+1和&a+1的差别一目了然。这个方法比背任何口诀都管用。
具体怎么画:横轴画地址,每个格子代表一个字节,把变量按类型填进去。数组就画连续的格子,指针就画一个格子然后箭头指向它指向的地方。&a的箭头指向整个数组块的起始,a的箭头指向第一个元素。移动的时候看箭头指向的"单位"是什么,就不会错。
5.2 面试时主动说明平台假设
指针大小、int大小、字节序这些都是平台相关的。回答时主动加一句"在64位小端环境下",会让面试官觉得你有工程意识。如果面试官追问"32位呢",你也能顺势展开,展示知识广度。我面别人的时候,最怕听到斩钉截铁的"就是8",然后问为什么,答不上来。
5.3 用编译器验证,别靠猜
sizeof的结果、指针差值,这些完全可以用代码验证。写个小程序打印出来,比在脑子里推演靠谱得多。我建议每个学C的人都养成习惯:对任何不确定的行为,写代码跑一遍。用gcc -Wall -Wextra打开所有警告,很多问题编译器会直接告诉你。
gcc -Wall -Wextra -o test test.c ./test如果开了-Wall还编译通过但运行结果诡异,那大概率是未定义行为,这时候可以上-fsanitize=address,undefined,能抓出越界、悬垂指针、有符号溢出等问题。这个工具我在实际项目里救过好几次命。
5.4 一个容易被忽略的点:sizeof对表达式的处理
sizeof的操作数如果是表达式,这个表达式不会被求值。比如int i = 0; sizeof(i++);,i最后还是0。因为sizeof只需要类型信息,不需要值。这个特性偶尔会被用来做编译期检查,比如sizeof(0 ? (void)0 : (void)0)这种技巧。面试里如果问到,能答出来是加分项。
但要注意,如果操作数是变长数组(VLA),sizeof会在运行期求值,因为VLA的大小编译期未知。这是C99引入的特性,日常业务代码里很少用,但面试官可能拿来区分"背书的"和"真懂的"。
6. 把这道题变成你自己的知识网络
一道题的价值不在于答案本身,而在于它能牵出多少相关知识。这道题表面考sizeof和指针,实际上串起了类型系统、内存布局、函数调用约定、未定义行为这几大块。我建议你按下面的方式做笔记,把零散知识点连成网:
- 类型维度:数组类型 vs 指针类型,退化规则,
sizeof与&的例外。 - 内存维度:栈、堆、静态区、只读区,各存什么,生命周期多长。
- 运算维度:指针算术的单位,
size_t的回绕,有符号无符号混用。 - 工具维度:
-Wall、-fsanitize、gdb的p sizeof(x)和ptype命令。
下次遇到新题,先判断它落在哪个维度,再调用对应的知识。比如看到"函数返回局部数组",立刻归到"内存维度-栈的生命周期";看到"两个指针相减",归到"运算维度-指针算术"。这样你的知识就是可检索的,而不是一堆散点。
我在实际带新人的时候发现,能通过这种"维度归类"方式学习的人,成长速度明显快于死记硬背的人。因为面试题千变万化,但底层机制就那么几套。把机制吃透,题目怎么变都不怕。
最后留一道思考题给你练手:如果func的签名改成void func(char (*arr)[6]),那sizeof(arr)是多少?arr+1移动多少字节?想清楚这道题,说明你对数组指针的理解已经到位了。下一练我会讲函数指针和回调,那是另一个面试重灾区。