Skip to content

Commit 2cbd97f

Browse files
committed
新增「编译的产物:code object 与 pyc」章节,收尾第三部分编译
拆解 PyCodeObject 字段、wordcode 字节码与表下标、lnotab、marshal 序列化,以及 pyc 的 16 字节头与缓存校验
1 parent 729eab9 commit 2cbd97f

7 files changed

Lines changed: 338 additions & 4 deletions

File tree

.vitepress/config.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export default defineConfig({
6464
text: '第 3 部分:编译',
6565
items: [
6666
{ text: '从源码到字节码(编译过程)', link: '/compile/source-to-bytecode/' },
67-
{ text: '编译的产物:code object 与 pyc(编写中…)', link: '/' }
67+
{ text: '编译的产物:code object 与 pyc', link: '/compile/code-object/' }
6868
]
6969
},
7070
{

SUMMARY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
## 第 3 部分:编译
2626

2727
- [从源码到字节码(编译过程)](compile/source-to-bytecode/index.md)
28-
- 编译的产物:code object 与 pyc
28+
- [编译的产物:code object 与 pyc](compile/code-object/index.md)
2929

3030
## 第 4 部分:虚拟机
3131

Lines changed: 60 additions & 0 deletions
Loading

compile/code-object/index.md

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
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+
![PyCodeObject 的字段分组](code-object-fields.svg)
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+
![wordcode 与表的下标关系](wordcode.svg)
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+
![pyc 文件结构](pyc-layout.svg)
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

Comments
 (0)