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 | global g_s |
使用命令stap -v test.stp -p3 > test.c
,可以查看编译后生成的C文件。
变量
代码部分,首先我们看到的是一个contest的结构体:
1 | struct context { |
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 | struct stp_globals { |
全局变量会被加上s_global_xx
前缀,而且定义了对应lock。全局变量在使用的时候,都会加上对应的锁。
函数
对于probe begin/oneshot/end部分的结构都差不多,以下是probe begin处的代码:
1 | static void probe_8187 (struct context * __restrict__ c) { |
printf
语句被转换成对应的内置函数调用,且代码块前后都加上了括号和花括号,防止代码污染。MAXACTION exceeded
部分是为了限制probe的执行时间的,每个probe都有这段代码,避免出现超时导致的内核失去响应的情况。probe oneshot
会在_stp_sprint
代码块之后多调用一个function___global_exit__overload_0
函数,这个函数内部调用了_stp_exit
函数。
在systemtap运行时的begin和end阶段,也就是systemtap_module_init
和systemtap_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 | static struct stapiu_consumer stap_inode_uprobe_consumers[] = { |
这里有一个.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 | _target = target() |
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 1
和int 3
两个trap,对应的函数是do_debug
以及do_int3
。 在arm64中是BRK(异常处理)指令。
kprobe的运行流程:
- 注册kprobe。一个kprobe结构体对应一个探针,包含有插入点地址,以及保存该插入点的原始指令original_opcode
- 替换原有指令。将插入点的指令替换为0xcc(int 3, ARM替换为BRK)。这样当CPU执行到该位置时,就会触发int 3或异常处理。
- 进入异常态执行pre_handler。找到该位置信息对应的kprobe,执行pre_handler。并将相应寄存器设置成单步调试single-step,将下一条指令设置为original_opcode,然后从异常态返回。
- 再次陷入异常态执行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_dir
和debugfs_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