古明地觉的编程教室 2024年09月30日
深度解密 Python 虚拟机的执行环境:栈帧对象
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文深入探讨了 Python 虚拟机运行字节码的原理,重点介绍了栈帧的概念和作用。栈帧是虚拟机执行的上下文,它存储了程序运行时所需的各种信息,例如局部变量、参数、全局变量、函数调用链路等。文章还介绍了栈帧的底层结构,即 PyFrameObject 和 _PyInterpreterFrame,解释了它们之间的区别和联系,以及每个字段的含义。通过分析栈帧的结构和运行机制,我们可以更好地理解 Python 代码的执行过程,以及虚拟机是如何管理程序执行环境的。

🤔 **栈帧是 Python 虚拟机执行字节码的上下文**,它存储了程序运行时所需的各种信息,例如局部变量、参数、全局变量、函数调用链路等,而这些信息无法静态地存储在 PyCodeObject 对象中。

🧐 **栈帧在底层由 PyFrameObject 表示**,它包含了栈帧的所有信息,但为了提高性能,虚拟机将 PyFrameObject 中的核心字段提取出来,形成了更加轻量级的 _PyInterpreterFrame,只有在需要更全面的帧信息时,才会创建 PyFrameObject。

🚀 **虚拟机在执行时会根据 PyCodeObject 对象动态创建出栈帧对象**,然后在栈帧里面执行字节码,每一个栈帧都会维护一个 PyCodeObject 对象,并且通过 f_back 字段将它们链接起来,形成一条执行环境链表,或者说栈帧链表,这模拟了 x64 机器上栈帧之间通过 RSP 和 RBP 指针建立的联系。

💪 **栈帧的结构设计使得 Python 虚拟机能够高效地管理程序执行环境**,并能够在函数调用和返回时快速切换执行上下文,确保程序的正常运行。

💡 **通过分析栈帧的结构和运行机制,我们可以更好地理解 Python 代码的执行过程,以及虚拟机是如何管理程序执行环境的**,这对于我们深入理解 Python 语言的底层机制,以及进行更高效的程序设计具有重要意义。

原创 古明地觉 2024-09-30 11:33 北京


楔子


从现在开始,我们将剖析虚拟机运行字节码的原理。前面说了,Python 解释器可以分为两部分:Python 编译器和 Python 虚拟机。

编译器将源代码编译成 PyCodeObject 对象之后,就由虚拟机接手整个工作。虚拟机会从 PyCodeObject 中读取字节码,并在当前的上下文中执行,直到所有的字节码都被执行完毕。

那么问题来了,既然源代码在经过编译之后,字节码指令以及静态信息都存储在 PyCodeObject 当中,那么是不是意味着虚拟机就在 PyCodeObject 对象上进行所有的动作呢?

很明显不是的,因为尽管 PyCodeObject 包含了关键的字节码指令以及静态信息,但有一个东西是没有包含、也不可能包含的,就是程序在运行时的执行环境,这个执行环境在 Python 里面就是栈帧



栈帧:虚拟机的执行环境


那什么是栈帧呢?我们举个例子。

name = "古明地觉"
def some_func():
    name = "八意永琳"
    print(name)
some_func()
print(name)

上面的代码当中出现了两个 print(name),它们的字节码指令相同,但执行的效果却显然是不同的,这样的结果正是执行环境的不同所产生的。因为环境的不同,name 的值也不同。


因此同一个符号在不同环境中可能指向不同的类型、不同的值,必须在运行时进行动态捕捉和维护,这些信息不可能在 PyCodeObject 对象中被静态存储。


因此虚拟机并不是在 PyCodeObject 对象上执行操作的,而是在栈帧对象上。虚拟机在执行时,会根据 PyCodeObject 对象动态创建出栈帧对象,然后在栈帧里面执行字节码。所以栈帧是虚拟机执行的上下文,执行时依赖的所有信息都存储在栈帧中。


因此对于上面的代码,我们可以大致描述一下流程:






虚拟机和操作系统


不难发现,Python 虚拟机执行字节码这个过程,就是在模拟操作系统运行可执行文件。比如:

程序加载

内存管理

指令执行

资源管理

异常处理


我们简单地画一张示意图,来看看在一台普通的 x64 机器上,可执行文件是以什么方式运行的,在这里主要关注栈帧的变化。假设有三个函数,函数 f 调用了函数 g,函数 g 又调用了函数 h。

首先 CPU 有两个关键的寄存器,它们在函数调用和栈帧管理中扮演关键角色。

RSP(Stack Pointer):栈指针,指向当前栈帧的顶部,或者说最后一个入栈的元素。因此随着元素的入栈和出栈,RSP 会动态变化。由于地址从栈底到栈顶是逐渐减小的,所以 RSP 会随着数据入栈而减小,随着数据出栈而增大。当然不管 RSP 怎么变,它始终指向当前栈的顶部。

RBP(Base Pointer):基指针,指向当前栈帧的基址,它的作用是提供一个固定的参考点,用于访问当前函数的局部变量和参数。当新的帧被创建时,它的基址会保存上一个帧的基址,并由 RBP 指向。

我们用一段 C 代码来解释一下。

#include <stdio.h>
int add(int a, int b) {
    int c = a + b;
    return c;
}
int main() {
    int a = 11;
    int b = 22;
    int result = add(a, b);
    printf("a + b = %d\n", result);
}

当执行函数 add 时,那么当前帧显然就是函数 add 的栈帧,而调用者的帧(上一级栈帧)显然就是函数 main 的栈帧。

栈是先入后出的数据结构,地址从栈底到栈顶是减小的。对于一个函数而言,所有对局部变量的操作都在自己的栈帧中完成,而调用函数的时候则会为其创建新的栈帧。

当执行函数 main 的时候,RSP 指向 main 栈帧的顶部,RBP 指向 main 栈帧的基址。然后在 main 里面又调用了函数 add,那么毫无疑问,系统会在地址空间中,在 main 的栈帧之上为 add 创建栈帧。然后让 RSP 指向 add 栈帧的顶部,RBP 指向 add 栈帧的基址,而 add 栈帧的基址保存了上一级栈帧(main 栈帧)的基址。

当函数 add 执行结束时,会销毁对应栈帧,再将 RSP 和 RBP 恢复为创建 add 栈帧之前的值,这样程序的执行流程就又回到了函数 main 里面,当然程序的运行空间也回到了函数 main 的栈帧中。

不难发现,通过两个 CPU 寄存器 RSP、RBP,以及栈帧中保存的上一级栈帧的基址,完美地维护了函数之间的调用链,这就是可执行文件在 x64 机器上的运行原理。

那么 Python 里面的栈帧是怎样的呢?



栈帧的底层结构


相较于 x64 机器上看到的那个简简单单的栈帧,Python 的栈帧实际上包含了更多的信息。注:栈帧也是一个对象。

// Include/pytypedefs.h
typedef struct _frame PyFrameObject;
// Include/internal/pycore_frame.h
struct _frame {
    PyObject_HEAD
    PyFrameObject *f_back;     
    struct _PyInterpreterFrame *f_frame; 
    PyObject *f_trace;          
    int f_lineno;               
    char f_trace_lines;         
    char f_trace_opcodes;       
    char f_fast_as_locals;      
};
typedef struct _PyInterpreterFrame {
    PyCodeObject *f_code; 
    struct _PyInterpreterFrame *previous;
    PyObject *f_funcobj; 
    PyObject *f_globals; 
    PyObject *f_builtins; 
    PyObject *f_locals;
    PyFrameObject *frame_obj;
    _Py_CODEUNIT *prev_instr;
    int stacktop;
    uint16_t return_offset;
    char owner;
    PyObject *localsplus[1];
} _PyInterpreterFrame;

栈帧在底层由 PyFrameObject 表示,在 3.11 之前,所有字段都保存在该结构体中。但里面有一部分字段,在大部分情况下都用不到,比如一些用于 Debug 的字段。而这些不常用的字段,显然会导致内存浪费,因为创建栈帧时要为所有字段都申请内存空间。

于是从 3.11 开始,虚拟机将 PyFrameObject 里面的核心字段提取出来,形成了更加轻量级的 _PyInterpreterFrame,从而减少内存使用并提高性能。

通过这种拆分,虚拟机在大多数情况下只需使用轻量级的 _PyInterpreterFrame 即可,只有在需要完整的帧信息时,才会创建 PyFrameObject。

但要强调的是,由于 _PyInterpreterFrame 里面没有 PyObject,所以它不是 Python 对象,它只是包含了栈帧的核心结构,真正的栈帧对象仍是 PyFrameObject。只不过对于虚拟机而言,很多时候只需实例化 _PyInterpreterFrame 结构体,即可完成任务。

另外 _PyInterpreterFrame 除了更轻量、结构更紧凑、创建速度快之外,它对 CPU 缓存也非常友好。

我们知道 Python 对象都是申请在堆上的,栈帧也不例外,当调用嵌套函数时,这些栈帧对象会零散在堆区的不同位置,对缓存不友好。但 _PyInterpreterFrame 则不是这样,虚拟机为它专门引入了一个 Stack,这是一段预分配的内存区域,专门用于存储 _PyInterpreterFrame 实例。

当需要创建 _PyInterpreterFrame 实例时,只需要改动一下栈指针,内存便创建好了。当需要销毁时,直接将它从栈的顶端弹出即可,不需要显式地释放内存。并且由于 _PyInterpreterFrame 都是紧密排列在一起,所以对缓存也更加友好。



字段含义解析与代码演示


下面来看一下这两个结构体里面的字段都表示啥含义,不过在解释字段含义之前,我们需要先知道如何在 Python 中获取栈帧对象。

import inspect
def foo():
    # 返回当前所在的栈帧
    # 这个函数实际上是调用了 sys._getframe(1)
    return inspect.currentframe()
frame = foo()
print(frame) 
"""
<frame at 0x100de0fc0, file '.../main.py', line 6, code foo>
"""

print(type(frame)) 
"""
<class 'frame'>
"""

我们看到栈帧的类型是 <class 'frame'>,正如 PyCodeObject 对象的类型是 <class 'code'> 一样,这两个类没有暴露给我们,所以不可以直接使用。

同理,还有 Python 的函数,类型是 <class 'function'>,模块的类型是 <class 'module'>。这些解释器都没有给我们提供,如果直接使用的话,那么 frame、code、function、module 只是几个没有定义的变量罢了,这些类我们只能通过这种间接的方式获取。

下面我们来看一下 PyFrameObject 里面每个字段的含义。

PyObject_HEAD

对象的头部信息,所以栈帧也是一个对象。

PyFrameObject *f_back

当前栈帧的上一级栈帧,也就是调用者的栈帧。所以 x64 机器是通过 RSP、RBP 两个指针维护函数的调用关系,而 Python 虚拟机则是通过栈帧的 f_back 字段。

import inspect
def foo():
    return inspect.currentframe()
frame = foo()
print(frame)
"""
<frame at 0x100de0fc0, file '.../main.py', line 6, code foo>
"""

# foo 的上一级栈帧,显然对应的是模块的栈帧
print(frame.f_back)
"""
<frame at 0x100adde40, file '.../main.py', line 12, code <module>>
"""

# 相当于模块的上一级栈帧,显然是 None
print(frame.f_back.f_back)
"""
None
"""

所以通过栈帧,你可以轻松地获取完整的函数调用链路,我们一会儿演示。

struct _PyInterpreterFrame *f_frame

指向 struct _PyInterpreterFrame 实例,它包含了栈帧的核心结构。

PyObject *f_trace

追踪函数,用于调试。

int f_lineno

获取该栈帧时的源代码行号。

import inspect
def foo():
    return inspect.currentframe()
frame = foo()
print(frame.f_lineno)  # 4

我们是在第 4 行获取的栈帧,所以打印结果是 4。

char f_trace_lines

是否为每一行代码调用追踪函数,当设置为真(非零值)时,每当虚拟机执行到一个新的代码行时,都会调用追踪函数。这允许调试器在每行代码执行时进行干预,比如设置断点、检查变量等。

char f_trace_opcodes

是否为每个字节码指令调用追踪函数,当设置为真时,虚拟机会在执行每个字节码指令之前调用追踪函数。这提供了更细粒度的控制,允许进行指令级别的调试。

所以不难发现,f_trace_lines 是行级追踪,对应源代码的每一行,通常用于普通的调试,如设置断点、单步执行等,并且开销相对较小。f_trace_opcodes 是指令级追踪,对应每个字节码指令,通常用于更深层次的调试,比如分析具体的字节码执行过程,并且开销较大。

import sys
def trace_lines(frame, event, arg):
    print(f"行号:{frame.f_lineno},文件名:{frame.f_code.co_filename}")
    return trace_lines
sys.settrace(trace_lines)

设置追踪函数一般需要通过 sys.settrace,不过不常用,了解一下即可。

char f_fast_as_locals

要解释这个字段,需要用到后续的知识,所以这里先简单了解一下即可。Python 函数的局部变量是采用数组存储的,以便快速访问,这就是所谓的 fast locals。

但有时候我们就是需要一个字典,里面包含所有的局部变量,这时候可以调用 locals 函数,将局部变量的名称和值以 key、value 的形式拷贝到字典中。而 f_fast_as_locals 字段则负责标记这个拷贝过程是否发生过。

然后再来看看 _PyInterpreterFrame 结构体里面的字段,我们说栈帧的核心字段都在该结构体中。

PyCodeObject *f_code

栈帧对象是在 PyCodeObject 之上构建的,所以它内部一定有一个字段指向 PyCodeObject。

import inspect
def e():
    f()
def f():
    g()
def g():
    h()
def h():
    frame = inspect.currentframe()  # 获取栈帧
    func_names = []
    # 只要 frame 不为空,就一直循环,并将函数名添加到列表中
    while frame is not None:
        func_names.append(frame.f_code.co_name)
        frame = frame.f_back
    print(f"函数调用链路:{' -> '.join(func_names[:: -1])}")
f()
"""
函数调用链路:<module> -> f -> g -> h
"""

模块 -> f -> g -> h,显然我们获取了整个调用链路,是不是很有趣呢?

struct _PyInterpreterFrame *previous

指向上一个 struct _PyInterpreterFrame,该字段底层没有暴露出来。

PyObject *f_funcobj

指向对应的函数对象,该字段解释器没有暴露出来。

PyObject *f_globals

指向全局名字空间(一个字典),它是全局变量的容身之所。是的,Python 的全局变量是通过字典存储的,调用函数 globals 即可拿到该字典。

# 等价于 name = "古明地觉"
globals()["name"] = "古明地觉"
# 等价于 print(name)
print(globals()["name"])  # 古明地觉
def foo():
    import inspect
    return inspect.currentframe()
frame = foo()
# frame.f_globals 同样会返回全局名字空间
print(frame.f_globals is globals())  # True
# 相当于创建了一个全局变量 age
frame.f_globals["age"] = 18
print(age)  # 18

关于名字空间,我们后面会用专门的篇幅详细说明。

PyObject *f_locals

指向局部名字空间(一个字典),但和全局变量不同,局部变量不存在局部名字空间中,而是静态存储在数组中。该字段先有个印象,后续再详细说。

PyObject *f_builtins

指向内建名字空间(一个字典),显然一些内置的变量都存在里面。

def foo():
    import inspect
    return inspect.currentframe()
frame = foo()
print(frame.f_builtins["list"]("abcd"))
"""
['a', 'b', 'c', 'd']
"""

和我们直接使用 list("abcd") 是等价的。

PyFrameObject *frame_obj

这个不用多说,负责指向 PyFrameObject 对象。

_Py_CODEUNIT *prev_instr

指向上一条已执行完毕的字节码指令,比如虚拟机要执行第 n 条指令,那么 prev_instr 便指向第 n - 1 条指令。由于每个指令都带有一个参数,所以 _Py_CODEUNIT 类型的大小是 2 字节。

int stacktop

表示栈顶相对于 localsplus 数组的偏移量。

uint16_t return_offset

表示 RETURN 指令相对 prev_instr 的偏移量,这个值只对被调用的函数有意义,它指示了函数返回后,调用者应该从哪里继续执行。它会在 CALL 指令(调用函数时)和 SEND 指令(发送数据到协程或生成器时)中设置。

这个设计允许更高效的函数返回处理,因为虚拟机可以直接跳转到正确的位置,而不需要额外的查找或计算。

def main():
    x = some_func()  # CALL 指令在这里
    y = x + 1     # 函数返回后应该执行的下一条指令
def some_func():
    return 42

当调用 some_func 时,虚拟机会执行 CALL 指令,在 CALL 指令中,会设置 return_offset。当执行完 some_func 的 RETURN 指令时,它会使用 return_offset 来决定跳转到调用者(main)中的哪个位置。

这种机制的优点是不需要在运行时计算返回位置,因为它已经在调用时预先计算好了,特别适用于处理生成器和协程等复杂控制流。

char owner

表示帧的所有权信息,用于区分帧是在虚拟机栈上的,还是单独分配的。

PyObject *localsplus[1]

一个柔性数组,负责维护 "局部变量 + cell 变量 + free 变量 + 运行时栈",大小在运行时确定



以上就是栈帧内部的字段,这些字段先有个印象,后续在剖析虚拟机的时候还会继续细说。

总之我们看到,PyCodeObject 并不是虚拟机的最终目标,虚拟机最终是在栈帧中执行的。每一个栈帧都会维护一个 PyCodeObject 对象,换句话说,每一个 PyCodeObject 对象都会隶属于一个栈帧。并且从 f_back 可以看出,虚拟机在实际执行时,会产生很多的栈帧对象,而这些对象会被链接起来,形成一条执行环境链表,或者说栈帧链表。

而这正是 x64 机器上栈帧之间关系的模拟,在 x64 机器上,栈帧之间通过 RSP 和 RBP 指针建立了联系,使得新栈帧在结束之后能够顺利地返回到旧栈帧中,而 Python 虚拟机则是利用 f_back 来完成这个动作。

当然,获取栈帧除了通过 inspect 模块之外,在捕获异常时,也可以获取栈帧。

def foo():
    try:
        1 / 0
    except ZeroDivisionError:
        import sys
        # exc_info 返回一个三元组
        # 分别是异常的类型、值、以及 traceback
        exc_type, exc_value, exc_tb = sys.exc_info()
        print(exc_type)  # <class 'ZeroDivisionError'>
        print(exc_value)  # division by zero
        print(exc_tb)  # <traceback object at 0x00000135CEFDF6C0>
        # 调用 exc_tb.tb_frame 即可拿到异常对应的栈帧
        # 另外这个 exc_tb 也可以通过下面这种方式获取
        # except ZeroDivisionError as e; e.__traceback__
        print(exc_tb.tb_frame.f_code.co_name)  # foo
        print(exc_tb.tb_frame.f_back.f_code.co_name)  # <module>
        # 显然 tb_frame 是当前函数 foo 的栈帧
        # 那么 tb_frame.f_back 就是整个模块对应的栈帧
        # 而 tb_frame.f_back.f_back 显然就是 None 了
        print(exc_tb.tb_frame.f_back.f_back)  # None
foo()

关于栈帧内部的字段的含义,我们就说完了。当然如果有些字段现在不是很理解,也没关系,随着不断地学习,你会豁然开朗。



小结


因为很多动态信息无法静态地存储在 PyCodeObject 对象中,所以 PyCodeObject 对象在交给虚拟机之后,虚拟机会在其之上动态地构建出 PyFrameObject 对象,也就是栈帧。


因此虚拟机是在栈帧里面执行的字节码,它包含了虚拟机在执行字节码时依赖的全部信息。

跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

Python 虚拟机 字节码 栈帧 PyCodeObject PyFrameObject _PyInterpreterFrame
相关文章