前置知识: C

C 语言理论知识点

6 minIntermediate

编译流程、内存模型、链接与加载等核心理论知识。

源文件(.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) #xSTR(hello) 展开为 "hello"
  • ## 拼接两个 token:#define CONCAT(a, b) a##bCONCAT(var, 1) 展开为 var1

编译(Compilation)

编译器将预处理后的 C 代码翻译为汇编代码。这是最复杂的阶段,包含词法分析、语法分析、语义分析、中间代码生成和优化。

预处理文件(.i) --> 编译器(cc1) --> 汇编文件(.s)

编译阶段的关键步骤:

  1. 词法分析 — 将源代码字符流分解为 token(关键字、标识符、常量、运算符、分隔符)
  2. 语法分析 — 根据 C 语言文法构建抽象语法树(AST),检查语法错误
  3. 语义分析型检查、作用域解析、左值/右值判定
  4. 中间代码生成 — 生成平台无关的中间表示(如 GCC 的 GIMPLE、LLVM 的 IR)
  5. 优化 — 常量折叠、死代码消除、循环优化、内联等
  6. 目标代码生成 — 生成特定架构的汇编代码

编译器优化的常见型:

优化示例效果
常量折叠3 + 5 -> 8减少运行时计算
死代码消除删除不可达代码减小代码体积
循环展开复制循环体减少分支减少分支预测开销
函数内联将函数调用替换为函数体消除调用开销
公共子表达式消除复用已计算的结果减少重复计算

汇编(Assembly)

汇编器将汇编代码翻译为目标机器码,生成目标文件(.o 或 .obj)。

汇编文件(.s) --> 汇编器(as) --> 目标文件(.o)

目标文件包含:

  • 代码段(.text) — 机器指令
  • 数据段(.data) — 已初始化的全局/静态变量
  • BSS 段(.bss) — 未初始化的全局/静态变量(仅占位,不占文件空间)
  • 符号表 — 导出和引用的符号及其地址
  • 重定位表 — 需要链接器填充的地址引用

链接(Linking)

链接器将多个目标文件和库合并为最终的可执行文件,解析符号引用

目标文件(.o) + 库文件(.a/.so) --> 链接器(ld) --> 可执行文件

链接的两个核心任务:

  1. 符号解析 — 将每个符号引用绑定到唯一的符号定义。如果找不到定义,报 “undefined reference” 错误;如果多个定义,报 “multiple definition” 错误。
  2. 重定位 — 将逻辑地址映射为实际内存地址,修改代码中的地址引用

静态链接 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 的底层实现:

  1. 小块分配(< 128KB)— 从 brk/sbrk 管理的堆区分配
  2. 大块分配(>= 128KB)— 使用 mmap 直接映射内存区域
  3. 空闲块管理 — 使用空闲链表(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 的核心内容

  1. 函数调用约定(Calling Convention)

    • 参数传递方式:哪些参数通过寄存器传递,哪些通过栈传递
    • 返回值传递方式:整数/浮点/结构体返回值的传递规则
    • 栈帧布局:调用者与被调用者的职责划分
  2. 数据布局

    • 基本型的大小和对齐要求
    • 结构体的成员排列和填充规则
    • 位域的分配顺序
  3. 名称修饰(Name Mangling)

    • C 语言:名称不修饰(或仅加下划线前缀)
    • C++ 语言:编码参数型信息到符号名中

常见调用约定

架构约定整数参数寄存器浮点参数寄存器调用者保存被调用者保存
x86-64 (System V)System V AMD64rdi, rsi, rdx, rcx, r8, r9xmm0-xmm7rax, rcx, rdx, rsi, rdi, r8-r11rbx, rbp, r12-r15
x86-64 (Windows)Microsoft x64rcx, rdx, r8, r9xmm0-xmm3rax, rcx, rdx, r8-r11rbx, rbp, rdi, rsi, r12-r15
AArch64AAPCS64x0-x7v0-v7x0-x18x19-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 不会发生,并据此进行优化,导致实际运行结果与直觉不符。

常见的未定义行为

  1. 有符号整数溢出

    int x = INT_MAX;
    int y = x + 1;  // UB:有符号溢出

    编译器可能假设有符号整数不会溢出,从而删除溢出检查。

  2. 解引用空指针

    int *p = NULL;
    *p = 42;  // UB
  3. 使用未初始化的变量

    int x;
    printf("%d", x);  // UB:读取未初始化的自动变量
  4. 数组越界访问

    int arr[5];
    arr[10] = 0;  // UB
  5. 修改字符串字面量

    char *s = "hello";
    s[0] = 'H';  // UB:字符串字面量存储在只读区
  6. 同一对象多次无序列点修改

    int i = 0;
    i = i++ + 1;  // UB:i 在两个序列点之间被修改两次
  7. 数据竞争

    // 两个线程同时读写同一非原子变量,无同步

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 不支持非对齐访问)

对齐规则

  1. 每个型的对齐要求等于其大小(基本型)或最大成员的对齐要求(结构体)
  2. 结构体的总大小必须是最大对齐要求的整数倍
  3. 编译器在成员之间插入填充字节(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 做型双关
静态链接编译时合并库代码库更新需重新编译
动态链接运行时加载共享库需处理符号版本和兼容性