Appearance
动态探针插桩原理详解(perf probe / uprobe / USDT)
本文是从 实验报告 README.md 拆出的原理篇,聚焦 uprobe / USDT / tracefs / ftrace 的工作机制与内核实现细节。实验设计、实验数据、性能分析等请回 README.md。
一句话总结:本文从"一条
perf probe --add 'do_work n=%di'在内核里到底发生了什么"出发,逐级拆解 uprobe 完整注册链路、int3 断点命中处理、uretprobe 跳板机制、USDT 零开销待命的本质、以及 tracefs 作为统一控制面的文件级实操,最终回答"内核凭什么知道该抓什么、怎么恢复用户态"三个核心疑问。
1 原理详解
1.1 术语前置(含英文缩写原文)
- 探针(probe):在程序某个位置插入的"观测点",命中时内核触发一次 tracepoint 事件并采集指定寄存器/内存。本实验主线是用户态动态探针(uprobe)。
- UDB(User-level Dynamic Tracing / 用户态动态追踪):在用户进程里做动态插桩的技术族统称,
perf probe(基于 uprobe)、bpf、bpftrace的用户态探针都属此类,区别于内核态动态追踪(kprobe)。 - uprobe(user-space probe / 用户态探针):Linux 内核提供的、挂在用户程序某虚拟地址上的断点机制。
perf probe -x ./binary背后往 ELF 对应地址写断点,命中时由内核 uprobe 子系统以 tracepoint 事件形式把上下文交给perf的 ring buffer。 - uretprobe(user-space return probe / 用户态返回探针):uprobe 的"返回版",在函数返回地址处插桩,捕获返回时刻与返回值。
- USDT(User Statically Defined Tracing / 用户态静态定义追踪):在源码里预埋的探针点,编译进二进制(一条
nop+ 一段 ELF note 记录参数位置),运行时零开销,直到有人perf probe --addattach 才激活。 - ftrace(function tracer / 函数跟踪器):Linux 内核自带的跟踪框架,含 function tracer,可在内核侧追踪带符号的函数(含部分通过
mcount/-pg插桩的用户态库,但本实验用它追踪内核函数与用户函数调用计数)。 - DWARF:调试信息格式。
-g编译后二进制里带 DWARF,记录"函数名→地址""变量名→寄存器/栈偏移"。抓函数参数n=%di依赖它;只做函数调用计数则不依赖。 - 符号表(
.symtab):ELF 里的"函数名→地址"映射。即使strip掉 DWARF,只要保留.symtab,perf probe仍能按函数名挂载(拿不到参数,但能计数/计时)。 - CoW(Copy-on-Write / 写时复制):内核在插入 uprobe 时,把目标代码页复制一份并改写断点指令,原进程其余映射不受影响。
- tracefs:内核调试文件系统(
/sys/kernel/debug/tracing),uprobe/ftrace 的注册接口都在这里。 - ring buffer(环形缓冲区):perf 在内核态分配的共享内存,tracepoint 事件先写入这里,用户态
perf异步读取。 - ABI(Application Binary Interface / 应用二进制接口):规定函数调用时参数/返回值如何放进寄存器与栈。本实验依赖 System V AMD64 ABI(x86-64 Linux 通用)。
perf record/perf script:perf record -e <event>把事件采样进perf.data;perf script把perf.data里每条事件逐行回放成可读文本。
1.2 核心直觉:动态 vs 静态
| 维度 | 动态插桩(uprobe) | 静态预埋(USDT) |
|---|---|---|
| 埋点时机 | 运行时,由 perf 在任意地址插 | 编译时,源码写死 |
| 是否需要源码 | 否(只要有二进制+符号) | 是(需改源码预埋) |
是否需要 -g | 抓参数需要,计数不需要 | 完全不需要 |
| 插桩位置 | 任意函数入口/返回/行 | 只有预埋的那几个点 |
| 运行时开销(未激活) | 无(没插就无) | 零(编译成 nop) |
| 灵活性 | 高(事后任意加) | 低(预埋点固定) |
一句话
- uprobe 是"事后任意插",USDT 是"事前固定埋"
- 生产排障常用 uprobe(不用重发)
- 性能热点长期观测常用 USDT(稳定、零开销、不依赖调试信息)
1.2b probe 支持的几种类型(分类详细论述)
"动态探针"是一族机制,按两个维度切分:
- 插桩位置:内核态 / 用户态
- 埋点方式:静态 / 动态
perf probe 只是统一前端,背后真正落地的是下面四类探针:

正文论述:上图用一条时序线说明 perf probe(性能剖析工具 perf 的插桩子命令)的统一前端背后实际落地的两类机制:
- 用户态目标(
-x ./binary,即指定被测二进制):走 uprobe / uretprobe(user-space probe / user-space return probe,用户态动态函数探针及其返回探针,本实验主角) provider:name形式:走 USDT(User Statically Defined Tracing,用户态静态定义跟踪点)
补充与提醒:
内核态目标(kprobe / kretprobe / tracepoint)原理同源,因本实验是用户态程序调优场景,未做展开
需注意:3.10 内核下 USDT 这条分支(图中
S)实际 attach 失败(见实验报告 §5.3),故本实验真正跑通的是 uprobe 分支一句话总结:
perf probe像统一收银台,背后按目标位置分派到 uprobe 或 USDT 两套内核机制。
| 探针类型 | 插桩位置 | 埋点方式 | 触发语法(perf) | 需 -g DWARF | 本实验 |
|---|---|---|---|---|---|
| kprobe | 内核函数 | 动态 | perf probe --add 'schedule' | 是 | 否 |
| kretprobe | 内核函数返回 | 动态 | perf probe --add 'schedule%return' | 是 | 否 |
| tracepoint | 内核固定点 | 静态 | perf stat -e tracepoint:syscalls:* | 否 | 否 |
| uprobe | 用户函数 | 动态 | perf probe -x ./bin --add 'do_work' | 抓参数需 | 是(主角) |
| uretprobe | 用户函数返回 | 动态 | perf probe -x ./bin --add 'do_work%return' | 抓参数需 | 是(返回探针) |
| USDT | 用户固定点 | 静态 | perf probe --add 'bin:provider:name' | 否 | 是(对照) |
本实验是用户态程序调优场景,因此同时用到 uprobe + uretprobe(动态)与 USDT(静态对照),并以 ftrace 作为内核内置方案补充。其余内核类型列出是为说明"动态探针"家族全貌。
1.3 uprobe 在内核里到底发生了什么(深入机制)
一条 perf probe --add 'do_work n=%di' 从用户态到命中采样,跨过三道关卡。理解链路才能看懂实验报告 §3/§5/§7。
1.3.0 tracefs:内核提供的统一插桩接口与数据通道(先讲清楚"内核给了什么")
tracefs 是什么。tracefs 是 Linux 内核提供的一个虚拟文件系统(pseudo/virtual filesystem,不占磁盘,内容由内核在内存中实时生成),标准挂载点为 /sys/kernel/debug/tracing(它最初挂在 debugfs 之下,后独立为 tracefs)。它本质上是内核把"跟踪/插桩能力"以文件接口的形式暴露给用户态——你不需要写内核模块,只要 echo 一行文本进某个文件、或 cat 某个文件,就能注册探针、开关 tracer、读取跟踪结果。uprobe、kprobe、ftrace、tracepoint 的控制面(注册/开关)全部收敛到 tracefs 这一层。
内核到底提供了什么。从"内核提供了什么东西"的角度,tracefs 背后是三件套:(a)接口层——tracefs 目录与文件(uprobe_events、kprobe_events、set_ftrace_filter、current_tracer、trace 等),用户态与内核的"控制面"对话窗口;(b)机制层——各 events 文件背后的内核子系统(uprobe 子系统负责用户态断点、ftrace 负责内核函数跟踪、tracepoint 是写死在内核代码里的静态钩子);(c)数据面——perf 的 ring buffer(环形缓冲区),内核态分配的一块共享内存,命中事件先写这里,用户态 perf 异步搬走。一句话:内核提供的是"文件接口 + 断点/跟踪机制 + 内核态 ring buffer"这套组合,tracefs 是把前两者暴露给用户的门面。
数据怎么交互(双向)。控制面走"写文件":用户态 perf probe 把解析好的探针描述 echo 进 uprobe_events,内核子系统据此在目标地址植入 int3。数据面走"ring buffer":目标进程执行到 int3 → 内核 handler 构造事件写入 ring buffer → 用户态 perf 异步 mmap 读取,落盘为 perf.data。两条通道方向相反、各司其职:tracefs 文件管"注册什么",ring buffer 管"采到了什么"。

正文论述:上图把 tracefs 的"双向交互"一次画清,分两层理解:
- 控制面(上半段:写文件注册)
- 用户态
perf把探针描述echo进 tracefs 的uprobe_events文件 - 内核 uprobe 子系统解析后,在目标进程代码页(经 CoW)植入
int3 - 这一步完成的是"注册",不产生任何事件数据
- 用户态
- 数据面(下半段:ring buffer 采数)
- 目标进程真正执行到
int3时,内核 handler 构造 tracepoint 事件、写入内核态 ring buffer - 用户态
perf通过mmap异步把事件搬走,落盘成perf.data(后续perf script逐行回放)
- 目标进程真正执行到
- 关键结论
- 控制面与数据面走的是两条完全不同的内核设施:控制面是 tracefs 文件(文本接口),数据面是 ring buffer(二进制共享内存),二者由内核 uprobe 子系统在中间桥接
- 这正回答了"内核提供了什么":内核提供 tracefs(控制门面)+ uprobe 机制(断点实现)+ ring buffer(数据通道)三者,用户态工具只负责按文件协议写、按
mmap协议读
1.3.0a tracefs 实操手册:如何直接用 echo/cat 操作、数据格式是什么
上一节讲的是"原理性理解",本节直接从文件操作出发,告诉你每个文件怎么写、怎么读、写进去的是什么格式、读出来的是什么格式。以下示例均来自本实验真实终端输出。
一、tracefs 目录一览——哪些文件能用、分别干什么
进入 /sys/kernel/debug/tracing(即 tracefs 挂载点),核心文件按用途分为五组:
| 用途 | 关键文件 | 操作 | 说明 |
|---|---|---|---|
| 控制面:探针注册 | uprobe_eventskprobe_events | sudo echo '...' >sudo cat | 注册/查看 uprobe 和 kprobe;写一行即注册、echo > 清空全部 |
| 控制面:事件开关 | events/probe_<group>/<name>/enable | echo 1 > 开echo 0 > 关 | 每个已注册探针有独立 enable 文件 |
| 控制面:ftrace 开关 | current_tracerset_ftrace_filterset_ftrace_pid | 写字符串 | 选 tracer(function / nop)、过滤函数、限定 PID |
| 数据面:事件格式查看 | events/probe_<group>/<name>/format | cat | 看每个探针的事件字段布局、参数类型/偏移 |
| 数据面:跟踪输出 | tracetrace_pipe | cat | trace 静态快照、trace_pipe 实时流式输出(阻塞读取) |
所有文件都是虚拟文件——你写什么立刻被内核解析,读什么由内核实时生成内容,不留磁盘痕迹。
二、uprobe_events 格式详解——写进去的是什么
uprobe_events 里每一行就是一个探针,格式固定:
bash
<类型>:<组名>/<事件名> <二进制路径>:<文件偏移量> [参数列表]逐字段拆解(以本实验实际注册的探针为例):
bash
p:probe_perf_probe_demo/do_work /root/perf-probe-exp/perf_probe_demo:0x0000000000000e10 n=%di| # | 字段 | 示例值 | 含义 |
|---|---|---|---|
| 1 | p 或 r | p | 探针类型:p(probe,入口探针 / uprobe)、r(retprobe,返回探针 / uretprobe) |
| 2 | 组名 | probe_perf_probe_demo | 事件组,perf probe 自动用 probe_<二进制名> 命名;手动写可自定义 |
| 3 | / 分隔符 + 事件名 | /do_work | 事件名即探针标识;perf stat -e <组名>:<事件名> 里用的就是这个 |
| 4 | 二进制路径 | /root/perf-probe-exp/perf_probe_demo | 目标 ELF 二进制文件的绝对路径(路径不对则注册失败/命中 0) |
| 5 | : + 文件偏移量 | :0xe10 | 文件内偏移而非虚拟地址;objdump -h 可见 text 段偏移 |
| 6 | 参数列表(可选) | n=%di | 抓参规则:变量名=%寄存器 或 变量名=@栈偏移。多参数空格分隔,如 n=%di x=%si |
返回探针(uretprobe)的区别:
bash
r:probe_perf_probe_demo/do_work__return /root/perf-probe-exp/perf_probe_demo:0x0000000000000e10p变成r,事件名自动加__return后缀- 参数部分为空(返回探针不抓入口参数,想抓返回值用
$retval或再起一个入口探针配合)
注册/删除/清空的命令对照:
bash
# 注册:直接 echo 追加到文件(一行一个探针)
echo 'p:mygroup/myfunc /path/to/binary:0x1234 n=%di x=%si' | sudo tee -a /sys/kernel/debug/tracing/uprobe_events
# 查看:直接 cat(输出格式同上表,每行一个探针)
sudo cat /sys/kernel/debug/tracing/uprobe_events
# 删除单个探针:echo 中 "-:" 前缀表示"删除该探针"
echo '-:mygroup/myfunc' | sudo tee -a /sys/kernel/debug/tracing/uprobe_events
# 清空全部(注意 ⚠️:一个 > 就全没了)
sudo echo '' > /sys/kernel/debug/tracing/uprobe_events三、事件注册后如何查看"格式"——events/.../format 文件
用 echo 注册 uprobe 后,内核自动在 events/<组名>/<事件名>/ 下创建一整套控制文件:
bash
ls /sys/kernel/debug/tracing/events/probe_perf_probe_demo/do_work/
# enable filter format id trigger其中 format 文件记录了该探针事件的数据结构,perf stat/perf record 读它来解析事件。直接 cat 即可看到字段布局:
bash
name: do_work
ID: 1516
format:
field:unsigned short common_type; offset:0; size:2; signed:0;
field:unsigned char common_flags; offset:2; size:1; signed:0;
field:unsigned char common_preempt_count; offset:3; size:1; signed:0;
field:int common_pid; offset:4; size:4; signed:1;
field:unsigned long __probe_ip; offset:8; size:8; signed:0;
field:unsigned int n; offset:16; size:4; signed:0;逐行解读:
common_type/common_flags/common_preempt_count/common_pid:4 字节公共头,每个 tracepoint 事件都有,记录事件类型、标志位、抢占嵌套深度、触发 PID__probe_ip(offset 8):命中地址(8 字节unsigned long),即 uprobe 所在指令地址,perf script输出的(400e10)就是这个值n(offset 16):你抓的参数(n=%di注册进来的),4 字节unsigned int,即%rdi寄存器值。若注册时写了多个参数,会依次排列在后面——format就是你的数据"解码表"
这个文件就是"数据格式"的精确定义。后面
perf script输出的n=0x94bf就是按 format 的offset:16 size:4从事件 payload 里抠出来的。如果你直接写uprobe_events而不是用perf probe,写完后来读一下format,验证字段偏移和你预期的一致。
四、如何开关已注册事件、直接读 trace 输出——不用 perf
不用 perf stat/perf record,仅靠 tracefs 文件也能完成"启用 → 触发 → 读取"全流程:
bash
# ① 先注册探针(第三步已做)
echo 'p:probe_perf_probe_demo/do_work /path/to/binary:0xe10 n=%di' \
| sudo tee /sys/kernel/debug/tracing/uprobe_events
# ② 开启该探针(enable 文件写 1)
sudo echo 1 > /sys/kernel/debug/tracing/events/probe_perf_probe_demo/do_work/enable
# ③ 跑目标程序
./perf_probe_demo 3
# ④ 读 trace 输出(静态快照)
sudo cat /sys/kernel/debug/tracing/tracetrace 原始输出示例(本实验真实抓取):
bash
# tracer: nop
# entries-in-buffer/entries-written: 51479/51479 #P:4
# _-----=> irq-off
# / _----=> need-resched
# | / _---=> hardirq/softirq
# || / _--=> preempt-depth
# ||| / delay
# TASK-PID CPU# |||| TIMESTAMP FUNCTION
# | | |||| | |
perf_probe_demo-2917 [003] d... 84586.014445: do_work: (0x400e10) n=0x94bf
perf_probe_demo-2917 [003] d... 84586.014652: do_work: (0x400e10) n=0x1a2ctrace 文件格式与 perf script 输出类似,但列聚合方式不同。逐字段:
| # | 示例 | 含义 |
|---|---|---|
| 1 | perf_probe_demo-2917 | 进程名-PID(<comm>-<pid>) |
| 2 | [003] | CPU 编号 |
| 3 | d... | 4 个标志:d=hardirq/softirq disabled, ... = need-resched / preempt-depth / delay 未触发 |
| 4 | 84586.014445: | 时间戳(秒.微秒),perf_clock 单调钟 |
| 5 | do_work: | 事件名(你注册时的 <事件名>) |
| 6 | (0x400e10) n=0x94bf | 命中地址 + 参数,格式与 format 里 field:unsigned int n 对应(值=38079) |
与 perf script 输出的对比:trace 输出更接近"原始 dmesg 风格",列布局固定;perf script 更灵活(-F 选字段、不依赖 tracefs)。两者底层数据源一致,都是 ring buffer。
trace_pipe 实时流式读取(阻塞等待,适合现场观察):
bash
# 终端 A:实时盯事件,来了就刷
sudo cat /sys/kernel/debug/tracing/trace_pipe
# 终端 B:启动目标程序
./perf_probe_demo 3
# 终端 A 同步刷出所有事件五、ftrace function tracer 的 tracefs 操作(对照)
uprobe 走 uprobe_events + perf,ftrace 走另一套文件。下面是完整操作流程,与实验报告 §5.4 的数据对应:
bash
# ① 清空旧数据
sudo echo '' > /sys/kernel/debug/tracing/trace
# ② 设置追踪目标函数(只追踪 do_work)
sudo echo 'do_work' > /sys/kernel/debug/tracing/set_ftrace_filter
# ③ 选择 function tracer(内核函数调用追踪器)
sudo echo function > /sys/kernel/debug/tracing/current_tracer
# ④ 开总开关
sudo echo 1 > /sys/kernel/debug/tracing/tracing_on
# ⑤ 跑目标程序
./perf_probe_demo 5
# ⑥ 关总开关,读结果
sudo echo 0 > /sys/kernel/debug/tracing/tracing_on
sudo cat /sys/kernel/debug/tracing/trace | grep do_work | wc -l
# 输出:0 ← 用户态函数 ftrace 追踪不到(实验报告 §5.4 已论证)
# 对照:追踪内核函数 vfs_read
sudo echo 'vfs_read' > /sys/kernel/debug/tracing/set_ftrace_filter
sudo echo function > /sys/kernel/debug/tracing/current_tracer
sudo echo 1 > /sys/kernel/debug/tracing/tracing_on
cat /some/file
sudo echo 0 > /sys/kernel/debug/tracing/tracing_on
sudo cat /sys/kernel/debug/tracing/trace | grep vfs_read | wc -l
# 输出:几百~几千 ← 内核函数正常追踪
# ⑦ 清场(恢复默认 nop tracer)
sudo echo '' > /sys/kernel/debug/tracing/set_ftrace_filter
sudo echo nop > /sys/kernel/debug/tracing/current_tracer
sudo echo '' > /sys/kernel/debug/tracing/traceftrace 可选的 current_tracer 值(写字符串切换):
写 current_tracer | 功能 | 输出文件名 |
|---|---|---|
nop | 空 tracer(默认,关闭) | — |
function | 按 set_ftrace_filter 过滤函调调用 | trace |
function_graph | 函数调用图(入口+返回,带耗时) | trace |
set_ftrace_filter 支持通配:echo 'do_w*' > .../set_ftrace_filter 匹配所有 do_w 开头的函数。写空串清空过滤器。
六、一句话总结
uprobe_events格式:<p|r>:<组>/<名> <二进制路径>:<偏移> [参数],写一行注册一行,-:删除,空串清空。- event format 文件:注册后自动生成,定义每个事件字段的 offset/size/type,是数据解码的唯一权威参考。
- 直接用 tracefs 的完整闭环:
echo ... > uprobe_events注册 →echo 1 > enable开 → 跑程序 →cat trace/trace_pipe读事件。 - ftrace 另一套文件但同根:
set_ftrace_filter+current_tracer+tracing_on+trace,格式与 uprobe trace 输出类似(进程-PID / CPU / 标志 / 时间戳 / 函数名)。
阅读导航:本节的
trace原始输出、format解码表,与实验报告 §5.1 ④perf script实测是同一份 ring buffer 数据的两种呈现——想看"真实抓到的样子"去实验报告 §5.1,想看"perf script那一行 7 个字段怎么读"去实验报告 §5.1 ④a;想看"为什么这么快/怎么恢复"去 §1.3.2b。
1.3.1 完整注册链路
perf probe 走"用户态解析 → 注册进 tracefs → 内核接管"三步:

正文论述:注册链路分三段跨过:
① 用户态解析
perf probe读 ELF(Executable and Linkable Format,可执行可链接格式)的.symtab(符号表段)/.debug_info(DWARF 调试信息段)- 把
do_work映射到文件偏移、把参数n映射到寄存器%rdi
② 写 tracefs
- 解析结果写成一行文本写入
/sys/kernel/debug/tracing/uprobe_events - 格式:
p:组/名 二进制路径:文件偏移 参数编码 - 这一步成功只是"申请"成功,探针尚未真正激活
- 解析结果写成一行文本写入
③ 内核接管
- uprobe 子系统在目标偏移处通过**写时复制(CoW, Copy-on-Write)**把该代码页换成带
int3断点的副本 - 进程执行到该地址触发
int3后,以 tracepoint(内核静态跟踪点)事件形式把上下文塞进perf的 ring buffer(环形缓冲区)
- uprobe 子系统在目标偏移处通过**写时复制(CoW, Copy-on-Write)**把该代码页换成带
一句话总结:注册 = 用户态算地址 → 写 tracefs 登记 → 内核 CoW 植入 int3 三步走;前两步只是"申请",真正激活在内核。
- 符号/DWARF 解析:
perf probe读 ELF 的.symtab/.debug_info,把do_work映射到文件偏移、把n映射到寄存器%rdi。 - 写
uprobe_events:解析结果写成一行文本写入 tracefs(格式:p:组/名 二进制路径:文件偏移 参数编码)。这一步成功只是"申请"成功。 - 内核注册:内核 uprobe 子系统在目标偏移处,通过**写时复制(CoW)**把该代码页换成带
int3断点的副本。 - 命中派发:进程执行到该地址触发
int3→ 内核 uprobe 处理 → 以 tracepoint 事件形式把上下文塞进perf的 ring buffer。
1.3.2 int3 断点如何被处理(命中瞬间)
uprobe 复用 x86 的 int3(0xCC)断点指令机制(与 kprobe 同源)。命中时的内核处理流程:

正文论述:uprobe 复用 x86 的 int3(0xCC)断点指令机制(与 kprobe 同源)。命中处理分三步:
保存现场 + 构造事件上下文
- 内核 handler 先保存寄存器现场,构造 tracepoint(内核静态跟踪点)事件上下文
读取参数(视调试信息而定)
- 若有 DWARF(Debugging With Arbitrary Record Formats,一种标准调试信息格式,记录变量/参数与寄存器的对应关系):按偏移读
%rdi等寄存器,填入事件 payload - 若无 DWARF:仅记录 IP/时间戳
- 事件写入
perfring buffer(环形缓冲区)
- 若有 DWARF(Debugging With Arbitrary Record Formats,一种标准调试信息格式,记录变量/参数与寄存器的对应关系):按偏移读
单步补执行 + 恢复
- 内核单步执行被
int3替换掉的原指令(原指令暂存在 uprobe 副本里) - 恢复现场,返回原流程
- 内核单步执行被
关键点
int3是陷阱而非真的"断",对用户进程透明- 这正是 uprobe 相比
printf开销小得多的原因(一次陷阱 + 一次缓存写入)
一句话总结:命中 = 保存现场 → 读参写 ring buffer → 单步补执行原指令,全程对进程透明。
关键点:
int3是陷阱,不是真的"断"。内核捕获后单步执行原指令再恢复,对用户进程透明。这也是 uprobe 相比printf开销小得多的原因——一次陷阱 + 一次缓存写入。
1.3.2b 命中后内核如何"定位 / 取数 / 恢复"(回答三个核心疑问)
§1.3.2 只画了粗线条,这里把大家最常卡住的三个问题一次讲透:int3 触发后内核凭什么找到"正确的那个探针"?它怎么知道"该抓什么"?处理完又怎么"无缝恢复用户态"?

① 如何找到"正确的地方"(定位)
int3让 CPU 进入异常态,内核do_int3拿到"触发异常的指令虚拟地址",不会去扫描所有探针。- 注册时(§1.3.1 ③)内核已在目标进程的
mm->uprobes_state里建好一棵红黑树,key =inode : 偏移(即"哪个文件的哪段偏移")。 - 命中时直接拿异常地址算
inode:offset去查这棵树 → 命中唯一的struct uprobe,其内已挂好注册时登记的trace_event_call(事件元信息)。"去哪找"在注册期就固化了,命中只是 O(log n) 查表。 - 这就是为什么纯
strip发布物会失败:strip 后虚拟地址与文件偏移错位,查表 key 算错,内核找不到对应的 uprobe 或映射到错误位置(实验报告 §6.2)。
② 如何知道"要抓什么"(取数格式在注册期固化)
- 注册时
perf probe --add 'do_work n=%di'里的n=%di不是每次现解析,而是被编译成一串fetch_insn(取数指令):"%rdi的值 → 命名为n→ 放到事件第 N 个槽位"。这组指令和事件格式一起固化进内核的trace_event_call,并体现在tracefs的events/probe_.../do_work/format文件里(可cat查看字段布局)。 - 命中时 handler 按这组
fetch_insn[]机械执行:从%rdi(或 DWARF 给出的栈偏移)读值填入事件 payload,不依赖运行时再查 DWARF。 - DWARF 的作用发生在注册期——它只用来把"参数
n"翻译成"取%rdi"这一条 fetch 规则;一旦注册成功,后续命中零解析成本。 - USDT 走另一条路(不依赖 DWARF):参数位置在编译期就写进 ELF note(
.note.stapsdt的Arguments: -4@%edi -8@-8(%rsp)),attach 时内核直接按 note 生成 fetch_insn,所以 USDT 零开销待命且不需要-g(§1.3.4)。
③ 如何恢复用户态执行(单步补执行 + 现场还原)
原指令被
int3覆盖前,内核已把它暂存在 uprobe 副本里。恢复分两步:- 单步补执行:内核把原指令放回该地址,给该线程的
RFLAGS置上TF(Trap Flag,单步陷阱标志),让 CPU 只跑这一条原指令;指令跑完立即再触发一次debug异常。 - 清标志 + 还原现场:
do_debughandler 收到单步完成信号,清掉TF、从内核栈恢复之前保存的寄存器现场,用iret返回用户态——此时进程就像"那条指令本来就正常执行过"一样继续往下走。
- 单步补执行:内核把原指令放回该地址,给该线程的
对用户进程完全透明:进程看不到
int3、看不到中间的单步,只觉得"那条指令执行得稍微慢了几个纳秒"。uretprobe 额外一步:返回探针还要在注册时把栈上的返回地址替换成"跳板页"地址(§1.3.3),所以恢复时除单步外还需把真正的返回地址还原、跳回调用者。
一句话总结:
int3命中后内核"按注册期建好的 inode:offset 红黑树 O(log n) 定位 → 按固化好的 fetch_insn[] 取参 → 单步补执行原指令并 iret 恢复现场"三步走,全程对用户进程透明,这也是 uprobe 纳秒级开销、远快于 printf 的根本原因。
1.3.3 uretprobe 的跳板机制(返回探针怎么工作)
函数返回探针不能简单地"在返回地址插 int3"——返回地址在栈上、会被覆盖。Linux 的 uretprobe 用**跳板(trampoline)**方案:
- 注册时,内核在返回地址处把栈上的返回地址替换为"uprobe 跳板页"地址。
- 函数
ret时跳到跳板页,触发一次 uretprobe 事件(此时可抓返回值%rax)。 - 跳板再把真正的返回地址写回,跳回原调用者。
bash
正常: func → ret → caller
插桩: func → ret → [trampoline] → 触发 uretprobe → 跳回 caller因此 uretprobe 要求函数有明确的返回点,且返回地址在栈上未被破坏。
noinline保证do_work有独立返回点,是%return可用的前提(实验报告 §2.4)。
1.3.4 USDT 为什么是"零开销待命"
USDT 预埋点 DTRACE_PROBE2(...) 在编译后变成:
- 一条
nop指令(占位,CPU 跳过,零可见开销); - 一段 ELF note(
.note.stapsdt),记录"探针名 + 参数在哪些寄存器/栈偏移"。
未 attach 时程序照常跑,那行 nop 就是个空操作。一旦 perf probe --add attach,内核把该 nop 改写为 int3,后续处理与 uprobe 完全一致——但参数位置早已由编译期 ELF note 固定,不依赖运行时 DWARF。这就是 USDT 不需要 -g 却能抓参数的根本原因。

正文论述:USDT(User Statically Defined Tracing,用户态静态定义跟踪点)预埋点 DTRACE_PROBE2(...)(DTRACE 即 Dynamic Tracing 的宏前缀,源自 DTrace 工具约定)编译后变成两部分:
- 一条
nop指令(x86 单字节空操作,CPU 跳过,零可见开销) - 一段 ELF note(
.note.stapsdt,ELF 文件里的附加说明段,记录"探针名 + 参数在哪些寄存器/栈偏移")
运行时两种状态:
未 attach(待命):程序照常跑,那行
nop就是空操作,开销为零已 attach:
perf probe --add触发后,内核把该nop改写为int3,后续处理与 uprobe 完全一致- 但参数位置早已由编译期 ELF note 固定,不依赖运行时 DWARF
关键点:这就是 USDT 不需要
-g却能抓参数的根本原因(参数布局在编译期固化进 note,而非靠运行时调试信息)一句话总结:USDT = 编译期埋 nop + 记参数位置,运行时按需改 int3,参数布局静态固化不靠 DWARF。
USDT 的"零开销待命"是否成立,取决于 perf/内核版本:本实验环境(kernel 3.10 / perf 3.10)实测发现(详见实验报告 §5.3)——
perf probe --add 'usdt_demo:work_enter'报non-digit char in line number(老内核把:误当行号);改用 Location 地址挂则perf stat报<not supported>(虚拟地址当文件偏移,换算失败)。即:3.10 内核的 perf 无法实际挂载 USDT 点,但readelf -n能看到预埋的stapsdtnote(证明预埋本身成功)。升级内核(≥4.x)/ 改用 SystemTap / bpftrace 才能挂载。新版本内核上 USDT 仍是"零开销待命、不依赖-g"的理想静态点。
参考
- 配套实验报告:README.md
- perf 工具主文档:tools/code/perf.md
- perf 12 场景架构:concepts/tools/perf-demos-architecture.md
- perf 高级用法(含 kprobe):concepts/tools/perf-advanced.md
- 符号表深度剖析(
.symtab/ DWARF 与探针的关系):concepts/elf/symbol-table.md man perf-probe/man ftrace/man stapprobes