☰
C语言高频面试题汇总:指针、内存对齐与手写代码全解析
2026/10/16 8:12:38 网站建设 项目流程

写了这么多年C代码,面试别人也被别人面试,手里这份C语言高频面试题汇总攒了不少年头。一开始是为了自己跳槽时复盘,后来变成我跟团队面试新人时的底稿,再后来发给过一些准备校招和社招的朋友,反馈都还不错。这次干脆把核心部分整理成文,方向偏嵌入式、后台Linux开发、基础软件栈这三大类岗位,覆盖指针、内存、结构体对齐、宏、编译链接、手写代码这些真正高频的考点。

你别把它当期末提纲去背,当一份“查漏补缺清单”更有用。面试官问C语言,真正想看的不是你能不能默写答案,而是你有没有把“变量、地址、生命周期、内存布局”这串东西在脑子里串成一条线。看完这篇,大概能覆盖中等偏难题目里八成以上的考点。如果里面某些点你从来没仔细想过,那下一轮复习应该从哪儿开始,心里大概有数了。

1. C语言面试题的考察逻辑:先把地图铺开再刷题

1.1 面试官问一道题,到底想要什么答案

我面过不少候选人,C语言用了三四年,却会在“数组名和指针的区别”这种基础题上卡壳。原因不是没背题,而是把C语言用成了高级语言:总在关心对象怎么放、引用怎么改,忽略了C语言最底层的杀招——地址和内存模型。面试官追问一两个“为什么”,往往不是想抓你的错误,而是想观察你怎么拆解问题。

比如问const char *p和char * const p的区别,如果只说“一个是p指向的内容不能改,一个是p本身不能改”,这只是八股。能顺手补一个使用场景,比如前者常用来声明只读字符串参数,后者常用来声明固定不动的缓冲区指针,这就能证明你真的在工程里用过,而不是考前突击背下来的。

所以刷题的时候,别只看答案,要连“为什么这样设计”“什么场景下会出这个问题”一起看。面试官心里的评分表往往是这样:基础概念占一部分,手写代码的边界处理占一部分,讲不清楚点还硬编是最减分的。

1.2 高频考点分布其实非常集中

C语言面试题看起来漫天都是,实际高频模块就下面这几块。我把常见问法也列出来,复习时对着这个表自查就够了。

考点模块面试官考察点最常见问法
指针与数组地址、类型、退化的理解a和&a一样吗?a+1和&a+1有什么区别?
内存管理生命周期、泄漏、非法访问malloc 和 free 如何配对?返回局部变量地址有什么问题?
结构体/联合体内存对齐和空间布局这个结构体多大?为什么要对齐?位域能用吗?
关键字语义编译语义差异static、const、volatile、extern 分别有什么作用?
编译链接模块化和符号解析头文件怎么写?undefined reference 是什么原因?
手写代码边界处理、逻辑清晰链表反转、字符串拷贝、memmove、判断大小端。

把这几块吃透,基本就抓住了C语言面试的主干。接下来按顺序展开,每部分我都会把答案和背后的原理一起讲。

2. 指针与内存:C语言面试题的中流砥柱

2.1 数组名、指针和地址的弯弯绕

先看一道经典到不能再经典的题:

int a[5]; printf("%p\n", a); // 数组首元素地址 printf("%p\n", &a); // 指向整个数组的指针 printf("%zu\n", sizeof(a)); // 常见32位int下是 20 printf("%zu\n", sizeof(a + 0)); // 4或8,取决于指针宽度

a和&a打印出来的数值看起来一样,但类型完全不同。a的类型是int*,指向首个元素;&a的类型是int(*)[5],指向整个数组。这导致a+1只跳过一个 int,而&a+1直接跳过整个数组,偏移 20 个字节。这个点面试频率极高,因为特别能检验一个人是不是真的理解“指针类型决定步长”。

数组名还有一个关键规则:除了作为sizeof或&的操作数之外,它在表达式中都会“退化”成指向首元素的指针。所以函数参数里写的int a[5],本质上就是int *a,在函数内sizeof(a)得到的是指针大小,不是 20。很多人栽在这里,就是没把“数组参数会退化成指针”这条规则刻进脑子里。

二维数组也得会看。对于int a[2][3],a的类型是int (*)[3],a+1偏移 12 字节;*(a+1)拿到第二行的首地址,在表达式中再退化成一维指针;要取a[1][2],就是*(*(a+1)+2)。这种写法看起来绕,其实是理解“数组的数组”的钥匙:第一层a指向的是由3个int组成的数组,第二层才指向int。

2.2 sizeof 和 strlen 的组合拳

sizeof是编译期操作符,而且只在极少情况下会执行表达式;strlen是运行时函数,逐字节数到\0为止。这个区别是所有相关题目的根基。

char *p = "hello"; printf("%zu\n", sizeof(p)); // 指针大小,64位平台通常是8 printf("%zu\n", sizeof("hello")); // 6,因为包含结尾的 '\0' printf("%zu\n", strlen(p)); // 5,只统计到 '\0' 之前的字符

sizeof(“hello”)等于 6 是很多人第一次会记错的点。字符串字面量在 C 语言中是一个字符数组,数组长度天然包含结尾的'\0',所以是 6。而strlen是运行时从首个字符开始数,遇到'\0'停,所以返回 5。

再比如:

char buf[] = "hello"; printf("%zu\n", sizeof(buf)); // 6

如果一个函数接收char buf[]作为形参,内部再写sizeof(buf),得到的就不是 6 了,因为形参退化成指针。这个细节在写网络协议解析、固定长度数组遍历时经常踩坑,面试官也特别喜欢拿它做“追问点”。

2.3 野指针、悬空指针、内存泄漏:必聊的三大类问题

这三类问题是 C 语言面试里的“安全三连”,每次都会有人栽。

野指针是指针本身没有被初始化,里面存了一个未知地址。悬空指针是指向的内存已经被释放或失效,但指针变量仍保留着旧地址。内存泄漏则是动态分配的内存没有被正确释放。区别要说清楚,因为处理方式不同:野指针要在定义时就初始化,悬空指针要在释放后置NULL,内存泄漏要靠“谁分配谁释放”的约定来预防。

典型的悬空指针例子是这个:

int *bad(void) { int x = 10; return &x; // x 是栈上局部变量,函数返回后生命周期结束 }

返回局部变量地址,编译器通常也会警告。面试时要答出来:局部变量在函数返回后栈帧就失效了,拿着这个地址去访问是未定义行为。如果把x改成static int x,它驻留在静态存储区,返回地址才安全,但又牵扯到线程安全和共享状态的问题,工程上并不推荐随便用。

动态内存方面,规范做法是:

int *p = malloc(sizeof(int) * n); if (p == NULL) { // 处理失败 } ... free(p); p = NULL; // 避免悬空指针

释放后置 NULL 不是强迫症,是防止之后误操作同一个指针。如果两次free(p),第一次释放,第二次就是 double free,轻则崩溃,重则破坏堆结构。最好还在代码层面约定:谁 malloc 谁 free,谁负责传入缓冲区谁负责校验长度。面试里如果能主动说出“调用方要提供目标缓冲区大小”,会比闷头写strcpy加分不少。

2.4 指针运算和函数指针:面试官爱玩的“加法”

指针加1到底加多少,要看指针类型。int*指针加1,地址值加4(常见32位int),char*加1,地址值加1,struct Node*加1,地址值加整个结构体大小。这就是“指针类型决定步长”的表现。有些编译器支持void*按1字节移动,但标准 C 不定义void*的算术运算,工程上不要依赖这种扩展。

函数指针也是C语言里很有区分度的考点。它不直接考语法,而是考你会不会用它写出灵活的结构:

typedef int (*op_fn)(int, int); int add(int a, int b) { return a + b; } int sub(int a, int b) { return a - b; } op_fn ops[] = { add, sub }; int result = ops[op](x, y);

函数指针数组在嵌入式菜单逻辑、协议解析、状态机里非常常见。面试官如果让你写一个“根据指令码调用不同处理函数”的代码,本质就是查函数指针表。能想到这一层,说明你理解“代码也可以作为数据被传递”这件事。

3. 宏、关键字与编译链接:藏在代码背后的规则

3.1 宏定义为什么必须疯狂加括号

宏是预处理器阶段的纯文本替换,不是函数,也不是语句。所以入门级别的题目就是#define SQUARE(x) x*x哪里有坑。

#define SQUARE(x) ((x) * (x)) #define MAX(a, b) ((a) > (b) ? (a) : (b))

如果写成SQUARE(a+1),没有外层括号会变成a+1*a+1,结果完全不对。所以宏参数和整体结果都要加括号,这是铁律。

但加了括号也不是就安全了,宏还会导致参数“多重求值”。比如MAX(a++, b),如果a更大,a++会被执行两次,这不是期望行为。工程上能不用宏函数就不用,改用static inline函数,既保留类型检查,又避免副作用。如果必须用宏封装资源释放,常见写法是do { ... } while(0):

#define SAFE_FREE(p) do { free(p); (p) = NULL; } while (0)

do-while(0)能把宏包成一条语句,避免出现在if后面只有单个分号时的语法问题。面试时写出这个结构,比只会背“宏要加括号”高一个档次。

3.2 static、const、volatile、extern 的一网打尽

这几个关键字是 C 语言面试的“身份识别题”,每个都要能做三件事:说作用、举例子、说限制。

static有三个作用:

  • 修饰局部变量:改变存储位置,从栈移到静态存储区,生命周期延长到程序结束,但作用域不变。第一次初始化后,之后调用不会再重新初始化。
  • 修饰全局变量:限制链接作用域,变量只在本文件内可见,外部文件无法用extern引用。
  • 修饰函数:限制函数的外部链接性,只在当前源文件可见。

const要分清它修饰的是指针本身还是指针指向的内容:

const int *p; // p 指向的内容不能改,但 p 可以改 int * const p; // p 本身不能改,但 *p 可以改 const int * const p; // 两者都不能改

判断技巧是:const在*左边,修饰指向内容;const在*右边,修饰指针变量本身。const和宏不一样,宏在预处理阶段替换,const是编译期变量,有类型检查,调试信息也更友好。按现代 C 的代码规范,能用const的地方就不要用宏。

volatile的作用是告诉编译器:这个变量可能在外部被修改,每次使用都必须从内存重新读取,不能优化到寄存器里缓存。典型场景有三个:硬件寄存器地址、中断服务函数和主循环共享的变量、多线程环境下的共享标志。要注意的是,volatile并不保证原子性,多线程场景下该用锁或原子操作还得用。

还有个更进阶的组合:const volatile uint32_t *reg,表示程序不能修改这个寄存器,但外部硬件会随时改变它。很多嵌入式开发岗位会拿这个问,能答出来基本就是加分项。

extern是声明外部变量或函数,告诉编译器“符号在别的源文件里”。它有两种典型用法,一个是跨文件访问全局变量,另一个是在头文件里放函数声明。训练一个人的模块化能力,就看extern用得规不规范。

3.3 头文件重复包含与声明问题

任何一份真实工程的代码里,头文件写到后来都会遇到重复包含的问题。经典解法是 include guard:

#ifndef PROJECT_INC_UTIL_H #define PROJECT_INC_UTIL_H /* 头文件内容 */ #endif

还可以用#pragma once,现在主流编译器都支持,写法更干净,但早期某些编译器不认识,兼容性不如 include guard。我的建议是:跨平台公共代码尽量用 include guard,私有项目用#pragma once问题也不大。

头文件里不能定义全局变量,这也是一个高频坑。如果在头文件里写了int global_counter = 0;,然后两个.c文件都包含它,链接阶段就会报multiple definition。正确做法是在一个.c文件里定义变量,在头文件里用extern声明。常量也一样,C 语言里文件作用域的const默认并不保证内部链接,多个源文件包含会导致重复定义问题,所以头文件里的常量推荐写成static const或者用 enum。

3.4 编译链接过程与常见错误

C 源文件变成可执行文件,大致经过四个阶段:预处理、编译、汇编、链接。预处理展开宏、处理#include;编译把 C 代码翻译成汇编,同时做语法和类型检查;汇编把汇编代码转成目标文件;链接把多个目标文件和库拼在一起,解析符号引用和重定位。

面试里最常见的问题就是:

  • undefined reference to 'foo':函数只有声明没有定义,或对应.c文件没参与编译,或链接时库的顺序不对。排查时先确认声明和定义拼写一致,再看文件有没有加进编译列表,最后看静态库是不是放在依赖它的目标文件后面。
  • multiple definition of 'bar':同一个全局符号在多个目标文件里都有定义。先查头文件里是否定义了变量,再查是否有两个.c文件都定义了同一个非 static 全局变量。
  • 函数没有声明就调用,编译器报 implicit declaration 或 warning。C89 和 C99 之后的处理方式不一样,正规做法是每个跨文件函数都要有正确声明。

懂编译链接,不只为面试,实际排查问题的时候真的能省半天时间。

4. 结构体、联合体与内存对齐:硬件视角的考察重点

4.1 结构体对齐:每场面试都要算一遍

结构体大小不是简单的成员大小相加,背后是内存对齐规则。CPU 按字长读取内存更高效,如果int落在奇数偏移上,可能需要两次内存访问。所以编译器会插入 padding,把成员放到合适偏移上。

看一个经典对比:

struct A { char a; int b; char c; }; struct B { char a; char c; int b; };

在常见的 32 位 int、4 字节对齐平台上,sizeof(struct A)是 12,sizeof(struct B)是 8。原因很简单:struct A里char占偏移0,为了放int需要填3个字节,int放在偏移4,最后char放在偏移8,整个结构体再按最大对齐数4补齐到12。struct B把两个char放在一起,偏移1之后填2个字节,int放偏移4,总大小8,一点不浪费。

这也是为什么结构体设计会影响内存占用。大量实例化的结构体,比如网络包、游戏实体、内存池项,调整成员顺序能降低内存占用。面试时算完大小,最好补一句“如果平台要求严格对齐,访问未对齐成员可能直接总线错误”,这能体现硬件意识。

网络协议解析里经常用#pragma pack(push, 1)或__attribute__((packed))让结构体紧凑排列。但要清楚,紧凑排列省了空间,却可能导致非对齐访问,在有些 CPU 上性能下降甚至崩溃。所以只在需要拼接固定格式缓冲区时用,并且数据拷贝要谨慎。

4.2 联合体的大小与字节序

联合体的大小取决于最大成员,因为所有成员共享同一块内存。比如:

union Value { char c; int i; double d; };

这个 union 的大小通常是 8,因为double占8字节且对齐数最大。联合体常用于“同一块内存不同视角解释”的场景,最经典的面试题就是判断大小端。

大小端是指多字节整数在内存中的字节排列顺序。小端模式低位字节在低地址,大端模式高位字节在低地址。判断代码可以这样写:

int is_little_endian(void) { unsigned int x = 1; unsigned char *p = (unsigned char *)&x; return *p == 1; }

也可以借助 union:

union { unsigned int i; unsigned char c; } u; u.i = 1; if (u.c == 1) { // 小端 }

在本地写代码时,大端小端感觉不出来差异,但涉及跨端字节序转换、网络协议、二进制解析时,这个问题会直接影响正确性。面试问大小端,本质上是在考察你有没有“数据在内存中的真实布局”这个概念。

4.3 位域:节省空间的代价

位域可以按 bit 分配空间,比如:

struct BitStatus { uint32_t enable : 1; uint32_t mode : 3; uint32_t reserved : 28; };

这个结构体占4字节,enable只占1位,mode占3位。位域确实省空间,但它有几个明显的坑:

  • 位域不能取地址,你不能&status.enable。
  • 一个位域跨不跨字节边界、从高位移到低位还是从低位移到高位,由编译器决定,C 标准没有统一规定,跨平台移植时容易出问题。
  • 访问位域不是零开销,编译器会生成掩码和移位操作,有时比普通成员访问更慢。

所以在硬件寄存器映射和协议头解析里,我见过很多项目用位域写得非常直观,也见过因为位域布局不匹配导致 bug 难查的。比较稳妥的做法是:用 uint8_t、uint16_t、uint32_t 定义结构体,再配合掩码#define和读改写函数去操作位。能答出这一点,面试官就知道你不是只在教科书里见过位域。

5. 高频手写代码题:面试现场的实战拉练

5.1 字符串函数:反转、拷贝、求长度

字符串题是手写代码的保底题。面试官一般先让你写一个strlen,看你能不能写出指针版本:

size_t my_strlen(const char *s) { const char *p = s; while (*p) { ++p; } return (size_t)(p - s); }

注意const char *表示不会修改字符串内容,return类型是size_t,不是int。这些细节都是评分点。工程标准库里的strlen往往按字长优化,一次读多个字节再判断,但面试阶段写逐字节版本没问题,关键要解释清楚复杂度是 O(n)。

再写strcpy:

char *my_strcpy(char *dst, const char *src) { char *d = dst; while ((*d++ = *src++)) ; return dst; }

这里有两个高频追问:为什么要返回char*?因为支持链式调用,比如printf("%s", strcpy(a, b))。为什么不检查目标缓冲区大小?因为标准strcpy的语义就是由调用方保证空间,这也是它经常被当成不安全函数的原因。面试时主动说“工程上应该用strncpy或自己封装带长度参数的版本”,比埋头写完强很多。

字符串反转也是送分题,但容易忽略\0:

void reverse_string(char *s) { if (!s) { return; } char *left = s; char *right = s + strlen(s) - 1; while (left < right) { char c = *left; *left++ = *right; *right-- = c; } }

边界条件是空指针和空字符串。很多人在right = s + strlen(s) - 1这里翻车,因为空字符串时strlen为 0,s - 1直接变成了非法指针。写之前把空串分支想清楚,手写题分数会高不少。

5.2 链表题:反转、合并、判定环

链表题是 C 语言面试里最常见的“动态数据结构”题。只要能把节点定义和指针操作写顺手,基本不会卡壳。

单链表反转是必考题。迭代版本:

struct Node { int val; struct Node *next; }; struct Node *reverse_list(struct Node *head) { struct Node *prev = NULL; struct Node *curr = head; while (curr) { struct Node *next = curr->next; curr->next = prev; prev = curr; curr = next; } return prev; }

这句struct Node *next = curr->next;必须在修改curr->next之前存好,否则就丢了后续节点。面试官有时会追问递归版本怎么写,核心是:先反转到尾部得到新头,再让当前节点的后一个节点指回当前节点,并把当前节点的 next 置空:

struct Node *reverse_list_recursive(struct Node *head) { if (head == NULL || head->next == NULL) { return head; } struct Node *new_head = reverse_list_recursive(head->next); head->next->next = head; head->next = NULL; return new_head; }

递归版返回值是新的头节点,思路上比迭代稍绕,但代码更短。回答时可以和面试官讨论递归的栈空间开销是 O(n),大链表下可能会栈溢出。

判断链表是否有环用快慢指针:

int has_cycle(struct Node *head) { struct Node *slow = head; struct Node *fast = head; while (fast && fast->next) { slow = slow->next; fast = fast->next->next; if (slow == fast) { return 1; } } return 0; }

这里最容易错的是循环条件。写成while (fast)不检查fast->next,后面fast->next->next就会空指针解引用。能写对边界,说明你对空链表和单节点链表都想过。

5.3 memcpy 与 memmove:容易被深挖的标准库函数

memcpy和memmove的区别是标准库题里的分水岭。memcpy不保证处理源和目标重叠,memmove保证可以安全处理重叠。答题时如果只背这一句,会被追问“为什么不安全”和“怎么安全实现”。手写memcpy:

void *my_memcpy(void *dst, const void *src, size_t n) { char *d = (char *)dst; const char *s = (const char *)src; while (n--) { *d++ = *s++; } return dst; }

这个版本在重叠时会有问题。比如要把字符串从位置2拷贝到位置0,前向拷贝会把还没读的源内容覆盖掉。memmove的思路是根据目标地址在源地址前面还是后面,决定从前向后拷还是从后向前拷:

void *my_memmove(void *dst, const void *src, size_t n) { char *d = (char *)dst; const char *s = (const char *)src; if ((uintptr_t)d < (uintptr_t)s) { while (n--) { *d++ = *s++; } } else if ((uintptr_t)d > (uintptr_t)s) { d += n; s += n; while (n--) { *--d = *--s; } } return dst; }

当dst在src后面时,从后往前拷贝,先处理尾部,就不会覆盖还没拷贝的源内容。这里有个加分点:严格来说,用<、>比较两个不相干对象的指针是 C 标准里限制比较多的操作,所以标准库实现大都把指针转成整数地址再比较。能在面试里主动提这个细节,说明你不是只会背 API。

很多标准库实现还会把拷贝优化成按字复制,比如一次拷贝 4 字节或 8 字节,再做尾部收尾。面试手写阶段不需要写那么复杂,但一定要说清楚边界对齐对性能的影响。

5.4 判断大小端:一道题串起指针、类型、内存

大小端题在前面的联合体部分已经出现过,这里从手写代码角度再列一遍:

int is_little_endian(void) { unsigned int x = 1; unsigned char *p = (unsigned char *)&x; return *p == 1; }

核心是:通过指针把内存字节逐个读出来,而不是按原类型解释。如果最低地址存的是0x01,说明低位字节在低地址,也就是小端;如果最低地址存的是0x00,就是大端。另一个版本用 union 写:

int is_little_endian_union(void) { union { unsigned int i; unsigned char c; } u; u.i = 1; return u.c == 1; }

两个版本内存效果相同,区别在于第一个体现指针强转,第二个体现 union 的数据共享语义。面试官如果继续问“网络字节序是什么”,答案是网络字节序规定为大端,所以跨端传输时要调用htonl、ntohl这类函数转换。能把这几个点连起来说,基本就证明你脑子里有一个完整的“内存字节视图”。

6. 面试实战中的踩坑与提分技巧

6.1 这些翻车点我见过太多次了

整理一下我这些年面试里见过的高频错误,希望你先有点印象,避免在同一个坑里栽跟头。

翻车点错误示例正确思路
数组名和&a混用以为a和&a完全等价两者数值相同,类型不同,步长不同
sizeof当运行时函数用在函数参数里计算数组大小形参数组退化成指针,sizeof得到指针大小
返回局部变量地址return &local_var;栈帧销毁后地址失效,应分配在堆或静态区
释放后继续使用free(p);后还访问*p释放后立即置 NULL,统一所有权约定
手写链表不检查空指针fast->next->next直接操作先判断fast && fast->next

这些错误在笔试题阶段很容易被掩盖,真正跑到机器上调试时才会暴露。面试时能把“边界条件”主动放在开头讲,会给人留下非常严谨的印象。

6.2 从面试官角度给的三条提分建议

第一条,回答问题先画内存图。不管是数组名、指针加1还是结构体对齐,拿出笔在纸上画出栈、堆、全局区,标出变量和地址。大部分面试官都吃这一套,因为这是真正的思考方式。

第二条,手写代码先讲思路,再写代码,最后主动说复杂度。不要一上来就埋头写。比如链表反转,先说“用三个指针迭代,维护 prev/curr/next,每次反转一个节点的指向,最后返回新的头”,然后再落笔。代码写完再补一句时间 O(n)、空间 O(1)。整个过程逻辑非常清楚,即使有小 bug,面试官也更愿意帮你纠正。

第三条,不会的题不要硬编。可以直接说“这部分我了解得不深,但根据我的理解,大概和 XXX 有关,我会先查什么,再确认什么”。这种回答方式比沉默或胡扯好得多。C 语言面试不是表演记忆力,是看你在面对不确定问题时,能否给出可以继续深入的方向。

这几年面试下来,我最大的体会是:能把基础题答得稳、答得慢、答得有层次的人,往往在工程里也是最靠谱的。C 语言面试其实考验的是“你对一台机器到底有多诚实”——指针、内存、字节序这些概念不来半点虚的。你愿意踏实理解它们,它们就不会在关键时候背叛你。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询