Chapter 68: compile.c and symtable.c
读 Python 源码时,compile.c 和 symtable.c 连接的是两个世界:前面是 parser 交出的 AST,后面是 ceval.c 能执行的 code object。读完本章后,读者应能定位一个源码片段在编译阶段经历的表示变化,判断一个名字为什么会被编译成 local、global、free、cell 或 nonlocal 访问,并把 opcode 选择、常量表、名字表、跳转、basic block 和 exception table 放回同一条编译链中解释。
本章以一个短函数为贯穿材料。它包含外层局部变量、内层函数、nonlocal、生成器表达式和普通全局名字查询,足够暴露 symtable.c 与 compile.c 的分工。
def make_accumulator(seed):
total = seed
def add(delta):
nonlocal total
total = total + delta
return total
return (add(item) for item in range(3))
这个片段的用户可见行为来自运行时执行,前置决定来自编译阶段。total 是否进入 closure、add 是否被生成器表达式捕获、range 走哪类名字查找、item 属于哪个作用域、生成器表达式是否产生新的 code object,都在 bytecode 执行前已经被分析或编码。CPython 当前源码组织会演进;在 Python 3.11+ 的主线中,官方源码树里 compile.c 仍是 _PyAST_Compile() 入口和编译状态枢纽,AST 到指令序列的生成大量落在 codegen.c,CFG 优化和组装路径涉及 flowgraph.c、assemble.c 等文件。Roadmap 使用 compile.c and symtable.c 作为章节标题,本章按这个稳定阅读入口解释编译链,不把某个开发分支的文件拆分当成所有版本的固定布局。相关背景可对照 CPython 开发文档的 compiler design notes 与 CPython 源码树中的 Python/compile.c、Python/symtable.c。
68.1 compiler pipeline
compiler pipeline 要回答的问题是:一段已经通过语法解析的 Python 源码,如何变成可以被 interpreter 执行的 PyCodeObject。这里的 pipeline 指源码进入 parser 之后的编译链:AST 提供语法结构,symbol table 提供名字分类,code generation 产生指令序列,CFG 和 basic block 整理跳转与栈深度,assembler 写出最终 bytecode、常量表、名字表、line table 和 exception table。
贯穿材料可以被拆成四层表示。第一层是 AST,表达“这里有一个函数定义,函数体里有赋值、内层函数定义和返回语句”。第二层是 symbol table,表达“total 在外层函数中绑定,同时被内层函数通过 nonlocal 访问,所以外层需要提供 cell;add 在外层绑定,又被生成器表达式读取,所以外层也需要把它暴露给内层 code object;range 没有在局部绑定,后续会走 global/builtins 查询”。第三层是 instruction sequence 和 CFG,表达“先建立外层函数 code object,再在调用时建立 add 的函数对象和生成器对象,跳转与异常边界按 basic block 组织”。第四层是 PyCodeObject,表达“执行所需的 bytecode、常量、名字、局部变量、freevars、cellvars 和位置信息已经冻结到一个可运行对象中”。
这条链的关键关系可以用一个简化图表示。图中只展示编译阶段,不展开 parser 内部,也不进入 ceval.c 的逐条 opcode 执行。
图里的 symtable.c 位于 AST 之后、opcode 选择之前。这个位置决定了它的职责:它不执行表达式,也不分配 Python 对象的运行时值;它只在语法结构上判断名字属于哪个作用域类别,并把判断结果交给后面的 code generation。后续生成 LOAD_FAST、LOAD_GLOBAL、LOAD_DEREF 或相关 closure 指令时,compiler 依赖的正是这份名字分类。
compile.c 处在更大的枢纽位置。它创建 compiler state,保存当前 compilation unit,跟踪当前 scope 的 metadata,维护 constants、names、varnames、cellvars、freevars 等表,并协调 codegen、CFG、优化和 assemble。对于源码阅读,compile.c 适合先读入口和状态结构,再沿调用跳到 symtable.c、codegen.c、flowgraph.c 或 assembler;直接从某个 opcode 生成函数开始读,容易丢失“这个 opcode 为什么被选中”的前置条件。
对贯穿材料来说,编译链先为 module 生成一个 code object,其中包含 make_accumulator 的函数 code object 常量。调用 make_accumulator 时才执行外层函数的 bytecode;外层函数执行时会创建 add 函数对象,并返回一个生成器对象。生成器对象对应独立 code object,里面读取外层提供的 add,同时拥有自己的局部迭代变量 item。这个层级说明:编译阶段已经把未来运行时需要的 code object 嵌套关系准备好,运行时只是按调用、返回、resume 的顺序激活它们。
读这一链路时应固定一个检查顺序:先确认 AST 中的 code block 边界,再读 symbol table 的名字分类,再看 compiler unit 如何建立 metadata,接着看 AST 节点如何产生指令序列,最后看 CFG、栈深度和组装如何把指令序列变成 PyCodeObject。这个顺序能把“语法长什么样”和“执行时查哪里”连接起来。
68.2 symbol analysis
symbol analysis 要回答的问题是:一个名字在当前 code block 中是绑定、读取、声明、捕获,还是需要交给外层或全局查询。symtable.c 的输入是 AST,输出是每个作用域的 symbol table entry。entry 中保存当前 block 的名字集合、参数、局部绑定、显式 global、显式 nonlocal、free variable、cell variable、comprehension 或 generator scope 等信息。
在贯穿材料中,外层函数 make_accumulator 是一个 code block。seed 来自参数绑定,total 由赋值语句绑定,add 由内层函数定义绑定。生成器表达式 (add(item) for item in range(3)) 也形成一个可 resume 的 code block;它有自己的迭代变量 item,并读取外层的 add。内层函数 add 声明 nonlocal total,所以 total 的绑定位置被锁定到外层函数。于是外层的 total 需要从普通 local 升级为 cell,供内层函数通过 closure 访问。
这一步的核心产物可以压缩成一张表。表中“角色”是编译阶段的分类结果,后续 opcode 选择会消费这些结果。
| 名字 | 出现位置 | symbol analysis 结论 | 后续影响 |
|---|---|---|---|
seed | make_accumulator(seed) | 外层函数参数,也是 fast local | 外层函数用局部槽访问 |
total | 外层赋值,内层 nonlocal | 外层 cell variable,内层 free variable | 通过 closure cell 共享绑定 |
add | 外层函数定义,生成器表达式读取 | 外层 local,并因被生成器捕获而进入 cell/free 关系 | 生成器 code object 读取外层函数对象 |
delta | add(delta) | 内层函数参数 | add 的局部槽 |
item | 生成器表达式 | 生成器作用域的局部迭代变量 | 不泄漏到外层函数局部名字 |
range | 生成器表达式外侧 iterable 构造 | 当前函数中无绑定 | 通过 global/builtins 路径解析 |
global 和 nonlocal 的差异在 symbol analysis 阶段已经定型。global name 把当前 block 对该名字的绑定与读取指向 module global namespace,并允许后续运行时再落到 builtins 查询。nonlocal name 要求外层非全局函数作用域中存在相应绑定,并把当前 block 的读写连接到外层 cell。nonlocal total 的存在使 add 内部的 total = total + delta 不再创建新的局部 total,而是读写外层 closure cell。
free variable 与 cell variable 是同一条捕获关系的两端。一个名字在当前 block 中被绑定,并被内层 block 读取或写入时,当前 block 需要提供 cell;同一个名字在内层 block 中表现为 free variable。这个分类不依赖运行时值的类型。total 可以绑定到 int、list 或任何对象,symbol analysis 只关心名字绑定和跨作用域访问关系。
comprehension scope 的版本边界需要单独固定。Python 3 中生成器表达式始终形成独立可 resume 的执行单元;list、set、dict comprehension 在语言层面保留迭代变量的局部隔离,CPython 3.12+ 对部分 comprehension 采用内联实现以减少函数调用开销。源码阅读时应把“语言作用域语义”和“当前 CPython 版本的实现形态”分开判断:先确认变量是否向外泄漏、是否捕获外层名字,再根据目标版本查看它是否生成独立 code object 或内联指令片段。
symtable.c 还承担语义错误的早期检查。比如 nonlocal total 找不到合法外层绑定时,编译阶段会报告错误;在同一作用域中把一个名字同时声明成 global 和 nonlocal 也会被拒绝。这样的错误来自静态名字关系,和运行时有没有执行到该语句无关。
读 symbol analysis 的可复用顺序是:先划分 code block,再标出每个 block 的绑定点,然后标出读取点和声明语句,接着沿嵌套关系传播 free/cell 信息,最后回到每个名字的最终分类。这个顺序能解释后续 opcode 的来源,也能解释闭包、生成器和 comprehension 的局部隔离。
68.3 bytecode generation
bytecode generation 要回答的问题是:当名字分类和 AST 结构已经确定后,compiler 如何把每个 AST 节点翻译成 stack-based interpreter 能消费的指令。CPython bytecode 是栈式指令模型;多数表达式会把对象压入 value stack,操作类指令从栈顶取输入,再把结果压回栈顶。后续 ceval.c 执行 bytecode 时,依赖的就是这套栈约定。
贯穿材料中,total = seed 的生成逻辑可以理解为“读取 seed,把结果写入 total”。因为 seed 是 fast local,读取会走局部槽;因为 total 后续要被 closure 捕获,写入目标会进入 cell 相关路径。具体 opcode 名称会随 Python 版本调整,阅读时应关注三类信息:读取从哪个名字表或局部槽取值,写入落在哪类 storage,当前指令对 value stack 的输入输出是什么。
def add(delta): ... 的编译分成两个层次。内层函数体本身先被编译成独立 code object,记录 delta、total、返回语句和自由变量关系。外层函数执行到函数定义语句时,会基于这个 code object 和 closure cell 创建 function object,并把它绑定到外层名字 add。所以函数定义在编译结果中同时表现为“一个嵌套 code object 常量”和“运行时创建 function object 的指令序列”。
生成器表达式也分成两个层次。外层需要先构造 iterable,例如对 range(3) 的调用;然后创建生成器对象,把需要捕获的自由变量带给生成器 code object。生成器内部的 code object 负责迭代、绑定 item、调用 add(item)、yield 结果以及在耗尽时结束。这里的关键判断是:range 的查询发生在外层表达式求值阶段,item 的推进发生在生成器 resume 阶段,add 的读取来自生成器捕获到的外层 cell 或 free variable 关系。
下面这段伪代码只表达编译结果的结构,不代表 CPython 真实 opcode 名称和顺序。它用于说明 compiler 在不同层级上生成不同 code object。
# simplified pseudocode for compiler output shape
module_code.constants = [code(make_accumulator)]
code(make_accumulator).cellvars = ["total", "add"]
code(make_accumulator).freevars = []
code(make_accumulator).constants = [code(add), code(genexpr), 3]
code(add).cellvars = []
code(add).freevars = ["total"]
code(genexpr).cellvars = []
code(genexpr).freevars = ["add"]
这个结构说明,code generation 会把 symbol analysis 的结论编码到 code object metadata 中。co_varnames 记录普通局部槽,co_cellvars 记录当前 code object 向内层提供的 cell,co_freevars 记录当前 code object 从外层接收的 cell。运行时 frame 创建时,会根据这些 metadata 建立 fast locals、closure cells 和自由变量访问入口。
stack effect 是读 bytecode generation 的第二个核心。表达式 total + delta 至少需要先把 total 与 delta 两个对象放到栈上,再执行二元运算,最后把结果写回目标。函数调用需要把 callable、参数和调用状态组织到栈或内部调用约定中。跳转指令需要保证不同分支汇合处的栈高度一致。compiler 因此既要生成指令,也要让后续 CFG 与 stack depth 分析能证明指令序列可执行。
名字表和常量表是 bytecode 的索引化结果。源码里的字符串名字、常量数字、嵌套 code object 都不会以源码文本形式直接执行;它们会进入 co_names、co_consts、co_varnames、co_cellvars 或 co_freevars 等表,再由 opcode 参数索引访问。这个设计让运行时执行可以用紧凑整数参数定位对象,同时保留 introspection 所需的 code object metadata。
读 bytecode generation 时,应按 AST 节点类型追踪“先生成哪些子表达式,再生成当前节点指令,最后生成写入、跳转或返回”。对于函数、类、lambda、生成器表达式这类会引入 code block 的节点,还要额外追踪 compiler unit 的进入和退出。这样可以解释同一段源码中为什么有多个 code object,以及每个 code object 的 locals/freevars/cellvars 为什么不同。
68.4 AST traversal
AST traversal 要回答的问题是:compiler 如何保持 Python 语义结构,并按正确顺序把语法特性送入 symbol analysis 和 code generation。AST 是带类型的语法树;函数定义、参数、赋值、调用、生成器表达式、nonlocal 声明、返回语句都以不同节点出现。编译阶段遍历 AST 时,既要保留源码顺序,也要识别哪些节点打开新的 code block。
在贯穿材料中,AST traversal 首先看到顶层 FunctionDef make_accumulator。对 module 来说,这个节点意味着“创建一个函数 code object,并在运行时绑定函数名”。进入外层函数体后,遍历顺序遇到赋值 total = seed、内层 FunctionDef add、返回语句。遍历到 add 时,compiler 需要为内层函数开启新的 scope;遍历到生成器表达式时,也需要为生成器体建立独立执行单元。这个顺序保证 code object 的嵌套关系与源码结构一致。
AST traversal 同时携带 source location。行号、列号和范围信息会进入后续 line table、traceback、debugger、coverage 工具和错误定位。对源码阅读来说,这说明 compiler 生成可执行字节码的同时,还会保留“这条指令大致来自哪段源码”的映射。异常追踪和调试体验依赖这条映射,而这条映射从 AST 节点位置开始传递。
语义结构的保留体现在 evaluation order 上。以返回语句为例,return (add(item) for item in range(3)) 中,外层需要先计算生成器表达式的外侧 iterable。也就是 range(3) 的调用在创建生成器对象时发生;生成器体中的 add(item) 在后续迭代生成器时发生。AST traversal 和 code generation 必须把这个顺序编码到不同 code object 与不同执行时机中。
下面的简化片段用于观察这个顺序。它只使用 Python 代码现象,不要求打开 CPython 源码。
def trace(label):
print(label)
return [1, 2]
def build():
return (x for x in trace("outer iterable"))
g = build()
print("after build")
next(g)
执行 build() 时,trace("outer iterable") 已经运行;next(g) 时,生成器体才推进并产生 x。这个现象对应编译阶段的结构划分:生成器表达式外侧 iterable 属于创建生成器对象前的求值路径,生成器 body 属于生成器 code object 的 resume 路径。AST traversal 把同一行源码切成两个执行阶段,后续 bytecode generation 才能保持语言语义。
作用域信息也依赖 AST traversal 的边界判断。nonlocal total 出现在 add 的函数体中,只影响 add 这个 block 对 total 的解释;它不会改变 module,也不会把 total 变成全局名字。生成器表达式中的 item 只属于生成器作用域;它的绑定点来自 generator 内部迭代协议,不会成为 make_accumulator 的普通局部变量。
读 AST traversal 时,可以用三个问题定位源码:这个节点是否引入新的 code block;这个节点的子表达式按什么顺序求值;这个节点携带的 source location 会被用于哪类错误、调试或 traceback 信息。三个问题合在一起,能把 AST 从“语法树”还原成编译阶段的控制边界。
68.5 compiler optimization
compiler optimization 要回答的问题是:在不改变 Python 语义的前提下,compiler 可以怎样整理指令、basic block、常量、跳转和栈深度。这里的优化属于编译结果整理,边界受语言语义限制;它不能假设用户代码没有副作用,也不能把运行时可变行为提前固化。
CPython 的优化路径具有明显版本敏感性。Python 3.11 引入新的异常表和大量 bytecode 形态调整;Python 3.12 起部分 comprehension 相关实现发生变化;Python 3.13/3.14 开发分支继续调整 opcode、instrumentation、JIT 实验和内部文件边界。因此阅读源码时应把“稳定职责”和“版本形态”分开:稳定职责是构造 CFG、整理 basic block、计算 stack depth、生成 exception table 和最终 code object;版本形态是具体 opcode 名称、文件拆分、inline cache 布局和某些优化 pass 的位置。
basic block 是优化和组装的核心单位。一个 block 内部通常按顺序执行,block 之间通过 jump、fallthrough、exception edge 或返回结束连接。把 instruction sequence 转成 CFG 后,compiler 可以删除不可达 block、合并跳转、修正跳转目标、计算每条路径上的栈高度,并确保分支汇合处的 stack shape 合法。这个过程服务的是后续 ceval.c 的执行安全性和紧凑性。
异常路径在 Python 3.11+ 中以 exception table 表达。早期版本大量使用设置和清理 block 的字节码形态;新版本把异常处理范围、handler 目标和栈恢复信息编码进表结构,让正常路径减少异常管理指令。对当前章节来说,结论是:异常控制流虽然在运行时触发,处理范围和 handler 映射在编译阶段已经由 CFG 与 assembler 准备好。
常量处理也有明确边界。字面量常量、嵌套 code object、某些可折叠表达式可以进入常量表或被整理;涉及函数调用、属性访问、全局名字查询、用户自定义运算的表达式需要保留运行时求值。比如 1 + 2 在某些版本和场景下可以被编译期折叠,range(3) 需要运行时调用,因为 range 是名字查询结果,用户可以在 global namespace 中重新绑定它。
贯穿材料中的 range(3) 正好暴露这个边界。编译器知道 3 是常量,也知道 range 是一个未在当前作用域绑定的名字;编译器不能把 range(3) 固定为某个 range object,因为运行时 range 可能来自 module global,也可能落到 builtins。优化阶段可以整理常量 3 和调用指令形态,却要保留名字解析和调用的运行时路径。
stack depth 计算是容易被忽略的编译产物。每个 code object 需要知道执行该 bytecode 时 value stack 的最大深度,frame 创建时会据此准备运行时空间。这个数值来自所有控制流路径的 stack effect 分析。带有分支、循环、异常和生成器暂停点的函数都需要在 CFG 上计算栈高度;生成正确 opcode 只是第一步,证明这些 opcode 在所有路径上保持合法栈形状同样属于 compiler 的责任。
源码阅读的可复用判断顺序可以固定为五步:第一,看当前语法节点是否建立 code block;第二,看 symtable.c 给每个名字的最终分类;第三,看 code generation 选择的读写和调用指令是否匹配名字分类;第四,看 CFG 如何连接 basic block、跳转和异常边界;第五,看 assembler 如何写出 PyCodeObject 的 tables、bytecode、line table、exception table 和 stack size。用这个顺序阅读 compile.c 与 symtable.c,能从一个 Python 现象追到编译期证据。
最小自检任务
阅读下面的代码,不运行 dis,只根据本章模型判断每个名字的编译期角色,并说明最终会形成哪些 code object。
def outer(limit):
factor = 2
def scale(value):
return value * factor
return (scale(i) for i in range(limit))
问题:limit、factor、scale、value、i、range 分别属于哪类名字关系?为什么生成器表达式会影响 scale 的编译期角色?
答案要点
outer 是 module 中绑定的函数名,module code object 会持有 outer 的函数 code object。调用 outer 时进入外层函数 code object,limit 是外层函数参数,属于 fast local;factor 在外层函数中赋值,并被内层函数 scale 读取,所以外层需要为 factor 提供 cell,scale 中的 factor 是 free variable。
scale 在外层函数中由函数定义绑定,同时被生成器表达式体 scale(i) 读取。生成器表达式拥有独立可 resume 的 code object,所以它需要从外层捕获 scale;这会让外层的 scale 从普通局部绑定进入可被内层读取的 cell/free 关系。value 是 scale 的参数,只属于 scale 的局部槽。i 是生成器表达式的迭代变量,只属于生成器 code object。range 在当前局部作用域没有绑定,后续按 global/builtins 名字路径查找。
最终至少形成 module、outer、scale、生成器表达式四个 code object。具体 opcode 名称和指令序列随 CPython 版本变化;稳定结论是 symbol analysis 先确定名字角色,code generation 再根据这些角色选择 fast local、global 或 closure 相关访问路径,CFG 与 assembler 最后把指令、常量表、名字表、freevars、cellvars 和栈深度写入 PyCodeObject。
本章知识点总结
- 编译链路:CPython 编译阶段把 AST、symbol table、instruction sequence、CFG、basic block、assembler 和
PyCodeObject串成源码到字节码的转换链。 - 入口职责:
compile.c适合作为源码阅读入口,因为它保存 compiler state、当前 compilation unit 和 code object metadata 的构造过程。 - 符号分析:
symtable.c根据 AST 判断名字的绑定、读取、声明和跨作用域捕获关系,并把结果交给 opcode 生成阶段。 - 作用域分类:local、global、free、cell、nonlocal 的差异来自 code block 边界、绑定点、读取点和声明语句的组合。
- 闭包关系:cell variable 是外层 block 提供给内层读取的绑定,free variable 是内层 block 从外层接收的绑定。
- 生成器边界:生成器表达式形成独立可 resume 的 code object,迭代变量属于生成器作用域,捕获名字通过 closure 关系进入生成器。
- 指令生成:bytecode generation 把 AST 节点翻译成栈式指令,并把 constants、names、varnames、cellvars 和 freevars 写入 code object metadata。
- 栈约定:每条指令都有 stack effect,分支汇合、调用、返回和异常路径都需要保持合法的 value stack 形状。
- AST 遍历:AST traversal 保留源码顺序、source location、code block 边界和子表达式求值顺序。
- 优化边界:compiler optimization 可以整理常量、跳转、basic block、栈深度和 exception table,但必须保留名字查询、函数调用和用户可见副作用的运行时语义。
- 版本边界:Python 3.11+ 的 exception table、Python 3.12+ 的 comprehension 实现和开发分支中的 opcode 调整都需要按目标 CPython 版本阅读。
- 阅读顺序:读
compile.c与symtable.c时,应先划分 code block,再看名字分类,再看 opcode 选择,最后看 CFG、stack depth 和PyCodeObject组装。