【C 语言面试】高频考点深度解析
1. 说说 static 关键字的几个用处(基础必问)
static 是 C 语言中非常灵活的关键字,核心作用是 “改变生命周期” 或 “限制作用域”,具体有 3 个场景:
- 修饰局部变量:普通局部变量出作用域就销毁,static 修饰后,生命周期延长至整个程序结束(相当于 “全局生命周期,局部作用域”)。比如函数内的 static int a=0,每次调用函数 a 不会重置,会保留上次的值,但出了函数仍无法访问。
- 修饰全局变量:普通全局变量是 “外部链接”,能被其他.c 文件通过 extern 访问;static 修饰后变成 “内部链接”,只能在当前.c 文件可见,避免全局变量命名冲突。
- 修饰函数:和全局变量类似,static 修饰的函数只能在当前.c 文件中调用,其他文件无法通过声明访问,常用于封装内部工具函数,避免接口暴露。
面试技巧:回答时要区分 “生命周期” 和 “作用域”,这是面试官判断你是否理解透彻的关键。
2. 谈谈你对指针的理解(重中之重)
指针是 C 语言的灵魂,也是面试高频考点,回答要从 “本质 + 分类” 展开:
- 宏观本质:指针的本质是 “内存地址”—— 内存中每个字节都有唯一编号(地址),指针变量就是用来存储这个地址的变量。简单说:“指针 = 地址,指针变量 = 存地址的变量”。
- 常见分类:
- 一级指针:直接指向普通变量(如 int* p=&a),最常用;
- 二级指针:指向指针的指针(如 int** pp=&p),用于函数传参修改指针指向;
- 数组指针:指向数组的指针(如 int (*p)[5]),注意和指针数组区分;
- 函数指针:指向函数的指针(如 int (*p)(int,int)),可实现回调函数;
- 野指针:未初始化或指向非法内存的指针(如 int* p; 直接解引用),是常见 Bug 来源。
面试技巧:可以举一个简单例子(如用指针交换两个变量),证明你不仅懂概念,还会实际使用。
3. 源码、反码、补码的关系(基础理论)
计算机中所有数据都以补码存储,三者关系核心看 “正数 / 负数”:
- 正数:原码、反码、补码完全相同。比如 + 5(二进制 00000101),三者都是 00000101。
- 负数:
- 反码 = 原码的 “符号位不变,其余位按位取反”;
- 补码 = 反码 + 1。例:-5 的原码是 10000101,反码是 11111010,补码是 11111011。
面试延伸:为什么要用补码?—— 统一正负数的加减运算,避免单独处理减法(比如 a-b 等价于 a+(-b) 的补码)。
4. 什么是大小端?如何判断当前机器是大端还是小端?(腾讯一面真题)
大小端是数据在内存中的存储顺序,面试不仅要懂定义,还要会写判断代码:
(1)字面定义
- 大端存储:数据的 “高位” 存在内存的 “低地址”,低位存在内存的高地址(类似 “按顺序存储”)。比如 0x12345678,大端存储为:低地址→12 34 56 78→高地址。
- 小端存储:数据的 “低位” 存在内存的 “低地址”,高位存在内存的高地址(更符合计算机读取习惯)。比如 0x12345678,小端存储为:低地址→78 56 34 12→高地址。
5. 结构体内存对齐规则,以及为什么要有内存对齐?(高频考点)
结构体大小计算是面试 “送分题”,但容易踩坑,必须掌握规则和原理:
(1)内存对齐规则(5 点必记)
- 第一个成员的偏移量为 0(直接存在结构体起始地址);
- 其他成员要对齐到 “对齐数” 的整数倍地址;
- 对齐数 = min (编译器默认对齐数,成员变量大小)(VS 默认 8,GCC 默认 4);
- 结构体总大小 = 所有成员的 “最大对齐数” 的整数倍;
- 可通过
#pragma pack(n)修改默认对齐数(n 为 2、4、8 等,取消用#pragma pack())。
示例计算:
struct Test {char a; // 大小1,对齐数1,偏移0int b; // 大小4,对齐数4(min(8,4)),偏移4(0+1后补3字节)char c; // 大小1,对齐数1,偏移8(4+4)
};
// 最大对齐数是4,总大小=12(8+1后补3字节,凑4的倍数)
(2)为什么需要内存对齐?
- 平台兼容性:不是所有硬件都能访问任意地址的数据(比如某些 CPU 只能读取 4 的倍数地址),不对齐会直接报错;
- 性能优化:对齐后 CPU 只需 1 次内存访问就能读取数据,未对齐可能需要 2 次(比如读取 int 时跨了两个 4 字节块)。
6. 谈一谈位段、联合体、枚举的区别
三者都是 C 语言的 “自定义类型”,但用途和特性完全不同:
(1)位段(struct + 位宽)
- 定义:和结构体类似,但成员后加 “: 数字”,表示该成员占用的 “二进制位数”;
- 要求:成员必须是 int、unsigned int、signed int(char 也可,本质是 int);
- 用途:节省内存(比如存储状态标志,只需 1 位即可)。
struct Flag {unsigned int is_ok : 1; // 1位,0或1unsigned int type : 3; // 3位,0-7
};
(2)联合体(union)
- 定义:成员共享同一块内存空间,大小 = 最大成员的大小;
- 特性:修改一个成员会覆盖其他成员的值;
- 用途:判断大小端、节省内存(比如同一空间存储不同类型数据)。
(3)枚举(enum)
- 定义:用于表示 “有限个可选值”,{} 内是枚举常量;
- 特性:枚举常量默认从 0 开始递增(可手动赋值),本质是 int 类型;
- 用途:替代 #define,增强可读性和类型安全(比如表示状态码、选项)。
enum Status {SUCCESS, // 0ERROR, // 1PENDING // 2
};
7. 程序从编译到运行的整个过程(美团二面真题)
C 语言程序从源码到可执行文件,需经历 4 个阶段,要明确每个阶段的输入输出:
- 预处理阶段:处理
#include(头文件展开)、//和/* */(删注释)、#if(条件编译)、#define(宏替换),生成.i文件(仍为 C 语言代码); - 编译阶段:将 C 语言代码翻译为汇编语言,进行语法检查、优化,生成
.s文件(汇编代码); - 汇编阶段:将汇编代码转换为二进制指令,生成
.o文件(可重定位目标文件); - 链接阶段:将多个
.o文件 + 系统库(如 printf 所在的 libc 库)合并,解决符号引用(比如函数调用),生成可执行程序(如 Linux 下的 elf 文件)。
面试技巧:可以举例 “调用 printf”—— 编译时 printf 是未定义的符号,链接阶段会从 libc 库中找到 printf 的实现并关联。
8. extern 关键字的作用(腾讯一面真题)
extern 的核心作用是 “声明外部符号”,分两种场景:
- 声明外部变量:表示该变量在其他.c 文件中定义,提醒编译器去其他文件找定义(不能重复定义);
- 声明外部函数:表示该函数在其他文件中实现,常用于多文件编程(比如把函数声明放在.h 文件,实现放在.c 文件)。
示例:
// a.c(定义变量)
int g_val = 10;// b.c(声明并使用)
extern int g_val; // 声明,不是定义
printf("%d", g_val); // 正确,会去a.c找定义
注意:extern 不能初始化变量(如extern int g_val=20是错误的,变成了定义)。
9. volatile 关键字的作用(腾讯一面真题)
volatile 的核心是 “禁止编译器优化”,回答要讲清原理和场景:
- 作用:告诉编译器,修饰的变量可能被 “意外修改”(比如硬件中断、多线程),编译器不能将变量缓存到寄存器,每次访问必须从内存中读取;
- 常见场景:硬件寄存器操作、多线程共享变量、信号处理函数中的变量。
反例:没有 volatile 时,编译器可能优化代码:
// 没有volatile,编译器可能认为flag一直是0,死循环
int flag = 0;
// 中断服务函数可能修改flag为1
void interrupt_func() { flag = 1; }int main() {while (!flag); // 编译器优化后,可能一直读寄存器中的0return 0;
}// 加volatile后,每次都从内存读flag,能退出循环
volatile int flag = 0;
10. #define 和 const int 定义常量的区别?你更倾向于哪种?(小米一面真题)
这是 “预处理” 和 “编译” 阶段的核心区别,回答要分点清晰:
(1)核心区别
| 特性 | #define 宏 | const int 常量 |
|---|---|---|
| 处理阶段 | 预处理阶段(文本替换) | 编译 / 运行阶段 |
| 类型检查 | 无,仅文本替换 | 有,严格类型检查 |
| 调试 | 无法调试(替换后消失) | 可调试(真正的变量) |
| 内存占用 | 可能重复替换,占用更多内存 | 仅占一份内存 |
(2)更倾向于 const int
原因:const 有类型安全检查,能避免宏替换导致的隐藏 Bug(比如#define N 1+2,N*3会变成1+2*3=7,而非 9),且便于调试和维护。
例外场景:需要定义 “字符串常量” 或 “复杂表达式” 时,可用宏(如#define MAX(a,b) ((a)>(b)?(a):(b))),但要注意加括号避免优先级问题。
11. 指针常量和常量指针(滴滴一面真题)
这两个概念容易混淆,核心看 “const 修饰的是指针还是指向的内容”:
(1)常量指针(const int* p /int const* p)
- 口诀:“内容不可改,指向可改”;
- 解释:指针 p 指向的内容是常量,不能通过 p 修改,但 p 可以指向其他变量;
- 示例:
const int* p = &a;
// *p = 20; 错误,不能修改指向的内容
p = &b; // 正确,可以修改指向
(2)指针常量(int* const p)
- 口诀:“指向不可改,内容可改”;
- 解释:指针 p 的指向是常量,不能指向其他变量,但可以通过 p 修改指向的内容;
- 示例:
int* const p = &a;
// p = &b; 错误,不能修改指向
*p = 20; // 正确,可以修改内容
记忆技巧:const 靠近谁,谁就不能改 —— 靠近*是内容不可改,靠近 p 是指向不可改。
12. malloc、calloc 和 realloc 的区别
三者都是 C 语言动态内存分配函数,核心区别在 “初始化” 和 “用途”:
(1)malloc vs calloc
- 初始化:malloc 不初始化,分配的空间是随机值;calloc 会将空间初始化为 0;
- 传参:malloc 只传 “总字节数”(如
malloc(4*10));calloc 传 “元素个数” 和 “每个元素大小”(如calloc(10,4)); - 用途:需要初始化的场景用 calloc(如数组初始化),不需要则用 malloc(效率更高)。
(2)realloc 的特殊用途
- 作用:修改已分配内存的大小(扩大或缩小);
- 传参:第一个参数是已分配的指针,第二个参数是 “期望的总字节数”;
- 注意:扩大时可能会分配新地址(并拷贝原数据),缩小则直接截断,原数据保留。
示例:
int* p1 = malloc(4*5); // 20字节,随机值
int* p2 = calloc(5,4); // 20字节,全0
int* p3 = realloc(p1, 4*10); // 扩大到40字节
13. malloc 的原理(深度考点)
malloc 的实现和操作系统相关,但核心逻辑一致,回答要分 “小内存” 和 “大内存”:
- 小内存(<128KB):malloc 会维护一个 “空闲链表”,记录当前可用的内存块。申请时先遍历链表,找到足够大的块分配;若没有则调用
sbrk()系统调用,扩大堆空间后分配。 - 大内存(≥128KB):直接调用
mmap()系统调用,在文件映射区分配内存(无需通过空闲链表)。 - 虚拟内存特性:malloc 分配的是 “虚拟地址空间”,此时并未真正分配物理内存;只有当程序访问该内存时,操作系统才会触发 “缺页中断”,分配物理内存并建立虚拟内存和物理内存的映射。
- 内存分配粒度:操作系统按 “页” 分配物理内存(比如 4KB / 页),即使申请 1 字节,也会分配 1 页物理内存,但虚拟内存仍显示 1 字节。
14. 按行和按列遍历二维数组的性能差异?(快手二面真题)
核心原因是 “CPU 缓存机制”,直接影响访问效率:
- 按行遍历:C 语言二维数组的存储是 “行优先”(比如 int a [3][4],存储顺序是 a [0][0]→a [0][1]→...→a [0][3]→a [1][0]),内存地址连续。CPU 会预读连续内存到缓存,后续访问直接命中缓存,效率高。
- 按列遍历:访问的内存地址不连续(比如 a [0][0]→a [1][0]→a [2][0]),每次访问的地址跨度大,缓存无法命中,需要频繁从内存读取数据,效率低。
示例:
int a[1000][1000];
// 按行遍历(高效)
for (int i=0; i<1000; i++)for (int j=0; j<1000; j++)a[i][j] = 0;// 按列遍历(低效)
for (int j=0; j<1000; j++)for (int i=0; i<1000; i++)a[i][j] = 0;
面试延伸:可以提 “空间局部性”——CPU 缓存倾向于缓存连续的内存区域,按行遍历正好符合这一特性。
总结:C 语言面试备考技巧
- 抓核心:static、指针、内存对齐、编译过程是必考点,必须吃透原理 + 实战;
- 记口诀:比如指针常量和常量指针的 “const 靠近谁谁不可改”,方便快速回忆;
- 写代码:面试时可能让你手写判断大小端、结构体大小计算、malloc 使用等代码,平时要多练;
- 联场景:比如 volatile 关联 “硬件中断”,extern 关联 “多文件编程”,结合场景记忆更深刻。
