1. C语言面试题到底在考什么
1.1 从一道“简单题”说起
先看一道我面过不下五十个候选人的题:
#include <stdio.h> void func(void) { char str[] = "hello"; char *p = "hello"; printf("%zu %zu\n", sizeof(str), sizeof(p)); }能一口报出答案的人不少,但能说清楚“为什么”的人,十个里大概只有三个。这道题表面在考sizeof,实际在考三件事:数组名在sizeof下的特殊性、字符串字面量的存储位置、以及指针和数组的本质区别。面试官想知道的不是你背没背过答案,而是你有没有真正理解C语言的内存模型。
这就是C语言面试题的核心特征——题目短小,考点密集,追问空间极大。一道三行的代码,可以顺着问到内存布局、编译链接、未定义行为、编译器优化,甚至延伸到嵌入式场景下的栈空间分配。所以准备C语言面试,死记硬背是最低效的路径,真正有效的是把每个知识点背后的“为什么”打通。
1.2 高频考点的分布规律
我把近几年接触到的C语言面试题做了一个粗略统计,考点分布大致是这样的:
| 考点类别 | 出现频率 | 典型关键词 |
|---|---|---|
| 关键字语义 | 极高 | static、const、volatile、extern |
| 内存与指针 | 极高 | 指针运算、数组退化、内存对齐 |
| sizeof与类型 | 高 | sizeof、strlen、类型转换 |
| 预处理与编译 | 中高 | 宏定义、头文件、条件编译 |
| 位运算 | 中 | 掩码、移位、大小端 |
| 数据结构手写 | 中 | 链表、字符串操作、排序 |
| 未定义行为 | 中 | 越界、野指针、序列点 |
这张表不是让你按频率去押题,而是帮你建立一个认知:C语言面试的考点是收敛的。它不像前端面试那样每年都有新框架、新工具,C语言的核心考点二十年没怎么变过。变的只是提问的角度和深度。
1.3 不同岗位的考察侧重
同样是考C语言,岗位不同,侧重点差异很大。我整理了一个对照表:
| 岗位方向 | 考察重点 | 典型追问 |
|---|---|---|
| 嵌入式开发 | 位操作、volatile、内存布局、栈空间 | “单片机C语言没有堆栈吗”这类问题 |
| 系统/内核开发 | 指针、内存管理、并发、ABI | 虚拟存储器管理、页表相关 |
| 应用层开发 | 字符串处理、数据结构、算法 | 链表操作、字符串逆序 |
| 音视频/图形 | 内存对齐、类型转换、性能 | 结构体对齐、SIMD相关 |
| 通用后端 | 基础语法、常见陷阱 | static/const语义、sizeof |
所以你在准备的时候,先搞清楚目标岗位的方向,再决定把精力往哪里倾斜。嵌入式岗位不会深究虚拟存储器,后端岗位也不太会问你寄存器操作。
2. static、const、sizeof三大金刚逐个拆解
2.1 static关键字的三种面貌
static是C语言面试里出现频率最高的关键字之一,没有悬念。它的核心难点在于:同一个关键字,在不同位置有完全不同的语义。
第一种:修饰局部变量
void counter(void) { static int count = 0; count++; printf("%d\n", count); }每次调用counter,count都保留上一次的值。原因在于static局部变量存储在静态存储区(通常是数据段),而不是栈上。它的生命周期贯穿整个程序运行期,但作用域仍然局限在函数内部。
这里有个常见的追问:static局部变量什么时候初始化?答案是程序启动时、main函数执行之前,而且只初始化一次。如果你写static int count = 0;,编译器实际上把它放在了.data段(已初始化)或.bss段(未初始化或初始化为0)。
第二种:修饰全局变量和函数
// file1.c static int internal_var = 42; static void internal_func(void) { /* ... */ }这时候static的含义变成了内部链接(internal linkage)。也就是说,internal_var和internal_func只在当前编译单元可见,其他.c文件即使写了extern声明也链接不到。
这个特性在实际工程中非常有用——它实现了编译单元级别的封装。C语言没有private关键字,static就是最接近的东西。我在做嵌入式项目时,几乎所有的驱动内部函数都会加static,避免符号污染和命名冲突。
第三种:修饰函数参数(C99起)
void process(int arr[static 10]) { // 编译器可以假设arr至少有10个元素 }这个用法比较冷门,但面试时偶尔会被问到。static在这里是给编译器的优化提示,表示数组至少有这么多个元素。实际项目中用得不多,但知道它能体现你对标准的熟悉程度。
注意:
static修饰局部变量时改变的是生命周期,修饰全局变量/函数时改变的是链接属性。这两个语义完全不同,面试时一定要分清楚说。
2.2 const的“只读”到底是什么意思
const是另一个高频考点,而且是最容易被误解的关键字。很多人把它简单理解为“常量”,但它的准确含义是只读(read-only),不是常量表达式(constant expression)。
const int n = 10; int arr[n]; // C99之前这是错误的,因为n不是常量表达式在C语言中,const变量仍然是变量,只是你不能通过它来修改值。它和#define的区别在于:#define是预处理阶段的文本替换,const是编译阶段的类型检查。
const和指针的组合是面试的重灾区,四种写法必须分清楚:
| 写法 | 含义 | 能否修改指针 | 能否修改指向的值 |
|---|---|---|---|
const int *p | 指向常量的指针 | 可以 | 不可以 |
int const *p | 同上 | 可以 | 不可以 |
int * const p | 常量指针 | 不可以 | 可以 |
const int * const p | 两者都不可变 | 不可以 | 不可以 |
记忆技巧:从右往左读。const int *p读作“p是一个指针,指向const int”;int * const p读作“p是一个const指针,指向int”。
const在函数参数中的作用也很重要:
void print_string(const char *str) { // 保证函数内部不会修改str指向的内容 }这不仅是给自己看的约束,也是给调用者的承诺。在团队协作中,凡是只读的指针参数都应该加const,这是一个好习惯。
实操心得:
const修饰的变量在C语言中默认是外部链接的(和C++不同),如果写在头文件里被多个源文件包含,会导致重复定义。正确做法是配合static使用,或者只在.c文件中定义。
2.3 sizeof的坑比你想象的多
sizeof是C语言面试的“送命题”——看起来简单,但几乎每道题都有坑。
坑一:sizeof是运算符,不是函数
int a = 10; printf("%zu\n", sizeof(a)); // 4(假设32位int) printf("%zu\n", sizeof a); // 4,可以不加括号 printf("%zu\n", sizeof(int)); // 4,类型必须加括号sizeof在编译期求值(除了变长数组),所以sizeof(a++)不会导致a自增。这一点经常被用来出题。
坑二:数组名在sizeof下的特殊性
int arr[10]; int *p = arr; printf("%zu\n", sizeof(arr)); // 40 printf("%zu\n", sizeof(p)); // 8(64位系统)数组名在大多数表达式中会“退化”为指向首元素的指针,但在sizeof、&、以及字符串字面量初始化数组时不会退化。这是数组和指针最本质的区别之一。
坑三:sizeof和strlen的区别
char str[] = "hello"; printf("%zu\n", sizeof(str)); // 6,包含'\0' printf("%zu\n", strlen(str)); // 5,不包含'\0'sizeof是编译期计算的内存占用,strlen是运行期遍历到\0为止。两者完全不同,但经常被放在一起考。
坑四:结构体的sizeof和对齐
struct A { char c; int i; char d; }; printf("%zu\n", sizeof(struct A)); // 12,不是6这是因为内存对齐。int需要4字节对齐,所以c后面填充3字节,d后面再填充3字节。对齐规则是:每个成员的偏移量必须是其自身大小的整数倍,结构体总大小必须是最大成员大小的整数倍。
| 成员 | 大小 | 偏移量 | 填充 |
|---|---|---|---|
| c | 1 | 0 | 3字节 |
| i | 4 | 4 | 0 |
| d | 1 | 8 | 3字节 |
| 总计 | 12字节 |
如果调整成员顺序为int i; char c; char d;,大小就变成8字节。这就是为什么在嵌入式开发中,结构体成员的排列顺序会影响内存占用。
提示:
sizeof返回的是size_t类型,用%zu打印。用%d在64位系统上可能出问题,这是一个容易被忽略的细节。
3. 指针与内存:面试的真正分水岭
3.1 指针运算的本质
指针运算是C语言的精髓,也是面试中区分“会用C”和“懂C”的分水岭。
int arr[5] = {1, 2, 3, 4, 5}; int *p = arr; printf("%d\n", *(p + 2)); // 3 printf("%p\n", (void*)p); // 假设是0x1000 printf("%p\n", (void*)(p + 2)); // 0x1008,不是0x1002p + 2不是地址加2,而是加2 * sizeof(int)。指针的加减运算以指向类型的大小为单位。这个规则看起来简单,但在多级指针和类型转换的场景下就容易出错。
int arr[3][4]; int (*p)[4] = arr; printf("%p\n", (void*)(p + 1)); // 跳过一整行,16字节二维数组的指针运算更容易混淆。arr退化为指向第一行的指针,类型是int(*)[4],所以p + 1跳过4个int。
3.2 野指针、悬空指针和内存泄漏
这三兄弟是C语言面试的常客,也是实际项目中最容易出bug的地方。
野指针:未初始化的指针。
int *p; // 野指针,指向随机地址 *p = 10; // 未定义行为悬空指针:指向已释放内存的指针。
int *p = malloc(sizeof(int)); free(p); *p = 10; // 悬空指针,未定义行为 p = NULL; // 正确做法:释放后置空内存泄漏:分配了内存但忘记释放。
void leak(void) { int *p = malloc(100 * sizeof(int)); // 忘记free(p) return; }排查内存泄漏的常用手段:Valgrind(Linux)、AddressSanitizer(跨平台)、以及自己封装malloc/free做计数。在嵌入式环境中没有这些工具,通常靠代码审查和静态分析工具。
实操心得:我个人的习惯是,
free之后立刻把指针置为NULL。这样即使后面误用了,也是解引用空指针(通常会崩溃,容易定位),而不是操作一块已经不属于你的内存(可能悄无声息地破坏数据)。
3.3 内存布局:从栈到堆
一个典型的C程序内存布局是这样的:
| 区域 | 存储内容 | 生长方向 | 生命周期 |
|---|---|---|---|
| 代码段(.text) | 机器指令 | - | 程序运行期 |
| 只读数据段(.rodata) | 字符串字面量、const全局 | - | 程序运行期 |
| 已初始化数据段(.data) | 初始化的全局/静态变量 | - | 程序运行期 |
| 未初始化数据段(.bss) | 未初始化的全局/静态变量 | - | 程序运行期 |
| 堆(heap) | malloc分配的内存 | 向上增长 | 手动管理 |
| 栈(stack) | 局部变量、函数参数 | 向下增长 | 函数调用期 |
面试中经常问的一个问题是:“单片机C语言没有堆栈吗?”这个问题其实问的是嵌入式环境下栈和堆的配置。单片机当然有栈,只是栈的大小通常在启动文件或链接脚本中固定配置,而且很多嵌入式项目会刻意避免使用堆(因为动态内存分配在资源受限环境下容易导致碎片化)。
// 典型的嵌入式启动文件中的栈配置 Stack_Size EQU 0x00000400 // 1KB栈空间在资源紧张的MCU上,栈溢出是一个常见问题。递归调用、大型局部数组、深层函数嵌套都可能导致栈溢出。我踩过的坑是:在STM32上定义了一个char buffer[2048]的局部数组,结果直接hard fault,因为默认栈只有1KB。
4. 预处理、编译与那些“看不见”的坑
4.1 宏定义的陷阱
宏定义是C语言面试中另一个高频考点,因为它的“文本替换”本质导致了很多反直觉的行为。
#define SQUARE(x) x * x int a = SQUARE(2 + 3); // 展开为 2 + 3 * 2 + 3 = 11,不是25正确写法是给参数和整体都加括号:
#define SQUARE(x) ((x) * (x))但即使这样,也有副作用问题:
#define MAX(a, b) ((a) > (b) ? (a) : (b)) int i = 5; int m = MAX(i++, 3); // i++被执行了两次所以现代C语言中,能用static inline函数的地方尽量不用宏。宏的优势在于:类型无关、可用于条件编译、可以生成代码片段。
#和##运算符也是常考点:
#define STR(x) #x #define CONCAT(a, b) a##b printf("%s\n", STR(hello)); // "hello" int CONCAT(var, 1) = 10; // 等价于 int var1 = 10;4.2 头文件与链接
头文件相关的问题在面试中也很常见,尤其是static和const在头文件中的行为。
// header.h const int MAX_SIZE = 100; // 每个包含该头文件的.c都会有一个副本 static int count = 0; // 同上,每个编译单元独立正确做法是在头文件中用extern声明,在.c文件中定义:
// header.h extern const int MAX_SIZE; // source.c const int MAX_SIZE = 100;或者对于简单的常量,直接用#define或enum。
头文件保护是另一个基础但重要的话题:
#ifndef HEADER_H #define HEADER_H // 内容 #endif现代编译器也支持#pragma once,但它不是标准的一部分。在跨平台项目中,我通常还是用传统的#ifndef方式。
4.3 未定义行为:面试官最爱追问的深水区
未定义行为(Undefined Behavior,UB)是C语言面试中区分度最高的考点之一。面试官通过UB问题来判断你是否真正理解C语言的内存模型和编译器行为。
常见的UB包括:
- 数组越界访问
- 解引用空指针或野指针
- 有符号整数溢出
- 序列点之间的多次修改(如
i = i++) - 使用已释放的内存
- 类型双关(type punning)违反严格别名规则
int i = 0; i = i++; // UB,i的值不确定int arr[5]; arr[5] = 10; // UB,越界写入面试中如果被问到UB,不要只说“这是未定义行为”,要能解释为什么标准要把它定义为UB——因为不同的硬件平台和编译器优化策略可能导致不同的结果,标准无法统一规定。
提示:在实际项目中,开启编译器的警告选项(
-Wall -Wextra -Werror)和运行时检测工具(AddressSanitizer、UBSan)可以帮你捕获大部分UB问题。
5. 手写代码题:链表、字符串与排序
5.1 单链表操作
链表是C语言面试中最常见的手写代码题,没有之一。核心操作包括:创建、插入、删除、反转、查找中间节点、判断是否有环。
反转链表是出现频率最高的题目:
struct Node { int data; struct Node *next; }; struct Node* reverse(struct Node *head) { struct Node *prev = NULL; struct Node *curr = head; while (curr != NULL) { struct Node *next = curr->next; curr->next = prev; prev = curr; curr = next; } return prev; }这段代码的关键在于三个指针的配合:prev记录已反转部分的头,curr是当前处理的节点,next暂存下一个节点。写的时候一定要画图,否则很容易指针丢失。
判断链表有环用快慢指针:
int has_cycle(struct Node *head) { struct Node *slow = head; struct Node *fast = head; while (fast != NULL && fast->next != NULL) { slow = slow->next; fast = fast->next->next; if (slow == fast) return 1; } return 0; }快指针每次走两步,慢指针每次走一步。如果有环,快指针一定会在环内追上慢指针。这个算法的时间复杂度是O(n),空间复杂度是O(1)。
5.2 字符串处理
字符串逆序是另一个高频题:
void reverse_string(char *str) { if (str == NULL) return; int len = strlen(str); for (int i = 0; i < len / 2; i++) { char tmp = str[i]; str[i] = str[len - 1 - i]; str[len - 1 - i] = tmp; } }注意边界条件:空指针、空字符串、单字符字符串。面试时先把这些边界条件说清楚,再写代码,会加分不少。
字符串拷贝也是常考的,重点在于处理重叠内存:
void *my_memcpy(void *dst, const void *src, size_t n) { char *d = dst; const char *s = src; while (n--) *d++ = *s++; return dst; } void *my_memmove(void *dst, const void *src, size_t n) { char *d = dst; const char *s = src; if (d < s) { while (n--) *d++ = *s++; } else { d += n; s += n; while (n--) *--d = *--s; } return dst; }memcpy不处理重叠,memmove处理重叠。面试时如果被要求实现memcpy,主动提一句重叠问题,会显得你考虑周全。
5.3 排序算法
冒泡排序是C语言入门必学,也是面试中偶尔会考的基础题:
void bubble_sort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int swapped = 0; for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; swapped = 1; } } if (!swapped) break; // 优化:已经有序 } }加上swapped标志是一个小优化,最好情况下时间复杂度从O(n²)降到O(n)。面试时写出这个优化,能体现你有实际工程思维。
快速排序的手写版本也经常被要求:
int partition(int arr[], int low, int high) { int pivot = arr[high]; int i = low - 1; for (int j = low; j < high; j++) { if (arr[j] <= pivot) { i++; int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } } int tmp = arr[i + 1]; arr[i + 1] = arr[high]; arr[high] = tmp; return i + 1; } void quick_sort(int arr[], int low, int high) { if (low < high) { int pi = partition(arr, low, high); quick_sort(arr, low, pi - 1); quick_sort(arr, pi + 1, high); } }6. 常见问题速查与避坑指南
6.1 面试高频问题速查表
| 问题 | 核心考点 | 一句话答案 |
|---|---|---|
| static局部变量的生命周期 | 存储位置 | 静态存储区,程序运行期 |
| const和define的区别 | 编译阶段 | const有类型检查,define是文本替换 |
| sizeof数组和指针 | 数组退化 | 数组名在sizeof下不退化 |
| 指针加法的单位 | 类型大小 | 以指向类型的大小为单位 |
| 结构体对齐规则 | 内存对齐 | 偏移量是成员大小整数倍 |
| 野指针和悬空指针 | 内存管理 | 未初始化 vs 已释放 |
| 宏定义的副作用 | 文本替换 | 参数可能被多次求值 |
| 未定义行为的例子 | 语言标准 | 越界、空指针解引用、有符号溢出 |
6.2 我踩过的坑
坑一:sizeof返回size_t,用%d打印在64位系统上会出错。这个坑我在早期项目中踩过,在32位系统上测试没问题,换到64位就打印出奇怪的数字。正确做法是用%zu。
坑二:const变量在头文件中定义导致重复符号。在C语言中,const全局变量默认是外部链接的,如果头文件被多个.c包含,链接时会报重复定义。解决方案是加static或者用extern声明。
坑三:static局部变量在多线程环境下的初始化问题。C11之前,static局部变量的初始化不是线程安全的。虽然大多数编译器会生成线程安全的初始化代码,但标准并不保证。在多线程环境下,最好用显式的一次性初始化机制。
坑四:malloc(0)的返回值。标准规定malloc(0)的行为是实现定义的,可能返回NULL,也可能返回一个可以free的指针。不要依赖任何特定行为。
坑五:char是否有符号。C标准没有规定char默认是有符号还是无符号,这是实现定义的。在x86上通常是有符号的,在ARM上通常是无符号的。如果需要一个明确有符号的8位整数,用int8_t;需要无符号的,用uint8_t。
6.3 准备策略建议
最后说几点准备C语言面试的策略。第一,不要只刷题,要理解底层原理。每道题背后都对应一个知识点,把知识点打通了,题目怎么变都不怕。第二,动手写代码。看会了和写出来是两回事,链表反转、字符串处理这些题一定要在纸上或编辑器里手写几遍。第三,关注目标岗位的方向。嵌入式岗位多准备位操作和内存布局,后端岗位多准备数据结构和算法。第四,准备好追问。面试官问完static的作用后,很可能追问“static局部变量在什么阶段初始化”“多线程下安全吗”,这些延伸问题才是真正拉开差距的地方。
C语言这门语言,入门容易精通难。面试题只是表象,真正要建立的是对内存、编译、链接这些底层机制的理解。这个理解一旦建立起来,不光面试能用,实际工作中排查bug、优化性能都会受益。