本节目标:掌握运行时代码改写的三条路径——直接改 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 µs | 1.0× |
bytecode 注入钩子 | 约 101 µs | 约 1.0× |
sys.monitoring(PY_START) | 约 175 µs | 1.7× |
sys.settrace(call) | 约 572 µs | 5.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=...))是风险最低的改写,但要先反汇编确认常量下标——编译器会做常量折叠,顺序不由源码决定。 bytecode0.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 与自适应解释器 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。