This page looks best with JavaScript enabled

译|2008|User-Space Probes (Uprobes)

 ·   ·  ☕ 14 min read

译者序

这篇文章翻译自 SystemTap 项目中 uprobes.txt 文件,此文件描述了 Uprobes 的概念、工作原理、限制等内容。用途跟 Kprobes 一样,用来追踪运行在用户态的应用程序的。看提交历史,这个功能在 2012 年才提交到 Linux 内核中。

文章最后提供的例子,可以修改来玩一玩。

注:因为水平有限,文中难免存在遗漏或者错误的地方。如有疑问,建议直接阅读原文。


概念:Uprobes、Return 探针

Uprobes 能够动态的介入应用程序的任意函数,采集调试和性能信息,且不引起混乱。你可以在任意地址上,指定断点命中时调用的内核函数。

目前,用户态探针有两种类型: uprobes 和 uretprobes(也叫 return 探针)。可以在应用程序的虚拟地址空间的任意指令上插入 uprobe 。 当用户函数返回的时候触发 return 探针。后续内容会详细的讨论这两类探针的细节。

register_uprobe() 注册函数设定要探测的进程,探针插入的位置,以及命中探针时调用什么回调函数。

通常,基于 Uprobes 的探测工具是被打包成内核模块。最简单的内核模块,初始化函数安装(“注册”)一个或多个探针,而后在 exit 函数中注销。其实还可以在响应其他事件中注册或注销探针。例如:

  • 探针回调函数自身可以注册或注销探针
  • 可以创建 Utrace 回调函数来注册或注销探针,探测特定的进程什么时候派生子进程、克隆线程、执行、进入系统调用、接收信号、退出等等。参考 Documentation/utrace.txt

Uprobe 是怎么工作的?

当一个 uprobe 被注册后,Uprobes 会创建一个被探测指令的副本,停止被探测的应用程序,用断点指令替换被探测指令的首字节(在 i386 和 x86_64 上是 int3),之后让应用程序继续运行。(在插入断点的时候,Uprobes 使用与 ptrace 使用的相同的 copy-on-write 机制,这样断点也只影响那个进程,不会影响其他运行相同程序的进程。甚至是被探测的指令在共享库中也一样。)

当 CPU 命中断点指令的时候,发生了一个 trap,CPU 用户模式的寄存器都被保存起来,产生了一个 SIGTRAP 信号。Uprobes 拦截 SIGTRAP 信号,找到关联的 uprobe。然后,用 uprobe 结构体和先前保存的寄存器地址调用与 uprobe 关联的回调函数。这个回调函数可能会阻塞,但要记住回调函数执行期间,被探测的线程一直是停止的。

接下来,Uprobes 会单步执行被探测指令的副本,之后会恢复被探测的程序,让它在探测点之后的指令处继续执行。(实际上单步执行原始指令会更简单,但之后,Uprobes 必须移除断点指令。这在多线程应用程序中会引起问题。比如,当另一个线程执行过探测点的时会打开一个时间窗口。)

被单步执行的指令副本存储在每个进程的"单步跳出(SSOL)区域"中,它是由 Uprobes 在每个被探测进程的地址空间中创建的很小的 VM 区域。

Utrace 的作用

当先前被取消探测的进程上又注册一个探针的时候,Uprobes 用 Utrace 为进程中每个线程建立了一个追踪"引擎"。Uprobes 使用 Utrace “静默"机制,在插入或移除断点之前停止所有线程。Utrace 在被探测进程的生命周期中(fork, clone, exec, exit),通知 Uprobes 断点和单步执行陷阱以及其他感兴趣的事件。

Return 探针怎么工作的?

当你调用 register_uretprobe() 函数的时候,Uprobes 在函数的入口处创建一个 uprobe 。当调用被探测函数的时候命中这个探针,Uprobes 会保存 return 地址的一个副本,然后用"蹦床"的地址替换 return 地址 —— 一段包含一个断点指令代码。

当被探测的函数执行它的 return 指令时,控制转移到蹦床,命中断点。Uprobes 的蹦床回调函数调用与 uretprobe 关联的回调函数,然后把已保存的指令指针设置为已保存的 return 地址,再然后就从 trap 返回后的地方恢复执行。

蹦床存储在 SSOL 区域中。

多线程应用

Uprobes 支持多线程应用的探测。Uprobes 在被探测的应用中没有线程数量的限制。
在单个进程中的所有线程,使用相同的文本页,所以进程中的每个探针,会影响所有线程;当然,每个线程命中探测点(以及运行回调函数)是相对独立的。多个线程可能同时运行相同的回调函数。如果你想要一个特定的线程或是一组线程运行一个特定的回调函数,那回调函数应该检查 currentcurrent->pid 来确认哪个线程命中了探测点。

当进程克隆一个新的线程时,该线程自动的共享所有为进程创建的探针。

要记住,注册或注销探针的时候,要等到 Utrace 停止了进程中的所有线程后,才会插入或删除断点。注册/注销函数在断点已经被插入或移除之后才返回(看下一章节)。

探针回调函数內注册探针

uprobeuretprobe 回调函数可以调用 Uprobes API 中的任何函数([un]register_uprobe(), [un]register_uretprobe())。探针回调函数甚至可以注销它自己。不过,在回调函数中调用的时候,实际的注册/注销操作不会立刻执行。反而,它们会被放入队列,在该探测点已经运行所有回调函数之后执行。在回调函数中,注册/注销函数会返回 -EINPROGRESS。如果在 uprobe 对象中设置了 registration_callback 字段,会在注册/注销操作完成的时候调用。

已支持的 CPU 架构

uprobesuretprobes 被实现,在下面的架构上:

  • i386
  • x86_64 (AMD-64, EM64T)
  • ppc64
  • s390x

配置 Uprobes

// TODO: 补丁实际上把 Uprobes 配置放在 “Instrumentation Support” 下面与 Kprobes 一起。需要决定哪个更好。

在使用 make menuconfig/xconfig/oldconfig 配置内核的时候,确保 CONFIG_UPROBES 设置为 “y”。在 “Process debugging support” 下面,选择 “Infrastructure for tracing and debugging user processes” 开启 Utrace,然后选择 “Uprobes”。

确保 “Loadable module support”(CONFIG_MODULES)和 “Module unloading” (CONFIG_MODULE_UNLOAD) 都被设置为 “y”,这样就可以加载或卸载基于 Uprobes 的测试工具模块。

API 参考

Uprobes API 为每种类型的探针分别提供了"注册"和"注销"函数。这有一份这些函数以及相关的探针处理函数的简短说明。例子见文档后半部分。

register_uprobe

1
2
#include <linux/uprobes.h>
int register_uprobe(struct uprobe *u);

在 pid 是 u->pid 的进程中,虚拟地址 u->vaddr处设置断点。在命中断点的时候,Uprobes 调用 u->handler

register_uprobe() 调用成功返回 0,如果是在 uprobe 或 uretprobe 回调函数中(因此延迟了)调用返回 -EINPROGRESS,否则返回负的 errno 。

“延迟注册回调”,解释了在完成延迟注册后如何通知。

用户的回调函数(u->handler):

1
2
3
#include <linux/uprobes.h>
#include <linux/ptrace.h>
void handler(struct uprobe *u, struct pt_regs *regs);

在断点命中的时候调用,传入指向断点关联的 uprobe 指针 u 和含有保存的寄存器的结构体指针 regs

register_uretprobe

1
2
#include <linux/uprobes.h>
int register_uretprobe(struct uretprobe *rp);

在 pid 是 rp->u.pid 的进程中,函数地址 rp->u.vaddr 处创建 return 探针。当该函数返回时,Uprobes 调用 rp->handler

register_uretprobe() 成功返回 0 ,如果是在 uprobe 或 uretprobe 回调函数中(因此被延迟)调用返回 -EINPROGRESS,否则返回负的 errno。

“延迟注册回调”,解释了在完成延迟注册后如何通知。

用户的 return 探针回调函数(rp->handler):

1
2
3
#include <linux/uprobes.h>
#include <linux/ptrace.h>
void uretprobe_handler(struct uretprobe_instance *ri, struct pt_regs *regs);

regs表示用户的 uprobe 处理函数。ri 指向 uretprobe_instance 对象,其关联了当前正在返回的函数实例。可以关注对象中的两个字段:

  • ret_addr:return 地址
  • rp:指向对应的 uretprobe 对象

ptrace.h 文件中,regs_return_value(regs) 宏提供了一种简单的抽象,从架构的 ABI 定义的相关寄存器中获得返回值。

unregister_*probe

1
2
3
#include <linux/uprobes.h>
void unregister_uprobe(struct uprobe *u);
void unregister_uretprobe(struct uretprobe *rp);

移除探针。注销函数可以在探针注册之后的任何时间调用,还能在 uprobe 或 uretprobe 回调函数中调用。

延迟注册回调

1
2
3
#include <linux/uprobes.h>
void registration_callback(struct uprobe *u, int reg,
	enum uprobe_type type, int result);

像前面提到的函数,可以在 uprobe 或 uretprobe 回调函数内部调用。当发生这种情况的时候,注销/注册操作会被延迟,直到与探测点关联的所有回调函数都已运行之后执行。在完成注销/注册操作之后,Uprobes 会检查 uprobe 关联的 registration_callback 成员变量:uprobe 对应 u->registration_callback 或者 uretprobe 对应 rp->u.registration_callback。如果存在 Uprobes 会调用 registration_callback 回调函数,并传入下面的值:

  • u = uprobe 对象的地址。(uretprobe 对象,可以使用 container_of(u, struct uretprobe, u) 获得 uretprobe 对象的地址。)
  • reg = 1 for register_u[ret]probe() or 0 for unregister_u[ret]probe()
  • type = UPTY_UPROBE or UPTY_URETPROBE
  • result = 如果不是延迟操作,作为 register_u[ret]probe() 的返回值。对于 unregister_u[ret]probe() 总是返回 0 。

注意:Uprobes 只在延迟注销/注册的情况下调用 registration_callback

Uprobes 功能与限制

希望用户给 uprobe 结构体的成员赋值:pid, vaddr, handler, (如果需要)registration_callback。其他保留的成员给 Uprobes 使用。如果做了下面这些事情,Uprobes 可能会产生不期望的结果:

  • 把保留的 uprobe 结构体成员设置为非 0 值
  • 在注册期间改变 uprobe 或 uretprobe 对象的内容
  • 注册已注册的 uprobe 或 uretprobe

Uprobes 允许在特定的地址上注册任意数量的探针(uprobes、uretprobe 都可以)。探针回调函数是按照它们注册的顺序调用的。

任意数量的内核模块可以同时探测一个特定的进程,而特定的模块也可以同时探测任意数量的进程。

threads).
在进程中的所有线程之间的探针是共享的(包括新创建的线程)。

如果被探测进程退出或执行,Uprobes 会自动注销所有与之关联的 uprobes 和 uretprobes 。之后再注销这些探针将会视为无效。

另外,如果从进程的虚拟内存映射删除探测的内存区域的话(例如:通过 dlclose(3)munmap(2)),目前需要先主动注销探针。

没有方法在 fork 进程时继承探针,Uprobes 会在新创建的子进程中移除所有探测点。关于这点更多的信息见"与 Utrace 交互”。

至少在某些架构上,Uprobes 不会尝试校验,指定的探针地址是否是一条指令的开始。如果你弄错了,可能造成会混乱。

为避免干扰交互式调试工具,Uprobes 会拒绝在已存在断点指令的地方插入探测点,除非是 Uprobes 放那里的。有一些架构可能会拒绝在其他类型的指令上插入探针。

如果在可内联的函数中插入一个探针,Uprobes 并不会尝试给该函数的所有内联实例插入探针。如果没有命中期望的探针,要记住,gcc 可能会自动内联一个函数。

探针回调函数可以修改目标函数的环境 ——例如:修改数据结构,或修改 pt_regs 结构体的内容(从断点返回之后保存的寄存器)。因此,Uprobes 可以用来,安装补丁或测试时注入错误。当然 Uprobes 没有办法区分错误是故意注入的还是意外发生的。所以不要搞事情。

因为 return 探针是通过使用蹦床的地址替换 return 地址实现的,那么栈回溯和调用 __builtin_return_address() 产生的是蹦床的地址,而不是uretprobed 函数的实际 return 地址。

如果函数调用的次数与 return 次数不匹配(例如:如果函数调用 longjmp() 退出),在这种函数上注册 return 探针,可能会产生不期望的结果。

当在探测点注册第一个探针或者注销最后一个探针的时候,Uprobes 要求 Utrace 去"暂停"目标进程,这样 Uprobes 就可以插入或者移除断点指令。如果进程还没有停止,Utrace 会停止它。如果进程正在运行一个可中断的系统调用,可能会让系统调用提早完成或失败而产生 EINTR 信号。(ptrace 系统调用的 PTRACE_ATTACH 请求有同样的问题。)

当 Uprobes 在先前的未探测的页面上建立探测点的时候,Linux 会通过 copy-on-write 机制创建了这个页面的新副本。在移除探测点的时候,Uprobes 并不会尝试合并同一个页面的副本。如果探测在大量长时间运行的进程中探测大量的页面,会影响内存可用性。

与 Kprobes 交互

Uprobes 打算与 Kprobes 进行有效的相互操作(见 Documentation/kprobes.txt 文件)。例如,检测模块可以同时调用 Kprobes API 和 Uprobes API。

uprobe 或 uretprobe 回调函数可以注册或注销 kprobes、jprobes、kretprobes,以及 uprobes 和 uretprobes。另外,kprobe、jprobe、kretprobe 回调函数一定不能休眠,不然会无法注册或注销这些探针。(欢迎移除这种限制的想法)

注意,命中 u[ret]probe 的开销是命中 k[ret]probe 的几倍。

与 Utrace 交互

在"Utrace 的作用"章节中提到,Uprobes 是 Utrace 的客户端。Uprobes 为每个被探测的线程建立了一个 Utrace 引擎,及为 clone/fork, exec, exit, “core-dump” 信号(其包括断点陷阱)这类事件创建寄存器回调函数。Uprobes 是在进程首次被探测的时候创建引擎,或者在创建线程时通知,反正先到先处理。

检测模块可以同时使用 Utrace 和 Uprobes APIs(以及 Kprobes)。这么做的时候,请记住下面的事情:

  • 对于特定的事件,Utrace 回调函数是按引擎的创建顺序调用的。目前 Utrace 没有机制来改变顺序。
  • 在 Uprobes 得知目标进程创建了子进程后,会在子进程中移除断点。
  • 在 Uprobes 得知目标进程已经执行或退出后,将会清理这个进程中的数据结构(先允许终止未完成的注销、注册操作)。
  • 当目标线程命中断点或被探测指令单步执行完成的时候,通知已设置 UTRACE_EVENT(SIGNAL_CORE) 标记的引擎。Uprobes 信号回调函数防止(通过 UTRACE_ACTION_HIDE)这个事件,报告给在列表后面的引擎。但,如果你的引擎是在 Uprobes 的引擎之前创建的,还是会收到这个事件。

如果你想在新的子进程中创建探针,可以用以下办法:

  • 用 Utrace 注册一个 report_clone 回调函数。在这个回调函数中,以 CLONE_THREAD 标记区分创建新线程还是进程。
  • 在你的 report_clone 回调函数中,调用 utrace_attach() 附着到子进程,以及设置引擎的 UTRACE_ACTION_QUIESCE 标记。子进程将会停顿在准备要探测的位置。
  • report_quiesce 回调函数中,注册所需要的探针。(注意,不能对父子进程使用同一个探测对象。如果想要复制探测点,必须创建一个新的 u[ret]probe 对象集合。)

Here are sample overhead figures (in usec) for different architectures.

探针开销

// TODO: 已经过时。
// TODO: 根据其他架构的测试整理。
在 2007 年常见的 CPU 上,处理 uprobe 命中大约需要3微秒的时间。基准测试反复命中相同的探测点,每次触发一个简单的处理程序,每秒报告 30w-35w 次命中,具体取决于架构。通常,return 探针命中比 uprobe 命中多花 50% 的时间。当在某个函数上设置了 return 探针,会在该函数的入口处添加 uprobe ,本质上不会增加开销。

下面是些不同架构的样本(纳秒)。

u = uprobe; r = return probe; ur = uprobe + return probe

i386: Intel Pentium M, 1495 MHz, 2957.31 bogomips
u = 2.9 usec; r = 4.7 usec; ur = 4.7 usec

x86_64: AMD Opteron 246, 1994 MHz, 3971.48 bogomips
// TODO

ppc64: POWER5 (gr), 1656 MHz (SMT disabled, 1 virtual CPU per physical CPU)
// TODO

TODO

  • Systemtap:基于探针的检测工具,提供简化的编程接口。SystemTap 已经支持内核探针。还可以利用 Uprobes 。
  • 支持其他 CPU 架构

Uprobes 团队

下面的成员对 Uprobes 作出了主要的贡献:

Uprobes 例子

这儿有份内核模块样本,展示 Uprobes 的用法,统计在特定地址的指令执行了多少次,以及可选的(除非 verbose=0)输出每次执行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/* uprobe_example.c */
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/uprobes.h>

/*
 * Usage: insmod uprobe_example.ko pid=<pid> vaddr=<address> [verbose=0]
 * where <pid> identifies the probed process and <address> is the virtual
 * address of the probed instruction.
 */

static int pid = 0;
module_param(pid, int, 0);
MODULE_PARM_DESC(pid, "pid");

static int verbose = 1;
module_param(verbose, int, 0);
MODULE_PARM_DESC(verbose, "verbose");

static long vaddr = 0;
module_param(vaddr, long, 0);
MODULE_PARM_DESC(vaddr, "vaddr");

static int nhits;
static struct uprobe usp;

static void uprobe_handler(struct uprobe *u, struct pt_regs *regs)
{
	nhits++;
	if (verbose)
		printk(KERN_INFO "Hit #%d on probepoint at %#lx\n",
			nhits, u->vaddr);
}

int __init init_module(void)
{
	int ret;
	usp.pid = pid;
	usp.vaddr = vaddr;
	usp.handler = uprobe_handler;
	printk(KERN_INFO "Registering uprobe on pid %d, vaddr %#lx\n",
		usp.pid, usp.vaddr);
	ret = register_uprobe(&usp);
	if (ret != 0) {
		printk(KERN_ERR "register_uprobe() failed, returned %d\n", ret);
		return -1;
	}
	return 0;
}

void __exit cleanup_module(void)
{
	printk(KERN_INFO "Unregistering uprobe on pid %d, vaddr %#lx\n",
		usp.pid, usp.vaddr);
	printk(KERN_INFO "Probepoint was hit %d times\n", nhits);
	unregister_uprobe(&usp);
}
MODULE_LICENSE("GPL");

你可以用下面的 Makefile 编译内核模块 uprobe_example.ko

1
2
3
4
5
6
7
8
obj-m := uprobe_example.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
default:
	$(MAKE) -C $(KDIR) SUBDIRS=$(PWD) modules
clean:
	rm -f *.mod.c *.ko *.o .*.cmd
	rm -rf .tmp_versions

例如,如果你想要运行 myprog ,然后监控 myfunc() 的调用情况,你可以这样做:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ make			// Build the uprobe_example module.
...
$ nm -p myprog | awk '$3=="myfunc"'
080484a8 T myfunc
$ ./myprog &
$ ps
  PID TTY          TIME CMD
 4367 pts/3    00:00:00 bash
 8156 pts/3    00:00:00 myprog
 8157 pts/3    00:00:00 ps
$ su -
...
# insmod uprobe_example.ko pid=8156 vaddr=0x080484a8

每次调用 myfunc() 函数,将会在 /var/log/messages 文件中和终端上,看到这种信息:“kernel: Hit #1 on probepoint at 0x80484a8”。要关闭探测,就移除模块:

1
# rmmod uprobe_example

将会在 /var/log/messages 文件中和终端上看见这种信息:“Probepoint was hit 5 times”。

Uretprobes 例子

这是展示 return 探针用法的内核模块样本,输出函数的返回值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
/* uretprobe_example.c */
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/uprobes.h>
#include <linux/ptrace.h>

/*
 * Usage:
 * insmod uretprobe_example.ko pid=<pid> func=<addr> [verbose=0]
 * where <pid> identifies the probed process, and <addr> is the virtual
 * address of the probed function.
 */

static int pid = 0;
module_param(pid, int, 0);
MODULE_PARM_DESC(pid, "pid");

static int verbose = 1;
module_param(verbose, int, 0);
MODULE_PARM_DESC(verbose, "verbose");

static long func = 0;
module_param(func, long, 0);
MODULE_PARM_DESC(func, "func");

static int ncall, nret;
static struct uprobe usp;
static struct uretprobe rp;

static void uprobe_handler(struct uprobe *u, struct pt_regs *regs)
{
	ncall++;
	if (verbose)
		printk(KERN_INFO "Function at %#lx called\n", u->vaddr);
}

static void uretprobe_handler(struct uretprobe_instance *ri,
	struct pt_regs *regs)
{
	nret++;
	if (verbose)
		printk(KERN_INFO "Function at %#lx returns %#lx\n",
			ri->rp->u.vaddr, regs_return_value(regs));
}

int __init init_module(void)
{
	int ret;

	/* Register the entry probe. */
	usp.pid = pid;
	usp.vaddr = func;
	usp.handler = uprobe_handler;
	printk(KERN_INFO "Registering uprobe on pid %d, vaddr %#lx\n",
		usp.pid, usp.vaddr);
	ret = register_uprobe(&usp);
	if (ret != 0) {
		printk(KERN_ERR "register_uprobe() failed, returned %d\n", ret);
		return -1;
	}

	/* Register the return probe. */
	rp.u.pid = pid;
	rp.u.vaddr = func;
	rp.handler = uretprobe_handler;
	printk(KERN_INFO "Registering return probe on pid %d, vaddr %#lx\n",
		rp.u.pid, rp.u.vaddr);
	ret = register_uretprobe(&rp);
	if (ret != 0) {
		printk(KERN_ERR "register_uretprobe() failed, returned %d\n",
			ret);
		unregister_uprobe(&usp);
		return -1;
	}
	return 0;
}

void __exit cleanup_module(void)
{
	printk(KERN_INFO "Unregistering probes on pid %d, vaddr %#lx\n",
		usp.pid, usp.vaddr);
	printk(KERN_INFO "%d calls, %d returns\n", ncall, nret);
	unregister_uprobe(&usp);
	unregister_uretprobe(&rp);
}
MODULE_LICENSE("GPL");

像在上面的 uprobe 例子那样编译内核模块。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ nm -p myprog | awk ‘$3=="myfunc"’
080484a8 T myfunc
$ ./myprog &
$ ps
  PID TTY          TIME CMD
 4367 pts/3    00:00:00 bash
 9156 pts/3    00:00:00 myprog
 9157 pts/3    00:00:00 ps
$ su -
…
# insmod uretprobe_example.ko pid=9156 func=0x080484a8

/var/log/messages 文件中和终端上,会看到如下信息:

1
2
kernel: Function at 0x80484a8 called
kernel: Function at 0x80484a8 returns 0x3

移除模块关闭探测:

1
# rmmod uretprobe_example

/var/log/messages 文件中和终端上,会看到信息:“73 calls, 73 returns”。