深入理解 Python 求值执行
你是否遇到过这种情况,包含 Python 语言的应用崩溃了,但面对调用栈日志无处下手?
现代机器学习项目往往需要采用多语言方案,以同时确保提供高性能和用户友好体验,比如:
- NVIDIA Triton Inference Server 提供 Python Backend,允许用户无需编写 C++ 代码即可通过 Python 脚本部署模型,极大地降低了业务人员的门槛。
- PyTorch Custom C++ Extensions:为了加速特定的算子,编写 C++/CUDA 代码并通过 pybind11 绑定到 Python。
在这种混合架构下,一旦程序崩溃,GDB 打印出的堆栈通常充满了 PyEval_* 等晦涩的符号。
12345678910111213141516171819#0 0x00007ffff7e0b12c in trigger_crash () at libcrash.c:8
#1 0x00007ffff7de8ac6 in ?? () from /usr/lib/libffi.so.8
#2 0x00007ffff7de576b in ?? () from /usr/lib/libffi.so.8
#3 0x00007ffff7de806e in ffi_call () from /usr/lib/libffi.so.8
#4 0x00007ffff7e2f1bd in ?? () from /usr/lib/python3.13/lib-dynload/_ctypes.cpython-313-x86_64-linux-gnu.so
#5 0x00007ffff7e2cec3 in ?? () from /usr/lib/python3.13/lib-dynload/_ctypes.cpython-313-x86_64-linux-gnu.so
#6 0x00007ffff79620cb in _PyObject_MakeTpCall () from /usr/lib/libpython3.13.so.1.0
#7 0x00007ffff797697a in _PyEval_EvalFrameDefault () from /usr/lib/libpython3.13.so.1.0
#8 0x00007ffff7a4e2c9 in PyEval_EvalCode () from /usr/lib/libpython3.13.so.1.0
#9 0x00007ffff7a8c88c in ?? () from /usr/lib/libpython3.13.so.1.0
#10 0x00007ffff7a8985d in ?? () from /usr/lib/libpython3.13.so.1.0
#11 0x00007ffff7a86f58 in ?? () from /usr/lib/libpython3.13.so.1.0
#12 0x00007ffff7a86212 in ?? () from /usr/lib/libpython3.13.so.1.0
#13 0x00007ffff7a85b83 in ?? () from /usr/lib/libpython3.13.so.1.0
#14 0x00007ffff7a83e51 in Py_RunMain () from /usr/lib/libpython3.13.so.1.0
#15 0x00007ffff7a3bbeb in Py_BytesMain () from /usr/lib/libpython3.13.so.1.0
#16 0x00007ffff7427635 in ?? () from /usr/lib/libc.so.6
#17 0x00007ffff74276e9 in __libc_start_main () from /usr/lib/libc.so.6
#18 0x0000555555555045 in _start ()
你看到的是 CPythonCPython 是 Python 编程语言的官方、标准、也是最广泛使用的解释器实现。当我们平时说“我安装了 Python”,99% 的情况下,我们指的就是 CPython。 虚拟机的执行路径,而不是编写的 Python 业务代码。针对这种情况,本文将深入 CPython,剖析 Python 代码是如何被求值的,为调试这类跨语言工具提供基础知识。
看完本文,你能回答以下问题:
- 为什么 GDB 默认无法直接看到 Python 调用栈?
- 如何在只有 Core Dump 的情况下恢复 Python 业务逻辑的堆栈?
- 如何判断一个崩溃的 Python 进程是否正持有 GIL?
- py-spy 与 PyStack 这类工具在实现“零侵入”观测时,核心差异在哪里?
字节码与虚拟机
在深入之前,首先需要理解 Python 是如何被“翻译/执行”的。
Python 并非纯粹的解释型语言。实际上,CPython 拥有一个严格的编译阶段。当 Python 程序执行时,源代码首先被编译成一种名为字节码(Bytecode)的中间表示(Intermediate Representation, IR)。这种字节码是特定于Python 虚拟机(Python Virtual Machine, PVM)的低级指令集。虚拟机加载字节码对象,逐条执行指令,操纵堆栈和内存对象。
PyCodeObject 与其序列化
Python 的编译单元是代码对象(Code Object),在 C 语言层面,它对应 PyCodeObject 结构体;在 Python 层面,它是 types.CodeType 的实例。代码对象是不可变(immutable)的,它包含了虚拟机执行一段代码所需的所有静态信息。下面是 PyCodeObject 定义的代码片断本文的代码片段旨在展示核心原理,并非完整的类结构或逻辑链路。:
123456789101112131415161718192021struct PyCodeObject _PyCode_DEF(1);
#define _PyCode_DEF(SIZE) { \
/* The hottest fields (in the eval loop) are grouped here at the top. */ \
PyObject *co_consts; /* list (constants used) */ \
PyObject *co_names; /* list of strings (names used) */ \
PyObject *co_exceptiontable; /* Byte string encoding exception handling \
table */ \
PyObject *co_filename; /* unicode (where it was loaded from) */ \
PyObject *co_name; /* unicode (name, for reference) */ \
PyObject *co_qualname; /* unicode (qualname, for reference) */ \
_PyCoCached *_co_cached; /* cached co_* attributes */ \
char co_code_adaptive[(SIZE)]; \
}
typedef struct {
PyObject *_co_code; /* 字节码指令流 */
PyObject *_co_varnames; /* 局部变量 */
PyObject *_co_cellvars;
PyObject *_co_freevars;
} _PyCoCached;
编译好的代码对象会被序列化(Marshal)到 .pyc 文件中。与 pickle 不同,marshal 格式不保证跨版本兼容,它专门用于 Python 内部的对象持久化。下面这个完整的实验代码展现了 .pyc 的格式:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748import marshal
import py_compile
import struct
import time
import dis
source_code = """
global_var = 114514
def foo(x):
local_var = 42
return x + local_var + global_var
"""
filename = "test_script.py"
with open(filename, "w") as f:
f.write(source_code)
# cfile 是编译后的输出路径,例如 __pycache__/test_script.cpython-310.pyc
# 也可以使用 co_module = compile(src, "<string>", "exec") 编译为不包含头结构的 Code Object
cfile = py_compile.compile(filename)
# 解析 .pyc 文件结构 (Python 3.7+):
# [4 bytes Magic Number] [4 bytes Bit Field] [4 bytes Timestamp] [4 bytes Size] [Marshalled Code Object...]
with open(cfile, "rb") as f:
magic = f.read(4)
bit_field = f.read(4)
timestamp = f.read(4)
file_size = f.read(4)
print(f"Magic Bytes: {magic.hex()}")
mod_time = time.ctime(struct.unpack('<I', timestamp)[0])
print(f"Timestamp: {mod_time}")
# 反序列化代码对象 (Unmarshalling)
# 这里的 load 对应 C 语言层面的 PyMarshal_ReadObjectFromString
code_obj = marshal.load(f)
print(f"co_name (函数/模块名): {code_obj.co_name}")
print(f"co_consts (常量池): {code_obj.co_consts}")
func_code = None
for const in code_obj.co_consts:
if hasattr(const, 'co_name'):
print(f"\n[Dive into '{const.co_name}']:")
func_code = const
print(f"co_varnames (局部变量): {func_code.co_varnames}")
print(f"co_code (字节码指令流): {func_code.co_code.hex()}")
dis.dis(func_code)
代码运行后输出如下:
123456789101112131415161718Magic Bytes: f30d0d0a
Timestamp: Thu Jan 1 22:20:54 2026
co_name (函数/模块名): <module>
co_consts (常量池): (114514, <code object foo at 0x7f7ff69e70f0, file "test_script.py", line 3>, None)
[Dive into 'foo']:
co_varnames (局部变量): ('x', 'local_var')
co_code (字节码指令流): 950053016e0158012d0000005b0000000000000000002d0000002400
3 RESUME 0
4 LOAD_CONST 1 (42)
STORE_FAST 1 (local_var)
5 LOAD_FAST_LOAD_FAST 1 (x, local_var)
BINARY_OP 0 (+)
LOAD_GLOBAL 0 (global_var)
BINARY_OP 0 (+)
RETURN_VALUE
通过 types.CodeType,我们可以手动创建一个新的代码对象,替换原函数的 __code__ 属性,从而在不改变源码的情况下修改函数行为。下面这个例子把一个加法函数的行为改变为乘法。下面这个例子把一个加法函数的行为改变为乘法。
12345678910111213141516171819202122232425import opcode
def target_func(a, b):
return a + b
print(f"Original: {target_func(5, 5)}")
co = target_func.__code__
code = bytearray(co.co_code)
# Patching Logic (Python 3.11+)
# BINARY_OP (opcode 122) uses an arg to define the operation (0=Add, 5=Mul)
BINARY_OP = opcode.opmap['BINARY_OP']
NB_ADD = 0 # Slot for addition (+)
NB_MUL = 5 # Slot for multiplication (*)
for i in range(0, len(code), 2):
op, arg = code[i], code[i + 1]
if op == BINARY_OP and arg == NB_ADD:
print(f"Patching BINARY_OP(+) at offset {i} to (*)")
code[i + 1] = NB_MUL
target_func.__code__ = co.replace(co_code=bytes(code))
print(f"Patched: {target_func(5, 5)}")
虚拟机架构:基于堆栈的计算模型
CPython 虚拟机是一个堆栈机器(Stack Machine)。与基于寄存器的虚拟机(如 Lua VM 或 DalvikDalvik 是 Google 为 Android 平台设计的 Java 虚拟机(JVM)变体。)不同,PVM 的指令通常不包含显式的操作数地址,而是隐式地从堆栈顶部获取操作数。
我曾开发过一个名为 lightning-ac 的栈式虚拟机玩具项目,其核心代码仅有 400 行。该项目的核心设计思路是为了解决在 LeetCode 等算法平台提交代码时的隐私问题。项目设计了一套堆栈机器的指令集,在提交时,只需提供一个约 100 行的微型虚拟机解释器以及编译好的字节码指令流,即可在保护核心逻辑不被窥视的同时完成题目交付。项目已完整通过了汉诺塔和快速排序的测试。当然这只是玩具,执行时间是原生 C 程序的 4 到 10 倍;而且我没有实现从 C 程序到指令集的编译器,需要手写 IR 指令,非常需要耐心。
继续使用上一个小章节的例子,从 dis.dis 的输出可以看到计算过程:
| 行号 | 字节码指令 | 操作数 | 栈的变化 | 语义说明 |
|---|---|---|---|---|
| 3 | RESUME | 0 | [] | no-op,用于调试、安全检查 |
| 4 | LOAD_CONST | 1 (42) | [42] | 从常量池 co_consts 加载索引为 1 的对象压入栈顶。 |
STORE_FAST | 1 (local_var) | [] | 弹出栈顶值,存入局部变量表索引 1 的位置(即 local_var)。 | |
| 5 | LOAD_FAST_LOAD_FAST | 1 (x, local_var) | [x, 42] | 特化指令:为了加速,一次性将两个局部变量压入栈中。 |
BINARY_OP | 0 (+) | [x + 42] | 弹出栈顶两个元素执行加法,将结果压回。 | |
LOAD_GLOBAL | 0 (global_var) | [x + 42, 114514] | 在全局命名空间查找 global_var 并压入栈顶。 | |
BINARY_OP | 0 (+) | [(x + 42) + 114514] | 再次执行加法,结果压回栈顶。 | |
RETURN_VALUE | [] | 弹出栈顶元素作为函数的返回值。 |
在官方文档 可以查询到字节码指令的详细规范,比如特化指令 LOAD_FAST_LOAD_FAST(var_names) 的规范是:Pushes references to co_varnames[var_nums >> 4] and co_varnames[var_nums & 15] onto the stack.
下面看看源码是如何实现的,Python/bytecodes.c 是新版本中字节码定义的源头。CPython 现在使用一种特殊的 DSL(领域特定语言)来描述字节码,然后自动生成到 generated_cases.c.h (用于 Tier 1,后文会介绍是什么)和 executor_cases.c.h (用于 Tier 2)中。下面是 LOAD_FAST_LOAD_FAST 的实现,这里的 (inputs -- outputs) 是一种描述栈效应(Stack Effect)的 DSL,表达需要从栈中弹出 inputs(可以为空),并压入 outputs。
123456inst(LOAD_FAST_LOAD_FAST, ( -- value1, value2)) {
uint32_t oparg1 = oparg >> 4;
uint32_t oparg2 = oparg & 15;
value1 = PyStackRef_DUP(GETLOCAL(oparg1));
value2 = PyStackRef_DUP(GETLOCAL(oparg2));
}
CPython 目前采用分层执行架构,通过两套不同的系统来平衡通用性与高性能。Tier 1 是标准的字节码解释器,其入口位于 _PyEval_EvalFrameDefault(该函数是求值的核心入口函数,位于Python/ceval.c)。当某些代码路径被识别为“热点”时,系统会将其转换为微操作(uops) 序列,并进入 Tier 2 层(Python 3.12 引入此特性)。Tier 2 可以进一步选择通过解释器运行,或者由 JIT(即时编译器)编译为机器码。
1234567891011121314151617181920212223242526272829PyObject* _Py_HOT_FUNCTION DONT_SLP_VECTORIZE
_PyEval_EvalFrameDefault(PyThreadState *tstate, _PyInterpreterFrame *frame, int throwflag)
{
goto start_frame; // start_frame is a label defined in "generated_cases.c.h"
# include "generated_cases.c.h"
}
#ifdef _Py_TIER2
#ifdef _Py_JIT
_PyJitEntryFuncPtr _Py_jit_entry = _Py_LazyJitShim;
#else
_PyJitEntryFuncPtr _Py_jit_entry = _PyTier2Interpreter;
#endif
#endif
_Py_CODEUNIT *
_PyTier2Interpreter(
_PyExecutorObject *current_executor, _PyInterpreterFrame *frame,
_PyStackRef *stack_pointer, PyThreadState *tstate
) {
for (;;) {
uopcode = next_uop->opcode;
switch (uopcode) {
#include "executor_cases.c.h"
default:
Py_UNREACHABLE();
}
}
}
求值框架
Python 的执行模型建立在一个基于栈的虚拟机之上,而这一模型的核心是“求值框架”(Evaluation Frame),即通常所说的“栈帧”。它是 CPython 在运行时维护函数调用上下文、局部变量、操作数栈以及指令指针的关键数据结构。
栈帧
长久以来,Python 的函数调用开销被诟病为性能瓶颈之一,其根源很大程度上在于帧对象的分配机制。近年来,为了提升 Python 的运行速度,核心开发团队推动了名为“香农计划”(Shannon Plan)的一系列优化措施。这导致了帧结构的剧烈演变。在 Python 3.10 及以前,帧是一个完整的 Python 对象(PyFrameObject),分配在堆上,生命周期管理依赖引用计数,开销巨大。而在 Python 3.11 中,引入了 _PyInterpreterFrame,这是一种轻量级的、非 Python 对象的 C 结构体,主要分配在专用的 C 数据栈上,极大地减少了内存分配和垃圾回收的压力。
现在的架构可以被视为“双层”模型:
-
_PyInterpreterFrame(内部帧):这是虚拟机实际使用的帧。它是一个轻量级的 C 结构体,通常分配在解释器的“数据栈”(Data Stack)上。它不是PyObject,没有引用计数头部,创建和销毁几乎没有开销(仅是指针移动)。它包含了执行所需的所有状态,如指令指针、局部变量数组等 。 -
PyFrameObject(外部帧):这仍然是一个 Python 对象,但它变成了一个“懒加载”的代理包装器。只有当用户代码(如通过sys._getframe(),inspect.stack())或调试器显式请求访问帧对象时,解释器才会从堆上分配一个PyFrameObject,并将其指向对应的_PyInterpreterFrame。如果代码不进行内省内省(Introspection)是指程序在运行时检查对象、类或函数的类型、属性和能力的一种能力。另一个相关的概念是反射 (Reflection),反射包含了内省,并在此基础上允许程序在运行时修改自身的行为或结构。,这个对象可能永远不会被创建 。
1234567891011typedef struct _PyInterpreterFrame {
_PyStackRef f_executable; /* Deferred or strong reference (code object or None) */
struct _PyInterpreterFrame *previous;
_PyStackRef f_funcobj; /* Deferred or strong reference. Only valid if not on C stack */
PyObject *f_globals; /* Borrowed reference. Only valid if not on C stack */
PyObject *f_builtins; /* Borrowed reference. Only valid if not on C stack */
PyObject *f_locals; /* Strong reference, may be NULL. Only valid if not on C stack */
PyFrameObject *frame_obj; /* Strong reference, may be NULL. Only valid if not on C stack */
_Py_CODEUNIT *instr_ptr; /* Instruction currently executing (or about to begin) */
_PyStackRef *stackpointer;
} _PyInterpreterFrame;
Python 3.11 引入了基于数据栈的执行模型。当一个 Python 函数调用另一个 Python 函数时,解释器不再递归调用 C 函数 _PyEval_EvalFrame,而是简单地在预分配的“数据栈”上初始化一个新的 _PyInterpreterFrame,更新状态指针,然后通过 goto 跳转到解释器循环的开始处。这种技术实际上实现了类似于尾调用优化(Tail call optimization)的效果,使得大部分 Python 函数调用不再消耗 C 栈空间 。这种设计对调试带来不小影响,在 C 栈中我们看到的一个 _PyEval_EvalFrameDefault 调用 可能实际上对应着多个 Python 函数的调用。
内存布局与栈架构
我们需要区分两个“栈”的概念:
- 调用栈(Call Stack):由一系列帧组成的链表,表示函数的嵌套调用关系。在 3.11+ 中,这体现为
_PyInterpreterFrame结构体通过previous指针连接而成的链。 - 值栈(Value Stack):每个帧内部用于计算的临时存储区。例如,计算
a + b时,a和b会被压入值栈,BINARY_OP指令弹出它们并将结果压回栈顶 。
Python 并不使用字典来存储局部变量(尽管 locals() 返回一个字典)。这种机制被称为 Fast Locals。 为了最大限度地提高 CPU 缓存命中率,CPython 将局部变量、闭包变量(Cells/Free Variables)和值栈分配在同一块连续的内存区域中,这块区域被称为 f_localsplus。其内存分布如下:
| 索引范围 | 内容对应 | 对应指令 |
|---|---|---|
0 到 co_nlocals - 1 | 局部变量 (Local Variables) | LOAD_FAST / STORE_FAST |
co_nlocals 到 co_nlocals + co_ncellvars + co_nfreevars - 1 | 闭包单元 (Cell/Free Vars) | LOAD_DEREF / STORE_DEREF |
| 动态变化(不占固定索引) | 值栈 (Value Stack) | PUSH / POP 操作 (如 BINARY_ADD) |
在 Python 3.12 及以前,locals() 返回的字典是一个快照(Snapshot)。如果需要修改生效(例如调试器想要修改变量),必须调用 C API PyFrame_LocalsToFast,将字典的内容强制同步回数组 。Python 3.13 引入了 PEP 667 (Consistent views of namespaces),彻底解决了这个问题。现在,frame.f_locals 不再返回一个普通的字典,而是返回一个 PyFrameLocalsProxy 对象。对这个代理对象的任何读取或写入操作,都会直接透传到底层的 f_localsplus 数组。
核心执行循环
所有 Python 代码的执行最终都会进入 _PyEval_EvalFrameDefault 函数(或其变体)。这个函数的核心是一个无限 for 循环,循环体内包含一个巨大的 switch 语句(或计算跳转表),根据当前字节码指令(Opcode)进行分发 。
CPython 提供了一个允许替换默认求值函数的 API:_PyInterpreterState_SetEvalFrameFunc。这允许第三方工具(如 JIT 编译器 Pyjion,或者调试器)拦截帧的执行。当创建一个新帧时,解释器会调用 tstate->interp->eval_frame(tstate, frame, throwflag)。默认情况下,这个指针指向 _PyEval_EvalFrameDefault。但是,如果安装了 JIT,它可以指向一个编译成机器码的函数,完全绕过 ceval.c 的字节码解释循环。在 Python 3.11 中,为了优化性能,如果 eval_frame 为 NULL,则直接调用默认解释器,避免了函数指针调用的开销。
123456789static inline PyObject*
_PyEval_EvalFrame(PyThreadState *tstate, _PyInterpreterFrame *frame, int throwflag)
{
EVAL_CALL_STAT_INC(EVAL_CALL_TOTAL);
if (tstate->interp->eval_frame == NULL) {
return _PyEval_EvalFrameDefault(tstate, frame, throwflag);
}
return tstate->interp->eval_frame(tstate, frame, throwflag);
}
我们可以用 sys.settrace 在每行代码或每个函数调用时介入。当 sys.settrace 被激活时,线程状态 tstate->cframe->use_tracing 标志被设置。解释器循环会放弃使用优化的特化指令(Specialized Instructions)CPython 引入了 PEP 659 (Specializing Adaptive Interpreter)。以一个简单的字节码指令 BINARY_OP 为例: 在执行之前,虚拟机并不知道操作数是什么,它必须在运行时进行繁琐的检查。如果虚拟机发现这行代码连续多次都在处理两个整数相加,它就会把这条指令原地替换为专门处理整数的特化版本:BINARY_OP_ADD_INT。,转而进入 legacy tracing 路径。这会导致性能显著下降(可能慢 10 倍以上),因为解释器必须在每条指令或每行代码边界暂停,构造帧对象,并调用 Python 回调函数 。
生成器与协程的帧状态管理
生成器(Generator)和协程(Coroutine)都从底层实现看,协程的挂起同样依赖 yield。await something 本质上是调用 something.__await__(),最终通过 yield 实现非阻塞挂起。通过 yield 关键字实现了函数的暂停和恢复。普通函数的帧在其返回后就可以销毁(在 3.11+ 中是从 C 栈中弹出)。但生成器不同,当它 yield 时,函数并未结束,其局部变量状态必须保留,以便下次恢复。因此,生成器的帧必须分配在堆上,或者其状态必须能够从栈上“逃逸”到堆上。 在 Python 3.11 的实现中,当执行 RETURN_GENERATOR 指令时,解释器会创建一个生成器对象,并将当前的 _PyInterpreterFrame 状态复制或关联到这个生成器对象中。这使得帧的生命周期与生成器对象的生命周期绑定。
GIL 锁与求值计算
全局解释器锁(Global Interpreter Lock,简称 GIL)是 CPython 解释器中最为核心且最具争议的同步原语,GIL 并非 Python 语言规范的一部分,而是 CPython 实现层面的产物。Jython, Codon 等其他 Python 实现就不包含 GIL 锁。尽管其初衷是保护非线程安全的 CPython 内存管理机制,保证只有一个线程能够持有对 Python 对象及其内存管理器的写权限,但在多核处理器普及的今天,GIL 已成为限制 Python 并行计算能力的主要瓶颈。
GIL 锁的实现演进:
- 在很长一段时间里(Python 3.12 之前),GIL 的状态存储在进程级的全局变量
_PyRuntimeState中(符号表中通常显示为_PyRuntime)。这意味着整个进程共享这一把锁,无论你开启了多少个线程。 - 随着 Python 3.13 的发布以及 PEP 684(Per-Interpreter GIL)的推进,情况发生了本质的变化。为了支持真正的多解释器并行注意,多解释器并行并不等同于 Python 中的多线程。一个解释器可以包含多个线程。如果你在一个解释器内部启动了若干个线程,这些线程依然要争夺解释器的那一把 GIL。它们之间互斥,不能并行。,绝大多数运行时状态——包括 GIL 本身——正在从全局的
_PyRuntime迁移到每个解释器独有的PyInterpreterState结构中。 - Python 3.14 提出 No-GIL,使用了有偏引用计数(Biased Reference Counting)和 Mimalloc(由微软开发的高性能分配器)。Python 3.14 并不会默认开启 No-GIL,它依然是一个构建选项(
--disable-gil)。但这一版本标志着 CPython 从“由于 GIL 存在而只能并发”向“真正的并行运行时”的实质性跨越。No-GIL 并非没有代价,即便使用了有偏引用计数,为了维护线程安全,解释器在执行字节码时仍需进行额外的检查(例如延迟引用计数的处理)。目前的基准测试显示,No-GIL 版本在单线程任务上比标准版本慢 10% 左右;同时,无法直接混用标准 Python 和 No-GIL Python 的扩展模块,No-GIL 版本拥有独立的 ABI 标签(abi3t,其中t代表 threaded),这意味着像 NumPy、PyTorch 这样的 C 扩展库必须专门为 No-GIL 版本进行编译和发布。
GIL 对求值的影响主要体现在它打断了指令流的连续性。解释器不可能在每条字节码指令之后都检查是否有线程想要抢占 GIL,因为原子操作和锁检查的开销相对于简单的指令(如 LOAD_FAST)来说太大了。因此,CPython 采用了一种名为 eval_breaker 的机制。 在 Python 3.10 之前,系统维护一个 tick 计数器,每执行 N 条指令检查一次,当这个标志位被设置时,解释器会暂停当前的字节码执行,去处理信号或释放 GIL 让其他线程运行。在 Python 3.13 中,为了支持未来的 No-GIL 构建,eval_breaker 被移动到了 PyThreadState 结构中 。
通过 GDB,我们可以直接观察到这个机制在内存中的真实状态。
123456789// 以 Python 3.14 为例
(gdb) print _PyRuntime.interpreters.main.ceval.gil.locked
$1 = 1 // 锁状态:1 表示当前 GIL 被占用
(gdb) print _PyRuntime.interpreters.main.ceval.gil.last_holder
$2 = (PyThreadState *) 0x555555bd1538 <_PyRuntime+331640> // 指向当前持有锁的线程状态对象
(gdb) print _PyRuntime.interpreters.main.ceval.gil.last_holder->native_thread_id
$3 = 3130637 // 对应的操作系统原生线程 ID
在 GDB 中查看 Python 语义
实战中,我们通常需要对业务团队提供的 Core Dump(核心转储)文件Core Dump 是进程在终止瞬间的内存、寄存器和执行状态的二进制快照。对于 C/C++ 等编译型语言,使用 GNU Debugger (GDB) 分析 Core Dump 是标准流程。进行分析,这一章节讨论如何使用 GDB 完成这个任务。
Core Dump 文件的生成与内核机制
Core Dump 的生成涉及 Linux 内核的信号处理、内存管理以及文件系统交互。当进程接收到某些特定信号(如 SIGSEGV、SIGQUIT、SIGABRT)且未定义自定义处理函数时,内核会触发默认动作:终止进程并生成 Core Dump。这一过程由内核函数 do_coredump 或 vfs_coredump 协调。当然,也可以使用 gcore 命令对正在运行的进程生成 Core Dump 文件,这通常在调试死锁的问题中使用。
Linux 系统出于磁盘空间和性能考虑,通常将 Core 文件大小限制默认为零,我们需要使用下面的指令显式解除此限制。对于由 systemd 管理的守护进程,需要在 Unit 文件中设置 LimitCORE=infinity。
1$ ulimit -c unlimited
内核参数 kernel.core_pattern 决定了 Core Dump 的命名规则和存储位置。查看当前配置:
123# 查看 core 文件生成规则
$ cat /proc/sys/kernel/core_pattern
|/usr/lib/systemd/systemd-coredump %P %u %g %s %t %c %h %d %F
此处的管道符 | 具有特殊意义。它表明内核不会直接将 Core 数据写入磁盘,而是将其通过管道流式传输给用户态程序 /usr/lib/systemd/systemd-coredump。 在现代 Linux 发行版中,systemd-coredump 会将 Core 数据压缩存储在 /var/lib/systemd/coredump 中。coredumpctl 是管理这些数据的标准工具。为了使用 GDB 分析,必须将压缩的内部格式导出为标准的 ELF Core 文件:
12coredumpctl list
coredumpctl dump <PID> --output core.mycrash
启动 GDB 时,需要同时指定崩溃时的可执行文件和Core 文件。这两个文件必须严格匹配,否则符号表无法正确映射。
123(gdb) core core.mycrash
# 或者在启动时加载
$ gdb /usr/bin/python3 core.mycrash
直接使用原生的 GDB 只能看到无法直接映射回 Python 源码路径和行号。下一小节讨论如何获取调试信息。
调试信息的获取与 Build-ID 机制
当 GDB 加载二进制文件时,它会检查 .note.gnu.build-id 段。这是一个由编译器(GCC/LLVM)在链接阶段计算出的 SHA-1 哈希值(160位),用于唯一标识该二进制构建版本。通过 readelf 或 eu-unstrip 可以查看二进制文件的 Build-ID:
1234567$ readelf -n /usr/bin/python | grep -A3 build.id
Displaying notes found in: .note.gnu.build-id
Owner Data size Description
GNU 0x00000014 NT_GNU_BUILD_ID (unique build ID bitstring)
Build ID: 8c0dc848f5b978c56ebeb07255bb332b4b37ae4e
$ eu-unstrip -n --core=core.mycrash | head -n 1
0x55a08b363000+0x5000 8c0dc848f5b978c56ebeb07255bb332b4b37ae4e@0x55a08b3633b0 . - /usr/bin/python3.13
Linux 发行版通常将二进制文件剥离符号后发布(以减小体积),并将调试符号打包在独立的 -debuginfo 或 -dbg 包中。
12345678910# Fedora
$ yum install gdb python-debuginfo
# Ubuntu
$ apt-get install gdb python3.12-dbg
# Centos
$ yum install yum-utils
$ debuginfo-install glibc
$ yum install gdb python-debuginfo
在微服务和容器化环境中,手动安装每个依赖库的 debuginfo 包极其繁琐且不仅可能。debuginfod 协议解决了这个问题。它是一个基于 HTTP 的文件服务器,能够根据 Build-ID 索引并提供调试资源。用 debuginfod-find 可以查询某个 Build-ID 在服务器上是否存在。
1$ debuginfod-find debuginfo d5e3b0d8921923f35438adefa9f864745abc5e90
若不需要查询(例如网络隔离环境或查询速度过慢,在生产环境中这很可能出现),可显式关闭:
12$ unset DEBUGINFOD_URLS
(gdb) set debuginfod enabled off
对于深入的解释器开发或调试极其隐晦的 Bug,发行版提供的符号可能不够用(通常编译优化级别为 -O2 或 -O3,导致变量被“优化掉”)。此外,像 Arch Linux 等滚动更新发行版,其上游并不总是提供配套的调试文件。此时需要手动编译 CPython:
1234# 调试版本会启用 Py_DEBUG 宏,会改变 cpython 的行为
$ ./configure --with-pydebug CFLAGS="-O0 -g"
# 带调试信息的生产版本
$ ./configure --enable-optimizations CFLAGS="-g -O3"
加载 python-gdb.py 扩展
拥有了 Core 文件和调试符号后,我们终于可以开始真正的分析。此时,原生的 GDB 指令(如 bt, info registers)虽然可用,但它们显示的是 C 语言层面的执行流。我们需要借助 CPython 提供的 GDB 扩展脚本来“翻译”这些信息。
python-gdb.py(在某些系统中名为 libpython.py)是 CPython 源码树的一部分(这意味着不保证跨版本兼容)。它利用 GDB 的 Python API,读取 C 结构体(如 PyFrameObject)并将其格式化为 Python 开发者熟悉的形式。
一旦扩展加载成功,一系列 py- 开头的指令便可使用。比如,可以使用 thread apply all py-bt 打印每个线程在 Python 层面的执行位置。
为了更深入理解 python-gdb.py 做了什么,我们可以从头写一个简单版本的 pt-bt:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374import gdb
def get_code_info(py_frame):
try:
f_executable = py_frame["f_executable"]
if f_executable.type.name != "_PyStackRef":
return None, None, None
USED_TAGS = 0b11
bits = int(f_executable["bits"]) & ~USED_TAGS
if bits == 0:
return None, None, None
PyObject_ptr = gdb.lookup_type("PyObject").pointer()
py_obj = gdb.Value(bits).cast(PyObject_ptr)
PyCodeObject_ptr = gdb.lookup_type("PyCodeObject").pointer()
code_obj = py_obj.cast(PyCodeObject_ptr)
co_name_ptr = code_obj["co_name"]
co_filename_ptr = code_obj["co_filename"]
name = co_name_ptr.format_string().strip("'")
filename = co_filename_ptr.format_string().strip("'")
try:
lineno = int(code_obj["co_firstlineno"])
except:
lineno = 0
return name, filename, lineno
except:
return None, None, None
class MyPyBacktrace(gdb.Command):
def __init__(self):
super(MyPyBacktrace, self).__init__(
"my-py-bt", gdb.COMMAND_STACK, gdb.COMPLETE_NONE
)
def invoke(self, argument, from_tty):
frame = gdb.selected_frame()
index = 0
print(f"{'INDEX':<5} {'FUNCTION':<25} {'FILE:LINE'}")
while frame:
if frame.name() == "_PyEval_EvalFrameDefault":
try:
py_frame = frame.read_var("frame")
FRAME_OWNED_BY_INTERPRETER = 3
while py_frame:
owner = int(py_frame["owner"])
if owner == FRAME_OWNED_BY_INTERPRETER:
break
name, filename, lineno = get_code_info(py_frame)
if name:
print(f"{index:<5} {name:<25} {filename}:{lineno}")
index += 1
try:
py_frame = py_frame["previous"]
except:
break
except:
pass
frame = frame.older()
MyPyBacktrace()
print("Custom command 'my-py-bt' loaded.")
关键点如下:
- 寻找 C 与 Python 的边界:脚本首先遍历 GDB 获取的 C 语言调用栈
frame = frame.older(),寻找符号_PyEval_EvalFrameDefault。一旦定位到这个 C 栈帧,我们就能读取其局部变量_PyInterpreterFrame *frame。 - 解码 _PyStackRef 与指针标记(Pointer Tagging):
f_executable不是一个裸指针,而是一个_PyStackRef联合体(Union)。这是Python 3.13 引入的延迟引用计数 (Deferred Refcounting)优化,避免频繁对引用计数进行更新。虚拟机需要知道栈上的某个值到底是“借来的(Borrowed)”还是“拥有的(Owned)”。CPython 没有使用额外的内存来存这个布尔值,而是使用了指针标记(Pointer Tagging)技术,使用地址的低 2 位来实现标记。解码后我们得到PyCodeObject。 - 逆向回溯帧链表:一旦抓住了当前的 Python 帧,脚本便利用
previous指针向后回溯调用链,直到遇到FRAME_OWNED_BY_INTERPRETER其他的标记包括FRAME_OWNED_BY_THREAD,FRAME_OWNED_BY_GENERATOR,FRAME_OWNED_BY_FRAME_OBJECT(当使用内省时使用)。。当遇到这种类型的帧时,意味着我们已经到达了当前解释器执行流的底部(例如模块层级或由 C 扩展调用的边界),此时应当停止回溯。
观测工具技术原理分析
观测工具通常被划分为进程内(In-Process)观测与进程外(Out-of-Process)观测。传统的调试器如 pdb 或性能分析器如 cProfile 均属于前者。它们通过 sys.settrace() 等机制挂钩(Hook)到 Python 解释器的执行循环中,但无可避免地引入了显著的“观察者效应”(Observer Effect)——即观测行为本身改变了目标系统的运行时特征。对“零侵入”观测的需求催生了以 PyStack 和 py-spy 为代表的进程外观测技术。
跨进程内存访问机制主要有两种:
ptrace:Unix 哲学中用于进程控制和调试的基石。缺点是传统的ptrace(PTRACE_PEEKDATA,...)操作一次只能读取一个字(Word,通常为 4 或 8 字节)的数据 ,同时采用的是 “停止-世界”(Stop-the-World)的方法。process_vm_readv(引入于 Linux 3.2):允许调用者构建一个包含多个远程地址段的iovec向量,内核会在一次系统调用中完成所有这些分散内存段的读取,并将它们填充到观测者进程的连续缓冲区中。该方法不保证原子性。
目标 Python 进程仅仅是操作系统眼中的一堆内存页,它不会主动告诉外部观测者“这里是解释器状态”。因此,观测工具必须通过以下两种途径之一来定位关键数据结构(如 PyInterpreterState 或 _PyRuntime):
- 依赖二进制文件(ELF)中的符号表(Symbol Table)或调试信息(DWARF)。这是 PyStack 处理 Core Dump 时的主要手段 。
- 在符号表被剥离(Stripped)的情况下,扫描内存段(如 BSS 段),通过特征匹配来猜测解释器结构的位置。这是 py-spy 的主要手段。
PyStack
PyStack 通过解析二进制文件中的 DWARF 信息实现了混合显示 C 栈和 Python 栈。DWARF 标准定义了 .debug_info(类型信息)和 .debug_frame(栈帧回溯信息)在现代编译器优化(如 -fomit-frame-pointer)下,栈帧不再通过简单的帧指针rbp相连。需要利用 DWARF 中定义的 CFI(Call Frame Information)状态机程序。。在分析运行中的进程时,PyStack 面临数据竞争(Data Race)的风险。PyStack 采取了较为保守的策略:短暂地使用 ptrace 挂起目标线程,确保在通过 process_vm_readv 抓取关键结构时,内存状态是静止的。
PyStack 的一大杀手锏是能够打印栈帧中的局部变量和参数 。PyStack 内部实现了一套 Python 对象解码器。它读取远程内存中的 PyObject 数据结构,通过 ob_type 字段判断对象类型。
py-spy
py-spy 旨在以极低的开销进行持续采样,生成火焰图(Flame Graph)或实时拓扑视图(Top View)。
针对无符号表的情况,py-spy 引入了一套 .bss 启发式扫描算法,找出潜在的 PyInterpreterState 指针。
除了记录“正在运行什么函数”,py-spy 还能洞察“线程是否持有 GIL”。这是通过直接读取 _PyRuntime 结构中的 ceval.gil 字段实现的 。
在 x86_64 架构下,混合栈展开面临 PyFrameObject 指针的丢失的问题。PyEval_EvalFrameDefault 的参数中包含了当前的 Python 帧对象。这个参数通常传递给 rdi 寄存器。 然而,rdi 是一个“调用者保存”寄存器。在 PyEval_EvalFrameDefault 函数内部执行期间,编译器可能会复用 rdi 寄存器来存储其他临时变量。这意味着当 py-spy 展开到这一层 C 栈帧时,它无法通过简单的寄存器读取来获取当前的 Python 栈帧地址 。py-spy 实现了一系列猜测和扫描帧对象的方法。
总结
这篇文章深入探讨了 CPython 虚拟机的内部求值机制,并系统性地介绍了如何处理和分析包含 Python 逻辑的跨语言(Python/C++)应用崩溃问题。
相信下次生产环境再遇到 Python 相关问题时,你可以从从容容游刃有余。
拓展阅读
- Diving into the Python call stack (PyGotham 2018) 利用 Crashpad 工具由 Google 开发的开源多平台崩溃报告系统(Crash Reporting System)。它最初是作为 Google Chrome 的崩溃收集工具而开发的,旨在取代早期的 Breakpad。为 Python 桌面客户端构建一套可靠的跨进程崩溃报告系统。
- Debugging C API extensions and CPython Internals with GDB 官方
python-gdb.py文档。 - Unpacking PyArmor: SoderCTF - Rev6 PyArmor 混淆脚本逆向工程实战案例。
- Deep dive into Python's VM: Story of LOAD_CONST bug
- Debugging a Mixed Python and C Language Stack 一个调试 Python 死锁的案例。