1.2 编译器四阶段:预处理编译汇编链接


1.2 编译器四阶段:预处理、编译、汇编、链接

本节摘要:本节深入流水线内部:预处理器只做文本替换却威力巨大;编译器在多个优化等级间权衡生成汇编;目标文件按"节"组织并携带符号表;链接器有静态与动态两种库的链接策略,直接决定程序体积与部署形态。理解这些,构建系统报的错将不再是天书。

预处理:不懂 C 语法的文本搬运工

预处理器眼里没有函数、类型、作用域,只有"文本行"。它的三板斧是宏替换、文件包含、条件选择。看一个容易翻车的宏:

#define SQUARE(x) x * x int r = SQUARE(a + 1); /* 展开成 a + 1 * a + 1 ,灾难 */

展开结果是 a + 1 * a + 1,乘法优先级把算式撕碎。预处理阶段不做任何运算,纯文本代入。写宏要养成括号习惯:

#define SQUARE(x) ((x) * (x))

gcc -E 可以直接看到展开结果,这是排查宏问题的第一手段。另外两条预处理常识:

  • #include <stdio.h>#include "util.h" 的区别只在搜索路径顺序:尖括号先搜系统目录,双引号先搜当前目录,本质都是"把整个文件内容抄到这里"。
  • 头文件里一般只放声明(函数原型、类型定义、宏),定义放在 .c 文件里。声明可以重复,定义不行——这是链接器定的规矩,不是编译器的。

编译:从语法树到汇编指令

编译器内部走完 词法分析 → 语法分析 → 语义检查 → 优化 → 指令选择 五步,输出汇编文本。对写程序的人来说,最值得理解的是优化等级

gcc -O0 -S opt.c # 不优化,编译快,适合调试 gcc -O2 -S opt.c # 发布级优化 gcc -O3 -S opt.c # 更激进,可能增大体积

同一个循环在 -O0-O2 下的汇编可能差好几倍:-O0 老老实实每行 C 对应几条指令,变量全部在内存读写;-O2 会把变量请进寄存器、展开小循环、删除公共子表达式、甚至重排指令以填满流水线。两件事必须知道:

  1. 调试要用 -O0。优化会重排和删除代码,断点行号会飘,单步会"跳来跳去"。
  2. 未初始化的局部变量在 -O2 下行为更诡异。例如经典的"返回未初始化变量"问题:-O0 时经常"碰巧"是对的(栈上残留值恰好可用),-O1 以上直接把这条路径判死,返回垃圾值。 Undefined behavior 不是"结果随机",是"编译器可任意处置"。

一个能亲眼看到优化威力的小实验:

volatile int sink; int sum_array(const int *a, int n) { int s = 0; for (int i = 0; i < n; i++) sink = a[i] + s, s += a[i]; return s; }

volatile 去掉后开 -O2,编译器可能把整个循环向量化成几条 SSE 指令;带着 volatile(告诉编译器"这个变量随时会被外部改变,别自作聪明"),循环就老老实实一条条执行。volatile 是写给优化器看的封条,不是线程同步工具——这是新手最常见的误用。

汇编与目标文件:按"节"组织的二进制

汇编器把 .s 翻译成机器码,装进目标文件的各个"节"(section):

$ size hello.o text data bss dec hex 160 0 0 160 a0
  • text:代码与只读数据(字符串字面量也在这里,所以它只读);
  • data:已初始化的全局变量与 static 变量;
  • bss:未初始化(或初始化为 0)的全局变量。文件里只记长度不存内容,加载时清零,这是 bss 存在的意义——省文件体积。

符号表用 nm 看。再补两类符号标记:D 表示 data 节的已定义符号,B 表示 bss 节的,T 表示 text 节的。做个实验:

int g_init = 42; /* D 符号 */ int g_zero; /* B 符号 */ static int g_hidden = 7; /* 小写 d,局部符号,链接器不可见 */

对应 nm 输出:g_initDg_zeroBg_hidden 标小写 d。大写表示全局符号(可供其他文件链接),小写表示文件私有。static 修饰全局变量时把符号压成私有——链接层面的封装。

链接:符号解析与两种库

链接器干两件事:符号解析(每张 U 欠条找到唯一定义)与重定位(把合并后的地址填回指令)。多个目标文件同名全局符号撞车,就报 multiple definition——解法要么改名字,要么用 static 收编为私有。

库的两种形态值得放在一起比:

维度 静态库 .a 动态库 .so / .dll
链接时机 构建时复制进可执行文件 运行时由加载器映射
文件体积 可执行文件变大 可执行文件小
内存占用 每个进程一份副本 多进程共享同一份
升级 必须重新链接 替换库文件即生效
部署风险 无依赖,单文件即可跑 目标机器必须有匹配版本的库

静态链接的链接器只从 .a 里挑出真正用到的目标模块;动态链接则只在可执行文件里记下"我需要某某库的某某符号",运行时由动态链接器(Linux 上的 ld.so,Windows 上的加载器)现场绑定。"缺失动态库"的运行期错误,就是这一步找不到库文件——用 ldd 命令可以列出可执行文件依赖的所有动态库。

⚠️ 常见坑:混用不兼容的 ABI。动态库升级后函数签名或结构体布局变了,旧的程序还按老布局访问,行为未定义。工程上说的"ABI 兼容"就是保证二进制层面的接口稳定,比源码兼容严格得多。

💡 关键直觉:链接器是"全程序级"的检查员,编译器只看单个文件。所有跨文件的问题——声明与定义不一致、缺库、重复定义、ABI 不匹配——都爆发在链接期或运行期。编译过了不代表没问题,只代表"这个文件自己没问题"。

本节要点回顾

  • 预处理器是纯文本搬运工:宏参数必须加括号;头文件只放声明,定义放 .c。
  • 优化等级决定"执行的代码":调试用 -O0,发布用 -O2;volatile 是给优化器的封条,不是同步工具。
  • 目标文件按节组织:text 只读、data 存已初始化全局、bss 只记长度加载清零;nm 的大小写符号区分全局与私有。
  • 链接器做符号解析与重定位:静态库按需复制,动态库运行时绑定;ldd 可查动态依赖。
  • 跨文件问题都在链接期之后爆发:编译通过只是第一关。

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U