Skip to main content

Chapter 25: Bytecode Architecture

Python 源码经过 tokenizer、parser、AST、symbol table 和 CFG 之后,最终进入一个更靠近执行器的表示:CPython bytecode。读懂 bytecode 的目标,是把一段 Python 代码还原成一串可执行指令,并判断每条指令如何读取名字、操作对象、改变 frame value stack,以及把控制流交给下一条指令。

本章围绕一个短函数展开:它先建立局部变量,再遍历输入,再调用 len,最后返回累加结果。这个例子覆盖局部变量、常量表、全局名字、函数调用、循环跳转和返回值,足够支撑 bytecode architecture 的最小模型。

def score(items):
total = 0
for item in items:
total += len(item)
return total

读完本章后,读者应能追踪一段 CPython bytecode 的执行顺序,区分 opcode、argument、stack effect、inline cache 和 specialized opcode 各自承担的角色,并能用一个固定检查顺序从 dis 输出回到源码现象。版本边界以 CPython 为主;bytecode 属于 CPython 实现细节,跨 Python VM 和跨版本都没有稳定承诺,当前细节应以对应版本的 dis 文档 和实现源码为准。

25.1 Bytecode format, opcode system, and stack machine

CPython bytecode 是 code object 中面向解释器的指令流。Python 源码中的表达式、语句、循环、异常路径和函数调用,在编译阶段被转成一组 opcode;运行时,当前 frame 持有 code object、局部变量、全局名字、builtins、instruction pointer 和 value stack,解释器按指令顺序推进执行。

在这个层级,源码中的一行代码已经被拆成多个小动作。total = 0 至少包含“把常量 0 放入栈顶”和“把栈顶对象写入局部变量 total”两个动作。total += len(item) 还会包含名字读取、局部变量读取、函数调用、二元操作和结果写回。bytecode architecture 的核心判断是:每条指令读取什么输入,改变哪个运行时位置,产生什么输出。

可以把 score 的执行路径压缩成下面这张图。图中只展示 bytecode architecture 的边界:源码如何进入 code object,frame 如何带着 value stack 执行指令,adaptive runtime 的细节只作为后续阶段保留。

bytecode 的基本单元由 opcode 和可选 argument 组成。opcode 表示动作类型,例如加载常量、读取局部变量、调用 callable、执行二元操作、跳转或返回。argument 表示这个动作需要的编号或参数,常见来源包括 co_constsco_namesco_varnames、jump target 和比较操作种类。读 bytecode 时,opname 提供人类可读动作,argval 或 argrepr 提供 argument 已解析后的含义。

score 中的常量 0 会进入 co_consts,局部名 itemstotalitem 会进入局部变量表,名字 len 会进入名字表。相应指令自身通常携带能指向这些表项的编号,完整字符串对象保存在 code object 的元数据表中。这个设计让 code object 把“指令流”和“静态元数据”分离:指令流负责顺序和动作,元数据表负责名字、常量和局部变量槽位。

value stack 是理解 CPython bytecode 的主线。许多指令通过栈交换临时值:加载类指令把对象压入栈顶,调用和运算类指令消耗栈顶若干对象并推入结果,写入类指令从栈顶取走对象并写入局部变量或属性位置。total += len(item) 的可观察路径可以描述成:读取 total 到栈,读取 callable len 和参数 item,执行调用得到长度对象,再与原来的 total 做加法,最后把结果写回 total

下面是对 score 的简化 disassembly 观察材料。它保留了关键操作的顺序,省略了不同 CPython 版本中可能出现的初始化、cache、精确跳转标签和 specialized 变体。

LOAD_CONST 0
STORE_FAST total
GET_ITER
FOR_ITER loop_end
STORE_FAST item
LOAD_FAST total
LOAD_GLOBAL len
LOAD_FAST item
CALL 1
BINARY_OP +=
STORE_FAST total
JUMP_BACKWARD loop_start
LOAD_FAST total
RETURN_VALUE

这段输出应按 stack effect 读。LOAD_CONST 00 成为栈顶,STORE_FAST total 取走栈顶对象并写入局部槽位。循环开始后,迭代器位于运行时控制流中,FOR_ITER 负责推进迭代并在结束时跳出循环。循环体内,LOAD_FAST totalLOAD_FAST item 都是从局部槽位读取对象;LOAD_GLOBAL len 走全局名字和 builtins 的查找路径;CALL 1 消耗 callable 和一个参数并把返回值放回栈顶;BINARY_OP += 产生新结果或协议层面的就地结果;STORE_FAST total 再次把结果落回局部槽位。

opcode system 的阅读边界也在这里出现。bytecode 能显示解释器执行的近端动作,对象协议的内部逻辑仍由类型和 runtime 分发负责。CALL 会进入 callable 调用路径,BINARY_OP 会进入数值协议和类型分发,LOAD_GLOBAL 会进入名字查找和缓存路径。bytecode 负责把这些 runtime 入口排列成可执行顺序,具体协议分发仍由对象模型和解释器运行时完成。

25.2 dis module as observation tool

dis module 是观察 CPython bytecode 的标准工具。它接收函数、code object、字符串源码、模块、类或 traceback 等输入,把 code object 中的指令表示成人类可读格式。对源码阅读来说,dis 的价值在于把“我以为 Python 会这样执行”改写成“当前 CPython 版本编译出了这组指令”。

最小观察方式是对函数调用 dis.dis。下面代码展示的是观察入口,具体输出会随 CPython 版本变化。写技术判断时,应把输出当成当前解释器版本下的证据,而非跨版本规范。

import dis


def score(items):
total = 0
for item in items:
total += len(item)
return total


dis.dis(score)

dis 输出通常包含源代码位置、当前指令标记、jump label、instruction offset、opname、argument 和 argument 解释。Python 3.13 以后,默认输出对 jump target 和 exception handler 更偏向 logical label;Python 3.14 增加了显示 source positions 的能力。读取这类输出时,先抓住 opname 和 argrepr,再回头看行号、label 和 offset,阅读负担会小很多。

dis.get_instructionsdis.Bytecode 更适合程序化分析。它们把每条指令表示成 Instruction 对象,常见字段包括 opnameopcodeargargvalargreproffsetpositions 和 cache 相关信息。文本输出适合人工阅读,Instruction 对象适合写检查脚本、教学工具或调试辅助。二者观察的是同一层事实,只是呈现形式不同。

观察 score 时,可以按三层证据读 dis。第一层是源码行到指令段的对应关系,用来判断某行源码大致被编译成哪些动作。第二层是指令之间的栈关系,用来判断某个对象从哪里来、去哪里。第三层是控制流标签和跳转,用来判断循环、条件分支和异常路径如何回到解释器的 instruction pointer。

total += len(item) 为例,dis 帮助读者拆开四个动作。读取 total 说明当前值来自 fast locals;读取 len 说明这里仍存在普通名字查找入口;读取 item 说明循环变量已经被写入局部槽位;调用和二元操作说明运行时会先得到 len(item) 的返回值,再把它与 total 组合,最后写回 total。这个顺序比源码的一行表达式更接近解释器实际推进方式。

dis 也能观察 traceback 对应的出错指令。异常发生时,traceback 持有 frame 和 instruction position 相关信息,dis.Bytecode.from_traceback 可以把当前位置标出来。这样读者可以把异常从“某一行报错”进一步定位到“这一行中的哪条 bytecode 触发了错误”。对包含多个调用或多个下标访问的一行代码,这个定位很有价值。

工具边界需要写清楚。dis 只能告诉读者当前 CPython 编译器和解释器暴露出来的 bytecode 形态,它提供的是 CPython 实现证据,语言规范层面的判断仍要回到 Python language reference。语言语义由 Python language reference 约束,bytecode 是 CPython 为实现这些语义选择的执行表示。工程判断应把两者分层:语义问题先看语言规则,实现和性能问题再看 CPython bytecode、解释器循环和对象协议。

25.3 Inline cache and adaptive opcode

Python 3.11 引入的 specializing adaptive interpreter 让 bytecode 观察多了一层 runtime 状态。普通指令仍表达语言语义,但解释器可以在运行中记录类型、名字查找、属性访问和调用路径的局部事实,并把某些指令替换成更适合当前场景的 specialized 形态。PEP 659 把这条路线称为 Specializing Adaptive Interpreter

Inline cache 是跟随特定指令存放的小型缓存区域。它的作用是把重复执行时能复用的运行时信息放在指令附近,例如全局名字查找的版本信息、属性访问的类型版本、调用路径的形状。缓存命中时,解释器可以减少重复查找和动态判断;缓存失效时,解释器回到通用路径并更新计数或缓存内容。

score 中,LOAD_GLOBAL len 是一个适合观察 inline cache 的位置。第一次执行时,解释器需要按 globals、builtins 等路径查找 len。当同一个 code object 被反复执行,并且相关 namespace 版本保持稳定时,缓存可以记录足够信息,让后续查找更快完成。这里的缓存优化的是名字查找成本,语义仍然是“按 Python 名字解析规则找到 len”。

dis.disshow_caches=True 可以显示 inline cache 相关槽位。较新版本的 Instruction 对象也提供 cache 信息字段,便于把缓存与它服务的 base instruction 联系起来。阅读 cache 时,先确认它跟随哪条 base opcode,再判断它服务的是名字查找、属性访问、二元操作还是调用路径。孤立阅读 CACHE 或 cache 元数据会丢失对象关系。

Adaptive opcode 是解释器运行后出现的 specialized 表示。dis.dis(..., adaptive=True) 可以显示当前可见的 adaptive 或 specialized bytecode。对同一个函数,刚定义后和运行很多次后的输出可能不同,因为 specialization 依赖实际运行数据。这个事实把 bytecode 阅读分成两个层次:原始 bytecode 说明编译器给出的通用执行计划,adaptive bytecode 说明解释器在当前运行历史下形成的优化状态。

specialized opcode 的正确读法是先还原到 base opcode。看到类似属性访问、全局名字、二元操作或调用相关的 specialized 变体时,先问它属于哪个 instruction family,再问它缓存了什么假设,最后问这些假设失效时会回到什么通用路径。这样可以把性能现象和语义现象分开:语义由 base opcode 和对象协议负责,性能差异来自缓存命中、类型稳定性、namespace 版本稳定性和调用形状稳定性。

这一节只建立阅读边界,下一章会展开 PEP 659 的 quickening、specialization family、counter 和 de-optimization。当前章节需要掌握的结论是:inline cache 和 adaptive opcode 是 bytecode architecture 的 runtime 扩展,它们让同一段 Python 源码在执行历史不同的情况下呈现不同的低层形态;读者应把它们看成 CPython 解释器对通用 bytecode 的局部优化状态。

25.4 Bytecode evolution and instruction family

CPython bytecode 随版本持续演进。这个演进包含指令编码、jump argument、exception table、inline cache、输出格式和 specialized 指令族。阅读 bytecode 时,版本边界属于一等信息;同一个 Python 源码在 3.10、3.11、3.13 和 3.14 中可能得到不同 opname、不同 jump 表示和不同 dis 输出格式。

几个版本节点能帮助读者建立稳定边界。Python 3.6 后,每条指令使用固定宽度的基本编码。Python 3.10 后,jump、exception handling 和 loop 指令的 argument 更偏向 instruction offset。Python 3.11 后,部分指令带有 inline cache entries,并且解释器可以显示 adaptive bytecode。Python 3.13 的 dis 输出开始使用 logical labels 表达 jump target 和 exception handler。Python 3.14 增加了 show_positions 和 specialized bytecode 的命令行显示入口。

这些变化说明 bytecode 是 CPython 内部工程接口。它需要服务解释器性能、调试信息、异常处理、监控能力和源码位置映射。指令形态变化并不自动改变 Python 语言语义;同一段 for 循环仍然按迭代协议推进,同一个函数调用仍然按 callable 协议执行,同一个名字读取仍然按作用域和 namespace 规则解析。变化发生在 CPython 如何把这些语义安排成更适合执行器的指令序列。

Instruction family 是阅读演进的稳定抓手。一个 family 由某个 base operation 和多个 specialized 或相关变体构成。例如属性读取、全局名字读取、二元操作和函数调用都容易形成 family,因为它们在 Python 代码中频繁出现,且运行时形状具有可缓存性。读者看到陌生 opname 时,可以先从名字中识别它服务的 base operation,再回到对象模型或协议分发层理解语义。

LOAD_GLOBAL family 为例,base operation 说明源码正在读取一个非局部普通名字。specialized 形态可能记录 globals 或 builtins 的版本信息,减少重复查找。阅读时,稳定问题是“这个名字从哪个 namespace 解析出来”;版本相关问题是“当前 CPython 用什么缓存和 specialized opcode 加速这次解析”。前者属于语言和 runtime 对象关系,后者属于解释器实现和性能边界。

BINARY_OP family 为例,base operation 说明源码执行二元运算或增强赋值。真正的运算语义仍要进入对象类型的数值协议,例如整数加法、字符串拼接、列表增强赋值或自定义类型方法。specialized 形态可以加速常见类型组合,但阅读结论仍要回到两个输入对象、目标操作和结果对象。对 total += len(item)BINARY_OP += 的关键问题是左操作数来自 total,右操作数来自 len(item),结果被 STORE_FAST total 写回。

版本演进还会影响源码位置和调试体验。co_lines()、positions、logical label 和 exception table 都让工具能把 bytecode 更准确地映射回源码范围。源码阅读时,可以利用这些信息定位一行中的具体表达式;性能阅读时,则要把这些调试元数据和执行成本分开看。位置映射帮助解释“哪里执行”,stack effect 和 opcode 才帮助解释“怎么执行”。

25.5 Bytecode reading checklist

读 bytecode 最容易混乱的原因,是同时看源码、opname、栈、局部变量、名字查找、缓存和版本差异。稳定做法是固定检查顺序:先确认版本和观察工具,再确认 code object,再按 instruction sequence 追踪 stack effect,随后处理控制流,最后观察 cache 和 specialized 状态。

第一步,确认 CPython 版本和 dis 参数。版本决定 opname、jump 表示、cache 展示方式和 source position 信息。对 bytecode 结论应写明“在 CPython 某版本下观察到”。如果只是解释 Python 语义,应减少对具体 opname 的依赖,把结论落到名字解析、对象协议、frame 状态和控制流规则。

第二步,确认 code object 的静态表。co_consts 回答常量从哪里来,co_names 回答普通名字有哪些,co_varnames 回答 fast locals 的槽位,嵌套 code object 回答函数、lambda、comprehension 或 class body 如何形成独立编译单元。读 score 时,先知道 0lenitemstotalitem 分别处于哪些表中,后面的 opcode argument 才能解释清楚。

第三步,按顺序追踪 stack effect。每遇到 LOAD_*,写下它把哪个对象放入栈顶;每遇到 CALLBINARY_OP 或比较指令,写下它消耗几个对象并产出什么;每遇到 STORE_*,写下它把栈顶结果写到哪里。这个步骤能把“源码一行”拆成“对象在 frame 中移动的过程”。

第四步,处理控制流。循环和条件分支要看 jump label、fallthrough path 和 backward jump。异常路径还要看 exception table 和 handler label。对 for item in items,关键是迭代器如何推进、循环变量何时写入局部槽位、结束时如何跳出循环。控制流读完后,才能判断某条指令在什么条件下执行,以及它的 stack effect 是否会影响后续路径。

第五步,处理名字查找和协议分发。LOAD_GLOBAL len 只是名字查找入口;要解释查找结果,需要回到 globals、builtins 和 namespace 版本。CALL 1 只是调用入口;要解释调用行为,需要回到 callable 对象、参数绑定和可能的 vectorcall 路径。BINARY_OP += 只是二元操作入口;要解释结果,需要回到对象类型、数值协议和增强赋值语义。

第六步,观察 cache 和 specialized 状态。show_caches=True 用来查看 inline cache,adaptive=True 用来查看当前运行历史下的 adaptive 或 specialized bytecode。阅读顺序应放在 base opcode 和 stack effect 之后,因为 cache 解释性能状态,base opcode 解释执行语义。看到 specialized 变体时,先还原 family,再判断缓存服务的假设和可能失效条件。

把这六步应用到 score,可以得到一个完整复盘:函数 code object 持有常量、名字和局部变量表;初始化语句把 0 写入 total;循环语句通过迭代协议推进 items;循环体读取 total、查找 len、读取 item、调用 callable、执行二元操作并写回;返回语句读取最终 total 并结束 frame。cache 和 specialization 只改变部分查找、调用或运算的执行成本,不改变这条对象流动路径。

这套 checklist 的工程价值在于定位问题。看到性能差异时,先看是否存在高频名字查找、属性访问、调用或二元操作,再看 specialized 状态是否稳定。看到异常时,先看 traceback 对应指令,再回到该指令消耗的对象。看到版本输出差异时,先确认 dis 文档中的版本变化,再把结论退回到 base operation 和 Python 语义层。

最小自检任务

阅读下面代码。请先根据本章的 bytecode reading checklist 推断 combine(values) 的核心 bytecode 路径会包含哪些对象移动和 runtime 入口,再把 dis 作为可选复查工具。

def combine(values):
result = []
for value in values:
result.append(str(value))
return ",".join(result)

要求回答三个问题:result = [] 在 frame 中大致发生什么;result.append(str(value)) 这一行至少涉及哪些名字或属性读取、调用和 stack effect;return ",".join(result) 为什么同时包含常量、属性访问、参数读取和调用。

答案要点

result = [] 会先创建一个新的 list 对象并把它放入栈顶,然后通过局部变量写入指令把它保存到 fast locals 的 result 槽位。这里的关键对象关系是:局部名 result 绑定到当前 frame 中的新 list 对象,后续循环体会持续读取同一个局部槽位。

循环体会先从 values 得到 iterator,再在每轮迭代中把当前元素写入局部槽位 valueresult.append(str(value)) 至少包含读取局部变量 result、读取它的 append 属性或方法入口、读取全局或 builtins 名字 str、读取局部变量 value、调用 str(value)、再调用 append(...)。从 stack effect 看,str(value) 的返回值会成为传给 append 的参数,append 的返回值通常为 None,随后该临时结果会被弹出或丢弃;真正的状态变化发生在 list 对象内部。

return ",".join(result) 会先加载字符串常量 ",",再读取它的 join 属性或方法入口,然后读取局部变量 result 作为参数并执行调用。最终调用结果成为栈顶对象,RETURN_VALUE 消耗这个栈顶对象并结束 frame。这里的核心边界是:bytecode 能展示常量加载、属性访问、参数读取、调用和返回顺序;字符串连接的具体实现成本属于 str.join 的内部实现。

本章知识点总结

  • Bytecode 定位:CPython bytecode 是 code object 中面向解释器的指令流,用来驱动当前 frame 的执行。
  • Opcode 角色:opcode 表示动作类型,argument 通常指向常量表、名字表、局部变量表或跳转目标。
  • Stack 主线:value stack 连接临时对象,加载指令推入对象,调用和运算指令消耗对象并产出结果。
  • Frame 关系:bytecode 执行依赖当前 frame 中的 code object、locals、globals、builtins、instruction pointer 和 value stack。
  • dis 证据dis 把当前 CPython 版本的 code object 指令显示出来,适合验证源码如何被编译成近端执行动作。
  • Instruction 字段Instruction 对象中的 opnameargvalargreprpositions 和 cache 信息能支持程序化分析。
  • 缓存边界:inline cache 服务特定 base opcode,用来保存可复用的运行时查找或分发信息。
  • Adaptive 状态:adaptive 或 specialized bytecode 反映当前执行历史下的优化状态,阅读时应先还原到 base opcode。
  • 指令族方法:陌生 opname 可先归入属性访问、名字读取、二元操作或调用等 instruction family,再回到对象模型解释语义。
  • 版本边界:CPython bytecode 跨版本会改变编码、opname、jump 表示、cache 展示和 dis 输出格式。
  • 控制流阅读:循环、条件和异常路径需要结合 jump label、fallthrough path、backward jump 和 exception table 判断。
  • 检查顺序:稳定阅读顺序是确认版本、确认 code object、追踪 stack effect、处理控制流、解释协议分发、观察 cache 状态。