C 语言理论知识点
编译流程、内存模型、链接与加载等核心理论知识。
源文件(.c) —> 预处理器(cpp) —> 预处理后的源文件(.i)
预处理器的核心工作:
- **#include** -- 将头文件内容直接插入到包含点。`<header>` 在系统路径搜索,`"header"` 在当前目录优先搜索。
- **#define** -- 宏定义展开。无参宏做文本替换,有参宏做参数替换。注意宏展开是纯文本操作,不做类型检查。
- **#ifdef/#ifndef/#endif** -- 条件编译,用于头文件防重复包含(include guard)和平台适配。
- **#pragma** -- 编译器特定指令,如 `#pragma once`(防重复包含)、`#pragma pack`(控制对齐)。
宏展开的陷阱:
```c
#define SQUARE(x) x * x
SQUARE(1 + 2) // 展开为 1 + 2 * 1 + 2 = 5,而非 9
#define SQUARE(x) ((x) * (x))
SQUARE(1 + 2) // 展开为 ((1 + 2) * (1 + 2)) = 9
# 和 ## 运算符:
#将宏参数转为字符串:#define STR(x) #x,STR(hello)展开为"hello"##拼接两个 token:#define CONCAT(a, b) a##b,CONCAT(var, 1)展开为var1
编译(Compilation)
编译器将预处理后的 C 代码翻译为汇编代码。这是最复杂的阶段,包含词法分析、语法分析、语义分析、中间代码生成和优化。
预处理文件(.i) --> 编译器(cc1) --> 汇编文件(.s)
编译阶段的关键步骤:
- 词法分析 — 将源代码字符流分解为 token(关键字、标识符、常量、运算符、分隔符)
- 语法分析 — 根据 C 语言文法构建抽象语法树(AST),检查语法错误
- 语义分析 — 类型检查、作用域解析、左值/右值判定
- 中间代码生成 — 生成平台无关的中间表示(如 GCC 的 GIMPLE、LLVM 的 IR)
- 优化 — 常量折叠、死代码消除、循环优化、内联等
- 目标代码生成 — 生成特定架构的汇编代码
编译器优化的常见类型:
| 优化类型 | 示例 | 效果 |
|---|---|---|
| 常量折叠 | 3 + 5 -> 8 | 减少运行时计算 |
| 死代码消除 | 删除不可达代码 | 减小代码体积 |
| 循环展开 | 复制循环体减少分支 | 减少分支预测开销 |
| 函数内联 | 将函数调用替换为函数体 | 消除调用开销 |
| 公共子表达式消除 | 复用已计算的结果 | 减少重复计算 |
汇编(Assembly)
汇编器将汇编代码翻译为目标机器码,生成目标文件(.o 或 .obj)。
汇编文件(.s) --> 汇编器(as) --> 目标文件(.o)
目标文件包含:
- 代码段(.text) — 机器指令
- 数据段(.data) — 已初始化的全局/静态变量
- BSS 段(.bss) — 未初始化的全局/静态变量(仅占位,不占文件空间)
- 符号表 — 导出和引用的符号及其地址
- 重定位表 — 需要链接器填充的地址引用
链接(Linking)
链接器将多个目标文件和库合并为最终的可执行文件,解析符号引用。
目标文件(.o) + 库文件(.a/.so) --> 链接器(ld) --> 可执行文件
链接的两个核心任务:
- 符号解析 — 将每个符号引用绑定到唯一的符号定义。如果找不到定义,报 “undefined reference” 错误;如果多个定义,报 “multiple definition” 错误。
- 重定位 — 将逻辑地址映射为实际内存地址,修改代码中的地址引用。
静态链接 vs 动态链接:
| 特性 | 静态链接 | 动态链接 |
|---|---|---|
| 链接时机 | 编译时 | 运行时 |
| 可执行文件大小 | 大(包含库代码) | 小(仅含引用) |
| 内存占用 | 每个进程一份库副本 | 多进程共享一份 |
| 更新库 | 需重新编译 | 替换 .so 即可 |
| 启动速度 | 快 | 稍慢(需加载 .so) |
内存模型
C 程序的内存布局从低地址到高地址依次为:
高地址
+------------------+
| 栈 (Stack) | <-- 局部变量、函数调用帧
| | |
| v | 栈向低地址增长
| |
| |
| ^ | 堆向高地址增长
| | |
| 堆 (Heap) | <-- malloc/free 管理的区域
+------------------+
| BSS 段 | <-- 未初始化的全局/静态变量(零初始化)
+------------------+
| 数据段 (Data) | <-- 已初始化的全局/静态变量
+------------------+
| 代码段 (Text) | <-- 机器指令(只读)
+------------------+
低地址
栈(Stack)
栈用于管理函数调用,遵循后进先出(LIFO)原则。每次函数调用创建一个栈帧(Stack Frame)。
栈帧包含:
- 返回地址(调用者的下一条指令地址)
- 保存的寄存器(被调用者保存的寄存器)
- 局部变量
- 参数(当参数超过寄存器传递数量时)
栈溢出的常见原因:
- 无限递归
- 过大的局部数组(如
int arr[10000000]) - 栈空间设置过小
堆(Heap)
堆由程序员通过 malloc/calloc/realloc/free 手动管理。堆内存的分配和释放由内存分配器(如 ptmalloc、tcmalloc、jemalloc)实现。
malloc 的底层实现:
- 小块分配(< 128KB)— 从 brk/sbrk 管理的堆区分配
- 大块分配(>= 128KB)— 使用 mmap 直接映射内存区域
- 空闲块管理 — 使用空闲链表(free list),首次适配/最佳适配/最差适配策略
常见堆错误:
- 内存泄漏:malloc 后未 free
- 重复释放:对同一指针 free 两次
- 使用已释放内存:free 后继续访问(use-after-free)
- 缓冲区溢出:写入超出分配大小
全局/静态存储区
- 数据段 — 已初始化的全局变量和静态变量,程序加载时由系统初始化
- BSS 段 — 未初始化的全局变量和静态变量,程序加载时由系统零初始化
int g_init = 42; // 数据段
int g_uninit; // BSS 段
static int s_init = 10; // 数据段
static int s_uninit; // BSS 段
void func() {
static int count = 0; // 数据段(只初始化一次)
int local = 5; // 栈
}
ABI(Application Binary Interface)
ABI 定义了编译后的机器码之间的接口规范,确保不同编译单元(甚至不同编译器)生成的目标文件可以正确链接和运行。
ABI 的核心内容
-
函数调用约定(Calling Convention)
- 参数传递方式:哪些参数通过寄存器传递,哪些通过栈传递
- 返回值传递方式:整数/浮点/结构体返回值的传递规则
- 栈帧布局:调用者与被调用者的职责划分
-
数据布局
- 基本类型的大小和对齐要求
- 结构体的成员排列和填充规则
- 位域的分配顺序
-
名称修饰(Name Mangling)
- C 语言:名称不修饰(或仅加下划线前缀)
- C++ 语言:编码参数类型信息到符号名中
常见调用约定
| 架构 | 约定 | 整数参数寄存器 | 浮点参数寄存器 | 调用者保存 | 被调用者保存 |
|---|---|---|---|---|---|
| x86-64 (System V) | System V AMD64 | rdi, rsi, rdx, rcx, r8, r9 | xmm0-xmm7 | rax, rcx, rdx, rsi, rdi, r8-r11 | rbx, rbp, r12-r15 |
| x86-64 (Windows) | Microsoft x64 | rcx, rdx, r8, r9 | xmm0-xmm3 | rax, rcx, rdx, r8-r11 | rbx, rbp, rdi, rsi, r12-r15 |
| AArch64 | AAPCS64 | x0-x7 | v0-v7 | x0-x18 | x19-x30 |
C 与 C++ 的 ABI 兼容性
C++ 的名称修饰导致 C++ 编译的库无法直接被 C 程序调用。使用 extern "C" 告诉 C++ 编译器按 C 的规则生成符号名:
#ifdef __cplusplus
extern "C" {
#endif
void c_api_function(int arg);
#ifdef __cplusplus
}
#endif
未定义行为(Undefined Behavior, UB)
未定义行为是 C 标准明确规定”不做任何保证”的程序行为。编译器可以假设 UB 不会发生,并据此进行优化,导致实际运行结果与直觉不符。
常见的未定义行为
-
有符号整数溢出
int x = INT_MAX; int y = x + 1; // UB:有符号溢出编译器可能假设有符号整数不会溢出,从而删除溢出检查。
-
解引用空指针
int *p = NULL; *p = 42; // UB -
使用未初始化的变量
int x; printf("%d", x); // UB:读取未初始化的自动变量 -
数组越界访问
int arr[5]; arr[10] = 0; // UB -
修改字符串字面量
char *s = "hello"; s[0] = 'H'; // UB:字符串字面量存储在只读区 -
同一对象多次无序列点修改
int i = 0; i = i++ + 1; // UB:i 在两个序列点之间被修改两次 -
数据竞争
// 两个线程同时读写同一非原子变量,无同步
UB 的危害
编译器基于”UB 不会发生”的假设进行优化,可能导致:
- 删除安全检查代码
- 改变程序控制流
- 产生看似无关的诡异 bug
经典案例:
int foo(int *p) {
int x = *p;
if (p == NULL) return -1; // 编译器可能删除此检查
return x;
}
编译器推理:如果 p 是 NULL,则 *p 是 UB,而 UB 不会发生,因此 p 不为 NULL,检查可以删除。
检测 UB 的工具
| 工具 | 检测范围 |
|---|---|
| UBSan (Undefined Behavior Sanitizer) | 有符号溢出、空指针、对齐、类型不匹配等 |
| ASan (Address Sanitizer) | 缓冲区溢出、use-after-free、double-free |
| MSan (Memory Sanitizer) | 未初始化内存读取 |
| TSan (Thread Sanitizer) | 数据竞争 |
| Valgrind | 内存泄漏、越界访问、未初始化读取 |
内存对齐(Memory Alignment)
为什么需要对齐
CPU 访问内存时,通常要求数据地址是其大小的整数倍。未对齐的访问可能导致:
- 性能下降(需要多次内存访问)
- 硬件异常(某些架构如 ARM、MIPS 不支持非对齐访问)
对齐规则
- 每个类型的对齐要求等于其大小(基本类型)或最大成员的对齐要求(结构体)
- 结构体的总大小必须是最大对齐要求的整数倍
- 编译器在成员之间插入填充字节(padding)以满足对齐要求
struct Example1 {
char a; // 1 字节,偏移 0
// 3 字节填充
int b; // 4 字节,偏移 4
char c; // 1 字节,偏移 8
// 3 字节填充
}; // 总大小:12 字节
struct Example2 {
int b; // 4 字节,偏移 0
char a; // 1 字节,偏移 4
char c; // 1 字节,偏移 5
// 2 字节填充
}; // 总大小:8 字节
通过调整成员顺序可以减少填充,节省内存。
手动控制对齐
#pragma pack(push, 1) // 设置 1 字节对齐
struct PackedStruct {
char a;
int b;
char c;
}; // 总大小:6 字节,无填充
#pragma pack(pop) // 恢复默认对齐
// C11 对齐指定符
struct AlignedStruct {
alignas(16) int x; // x 按 16 字节对齐
};
对齐与性能
缓存行(Cache Line)通常为 64 字节。如果频繁访问的数据跨越缓存行边界,会导致两次缓存加载。对齐到缓存行可以避免此问题:
alignas(64) int hot_data; // 独占一个缓存行
在多线程场景中,将不同线程频繁修改的变量放在不同缓存行,可以避免伪共享(False Sharing):
struct PaddedCounter {
alignas(64) long count;
};
struct PaddedCounter counters[N_THREADS]; // 每个 counter 独占缓存行
序列点(Sequence Point)
序列点是程序执行中的一个点,在此点之前的所有副作用都已求值完毕,之后的所有副作用尚未开始。C11 之后改称为”顺序先于”(sequenced before)关系。
C 中的序列点
| 位置 | 说明 |
| -------------------------- | ---------------------------- | ------------------ | -------------------------- |
| ; 分号 | 完整表达式结束 |
| && | | 的左操作数求值后 | 短路求值保证左操作数先完成 |
| ?: 的第一个操作数后 | 条件先于分支求值 |
| , 逗号运算符的左操作数后 | 左操作数先于右操作数 |
| 函数调用时 | 实参求值完成后、函数体执行前 |
缺少序列点的陷阱
a[i] = i++; // UB:i 的读取和修改之间无序列点
printf("%d %d", i++, i); // UB:参数求值顺序未定义
x = f() + g(); // f 和 g 的求值顺序未定义(但不是 UB)
严格别名规则(Strict Aliasing)
C 标准规定,不同类型的指针不能指向同一内存位置(少数例外除外)。违反严格别名规则是未定义行为。
允许的别名例外:
char *可以别名任何类型unsigned char *可以别名任何类型signed char *可以别名任何类型- 限定符变体(如
const int *可以别名int) - 相容类型的 signed/unsigned 变体
int x = 0x41424344;
float *fp = (float *)&x;
*fp = 3.14f; // UB:float* 不能别名 int
char *cp = (char *)&x;
cp[0] = 0; // 合法:char* 可以别名任何类型
类型双关(Type Punning)的正确做法:
// 方法1:使用 memcpy(编译器会优化掉)
float type_pun(int *ip) {
float f;
memcpy(&f, ip, sizeof(f));
return f;
}
// 方法2:使用联合体(C99 允许,C++ 中仍为 UB)
union { int i; float f; } u;
u.i = 0x41424344;
printf("%f", u.f);
理论速查表
| 概念 | 要点 | 常见陷阱 |
|---|---|---|
| 编译四阶段 | 预处理 -> 编译 -> 汇编 -> 链接 | 宏展开是纯文本替换 |
| 内存布局 | Text -> Data -> BSS -> Heap -> Stack | 栈向低地址增长,堆向高地址增长 |
| ABI | 调用约定 + 数据布局 + 名称修饰 | C/C++ 混编需 extern “C” |
| UB | 标准不做任何保证 | 编译器假设 UB 不发生,可能删除检查 |
| 对齐 | 每种类型有对齐要求 | 调整成员顺序可减少填充 |
| 序列点 | 副作用完成的分界点 | 两个序列点间多次修改同一对象是 UB |
| 严格别名 | 不同类型指针不能指向同一内存 | 用 memcpy 或 union 做类型双关 |
| 静态链接 | 编译时合并库代码 | 库更新需重新编译 |
| 动态链接 | 运行时加载共享库 | 需处理符号版本和兼容性 |