解密虚拟机的执行环境:栈帧对象


楔子



后续打算深入介绍 Python 的协程,这里先补充一些前置的知识。

前面我们介绍了 Python 的 PyCodeObject 对象,它是解释器对源代码编译之后的结果。该对象内部有很多属性,比如 co_code 负责存储相应的字节码,也就是虚拟机要执行的指令序列;co_names、co_consts 等等则负责存储代码中的符号、常量等静态信息。

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

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



什么是栈帧



栈帧,它是字节码执行时的上下文。我们看一个示例:

name = "古明地觉"

def f():
    name = "古明地恋"
    print(name)

f()
print(name)
"""
古明地恋
古明地觉
"""

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

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

因此虚拟机并不是在 PyCodeObject 对象上执行操作的,而是栈帧对象。虚拟机在执行时,会根据 PyCodeObject 对象动态创建出栈帧对象,然后在栈帧里面执行字节码。

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

  • 当虚拟机在执行第一条语句时,已经创建了一个栈帧,这个栈帧显然是模块对应的栈帧,假设叫做 A;

  • 所有的字节码都会在这个栈帧中执行,虚拟机可以从栈帧里面获取变量的值,也可以修改;

  • 当发生函数调用的时候,这里是函数 f,那么虚拟机会在栈帧 A 之上,为函数 f 创建一个新的栈帧,假设叫 B,然后在栈帧 B 里面执行函数 f 的字节码指令;

  • 在栈帧 B 里面也有一个名字为 name 的变量,但由于执行环境、或者说栈帧的不同,name 也不同。比如两个人都叫小明,但一个是北京的、一个是上海的,所以这两者没什么关系;

  • 一旦函数 f 的字节码指令全部执行完毕,那么会将当前的栈帧 B 销毁(也可以保留下来),再回到调用者的栈帧当中。就像是递归一样,每当调用函数时,就会在当前栈帧之上创建一个新的栈帧,一层一层创建,一层一层返回;


而实际上虚拟机执行字节码这个过程,就是在模拟操作系统运行可执行文件。我们再用一段 Python 代码解释一下:

def f(a, b):
    return a + b

def g():
    return f()

g()

程序先调用函数 g,那么会为函数 g 创建栈帧;然后在函数 g 里面调用函数 f,那么系统就又会在地址空间中,于函数 g 的栈帧之上创建函数 f 的栈帧。

当程序执行函数 g 时,那么当前帧就是函数 g 的栈帧,调用者的帧则是模块的栈帧。而当程序执行函数 f 时,那么当前帧就是函数 f 的栈帧,而调用者的帧则是函数 g 的栈帧。

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

当函数 f 的调用完成时,对应的栈帧就会被销毁,然后程序的运行空间会回到函数 g 的栈帧中。

那么下面我们就来看看栈帧在底层长什么样,注意:栈帧也是一个对象。



栈帧的底层结构



栈帧在底层是由 PyFrameObject 结构体表示的,但相比操作系统运行可执行文件时创建的栈帧,Python 的栈帧实际上包含了更多的信息。

typedef struct _frame {
    //可变对象的头部信息
    PyObject_VAR_HEAD      
    //上一级栈帧, 也就是调用者的栈帧 
    struct _frame *f_back;     
    //PyCodeObject对象
    //通过栈帧的f_code属性可以获取对应的PyCodeObject对象
    PyCodeObject *f_code;       
    //builtin名字空间,一个PyDictObject对象
    PyObject *f_builtins;       
    //global名字空间,一个PyDictObject对象
    PyObject *f_globals;        
    //local名字空间,一个PyDictObject对象 
    PyObject *f_locals;         
    //运行时的栈底位置 
    PyObject **f_valuestack;    
    //运行时的栈顶位置
    PyObject **f_stacktop;      
    //回溯函数,打印异常栈
    PyObject *f_trace;         
    //是否触发每一行的回溯事件
    char f_trace_lines;        
    //是否触发每一个操作码的回溯事件
    char f_trace_opcodes;       
    //是否是基于生成器的PyCodeObject构建的栈帧
    PyObject *f_gen;            
    //上一条已执行完毕的指令在f_code中的偏移量 
    int f_lasti;                
    //当前字节码对应的源代码行号
    int f_lineno;               
    //当前指令在栈f_blockstack中的索引
    int f_iblock;               
    //当前栈帧是否仍在执行
    char f_executing;           
    //用于try和loop代码块
    PyTryBlock f_blockstack[CO_MAXBLOCKS]; 
    //动态内存
    //维护 "局部变量+cell对象集合+free对象集合+运行时栈" 所需要的空间
    PyObject *f_localsplus[1];  
} PyFrameObject;

因此虚拟机会根据 PyCodeObject 对象来创建一个栈帧,也就是 PyFrameObject 对象,虚拟机实际是在栈帧对象上执行操作的。

每一个 PyFrameObject 都会维护一个 PyCodeObject,换言之,每一个 PyCodeObject 都会隶属于一个 PyFrameObject。并且从 f_back 可以看出,Python 在实际执行时,会产生很多的 PyFrameObject 对象,而这些对象会被链接起来,形成一条执行环境链表,或者说栈帧链表。

而这正是操作系统栈帧之间关系的模拟,对于操作系统而言,栈帧之间通过 rsp 和 rbp 指针建立了联系,使得新栈帧在结束之后能够顺利地返回到旧栈帧中,而 Python 则是利用 f_back 来完成这个动作。

栈帧里面的 f_code 指向相应的 PyCodeObject 对象,而 f_builtins、f_globals、f_locals 则是指向三个独立的名字空间。在这里,我们看到了名字空间执行环境(栈帧)之间的关系,前者只是后者的一部分。

名字空间负责维护变量和对象之间的映射关系,通过名字空间,我们能够找到一个符号被绑定在了哪个对象上。

另外在 PyFrameObject 的开头有一个 PyObject_VAR_HEAD,表示栈帧是一个变长对象,即每次创建的栈帧的大小可能是不一样的,那么这个变动在什么地方呢?

首先每一个 PyFrameObject 对象都维护了一个 PyCodeObject 对象,而每一个 PyCodeObject 对象都会对应一个代码块。在编译一段代码块的时候,会计算这段代码块执行时所需要的栈空间的大小,这个栈空间大小存储在 PyCodeObject 对象的 co_stacksize 中。

而不同的代码块所需要的栈空间是不同的,因此栈帧是一个变长对象。最后,其实栈帧里面的内存空间分为两部分,一部分是编译代码块需要的空间,另一部分是执行代码块所需要的空间,也称之为运行时栈(后续聊),不过我们只需要关注运行时栈即可。



在 Python 中访问栈帧



如果要在 Python 里面拿到栈帧对象,可以通过 inspect 模

import inspect

def f():
    # 返回当前所在的栈帧
    # 这个函数实际上是调用了 sys._getframe(1)
    return inspect.currentframe()

frame = f()
print(frame) 
"""
<frame at ..., file 'D:/satori/main.py', line 6, code f>
"""

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

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

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

下面我们就来访问一下栈帧的成员属性。

import inspect

def f():
    name = "古明地觉"
    age = 16
    return inspect.currentframe()

def g():
    name = "魔理沙"
    age = 333
    return f()

# 当我们调用函数 g 的时候,也会触发函数 f 的调用
# 而一旦 f 执行完毕,那么 f 对应的栈帧就被全局变量 frame 保存起来了
frame = g()
print(frame)
"""
<frame at ... 'D:/satori/main.py', line 6, code f>
"""

# 获取上一级栈帧,即调用者的栈帧
# 显然是函数 g 的栈帧
print(frame.f_back)
"""
<frame at ... 'D:/satori/main.py', line 11, code g>
"""

# 模块也是有栈帧的,我们后面会单独说
print(frame.f_back.f_back)
"""
<frame at ... 'D:/satori/main.py', line 27, code <module>>
"""

# 显然最外层就是模块了
# 模块对应的上一级栈帧是None
print(frame.f_back.f_back.f_back)
"""
None
"""


# 获取 PyCodeObject 对象
print(frame.f_code)
print(frame.f_back.f_code)
"""
<code object f ... "D:/satori/main.py", line 3>
<code object g ... "D:/satori/main.py", line 8>
"""

print(frame.f_code.co_name)
print(frame.f_back.f_code.co_name)
"""
f
g
"""


# 获取 f_locals
# 即栈帧内部的 local 名字空间
print(frame.f_locals)
"""
{'name': '古明地觉', 'age': 16}
"""

print(frame.f_back.f_locals)
"""
{'name': '魔理沙', 'age': 333}
"""


# 获取栈帧创建时对应的行号
print(frame.f_lineno)
print(frame.f_back.f_lineno)
"""
6
11
"""

# 行号为 6 的位置是: return inspect.currentframe()
# 行号为 11 的位置是: return f()

我们看到函数运行完毕之后,里面的局部变量居然还能获取,原因就是栈帧没被销毁,因为它被返回了,而且被外部变量接收了。同理该栈帧的上一级栈帧也不能被销毁,因为当前栈帧的 f_back 指向它了,引用计数不为 0,所以要保留。

通过栈帧可以获取很多的属性,我们后面还会慢慢说。此外,异常处理也可以获取到栈帧。

import sys

def foo():
    try:
        1 / 0
    except ZeroDivisionError as e:
        _, _, exc_tb = sys.exc_info()
        # exc_tb 还可以通过 e.__traceback__ 获取
        print(exc_tb)
        """
        <traceback object at 0x00000135CEFDF6C0>
        """


        # 调用 exc_tb.tb_frame 即可拿到异常对应的栈帧
        print(exc_tb.tb_frame)
        """
        <frame at ... 'D:/satori/main.py', line 15, code foo>
        """

        print(exc_tb.tb_frame.f_back)
        """
        <frame at ... 'D:/satori/main.py', line 31, code <module>>
        """

        # 显然 exc_tb.tb_frame 是当前函数 foo 的栈帧
        # 那么 exc_tb.tb_frame.f_back 就是整个模块对应的栈帧
        # 那么再上一级的话, 栈帧就是 None 了
        print(exc_tb.tb_frame.f_back.f_back)
        """
        None
        """


foo()

通过以上两种方式即可在 Python 中获取栈帧对象。

很多动态信息无法静态地存储在 PyCodeObject 对象中,所以虚拟机会在其之上动态地构建出 PyFrameObject 对象,也就是栈帧。因此虚拟机是在栈帧里面执行的字节码,它包含了虚拟机在执行字节码时依赖的全部信息。

相关推荐

  • TiDB在转转公司的发展历程
  • 代码总是被嫌弃写的太烂?装上这个IDEA插件再试试!
  • 中文Stable Diffusion模型太乙使用教程 - 掘金
  • 神经网络基础部件-BN层详解
  • 放弃高校Offer,加入OpenAI到底值不值得?
  • React Context 实现原理:它在 antd 源码里简直用的太多了
  • 作为NLP算法,最近被ChatGPT刷屏后的心路历程
  • 俄亥俄州5到20年后或现大批癌症患者;28岁单身女孩情人节前崩溃大哭;多地提醒防范诺如病毒;周黑鸭业绩降超90%...|酷玩日爆
  • 情人节的夜晚 | 每日一冷
  • 坐拥3亿多用户的“印度支付宝”,为什么还是被阿里抛售了?
  • 电池,真就这么香
  • “房市”和“房事”,保大还是保小?
  • 土耳其政府对上百名地产商发出逮捕令;斯诺登:美国击落不明飞行物​是为转移注意力;韩国民众向土耳其捐赠大量脏衣服 | 每日大新闻
  • 一千山东农民,如何打败鬼子精锐“坂田联队”?
  • 调试CSS
  • 浅析SeaweedFS与JuiceFS架构异同
  • 微软正式推出用于WSL的D3D12 GPU视频加速
  • 中国开源社区健康案例——LinkWechat开源社区
  • CentOS停服、Ubuntu断供俄罗斯 | 开源操作系统领域解读
  • 非WebKit引擎的iOS浏览器即将到来