Memray attach 故障分析:uprobe 撤销与 UCX 内联 hook 的冲突

Memray attach 到一个运行中的 Python 进程时,在创建辅助线程的过程中触发了 SIGSEGV。沿着调用栈检查,问题落在 libc 的 mmap 入口:首字节是 libc 文件中的原始内容,紧接着的四字节却能解出一条指向 UCX 的跳转位移。

调查记录中的独立映射实验复现了这个变化。实际使用的 memleak 安装 uprobe 时,把入口首字节从 E9 改成 CC,退出时又恢复成 F3。最终留下的 16 字节与故障进程完全一致。本文围绕这段字节的来源,记录故障指令、UCX hook、内核恢复路径与对照实验之间的关系。字节损坏机制已经复现,原进程的历史写入过程没有被直接记录。

故障环境与 Memray 的调用路径

此前的使用 eBPF 监控内存分配主要讨论分配统计与 RSS 等指标的关系。这次是在一个包含 Python 和原生库的推理环境中排查内存增长,接入 Memray 时发生了异常,需要先查清辅助线程为什么创建失败。

以下版本、地址和实验输出来自 2026 年 10 月 19 日的调查记录。目标是容器中的 PID 1,命令行以 /usr/bin/python /usr/local/bin/torchrun --nproc_per_node 8 开头,进程名为 pt_elastic,从 10 月 17 日起运行。这个进程负责启动和管理 worker,是 launcher。本文取证的地址与字节属于这个 launcher,其他 worker 有各自的 PID 和地址空间。

项目 现场信息
架构与内核 x86-64,5.15.0-94-generic
容器用户空间 Ubuntu 24.04 noble
Python 3.12.3
Memray 1.20.0
GDB 15.1-1ubuntu1~24.04.1
glibc 包 libc6 2.39-0ubuntu8.5
UCX 内存事件库 /opt/hpcx/ucx/lib/libucm.so.0.0.0,精确发行版本未取得
实际成功运行的 memleak /usr/sbin/memleak,日志与行为对应 libbpf-tools 实现,精确构建版本未取得

这里容器中的用户空间是 Ubuntu 24.04,使用的宿主机内核是 5.15.0-94-generic。

Memray 最初只报告:

1
An unexpected error occurred. Run with --verbose to debug the failure.

补充 GDB 诊断后,相关调用栈如下:

1
2
3
4
#0  0x00007fc3785a32f0 in mmap64 () from libc.so.6
#1 0x00007fc37851b537 in pthread_create () from libc.so.6
#2 memray_spawn_client (...) at inject.cpp:264
#3 <function called from gdb>

这条 attach 路径通过 GDB 在目标进程中执行函数,GDB 把这种操作称为 inferior call,其中 inferior 就是被调试进程。Memray 1.20.0 的注入实现在 memray_spawn_client() 中调用 pthread_create()。源码注释说明,attach 时解释器可能正在修改 GC 链表等不可重入的状态,因此创建一个辅助线程,由它取得 GIL(CPython 的全局解释器锁)后再执行 Python 代码。

这次创建线程需要为线程栈建立映射。现场寄存器中的映射长度是 0x801000:

1
2
0x801000 = 8 × 1024 × 1024 + 4096 字节
= 8 MiB + 4 KiB

这个长度与本次调用中 8 MiB 栈加一页保护空间的分配路径相符。调查记录对精确版本 libc 的离线反汇编还确认,栈中返回地址之前是一条直接调用 mmap 入口的指令。

发生异常的是已有主线程 LWP 1,它正在执行 GDB 发起的函数调用,辅助线程尚未创建成功。这次到达故障入口的具体路径是 memray_spawn_client → pthread_create → mmap。

mmap 入口的字节与故障地址

内存中的入口与 libc 文件不同

现场停在 mmap64 的第一条指令:

1
0x7fc3785a32f0 <mmap64>: repz mov -0x2(%rbx,%rdi,2),%eax

为核对这条指令,调查继续读取进程内存中的字节。GDB 会管理软件断点,部分内存读取接口会展示被断点替换之前的内容,因此这里采用调试器退出后的独立读取。

在 GDB detach 之后,通过 /proc/1/mem 直接读取同一地址,并与目标进程根目录下的 libc 文件比较,得到:

1
2
memory: f3 8b 44 7b fe f7 c1 ff 0f 00 00 75 33 55 48 89
disk: f3 0f 1e fa 41 f7 c1 ff 0f 00 00 75 33 55 48 89

这里选择读取 16 字节,是为了同时覆盖前五字节补丁和紧接着的指令。检查前还核对了 /proc/1/stat 的启动时间字段,避免 PID 被复用后读到另一个进程。

文件中的 F3 0F 1E FA 是 ENDBR64,用于 x86 控制流保护技术 CET 中的间接分支跟踪。启用间接分支跟踪时,这条指令标记允许到达的间接分支目标。本例需要核对的是它占据的四个字节。后面的 41 F7 C1 ... 则是检查 %r9d 低位的指令。

比较文件内容还需要把进程虚拟地址换算成文件偏移。现场包含故障地址的映射从 0x7fc3785a3000 开始,对应文件偏移 0x125000,因此:

1
2
3
入口文件偏移 = 映射文件偏移 +(入口虚拟地址 - 映射起始地址)
= 0x125000 + (0x7fc3785a32f0 - 0x7fc3785a3000)
= 0x1252f0

诊断包记录了映射所对应的文件与目标根目录下的 libc 具有相同 inode 和 Build ID。离线取得的 Ubuntu 官方库也匹配这个 Build ID,其文件标识如下:

1
2
Build ID: 282c2c16e7b6600b0b22ea0c99010d2795752b5f
SHA256: de259f5276c4a991f78bf87225d6b40e56edbffe0dcbc0ffca36ec7fe30f3f77

文件身份核对确认了这些字节属于同一构建的 libc,detach 后的直接读取确认了错误字节仍留在进程内存中。

错误指令解释了本次 SIGSEGV

现场寄存器与信号信息为:

1
2
3
rbx     = 0xfffffffffffff000  # 按有符号数解释为 -4096
rdi = 0
si_addr = 0xffffffffffffeffe # 按有符号数解释为 -4098

按这条 mov 的寻址方式,读取地址是:

1
rbx + rdi × 2 - 2 = -4096 + 0 × 2 - 2 = -4098

结果与信号中的 si_addr 完全一致。这使得故障栈、实际入口字节、寄存器与非法访问地址相互对应:本次 SIGSEGV 就发生在错误入口的第一次内存读取处。

残留位移与 UCX 内联 hook

内存中的前五字节是 F3 8B 44 7B FE。如果把首字节暂时按 E9 解释,后面的四字节恰好能构成一条 x86-64 相对跳转。

E9 rel32 总共占五字节,E9 是操作码,rel32 是小端编码的有符号 32 位位移。跳转基准是这条指令结束后的地址,因此可以离线计算:

1
2
3
4
候选指令:E9 8B 44 7B FE
位移: 0xFE7B448B - 2^32 = -25,475,957
目标: 0x7fc3785a32f0 + 5 - 25,475,957
= 0x7fc376d57780

候选目标位于已经加载的 libucm.so.0.0.0 中。根据现场映射换算到库内,位置是 0xb780,恰好对应:

1
2
3
4
000000000000b780 <ucm_override_mmap>:
b780: f3 0f 1e fa endbr64
b784: 41 57 push %r15
...

这四字节位移恰好指向 ucm_override_mmap 的入口。整个计算使用已经取得的地址与字节,在离线条件下完成。

libucm 的内存事件机制

UCX 是面向高性能通信的软件框架,libucm 是其中负责内存事件的组件。内联 hook 指直接改写已有函数的入口,把调用转到替代实现。在这个例子中,libc 的 mmap 入口被改成跳转,原本进入 libc 的调用就会先到达 ucm_override_mmap。

UCX 需要知道映射何时建立、释放或改变。例如,在 RDMA 场景中,注册一段内存需要向通信设备登记地址范围与访问权限。注册缓存保存已经完成注册的区域,后续操作可以复用它们。应用释放一段映射后,同一虚拟地址可以映射到另一组物理页,因此覆盖这段地址的旧缓存项需要失效。libucm 提供 mmap、munmap 等内存事件,供注册缓存处理这些变化。

在 UCX v1.18.0 的注册缓存实现中,ucs_rcache_unmapped_callback() 收到 UCM_EVENT_VM_UNMAPPED 后,从事件中取得地址和长度,算出待失效范围。随后按锁与配置条件立即处理相交缓存项,或把这个范围放入失效队列。这就是内存事件与注册缓存之间的具体连接。

在 UCX v1.18.0 的安装代码中,内存事件表通过 UCM_MMAP_RELOC_ENTRY(mmap) 将 mmap 与 ucm_override_mmap 对应起来。在称为 bistro 的内联改写模式下,安装逻辑取得原函数地址,然后调用 ucm_bistro_patch()。x86-64 实现在位移允许且未强制远跳转时,使用上述 E9 rel32 补丁。

实际建立映射仍然由内核完成。bistro 模式的原始操作路径可以直接发起系统调用,避免重新进入刚刚改写的 libc 入口。

安装代码用 installed_events 记录已经安装的事件,遍历事件表时会跳过对应项。跳转写入入口后,后续调用直接执行这条跳转。本次分析的起点就是探针安装前已经存在的五字节跳转。

现场没有取得 UCX 的精确发行版本,v1.18.0 用来核对实现路径。对现场库的判断依据是残留位移、进程映射以及 ucm_override_mmap 的符号位置。调查记录还列出了已加载的 PyTorch、Open MPI、UCC 等库,具体业务请求的通信调用栈未被采集。

uprobe 保存与恢复的原始指令

接下来的问题是,谁会保留跳转的四字节位移,却把 E9 变回文件中的 F3。先前运行过的 memleak 是一条需要核查的路径。

memleak 的用户态分配追踪在 malloc、mmap 等函数进入和返回时采集分配信息,再与释放事件关联。这里使用的 uprobe 是 Linux 的用户态动态探针机制,探测位置以文件 inode 和文件偏移标识,再应用到目标进程的相应映射。eBPF 程序负责探针命中后的采集逻辑,代码位置上的探针由内核安装。

在本次 x86-64 路径中,内核将探测位置的首字节改成 CC,即一字节的 INT3 软件断点。执行到这里时,CPU 触发断点异常,内核识别 uprobe,调用关联处理逻辑,再执行或模拟被替换的原指令。需要异地执行时会使用 XOL(execute out of line) 指令区,即在独立位置执行保存的指令,然后继续原执行流。

这里选择 mmap 的入口作为探测位置。原来的 ENDBR64 占四字节,UCX 跳转占五字节,INT3 占一字节。安装探针时只需用 CC 覆盖这条入口指令的第一个字节。

保存值来自文件页缓存

在上游 Linux v5.15 的 copy_insn() 中,读取来源是:

1
2
3
struct address_space *mapping = uprobe->inode->i_mapping;
loff_t offs = uprobe->offset;
void *insn = &uprobe->arch.insn;

它通过 __copy_insn(),从文件页缓存读取对应偏移的指令。对于这里的普通文件,该函数使用 read_mapping_page() 取得页面,然后把内容复制到 uprobe->arch.insn。

这份保存值与进程私有代码页中当前的指令可以不同。UCX 改写的是进程中的映射,libc 文件仍以 F3 0F 1E FA 开头,所以内核保存的原指令也从 F3 开始。

内核在准备探针时保存文件页缓存中的指令,撤销时使用这份保存值。 文件页已经在缓存中时,读取直接使用该页。

写入保留了当前页面的其他字节

实际修改进程代码走的是 uprobe_write_opcode()。其中两个连续操作说明了新页面从哪里来,以及改多少字节:

1
2
copy_highpage(new_page, old_page);
copy_to_page(new_page, vaddr, &opcode, UPROBE_SWBP_INSN_SIZE);

先复制进程当前的页面,再只替换断点覆盖的部分。x86 定义为:

1
2
#define UPROBE_SWBP_INSN       0xcc
#define UPROBE_SWBP_INSN_SIZE 1

安装时写入 CC。撤销时,set_orig_insn()传入保存的原指令起始字节,通过同一写入路径恢复。于是,读取“原始内容”的来源是文件页缓存,保留其余字节的来源却是进程当前页面。

将这两个来源放到现场的五字节上,得到:

状态 libc 文件中的入口 进程内存中的入口
未改写 F3 0F 1E FA 41 F3 0F 1E FA 41
UCX 安装跳转后 F3 0F 1E FA 41 E9 8B 44 7B FE
uprobe 安装后 F3 0F 1E FA 41 CC 8B 44 7B FE
uprobe 撤销后 F3 0F 1E FA 41 F3 8B 44 7B FE

内核保存的是 F3,安装时覆盖的却是 E9。撤销只恢复首字节,后面仍是 UCX 跳转的位移,最终组成了一条混合指令。

撤销代码还会比较恢复后的页面与文件页缓存是否完全一致。如果一致,可以让映射回到文件页。本例保留了四字节跳转位移,整页相等的条件不成立,进程继续使用包含这些字节的私有页面。

这几次写入可以严格按顺序发生:UCX 安装跳转,memleak 安装探针,再撤销探针。每一步读取和写入的内容由上表确定。即使目标始终没有执行过这个入口,完成安装与撤销也会留下 F3 8B 44 7B FE。

verify_opcode()在撤销时检查当前位置是否仍是软件断点。当前位置已经被改成其他字节时,它会返回并跳过恢复。本例撤销前的字节为 CC,满足继续恢复的条件。上述源码来自上游 v5.15,现场 Ubuntu 5.15.0-94 的完整补丁集尚未核对,后面的实验用于验证现场内核实际写入了什么。

memleak 的实现与运行记录

调查记录中出现了两个 memleak 命令,它们的实现和运行结果不同。

命令 实现 记录到的结果
/usr/sbin/memleak-bpfcc BCC Python 脚本 缺少 kernel headers,在编译 BPF 模块时失败
/usr/sbin/memleak -p 1 5 3 日志与行为对应 libbpf-tools 实现 正常输出过周期统计

第一条命令的错误停在:

1
2
3
4
Unable to find kernel headers.
...
bpf = BPF(text=bpf_source)
Exception: Failed to compile BPF module <text>

BCC v0.29.1 的 Python 实现先执行 BPF(text=bpf_source),成功后才依次调用 attach_probes()。因此,记录中的这次失败发生在 mmap 探针安装之前。

第二条命令中的 -p 1 指向目标进程,5 是报告间隔秒数,3 是报告次数。这是当时的操作记录。随后用于对照实验的也是这个 /usr/sbin/memleak。

两种实现对安装失败的处理有一处与本次调查直接相关的差异。Python 版调用 attach_probes("mmap", can_fail=True),允许该探针安装失败后继续运行。libbpf-tools 的对应代码则为 mmap 入口和返回探针使用检查错误的宏:

1
2
ATTACH_UPROBE_CHECKED(skel, mmap, mmap_enter);
ATTACH_URETPROBE_CHECKED(skel, mmap, mmap_exit);

在所核对的实现中,任一安装失败都会离开 attach_uprobes(),主程序进入清理流程。成功后才进入周期报告循环。退出时,程序释放持有的探针连接,内核据此移除相应探针。因此,正常输出周期统计提供了探针安装已通过的线索。

这里引用 v0.29.1,是因为日志文本、探针逻辑,以及下一节的路径截断行为与现场相符。实际二进制的包归属、版本和哈希尚未取得,本文保留这个版本信息缺口。

独立映射上的对照实验

实验要测量的是三个时刻的入口字节:安装之前、探针存在期间、撤销之后。为此,可以只映射 libc 副本并观察代码页,把实际函数调用留在实验之外。

实验脚本可下载:verify-uprobe-restore.py。它在原容器中运行,使用现场的 libc 和 /usr/sbin/memleak,以脚本自身的 PID 为探测目标。

文件与映射的安排

脚本先检查 libc 文件的 SHA256,再检查文件偏移 0x1252f0 处的 16 字节是否与现场原始入口相同。这个偏移由前面的映射换算得到,哈希检查将它限定在已核对的 libc 构建上。

接着,脚本普通复制一份 libc,并检查副本与源文件的设备号、inode 组合不同。因为 uprobe 用 inode 和文件偏移定位探测位置,副本的独立 inode 能让本次安装作用于副本对应的映射。

同一个副本建立两份 MAP_PRIVATE 映射:

映射 初始入口 用途
control F3 0F 1E FA 41 ... 检查原始入口能否在撤销后恢复
patched E9 8B 44 7B FE ... 检查已有跳转在撤销后留下哪些字节

实验组起初以可读、可写权限映射,写入五字节补丁后,调用 mprotect() 改成只读、可执行,即 RX。这是实验成立的一个具体条件:上游 v5.15 的 valid_vma()在安装探针时排除带 VM_WRITE 的映射。脚本会检查 /proc/self/maps,确认两份映射的权限均为 r-xp 后再启动 memleak。

探针通过 -p 指定脚本 PID,通过 -O 指定 libc 副本。报告间隔设为一秒,共报告两次,以留出观察 CC 的时间窗口。脚本在 memleak 运行前、运行期间和退出后读取两份映射,并在结束时检查磁盘副本的内容。

实验组中的跳转位移原样取自现场。由于映射地址已经改变,这份位移在实验进程中没有对应的 UCX 调用目标。脚本仅将映射中的字节作为数据读取,全程没有调用其中的代码。

首次运行中的对象路径截断

首次运行没有观察到字节变化,结果为 INCONCLUSIVE。memleak 日志给出了安装失败的位置:

1
2
3
libbpf: elf: failed to open /tmp/uprobe-restore-0b5_rgb8/li: No such file or directory
no program attached for malloc_enter: No such file or directory
failed to attach uprobes

传入的完整路径为:

1
/tmp/uprobe-restore-0b5_rgb8/libc-copy.so

这个路径包含 41 个 ASCII 字节,日志中的路径只剩下前 31 字节。libbpf-tools 的对象路径字段定义为 char object[32],处理 -O 时使用:

1
strncpy(env.object, arg, sizeof(env.object) - 1);

最多复制 31 字节,最后一个位置留给 NUL。被截断的文件路径不存在,因此 memleak 在第一个 malloc_enter 探针处退出。这轮运行尚未到达 mmap 探针的安装步骤。

随后把临时目录前缀缩短,得到 /tmp/ur-xxxxxxxx/c.so 这样的路径。其中随机目录名部分为 8 个字符,整个路径共 21 字节。脚本同时加入长度检查,再进行下一轮实验。

安装与撤销期间的实测字节

调查记录中,在原容器执行:

1
python3 verify-uprobe-restore.py /usr/sbin/memleak

输出如下,PID、临时路径、设备号和 inode 均为这次运行的实际值:

1
2
3
4
5
6
7
8
9
10
11
12
13
libc SHA256: de259f5276c4a991f78bf87225d6b40e56edbffe0dcbc0ffca36ec7fe30f3f77
Evidence directory: /tmp/ur-gfv8n1n1
Source/copy device+inode: (2097292, 56099800) (2051, 2398963)
Command: /usr/sbin/memleak -p 83885 -O /tmp/ur-gfv8n1n1/c.so 1 2
before control: f3 0f 1e fa 41 f7 c1 ff 0f 00 00 75 33 55 48 89
before patched: e9 8b 44 7b fe f7 c1 ff 0f 00 00 75 33 55 48 89
during control: cc 0f 1e fa 41 f7 c1 ff 0f 00 00 75 33 55 48 89
during patched: cc 8b 44 7b fe f7 c1 ff 0f 00 00 75 33 55 48 89
during control: f3 0f 1e fa 41 f7 c1 ff 0f 00 00 75 33 55 48 89
during patched: f3 8b 44 7b fe f7 c1 ff 0f 00 00 75 33 55 48 89
after control: f3 0f 1e fa 41 f7 c1 ff 0f 00 00 75 33 55 48 89
after patched: f3 8b 44 7b fe f7 c1 ff 0f 00 00 75 33 55 48 89
Result: REPRODUCED

对照组完成了 F3 → CC → F3。实验组完成了 E9 → CC → F3,后四字节一直是 8B 44 7B FE。最终实验组的 16 字节与 PID 1 中观察到的内容相同。

脚本要求以下条件同时成立,才输出 REPRODUCED:观察到实验组入口为 CC,memleak 在超时前以状态码 0 退出,磁盘副本保持原样,对照组恢复原始 16 字节,实验组得到准确的损坏字节。这样,现场内核与实际工具怎样安装和撤销探针,就有了直接的字节观测。

两份映射中都没有执行过代码,这次实验仍然得到了损坏入口。形成这些字节所需的操作已经缩小到:在已有五字节跳转的映射上安装探针,再撤销探针。

原进程故障与实验结果的对应

将源码与观测结果放在一起,原进程最符合现有证据的故障过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
UCX 将 mmap 入口改为 E9 8B 44 7B FE
↓
memleak 安装 uprobe,入口变为 CC 8B 44 7B FE
↓
memleak 退出,内核恢复文件原指令的首字节 F3
↓
入口留下 F3 8B 44 7B FE
↓
Memray 通过 GDB 调用 memray_spawn_client
↓
pthread_create 为线程栈调用 mmap
↓
错误的 mov 读取 0xffffffffffffeffe,触发 SIGSEGV

每个判断对应的证据可以分别列出:

判断 依据
故障发生在错误入口的内存读取处 指令的有效地址计算与 si_addr 相同
入口中四字节位移指向 UCX hook E9 rel32 的目标地址与现场 ucm_override_mmap 符号重合
实际 memleak 能留下这些损坏字节 独立映射实验记录了 E9 → CC → F3,最终 16 字节匹配
业务 PID 上曾成功运行过 memleak 操作记录与周期统计输出
该次运行就是原进程入口损坏的写入来源 根据以上事实作出的历史归因,缺少原进程改写前的快照与写入瞬间记录

实验直接验证了安装、撤销与字节变化之间的关系。原进程的历史归因还需要把那次成功运行的 memleak 与具体写入时刻对应起来,现有材料缺少第一次 Memray attach 之前的入口快照,也没有针对该地址的历史写入审计。因此,本文将它保留为最符合现有证据的解释。

GDB 是调查中检查过的另一条写入路径。Memray 1.20.0 的 attach 脚本在 malloc、calloc、Python 分配函数等位置设置断点,命中后删除这些断点,再加载注入库并调用 memray_spawn_client()。脚本没有在 mmap 入口设置断点,已有日志也未记录 GDB 在该地址恢复 F3。对 memleak 的判断则有独立映射中相同字节变化的直接支持。

本次实验覆盖了上述流程中的探针安装和撤销步骤。真实 UCX 初始化、业务进程中的历史执行顺序,以及从初始化一直运行到 Memray 触发异常的完整复现,仍属于尚未完成的验证。

调用与调试状态补充

launcher 的后续执行路径

损坏字节位于 launcher 的地址空间。已有线程可以继续等待,或执行不经过该入口的代码。worker 位于其他进程,它们的执行状态需要分别检查。调查前后 PID 和启动时间没有变化,说明这段观察期间未发现对应进程重启。

还需要按实际调用地址区分映射路径。直接发起 mmap 系统调用的代码会从自己的系统调用指令进入内核,复用已有内存的分配也可以跳过这次映射。现场 libc 中的 mmap、mmap64、__mmap 则是同地址别名,通过这些符号进入时都会执行同一段字节。

错误入口的后续指令

第一次错误读取的地址由 %rbx、%rdi 决定。在本次调用中,它恰好是非法地址 0xffffffffffffeffe。另一组寄存器值若使读取地址落在可访问页面中,CPU 还会继续执行后面的指令。

后面的字节同样需要重新解码。文件原始入口中的 41 是 REX 前缀,它使紧接着的 F7 C1 ... 使用 %r9d。这个前缀被五字节跳转覆盖后,从偏移 5 开始的指令变成:

1
test $0xfff, %ecx

在这里的 x86-64 函数调用约定中,mmap 的第四个参数 flags 位于 %ecx,第六个参数 offset 位于 %r9。原始入口检查 offset 的低位,损坏入口检查的则是 flags 的低位。

0xfff 覆盖低 12 位,来自精确 libc 反汇编中的立即数。调查记录显示,随后条件跳转的目标是设置 EINVAL 的路径。现场 flags 为 0x20022,所以:

1
0x20022 & 0xfff = 0x22

如果执行到这条 test,结果非零,就会跳到该错误返回路径。本次实际执行在前一条 mov 已经触发 SIGSEGV,上述分支是根据字节与寄存器推导出的后续行为。

GDB 对 inferior call 信号的处理

本次 attach 脚本设置了 unwindonsignal。在 inferior call 收到 SIGSEGV 后,GDB 恢复调用前的上下文,然后 detach。它恢复的是被调试调用的执行上下文,函数执行期间已经发生的内存写入、库加载,以及其他线程的动作会留在进程中。

现场进程在这次调试操作后继续存活,与上述信号处理过程相符。通常未被处理的 SIGSEGV 会终止整个进程,因此用进程内新建线程执行同一错误入口,仍然会影响整个进程。

探针存在期间的执行

本次实验将映射用于字节读取,探针没有被命中。由此得到的直接观察覆盖安装与撤销期间的字节变化。探针存在期间,内核执行或模拟保存指令的行为,需要另设执行实验验证。

这里保存的指令来自文件,进程原本准备执行的指令来自 UCX 补丁,两者已经不同。检查这一组合的兼容性时,还需要观察命中探针后是否按预期进入 ucm_override_mmap,以及后续参数和返回值是否正确。

处置与后续验证

继续调查应先保留当前进程的映射、入口字节、Build ID、工具版本和操作时间,再在独立进程中测试同一内核、UCX 与 profiler 的组合。新测试应在 UCX 安装后、探针安装后、撤销后分别读取入口,同时记录实际函数调用的结果。这样可以逐步补齐本次缺少的执行过程。

已经损坏的业务入口需要结合进程恢复流程处理。在线写回 E9 要确认 UCX hook 仍然有效、线程当前是否正在执行被改写的区域,以及指令修改如何同步到其他线程。本次没有验证这样的在线修补流程。重建进程会重新映射库并执行初始化,恢复前还需要移除已知的触发组合,恢复后核对入口字节与服务行为。调查记录没有包含实际业务重启结果。

后续选择 profiling 工具时,可以具体检查它如何接入目标:是否改写用户态函数入口、是否向目标注入代码、是否在目标中创建线程。本次 Memray 的调用栈确认了它会创建线程,memleak 的对照实验确认了它会通过内核改写入口。对同一版本组合,还应检查两个工具是否覆盖同一段代码,以及撤销探针后原有跳转的五个字节是否完整。

复现脚本补充

下载完整实验脚本后,显式指定现场实际使用的 memleak 运行:

1
python3 verify-uprobe-restore.py /usr/sbin/memleak

原脚本省略命令行参数时会查找 memleak-bpfcc。复现实测组合时,按上面的命令显式传入 /usr/sbin/memleak。

脚本限定 Linux x86-64,并在使用固定偏移前校验 libc 哈希。其他 libc 构建需要重新解析符号与文件偏移,再相应修改校验值。运行环境需要已经具备加载和附着用户态 BPF 探针的条件。

脚本每 20 毫秒读取一次入口字节,在一秒报告间隔内多次观察探针状态。轮询以 60 秒为期限,超过期限后进入终止测试子进程的流程,依次尝试 SIGINT、SIGTERM、SIGKILL,每次最多等待 5 秒。这些数值都是用于控制实验时长与观察频率的参数。轮询未捕获到 CC 时,脚本会输出 INCONCLUSIVE,此时需要结合日志确认探针是否安装,以及观察窗口是否覆盖了它存在的时间。

输出目录保留 maps.txt、memleak.log、result.json 与 libc 副本。result.json 记录内核版本、目标 PID、设备号与 inode、实际命令、观测字节和退出状态,便于与下一次运行比较。

参考资料