《Python高级编程》3.1 CPython 执行模型与字节码

深入 CPython 执行内核:栈式虚拟机的求值栈与指令编码、dis 的栈效应计算、类与推导式的代码生成、异常表机制,以及 3.11+ 自适应专门化如何在运行时把 LOAD_ATTR 改写为 LOAD_ATTR_INSTANCE_VALUE,附 3.14 字节码变化实测对照。

本节目标:读懂 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 才能看到真实指令。)对比三件事:

  1. RESUME 变成了 RESUME_CHECK——表示这段代码已经执行过、跳过了首次进入的完整检查。
  2. LOAD_ATTR n 变成了 LOAD_ATTR_INSTANCE_VALUE n——解释器发现 self 的类型是 Counter、且 n 是普通实例属性,于是走「直接按实例字典读值」的快路径,不再跑完整的描述符查找。
  3. 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 与运行时代码改写 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时