本节目标:读懂 CPython 的栈式求值模型,能用
dis逐条解释一段代码的真实字节码,并说清 3.11+ 自适应专门化在运行时对指令做了什么。
适用版本:Python 3.12+(实测 3.14.6)
3.1 CPython 执行模型与字节码
入门卷 1.3 节给过「源码 → 词法 → 语法 → 字节码 → 虚拟机」的流水线,也演示了 dis 的基本输出。这一节不再重复那条主线,而是往内核里再钻一层:求值栈怎么工作、指令怎么编码、编译器对类和推导式生成了什么、以及字节码在运行时如何被就地改写。站内专题 Python 元编程与动态特性深度解析
讲的是描述符、元类这些语言层的动态机制,从未触及字节码,所以本节与它没有重叠——它解释「属性查找能被拦截」,本节解释「属性查找在字节码层长什么样」。
3.1.1 栈式虚拟机:每条指令都在动一个栈
CPython 的求值循环(ceval)是一台栈式虚拟机:它没有寄存器,所有中间结果都压在一个「求值栈」(value stack)上,每条指令从栈顶取操作数、把结果压回去。看懂这一点,dis 的输出就变成了流水账。拿最简单的加法开刀——用 dis.get_instructions 拿到的每条 Instruction 都带 opcode、arg、argval,再配合 dis.stack_effect 就能算出它对栈深度的净影响:
import dis
def add(a, b):
return a + b
for ins in dis.get_instructions(add):
se = dis.stack_effect(ins.opcode, ins.arg) if ins.arg is not None \
else dis.stack_effect(ins.opcode)
print(f"{ins.offset:>3} {ins.opname:<36} {ins.argrepr:<10} effect={se:+d}")
真实输出(Python 3.14.6):
0 RESUME effect=+0
2 LOAD_FAST_BORROW_LOAD_FAST_BORROW a, b effect=+2
4 BINARY_OP + effect=-1
16 RETURN_VALUE effect=+0
逐条读:
LOAD_FAST_BORROW_LOAD_FAST_BORROW a, b:把局部变量a、b各压一份到栈上,栈深+2。这是 3.14 新增的融合超指令,把两次「借引用压栈」合并成一条。BINARY_OP +:弹出栈顶两个值,相加,把结果压回,净效果-1。RETURN_VALUE:弹出栈顶作为返回值。
注意 offset 从 4 直接跳到 16——中间 12 个字节不是空的,而是 BINARY_OP 的内联缓存槽(cache entries)。在 3.14 上 BINARY_OP 后面挂着 5 个 CACHE 单位,每个 2 字节,正好 12 字节。这些槽位是自适应专门化(见 3.1.5)存运行时状态的地方。
3.1.2 指令是 2 字节编码:opcode + oparg
dis 的友好输出背后是纯字节。直接看 co_code:
import dis
def f(a, b):
return a + b
print("co_code:", f.__code__.co_code)
for ins in dis.get_instructions(f):
print(f"offset={ins.offset:>2} opcode={ins.opcode:>3} {ins.opname}")
真实输出:
co_code: b'\x80\x00W\x01,\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00#\x00'
offset= 0 opcode=128 RESUME
offset= 2 opcode= 87 LOAD_FAST_BORROW_LOAD_FAST_BORROW
offset= 4 opcode= 44 BINARY_OP
offset=16 opcode= 35 RETURN_VALUE
CPython 3.6 起指令定长为 2 字节:第一个字节是 opcode,第二个字节是 oparg(操作数参数,0–255)。co_code 里每两个字节对应一条指令,所以 \x80\x00 是 RESUME 0,W\x01(W=87)是融合指令带 oparg 1,,\x00(,=44)是 BINARY_OP 0,中间 10 个 \x00 是 CACHE,#\x00(#=35)是 RETURN_VALUE。
oparg 的含义由 opcode 决定。同样是「0」,对 BINARY_OP 来说是运算符编号,对 RESUME 来说是模式位。运算符编号来自内部表 dis._nb_ops,实测其前几项是 ('NB_ADD', '+')、('NB_AND', '&')、('NB_FLOOR_DIVIDE', '//')……所以 BINARY_OP 0 就是加法,BINARY_OP 13(NB_INPLACE_ADD)就是 +=。
3.1.3 类与推导式:编译器生成了什么
函数体只是入门难度。看编译器给类和推导式生成了什么,才能体会字节码的层次。
先看一个带类属性和方法的类。compile 一个类定义并反汇编:
import dis
src = '''
class C:
kind = "demo"
def method(self):
return self.kind
'''
dis.dis(compile(src, "<c>", "exec"))
真实输出(节选,class C 内部代码对象):
Disassembly of <code object C ...>:
-- MAKE_CELL 0 (__classdict__)
2 RESUME 0
LOAD_NAME 0 (__name__)
STORE_NAME 1 (__module__)
LOAD_CONST 0 ('C')
STORE_NAME 2 (__qualname__)
LOAD_SMALL_INT 2
STORE_NAME 3 (__firstlineno__)
LOAD_LOCALS
STORE_DEREF 0 (__classdict__)
3 LOAD_CONST 1 ('demo')
STORE_NAME 4 (kind)
4 LOAD_CONST 2 (<code object method ...>)
MAKE_FUNCTION
STORE_NAME 5 (method)
LOAD_CONST 3 (())
STORE_NAME 6 (__static_attributes__)
RETURN_VALUE
几个值得注意的实现细节:
- 类体本身就是一段可执行的字节码,由外层
LOAD_BUILD_CLASS触发的CALL执行。类不是声明,是运行时执行一段代码后拿到的对象。 - 类体会自动注入
__module__、__qualname__、__firstlineno__、__static_attributes__等元信息(后两者由 3.13 引入)。 LOAD_LOCALS(3.12 随 PEP 695 引入,取代旧的LOAD_CLASSDEREF)把类命名空间取到栈上,MAKE_CELL __classdict__再把它存进一个 cell,供方法里的零参super()经__class__引用。- 方法体是另一个独立的代码对象,靠
MAKE_FUNCTION打包后STORE_NAME进类命名空间。
再看列表推导式——3.12 起推导式不再创建独立函数,而是内联到外层代码里(PEP 709):
import dis
dis.dis(compile("[x*2 for x in range(3) if x % 2]", "<c>", "eval"))
真实输出(节选):
1 LOAD_NAME 0 (range)
PUSH_NULL
LOAD_SMALL_INT 3
CALL 1
GET_ITER
LOAD_FAST_AND_CLEAR 0 (x)
SWAP 2
L1: BUILD_LIST 0
SWAP 2
L2: FOR_ITER 28 (to L5)
STORE_FAST_LOAD_FAST 0 (x, x)
LOAD_SMALL_INT 2
BINARY_OP 6 (%)
TO_BOOL
L3: POP_JUMP_IF_TRUE 3 (to L4)
NOT_TAKEN
JUMP_BACKWARD 19 (to L2)
L4: LOAD_FAST_BORROW 0 (x)
BINARY_OP 5 (*)
LIST_APPEND 2
JUMP_BACKWARD 30 (to L2)
L5: END_FOR
POP_ITER
ExceptionTable:
L1 to L3 -> L7 [2]
要点:循环变量 x 被 LOAD_FAST_AND_CLEAR 保存、结束后再 STORE_FAST 还原,以遵守「推导式不泄漏循环变量」的语义;LIST_APPEND 2 的 oparg 表示「结果列表在栈顶往下第 2 个位置」。整个推导式没有 MAKE_FUNCTION,确实内联了。
3.1.4 异常不是跳转,是一张表
3.11 之前,try/except 靠一堆 SETUP_FINALLY 之类的指令在栈上记账。3.11 起改成了异常表(exception table):代码里不再有专门管异常的指令,异常处理的跳转目标被集中记录在代码对象的一张表里。
import dis
src = '''
def f(x):
try:
return 10 // x
except ZeroDivisionError:
return None
'''
dis.dis(compile(src, "<c>", "exec"))
真实输出(f 的代码对象,节选):
4 L1: LOAD_SMALL_INT 10
LOAD_FAST_BORROW 0 (x)
BINARY_OP 2 (//)
L2: RETURN_VALUE
-- L3: PUSH_EXC_INFO
5 LOAD_GLOBAL 0 (ZeroDivisionError)
CHECK_EXC_MATCH
POP_JUMP_IF_FALSE 5 (to L5)
NOT_TAKEN
6 L4: POP_EXCEPT
LOAD_CONST 1 (None)
RETURN_VALUE
5 L5: RERAISE 0
ExceptionTable:
L1 to L2 -> L3 [0]
L3 to L4 -> L6 [1] lasti
L5 to L6 -> L6 [1] lasti
ExceptionTable 的每一行是「起始标签 → 结束标签 → 处理标签 [栈深]」。L1 to L2 -> L3 [0] 的含义是:若 L1 到 L2 之间(也就是 10 // x)抛异常,跳到 L3 的 PUSH_EXC_INFO 开始处理,此时栈深为 0。PUSH_EXC_INFO、CHECK_EXC_MATCH、RERAISE 是 3.11+ 的新异常指令族。异常处理在字节码层不再有专用跳转指令,而是「表驱动 + 通用异常指令」——这是 3.11 最重要的内部重构之一。
3.1.5 自适应专门化:热了以后字节码会变
从 3.11 起(PEP 659),CPython 引入了专门化自适应解释器:代码在冷启动时用通用指令执行,一旦某个位置被反复执行到「热」,解释器就把它就地改写成针对具体类型优化的专门化指令。这解释了 3.1.1 里那些 CACHE 槽位的用途——它们存的就是专门化所需的类型/版本信息。
下面这段代码在同一个方法上分别看冷态和热态。注意 dis.dis 有两个视图:默认视图显示去优化(deopt)后的通用指令,adaptive=True 才显示当前真实的字节码。
import dis
class Counter:
def __init__(self):
self.n = 0
def bump(self):
self.n += 1
return self.n
print("=== 冷:默认视图 ===")
dis.dis(Counter.bump)
c = Counter()
for _ in range(5000):
c.bump()
print("=== 热:adaptive=True ===")
dis.dis(Counter.bump, adaptive=True)
真实输出(节选):
=== 冷:默认视图 ===
6 RESUME 0
7 LOAD_FAST_BORROW 0 (self)
COPY 1
LOAD_ATTR 0 (n)
LOAD_SMALL_INT 1
BINARY_OP 13 (+=)
SWAP 2
STORE_ATTR 0 (n)
8 LOAD_FAST_BORROW 0 (self)
LOAD_ATTR 0 (n)
RETURN_VALUE
=== 热:adaptive=True ===
6 RESUME_CHECK 0
7 LOAD_FAST_BORROW 0 (self)
COPY 1
LOAD_ATTR_INSTANCE_VALUE 0 (n)
LOAD_SMALL_INT 1
BINARY_OP_ADD_INT 13 (+=)
SWAP 2
STORE_ATTR_INSTANCE_VALUE 0 (n)
8 LOAD_FAST_BORROW 0 (self)
LOAD_ATTR_INSTANCE_VALUE 0 (n)
RETURN_VALUE
(热态的默认视图仍然显示通用的 LOAD_ATTR / BINARY_OP,只有 adaptive=True 才能看到真实指令。)对比三件事:
RESUME变成了RESUME_CHECK——表示这段代码已经执行过、跳过了首次进入的完整检查。LOAD_ATTR n变成了LOAD_ATTR_INSTANCE_VALUE n——解释器发现self的类型是Counter、且n是普通实例属性,于是走「直接按实例字典读值」的快路径,不再跑完整的描述符查找。BINARY_OP 13变成了BINARY_OP_ADD_INT 13——发现两个操作数都是int,直接做整数加法。
专门化的种类远不止这三种。实测跑热后还可见到 LOAD_ATTR_NONDESCRIPTOR_WITH_VALUES(类属性、无数据描述符)、LOAD_ATTR_METHOD_WITH_VALUES(绑定方法调用)、LOAD_GLOBAL_BUILTIN(内建函数查找)、CALL_LEN(对 len() 的内建内联)等。
专门化指令是乐观的:一旦运行时的类型不再匹配(例如同一个位置这次传入了 str),解释器会触发去优化,退回通用指令并重新计数。所以同一段代码在不同调用模式下,adaptive=True 看到的内容可能不同。
3.1.6 co_code 与 _co_code_adaptive
一个容易混淆的点:既然字节码会被就地改写,为什么 f.__code__.co_code 看起来没变?因为 CPython 维护了两份字节码:
def f(a, b):
return a + b
c = f.__code__
print("co_code :", c.co_code)
print("_co_code_adaptive:", c._co_code_adaptive)
for _ in range(1000):
f(1, 2)
print("热后 co_code :", c.co_code)
print("热后 _co_code_adaptive:", c._co_code_adaptive)
真实输出:
co_code : b'\x80\x00W\x01,\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00#\x00'
_co_code_adaptive: b'\x80\x00W\x01,\x00\x11\x00\x00\x00\x00\x00\x00\x00\x00\x00#\x00'
热后 co_code : b'\x80\x00W\x01,\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00#\x00'
热后 _co_code_adaptive: b'\xc4\x00W\x01\x82\x00@\x03\x00\x00\x00\x00\x00\x00\x00\x00#\x00'
co_code是规范视图,永远保持通用指令,不随专门化改变;dis.dis默认展示的就是它。_co_code_adaptive才是解释器真正执行的可变副本,专门化就地写在这上面。热了以后\xc4(RESUME_CHECK)替换了\x80(RESUME)、\x82(BINARY_OP_ADD_INT)替换了,(BINARY_OP)。
这个设计让「反汇编」和「执行」解耦:调试器看的是稳定的通用视图,解释器跑的是被优化过的副本,去优化时只需把通用指令写回即可。
3.1.7 3.14 的字节码变化
同一段源码在不同版本生成的字节码并不相同。以 3.14 相对 3.13 为例,官方 What’s New 明确记录了几处新增指令(含伪指令),本机 3.14.6 实测均可见:
| 指令 | 作用 | 实测示例 |
|---|---|---|
LOAD_FAST_BORROW | 借引用压栈,不增加引用计数 | self.n 里的 self |
LOAD_FAST_BORROW_LOAD_FAST_BORROW | 把两次借引用压栈融合成一条 | add(a, b) |
LOAD_SMALL_INT | 直接压入等于 oparg 的小整数 | 字面量 0、1、2 |
LOAD_CONST_MORTAL | 加载可消亡对象常量 | 短生命周期的常量元组 |
(注:RESUME_CHECK 不是 3.14 新增,而是 RESUME 的专门化形式,随 3.11 自适应解释器引入。)LOAD_FAST_BORROW 系列的动机是降低引用计数开销:解释器若能证明帧内的引用活得比压到栈上的引用更久,就不必在压栈时 Py_INCREF。这是 3.14 针对求值循环做的微观优化,语义上对外不可见。反过来,3.14 也移除了 RETURN_CONST 等指令——这就是 .pyc 要带 cpython-314 版本标记的原因:字节码编码随版本变化,跨版本复用会直接失效。
小结
- CPython 是栈式虚拟机:指令从求值栈取操作数、把结果压回,
dis.stack_effect能精确算出每条指令对栈深的净影响。 - 指令是 2 字节定长编码(opcode + oparg),
co_code里每两字节一条;oparg 的含义由 opcode 决定,BINARY_OP的 oparg 就是运算符编号。 - 类体、推导式都是一段可执行字节码;推导式自 3.12 起内联、类体则自动注入
__module__、__qualname__、__static_attributes__等元信息。 - 3.11+ 异常处理改为异常表驱动,代码里不再有专用的异常跳转指令;3.11+ 引入自适应专门化,热代码会被就地改写成
LOAD_ATTR_INSTANCE_VALUE、BINARY_OP_ADD_INT等乐观指令,类型不匹配则去优化。 co_code是永不改变的规范视图,_co_code_adaptive才是解释器执行的、会被专门化的可变副本。- 3.14 新增
LOAD_FAST_BORROW、LOAD_SMALL_INT、LOAD_CONST_MORTAL等指令,动机是减少引用计数与解释开销。
理解了「字节码长什么样」,下一步自然就是「能不能改它」。3.2 节会把 co_code、bytecode 库和 sys.monitoring 三条路径逐一实测,看运行时代码改写到底能做什么、代价是多少。
阅读导航:上一节:2.3 动态代码生成与 AST 变换 · 下一节:3.2 dis / bytecode 与运行时代码改写 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。