外观
源代码如何成为机器指令
编译器把一种程序表示转换成另一种,同时在语言允许的范围内保留可观察语义。理解各阶段的输入和输出,可以解释语法错误、类型错误、链接错误为何发生在不同时间,也能理解优化如何改变机器执行。
以一个表达式贯穿流程
c
int f(int x) {
return x * 2 + 3;
}词法分析把字符识别为关键字、标识符、字面量和标点。语法分析根据语法规则构造树,* 的优先级高于 +,所以树的根是加法,左边是乘法。词法合法不代表语法合法,例如 return + ; 的 token 都可以识别,却不是合法表达式。
语义分析建立作用域和符号表,解析 x 绑定的是哪个声明,检查类型与控制流约束。词法作用域回答名字归属,运行时栈帧回答本次调用的数据放在哪里,两者不是同一个概念。
flowchart LR A[字符] --> B[Token] B --> C[语法树 AST] C --> D[名字绑定与类型检查] D --> E[中间表示 IR] E --> F[优化与指令选择] F --> G[寄存器分配] G --> H[目标文件与链接]
查看流程图文本
flowchart LR A[字符] --> B[Token] B --> C[语法树 AST] C --> D[名字绑定与类型检查] D --> E[中间表示 IR] E --> F[优化与指令选择] F --> G[寄存器分配] G --> H[目标文件与链接]
中间表示让优化有明确对象
上例可以表示为:
text
t1 = mul x, 2
t2 = add t1, 3
return t2SSA 为每个值定义唯一名字,控制流汇合处通过 phi 一类机制表达来自不同前驱的值。它使“这个使用来自哪个定义”更清晰,便于常量传播、死代码消除等分析;但 SSA 值并不意味着每个变量在真实机器上都占独立寄存器。
优化必须遵守语义。例如整数乘 2 是否可换移位,需要考虑语言类型、位宽和溢出规则。C 的有符号溢出是未定义行为,不能直接按模运算直觉推断所有优化。浮点加法不满足普通实数上的结合律,改变归约顺序会影响舍入;快速数学选项可能显式放宽这些限制。
后端如何使用有限硬件
指令选择把 IR 操作组合映射到目标指令集;寄存器分配根据值的活跃区间复用寄存器,寄存器不够时溢出到栈。调用约定规定参数、返回值以及哪些寄存器需要由调用者或被调用者保存,从而让独立编译的函数协作。
对同一个 C 函数,x86-64 和 RISC-V 的指令序列不同,但函数层语义可以相同。阅读汇编时先追数据依赖,再追具体助记符;一个源变量可能被消除、常量折叠或在多个位置重建。
编译、汇编、链接和装载
编译器生成汇编或目标码;汇编器处理指令编码、符号和重定位信息;链接器把多个目标文件及库连接起来,解析符号并完成重定位;装载器把可执行映像映射进进程空间,动态链接器还可能解析共享库符号。
“声明了函数但没提供定义”可能通过编译,却在链接时失败。动态库搜索或 ABI 不匹配可能在运行时才暴露。静态类型检查也不会捕捉所有内存越界,具体语言和工具能保证的范围不同。
解释器与 JIT
解释器逐步执行 AST 或字节码;JIT 在运行时把热点编译为机器代码,可以根据观测到的类型和分支优化,但必须在假设失效时退优化或回到通用路径。一次性编译的启动成本与长期执行收益需要权衡。
深度学习编译器处理张量图与算子:例如把逐元素操作融合,减少中间结果写回内存;把矩阵乘法分块映射到 GPU。它仍需保持数值和布局语义,还要考虑动态形状、设备内存及并行同步。这与 CSAPP 的局部性、CS149 的性能模型直接相连。
阅读时用这些问题检验理解
x 在哪个作用域声明?这一分支能否到达?这个值是否还会使用?两个指针是否可能别名?循环迭代之间是否有依赖?能回答这些问题,才能判断某个优化是否合法,而不是仅凭“更少指令”推测它更好。
自测:源代码里的局部变量为什么在调试器中显示 optimized out?
优化可能把它替换成常量、与其他值合并、留在暂存寄存器,或者完全消除。源语言变量与机器存储位置不是一一对应关系。调试构建可降低优化帮助观察,但性能分析仍要看实际发布构建。
来源:Stanford CS143、LLVM IR 参考、LLVM 入门教程。进一步读 CSAPP 机器代码与链接。