0%

Linux 动态调试之 SystemTap(原理篇)

Solaris 系统的DTrace是动态跟踪,动态调试技术的鼻祖,很多系统都有dtrace,比如Windows,macOS等。它是常驻在内核中的,通过用户执行的dtrace命令,把由D语言编写的脚本,提交到内核中的运行时执行。但是DTrace本身无法在linux中运行。于是大家开始尝试将DTrace移植到Linux,或者说让Linux也拥有像dtrace这样强大的动态调试工具,其中最著名的就是RedHat的SystemTap。

另外,在Linux 4.9-rc1之后,出现了另一个强有力的工具BPF(The Berkeley Packet Filter),以及扩展后的eBPF,从某种程度上来说,更接近DTrace的动态追踪机制。eBPF已经被4.x版本及之后的内核集成了,它是一个持续在内核态运行的,解释执行字节码的虚拟机。性能上,编译成ebpf字节码执行,比编译内核模块要快得多。(是的,SystemTap就是编译的内核模块)

SystemTap运行流程:

如上图所示,我们使用SystemTap时,虽然编写的是.stp文件,使用的是stp自己的语言(类似于dtrace脚本语言和C语言),但systemtap会在编译过程中将这些代码翻译成C代码,再将C代码编译为内核模块.ko,最后加载内核模块。

前4个阶段和后面3个阶段可以不在同一台机器上,即在开发机器上编译内核模块,在目标机器上运行(但内核版本要一致)。

stap 使用命令选项-p 序号,可以使systemtap在特定阶段停下来,比如stap -p3, 会在生成C代码之后停下来。

Parse(词法语法分析)

这部分跟普通的程序编译过程是一样的,parse阶段主要是解析stp代码,生成AST树(抽象语法树,Abstract Syntax Tree),检查语法错误。

Elaborate(语义分析)

elaborate阶段对生成的这颗AST树进行修剪,其中又包括很多小阶段。每个小阶段都会遍历整棵树,处理每个节点。主要包括stp语言宏的展开,查找stp函数的定义,匹配stp变量类型,debuginfo相关操作,符号解析,优化器等等(可以通过选项-u跳过优化阶段)。

这一阶段会用到systemtap的tapset(模块集合),路径是/usr/local/share/systemtap/tapset,所以这个路径里的函数可以直接拿来用。tapset包含了很多systemtap预定义的事件或函数,比如通用的查询表,受限内存管理,I/O操作等等。虽然也是stp文件,但只能作为库使用,不能直接运行。这一步会查找debuginfo中对应函数/变量/路径等的偏移地址。

Translate(生成C代码)

我们使用以下代码,来简单看一下stp编译后的C代码是怎样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
global g_s
probe begin {
a=1
printf("Hi %d", a)
}

probe oneshot {
printf("I'm in")
}

probe timer.ms(100) {
printf("timer")
exit()
}

probe end {
g_s = "now"
printf("end %s", g_s)
}

probe process("/lib64/libc.so.6").function("gethostbyname").return {
printf("uprobe_gethostbyname")
}

使用命令stap -v test.stp -p3 > test.c,可以查看编译后生成的C文件。

变量

代码部分,首先我们看到的是一个contest的结构体:

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
struct context {
#include "common_probe_context.h"
union {
struct probe_8187_locals {
int64_t l_a;
union { /* block_statement: ./test.stp:2 */
struct { /* source: ./test.stp:4 */
int64_t __tmp2;
};
};
} probe_8187;
struct probe_8189_locals {
union { /* block_statement: ./test.stp:7 */
};
} probe_8189;
struct probe_8190_locals {
union { /* block_statement: ./test.stp:11 */
};
} probe_8190;
struct probe_8191_locals {
union { /* block_statement: ./test.stp:16 */
struct { /* source: ./test.stp:18 */
string_t __tmp2;
};
};
} probe_8191;
struct probe_8193_locals {
} probe_8193;
} probe_locals;
union {
struct function___global_exit__overload_0_locals {
/* no return value */
} function___global_exit__overload_0;
} locals [MAXNESTING+1];
#if MAXNESTING < 0
#error "MAXNESTING must be positive"
#endif
#ifndef STP_LEGACY_PRINT
union {
struct stp_printf_1_locals {
int64_t arg0;
} stp_printf_1;
struct stp_printf_2_locals {
const char* arg0;
} stp_printf_2;
} printf_locals;
#endif // STP_LEGACY_PRINT
};

context这个结构体是在执行各个probe前后被复用的,因为一个context在某个时刻只能处于一个porbe中,所以采用了union来减少内存以及内核的栈空间,将内存设置为probe所用到的最大的变量数。struct probe_xxx_locals用来存储每个probe对应的本地变量。每个本地变量都被加了前缀l_,比如示例中的int64_t l_a

long类型的变量会被编译为int64_t,即使是在32位系统上,其实也是64位整数。string类型的变量会被编译为string_t,这是char[MAXSTRINGLEN]的别名,也就是string的大小其实是固定的,当实际数据大于MAXSTRINGLEN时,会被截断,同样,跟字符型数组一样,当数据中存在\0时,也会被截断。

跟本地变量不同,全局变量有自己独立的结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct stp_globals {
string_t s___global_g_s;
rwlock_t s___global_g_s_lock;
#ifdef STP_TIMING
atomic_t s___global_g_s_lock_skip_count;
atomic_t s___global_g_s_lock_contention_count;
#endif

};
//这里是一个stub
static struct stp_globals stp_global = {

};

全局变量会被加上s_global_xx前缀,而且定义了对应lock。全局变量在使用的时候,都会加上对应的锁。

函数

对于probe begin/oneshot/end部分的结构都差不多,以下是probe begin处的代码:

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
static void probe_8187 (struct context * __restrict__ c) {
__label__ deref_fault;
__label__ out;
struct probe_8187_locals * __restrict__ l = & c->probe_locals.probe_8187;
(void) l;
l->l_a = 0;
if (c->actionremaining < 2) { c->last_error = "MAXACTION exceeded"; goto out; }
{
(void)
({
l->l_a = ((int64_t)1LL);
((int64_t)1LL);
});

(void)
({
l->__tmp2 = l->l_a;
#ifndef STP_LEGACY_PRINT
c->printf_locals.stp_printf_1.arg0 = l->__tmp2;
stp_printf_1 (c);
#else // STP_LEGACY_PRINT
_stp_printf ("Hi %lld", l->__tmp2);
#endif // STP_LEGACY_PRINT
if (unlikely(c->last_error || c->aborted)) goto out;
((int64_t)0LL);
});

}
deref_fault: __attribute__((unused));
out:
_stp_print_flush();
}

printf语句被转换成对应的内置函数调用,且代码块前后都加上了括号和花括号,防止代码污染。MAXACTION exceeded部分是为了限制probe的执行时间的,每个probe都有这段代码,避免出现超时导致的内核失去响应的情况。probe oneshot会在_stp_sprint代码块之后多调用一个function___global_exit__overload_0函数,这个函数内部调用了_stp_exit函数。

在systemtap运行时的begin和end阶段,也就是systemtap_module_initsystemtap_module_exit两个函数调用时,会调用enter_be_probe函数,这个函数会通过struct stap_be_probe中的实例,在(*stp->probe->ph)(c)这一行调用对应probe的handler。probe begin/oneshot/end都会注册在struct stap_be_probe结构中。

所以当systemtap启动时,会调用probe begin/oneshot,区别是oneshot会调用_stp_exit函数,表明将要进入end阶段了,然后在end阶段会调用probe end。

相比于前面三个probe对应的struct stap_be_probe结构,probe timer对应的是struct stap_hrtimer_probe,然后在systemtap_module_init函数中会注册timer。

对于process probe,对应的类型是struct stapiu_consumer

1
2
3
static struct stapiu_consumer stap_inode_uprobe_consumers[] = {
{ .return_p=1, .target=&stap_inode_uprobe_targets[0], .offset=(loff_t)0x119230ULL, .probe=(&stap_probes[4]), },
};

这里有一个.offset=(loff_t)0x119230ULL的值,就是gethostbyname函数在process/lib64/libc.so.6(实际指向/usr/lib64/libc-2.17.so)中的偏移。可以通过命令readelf -f /usr/lib64/libc-2.17.so | grep gethostbyname 查看。这也是编译期间为什么需要debuginfo,这样才可以找到函数对应的偏移地址,然后加上库的基地址,就是实际运行地址了。

systemtap会检查已存在以及新创建的进程,如果有匹配某个probe,则会通过内核API注册对应的probe,然后内核触发回调时,就会执行这个函数。所以,每个匹配的进程都会执行probe。

如果需要指定进程的话,可以使用-x PID(会赋值给 target() ),或者-c CMD(新建子进程作为*target()*):

1
2
3
4
_target = target()
if (pid() != _target) {
next
}

Build(编译成内核模块.ko)

前面三个阶段已经生成了c文件,但其中有关于tapset的函数只是做了简单的链接(函数路径),比如:

1
c->last_stmt = "identifier 'exit' at /usr/local/share/systemstap/tapset/logging.stp:63:10";

在这个阶段,会将这部分一起编译到最终的内核模块里。

Load/Run(加载运行)

这部分是通过独立的二进制文件staprun实现的,这可以将编译跟运行环境分开。在staprun/staprun_funcs.c文件中,insert_module函数通过调用系统APIinit_module,来加载内核模块。

内核模块探测点的处理函数被封装成接口函数,底层调用Linux提供的kprobe接口函数来注册探测点。静态指针使用tracepoints,动态指针使用kprobe,用户态使用uprobes。

Kprobes,kernel probes,是linux内核的一个重要特性,也是一个轻量级的内核调试工具。perf、systemtap以及4.x之后出现的eBPF都是基于kprobes之上的。它主要有三类,kprobe、kreprobe、jprobe(最新的内核中已经被去掉了)。kprobe是最常使用的,可以在任何指令位置处插入探针。kretprobe可以查看函数执行结束后的参数变化,jprobe在函数进入时使用,类似于kprobe的pre_hanlder。

kretprobe用trampoline地址替换堆栈上的返回地址。因此当对应函数返回时,会先执行kretprobe设置的trampoline,然后再从trampoline返回到原来的返回地址。

kprobe的运行原理跟GDB很类似,也是基于中断处理的。系统初始化的时候会注册中断处理,在x86系统上分别是int 1int 3两个trap,对应的函数是do_debug以及do_int3。 在arm64中是BRK(异常处理)指令。

kprobe的运行流程:

  1. 注册kprobe。一个kprobe结构体对应一个探针,包含有插入点地址,以及保存该插入点的原始指令original_opcode
  2. 替换原有指令。将插入点的指令替换为0xcc(int 3, ARM替换为BRK)。这样当CPU执行到该位置时,就会触发int 3或异常处理。
  3. 进入异常态执行pre_handler。找到该位置信息对应的kprobe,执行pre_handler。并将相应寄存器设置成单步调试single-step,将下一条指令设置为original_opcode,然后从异常态返回。
  4. 再次陷入异常态执行post_handler。因为在上一步将下一条即将执行的指令设置为了original_opcode,且设置了single-step。因此从int 3返回后会立即执行原有指令,紧接着触发int 1(single-step),再次进入异常态。这时首先清除single-step相关的寄存器tag,然后执行kprobe的post_handler,最后从异常态安全返回。

以上就是kprobe执行过程,就是将原有的一条指令扩展成为执行pre_handler->执行原有指令->执行post_handler

systemtap就是调用的kprobe接口函数来注册stp脚本中定义的探测点。当内核运行到探测点时,就会调用对应的处理函数,其中的输出语句将会通过调用relayfs的接口函数输出数据,也就是下一步的通讯过程。

Store Output(用户态/内核态交互,通讯)

staprun加载内核模块之后,会exec一个stapio进程(/usr/libexec/systemtap/),跟加载后的内核模块通讯。内核模块会通过debugfs_create_dirdebugfs_create_file两个api创建一个debugfs下的“伪文件”。用户态程序可以通过读写这些“伪文件”来调用内核模块的制定函数。内核模块会创建/sys/kernel/debug/systemtap/$module_name/trace%d这样的文件用于数据流的传输。

stapio(用户态程序)跟内核模块交互,其实就是对文件进行读写。在int stp_main_loop(void)函数中会对不同的控制指令进行处理,_stp_ctl_wire_cmd函数中有对应的控制指令在内核态部分的处理逻辑。

Stop/Unload(卸载)

当满足以下两个条件之一时,straprun就会卸载内核模块:

  • stap脚本执行到exit()函数
  • 向stapio发送信号(比如ctrl+c)

内核模块将要退出时(遇到exit()函数),会将STP_REQUEST_EXIT控制消息写入伪文件.cmd的buffer中。stapio读取控制流时,看到这个消息,就会相应对应的STP_EXIT。然后内核看到STP_EXIT消息,就会做相应的清理工作,之后再返回STP_EXIT给stapio。stapio接收到这个消息之后就会卸载内核模块。

如果是stapio收到退出信号(ctrl+c),就会向内核模块发送STP_EXIT消息,然后再卸载内核模块。

卸载内核模块是stapio通过创建执行卸载操作的staprun进程实行的,所以stapio实际还是只负责通讯。


最后我们通过下面这张图来回顾一下systemtap的工作流程:

其中DWARF是指Linux的调试符号表格式。

有关systemtap的具体安装、使用、如何编写stp脚本等问题,请见我的下一篇文章Linux 动态调试之 SystemTap(使用篇)。

Reference

[1] systemtap 探秘(一)- 基本介绍 https://segmentfault.com/a/1190000019566831

[2] systemtap原理及使用 https://www.bbsmax.com/A/A7zgmEOP54/

[3] SystemTap Kprobe原理 https://lzz5235.github.io/2013/12/18/systemtap-kprobe.html

[4] 3.1 Systemp 简介 https://hotttao.github.io/2020/01/08/linux_perf/11_stap%E7%AE%80%E4%BB%8B/

[5] kprobe原理解析(二) https://www.cnblogs.com/honpey/p/4575902.html

----------------------END END----------------------