Python 3.14 目前主要的一些主要的特性其实已经固定了,在我看来,Python 3.14 是一个未来很多年的一个核心版本。因为其确定了是时代的 Python
调试生态的基准,这篇文章将会来聊聊这个 Python 世界中的史诗级改进
正文
在我们日常调试 Python 代码的时候,我们经常会遇到这样一个问题,我们需要采样当前的 Python Runtime 的状态,进而进一步调试我们的 Python 进程
常见的手段莫过于两种
- 通过 eBPF + UProbe 等手段来触发
- 通过
process_vm_readv
等 Syscall 来直接整块读取内存
无论这两种方式都有一个核心的问题,我们怎么样来解析内存中的数据?
用 https://github.com/jschwinger233/perf-examples/blob/main/cpython310_backtrace/bpf.c 来做一个例子,在之前的很多年的时候,我们会怎么做
1 |
|
上面的核心代码其实没多少,核心的逻辑就还是我们手动模拟 Python 中关键的 PyFrameObject
结构体,然后我们在内存中不断做一次搜索,暴力匹配到特征一致的内存
其余诸如 PySpy 这样的工具也是类似的思路
这个方式最核心的问题是在于说,Python 每个版本的 ABI 都可能发生变化,所以我们需要不断的根据不同的版本去做兼容(比如 PySpy 维护了从3.7到3.12的不同的 PyFrameObject
。
那么我们有没有更好的方法来处理这个问题?或者说我们能不能更好的去定位?
可以的,写 Python 的同学肯定都知道我们 Python 中有一个全局的变量 _PyRuntime
,其类型为 pyruntimestate
,大致的布局如下
1 | struct pyruntimestate { |
眼尖的同学肯定看到了,我们其中有一段核心的代码
1 | struct pyinterpreters { |
维护了一个 PyInterpreterState
的链表,我们可以通过 PyInterpreterState
来获取当前的 Frame,PyInterpreterState
中的 TreadState 来获取当前的线程状态
1 | struct pythreads { |
而 PyThreadState
中和核心的 struct _PyInterpreterFrame *current_frame
就是我们需要的 frame state,整个流程大概如下
graph TD
PyRuntime["_PyRuntime (pyruntimestate)"] --> Interpreters["interpreters (pyinterpreters)"]
Interpreters -->|head| InterpreterStateHead["PyInterpreterState *head"]
Interpreters -->|main| InterpreterStateMain["PyInterpreterState *main"]
%% Define interpreter state structure
subgraph PyInterpreterState
InterpreterID["int64_t id"]
ThreadsStruct["struct pythreads threads"]
NextInterpreter["PyInterpreterState *next"]
end
InterpreterStateHead --- PyInterpreterState
InterpreterStateMain --- PyInterpreterState
%% Link to threads structure
ThreadsStruct --> ThreadHead["PyThreadState *head"]
ThreadsStruct --> ThreadMain["PyThreadState *main"]
%% Define thread state structure
subgraph PyThreadState
ThreadID["uint64_t thread_id"]
InterpreterPtr["PyInterpreterState *interp"]
CurrentFrame["_PyInterpreterFrame *current_frame"]
NextThread["PyThreadState *next"]
end
ThreadHead --- PyThreadState
ThreadMain --- PyThreadState
%% Frame structure
CurrentFrame --> Frame["_PyInterpreterFrame structure"]
subgraph _PyInterpreterFrame
PreviousFrame["_PyInterpreterFrame *previous"]
CodeObject["PyCodeObject *f_code"]
Locals["PyObject **localsplus"]
end
%% Connected paths in color
PyRuntime ==>|"Main Path"| Interpreters
Interpreters ==>|"Main Path"| InterpreterStateMain
InterpreterStateMain ==>|"Main Path"| ThreadsStruct
ThreadsStruct ==>|"Main Path"| ThreadMain
ThreadMain ==>|"Main Path"| CurrentFrame
CurrentFrame ==>|"Main Path"| Frame
class PyRuntime,InterpreterStateMain,ThreadMain,CurrentFrame,Frame mainPath;
classDef mainPath fill:#f96,stroke:#333,stroke-width:2px;
classDef mainNodes fill:#f9f,stroke:#333,stroke-width:2px;
那么我们现在来解决第一个问题,我们怎么样获取在内存中的 _PyRuntime
的地址呢?
我们把这个问题抽象成下面最简单一个 C 代码
1 |
|
我们怎么样获取 abc 的地址呢?这里写过 C 的同学可能反应过来了,我们可以使用 __attribute__((section()))
的语法,来将其放到一个特定的段中
1 |
|
我们编译,并用 readelf
来解析一下二进制
1 | ╰─ readelf -S ./a.out| grep my_section |
我们能看到这里我们得到了一个相对地址。后续我们就可以通过解析 ELF 来遍历寻找到 abc
变量的地址
那么在 Python 中同样如此,在代码中有这样一段代码
1 |
|
这样我们就能比较方便的获取到 PyRuntime 在内存中的地址。
那么现在第二个问题是,我们怎么样通过我们前面介绍的调用链获取到地址?
大家可能第一反应还是想通过维护不同版本的数据结构来获取具体的地址。不过这里我们有没有办法可以用更简单的方法来处理呢?答案是有的
眼尖的同学可能看到了我们在 pyruntimestate
中有一个字段叫 debug_offsets
,我们来看下我们怎么初始化这个字段的吧
1 |
我们能看到我们使用了 offsetof
这个非常经典的宏来将一下我们常用的字段相较于结构体的偏移写入到 debug_offsets
中去。而 debug_offsets
将固定存在于 pyruntimestate
的第一个字段,同时起改变频率相对较低,所以我们就可以通过 debugger_support
获取不同地址的偏移量来获取最终我们想要的数据。
通过这样的做法,我们实际上就有很多很好玩的事情可以做了。实际上官方也是基于这样一套机制提出了 PEP 768 – Safe external debugger interface for CPython https://peps.python.org/pep-0768/。可以允许用户远程的为一个 Python 进程注入一段调试代码
我们来看一下这个 PEP 的核心实现
在前面介绍过的 ThreadState 中新增了一组结构
1 | typedef struct _remote_debugger_support { |
在执行过程中,如果 debugger_pending_call
为 1 的时候,我们就会去执行 debugger_script_path
中的脚本
1 | int _PyRunRemoteDebugger(PyThreadState *tstate) |
那么问题来了,我们现在怎么样给目标 Python 进程注入对应的值呢?我们来看看 remote_debugging.c 中的实现
首先入口函数为 _PySysRemoteDebug_SendExec
1 | int |
前面都是一些例行的检查,我们来看看 send_exec_to_proc_handle
这个函数
1 | static int |
我们先不考虑具体的细节的话,这段函数的逻辑还是非常明确的,通过 read_offsets
获取目标的地址偏移,通过 read_memory
这个函数读取不同地址,然后做一些处理后,通过 write_memory
来写入到目标进程中去
而 read_offsets
这个函数就是我们前面核心提到过的怎么样使用目前 Python 给出的调试信息的例子,我们来看一下其在 Linux 下的实现
1 | static int |
这里的核心函数是 _Py_RemoteDebug_ReadDebugOffsets
, 我们接着来看这个的实现
1 | static int |
我们注意到,这里的核心还是我们先要获取到 PyRuntime
的地址,那么我们来看看 _Py_RemoteDebug_GetPyRuntimeAddress
的实现
1 | static uintptr_t |
我们这里能看到 _Py_RemoteDebug_GetPyRuntimeAddress
调用了 search_linux_map_for_section
来获取当前的 PyRuntime
的地址,而 search_linux_map_for_section
则是通过 /proc/${pid}/maps
,暴力遍历 maps
中的内存段来获取具体的地址。
我们来看看 search_elf_file_for_section
的实现
1 | search_elf_file_for_section( |
这段代码稍微有点复杂,我们来拆分看一下
首先函数的声明
1 | search_elf_file_for_section( |
用于在ELF文件中搜索特定的section。参数包括:进程句柄、要查找的section名称、起始地址(文件在进程空间的映射位置)、ELF文件路径。
1 | int fd = open(elf_file, O_RDONLY); |
以只读方式打开ELF文件,如果失败则设置Python异常并跳转到退出处理。
1 | file_memory = mmap(NULL, file_stats.st_size, PROT_READ, MAP_PRIVATE, fd, 0); |
将文件内容映射到内存,以只读和私有方式,从文件头开始。失败则设置异常并退出。
1 | Elf_Ehdr* elf_header = (Elf_Ehdr*)file_memory; |
将文件开头 cast 为ELF文件头结构,并找到section header表的位置,它在文件偏移e_shoff处。
1 | Elf_Shdr* shstrtab_section = §ion_header_table[elf_header->e_shstrndx]; |
获取section字符串表(包含所有section名称的表),通过e_shstrndx索引定位。同时遍历所有section,查找匹配的section名称。注意需要跳过section名字的”.”前缀。
1 | Elf_Phdr* program_header_table = (Elf_Phdr*)(file_memory + elf_header->e_phoff); |
找到program header表,然后搜索第一个PT_LOAD类型的segment,它定义了程序加载时的基地址。
1 | if (section != NULL && first_load_segment != NULL) { |
如果找到了目标section和第一个LOAD segment,计算目标section的运行时地址:
- 计算ELF文件的加载基地址(考虑对齐)
- 目标地址 = 进程中映射的起始地址 + section的虚拟地址 - ELF加载基地址
经过这样一个流程,我们就能最终的获取到 _PyRuntime
中的地址,然后基于此做一些包括 PEP 768 在内很有趣的工作。
总结
Python 3.14 官方其实将进程信息以半正式化的形式形成了一组相对稳定的 ABI,这样可以使我们调试工具能以更好的方式对 Python 进程进行无侵入的调试与观测。PEP 768 其实是这个过程中一个的有效产物。而基于 PEP768 处理的比如 Remote PDB debug,目前也已合入分支。
可以说从 Python 3.14 起,Python 的调试工具和手段将得到极大的丰富与增强。建议大家在出来后的第一时间进行升级(
差不多就这样(