乐趣区

systemtap-探秘一

Linux 内核(以下简称内核)提供了 kprobe 和 uprobe 的机制,允许用户通过编写自己的内核模块,挂载特定的事件来执行自己的函数。比如我们可以在 accept 系统调用结束时记录下新创建的 fd;或者在 VFS 读写操作前后记录时间戳,统计它们的耗时。

直接裸写内核模块费时费力,其中有部分工作还是可以套模板的(比如从挂载 accept 系统调用改成挂载 write 调用)。而且更大的问题是它不够安全。要是内核态的程序崩溃了,那就是 kernel panic 的事了,准备去重启服务器吧。于是乎像是 systemtap 这样的项目就应运而生了。这一类项目提供了自己的 DSL,然后编译成内核模块。用户不用直接跟 C 代码和内核接口打交道,而是改用能力受限但安全得多的 DSL 编写自己的小工具。打个比方,就像写 SQL 查询数据库和直接编写数据库 C 插件的区别。

需要强调的是,systemtap DSL(以下简称 stp)并非绝对地安全。首先,stp 允许我们嵌入 C 代码,而这部分自然是不安全的。其次,stp 允许我们通过宏来调整编译出来的内核模块的行为,而有些宏是有副作用的。举个例子,stp 里面的数组大小是预先分配好的,大小取决于 MAXMAPENTRIES 这个宏(默认 2048)。有些时候,由于要插入很多的数据,你需要调大这个宏。如果简单粗暴地随便在后面加若干个 0,可能会导致内核分配内存失败,进而产生一系列问题(包括 kernel panic)。

对于 4.x 高版本的内核,我们可以使用 ebpf 机制而不是编写或者间接编写内核模块。ebpf 是个在内核态运行的,解释执行字节码的虚拟机。它从底子上提供了更多的安全限制,要比内核模块安全得多。而且编译成 ebpf 字节码要比编译内核模块快很多,这也是基于 ebpf 的内核 profile 的一大优势。

systemtap 除了可以生成内核模块,它还有生成 ebpf 字节码和生成 ptrace 代码的后端。2018 年,我曾尝试过 systmtap 的 ebpf 后端,发现该后端由于正在开发中,支持的 stp 语法特性较少,只有一部分 stp 文件能成功跑起来。相比较而言,BCC 支持的语言特性倒是足够多,然而其对 debuginfo 的支持几乎等于没有,以致于用户态 profile 只能基于 USDT(刻薄一点,就是几乎不能用)。不知道它们俩现在发展得怎样了。

回到一开始的话题上来。一次完整的 stap test.stp ... 会经历下面的阶段:

  1. parse stp 代码(词法分析和语法分析)
  2. 解读 stp 代码(语义分析)
  3. 生成 C 代码(中间代码生成)
  4. 编译内核模块
  5. 运行

其中最后一个阶段由 stap 创建的 staprun 子进程执行。这一阶段可以单独拆开来,即前四个阶段在开发机器上生成编译好的内核模块,然后第五个阶段在目标机器上执行。具体怎么做参考 systemtap 的文档。

如果你对其中的细节感兴趣,或者需要搞明白 systemtap 那些奇奇怪怪的错误信息,可以在运行时加上 -vvv 来显示更多上下文。

另外在命令中加入 -p 阶段序号 可以让 systemtap 在执行完特定阶段后停下来。比如 stap -p3 会在生成完 C 代码后停下来。

本系列会按这五个阶段,一步步介绍整个流程。重点会放在第三阶段生成 C 代码的部分。我们会讲讲那些 stp 语言的元素,比如全局数组、聚合函数,是怎样编译成 C 代码的。

不过在此之前,先从第一阶段开始吧。

词法分析和语法分析

这一阶段没什么好说的,就是解析 stp 代码,生成一棵 AST 树。

如果解析过程中出错,会报告 parse error

parse error: expected literal string or number
        saw: operator ')' at test.stp:3:15
     source: probe oneshot() {
                           ^

1 parse error.
Pass 1: parse failed.  [man error::pass1]

上面是 systemtap 奇奇怪怪的报错信息的一个例子。正确的写法是 probe oneshot {,oneshot 后面不带括号。

systemtap 虽然发现了语法上的问题,但是它的错误信息里面没有考虑到当前正在解析 probe 的上下文。它以为是在解析某些带参数的 probe,如 probe process("path").function("xxx"),所以才声称 expected literal string or number

不过好在 stp 的语法比较简单,即使看不懂错误提示也能改对。

语义分析

上一阶段结束后,stp 代码已经被解析成一棵 AST 树了。这一阶段主要是对这棵树做修剪,为下一阶段生成 C 代码做准备。

这一阶段由一系列分阶段组成。每个分阶段会执行一个或多个 visitor,遍历整棵树,处理每个节点。这些分阶段包括 stp 语言宏的展开、查找 stp 函数定义、匹配 stp 变量类型、debuginfo 相关的一些操作、若干种优化器等等。作为承上启下的一个阶段,该阶段涉及了很多细节。由于这些细节跟 systemtap 内部实现紧密相关,要想理解它们,最好需要阅读其实现代码。

这里我们就只提下优化器部分。在生成 C 代码之前,systemtap 会执行一系列优化器,如常量折叠、冗余分支消除等,对 AST 树进行剪枝。对于大型的 stp 脚本,花在这一阶段的时间可能多达 10 秒,快赶上第四阶段编译内核模块的耗时了。如果你对 systemtap 脚本的预备耗时比较敏感,可以在运行时加上 -u 选项,跳过这些优化阶段。

预告

接下来,我会花上几篇文章的篇幅,讲讲第三阶段中 C 代码的生成,重点是 stp 语言的一些特性。

退出移动版