编译之桥
你以为编译器是翻译器。翻译器至少忠实于原文。可这六层翻译,没有一层是忠实的——每一层都在丢,只是你不知道丢了什么。
意图之渊
编译殿的门楣上刻着六个字:"意不可直达。"你怀里揣着一张源意符——上面写着一段你亲手写的代码,是你的"意图"。殿中央悬着一台巨大的黑色法器,形如漏斗,六层。
「这叫编译器。你的意图在顶,机器在底。中间隔着六层翻译。」
「赌一局。你的意图过了这六层,还剩几分?我赌——剩不了多少。」
翻译无损预测挑战器
反直觉下注(六选一)→ 每层真容浮现 → 信息丢失清单全景拼合
详解 · K18-01 编译器的基本结构与功能概述
编译器的经典定义(Dragon Ch1):读入源语言的程序,翻译成等价的目标语言程序,并报告过程中发现的错误。六阶段流水线——词法→语法→语义→中间代码→优化→目标代码——前三个阶段合称前端(分析,面向源语言、与机器无关),后三个合称后端(综合,面向目标机);Java「一次编译、处处运行」正是靠前端统一到字节码、后端由各平台 JVM 实现完成的解耦,而 clang 前端 + LLVM 后端、GCC 的 GIMPLE/RTL 中间层,都是沿这条缝把编译器拆成可独立演化的两半。台上六层漏斗每层都有代价:词法丢注释空白、语法在歧义处替你选树、语义做隐式转换、IR 展平控制结构、优化重排浮点、代码生成溢出寄存器——S0 你押的那注(哪层无损)输掉的机制,就是这张分层代价表。与解释器对照:解释器逐句执行不生成目标文件(CPython、早期 BASIC),编译器整体翻译再运行;边界在模糊——JVM 字节码先编译后解释、V8 对热函数 JIT 再编译、GraalVM 的 AOT 又把 JIT 提前到构建期。为什么坚持多遍结构:单遍编译器(早期 Pascal)内存受限的产物,无法做全局优化——分阶段让每遍只做一件事,也让「代价已知」成为可能:你只有先切开层,才谈得上逐层丈量丢失。
词法阁 · 断词之刃
石径尽头是一面刻满字符的石壁,字符流如瀑布般向下淌。诺依曼老祖递给你一柄断词刃。
「把这串字符切成 Token。规则你自己定。」
「按空格切嘛。名字里都有'空格'了,它不会骗你的。」
断词刃 · Token切分实验台
预测三选一 → 手动切词(a+++b)→ 信息丢失清单
详解 · K18-02 词法分析(Lexical Analysis):从字符流到Token
词法分析器(Dragon Ch3)的任务:字符流→Token 流,同时剥离注释与空白、记录行列号供报错定位。Token 是二元组(种别码, 属性值):种别码区分关键字/标识符/常量/运算符/界符,属性值携带具体信息(标识符在符号表中的入口指针)。台上 a+++b 的两种切法实锤了「最长匹配 / 最大 munch」规则:C 标准(C99 6.4¶4)规定词法元素取可能的最长字符序列,a+++b 因此切成 a ++ + b 而非 a + ++ b——切分发生在语法分析之前,语法器永远看不到另一种切法。S1 亲手烧出的信息丢失清单:注释不进 Token 流、空白全丢、格式信息归零——这正是「编译器不看缩进」的机制根源,而 Python 反其道行之,把缩进升级为 INDENT/DEDENT Token,词法层不丢反用。词法为何独立成阶段:①Token 结构平坦,正则语言足够表达(K18-03);②一遍只做一件事,让编译器结构可控;③正则→DFA 有全自动构造算法,Lex/Flex 把这层完全机械化(K18-04)。反例也真实:C 里 typedef 名与普通标识符同形,分词要查符号表才能判定——词法与语义并非严格隔离,这是 C 编译器实现中最著名的脏角落,也提醒你:分层是工程纲领,不是物理定律。
文法阁 · 正则之限
石壁后是一座陈列阁,阁中悬浮着无数发光的符文模式——正则表达式。诺依曼老祖递给你一面透镜。
「你想让断词刃自动切词?先给它一套规则。正则表达式就是规则的语言。但——每种语言都有它说不出的东西。」
「正则万能嘛。写个模式匹配一切——你信吗?我赌你信。」
正则炼机 · NFA→DFA 子集构造 + 嵌套之墙
输入正则→炼NFA→子集构造转DFA→Lex生成→嵌套括号挑战
NFA · 非确定有限自动机(含 ε 转移)
DFA · 确定有限自动机
Lex/Flex · 自动生成词法分析器
嵌套之墙 · 正则的天花板
尝试用正则匹配任意深度嵌套括号 (a(b(c)d)e)
详解 · K18-03 正则表达式与有限自动机(DFA/NFA)
正则表达式的数学地位(Dragon Ch3):它与 NFA、DFA 三者等价。正则→NFA 走 Thompson 构造(Thompson 1968,CACM):每个基本正则一个小 NFA 片段,连接/并/星用 ε 转移缝合——台上标识符 [a-zA-Z_][a-zA-Z0-9_]* 造出的 q0..q6,首字符并集分叉、星号部分 ε 进环、ε 出环,正是教科书原样。NFA→DFA 走子集构造:DFA 的一个状态 = NFA 的一个状态集合(ε-闭包),台上 {q1,q2} 经 ε-闭包坍缩成 {q1,q2,q3,q4,q6}(含接受态——所以单字符标识符 x 也接受),再读字符到 {q4,q5,q6} 自环到底。代价账:子集构造最坏指数爆炸——n 状态 NFA 理论上可产生 2ⁿ 个 DFA 状态,但词法模式的工程实例很少触发;DFA 换来的线性性值这个价:匹配时间只与输入长度有关,与模式复杂度无关,这是词法扫描能扛百万行源码的底座。天花板由泵引理(pumping lemma)钉死:正则语言无法计数——匹配任意深度嵌套括号需要「记住已见过几个左括号」,有限状态机的状态有限,数到某个深度必然循环复用;嵌套之墙撞的就是这堵墙。这条线同时划出了词法与语法的分工:Token 结构平坦归正则,程序结构嵌套归 CFG——工具的边界,定义了阶段的分工。
详解 · K18-04 词法分析器生成工具:Lex/Flex
Lex(Lesk & Schmidt 1975,贝尔实验室 CSTR 39;Flex 是其开源重写)把词法分析器从手工代码变成声明式规格:写「正则模式 + 动作」对,工具自动完成 Thompson 构造+子集构造+DFA 表压缩,生成 yylex()。台上生成的那段就是 Flex 源码骨架:IDENTIFIER/NUMBER/运算符模式列 + return Token 动作列 + 空白/注释 skip。三个工程细节:①最长匹配在工具层实现——多条模式都命中时取最长者,等长时取声明序在前者,C 里 a+++b 的切法出处就在这里;②Flex 把全部模式合成一个 DFA(不是每模式一个),单遍扫描同时试所有模式,达到理论线性的实用形态;③yymore()/REJECT 等逃生舱提供规则之外的补丁接口——表达力不够时的最后手段。血亲谱系:Java 的 JFlex、Python 的 re(运行时把模式编译成内部自动机并缓存)、grep 系工具。生产率账:词法规格从「周级手写」缩到「小时级声明」——生成器模式的第一次胜利;代价是你必须用它的语言思考:正则之外的表达力,一寸都没有(嵌套之墙在 Flex 里同样撞得到),这正是下一层要交给语法分析器的东西。
语法殿 · 树之歧途
语法殿中央是一棵悬浮的光树——语法分析树,节点是 Token,枝干是产生式规则。诺依曼老祖递给你一卷文法。
「把 Token 流种进去,让它长成树。规则在这里——上下文无关文法。」
「树长出来就唯一了?你确定?」
语法树构建 · dangling-else 双树对比
表达式树生长 → 左递归消除 → dangling-else 双树歧义崩塌
dangling-else:if (a) if (b) x=1; else x=2;
详解 · K18-05 语法分析(Syntax Analysis):从Token到语法树
语法分析器(Dragon Ch4)以 Token 流为输入、语法树为输出,依据是上下文无关文法。语法树 vs AST:语法树保留全部非终结符节点(E→T→F→id 的单链全在),AST 折叠单链只留运算符与操作数——台上 S2 那棵树标了「单链已折叠」,就是两者的分界演示;现代编译器(clang/LLVM、rustc)从词法后直接构 AST,纯语法树只活在教科书与考卷里。分析的两条路线:自顶向下(从 E 开始按产生式展开,分叉处向前看 1 个 Token 决策——递归下降/LL(1))与自底向上(从 id 开始归约成 F、T、E——LR)。复杂度账:通用 CFG 分析 O(n³)(CYK/Earley),而 LL(1)/LR(1) 都是线性 O(n)——代价是只接受文法的一个可判定子集。工程现实:手写递归下降在 GCC/Clang/V8 里全面回归,赢在错误恢复可控、可为表达式优先级写直觉代码;生成器路线(Bison)在语法稳定的领域(SQL、配置语言)仍是标配。左递归是自顶向下的死穴:E→E+T 让递归下降无限展开——S2 左递归按钮的「栈溢出」警告是真的;标准消左递归变换 E→T E'、E'→+TE'|ε 能解,但树形随之改变——文法改写本身也是一次语义形状的变换,这层代价台上「树形也改了」那行字替你记下了。
详解 · K18-06 上下文无关文法(CFG)与二义性
CFG 四元组 G=(终结符集 V_T, 非终结符集 V_N, 产生式集 P, 开始符号 S);推导即从 S 反复应用产生式,最左/最右推导对应自顶向下/自底向上两个分析方向。二义性定义:存在一个句子拥有两棵不同语法树(等价地:两个不同的最左推导)。台上 dangling-else——if(a) if(b) x=1; else x=2; 的 else 挂外层还是内层,两棵树都合法。C 标准的裁决是「else 属于最近的尚未配对的 if」——不是语义推理,是标准文本里的约定条款;你写的缩进在词法层已被吞掉(S1 的丢失清单),编译器根本没见过。三条消歧路线:①改文法——引入 matched/unmatched 语句非终结符,把「配对 if」与「悬空 if」拆成两类产生式(Dragon Ch4.3 的正规做法);②优先级/结合性声明——给冲突双方声明优先级与结合性(Yacc 的 %left/%right/%nonassoc,同法可给 if/else 显式声明以消解悬空 else);③默认移进——Bison 对 shift/reduce 冲突默认移进,恰好等价于最近匹配。根本坏消息:二义性不可判定——不存在算法判定任意 CFG 是否二义(Chomsky 层次里的经典不可判定结果),所以工程只能「事前限制文法类」或「事后消解冲突」。顺带拆解台上表达式文法的形状密码:E→E+T|T、T→T*F|F 用分层产生式天然编码优先级(* 在更深的层先结合),用左递归编码左结合——文法的形状,就是语义的形状。
分析器工坊 · 上下之争
工坊里有两台法器:左台刻"自顶向下",右台刻"自底向上"。诺依曼老祖站在两台之间。
「树歧义是因为文法有二义性。换一种分析方法——让它从根往下长(LL),或者从叶往上拼(LR)。但每种方法都有它嚼不动的文法。」
「LL(1) 和 LR 都试一遍,总有一个能通吃?你赌哪个?我赌——都没有万能的。」
分析器工坊 · LL(1) vs LR + Yacc/Bison
LL(1) 预测分析表 → LR(0) 自动机 → SLR(1)/LALR(1) 冲突消解 → Yacc 生成
左台 · LL(1) 自顶向下
右台 · LR(0) 自底向上
Yacc/Bison · 自动生成 LALR(1) 分析器
详解 · K18-07 自顶向下分析:递归下降/LL(1)分析
递归下降:每个非终结符一个函数,函数体按产生式右部依次处理——E() 先调 T(),再视下一 Token 决定是否进入 E' 循环。它是手写编译器的默认选择(Clang 的 Parser、V8、rustc 都是),可读性与错误恢复能力无人能敌。LL(1) 的含义:从左到右扫描、最左推导、向前看 1 个符号;可行性由 FIRST/FOLLOW 集判定——FIRST(X)=X 能推出的首个终结符集合,FOLLOW(X)=X 之后可能紧跟的终结符集合;文法是 LL(1) 的当且仅当分叉处各候选的 FIRST 互不相交,且候选可推 ε 时其 FIRST 与 FOLLOW 也不相交。台上消左递归后的表达式文法恰好满足:E 行在 id/( 两列都填 E→TE',T' 在 +/)/$ 三列填 T'→ε、* 列填 T'→*FT'——预测分析表无多重定义格,这正是「每格至多一条」的机器可验证形态。两张死穴:左递归(FIRST 循环依赖,台上「LL(1) 冲突」按钮打出的就是这张牌)与公共前缀(if 语句两个分支都以 'if' 开头,需提左公因子 if-stmt→if ( E ) stmt matched-tail)。能力上限:LL(1) 是 LL(k) 家族最小的一个,LL(2) 多看一个符号可解部分冲突但表指数膨胀——工程上宁可改文法也不升 k。递归下降+回溯=朴素自顶向下,最坏指数时间;「永不回头」才是 LL 线性的前提,也是它与 LR 各领半壁的资本。
详解 · K18-08 自底向上分析:LR分析/LALR(1)/SLR(1)
LR 分析:从左到右扫描、构造最右推导的逆;核心数据结构是 LR 项(带 · 的产生式)与项集自动机。规范 LR(0) 构造:closure+goto 迭代到不动点——台上表达式文法(E→E+T|T; T→T*F|F; F→(E)|id)造出 I0..I11 共 12 个状态,Dragon Ch4 的经典数字。冲突两型:shift/reduce(I2 与 I9:T→T·*F 要移进 *,E→T·/E→E+T· 要归约)与 reduce/reduce。三档消解:①SLR(1)(DeRemer 1971):仅当下一 Token ∈ FOLLOW(产生式左部) 才归约——本 LR 文法 FOLLOW(E)={+,$,)},* ∉ FOLLOW(E),I2 冲突消解;②LALR(1):规范 LR(1) 项给每个项配前瞻符,同芯(相同 LR(0) 项目集)状态合并——状态数退回与 LR(0)/SLR(1) 相同(12 个),能力介于两者之间;③规范 LR(1):每个「项×前瞻符」独立成状态,最强(覆盖几乎所有无二义 CFG)但状态数可能剧烈膨胀。能力排序 LR(1)>LALR(1)>SLR(1)>LR(0),状态数同向增长——这是表达力与状态数的单向阶梯。LR 为什么赢:左递归天然友好(无需改文法,且左递归恰好对应左结合语义),实用语言文法几乎全部落在其射程内;Yacc/Bison、PostgreSQL 的 SQL 前端内核至今是 LALR(1) 变体。
详解 · K18-09 语法分析器生成工具:Yacc/Bison
Yacc(Yet Another Compiler-Compiler,Johnson 1975,贝尔实验室 CSTR 32;Bison 为 GNU 重写)与 Lex 配成经典工具链:lex 产 yylex(),yacc 产 yyparse(),约定 yylex() 返回 Token 种别码、yylval 携带属性。Yacc 读入文法→构造 LALR(1) 自动机→冲突按声明裁决:%left/%right/%nonassoc 声明结合性与优先级(后声明者优先级高——%left '+' 写在 %left '*' 之前,即 * 优先);%prec 手动指定某产生式优先级;shift/reduce 冲突默认移进——「else 绑定最近 if」的出处就在这条默认规则。台上生成骨架里「默认移进 = else 绑定最近的 if」那行注释正是 dangling-else 的消解现场。工程地雷三枚:①冲突静默——Bison 默认只打 warning 照常生成,规范项目必须用 %expect 精确声明冲突数(GCC 的语法文件就这么干);②错误恢复——error 伪 Token 让分析器在语法错误后跳到同步点继续,这是编译器「一次报多个错」的机制;③路线分野——生成器赢在文法即文档、改动即重生成(PostgreSQL 至今用 Bison),手写递归下降赢在错误信息质量与可调试性(Clang),GLR 模式(并行探索所有冲突分支)则留给歧义文法与自然语言场景。「else 绑定最近 if」不是语义决定,是你在 Yacc 声明段里写出来的约定——这句台上顿悟的工程注脚。
语义渊 · 类型之审
语义殿里悬着一架天平——类型天平。殿壁上挂着一面巨大的符号表,密密麻麻记着每个名字的类型与作用域。
「语法树只管结构对不对。'int 加 float 等于什么'——这归语义管。」
「类型检查抓错,天经地义——它怎么会改你的程序?」
类型天平 · 符号表作用域
类型检查(int+float隐式转换/char截断)→ 作用域遮蔽查询 → 栈式符号表
符号表 · 栈式作用域
详解 · K18-10 语义分析(Semantic Analysis):类型检查/属性文法
语义分析是前端第一道"懂含义"的关口(Dragon Ch5-6):语法树只回答"结构是否合法",语义分析回答"这句话有没有意义"——类型是否匹配、变量是否声明、函数参数对不对得上。实现框架是语法制导翻译(SDT):给文法符号挂属性(综合属性向上聚合、继承属性向下传递),每个产生式配语义规则——属性文法让"检查"变成一遍可自动生成的树遍历。台上三张审计牌对应三类典型行为:int+float 触发隐式转换(C 的"常用算术转换"把 int 提升为 float,看似无害的精度提升);x=300 赋给 char 触发实现定义的截断——300 的二进制低 8 位是 00101100 即 44,台上那个 44 不是随手写的,是 300 mod 256 的精确值;块内 x 遮蔽外层 x 完全合法,语义分析不吭声。"只抓错不改义"为什么是假的:隐式转换在语法树里插入转换节点(int→float 的转换指令就来自这里),截断直接改变数值,遮蔽改变名字绑定——三者都是"你没写但编译器替你决定了"的行为。类型判定的核心算法是类型等价(结构等价 vs 名字等价)与类型相容(能否隐式转换);C 走宽松路线(大量隐式转换埋雷),Java/Rust 走严格路线(Java 要求显式窄化 cast,Rust 干脆没有隐式数值转换)——设计哲学的差异,在台上天平的三次称量里看得清清楚楚。
详解 · K18-15 符号表管理与作用域处理
符号表是语义分析的数据底座(Dragon Ch7):每个声明登记一条记录(名字、类型、作用域层级、存储类别、偏移量),每次引用去表里查——"变量是否声明过""类型是什么""是否被遮蔽"全靠它。作用域处理的标准实现是栈式符号表:进作用域压栈、退出弹栈,查询从栈顶向下找最近声明——台上画的三层(全局 x=1(int) → 函数 x=2(int) → 块 x=3(float))正是这个栈的横切面;查询按钮按下的瞬间,箭头从块作用域向上穿透两层命中最近一条——内层遮蔽外层不是 bug,是"最近声明优先"规则的直接后果。工程实现两种主流形态:①链式哈希桶(每层一张表串成链,插入 O(1)、查询遍历层数),Dragon 书的标准做法;②扁平表+版本号(所有名字一张表,靠代次区分),编译速度敏感的实现常用。符号表的生命周期横跨全编译器:语义阶段登记,代码生成阶段靠它算栈帧偏移,链接阶段的名字解析(K18-16)本质上是它从编译器内部走到目标文件符号表的全局化。语言特性的考验清单:C 的 static 限定符号可见性,C++ 的名字修饰(name mangling)把重载函数编码成唯一符号名;词法作用域 vs 动态作用域的分野(绑定到书写点还是调用点——早期 Lisp 用动态作用域,绝大多数现代语言退回词法作用域,因为它可静态判定)。S1 词法层丢掉缩进、这里丢掉"你以为的 x",两层丢失叠加,就是你必须在心里维护一张自己的符号表的原因。
中码塔 · 三地址之桥
中间码殿里有一座桥——桥这头是语法树,桥那头是机器码。诺依曼老祖指着桥面。
「树太高级,机器太低级。中间需要一座桥——中间代码。但过桥要交费——你的 if-else、for 循环,全要被展平成跳转和赋值。」
三地址码生成 · SSA变换
语法树逐节点翻译为三地址码 → SSA变量版本化 → phi函数插入 → CFG构建
SSA变换 · 变量版本化
详解 · K18-11 中间代码生成:三地址码/SSA形式
中间表示(IR)是编译器的"通用语"(Dragon Ch7-8):前端面对上百种源语言、后端面对几十种目标机,n×m 的组合爆炸靠 IR 摊平成 n+m——LLVM 能同时服务 C/C++/Swift/Rust 前端和 x86/ARM/RISC-V 后端,靠的就是这层约定。三地址码(TAC)是最经典的低级 IR:每条指令至多一个运算,x = y op z——名字来自"至多三个地址"。台上 if (a>b) x=a; else x=b; 展平出的六行正是标准产物:t1 = a>b(比较进临时变量)、ifz t1 goto L1(条件为零跳转,即假分支)、t2 = a / goto L2 / L1: t2 = b(两条路径各写一次 t2)、L2: x = t2(汇合后落地)。注意丢了什么:if-else 的"结构"没了,只剩标签与跳转——这就是展平的代价。SSA(静态单赋值,Cytron et al. 1991, TOPLAS):每个变量恰好赋值一次,再赋值就开新版本——x 在 then 分支是 x_1、else 分支是 x_2,汇合点必须回答"用哪个",答案是 phi 函数:x_3 = phi(x_1, x_2),按实际执行路径选择。SSA 的收益账:use-def 链显式化(数据流分析从迭代求不动点变成一遍扫过),常量传播、死代码删除、公共子表达式消除在 SSA 形式下都变得锋利——GCC 4.0(2005)把整个优化器重写到 SSA 之上、LLVM IR 生而 SSA,是这份数学便利的产业背书。代价是 phi 的放置需要支配边界(dominance frontier)计算,且最终还要撤掉 phi(lowering 回 MOV 指令,coalescing pass 负责消除冗余 MOV)。台上"不插 phi 直接验证"按钮的报错不是演的:汇合点读 x、定义点有两个,非 SSA 形式下这是个需要迭代数据流分析才能回答的问题——phi 把这个运行时问题变成了编译期的一次显式声明。
优化坊 · 常量之蚀
优化殿中央是一台巨大的熔炉——优化器。炉壁上刻着"更快、更小、更好"。语义之渊盘在熔炉上,声音甜得发腻。
「优化只提速不改义,这不是明摆着吗?把代码丢进去。」
「先丢几段看起来无害的试试。别急。」
优化熔炉 · 常量折叠与死代码消除
投入代码片段 → 优化前后对比 → 看似无害的提速
详解 · K18-12 代码优化:常量折叠/死代码消除/公共子表达式消除
优化器的设计公理只有一条:不改变程序的可观察行为——相同输入产生相同输出与终态(C/C++ 标准的 as-if 规则)。台上三段"无害"优化是入门三件套。常量折叠(constant folding):2+3 在编译期算成 5——语义分析后在 AST/IR 上把常量子表达式折掉;细节在于它必须用目标语言的语义而非宿主机器的:浮点要按 IEEE 754 精确舍入来折,否则就违反 as-if——交叉编译器不许直接用宿主机 double 折目标机 float 表达式,原因在此。死代码消除(DCE):if(0){debug();} 控制流不可达,整段删除;现代实现靠 CFG 上的活跃性分析反向传播——"定义后从未被读"的赋值同样可删(dead-store elimination 常与之合并)。公共子表达式消除(CSE):a*b 算过一次、中间没改写 a 或 b,第二次直接复用结果;台上第三段"循环不变量提升"(loop-invariant code motion)正是 CSE 在循环语境的表亲——c=a*b 与循环变量无关,提到循环外只算一次,n 次循环的 n 次乘法降为 1 次。安全边界:三者都只在"证明无副作用"时才动——函数调用可能改写全局状态(不能提出循环)、除法可能除零陷入(不能随意重排)、指针别名可能让"没改写 a"的判定失效(C 的 restrict 关键字就是程序员给编译器的无别名承诺)。全局 CSE 需要值编号(value numbering)判定"计算等价":局部做哈希(LVN)、全局在支配树上做(GVN,GCC/LLVM 的 gvn pass 即此)。这三件套的共性是在 as-if 规则内提速,行为严格不变——S5 的"看似无害"是真的无害;真正的崩塌(浮点重排、UB 利用)在 S7,因为那里的优化器拿到的承诺越出了 as-if——正是这条安全边界的存否,划分了"无害提速"与"改义崩塌"。
编译流水线动画器
殿中央的六层漏斗重新亮起,这一次你看得更清楚了——每一层都是一个独立的变换阶段。
「你走过的每一层,拼起来就是完整的编译流水线。看一遍——源代码怎么一步步变成机器码。」
编译流水线动画器
逐阶段点击 → 每阶段输入/输出/变换展示 → 完整流水线串联
优化三旋钮实验台 · 意图之蚀
优化殿的熔炉火力全开。三只旋钮浮在炉口:优化级别、浮点重排、UB利用。诺依曼老祖退后一步,看着你。
「旋钮都给你了。-O3、-ffast-math,拉满它。更快就是更好,不是吗?」
优化三旋钮 · 可观察行为差异检测器
三旋钮(优化级别/浮点重排/UB利用)→ 优化熔炉前后对比 → 浮点重排崩塌
详解 · K18-13 指令级优化:循环展开/强度削减/内联
循环是优化的主战场——程序时间的大头集中在少数热点循环里(Amdahl 定律决定优化收益的分布),三件武器都围它转。循环展开(loop unrolling):for(i=0;i<4;i++) sum+=a[i] 改写为每次迭代处理 2 或 4 个元素——循环开销(增量、比较、跳转)被摊薄,更重要的是给指令调度器露出更大的基本块,乱序执行与向量化的空间都来自这里;代价是代码体积膨胀(指令缓存 miss 的反噬),所以 GCC 的 -funroll-loops 到 -O3 才默认开启。强度削减(strength reduction):把贵的运算换成便宜的——i*4 换成 i<<2,更狠的是归纳变量变换:循环里 t = 4*i 与 i 同步增长,t 可用 t += 4 递推替代乘法——乘法降为加法,是体系结构层面的降维打击(Dragon Ch10 的经典例子,最早的 Fortran 优化器就会这招)。内联(inlining):把函数调用替换为函数体——消掉调用开销(压栈、跳转、返回)只是小头,真正的大头是"内联后调用者上下文可见":常量参数传播、死参数消除、跨函数 CSE 全部解锁,没有内联就没有跨函数优化;代价是代码体积指数级膨胀、虚函数/函数指针难以内联(需去虚化 devirtualization 先行)、调试信息变差(栈帧里看不到被内联的函数名,现代调试器靠 DWARF 的 inline frame 信息补救)。三者的共同哲学:用编译期的一次性代价(体积、编译时间)换运行期的持续收益——拨杆就握在台上那三只旋钮手里:-O0 全关(编译快、可调试)、-O2 平衡、-O3 激进(展开/内联拉满,赌指令缓存放得下)。S5→S7 的分野在此有了技术注脚:旋钮不是装饰,每只都对应一组真实、有代价、改变代码形状的变换——你旋的不是开关,是 trade-off。
链接殿 · 落地为器
代码生成殿是一条长廊,尽头是一台正在运转的机器——真正的 CPU。诺依曼老祖指着长廊上的三道门。
「优化过的中间码还不能跑。它要经过三道门:生成、链接、运行。走一遍。」
代码生成 · 链接 · 运行时
寄存器分配(图着色)→ 链接器符号解析 → 栈帧/堆/GC运行时
详解 · K18-14 目标代码生成:指令选择/寄存器分配/指令调度
代码生成是编译器的最后一道翻译(Dragon Ch9):优化后的 IR → 目标机汇编。三个核心子问题。指令选择(instruction selection):IR 操作到目标 ISA 指令的多对多映射——一条三地址乘加可能命中 x86 的 lea 或 ARM 的乘累加指令 mla(一条完成),反过来一条除法可能展开成十几条指令(无除法器的小核心靠减法迭代或 magic number 乘法模拟);现代编译器用树覆盖(BURS/iburg)或 DAG 匹配,在指令模式库里找最小代价覆盖。寄存器分配(register allocation):物理寄存器只有 16 个(x86-64 通用寄存器),活跃变量却有几百个——经典解法是图着色(Chaitin 1981):变量建干涉图,生命区间重叠(同时活跃)的变量连边,相邻不许同色,K 种颜色对应 K 个可用寄存器;着色失败就选变量溢出(spill)到栈内存,每次溢出引入访存——台上"寄存器溢出增加访存"的代价牌说的就是它。LLVM 的 JIT 路线用线性扫描(linear scan,Poletto & Sarkar 1999)以质量换编译速度,Chaitin 式图着色仍在静态编译器里守质量底线。指令调度(instruction scheduling):重排指令顺序填满流水线——访存延迟从 L1 的约 4 周期到主存的数百周期,靠交错的无关指令隐藏;乱序 CPU 硬件自己会调度,编译器调度的价值主要在顺序核心与 VLIW(Itanium 的教训:把调度全推给编译器,编译器接不住)。三个子问题互相咬合:选择影响寄存器需求,分配引入 MOV 后又需要再调度——后端就是这三个 pass 的迭代精化。台上代码生成门后的三行小字,就是这个子领域的全部大纲。
详解 · K18-16 链接器(Linker)的工作原理:符号解析/重定位
链接器把一堆可重定位目标文件(.o)与库拼成一个可执行文件,只干两件事(CSAPP Ch7 的著名论断):符号解析与重定位。符号解析:每个目标文件定义与引用符号——main.o 引用 printf、定义 main,printf 在 libc 里定义;链接器为每个引用绑定唯一定义。多重定义按强弱符号规则裁决:函数与已初始化全局变量是强符号,重定义直接报错;未初始化全局变量是弱符号,重复时链接器静默选一个——C/C++ 最阴险的一类 bug 源(两个文件各自定义同名弱符号,编译链接全过,行为却取决于链接器心情)。找不到定义就是 undefined reference——台上"找不到→链接错误"的出处。重定位:编译器生成 .o 时不知道最终地址,全局符号引用处全部占位并留重定位记录(.rel.text/.rel.data 节);链接器先合并所有同类型节、确定每个符号的最终地址,再回头把每条引用改成真实地址——"把相对地址改为绝对地址"的工程实现就是这批重定位记录的批量回填。静态 vs 动态的 trade-off:静态链接把库代码拷进可执行文件——体积大(每个程序一份 printf)、库升级要重链接;动态链接只留符号引用、运行时解析——体积小、多进程共享一份物理内存、库可独立升级,代价是运行时开销与依赖地狱(DLL hell / soname 版本地狱,台上那张牌)。链接顺序的坑:ld 单遍扫描,命令行上库必须放在引用它的目标文件之后,循环依赖要重复写库——ln: undefined reference 的半数来源。
详解 · K18-17 加载器(Loader)与动态链接/共享库
可执行文件躺在磁盘上不会跑——加载器把它搬进内存、建好运行时映像才算数(CSAPP Ch7.9)。加载流程:shell 调 execve → 内核按 ELF 程序头表把各段映射进虚拟地址空间(代码段只读共享、数据段写入初始值、BSS 清零)→ 跳入口点(_start → __libc_start_main → main),argc/argv 已在栈上摆好。加速手段是按需调页(demand paging):代码段惰性映射,第一个缺页中断才真正读磁盘——启动快、冷页才付费。动态链接:共享库(.so/.dll)的符号解析推迟到加载时甚至首次调用时。老方案是加载时重定位——库被固定到某个地址、每处引用直接改写,代价是库代码无法在进程间共享(改写让每进程一份)。现代方案是位置无关代码(PIC):代码段一个字节不改,跨模块引用全部绕道 GOT(全局偏移表,在数据段,每进程私有可改)与 PLT(过程链接表,惰性绑定:第一次调用才解析符号并回填 GOT)——惰性绑定换启动速度,代价是首次调用慢,LD_BIND_NOW=1 可强制加载时全绑定。工具箱三件:LD_LIBRARY_PATH 改搜索路径,LD_PRELOAD 劫持符号(替换 malloc 为 jemalloc/tcmalloc 的标准姿势),ldd 查依赖、nm/readelf/objdump 做尸检。静态/动态/半静态三角:静态打包部署最省心(容器镜像、单文件发布),动态省内存省磁盘,dlopen 支持运行时插件(浏览器扩展、数据库插件的底座)。台上运行时那道门背后,正是这套机制在每次 ./a.out 时无声运转。
详解 · K18-18 运行时环境:栈分配/堆管理/垃圾回收基础
程序编译完只是半成品——跑起来还需要运行时系统管理三块内存(Dragon Ch7 运行时存储分配组织)。栈(stack):函数调用的天然结构——每调用压一个栈帧(返回地址、保存寄存器、局部变量、参数),返回即弹;分配与释放各一条指令(移动栈指针),快到忽略不计;代价是生命周期严格嵌套且大小受限——Linux 默认栈 8MB,递归过深就是 Stack Overflow(台上"栈溢出:递归太深"的牌)。堆(heap):生命周期任意的对象(malloc/new)——分配释放顺序任意,管理成本随之而来:空闲块组织(free list/bins)、分配策略(first fit/best fit)、碎片(外部碎片:总空闲量够但单块不够——长期运行的服务器内存涨不回去的元凶之一)。现代分配器(glibc ptmalloc、jemalloc、tcmalloc)的共同思路是按大小分 bins + 线程本地缓存避开锁竞争;顺带解释一个常见误判:free() 之后 RSS 不降多半不是泄漏,是分配器囤着空闲块备复用。垃圾回收(GC)把"何时释放"从程序员手里收走:标记-清除(mark-sweep,从根集做可达性判定,碎片仍在)、标记-复制(semispace,活对象拷到另一半空间,无碎片但内存减半)、分代假说(多数对象朝生夕死——新生代复制、老年代标记整理,JVM/V8 的主架构)。三色标记的保守式实现需要暂停全部应用线程(stop-the-world),"GC 停顿"的来源;并发标记(CMS/ZGC/Shenandoah)把停顿压到毫秒级以下,代价是写屏障与吞吐损失。三种语言立场:C 手动(最快最易错)、Rust 编译期所有权(零运行时成本、学习曲线陡)、Go/Java GC(安全、有停顿)——没有免费的内存管理,只有换了付款方式的内存管理。
Boss 战 · 语义之渊
长廊灯火齐灭。灰雾从四面八方压过来,凝成一张巨大的、没有五官的脸——由无数行断裂的代码构成。
「我是语义之渊。我不住在你的语法错误里——那些你编译时就抓到了。我住在你'以为翻译无损'的地方。三问。答错一题,渊就深一分。」
渊账累计 0 笔 · 三问全对后清零
尾声 · 编译之桥
渊散尽时编译殿灯火重明,你手里的源意符微微发光——不是它变回了原样,是它带着所有变换的痕迹,成了另一种完整。上面的蚀痕还在,像一枚枚没说出口的印章。
「你通译了。不是因为你答对了三问——答对的会忘。是因为你走过的每一层翻译,都刻在你手上了。但——你的程序编译完了,可它要在两台机器之间传输。那条路,叫计算机网络。」
知识图谱点亮 · 编译之悟
点亮全章8块碎片 → 变换链剪影定格 → 境界突破
复盘 · 编译之桥主线:从六层漏斗到代价账本
一条主线串起全章:编译之殿(S0:押注哪层翻译无损——六层全输,分层本身就有代价)→ 词法殿(S1:a+++b 按最长匹配切成 a++ + b,注释/空白/格式在 Token 化时丢失——"编译器不看缩进"的机制根源)→ 文法阁(S-regex:正则→NFA→DFA 三级等价,ε-闭包子集构造,泵引理钉死正则数不了嵌套——嵌套之墙划出词法与语法的分工)→ 语法殿(S2:语法树 vs AST 单链折叠,dangling-else 两棵树都合法——二义性不可判定,只能约定裁决)→ 分析器工坊(S-parser:LL(1) 死于左递归,LR(0) 12 状态撞 shift/reduce 冲突,SLR(1) 用 FOLLOW(E)={+,$,)} 消解——能力与状态数的单向阶梯)→ 语义渊(S3:int+float 隐式转换、300→44 截断、块作用域遮蔽外层 x——"只抓错不改义"崩塌)→ 中码塔(S4:if-else 展平成 6 行三地址码,SSA 把 x 拆成 x_1/x_2/x_3=phi(x_1,x_2)——结构没了,身份也拆了)→ 优化坊(S5:常量折叠/死代码消除/循环不变量提升——as-if 规则内的真无害)→ 流水线(S6:六阶段全景,每阶段的输入/输出/代价一张表)→ 三旋钮(S7:浮点重排 (1e20+(-1e20))+1.0=1.0 vs 1e20+((-1e20)+1.0)=0.0,UB 利用把 a+b<a 改成 b<0——as-if 越界,"优化无损"崩塌)→ 链接殿(S8:图着色寄存器溢出、符号解析 undefined reference、栈溢出/堆碎片/GC 停顿——语义鸿沟的最后一里)→ 三问清账(S9:渊账归零)→ 图谱归一(S10:碎片点亮,变换链剪影定格)。贯穿全章的三个顿悟:①翻译即变换,变换必有代价——六层漏斗没有一层是无损的,"无损"只是你还没发现丢了什么;②语义鸿沟不可消除——源语言的意图(缩进、结构、精度、别名)注定无法完整进入目标语言,能做的只是把丢失显式化;③鸿沟可丈量则可架桥——编译器 = 代价已知的有损变换链,每一层丢什么你知道、改什么你丈量过,这就是"语言通译"的全部含义。元婴二层圆满——编译器你通了,但程序要在两台机器之间传输,那条路叫计算机网络。下一章,织脉之门。