源码分析Python的栈帧对象的构造和VM执行字节码的原理

一、 什么是堆栈?

栈帧也叫过程活动记录,是编译器用来实现过程/函数调用的一种数据结构。—–百度百科

栈帧对象其中所使用的数据结构是我们常见的栈,栈是一种受限的线性表,具备后进先出的特性。栈这个数据结构具有记忆效果,常见的使用场景比如浏览器的前进和后退。在几乎所有语言中,栈帧都是实现函数调用功能的标配,栈帧中可以保存某一函数运行过程中的上下文信息,即过程活动记录。今天,通过阅读源码一起探究,在Python中,是如何使用栈帧对象(栈结构)来实现函数的调用和相关上下文信息的保存和获取的, 以及分析Python VM执行字节码的原理。

二、 栈帧对象的结构体

搜索Python实现的源码,发现栈帧结构有两部分,一部分是外部栈帧(PyFrameObject),另外一部分是内部栈帧(InterpreterFrame)。先来看第一部分。

1.PyFrameObject对象位于/Include/pyframe.h头文件中:

1
2
3
4
5
6
7
8
9
10
struct _frame {
PyObject_HEAD
struct _frame *f_back; /* 指向前一个栈帧对象 */
struct _interpreter_frame *f_frame; /* 指向栈帧数据 */
PyObject *f_trace; /* 堆栈异常函数 */
int f_lineno; /* 指向与当前字节码指令对应的源码⾏号 */
char f_trace_lines; /* Emit per-line trace events? */
char f_trace_opcodes; /* Emit per-opcode trace events? */
char f_own_locals_memory; /* 栈帧所占有的内存 */
};

2.InterpreterFrame对象位于/Include/internal/pycore_frame.h头文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct _interpreter_frame {
PyFunctionObject *f_func;
PyObject *f_globals; // 全局名字空间
PyObject *f_builtins; // 内建名字空间
PyObject *f_locals; // 局部名字空间
PyCodeObject *f_code; // 代码对象,记录该栈帧的上下文信息
PyFrameObject *frame_obj; // 基本栈帧对象
/* Borrowed reference to a generator, or NULL */
PyObject *generator; // 生成器对象
struct _interpreter_frame *previous; // 指向前一个栈帧对象
int f_lasti; /* 记录最后一条调用的指令 */
int stacktop; /*栈顶指针*/
PyFrameState f_state; /* 栈帧所处的状态*/
PyObject *localsplus[1]; /* 申请的连续的内存地址, 即栈*/
} InterpreterFrame;

看完了上述两个数据结构,栈帧对象是通过双向链表的形式组织起来,我大致绘了如下关于栈帧对象的数据结构图。

https://django-blog-syz.oss-cn-shanghai.aliyuncs.com/frameobject.png

通过上述图,我们可以很清楚的看出来栈帧对象是以双向链表的形式组合在一起。那么这个栈帧对象链又是被谁管理的呢?我们进一步分析。

在Include/internal/pycore_frame.h中,我们可以找到对链栈的一系列的实现,包含判断栈帧的状态、获取栈帧的基地址、获取栈帧的栈顶对象、出栈、入栈,获取栈顶的栈顶地址,获取栈帧的局部变量(局部变量以数组形式存储,在编译时就可确定个数,方便通过偏移量直接定位)等等。首先来看栈帧的初始化函数_PyFrame_InitializeSpecials

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

static inline void
_PyFrame_InitializeSpecials(
InterpreterFrame *frame, PyFunctionObject *func,
PyObject *locals, int nlocalsplus)
{
Py_INCREF(func);
frame->f_func = func;
frame->f_code = (PyCodeObject *)Py_NewRef(func->func_code);
frame->f_builtins = func->func_builtins;
frame->f_globals = func->func_globals;
frame->f_locals = Py_XNewRef(locals);
frame->stacktop = nlocalsplus;
frame->frame_obj = NULL;
frame->generator = NULL;
frame->f_lasti = -1;
frame->f_state = FRAME_CREATED;
frame->is_entry = false;
}

继续顺藤摸瓜,看哪些地方调用它。在Python/ceval.c文件中的_PyEvalFramePushAndInit中,ceval.c文件中基本都是与Python VM执行字节码相关的程序有关。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
static InterpreterFrame *
_PyEvalFramePushAndInit(PyThreadState *tstate, PyFunctionObject *func,
PyObject *locals, PyObject* const* args,
size_t argcount, PyObject *kwnames)
{
// 从函数对象中获取代码对象
PyCodeObject * code = (PyCodeObject *)func->func_code;
// 计算栈帧所需占用的大小
size_t size = code->co_nlocalsplus + code->co_stacksize + FRAME_SPECIALS_SIZE;
// 为栈帧对象申请一块内存,然后加入到本地线程的上下文中
InterpreterFrame *frame = _PyThreadState_BumpFramePointer(tstate, size);
if (frame == NULL) {
goto fail;
}
// 对申请到的栈帧对象进行初始化
_PyFrame_InitializeSpecials(frame, func, locals, code->co_nlocalsplus);
PyObject **localsarray = &frame->localsplus[0];
for (int i = 0; i < code->co_nlocalsplus; i++) {
localsarray[i] = NULL;
}
// 初始化局部变量
if (initialize_locals(tstate, func, localsarray, args, argcount, kwnames)) {
_PyFrame_Clear(frame);
return NULL;
}
return frame;
fail:
/* Consume the references */
for (size_t i = 0; i < argcount; i++) {
Py_DECREF(args[i]);
}
if (kwnames) {
Py_ssize_t kwcount = PyTuple_GET_SIZE(kwnames);
for (Py_ssize_t i = 0; i < kwcount; i++) {
Py_DECREF(args[i+argcount]);
}
}
PyErr_NoMemory();
return NULL;
}

说明:

1._PyEvalFramePushAndInit该函数会用于call_function等字节码中,因为调用函数,意味着创建新的栈帧对象,并加入到栈帧调用链中,进入子函数后,栈帧也相应的跳到对应的栈帧对象,然后执行栈帧对象中的代码对象,执行完成,将返回结果保存到调用方的栈帧对象中并释放回收当前栈帧。

2.该函数中有两个比较重要的函数,分别是_PyThreadState_BumpFramePointer, initialize_locals两个函数,前者时在本地线程的上下文中申请size大小的栈帧对象,并添加到栈帧链的末尾(逻辑结构上认为是链式)。后者时初始化栈帧对象中的函数局部变量,包含函数的参数等。接下来就来分析下这两个函数内部的实现。

3._PyThreadState_BumpFramePointer函数源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
static inline InterpreterFrame *
_PyThreadState_BumpFramePointer(PyThreadState *tstate, size_t size)
{
// 获取上一个栈帧对象在动态数组中地址
PyObject **base = tstate->datastack_top;
if (base) {
// 如果存在栈帧对象
// 计算新栈帧对象的地址
PyObject **top = base + size;
// 不能超过数据栈的上限
assert(tstate->datastack_limit);
if (top < tstate->datastack_limit) {
// 将栈帧对象的地址添加到动态数组
tstate->datastack_top = top;
return (InterpreterFrame *)base;
}
}
return _PyThreadState_BumpFramePointerSlow(tstate, size);
}

struct _ts {
...
PyObject **datastack_top; // 动态数组, 指向动态数组中的元素地址
PyObject **datastack_limit; // 动态数组, 指向动态数组中的元素地址
...

4.initialize_locals函数源码如下:

1
未完待续~

5.看了上述分析,做一个小的总结:

栈帧对象在存储结构上是以连续的动态数组的方式组织起来,逻辑结构上,采用双向链表的方式组织起来。使用数组的好处是可以通过偏移量直接定位数据;使用双向链表的好处是插入(分配),删除(回收)更加高效。所以阅读源码,不仅仅只是阅读,而是学着理解设计者设计和实现的思路并丰富自己。

三、Python 执行过程及其VM的执行原理

众所周知,Python是解释型语言,从Python应用层面来看,Python是允许写一部分代码,就执行一部分代码,使用过jupyter notebook的同学应该都深有体会。

1.一般,所有语言的入口函数基本上都以main函数开头,Python也不例外。Python执行py文件的入口源码位于/Python/pythonrun.c

​ 1).执行Python某py文件时,首先会打开py文件并封装成文件对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PyObject *
PyRun_FileExFlags(FILE *fp, const char *filename, int start, PyObject *globals,
PyObject *locals, int closeit, PyCompilerFlags *flags)
{
PyObject *filename_obj = PyUnicode_DecodeFSDefault(filename);
if (filename_obj == NULL) {
return NULL;
}

PyObject *res = pyrun_file(fp, filename_obj, start, globals,
locals, closeit, flags);
Py_DECREF(filename_obj);
return res;

}

​ 2).根据文件对象,构建AST抽象语法数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static PyObject *
pyrun_file(FILE *fp, PyObject *filename, int start, PyObject *globals,
PyObject *locals, int closeit, PyCompilerFlags *flags)
{

// ...
mod_ty mod;
// 构建AST语法树
mod = _PyParser_ASTFromFile(fp, filename, NULL, start, NULL, NULL,
flags, NULL, arena);

if (closeit) {
fclose(fp);
}

PyObject *ret;
if (mod != NULL) {
// 将AST转为字节码, 然后执行字节码
ret = run_mod(mod, filename, globals, locals, flags, arena);
}
else {
ret = NULL;
}
_PyArena_Free(arena);

return ret;
}

​ 3).将AST抽象语法数转为字节码,并执行字节码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static PyObject *
run_mod(mod_ty mod, PyObject *filename, PyObject *globals, PyObject *locals,
PyCompilerFlags *flags, PyArena *arena)
{
PyThreadState *tstate = _PyThreadState_GET();
// 将AST语法树转为字节码
PyCodeObject *co = _PyAST_Compile(mod, filename, flags, -1, arena);
if (co == NULL)
return NULL;

if (_PySys_Audit(tstate, "exec", "O", co) < 0) {
Py_DECREF(co);
return NULL;
}

// 执行字节码, 最底层调用_PyEval_EvalFrameDefault方法执行字节码对象。
PyObject *v = run_eval_code_obj(tstate, co, globals, locals);
Py_DECREF(co);
return v;
}

总结:

Python执行某py文件的过程大体上可以分为三部:

  • 执行Python某py文件时,首先会打开py文件并封装成文件对象。

  • 根据文件对象,构建AST抽象语法数。

  • 将AST抽象语法数转为字节码,并调用_PyEval_EvalFrameDefault方法执行逐行字节码。

2.在了解了Python执行py文件的过程后,可以发现Python VM真正执行的是一行一行的字节码。Python自己定义了一套字节码,

位于/Include/opcode.h头文件中,在我目前的Python3.11.0 a0版本中,已经增长到155个字节码了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 查看字节码的函数
def test(name, age, *args):
print(name, age)

print(dis(test))
# 对应结果
56 0 LOAD_GLOBAL 0 (print)
2 LOAD_FAST 0 (name)
4 LOAD_FAST 1 (age)
6 CALL_FUNCTION 2
8 POP_TOP
10 LOAD_CONST 0 (None)
12 RETURN_VALUE
None

3.接下来,分析下_PyEval_EvalFrameDefault函数,它是Python VM 执行字节码的核心函数,并结合_PyEval_EvalFrameDefault函数,分析下上述函数对应的各个字节码的执行逻辑。_PyEval_EvalFrameDefault函数位于/Python/ceval.c文件中。由于该函数代码段过长,我这里截取部分进行分析,其他部分用...代替。

首先了解下字节码的结构组成,总共16bit, 根据自己主机的CPU端模式的架构决定,我的电脑上是小端存储。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#ifdef WORDS_BIGENDIAN
// 字节码大小为16bit, 包含两部分, 操作码和操作数
// 字节序列为大端方式正序存储,即高位字节排放在内存的低地址端, 低位字节排列在内存的高地址端
# define _Py_OPCODE(word) ((word) >> 8)
# define _Py_OPARG(word) ((word) & 255)
# define _Py_MAKECODEUNIT(opcode, oparg) (((opcode)<<8)|(oparg))
#else
// x86的CPU默认是小端
// 字节序列为小端方式存储, 即高位字节排放在内存的高地址端, 低位字节排放在内存的低地址端
// opcode -------> oparg
// 低地址 --------> 高地址
// 低位 -------> 高位
// 8bit | 8bit
# define _Py_OPCODE(word) ((word) & 255)
# define _Py_OPARG(word) ((word) >> 8)
# define _Py_MAKECODEUNIT(opcode, oparg) ((opcode)|((oparg)<<8))
#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
// 虚拟机执行字节码的核心代码
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate, InterpreterFrame *frame, int throwflag)
{
// ...
// 当前指令的操作码
int opcode;
// 当前指令的操作数
int oparg;
// 返回结果
PyObject *retval = NULL;

// ...
// 获取栈帧对象中的代码对象
PyCodeObject *co = frame->f_code;

// ...
// 名字空间
PyObject *names = co->co_names;
// 常量空间
PyObject *consts = co->co_consts;

// ...
// 获取栈顶指针的地址,等于frame->localsplus+frame->stacktop, 即栈帧基地址 + 栈顶指针偏移量
PyObject **stack_pointer = _PyFrame_GetStackPointer(frame);
frame->stacktop = -1;
frame->f_state = FRAME_EXECUTING;

// ...
// 获取指令的操作码
opcode = _Py_OPCODE(*next_instr);

// ...
dispatch_opcode:
switch (opcode) {
TARGET(NOP): {
DISPATCH();
}

/* We keep LOAD_CLOSURE so that the bytecode stays more readable. */
// 加载局部变量值或闭包变量值
TARGET(LOAD_CLOSURE):
TARGET(LOAD_FAST): {
PyObject *value = GETLOCAL(oparg);
if (value == NULL) {
goto unbound_local_error;
}
Py_INCREF(value);
// 将局部变量推入栈帧
PUSH(value);
DISPATCH();
}

// 加载常量值
TARGET(LOAD_CONST): {
PREDICTED(LOAD_CONST);
PyObject *value = GETITEM(consts, oparg);
Py_INCREF(value);
// 将常量值推入栈帧
PUSH(value);
DISPATCH();
}

// 保存变量到局部名字空间
TARGET(STORE_FAST): {
PREDICTED(STORE_FAST);
// 从栈帧中弹出局部变量值, 并保存到局部名字空间
PyObject *value = POP();
// 保存到栈中指定oparg操作数对应的位置
SETLOCAL(oparg, value);
DISPATCH();
}
// 调用bound_method对象
TARGET(CALL_METHOD): {
/* Designed to work in tamdem with LOAD_METHOD. */
PyObject **sp, *res;
int meth_found;

sp = stack_pointer;
/* `meth` is NULL when LOAD_METHOD thinks that it's not
a method call.

Stack layout:

... | NULL | callable | arg1 | ... | argN
^- TOP()
^- (-oparg)
^- (-oparg-1)
^- (-oparg-2)

`callable` will be POPed by call_function.
NULL will will be POPed manually later.
If `meth` isn't NULL, it's a method call. Stack layout:

... | method | self | arg1 | ... | argN
^- TOP()
^- (-oparg)
^- (-oparg-1)
^- (-oparg-2)

`self` and `method` will be POPed by call_function.
We'll be passing `oparg + 1` to call_function, to
make it accept the `self` as a first argument.
*/
// 用于检测回调的对象是否是bound_method函数, 如果是标识1, 否标识0
// 这里的oparg表示第一个参数在栈中的位置
meth_found = (PEEK(oparg + 2) != NULL);
res = call_function(tstate, &sp, oparg + meth_found, NULL, cframe.use_tracing);
stack_pointer = sp;

STACK_SHRINK(1 - meth_found);
// 将结果推入栈
PUSH(res);
if (res == NULL) {
goto error;
}
CHECK_EVAL_BREAKER();
DISPATCH();
}

// 回调普通function函数
TARGET(CALL_FUNCTION): {
PREDICTED(CALL_FUNCTION);
PyObject **sp, *res;
// 指向栈顶的指针
sp = stack_pointer;

// 创建新栈帧对象, 获取当前栈中的函数名, 参数并进入新栈帧中回调, 然后将结果res推入当前栈中,并回收新创建的栈帧对 象。
res = call_function(tstate, &sp, oparg, NULL, cframe.use_tracing);
stack_pointer = sp;
PUSH(res);
if (res == NULL) {
goto error;
}
CHECK_EVAL_BREAKER();
DISPATCH();
}

说明:

  • 未完待续~