Chapter 24: CFG and Bytecode Generation
读完前两章后,源码已经经过 tokenizer、PEG parser、AST 和 symbol table。到这一章,问题变成:一棵带有作用域信息的 AST,怎样变成解释器能够逐条执行的 bytecode。这里要追踪的对象是控制流图(control flow graph,CFG)、伪指令、opcode、跳转目标、栈深和 code object 元数据。
本章的贯穿代码是一个很小的函数。它包含局部变量、条件分支、表达式计算和返回值,足够展示 AST 到 CFG 再到 bytecode 的主路径。
def classify(x):
total = 0
if x > 10:
total = x + 1
else:
total = x - 1
return total
这段代码在语言层看起来是顺序语句和一个 if。在 CPython 编译器内部,它会被整理成基本块、块之间的边、栈机器指令和 code object 中的表。读者读完本章应能定位一段 Python 源码中的控制流如何落到 CFG,判断变量和常量如何进入 opcode 参数,解释跳转、栈深和 exception table 对执行器的影响。
本章默认讨论 CPython。Bytecode 属于 CPython 实现细节,dis 文档明确说明不同 Python VM 和不同 CPython 版本之间可以改变 opcode 形态。本章把 CPython 3.11+ 的 exception table 和 3.13+ dis 标签显示作为阅读边界;具体 opcode 名称以当前解释器版本为准。
24.1 Control flow graph construction
CFG 把语法里的结构控制流转成编译器可以分析的有向图。一个 CFG 节点叫基本块(basic block),它内部是一段连续执行的指令序列;进入块后会从头执行到块尾,块尾再通过顺序落下、条件跳转、无条件跳转、返回或异常边转到后续位置。CPython 的编译说明把 CFG 放在 AST 与最终 bytecode 之间,并把 Python/flowgraph.c 作为 CFG 构造和优化相关源码入口之一,整体流程可见 CPython compiler design。
classify 的 if x > 10 先形成一个条件判断块。这个块计算 x > 10 的 truth value,然后根据结果指向两个后继:真分支执行 total = x + 1,假分支执行 total = x - 1。两个分支都会进入同一个返回块,返回块读取 total 并结束当前 frame 的执行。
这张图只表达编译期 CFG 的主干关系,省略具体 opcode 和源码位置表。
这张图的关键点是边界位置。条件判断块的尾部可以分裂到两个目标,两个赋值块的尾部可以汇合到同一个返回块。语法里的缩进块给人一种树形结构,CFG 把它改写成图结构;树结构适合表达包含关系,图结构适合表达执行位置之间的转移关系。
循环会让 CFG 出现回边。for 或 while 的测试块会指向循环体,也会指向循环退出点;循环体尾部会回到下一轮测试或迭代点。短路布尔表达式也会拆块,例如 x or y 会先测试 x,只有需要继续判断时才进入测试 y 的块。异常路径会增加异常边,后续 assemble 阶段还会把受保护范围和 handler 关系写入 exception table。
阅读 CPython 编译代码时,CFG 的判断顺序是:先找语法结构产生的分支点,再找分支目标是否需要成为基本块开头,然后看块尾用顺序落下、条件跳转、无条件跳转、返回还是异常边连接后续块。这样读 if、loop、try、短路表达式时,关注点会从缩进层级转到 instruction boundary 和 edge。
24.2 Bytecode generation and opcode selection
Bytecode generation 把 AST 节点、symbol table 信息和编译器状态转成 opcode 序列。CPython 源码中的 Python/codegen.c 负责根据 AST 形态发出伪指令;这些伪指令接近 bytecode,但跳转标签、部分抽象操作、异常范围和表项索引还需要后续阶段解析。编译说明中提到的 ADDOP、ADDOP_I、ADDOP_LOAD_CONST、ADDOP_JUMP 这类宏,承担的是“向当前指令序列添加一个操作”的职责。
Opcode 选择同时依赖语法和名字分类。x 是函数参数,symbol table 已经把它归入当前函数的 local,所以读取 x 会走 fast local 相关指令。10、0、1 是字面量常量,会进入 co_consts,加载时 opcode 参数指向常量表中的位置。total 是当前函数中赋值产生的 local,写入会走 local store 指令,读取返回值时再走 local load 指令。
可以用这条简化链路观察 x + 1 的表达式编译方式:先把 x 放到 value stack,再把常量 1 放到 value stack,然后发出二元运算指令,运算结果留在栈顶,最后把栈顶结果写回 total 对应的 local slot。CPython bytecode 是 stack machine 指令流,表达式内部的中间值主要通过 value stack 传递;这会影响后续 stacksize 计算。
if x > 10 的判断也遵循同一模型。编译器先发出加载 x 和加载 10 的指令,再发出比较指令。比较结果位于栈顶,条件跳转指令读取这个结果并决定进入真分支还是假分支。具体版本中条件跳转指令名称会变化,但选择原则稳定:表达式先产出栈顶值,控制流指令再消费这个值并连接 CFG 边。
Names table 与 constants table 是 code object 读取 opcode 参数的上下文。co_consts 保存常量和嵌套 code object,co_names 保存需要按名字解析的全局名、属性名或导入相关名字,co_varnames、cell/free variable 元数据保存局部变量和闭包相关布局。Opcode 参数通常是整数,解释器需要结合对应表才能把参数还原为“加载哪个常量”“读取哪个名字”“访问哪个 local slot”。
这里有一个版本边界。dis 能把 code object 转成人能读的指令视图,但它展示的是当前解释器版本的 bytecode 形态。Python 3.11 引入 inline cache 和 adaptive bytecode 观察选项,Python 3.13 的 dis 输出开始更多使用 logical labels 表示 jump target,Python 3.14 增加 source positions 相关显示选项。阅读 bytecode 时,应把 dis 当作观察当前 CPython code object 的工具,把语义判断落回源码、symbol table、CFG 和 code object 表之间的关系。
24.3 Compiler passes and optimization pass
CPython 编译链路由多段 pass 组成,每段 pass 的输入和输出不同。Parser 把 token stream 转成 AST;symbol table pass 分析每个 code block 的名字分类;codegen pass 把 AST 和名字分类转成伪指令序列;CFG pass 建图并做局部优化;assemble pass 解析标签、计算栈深、生成表并构造 PyCodeObject。这些 pass 把“源码结构”逐步降到“解释器执行材料”。
这条链路可以写成一条最小阅读路径:source → AST → symbol table → pseudo instructions → CFG → optimized CFG → instruction sequence → PyCodeObject。其中 AST 仍保留接近源码的结构,pseudo instructions 已经接近 stack machine,CFG 把跳转目标和基本块显式化,code object 则把 bytecode 与元数据封装给执行器。
Optimization pass 的作用是改变中间表示的形态,同时保持 Python 语义、栈效果、跳转目标和源码位置约束一致。CPython 编译说明把 Python/flowgraph.c 描述为 CFG 优化相关文件,也把最终 bytecode 发射放到 Python/assemble.c。从读源码角度看,优化 pass 的产物应按“控制流是否等价、栈高度是否一致、异常范围是否准确、源码位置是否可追踪”来判断。
以 classify 为例,优化 pass 可以整理空块、跳转链或不可达路径形态,同时要保留这三条语义事实:total = 0 在条件判断前执行;真分支与假分支只有一个会被执行;返回块读取的是分支后最终绑定的 total。如果读到优化后的 CFG 与初始 CFG 形态不同,应先检查这些语义事实是否保留,再检查 opcode 序列是否仍满足相同的栈输入和栈输出。
优化 pass 还受调试和监控信息约束。PEP 626 要求 tracing 打开时只为实际执行的源码行生成 line event,并让 frame 的 f_lineno 对应当前执行源码位置;PEP 文本还说明某些人工生成的 bytecode 需要标记为没有有意义的行号。这个约束会影响编译器处理合成指令、行号表和 source location 的方式,可见 PEP 626。
因此,编译器优化的判断标准并非“指令越少越好”。对 Python 这种需要 traceback、debugger、coverage、profiling 和 monitoring 支持的 runtime,优化后的 bytecode 仍要给工具留下可解释的源码位置和异常边界。读编译器时,要同时看执行语义和工具可观察语义。
24.4 Jump target, stacksize, and exception table
Jump target 是 CFG 边落到 bytecode 后的目标位置。编译早期可以用 basic block 或 logical label 表达“跳到哪里”,assemble 阶段才把目标解析成具体 instruction offset 或版本规定的相对偏移。dis 文档记录了多个版本变化:Python 3.10 起 jump、exception handling 和 loop 指令参数使用 instruction offset;Python 3.12 起 jump 参数相对于跳转指令之后的 cache entries;Python 3.13 输出倾向使用 logical labels 显示目标。这说明跳转显示形式是版本敏感内容。
Stacksize 是 code object 告诉执行器“这个 frame 的 value stack 至少需要多深”的元数据。这个值需要跨多条可达路径计算,因为条件分支、循环和异常路径都会改变栈高度。编译器需要沿 CFG 传播每条路径上的栈高度变化,取所有可达路径上的最大值。x + 1 这类表达式会先增加栈高度,再由运算和 store 消费栈顶;条件跳转会消费判断值;返回指令会消费返回值并结束 frame。
classify 的栈深可以用概念步骤理解。计算 x > 10 时,x 入栈,10 入栈,比较指令消费两个值并产生比较结果,条件跳转再消费结果。计算 x + 1 或 x - 1 时,两个操作数入栈,二元运算消费它们并产生一个结果,store 指令消费结果。返回时,读取 total 入栈,返回指令消费返回值。最大栈深来自这些路径中任意时刻栈上同时存在的最大值。
Exception table 是 CPython 3.11+ 阅读 bytecode 时必须纳入的对象。异常处理关系更多由 code object 中的 exception table 表达,它会记录受保护的 instruction range、handler 目标和恢复时需要的栈状态。解释器执行普通路径时按普通 opcode 推进,发生异常时再根据当前 instruction offset 查找对应 handler。
这会改变阅读 try 相关 bytecode 的方式。看到普通指令流中缺少显式包围整个 try body 的 setup 指令时,应把 exception table 作为确认异常处理关系的入口,检查它是否把受保护范围和 handler 连接起来。dis 在当前版本中会显示 exception handler 目标信息,但显示格式仍受版本影响。
跳转、stacksize 和 exception table 合在一起,构成执行器进入 code object 前需要的控制信息。Jump target 解决“下一条指令在哪里”,stacksize 解决“frame 需要多少 value stack 空间”,exception table 解决“异常从哪个范围转到哪个 handler”。这三者都来自 CFG 与 instruction sequence 的整理结果。
24.5 CFG to bytecode boundary
CFG 到 bytecode 的边界,是结构控制流转成线性 instruction stream 的位置。CFG 适合编译器做分析,因为它保留基本块和边;bytecode 适合解释器执行,因为它是一段按 instruction pointer 推进的线性数据,并通过 jump 参数、exception table 和 code object 表恢复非线性控制流。
classify 在这个边界上发生三类转换。第一类是块顺序选择,编译器把 entry、test、then、else、join 等块排成线性顺序。第二类是标签解析,原先指向 basic block 的 jump 变成指向 instruction target 的参数。第三类是元数据封装,constants、names、varnames、stacksize、line table、exception table 和 bytecode 一起成为 code object。
这条边界也是源码阅读的分层点。读 Python/codegen.c 时,重点是 AST 节点如何选择 opcode 和参数来源;读 Python/flowgraph.c 时,重点是基本块、边和优化;读 Python/assemble.c 时,重点是 logical label、instruction offset、stacksize、exception table、locations table 和 PyCodeObject 的构造。三段源码解决的问题不同,把它们混在一起读会导致定位困难。
迁移到任意 Python 片段时,可以按这个顺序检查:先确认当前源码结构会产生哪些分支、循环或异常范围;再确认相关名字在 symbol table 中属于 local、global、free 还是 cell;接着判断表达式会怎样在 value stack 上产生和消费中间值;然后把分支点和汇合点画成基本块;最后观察 code object 的表、跳转目标、stacksize 和 exception table 是否支撑同一组语义事实。
这个顺序也解释了 dis 的边界。dis 展示的是 assemble 之后的 code object 视图,适合验证 opcode、jump label、表项解释和 source position。AST、symbol table 和优化前 CFG 属于更早的编译阶段。需要从 dis 反推源码时,应把每条指令放回“它消费什么栈值、产生什么栈值、参数来自哪张表、它把 instruction pointer 指向哪里”这四个问题中。
本章建立的判断是:CPython 编译器先用 AST 和 symbol table 确定“要做什么”和“名字在哪里”,再用 CFG 确定“控制如何流动”,最后用 bytecode、jump target、stacksize 和 exception table 交给解释器执行。源码中的缩进和语法结构,最终会变成 code object 中可执行、可跳转、可追踪的一组材料。
最小自检任务
只阅读下面代码,判断它在编译期会形成哪些核心 CFG 区域,并说明 value、0、1、2 分别会如何影响 opcode 参数来源和 value stack 行为。
def normalize(value):
if value < 0:
return 0
return value + 1 + 2
答案要点
这段代码至少包含 entry/test、negative-return、positive-return 和 exit 相关区域。if value < 0 的测试区域会加载 local value 和常量 0,比较结果进入栈顶,条件跳转消费这个结果并选择进入负数返回路径或正数返回路径。
value 是函数参数,symbol table 会把它归入当前函数 local,后续读取应走 local slot。0、1、2 是字面量常量,会进入 co_consts,加载时 opcode 参数指向常量表位置。负数路径加载常量 0 后返回;正数路径加载 value、1、2 并通过加法指令逐步消费和产生栈顶值,最后由 return 指令消费返回值。
跳转目标在 CFG 阶段可以是 basic block 或 logical label,在 assemble 阶段会解析成当前版本 bytecode 使用的目标表示。Stacksize 需要覆盖测试路径和正数表达式路径,取所有可达路径上的最大栈高度。这个题的核心结论是:语法上只有一个 if,编译期需要同时记录分支边、常量表、local 布局、栈效果和返回路径。
本章知识点总结
- CFG:CFG 用基本块和有向边表达控制流,适合编译器分析分支、循环、汇合和异常路径。
- 基本块:基本块内部顺序执行,块尾通过跳转、返回、异常或顺序落下连接后续块。
- 条件分支:
if会形成测试块、真分支、假分支和可能的汇合块,语法树结构会转成图结构。 - 伪指令:CPython codegen 阶段先发出接近 bytecode 的伪指令,后续阶段再解析标签和表项。
- Opcode 选择:Opcode 选择依赖 AST 节点形态和 symbol table 对名字的分类。
- 常量表:字面量和嵌套 code object 会进入
co_consts,加载常量的 opcode 参数指向表项位置。 - 名字表:全局名、属性名和导入相关名字依赖
co_names等表提供参数解释上下文。 - Value stack:CPython bytecode 使用 value stack 传递表达式中间值,运算指令消费操作数并产生结果。
- 优化 pass:CFG 优化可以改变中间表示形态,但必须保持语义、栈效果、跳转和源码位置约束一致。
- Jump target:跳转目标从 basic block 或 logical label 解析为当前版本 bytecode 使用的目标表示。
- Stacksize:
co_stacksize来自所有可达路径上的最大 value stack 高度。 - Exception table:Python 3.11+ 的 code object 使用 exception table 记录受保护范围、handler 目标和异常恢复信息。
- Code object:Assemble 阶段把 bytecode、表、栈深、源码位置和异常表封装成解释器可执行的
PyCodeObject。 - 阅读顺序:阅读编译结果时,应按源码结构、名字分类、栈效果、CFG 边、code object 元数据的顺序定位。