经过前面几篇文章,我们已经走完了 systemtap 运行的前三个流程,只差最后的编译和运行了。
编译
编译阶段没有什么要说的,唯一要说明的是 stap 生成的内核模块编译起来很耗时。一般来说,整个编译阶段会花上十几二十秒。所以在生成火焰图时,我通常会让 stap 空跑一遍,让它把内核模块编译出来,完成编译阶段后 Ctrl+C
中断掉它。等到正式压测时,再跑一遍。第二次跑的时候,由于可以用上第一次编译出来的内核,花在前四个阶段的时间会减少很多。
来说下最后的运行阶段。
加载
stap 的最后一个阶段,其实是通过运行一个独立的 staprun 二进制文件实现的。这么设计的目的在于把对内核模块的准备和运行分离开来。我们之前执行的 stap
二进制文件可以只负责生成内核模块,然后在目标机器上通过 staprun
运行。这样做的一个好处在于目标机器上可以不用装许多依赖(比如 kernel debuginfo)。另外,在有些公司,服务器只能运行签过名的内核模块,这时候就能先通过 stap
生成内核模块,签了名之后再通过 staprun
运行。具体怎么操作,烦请参考 man staprun
。
在 staprun/staprun_funcs.c
文件下,insert_module
函数调用了系统 API init_module
,把生成的内核模块加载上去。感兴趣的可以 man init_module
了解下这个 API。
通讯
加载的内核模块会创建 /sys/kernel/debug/systemtap/$module_name
这个目录,
__stp_module_dir = debugfs_create_dir(module_name, root_dir);
然后创建 /sys/kernel/debug/systemtap/$module_name/.cmd
这个文件。
/* create [debugfs]/systemtap/module_name/.cmd */
_stp_cmd_file = debugfs_create_file(".cmd", 0600, module_dir,
NULL, &_stp_ctl_fops_cmd);
debugfs_create_dir
和 debugfs_create_file
两者是内核模块提供的 API,用来创建一个 debugfs 下的“伪文件”。本着“一切皆文件”的传统,用户态程序可以通过读写这些“伪文件”来调用内核模块指定函数。
_stp_ctl_fops_cmd
这个结构体顾名思义,就是用在发送控制指令并接收响应的。
我们可以看下它的定义:
static struct file_operations _stp_ctl_fops_cmd = {
.owner = THIS_MODULE,
/* 读文件时触发 */
.read = _stp_ctl_read_cmd,
/* 写文件时触发 */
.write = _stp_ctl_write_cmd,
.open = _stp_ctl_open_cmd,
.release = _stp_ctl_close_cmd,
.poll = _stp_ctl_poll_cmd
};
同样该内核模块也会注册 /sys/kernel/debug/systemtap/$module_name/trace%d
模式的文件,用于数据流的传输。
让我们从内核态跳回到用户态来。staprun 并非是运行阶段最后执行的二进制文件 - 它在加载了内核模块之后,会 exec 成一个 stapio 进程,负责跟加载的内核模块通讯。
stapio 跟内核模块交互的部分(其实就是对文件的读写),主要位于
int stp_main_loop(void)
(负责读写控制流)
static void *reader_thread(void *data)
(负责读数据流)
这两个函数。
我们可以在 stp_main_loop
这个函数里读到对于不同的控制指令,在用户态部分是如何处理的。具体各个控制指令的定义,在 runtime/transport/transport_msgs.h
里面能看到。在 _stp_ctl_write_cmd
这个函数里能看到对应的控制指令在内核态部分的处理逻辑。因为这部分逻辑相对简单,大抵上就是消息解包/打包之后执行相应的动作,外加上有充足的注释,所以我就不赘述了。
卸载
当以下两个条件中的一个得到满足时,staprun 就会卸载内核模块:
- stap 脚本走到
exit()
这一步 - 给 staprun(其实现在已经 exec 成 stapio 了)发送信号(比如 Ctrl+C 一下)
在内核模块认为自己要退出时,它会把一个 STP_REQUEST_EXIT
控制消息放到伪文件 .cmd
的 buffer 里。当 stapio 读控制流时,如果遇到 STP_REQUEST_EXIT
消息,它就响应 STP_EXIT
消息。内核模块看到 STP_EXIT
后做对应的清理工作,然后返回 STP_EXIT
给 stapio。stapio 看到 STP_EXIT
后就会卸载内核模块。
相应地,如果 stapio 收到了某些要退出的信号,它会给内核模块发送 STP_EXIT
消息,然后再卸载内核模块。
有趣的是,stapio 卸载内核模块,是通过创建一个执行卸载操作的 staprun 进程来实现的。这么一来,内核模块的加载和卸载都是由 staprun 实现,而 stapio 只负责通讯的部分。
结语
整个 systemtap 的会话流程的介绍,到了本篇算是落下帷幕了。限于篇幅所限,有些无关主流程的细节在本系列文章中不得不舍去。感兴趣的读者可以进一步阅读 systemtap 的源码。
本系列文章讲的是 systemtap 的 kernel backend,也即生成内核模块的后端。对于现阶段主流的 Linux 内核版本(CentOS 7 对应的 3.x),通过生成内核模块来做 profile 是通常的做法。考虑到 CentOS 8 都开始用 4.1x 的内核了,也许在不久的将来,即使用 systemtap 也是用它的 bpf backend,即生成 ebpf 的后端。如果有打算阅读源码的话,建议着重看 bpf backend 的部分。