|
| 1 | +# 编译的产物:code object 与 pyc |
| 2 | + |
| 3 | +上一章我们走完了编译管线,得到了最终产物——一个 **code object**。这一章就把它拆开看:里面的字节码、常量表、名字表分别长什么样,以及它是怎么被存成 `.pyc` 文件、下次导入时直接复用的。 |
| 4 | + |
| 5 | +`compile()`、函数的 `__code__`、模块的顶层代码,拿到的都是 code object(C 层的 `PyCodeObject`)。它本质上是**一个把「这段代码运行所需的一切」打包在一起的对象**。 |
| 6 | + |
| 7 | +## code object 的字段 |
| 8 | + |
| 9 | +先看它的结构。字段不少,但按用途分成三组就清晰了: |
| 10 | + |
| 11 | +`源文件:`[Include/code.h](https://github.com/python/cpython/blob/v3.7.0/Include/code.h#L21) |
| 12 | + |
| 13 | +```c |
| 14 | +// Include/code.h —— PyCodeObject(节选) |
| 15 | +typedef struct { |
| 16 | + PyObject_HEAD |
| 17 | + int co_argcount; // 位置参数个数 |
| 18 | + int co_nlocals; // 局部变量个数 |
| 19 | + int co_stacksize; // 求值栈最大深度 |
| 20 | + int co_flags; // 标志位(CO_OPTIMIZED 等) |
| 21 | + int co_firstlineno; // 起始行号 |
| 22 | + PyObject *co_code; // 字节码(bytes) |
| 23 | + PyObject *co_consts; // 常量表(tuple) |
| 24 | + PyObject *co_names; // 全局名/属性名表(tuple) |
| 25 | + PyObject *co_varnames; // 局部变量名表(tuple) |
| 26 | + PyObject *co_freevars; // 自由变量名表 |
| 27 | + PyObject *co_cellvars; // cell 变量名表 |
| 28 | + PyObject *co_filename; // 源文件名 |
| 29 | + PyObject *co_name; // 对象名(函数名/模块名) |
| 30 | + PyObject *co_lnotab; // 字节码偏移 ↔ 源码行号 的映射 |
| 31 | + ...... |
| 32 | +} PyCodeObject; |
| 33 | +``` |
| 34 | + |
| 35 | + |
| 36 | + |
| 37 | +- **基本信息**(`co_argcount`、`co_nlocals`、`co_stacksize`、`co_flags`、`co_firstlineno`):虚拟机执行这段代码、建立栈帧、绑定参数时要用的元数据。 |
| 38 | +- **执行素材**(`co_code` 及它引用的 `co_consts`、`co_names`、`co_varnames`……):字节码本身,以及字节码会按下标引用的几张表。下一节细看。 |
| 39 | +- **调试与标识**(`co_filename`、`co_name`、`co_lnotab`):出错时的 traceback、调试器定位「哪一行」靠它们。 |
| 40 | + |
| 41 | +这些字段都能在 Python 层直接读到。拿一个简单函数看看: |
| 42 | + |
| 43 | +```python |
| 44 | +>>> def add(a, b): |
| 45 | +... c = a + b |
| 46 | +... return c |
| 47 | +... |
| 48 | +>>> co = add.__code__ |
| 49 | +>>> co.co_argcount |
| 50 | +2 |
| 51 | +>>> co.co_varnames # 局部名表:两个参数 + 一个局部变量 |
| 52 | +('a', 'b', 'c') |
| 53 | +>>> co.co_nlocals |
| 54 | +3 |
| 55 | +>>> co.co_consts # 这个函数没有字面量,只有隐含的 None |
| 56 | +(None,) |
| 57 | +>>> co.co_name, co.co_firstlineno |
| 58 | +('add', 2) |
| 59 | +``` |
| 60 | + |
| 61 | +`co_varnames` 是 `('a', 'b', 'c')`——参数和局部变量都在里面,**顺序就是它们的编号**。这个编号,正是字节码用来指代它们的下标。 |
| 62 | + |
| 63 | +## 字节码:每条指令两字节 |
| 64 | + |
| 65 | +`co_code` 是一段 `bytes`,也就是真正的**字节码**。从 Python 3.6 起,字节码采用 **wordcode** 格式:**每条指令固定两字节——前一字节是操作码 `opcode`(干什么),后一字节是参数 `oparg`(对谁干)**。 |
| 66 | + |
| 67 | +把上面 `add` 函数体反汇编出来(用标准库 `dis`),就能看到这些指令。下面是它在 **3.7** 下的字节码: |
| 68 | + |
| 69 | +``` |
| 70 | + 3 0 LOAD_FAST 0 (a) |
| 71 | + 2 LOAD_FAST 1 (b) |
| 72 | + 4 BINARY_ADD |
| 73 | + 6 STORE_FAST 2 (c) |
| 74 | +
|
| 75 | + 4 8 LOAD_FAST 2 (c) |
| 76 | + 10 RETURN_VALUE |
| 77 | +``` |
| 78 | + |
| 79 | +> 不同 Python 版本的字节码指令会有出入(3.8+ 还会多出一些指令),这里展示的是 3.7 的形式,用来说明结构即可。 |
| 80 | +
|
| 81 | +每行从左到右是:源码行号、字节码偏移、操作码、参数、以及参数解析后的含义。偏移是 `0, 2, 4, 6, 8, 10`——每条指令占两字节,所以两两递增。读一遍就是这段代码的执行步骤: |
| 82 | + |
| 83 | +- `LOAD_FAST 0`:把局部变量 0 号(`a`)压上求值栈; |
| 84 | +- `LOAD_FAST 1`:把 1 号(`b`)压栈; |
| 85 | +- `BINARY_ADD`:弹出栈顶两个值相加,结果压栈(它不需要参数); |
| 86 | +- `STORE_FAST 2`:把栈顶存进 2 号局部变量(`c`); |
| 87 | +- 最后把 `c` 压栈、`RETURN_VALUE` 返回。 |
| 88 | + |
| 89 | +这里的关键是:**`oparg` 通常是某张表的下标**。`LOAD_FAST 0` 的 `0` 不是数字 0,而是 `co_varnames[0]`——也就是名字 `a`。 |
| 90 | + |
| 91 | + |
| 92 | + |
| 93 | +不同指令查不同的表:取局部变量的 `LOAD_FAST` 查 `co_varnames`,取全局名的 `LOAD_GLOBAL` 查 `co_names`,取常量的 `LOAD_CONST` 查 `co_consts`。所以**字节码本身很紧凑(全是小整数下标),真正的名字和常量都集中放在那几张表里**。这些指令具体怎么在虚拟机里执行,是下一部分的主题;这里只要建立「指令 + 下标 → 表里的值」这个印象。 |
| 94 | + |
| 95 | +## lnotab:字节码与源码行号的对应 |
| 96 | + |
| 97 | +注意上面反汇编里左侧的行号(`3`、`4`)。字节码本身是线性的指令流,并不带行号;「第 6 号字节码属于源码第 3 行」这种对应关系,单独存在 `co_lnotab` 里——一张紧凑编码的「字节码偏移 ↔ 行号」映射表。 |
| 98 | + |
| 99 | +它平时不影响执行,只在**需要把字节码位置翻译回源码位置时**才用到:抛异常打印 traceback、调试器单步、`trace`/`profile` 统计行号,背后都是查这张表。所以一个 code object 不光能跑,还随身带着「我从哪行源码来」的信息。 |
| 100 | + |
| 101 | +## marshal:把 code object 序列化 |
| 102 | + |
| 103 | +code object 是个内存对象。要想把编译结果存到磁盘、下次直接用,就得把它**序列化**成字节序列——这件事由 `marshal` 模块负责。`marshal` 是 CPython 内部专用的序列化格式,能处理 code object 这种内置类型,且与具体 Python 版本绑定: |
| 104 | + |
| 105 | +```python |
| 106 | +>>> import marshal |
| 107 | +>>> data = marshal.dumps(co) # code object → bytes |
| 108 | +>>> type(data).__name__ |
| 109 | +'bytes' |
| 110 | +>>> co2 = marshal.loads(data) # bytes → code object |
| 111 | +>>> co2.co_varnames # 还原如初 |
| 112 | +('a', 'b', 'c') |
| 113 | +``` |
| 114 | + |
| 115 | +> `marshal` 不同于 `pickle`:它格式更底层、更快,但**不保证跨版本兼容**,也不为通用对象设计——它就是给「保存字节码」这类内部用途准备的。 |
| 116 | +
|
| 117 | +`marshal.dumps(code)` 正是 `.pyc` 文件主体的来源。 |
| 118 | + |
| 119 | +## pyc 文件:编译结果的缓存 |
| 120 | + |
| 121 | +现在拼出完整的 `.pyc`。每次 `import` 一个模块,Python 都要编译它;为避免重复编译,编译结果会被缓存成 `__pycache__/xxx.cpython-37.pyc`。一个 `.pyc` 文件 = **16 字节头 + `marshal` 序列化的 code object**。 |
| 122 | + |
| 123 | +这 16 字节头的布局(3.7 起遵循 [PEP 552](https://peps.python.org/pep-0552/)),可以在 [Lib/importlib/_bootstrap_external.py](https://github.com/python/cpython/blob/v3.7.0/Lib/importlib/_bootstrap_external.py#L536) 里看到生成代码: |
| 124 | + |
| 125 | +```python |
| 126 | +# Lib/importlib/_bootstrap_external.py —— 按时间戳的 pyc |
| 127 | +data = bytearray(MAGIC_NUMBER) # 前 4 字节:magic number |
| 128 | +data.extend(_w_long(0)) # 接 4 字节:flags(0 = 按时间戳) |
| 129 | +data.extend(_w_long(mtime)) # 接 4 字节:源文件修改时间 |
| 130 | +data.extend(_w_long(source_size)) # 接 4 字节:源文件大小 |
| 131 | +data.extend(marshal.dumps(code)) # 其余:序列化的 code object |
| 132 | +``` |
| 133 | + |
| 134 | + |
| 135 | + |
| 136 | +四个部分各司其职: |
| 137 | + |
| 138 | +- **magic number**:标识编译用的 Python 版本,**每个版本都不一样**——导入时一比对就能拒收别的版本生成的 `.pyc`,避免字节码不兼容。 |
| 139 | +- **flags**:PEP 552 引入的标志位。为 `0` 是默认的「按时间戳」校验;最低位为 `1` 则是「按哈希」校验(后 8 字节改存源文件内容的哈希,适合可重现构建)。 |
| 140 | +- **mtime + size**(或 hash):用来判断**源文件有没有改过**——若源文件的修改时间或大小和 `.pyc` 里记的对不上,就说明缓存过期,需要重新编译。 |
| 141 | + |
| 142 | +我们可以亲手把一个 `.pyc` 的头读出来看看: |
| 143 | + |
| 144 | +```python |
| 145 | +>>> import py_compile, struct, tempfile, os |
| 146 | +>>> d = tempfile.mkdtemp() |
| 147 | +>>> src = os.path.join(d, "m.py") |
| 148 | +>>> open(src, "w").write("x = 1 + 2\n") |
| 149 | +10 |
| 150 | +>>> pyc = py_compile.compile(src) |
| 151 | +>>> raw = open(pyc, "rb").read(16) |
| 152 | +>>> int.from_bytes(raw[0:2], "little") # magic 数(本机为 3.12) |
| 153 | +3531 |
| 154 | +>>> struct.unpack("<I", raw[4:8])[0] # flags:0 = 按时间戳 |
| 155 | +0 |
| 156 | +>>> struct.unpack("<I", raw[12:16])[0] # source_size:源文件 10 字节 |
| 157 | +10 |
| 158 | +``` |
| 159 | + |
| 160 | +`source_size` 正是 `"x = 1 + 2\n"` 的 10 个字节。至于 magic:本机是 3.12,所以读出 `3531`;**在 3.7.0 上这个数是 `3394`**(`MAGIC_NUMBER` 定义在同一文件里)。magic 随版本变化,恰恰是它存在的意义——保证一个 `.pyc` 只被生成它的那个 Python 版本使用。 |
| 161 | + |
| 162 | +导入一个模块时,Python 就按这套规则走:先比 magic(版本对不对),再按 flags 校验源文件有没有变;**全部通过,就直接 `marshal.loads` 还原 code object,跳过整条编译管线**;否则重新编译并刷新 `.pyc`。这就是为什么第二次 `import` 总是更快。 |
| 163 | + |
| 164 | +--- |
| 165 | + |
| 166 | +小结一下: |
| 167 | + |
| 168 | +- **code object**(`PyCodeObject`)把「运行一段代码所需的一切」打成一个包:基本信息、执行素材(字节码 + 几张表)、调试标识; |
| 169 | +- `co_code` 是 **wordcode** 字节码,**每条指令两字节**(opcode + oparg),`oparg` 往往是 `co_consts`/`co_names`/`co_varnames` 等表的**下标**——字节码紧凑,名字常量集中存表; |
| 170 | +- `co_lnotab` 记录「字节码偏移 ↔ 源码行号」,供 traceback 和调试器定位; |
| 171 | +- **`marshal`** 把 code object 序列化成字节;**`.pyc`** = 16 字节头(magic + PEP 552 标志 + mtime/size 或 hash)+ 序列化的 code object,是编译结果的缓存,靠 magic 防跨版本、靠 mtime/size 防源码变更。 |
| 172 | + |
| 173 | +到这里,第三部分「编译」就讲完了:源码经四阶段管线编译成 code object,再缓存为 `.pyc`。下一部分,我们终于要让虚拟机跑起来——看看这些字节码是**怎么被一条条执行**的。 |
0 commit comments