想象你开了一家叫“代码面馆”的跨国连锁店。纽约分店的厨师擅长意面,东京分店擅长拉面,成都分店擅长担担面——每种面都有自己独特的备料方式(这就像不同编程语言:C、Java、Python各有各的语法和习惯)。如果总部想统一品控、想在新城市快速开分店、想统一培训新厨师,你不可能让每家店都照着原产地厨师的“个人习惯”办事。于是总部请来一位“标准菜谱工程师”,把所有面食翻译成一套编号的步骤清单:步骤1取面粉200克、步骤2加水100毫升……任何分店、任何口味的厨师,都先按这套标准步骤出菜;出菜之后,再根据本地顾客口味做最后调整。 这套“标准菜谱”就是编译器里的中间表示(Intermediate Representation,简称IR)。编译器拿到一段源代码后,前端(Front End)负责把代码翻译成IR,后端(Back End)再根据目标机器(x86、ARM、RISC-V等)把IR翻译成真正的机器码。前后端各干各的活,全靠IR这座桥梁沟通。围绕IR,工程师们还设计了控制流图(后厨动线图)、三地址码(编号步骤)、SSA(每份配料的唯一来源标签)、优化Passes(品控团队的一轮轮巡检)等一整套体系。这篇文章,就带你把这间“面馆”的内部运作彻底看懂。

岗位关联

掌握IR设计,意味着你看得懂编译器最核心的枢纽,能真正参与LLVM、Rust工具链这类基础软件的生产与调试,而不只是停留在“写代码、跑模型”的表层。

目录
  1. 可视化演示
  2. IR基础概念:为什么要有“标准菜单”
  3. 三地址码:标准菜单上的编号步骤
  4. 控制流图:后厨动线图
  5. SSA原理:每份配料的唯一来源
  6. 支配边界:算出“必须有人驻守的岔路口”
  7. 优化Passes:品控团队的一轮轮巡检
  8. LLVM实践:连锁总店的标准手册
  9. 层次权衡:菜单详略的取舍智慧
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示编译器中间表示的核心结构:控制流图CFG的基本块与跳转边,以及SSA静态单赋值形式的phi函数与数据流。

本节配有交互动效(进入视口后自动加载,约 3KB)。

控制流图CFG 基本块:连续无跳转的指令序列 边:块间控制转移(跳转/ fallthrough) 支配者(dom): 从入口到节点N必经的节点 CFG是编译器优化的基础数据结构,支撑死代码消除、循环优化等pass

IR基础概念:为什么要有“标准菜单”

故事回到“代码面馆”。总部的标准菜谱工程师发现,如果不统一中间步骤,纽约店直接按意大利习惯出菜、东京店按日本习惯出菜,那么总部想推一款“减脂菠菜面”、想统计所有分店的用盐量,就会四处碰壁。统一需要付出一点翻译成本,但换来的是:前端(各分店的口味翻译)可以独立进化,后端(本地厨房的灶具适配)也可以独立升级——中间只要守住一张标准菜单。这正是IR存在的根本理由:让编译器前后端解耦。

在编译器里,前端把源代码翻译成IR,后端把IR翻译成目标机器码。IR的设计直接决定了优化器能看见什么、后端移植需要多少工作量。高层IR贴近源码,保留循环、类型、变量名等语义;低层IR贴近机器,暴露寄存器、内存地址甚至指令选择。大多数编译器不止一层IR,例如GCC有GENERIC、GIMPLE、RTL三层,逐层下降。

回扣比喻:只要标准菜单不变,前端可以换任何“方言”进来,后端可以换任何“灶台”出去,这就是IR带来的工程自由度。设计IR时,你会反复权衡:保留多少语义?服务哪些优化?适配多少后端?这正是编译器架构师的工作乐趣。

T=B(F(S))\displaystyle T = B(F(S))
这条等式浓缩了整条编译流水线:源代码 S 经前端 F 变成中间表示,再由后端 B 变成目标代码 T。
  1. 源程序 S输入给编译器的源代码文本,例如一个 main.c 文件,它承载着程序员写的逻辑。
    S=源代码S = \text{源代码}
  2. 前端 F执行词法、语法、语义分析,把源代码翻译成IR,相当于把各地口味转写成统一编号步骤。
    F(S)=IRF(S) = \text{IR}
  3. 中间表示 IR前端与后端之间的契约,是整条流水线的枢纽,优化器在这里做各种改进。
    IR∈{三地址码,SSA,CFG}\text{IR} \in \{\text{三地址码}, \text{SSA}, \text{CFG}\}
  4. 后端 B读取IR并做指令选择、寄存器分配、指令调度,最终输出目标机器码 T。
    B(IR)=TB(\text{IR}) = T

三地址码:标准菜单上的编号步骤

总部要推行标准菜单,第一步是把复杂菜式拆成一步步可执行的编号步骤。比如“鱼香肉丝盖浇面”可以写成:步骤1取煮熟面条200克;步骤2加热肉丝;步骤3混合酱料;步骤4出锅装盘。每一步只做一个动作。IR中的三地址码(Three-Address Code,3AC)就是这个思路:把任意复杂的表达式拆成一条条“至多一次运算”的指令。

三地址码的每条指令通常包含一个目标地址和两个源地址,所以叫“三地址”。比如源码 d = a + b * c 会被拆成两条:t1 = b * c 和 d = a + t1。每引入一个新的中间结果,就产生一个临时变量。临时变量的优点是让每个运算粒度和数据依赖都显式展现在编译器面前,方便后续做常量传播、公共子表达式消除等优化。

四元组(quadruple)是三地址码的一种经典存储形式,以 (op, arg1, arg2, result) 形式存储。数组引用也按三地址处理:x = a[i] 需要先计算地址 t = base + i * sizeof,再做取数操作。

回扣比喻:标准菜单把“鱼香肉丝盖浇面”拆成有编号的步骤,三地址码则把 a + b * c 拆成带编号的临时变量步骤。每一步都简单到不可能出错,优化器才能像品控员一样逐行检查、逐行改进。

result=operand1 op operand2\displaystyle \text{result} = \text{operand}_1\ \text{op}\ \text{operand}_2
这是三地址码的标准模板:一条指令最多包含一个运算符、两个源操作数、一个目标操作数。
  1. result(目标)存放本次运算结果的变量,通常是编译器新生成的临时变量,如 t1。
    result=t1\text{result} = t_1
  2. operand1 与 operand2(源)参与运算的两个操作数,可以是变量、常量或数组元素。
    operand1=b,operand2=c\text{operand}_1 = b,\quad \text{operand}_2 = c
  3. op(运算符)+ - * / 等算术运算,或 < > == 等比较运算。
    op∈{+,−,×,÷,<,>,=}\text{op} \in \{ +, -, \times, \div, <, >, = \}
  4. 整体约束每条指令只做一次运算,复杂的表达式被拆成多条指令,中间结果由临时变量传递。
    d=a+b×c⇒t1=b×c; d=a+t1d = a + b \times c \Rightarrow t_1 = b \times c;\ d = a + t_1

控制流图:后厨动线图

有了标准步骤,总部还需要知道后厨的动线:一个菜从备料到出餐会走哪些路线?哪些环节是必经之路?哪些环节会出现分支(比如顾客选辣或不辣)?后厨把流程图画在墙上:每个处理环节是一个方块,箭头表示下一步去哪。编译器里的控制流图(Control Flow Graph,CFG)就是这张后厨动线图。

CFG 的节点是基本块(Basic Block)。基本块是一段连续的三地址码指令,它们只有一个入口和一个出口:块内指令顺序执行,不会有跳转指令进入或离开块的中间。划分基本块的规则很简单:①程序第一条指令是入口;②任何跳转目标指令是入口;③跳转指令的下一条指令是入口。相邻入口之间的指令组成一个基本块。

基本块之间用有向边连接,边代表可能发生的控制转移。有了CFG,编译器就能做支配关系分析:若从入口到达基本块 m 的每条路径都必须经过 n,则称 n 支配 m(n dom m)。支配关系可以进一步用来识别回边(循环),为后续SSA和优化打基础。

回扣比喻:后厨动线图让店长看清每一道菜的必经环节和可能分流的位置。控制流图则让编译器看清每个基本块的入口和出口,并进一步算出“谁是必经路口”——这正是支配关系的含义,也是下一节SSA的地基。

Leader(i)  ⟺  i=I0∨∃ j∈J:i∈targets(j) ∨ i=next(Ij)\displaystyle \mathrm{Leader}(i) \iff i = I_0 \lor \exists\ j \in J: i \in \mathrm{targets}(j)\ \lor\ i = \mathrm{next}(I_j)
这条规则定义了“基本块入口指令”:要么是第一条指令,要么是跳转目标,要么紧跟在跳转指令之后。
  1. Leader(i)指令 i 是否是一个新基本块的入口(leader),是则开启一个新块。
    Leader(i)=true or false\mathrm{Leader}(i) = \text{true or false}
  2. i = I0整段程序的第1条指令一定是入口,相当于动线图的起点。
    I0=第一条三地址码I_0 = \text{第一条三地址码}
  3. i ∈ targets(j)只要有一条跳转指令的目标是 i,i 就是入口,因为控制流会从其他位置“空降”到这里。
    j:jmp i ⇒ i∈Leaderj: \text{jmp}\ i\ \Rightarrow\ i \in \mathrm{Leader}
  4. i = next(Ij)无条件跳转后面那条指令也是入口,因为跳转会“飞走”,后续指令从另一条路径进入。
    next(jmp L)∈Leader\text{next}(\text{jmp}\ L) \in \mathrm{Leader}

SSA原理:每份配料的唯一来源

继续看后厨:现在要追查“酱油”是哪位供应商送来的。如果多个供应商都能在同一个锅里倒酱油,出了问题就分不清责任。SSA(Static Single Assignment,静态单赋值形式)的思路与此完全一致:它要求程序中每个变量只能被赋值一次。如果同一个变量名在不同分支里被赋了不同的值,就通过“重命名”把它们变成不同版本:例如左分支写 x1 = 1,右分支写 x2 = 2,汇合之后用一条 phi 函数合并:x3 = phi(x1, x2)。

phi 函数不是真实机器码,它只是编译器的“合并说明”:告诉分析器,x3 的值可能来自 x1 也可能来自 x2,具体取决于运行时走哪条分支。之所以引入phi,是为了让每个变量保持“只赋值一次”的性质,同时不丢失“同一个变量在不同路径上有不同值”的信息。

SSA 的构建分为两步:第一步在汇合点插入 phi 函数,第二步对每个变量重命名加下标。SSA 给优化带来了巨大简化:每个变量只有一个定义点,所以“定义-使用”链(def-use chain)天然紧凑;常数传播、死代码消除、寄存器分配等许多算法在SSA上都能高效实现。正是因为这个巨大价值,现代编译器(LLVM、GCC的GIMPLE)都采用SSA作为核心IR。

回扣比喻:每份配料有了唯一的来源标签,一旦菜品出问题就能精准追责;SSA让编译器的每个变量都有唯一“出生地”,数据流分析自然清晰又高效。

x3=ϕ(x1, x2)\displaystyle x_3 = \phi(x_1,\ x_2)
phi 函数像一块“合流标记”:它不执行任何运算,只说明汇合点上的变量 x 可能来自两条分支的哪个版本。
  1. x1、x2(来源版本)分别来自两个不同分支的赋值,在SSA中它们是同名变量 x 的不同版本。
    x1=1;x2=2x_1 = 1;\quad x_2 = 2
  2. phi 操作phi 按运行路径从 x1、x2 中选择一个值赋予 x3。
    x3←select(x1, x2, path)x_3 \gets \text{select}(x_1,\ x_2,\ \text{path})
  3. 汇合点语义只有在多条控制流汇合处才需要 phi,直线代码不需要。
    汇合点={m∣∣pred(m)∣≥2}\text{汇合点} = \{ m \mid |\mathrm{pred}(m)| \geq 2 \}
  4. SSA 性质重命名之后,每个临时版本在整个程序中只出现一次作为定义。
    ∀ v, v在SSA中至多被定义一次\forall\ v,\ v \text{在SSA中至多被定义一次}

支配边界:算出“必须有人驻守的岔路口”

现在有个实际问题:在哪里插入 phi 函数?后厨负责人的经验是,要看“我的管辖范围在哪里结束”。在控制流图中,如果一个基本块 n 支配了某些节点,但当控制流跨越 n 的支配范围时,就会进入 n 的支配边界(Dominance Frontier,DF)。支配边界正是“n 的影响力边界”,也是变量定义的传递需要合并的地方。

严格定义:节点 m 属于 DF(n),当且仅当 n 支配 m 的某个前驱,但 n 不严格支配 m。直觉上,如果从 n 出发的路径可以在 m 处“汇合到另一条不经过 n 的路径”,那么 n 中定义的变量就需要在 m 处用 phi 合并。编译器按支配边界迭代放置 phi 函数,直到不再产生新的 phi 需求,这个过程就是迭代算法。

支配边界在工程中非常实用:它精确指出了“哪里需要合并”,避免在无关节点上浪费 phi 指令,也让构建SSA的算法(如Cytron提出的高效算法)有了理论依据。LLVM 和 GCC 都依赖支配边界来快速构造SSA。

回扣比喻:支配边界就像公司划定的“区域负责制边界”——每个片区经理只对自己的必经路段负责,两条路线的汇合处必须有人交接。编译器据此就知道:变量走到哪条汇合线时,需要两个来源的“交接签字”(phi)。

DF(n)={ m∣∃ p∈pred(m): n dom p ∧ n 不严格支配 m }\displaystyle \mathrm{DF}(n) = \{\, m \mid \exists\, p \in \mathrm{pred}(m):\ n\ \mathrm{dom}\ p\ \wedge\ n\ \text{不严格支配}\ m \,\}
这条定义告诉你:n 的支配边界是“n 支配了某条进入路径,但 n 自己管不到终点”的那些汇合节点。
  1. pred(m)节点 m 的所有前驱基本块集合,即能直接跳转到 m 的节点。
    pred(m)={p1,p2,… }\mathrm{pred}(m) = \{ p_1, p_2, \dots \}
  2. n dom pn 是 p 的必经之路,控制流只要到达 p 就必然路过 n。
    n dom pn\ \mathrm{dom}\ p
  3. n 不严格支配 mn 不能完全控制 m——要么 n 根本不支配 m,要么 n 就是 m 自己(比如循环回边)。
    ¬(n sdom m)\neg(n\ \mathrm{sdom}\ m)
  4. 结论满足这两点的 m 就是 n 支配范围的“出口检查站”,需要放置 phi。
    m∈DF(n)⇒需要 phim \in \mathrm{DF}(n) \Rightarrow \text{需要 phi}

优化Passes:品控团队的一轮轮巡检

标准菜单定好后,总部的品控团队不会一次性验收,而是分多轮巡检:第一轮检查食材用量是否统一,第二轮检查烹饪顺序能否合并,第三轮检查有没有多余的备料步骤。编译器的优化器也是这样工作的:它把IR送进一个个 pass(遍),每个 pass 负责一种独立的检查或改写,像一条流水线逐遍处理IR。

pass 分为两类:分析 pass 只读取IR并收集信息(如活跃变量分析、依赖分析),不修改IR;变换 pass 则实际改写IR(如死代码消除 DCE、常量传播、循环展开)。Pass管理器负责排定 pass 的执行顺序:每个 pass 可以声明它依赖哪些分析结果,如果之前已经有人算过,就可以复用,避免重复计算。这就是 LLVM 中 PassManager 的设计思想。

现代优化流水线通常按层次组织,例如先做高层语义优化(内联、循环变换),再做低层优化(寄存器分配前的合并、指令调度)。每轮 pass 相当于对IR做一次“不变式保持的改写”,直到达到不动点(fixpoint)——继续跑也不再产生新优化机会时,IR 就收敛到稳定形态。

回扣比喻:品控团队不会指望一次检查就面面俱到,而是设计多张检查表,轮流执行。每个 pass 只做一件小事,组合起来却能让整个后厨的质量达到星级标准。

IRi+1=passi(IRi),IR∗=fixpoint(IR0)\displaystyle \text{IR}_{i+1} = \mathrm{pass}_i(\text{IR}_i), \qquad \text{IR}^* = \mathrm{fixpoint}(\text{IR}_0)
优化流水线可以看成对 IR 反复施加变换函数,直到结果不再变化。
  1. IR_i(第 i 轮状态)优化开始时是 IR0,每轮 pass 后变成新的 IR。
    IR0→IR1→IR2→⋯\text{IR}_0 \to \text{IR}_1 \to \text{IR}_2 \to \cdots
  2. pass_i(单个变换)每个 pass 是一个函数,输入IR输出IR,像品控流程中的一项检查。
    passi:IR→IR\mathrm{pass}_i: \text{IR} \to \text{IR}
  3. fixpoint(不动点)当继续运行任何 pass 都产生不了新变化时,IR 达到稳定。
    IR∗=pass(IR∗)\text{IR}^* = \mathrm{pass}(\text{IR}^*)
  4. 性能意义把大问题拆成多个微小pass,每个 pass 逻辑简单、容易验证,组合起来却能产生巨大的优化收益。
    优化收益=⨁ipassi\text{优化收益} = \bigoplus_i \mathrm{pass}_i

LLVM实践:连锁总店的标准手册

回到现实世界。如今最著名的“连锁总店手册”就是 LLVM。LLVM(Low Level Virtual Machine)本质上是一个编译器基础设施,它的核心是一套精心设计的IR——LLVM IR。Clang(C/C++前端)、rustc(Rust前端)、Swift 编译器都把各自的源码翻译成 LLVM IR,然后共享同一套优化 pass 和后端代码生成器。这让“换前端”或“换后端”都变得相对轻松。

LLVM IR 有几个鲜明特点:它是静态单赋值(SSA)形式,每个临时变量都有显式类型和唯一定义;它使用无限数量的虚拟寄存器,真实寄存器分配留到后端;它自带一套类型系统(i32、指针、结构体、函数签名等),让优化器在分析时拥有丰富信息。LLVM IR 呈线性文本形式,后缀多为 .ll,也可以用位码(bitcode)存储。

LLVM 的 PassManager 和优化流水线(如 -O2 对应一长串 pass 序列)已经高度工程化。开发者可以编写自定义 pass 插入流水线,也可以复用现成的几十个优化 pass。正因如此,从游戏引擎到 iOS 应用,从 Rust 到 WebAssembly 工具链,到处都有 LLVM 的身影。

回扣比喻:LLVM 就是那本全球统一的连锁手册,各分店(语言前端)按手册写标准步骤,总部品控(优化 pass)批量检查,各地产的灶台(目标后端)按手册适配。真正做到了“一次标准菜单,处处复用”。

ϕ(x1,x2) ⟹ 前驱 p1:copy(x,x1); 前驱 p2:copy(x,x2)\displaystyle \phi(x_1,x_2)\ \Longrightarrow\ \text{前驱 } p_1: \mathrm{copy}(x, x_1);\ \text{前驱 } p_2: \mathrm{copy}(x, x_2)
在 LLVM 后端把 SSA 降到机器码之前,需要消除 phi 函数:在每个前驱边上插入一条复制指令。
  1. phi 消除(SSA Destruction)真实 CPU 没有 phi 指令,所以生成机器码前要把 phi 拆掉。
    phi 消除:IRSSA→IR非SSA\text{phi 消除}: \text{IR}_{\text{SSA}} \to \text{IR}_{\text{非SSA}}
  2. 前驱 p1 路径从 p1 进入汇合块时,把 x1 复制给 x。
    copy(x, x1) on edge p1→m\mathrm{copy}(x,\ x_1)\ \text{on edge}\ p_1 \to m
  3. 前驱 p2 路径从 p2 进入汇合块时,把 x2 复制给 x。
    copy(x, x2) on edge p2→m\mathrm{copy}(x,\ x_2)\ \text{on edge}\ p_2 \to m
  4. 关键边处理如果前驱和汇合块之间还有关键边,可能需要插入中间块来保证复制语义正确。
    关键边⇒split edge\text{关键边} \Rightarrow \text{split edge}

层次权衡:菜单详略的取舍智慧

最后,回到“标准菜单”的设计本身:菜单应该多详细?如果只写“做一碗面”,前端省事但后端完全不知道怎么做;如果写“加盐2克、揉面3分钟、切丝0.5毫米”,精细但每开一家新店都要重新培训一大堆细节。IR 设计同样面临这种详略取舍:栈式IR(stack IR)用栈来传递操作数,指令如 push a、add,结构紧凑但不直观;线性IR(linear IR)更像汇编,显式给出每个操作数,三地址码就是典型。

成熟的编译器往往同时使用多层IR:高层IR保留循环、类型、变量名等语义,方便做语言级优化;低层IR贴近目标机器,方便做指令选择和寄存器分配。例如 Java 字节码是栈式IR,JVM 还要再翻译成机器码;GCC 从 GENERIC 到 GIMPLE 再到 RTL 逐层下降;LLVM 从语言前端直接进入 SSA 形式的线性IR。

设计IR时主要考量三个维度:能否高效执行优化(可优化性)、能否轻松适配新后端(可移植性)、是否方便做静态分析(可验证性)。没有完美IR,只有合适的权衡。现代研究表明,SSA 形式线性IR配合多层抽象,已经在可优化性和可移植性之间取得了很好的平衡。

回扣比喻:栈式IR像“省略主语”的流水账,简洁省纸但上下文全在栈里;线性IR像写上“谁给谁、干什么”的明细账,清晰直观但篇幅更长。好的编译器会像优秀的总部一样,对不同环节选用不同详略的菜单,让每个岗位都拿到最适合自己的那本。

push a; push b; push c; mul; add\displaystyle \mathrm{push}\ a;\ \mathrm{push}\ b;\ \mathrm{push}\ c;\ \mathrm{mul};\ \mathrm{add}
这串栈式IR指令计算 b * c + a:每次运算从栈顶取操作数,结果压回栈顶。
  1. push a把变量 a 压入栈顶,栈深加1。
    Stack←Stack∪{a}\text{Stack} \gets \text{Stack} \cup \{a\}
  2. push b、push c依次压入 b 和 c,此时栈顶到栈底为 c、b、a。
    top=c\text{top} = c
  3. mul弹出栈顶两个值 b、c,计算乘积后压回栈,无需写出目标变量。
    pop c, pop b⇒push(b×c)\text{pop}\ c,\ \text{pop}\ b \Rightarrow \text{push}(b \times c)
  4. add再弹出两个值,把结果压回,整个表达式的值留在栈顶。
    pop (b×c), pop a⇒push(a+b×c)\text{pop}\ (b\times c),\ \text{pop}\ a \Rightarrow \text{push}(a + b\times c)

AI 应用场景

LLVM中端优化平台:多语言共享统一IR

阶段:中端优化与后端移植

解决的问题:传统编译器把前端语法树直接翻译到机器相关表示(RTL),导致优化逻辑和硬件细节纠缠,移植新CPU、支持新语言都要重写大量代码。使用 IR 统一枢纽后,前端只负责生成 IR,优化 pass 只负责改写 IR,后端只负责消费 IR,各层职责清晰。

具体案例:Rust 编译器 rustc 和 Swift 编译器都使用 LLVM 作为后端。Rust 前端把所有权检查后的 MIR 进一步降级为 LLVM IR,然后直接复用 LLVM 的 -O2 优化流水线,最终生成高效机器码。开发者不用为每种语言重写一套优化器,这正是IR解耦带来的工程红利。

Java 字节码:跨平台分发的“标准菜单”

阶段:跨平台分发与 JIT 即时优化

解决的问题:Java 程序要在 Windows、Linux、macOS 上运行,如果直接把源码编译成某个平台的机器码,就无法跨平台分发。字节码作为 Java 虚拟机的 IR,对平台无关,所有平台共享同一份 .class 文件。

具体案例:开发者编译 Hello.java 得到字节码后,JVM 在运行时用解释器逐条执行,或用 JIT 编译器把热点字节码编译成当前平台的机器码。字节码的栈式结构让 JVM 指令集非常紧凑,而 JIT 又把字节码当作输入IR做内联、逃逸分析等优化。

WebAssembly:浏览器里的可移植IR

阶段:前端语言与浏览器后端的解耦

解决的问题:浏览器只认 JavaScript,但开发者希望用 C、C++、Rust 等语言编写高性能 Web 应用。通过把源代码编译成 WebAssembly(Wasm)字节码,任何支持 Wasm 的浏览器都能执行,无需插件。

具体案例:Figma 的 Web 版把 C++ 核心引擎通过 Emscripten 编译成 Wasm,同时配合 WebGL 渲染,实现了接近原生的性能。Wasm 本质上是浏览器规定的一种栈式IR,既承载了安全沙箱约束,又给后端的 AOT/JIT 编译器留下了充分优化空间。

Python 演示

python63 行
#!/usr/bin/env python3# -*- coding: utf-8 -*-"""从三地址码指令序列划分基本块并构建控制流图"""  def find_leaders(instructions, jump_targets):    """返回基本块入口指令的下标集合。"""    leaders = {0}    for i, op in enumerate(instructions):        if op.startswith(("jmp", "goto", "if")):            leaders.add(i + 1)    leaders.update(t for t in jump_targets if t < len(instructions))    return sorted(leaders)  def build_cfg(instructions, jump_targets):    leaders = find_leaders(instructions, jump_targets)    blocks = []    for idx, start in enumerate(leaders):        end = leaders[idx + 1] if idx + 1 < len(leaders) else len(instructions)        blocks.append((start, end, instructions[start:end]))     edges = []    for i, (start, end, body) in enumerate(blocks):        last = body[-1]        if last.startswith("goto"):            target = int(last.split()[1])            edges.append((i, blocks_target_index(blocks, target)))        elif last.startswith("if"):            target = int(last.split()[-1])            edges.append((i, blocks_target_index(blocks, target)))            if end < len(instructions):                edges.append((i, i + 1))        elif end < len(instructions):            edges.append((i, i + 1))    return blocks, edges  def blocks_target_index(blocks, target_inst):    for i, (start, end, body) in enumerate(blocks):        if start == target_inst:            return i    return -1  def main():    instructions = [        "t1 = a + b",        "if t1 < 10 goto 4",        "t2 = a * b",        "goto 5",        "t2 = a - b",        "t3 = t2 + 1",    ]    jump_targets = {4, 5}    blocks, edges = build_cfg(instructions, jump_targets)    for i, (start, end, body) in enumerate(blocks):        print("B%d [%d:%d]: %s" % (i, start, end, " | ".join(body)))    print("CFG edges:", edges)  if __name__ == "__main__":    main()
目的:这段程序把一段带条件跳转的三地址码序列切分成基本块,并输出基本块之间的控制流边,让你直观看到CFG如何从指令序列中构建出来。
  1. leaders = {0}程序第一条指令一定是基本块入口,相当于动线图的起点。
  2. leaders.update(t for t in jump_targets if t < len(instructions))所有被跳转目标指向的指令也是入口,控制流会从这里“空降”。
  3. if last.startswith(("jmp", "goto", "if")):找到跳转指令,它的下一条指令要被标记为新入口。
  4. blocks.append((start, end, instructions[start:end]))相邻入口之间的一段指令构成一个基本块。
  5. target = int(last.split()[-1])从跳转指令末尾取出目标指令下标,映射到对应基本块。
  6. edges.append((i, blocks_target_index(blocks, target)))连接“当前块→跳转目标块”这条有向边。
  7. elif end < len(instructions):块尾不是无条件跳转时,控制流会顺序落入下一个基本块,生成顺序边。

常见误区

误区“反正最后都要变成机器码,IR 只是翻译途中随便画两笔的草稿,早点扔掉就好,不值得深入研究。”

正解恰恰相反,IR 是编译器工程的“承重墙”。它决定了前后端能否解耦、优化器能看见多少信息、移植新 CPU 要写多少代码。LLVM 之所以能同时服务 C、C++、Rust、Swift,核心就是因为拥有一套稳定且精心设计的 IR。就像连锁店的标准菜单,不是可有可无的草稿,而是整个品控体系的根基——菜单一改,所有分店都要跟着变。

为何常见:很多初学者从“翻译器”的角度理解编译器,只看到输入输出,忽略了中间过程的工程价值;类比理解起来也需要先体会过“前后端耦合”的痛苦才会改观。

误区“前端不是已经生成语法树了吗?那不就是 IR 吗?直接在语法树上做优化不就行了?”

正解AST 是源代码的树形结构镜像,保留着括号、运算符优先级、语法糖等“语言外形”;而 IR 是经过语义分析和降级后的线性指令,显式写出了控制流和临时变量,去掉了语法糖。AST 适合做语法层面的检查(比如变量是否声明),但做数据流分析、指令选择时必须用 IR。打个比方:AST 是顾客点单时写的“微辣、少油”备注,IR 才是后厨真正照着执行的编号步骤清单。

为何常见:初学者往往只看过编译原理前几章,对“语法分析之后还有什么”缺乏整体认知,容易把 AST 和 IR 混为一谈。

误区“基本块里写着一行 `x3 = phi(x1, x2)`,那么 CPU 肯定会执行这个 '合并' 操作啊?”

正解phi 函数是编译期概念,真实硬件没有任何 phi 指令。它的作用是“声明”汇合点的变量来源,帮助优化器做数据流分析。生成机器码之前,编译器会执行 SSA 消除:在每个前驱的边上插入 copy(x, x1) 或 copy(x, x2)。对应到后厨比喻,phi 是贴在交接点的一张“来源说明牌”,不是厨师真正要执行的烹饪动作;真正执行的复制操作,是配菜员在各自路径上完成的。

为何常见:SSA 形式的 IR 看起来像汇编,初学者容易把 IR 指令和硬件指令画等号,忽略了 IR 的抽象层次。

误区“SSA 就是给每个变量加个版本号,比如 x1、x2,这不就是给变量做类型标注吗?”

正解SSA 是一种数据流约束——每个变量在程序中恰好赋值一次,目的是让“定义-使用”关系变成一对一的显式边;而静态类型系统的职责是给值标注类型以检查类型错误,二者解决的问题完全不同。SSA 的价值在于:常量传播、死代码消除等优化在“每个变量只有一处定义”的前提下会简化很多。类比来说,类型系统像给配料贴“食用/非食用”标签,SSA 则是给每份配料贴“唯一供应商批次号”。

为何常见:看到 x1、x2 这种带编号的变量,容易联想到类型系统中的编号标注,实际上编号代表的是控制流中的定义版本。

误区“支配边界嘛,就是支配树里 n 的那些直接孩子,算一下树结构不就行了?”

正解支配树的孩子表示“直接支配者”关系——n 的孩子是那些被 n 支配且中间没有其他支配者的节点;支配边界却表示 n 的支配范围“结束之处”,即 n 支配了某条进入路径但管不到终点的汇合点。两者可能重合,但性质完全不同。用后厨比喻:支配树是“谁直接向谁汇报”的组织架构图,支配边界是“每条路线的物流交接点”。phi 插位依据的是支配边界,而不是支配树的孩子。

为何常见:两者中文名字都带“支配”二字,加上初学者常把树和图混淆,于是想当然地认为支配边界等于支配树边界。

面试题

Q1. 编译器引入中间表示(IR)最主要的好处是什么?

单选题 · easy

  • A.让源代码变得更加简短
  • B.隔离前端与后端,使优化和移植可以分工协作← 正确答案
  • C.替代操作系统提供的系统调用
  • D.自动消除程序中的所有 bug

答案:B。IR 像连锁餐厅的标准菜单,前端负责把各种语言“方言”翻译成统一步骤,后端按统一步骤适配不同机器。A 不相关,C 和 D 更是张冠李戴;只有 B 准确描述了 IR 作为前后端契约的核心价值。

Q2. 下列哪一条最符合三地址码的形式?

单选题 · easy

  • A.t2 = t1 * 3← 正确答案
  • B.add eax, ebx
  • C.print("hello")
  • D.if (x > 0) return 1;

答案:A。三地址码的模板是“目标 = 源1 op 源2”,t2 = t1 * 3 正好一个目标、两个源、一个运算符。B 是汇编二元指令,C 是函数调用,D 是高级语言控制语句,都不是三地址码的标准形态。

Q3. 基本块的划分依据是什么?

单选题 · mid

  • A.每条指令的长度相同
  • B.块内只有一个入口和一个出口,中间没有跳转进出← 正确答案
  • C.变量名是否以 t 开头
  • D.函数调用的次数相等

答案:B。基本块是一段必须顺序执行的指令序列:执行从块首进入、从块尾离开,块内不会出现跳转目标,也不会中途跳走。这就像后厨动线中一个不可拆分的环节。A、C、D 都与控制流的进入/离开方式无关。

Q4. 在 SSA 形式中,“每个变量只赋值一次”是如何实现的?

单选题 · mid

  • A.直接把重复赋值的语句全部删除
  • B.通过重命名加下标,并在汇合点用 phi 函数合并不同来源← 正确答案
  • C.把赋值语句全部改写成常量定义
  • D.要求程序员在源码中只能写一次赋值

答案:B。SSA 采用“版本化”策略:同一逻辑变量在不同分支赋值后改成 x1、x2 等不同版本,在汇合点用 phi 合并为 x3。它并不删除赋值,也不要求源码改动,而是编译器内部的 IR 转换操作。

Q5. 关于三地址码中的临时变量,下列说法正确的是?

多选题 · mid

  • A.每个复杂子表达式都会引入新的临时变量← 正确答案
  • B.临时变量让每个运算的依赖关系显式可见← 正确答案
  • C.临时变量必须由程序员手动管理内存
  • D.临时变量的数量通常多于源码中的变量数量← 正确答案

答案:ABD。三地址码把 a + b * c 拆成 t1 = b * c; d = a + t1,每个子表达式对应一个新临时变量,数量多于源码变量是常态。它们让 def-use 链条更清晰,但由编译器管理,程序员无需参与,所以 C 错误。

Q6. 关于控制流图(CFG)的构成,下列说法正确的是?

多选题 · mid

  • A.基本块作为图的节点← 正确答案
  • B.控制转移作为有向边← 正确答案
  • C.基本块内部指令顺序执行且有唯一出口← 正确答案
  • D.CFG 的每条边都代表数据依赖

答案:ABC。CFG 的节点是基本块,边代表控制转移(跳转或顺序落入),块内指令顺序执行、单一入口单一出口。D 错误:CFG 的边表达控制流,数据依赖要通过 def-use 链单独分析,不能用 CFG 边代替。

Q7. 若基本块 m 有两个前驱 p1、p2,其中 n 支配 p1 但 n 不支配 p2,且 n 严格支配 m,则 m 是否属于 DF(n)?

单选题 · hard

  • A.属于,因为 n 支配了 m 的某个前驱
  • B.不属于,因为支配边界要求 n 不严格支配 m← 正确答案
  • C.属于,因为 n 在支配树上位于 m 的上方
  • D.只有当 n = m 时才属于

答案:B。DF(n) 需要同时满足“n 支配 m 的某个前驱”和“n 不严格支配 m”两个条件。题中 n 虽支配 p1,但 n 严格支配 m 的结论已说明 n 完全管辖 m,不需要在 m 处合并来源。这像后厨两条路最终都在你管辖区内,就不需要对外交接点。

Q8. 关于 phi 函数,下列说法正确的是?

多选题 · hard

  • A.它在优化阶段代表“按控制流路径选择来源值”← 正确答案
  • B.它是真实 CPU 上会执行的硬件指令
  • C.它通常被插在汇合块的支配边界处← 正确答案
  • D.在 SSA 消除阶段会被复制/移动指令替代← 正确答案

答案:ACD。phi 是编译期伪指令,作用就是“按路径选值”,插入位置由支配边界决定;真实硬件没有 phi 指令,生成机器码前必须做 SSA 消除,把它转成前驱边上的复制指令。B 错在把 IR 指令误当成硬件指令。

Q9. 死代码消除(DCE)属于哪一类 pass?

单选题 · easy

  • A.分析 pass
  • B.变换 pass← 正确答案
  • C.词法 pass
  • D.语法 pass

答案:B。分析 pass 只读 IR 收集信息(如活跃性分析),变换 pass 会实际改写 IR。DCE 会把“不被使用且无副作用的赋值”从 IR 中删除,属于实实在在的变换。类比品控团队:分析是“检查”环节,变换是“动手修正”的环节。

Q10. 关于 IR 层次设计,下列说法正确的是?

多选题 · extreme

  • A.高层 IR 保留更多语言语义,更适合做语言级优化← 正确答案
  • B.低层 IR 更贴近目标机器,更适合做指令选择与寄存器分配← 正确答案
  • C.一套编译器可以同时使用多层 IR,逐层下降← 正确答案
  • D.栈式 IR 比 SSA 形式的线性 IR 更能简化数据流分析

答案:ABC。高层 IR(如 AST 或 GIMPLE 前段)保留循环、类型等语义;低层 IR(如 RTL)暴露机器细节;成熟编译器(GCC、LLVM)都是多层 IR 逐层 lowering。D 错误:SSA 的“单次赋值 + phi”特性让 def-use 链扁平化,恰恰是数据流分析最有利的形式,栈式 IR 的隐藏栈操作反而增加分析难度。