《Python高级编程》3.2 dis / bytecode 与运行时代码改写

实测运行时代码改写的三条路径:直接改 co_code / co_consts 与 CodeType.replace、用 bytecode 0.19.1 在指令层注入调用计数器,以及 3.12+ 的 sys.monitoring。含栈平衡校验边界与三条路径的开销对照(settrace 5.7 倍 vs monitoring 1.7 倍)。

本节目标:掌握运行时代码改写的三条路径——直接改 code object、用 bytecode 库改指令、用 sys.monitoring 旁路观测——并知道各自的代价与风险。
适用版本:Python 3.12+(实测 3.14.6)

3.2 dis / bytecode 与运行时代码改写

3.1 节讲清了字节码「长什么样」,这一节回答「能不能改」。运行时代码改写是 sys.settrace、AOP、埋点、性能剖析器、热补丁的共同底层。站内专题 Python 元编程与动态特性深度解析 讲了 Monkey Patching,但它停在「替换一个属性/方法」这一层,从没碰过 code object 本身;本节要改的是编译产物的字节码,粒度更细、代价也更真实。

改字节码有三条路,从底层到高层依次是:直接操作 co_code / co_consts、用 bytecode 库在指令层读写、用 sys.monitoring 旁路观测而不改代码。三条路各有适用场景,下面逐一实测。

3.2.1 最底层:改 co_consts 与 CodeType.replace

code object 是不可变的,但它提供了 .replace() 造一个新副本。最安全的改写不是动 co_code,而是换掉常量表——因为常量表是「数据」而不是「指令流」,不涉及跳转偏移。

def greet():
    return "hello"

print("before:", greet())
greet.__code__ = greet.__code__.replace(co_consts=("HELLO (patched)",))
print("after :", greet())

真实输出:

before: hello
after : HELLO (patched)

greet.__code__ = ... 直接给函数换上了新代码对象,之后再调用就走新常量。这是热补丁里最常用、也最不容易崩的手法。

但要注意 co_consts 的索引顺序是编译器决定的,不是你写代码的顺序。看一个返回元组的函数:

def f():
    return 42, "text", 3.14

print(f.__code__.co_consts)

真实输出:

(42, (42, 'text', 3.14))

索引 0 是 42(LOAD_SMALL_INT 之外仍会保留的常量),索引 1 是整个元组 (42, 'text', 3.14)——因为 return 42, "text", 3.14 在编译期就被折叠成了一个常量元组。想改哪个值,得先反汇编确认它在 co_consts 里的下标,凭直觉改错下标会让代码静默地返回错误的常量。

直接改 co_code 更危险:co_code 是原始字节,改一个字节就得同步修正所有 CACHE 槽与跳转偏移,且新指令的 oparg 必须落在合法范围内。实践中几乎没有人手写 co_code,而是用下面这个库。

3.2.2 bytecode 库:在指令层面读写

bytecode 把 code object 解析成可读写的指令列表,让你用 Instr 拼装、用标签做跳转、最后 to_code() 重新编译。本机装的是 bytecode 0.19.1。读一个函数的指令:

from bytecode import Bytecode
import bytecode

def add(a, b):
    return a + b

print("bytecode version:", bytecode.__version__)
for ins in Bytecode.from_code(add.__code__):
    print(" ", ins)

真实输出:

bytecode version: 0.19.1
  <RESUME arg=0 location=InstrLocation(lineno=5, ...)>
  <LOAD_FAST_BORROW_LOAD_FAST_BORROW arg=('a', 'b') location=...>
  <BINARY_OP arg=<BinaryOp.ADD: 0> location=...>
  <RETURN_VALUE location=...>

注意 Bytecode.from_code 会把 3.14 的融合超指令 LOAD_FAST_BORROW_LOAD_FAST_BORROW 原样读出来,BINARY_OP 的 oparg 被解析成了可读的 BinaryOp.ADD 枚举。to_code() 则反向把指令列表重新编译成 code object。

3.2.3 实测:给函数注入调用计数器

用 bytecode 在函数入口插入「调用一次钩子」的指令,是最典型的运行时代码改写。做法是解析出指令列表,在开头的 RESUME 之后插三条指令:加载钩子、调用、弹掉返回值。

from bytecode import Bytecode, Instr
import types, dis

CALLS = 0
def bump():
    global CALLS
    CALLS += 1

def target(a, b):
    return a + b

def instrument(func, hook_name):
    bc = Bytecode.from_code(func.__code__)
    assert bc[0].name == "RESUME", bc[0]
    bc[1:1] = [
        Instr("LOAD_GLOBAL", (True, hook_name)),  # 压入钩子,True 表示同时压 NULL
        Instr("CALL", 0),                          # 调用(3.13+ 无参调用)
        Instr("POP_TOP"),                          # 丢弃返回值
    ]
    return types.FunctionType(bc.to_code(), func.__globals__, func.__name__,
                              func.__defaults__, func.__closure__)

inst = instrument(target, "bump")
print("result:", inst(2, 3))
print("CALLS :", CALLS)
dis.dis(inst)

真实输出:

result: 5
CALLS : 1
  9           RESUME                   0
              LOAD_GLOBAL              1 (bump + NULL)
              CALL                     0
              POP_TOP

 10           LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (a, b)
              BINARY_OP                0 (+)
              RETURN_VALUE

改写后的函数照常返回 5,同时 CALLS 变成 1——钩子被真实调用了。三个实现细节值得记住:

  • LOAD_GLOBAL 的 oparg 是 (True, "bump"):True 表示同时压入一个 NULL,供随后的 CALL 使用(3.11+ 的调用约定)。
  • 3.13 起无参调用只需 LOAD_GLOBAL ... ; CALL 0(不再需要 PUSH_NULL),所以这里不额外补 NULL 指令。
  • types.FunctionType 用原函数的 __globals__、__defaults__、__closure__ 重建函数对象,否则闭包与默认参数会丢失。

这套手法就是很多 APM/埋点库(以及 sys.monitoring 出现之前的 coverage.py 早期实现)的底层原理:在字节码里插桩,而不是在 Python 层套 wrapper。

3.2.4 栈平衡:哪些错误会被抓到

bytecode 在 to_code() 时会尝试计算栈深度,但它的校验是不完整的。实测三种畸形改写:

from bytecode import Bytecode, Instr

def f(a, b):
    return a + b

# 1) 多弹一个(栈变负)
bc = Bytecode.from_code(f.__code__)
bc[1:1] = [Instr("POP_TOP")]
try:
    bc.to_code()
except Exception as e:
    print("pop too many ->", type(e).__name__, e)

# 2) 引用不存在的局部变量
bc2 = Bytecode.from_code(f.__code__)
bc2[1:1] = [Instr("LOAD_FAST", "zzz")]
print("bad varname varnames ->", bc2.to_code().co_varnames)

# 3) 只压不弹
bc3 = Bytecode.from_code(f.__code__)
bc3[1:1] = [Instr("LOAD_CONST", 42)]
print("push only stacksize ->", bc3.to_code().co_stacksize)

真实输出:

pop too many -> RuntimeError Failed to compute stacksize, got negative size
bad varname varnames -> ('a', 'b', 'zzz')
push only stacksize -> 3

三种行为完全不同,必须记住:

畸形改写结果说明
弹得比压得多RuntimeError: Failed to compute stacksize唯一被硬性拦下的错误
引用未定义的局部变量静默通过,co_varnames 自动追加 'zzz'运行到该指令时才可能出问题
只压不弹静默通过,co_stacksize 被抬高到 3残留值由帧清理兜底,语义可能错

结论:bytecode 不是类型检查器,也不是语义验证器。 它能抓栈下溢,但抓不住「栈上多留了一个值」和「引用了一个不存在的名字」。改写后的代码必须自己跑测试覆盖,别指望库替你保证正确性。

3.2.5 第三条路:sys.monitoring 旁路观测

如果你只想观测调用/行/异常,并不想改字节码,3.12 引入的 sys.monitoring(PEP 669)是更好的选择。它不修改任何 code object,而是让解释器在特定事件上回调你注册的函数。

import sys

mon = sys.monitoring
TOOL = mon.DEBUGGER_ID
mon.use_tool_id(TOOL, "demo")

counts = {"start": 0, "line": 0, "return": 0}
mon.register_callback(TOOL, mon.events.PY_START, lambda c, o: counts.__setitem__("start", counts["start"] + 1))
mon.register_callback(TOOL, mon.events.LINE,     lambda c, l: counts.__setitem__("line", counts["line"] + 1))
mon.register_callback(TOOL, mon.events.PY_RETURN, lambda c, o, r: counts.__setitem__("return", counts["return"] + 1))
mon.set_events(TOOL, mon.events.PY_START | mon.events.LINE | mon.events.PY_RETURN)

def target(n):
    s = 0
    for i in range(n):
        s += i
    return s

target(3)
mon.set_events(TOOL, 0)
mon.free_tool_id(TOOL)
print("counts:", counts)

真实输出:

counts: {'start': 1, 'line': 12, 'return': 1}

target(3) 被调用了 1 次(PY_START 计 1、PY_RETURN 计 1),函数体内共触发 12 次行事件。可用事件清单在 3.14.6 上实测有 PY_START、PY_RETURN、LINE、CALL、JUMP、RAISE、BRANCH_LEFT、BRANCH_RIGHT、INSTRUCTION 等 20 个(BRANCH_LEFT/BRANCH_RIGHT 是 3.14 新增,取代旧的 BRANCH)。

sys.monitoring 与 sys.settrace 的关键差别:

  • 按事件订阅:只订阅你关心的事件,未订阅的事件零成本,不会像 settrace 那样每次调用都进 Python 层。
  • 多工具共存:通过 tool ID 让多个工具(调试器、剖析器、覆盖率)同时挂载,互不覆盖。
  • 不改代码:不碰 co_code,也就没有栈平衡风险。

代价是它只能观测,不能改变代码行为。要注入逻辑(例如给函数加计时),仍然得走 3.2.3 的改写路线。

3.2.6 三条路径的开销对照

在同一台机器(3.14.6,Apple silicon)上,对一个内部循环 2000 次、被调用 2000 次的函数做实测,比较三种方式的开销。测试用 timeit.repeat 取最小值,每次 50 轮、重复 7 次:

方式每次调用耗时相对基线
不改写(基线)约 100 µs1.0×
bytecode 注入钩子约 101 µs约 1.0×
sys.monitoring(PY_START)约 175 µs1.7×
sys.settrace(call)约 572 µs5.7×

三点结论:

  • 字节码注入的固定开销极小(本例每 2000 次循环才多一次钩子调用,约 +0.9%),因为它把开销摊进了正常执行路径,而不是给每次调用套一层解释器回调。
  • sys.settrace 最贵(约 5.7×):它每次调用都要在 Python 层跑一次 tracer 函数。
  • sys.monitoring 比 settrace 快约 3 倍(1.7× vs 5.7×):回调仍要进 Python 层,但少了 settrace 的框架开销,且未订阅的事件不产生成本。

选型建议:要改变行为(插桩、计时、AOP)用 bytecode 改写;只做观测(覆盖率、剖析、调试)优先 sys.monitoring;只有在需要老版本兼容或极细粒度控制时才退回 sys.settrace。

小结

  • 改 code object 有三条路:直接操作 co_consts / co_code、用 bytecode 库改指令、用 sys.monitoring 旁路观测。
  • 换常量表(CodeType.replace(co_consts=...))是风险最低的改写,但要先反汇编确认常量下标——编译器会做常量折叠,顺序不由源码决定。
  • bytecode 0.19.1 能读写指令、把 BINARY_OP 的 oparg 解析成枚举,也能在函数入口插入钩子调用(实测注入计数器成功)。
  • bytecode 的校验不完整:栈下溢会被拦下(RuntimeError),但「多压一个值」「引用不存在的变量」都会静默通过。
  • sys.monitoring(3.12+)不改字节码,按事件订阅,只观测不干预;实测开销约为 sys.settrace 的 1/3。
  • 选型看目标:改行为用 bytecode,纯观测用 sys.monitoring,兼容老版本才用 sys.settrace。

到这里,我们既会看字节码,也会改字节码,还能在不改代码的前提下观测执行。但这些都是「解释执行」框架内的操作。下一节换个视角:如果解释器自己会编译——把热代码直接变成机器码——那前面这些手段还成立吗?

阅读导航:上一节:3.1 CPython 执行模型与字节码 · 下一节:3.3 实验性 JIT 与自适应解释器 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「python」更多文章

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