Cyrus Blog

FLAG{S0_H4PPY_C_U_H3R3} (>.<)

ebpf & bcc 中文教程及手册

本文共 7.9k 字,预计阅读时间 33 分钟。

本文主要结合 IO Visor Project 的英文文档进行翻译,从零开始入门 ebpf,并使用 bcc 编写自定义的探针 (Probe,某些地方叫做 Agent)。

本文中会有一些推荐阅读的参考资料和一些英文原文文档。如果忽略任何这些链接,都不会对理解本文造成问题,但是他们可能对一些技术历史和技术架构的了解有所帮助(或许只是谈资)

0x00 ebpf 是什么

历史渊源

要说清楚这个还是要说点历史的。推荐阅读:eBPF 简史

BPF 的全称是 Berkeley Packet Filter,顾名思义,这是一个用于过滤(filter)网络报文(packet)的架构。

BPF 采用的报文过滤设计的全称是 CFG(Computation Flow Graph),顾名思义是将过滤器构筑于一套基于 if-else 的控制流(flow graph)之上

BPF 被引入 Linux 之后,除了一些小的性能方面的调整意外,很长一段时间都没有什么动静。直到 3.0 才首次迎来了比较大的革新:在一些特定硬件平台上,BPF 开始有了用于提速的 JIT(Just-In-Time) Compiler。

自 3.15 伊始,一个套源于 BPF 的全新设计开始逐渐进入人们的视野,并最终(3.17)被添置到了 kernel/bpf 下。这一全新设计最终被命名为了 extended BPF(eBPF):顾名思义,有全面扩充既有 BPF 功能之意;而相对应的,为了后向兼容,传统的 BPF 仍被保留了下来,并被重命名为 classical BPF(cBPF)。

相对于 cBPF,eBPF 带来的改变可谓是革命性的:一方面,它已经为内核追踪(Kernel Tracing)、应用性能调优/监控、流控(Traffic Control)等领域带来了激动人心的变革;另一方面,在接口的设计以及易用性上,eBPF 也有了较大的改进。

定位和定义

所以 ebpf 实际上是类 Unix 系统上数据链路层的一种原始接口,他可以提供原始链路层包的收发和过滤。

再说简单一点,他就是一个带有过滤功能的数据包收发接口,当然也是可以 dump 数据的。

另外,ebpf 只能读取系统调用的参数和返回值,而不能修改任何寄存器(用户态或者内核态),因此它本身并不具有拦截功能。这一限制实际是其内部 verifier.c 模块的一个部分,真正的目的是为了保证内核本身的运行安全。因此在编写 BPF C 程序时,要非常小心的处理指针操作,避免一切的未定义行为(第5课的实验中会强调);如果出现了未被 verifier.c 检测出的异常行为,也会在执行时被其他模块中断(如第13课的实验中的报错)。

0x01 安装和试用 bcc

好了,刚说完 ebpf 还有 bcc,但是这是本文最后一个概念了:

bcc 是一个为了方便的创建高效内核跟踪和操作程序的工具包,包括一些开箱即用的工具和示例。 它基于 eBPF 开发(需要 Linux 3.15 及更高版本)。 bcc 使用的大部分内容都需要 Linux 4.1 及更高版本。

bcc 使得 bpf 程序更容易被书写,bcc 使用 Python 和 Lua,虽然核心依旧是一部分 C 语言代码(BPF C 代码)。但是我们很快就可以体验了,这比手动安装 C 语言依赖、编译、插入内核要方便的多。

这里是 bcc 的安装文档,安装完就可以体验了。Debian 系编译安装可以参考这里,其中依赖包安装部分的 clang-format-3.8 需要改为 clang-format-6.0。 运行前可能需要 pip/pip3 install bcc

有几点注意事项:

  1. hello_world.py 在 bcc 根目录下 example
  2. 需要 root 权限运行,记得加 sudo
  3. 如果程序没有输出,请新开一个会话(ssh 或新终端)随便写一点命令
  4. 推荐 python2

这是一个典型输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cyrus@debian:~/bcc$ cd examples/
cyrus@debian:~/bcc/examples$ ls
CMakeLists.txt cpp hello_world.py lua networking tracing usdt_sample
cyrus@debian:~/bcc/examples$ sudo python2 hello_world.py
bash-4168 [000] .... 1795009.638605: 0x00000001: Hello, World!
bash-4168 [000] .... 1795009.640489: 0x00000001: Hello, World!
bash-4168 [000] .... 1795009.909137: 0x00000001: Hello, World!
bash-4168 [000] .... 1795009.910601: 0x00000001: Hello, World!
bash-4168 [000] .... 1795013.394096: 0x00000001: Hello, World!
bash-4168 [000] .... 1795013.395250: 0x00000001: Hello, World!
bash-4168 [000] .... 1795013.981169: 0x00000001: Hello, World!
bash-4168 [000] .... 1795019.130056: 0x00000001: Hello, World!
cron-811 [000] .... 1795047.865667: 0x00000001: Hello, World!
cron-24857 [000] .... 1795047.868596: 0x00000001: Hello, World!
sh-24858 [000] .... 1795047.870019: 0x00000001: Hello, World!
systemd-journal-4412 [000] .... 1795075.045463: 0x00000001: Hello, World!
systemd-journal-4412 [000] .... 1795075.045677: 0x00000001: Hello, World!

我们看一下这个 hello world 的源码,非常简单:

1
2
from bcc import BPF
BPF(text='int kprobe__sys_clone(void *ctx) { bpf_trace_printk("Hello, World!\\n"); return 0; }').trace_print()

原来 python 中关机的语句只有一句 C 语言代码。这段代码实际上是 BPF C 代码。官方文档有以下两个部分:

除此之外还有这个英文原文文档:The bpftrace One-Liner Tutorial ,bpftrace 也是一个很棒的、高于 BPF C 语言层面的开发工具,他更多的是打印一些很棒的可视化信息。作为工具开发和对数据的挖掘和统计,我们还是以 bcc 和 BPF C 为主。如果有机会我会翻译一些 bpftrace 文档。

我的水平有限,这肯定不是最好的一篇中文 ebpf & bcc 文档,但是据我所知这是第一篇完全完整的中文版本(逃)。不过这里依旧致敬一下前辈:云栖社区、CSDN 等社区的一些文章。笔者之前使用过 DPDK,想要了解关于 ebpf 作为包过滤器在 xdp 的应用,推荐阅读这些文章

这是在工作过程中我找到的另一篇不错的文章,推荐阅读

0x02 Python 开发教程(译)

这是一个关于开发 bcc 工具和程序的 Python 接口教程。主要有两个部分:内核探测和流量探测(其中流量探测部分还没有编写)。代码片段取自库中 bcc 样例,请自行参考。

内核探测

内核探测部分包括 17 个课时(实际上每个都很简短)和 46 个知识点。

第1课 Hello World

我们在之前已经体验过 examples/hello_world.py 了,在别的会话中运行如 ls 等指令时,由于有新进程被创建,程序会打印出“Hello, World!”。如果失败了,可能需要重新去安装配置一下 bcc 环境,这里是安装文档

源码:

1
2
from bcc import BPF
BPF(text='int kprobe__sys_clone(void *ctx) { bpf_trace_printk("Hello, World!\\n"); return 0; }').trace_print()

有6个知识点:

  1. text='...' 这里定义了一个内联的、C 语言写的 BPF 程序。
  2. kprobe__sys_clone() 这是一个通过内核探针(kprobe)进行内核动态跟踪的快捷方式。如果一个 C 函数名开头为 kprobe__ ,则后面的部分实际为设备的内核函数名,这里是 sys_clone()
  3. void *ctx 这里的 ctx 实际上有一些参数,不过这里我们用不到,暂时转为 void * 吧。
  4. bpf_trace_printk() 这是一个简单地内核设施,用于 printf() 到 trace_pipe(译者注:可以理解为 BPF C 代码中的 printf())。它一般来快速调试一些东西,不过有一些限制:最多有三个参数,一个%s ,并且 trace_pipe 是全局共享的,所以会导致并发程序的输出冲突,因而 BPF_PERF_OUTPUT() 是一个更棒的方案,我们后面会提到。
  5. return 0 这是一个必须的部分(为什么必须请参见 这个issue)。
  6. .trace_print() 一个 bcc 实例会通过这个读取 trace_pipe 并打印出来。

第2课 sys_sync()

尝试练习一下探测 sys_sync 吧,检测到 sync 时打印出“sys_sync() called”。hello_worpd.py 有一切你需要用的知识。你可以通过在另外的会话里运行 sync 来测试。

你还可以在程序开始时打印“Tracing sys_sync()… Ctrl-C to end.”,不过这只是只靠 Python 实现的而已。

(译者注:sync 为 UNIX 操作系统的标准系统调用,功能为将内核文件系统缓冲区的所有数据写入存储介质。)

第3课 hello_fields.py

本课代码在 examples/tracing/hello_fields.py 中,典型输出(需要在新会话运行命令)应该是:

1
2
3
4
5
6
# ./examples/tracing/hello_fields.py
TIME(s) COMM PID MESSAGE
24585001.174885999 sshd 1432 Hello, World!
24585001.195710000 sshd 15780 Hello, World!
24585001.991976000 systemd-udevd 484 Hello, World!
24585002.276147000 bash 15787 Hello, World!

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from bcc import BPF

# define BPF program
prog = """
int hello(void *ctx) {
bpf_trace_printk("Hello, World!\\n");
return 0;
}
"""

# load BPF program
b = BPF(text=prog)
b.attach_kprobe(event=b.get_syscall_fnname("clone"), fn_name="hello")

# header
print("%-18s %-16s %-6s %s" % ("TIME(s)", "COMM", "PID", "MESSAGE"))

# format output
while 1:
try:
(task, pid, cpu, flags, ts, msg) = b.trace_fields()
except ValueError:
continue
print("%-18.9f %-16s %-6d %s" % (ts, task, pid, msg))

这和 hello_world.py 很接近,也是通过 sys_clone() 跟踪了一些新进程的创建,但是我们需要学一些新知识点:

  1. prog = 这次我们通过变量声明了一个 C 程序源码,之后引用它。这对于需要通过命令行参数为 C 程序增加不同的指令很棒。
  2. hello() 现在我们声明了一个 C 语言函数,而不是使用 kprobe__ 开头的快捷方式。我们稍后调用这个函数。BPF 程序中的任何 C 函数都需要在一个探针上执行,因而我们必须将 pt_reg* ctx 这样的 ctx 变量放在第一个参数。如果你需要声明一些不在探针上执行的辅助函数,则需要定义成 static inline 以便编译器内联编译。有时候你可能需要添加 _always_inline 函数属性。
  3. b.attach_kprobe(event=b.get_syscall_fnname("clone"), fn_name="hello") 这里建立了一个内核探针,以便内核系统出现 clone 操作时执行 hello() 这个函数。你可以多次调用 attch_kprobe() ,这样就可以用你的 C 语言函数跟踪多个内核函数。
  4. b.trace_fields() 这里从 trace_pipe 返回一个混合数据,这对于黑客测试很方便,但是实际工具开发中需要使用 BPF_PERF_OUTPUT()

第4课 sync_timing.py

(译者注:这段话直接 Google 翻译的)还记得系统管理员在 reboot 之前在慢速控制台上键入三次 sync 的日子,以便完成第一次异步同步时间吗? 然后有人认为 sync;sync;sync 是聪明的,在一条线上运行它们,尽管打败了原来的目的,这已成为行业惯例! 然后 sync 变成了 synchronous,所以更多的原因是愚蠢的。 无论如何。

下面的这个例子是,如果 1 秒之内多次使用了 sync ,它会告诉我们到底 do_sync 花了多少时间。也就是说如果使用了 sync;sync;sync ,后面两次 sync 会被打印出来。

他的代码在 examples/tracing/sync_timing.py

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
from __future__ import print_function
from bcc import BPF

# load BPF program
b = BPF(text="""
#include <uapi/linux/ptrace.h>

BPF_HASH(last);

int do_trace(struct pt_regs *ctx) {
u64 ts, *tsp, delta, key = 0;

// attempt to read stored timestamp
tsp = last.lookup(&key);
if (tsp != 0) {
delta = bpf_ktime_get_ns() - *tsp;
if (delta < 1000000000) {
// output if time is less than 1 second
bpf_trace_printk("%d\\n", delta / 1000000);
}
last.delete(&key);
}

// update stored timestamp
ts = bpf_ktime_get_ns();
last.update(&key, &ts);
return 0;
}
""")

b.attach_kprobe(event=b.get_syscall_fnname("sync"), fn_name="do_trace")
print("Tracing for quick sync's... Ctrl-C to end")

# format output
start = 0
while 1:
(task, pid, cpu, flags, ts, ms) = b.trace_fields()
if start == 0:
start = ts
ts = ts - start
print("At time %.2f s: multiple syncs detected, last %s ms ago" % (ts, ms))

要学的知识点:

  1. bpf_ktime_get_ns() 以纳秒为单位返回当前时间。
  2. BPF_HASH(last) 创建一个名为 last 的 BPF hash 映射。我们使用了默认参数,所以它使用了默认的 u64 作为 key 和 value 的类型。(译者注:这里可以理解为一个储存数据用的全局变量,因为一些原因可能在 BPF 中只能使用 hash 映射这种形式作为全局变量,以便通信。)
  3. key = 0 我们只储存一个键值对,每次存在 key 为 0 的位置即可。所以 key 固定为 0。
  4. last.lookup(&key) 在 hash 映射中寻找一个 key 对应的 value。不存在会返回空。我们将 key 作为地址指针传入函数。
  5. last.delete(&key) 顾名思义,移除一个键值对。至于为什么要删除,参见这个内核 bug
  6. last.update(&key, &ts) 顾名思义,传入两个参数,覆盖更新原有的键值对。ts 是时间戳。

第5课 sync_count.py

修改上一课的 sync_timing.py 程序,以存储所有内核中调用 sync 的计数(快速和慢速),并打印出来。 通过向现有的 hash 映射添加新的键值对,可以在 BPF 程序中记录此计数。

这是译者给出的一个参考代码。译者这里列举一些注意事项:

  1. bpf_trace_printk 的输出实际是一个字符串,也就是说,有多组数据希望从 trace_pipe 传递到 python 时应该使用分隔符,并且在 python 中自行 split 取出。这一点我们会在下一课提到。
  2. bpf 程序有自己的检查器(这可能是出于内核的安全考虑),所以关于指针的操作应该非常小心。

第6课 disksnoop.py

看一看 examples/tracing/disksnoop.py 的代码你会有一些新的发现,这是一个典型的输出:

1
2
3
4
5
6
7
# ./disksnoop.py
TIME(s) T BYTES LAT(ms)
16458043.436012 W 4096 3.13
16458043.437326 W 4096 4.44
16458044.126545 R 4096 42.82
16458044.129872 R 4096 3.24
[...]

我们看一下代码片段:

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
[...]
REQ_WRITE = 1 # from include/linux/blk_types.h

# load BPF program
b = BPF(text="""
#include <uapi/linux/ptrace.h>
#include <linux/blkdev.h>

BPF_HASH(start, struct request *);

void trace_start(struct pt_regs *ctx, struct request *req) {
// stash start timestamp by request ptr
u64 ts = bpf_ktime_get_ns();

start.update(&req, &ts);
}

void trace_completion(struct pt_regs *ctx, struct request *req) {
u64 *tsp, delta;

tsp = start.lookup(&req);
if (tsp != 0) {
delta = bpf_ktime_get_ns() - *tsp;
bpf_trace_printk("%d %x %d\\n", req->__data_len,
req->cmd_flags, delta / 1000);
start.delete(&req);
}
}
""")

b.attach_kprobe(event="blk_start_request", fn_name="trace_start")
b.attach_kprobe(event="blk_mq_start_request", fn_name="trace_start")
b.attach_kprobe(event="blk_account_io_completion", fn_name="trace_completion")
[...]

要学的知识点:

  1. REQ_WRITE 我们在 Python 中定义了一个常数,我们稍后会用到它。如果我们在 bpf 程序中直接使用它(而不重新定义),可能需要一些适当的 #include
  2. trace_start(struct pt_regs *ctx, struct request *req) 这个函数烧毁会被附加到内核探针上。内核探针使用的参数是 struct pt_regs *ctx ,用于寄存和 BPF 上下文;之后是被附加的函数的实际参数。我们把它附加到 blk_start_request() ,他的第一个参数是 struct request *
  3. start.update(&req, &ts) 我们把请求结构体的指针作为 hash 映射中的 key,这在探测中很常见。指向结构体的指针是很好的 key,因为这是唯一的:两个结构体不会具有相同的指针地址(只需要注意它被释放和重新使用的情况)。所以我们真正要做的就是,我们用我们自己的时间戳,把它与包含磁盘 I/O 描述的请求结构体对应起来,从而使得我们可以计时。提示:有两个用于储存时间戳的常用 key:指向结构体的指针,线程的 ID。
  4. req->__data_len 我们解引用 struct request 的成员(译者注:实际可以理解成从指针对应的值中取出各个成员),我们可以在内核源代码中看到 request 的成员定义。bcc 实际上将这些表达式重写为一系列的 bpf_probe_read() 调用。有时候 bcc 可能无法处理十分复杂的解引用,这时候就需要直接调用 bpf_probe_read()

这是一个非常有趣的程序,如果你能理解所有的代码,你就会理解许多重要的基础知识。 我们仍在使用 bpf_trace_printk() 来输出,所以我们接下来解决这个问题。

译者注:这个程序实现的实际是 Linux 的 Block 层 I/O 操作的输出,关于 struct request 的结构,可以在这里找到定义。

第7课 hello_perf_output.py

终于,我们可以不再使用 bpf_trace_printk() 而使用更推荐的 BPF_PERF_OUTPUT() 接口了。这也意味着我们不再继续简单地通过 trace_field() 来方便的获取 PID 和时间戳,我们需要调用一些函数直接获取他们。

这是一个在另一个会话运行时的典型输出:

1
2
3
4
5
6
7
# ./hello_perf_output.py
TIME(s) COMM PID MESSAGE
0.000000000 bash 22986 Hello, perf_output!
0.021080275 systemd-udevd 484 Hello, perf_output!
0.021359520 systemd-udevd 484 Hello, perf_output!
0.021590610 systemd-udevd 484 Hello, perf_output!
[...]

代码在 examples/tracing/hello_perf_output.py

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
from bcc import BPF

# define BPF program
prog = """
#include <linux/sched.h>

// define output data structure in C
struct data_t {
u32 pid;
u64 ts;
char comm[TASK_COMM_LEN];
};
BPF_PERF_OUTPUT(result);

int hello(struct pt_regs *ctx) {
struct data_t data = {};

data.pid = bpf_get_current_pid_tgid();
data.ts = bpf_ktime_get_ns();
bpf_get_current_comm(&data.comm, sizeof(data.comm));

result.perf_submit(ctx, &data, sizeof(data));

return 0;
}
"""

# load BPF program
b = BPF(text=prog)
b.attach_kprobe(event=b.get_syscall_fnname("clone"), fn_name="hello")

# header
print("%-18s %-16s %-6s %s" % ("TIME(s)", "COMM", "PID", "MESSAGE"))

# process event
start = 0
def print_event(cpu, data, size):
global start
event = b["result"].event(data)
if start == 0:
start = event.ts
time_s = (float(event.ts - start)) / 1000000000
print("%-18.9f %-16s %-6d %s" % (time_s, event.comm, event.pid,
"Hello, perf_output!"))

# loop with callback to print_event
b["result"].open_perf_buffer(print_event)
while 1:
b.perf_buffer_poll()

译者注:译者对这部分代码中的部分变量换了名字,这是为了避免造成一些不必要的误解。

要学的知识点:

  1. struct data_t 一个简单的 C 语言结构体,用于从内核态向用户态传输数据。
  2. BPF_PERF_OUTPUT(result) 表明内核传出的数据将会打印到 “result” 这个通道内(译者注:实际上这就是 bpf 对象的一个 key,可以通过 bpf_object["result"] 的方式读取。)
  3. struct data_t data = {}; 创建一个空的 data_t 结构体,之后在填充。
  4. bpf_get_current_pid_tgid() 返回以下内容:位于低 32 位的进程 ID(内核态中的 PID,用户态中实际为线程 ID),位于高 32 位的线程组 ID(用户态中实际为 PID)。我们通常将结果通过 u32 取出,直接丢弃最高的 32 位。我们优先选择了 PID(← 指内核态的)而不是 TGID(← 内核态线程组 ID),是因为多线程应用程序的 TGID 是相通的,因此需要 PID 来区分他们。通常这也是我们代码的用户所关心的。
  5. bpf_get_current_comm(&data.comm, sizeof(data.comm)); 将当前参数名填充到指定位置。
  6. reault.perf_submit() 通过 perf 缓冲区环将结果提交到用户态。
  7. def print_event() 定义一个函数从 result 流中读取 event。(译者注:这里的 cpu, data, size 是默认的传入内容,连接到流上的函数必须要有这些参数)。
  8. b["events"].event(data) 通过 Python 从 result 中获取 event。
  9. b["events"].open_perf_buffer(print_event)print_event 函数连接在 result 流上、
  10. while 1: b.perf_buffer_poll() 阻塞的循环获取结果。

这些未来的 bcc 版本中可能有所改进。 例如,Python 数据结构可以从 C 代码自动生成。

第8课 sync_perf_output.py

使用 BPF_PERF_OUTPUT 重新编写 sync_timing.py。

这是译者给出的一个参考代码

第9课 bitehist.py

以下工具记录磁盘不同大小 I/O 情况的直方图。这是一个典型输出:

1
2
3
4
5
6
7
8
9
10
11
12
# ./bitehist.py
Tracing... Hit Ctrl-C to end.
^C
kbytes : count distribution
0 -> 1 : 3 | |
2 -> 3 : 0 | |
4 -> 7 : 211 |********** |
8 -> 15 : 0 | |
16 -> 31 : 0 | |
32 -> 63 : 0 | |
64 -> 127 : 1 | |
128 -> 255 : 800 |**************************************|

代码在 examples/tracing/bitehist.py

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
from __future__ import print_function
from bcc import BPF
from time import sleep

# load BPF program
b = BPF(text="""
#include <uapi/linux/ptrace.h>
#include <linux/blkdev.h>

BPF_HISTOGRAM(dist);

int kprobe__blk_account_io_completion(struct pt_regs *ctx, struct request *req)
{
dist.increment(bpf_log2l(req->__data_len / 1024));
return 0;
}
""")

# header
print("Tracing... Hit Ctrl-C to end.")

# trace until Ctrl-C
try:
sleep(99999999)
except KeyboardInterrupt:
print()

# output
b["dist"].print_log2_hist("kbytes")

我们首先回顾一下早期课程:

  1. kprobe__ 前缀表示后面的部分实际为设备的内核函数名,这是一个通过内核探针(kprobe)进行内核动态跟踪的快捷方式。
  2. struct pt_regs *ctx, struct request *req 是内核探针的参数。这里的 ctx 是寄存器和 BPF 的上下文,req 是探针对应的内核函数 blk_account_io_completion() 的第一个参数。
  3. req->__data_len 是从req 的结构体中解引用。

本节课要学的知识点有:

  1. BPF_HISTOGRAM(hist) 定义一个直方图形式的 BPF 映射(译者注:区别于之前的 hash 映射),名为 hist。
  2. dist.increment() 第一个参数的运算结果作为直方图索引的递增量,默认为 1。自定义的递增量可以作为第二个参数提供。
  3. bpf_log2l() 返回参数以 2 为底的对数值。 这将作为我们直方图的索引,因此我们正在构建 2 的幂直方图。
  4. b["dist"].print_log2_hist("kbytes") 将直方图 dist 的索引打印为 2 的幂,列标题为 “kbytes”。从内核态传到用户态的唯一数据是这一系列索引项的总计数,从而提高了效率。(译者注:因为一个 2 的幂实际上对应多个索引,比如第 3 行包含了 2^2 至 2^3-1 的多个索引的计数之和。)

第10课 disklatency.py

编写一个对磁盘 I/O 进行计时的程序,并打印出延迟的直方图。 磁盘 I/O工具和时序可以在第 6 课的 disksnoop.py 程序中找到,直方图代码可以在第 9 课的 bitehist.py 中找到。

这是译者给出的一个参考代码

第11课 vfsreadlat.py

这是一个将 Python 和 C 文件分离的例子。典型输出如下:

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
# ./vfsreadlat.py 1
Tracing... Hit Ctrl-C to end.
usecs : count distribution
0 -> 1 : 0 | |
2 -> 3 : 2 |*********** |
4 -> 7 : 7 |****************************************|
8 -> 15 : 4 |********************** |

usecs : count distribution
0 -> 1 : 29 |****************************************|
2 -> 3 : 28 |************************************** |
4 -> 7 : 4 |***** |
8 -> 15 : 8 |*********** |
16 -> 31 : 0 | |
32 -> 63 : 0 | |
64 -> 127 : 0 | |
128 -> 255 : 0 | |
256 -> 511 : 2 |** |
512 -> 1023 : 0 | |
1024 -> 2047 : 0 | |
2048 -> 4095 : 0 | |
4096 -> 8191 : 4 |***** |
8192 -> 16383 : 6 |******** |
16384 -> 32767 : 9 |************ |
32768 -> 65535 : 6 |******** |
65536 -> 131071 : 2 |** |

usecs : count distribution
0 -> 1 : 11 |****************************************|
2 -> 3 : 2 |******* |
4 -> 7 : 10 |************************************ |
8 -> 15 : 8 |***************************** |
16 -> 31 : 1 |*** |
32 -> 63 : 2 |******* |
[...]

你可以在 examples/tracing/vfsreadlat.pyexamples/tracing/vfsreadlat.c 看到源码。

要学的知识点有:

  1. b = BPF(src_file = "vfsreadlat.c") 从一个独立的源文件中读取 BPF C 代码。
  2. b.attach_kretprobe(event="vfs_read", fn_name="do_return") 将 BPF C 函数 do_return() 附加到内核函数 vfs_read() 的返回结果上。这里是一个返回型内核探针(kretprobe):他探测的对象是内核函数的返回值或输出值,而非它的输入值。
  3. ["dist"].clear() 清理直方图。

第12课 urandomread.py

在命令 dd if=/dev/urandom of=/dev/null bs=8k count=5 运行时探测:

1
2
3
4
5
6
7
8
# ./urandomread.py
TIME(s) COMM PID GOTBITS
24652832.956994001 smtp 24690 384
24652837.726500999 dd 24692 65536
24652837.727111001 dd 24692 65536
24652837.727703001 dd 24692 65536
24652837.728294998 dd 24692 65536
24652837.728888001 dd 24692 65536

emmm,这里意外地抓住了 smtp。代码在 examples/tracing/urandomread.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from __future__ import print_function
from bcc import BPF

# load BPF program
b = BPF(text="""
TRACEPOINT_PROBE(random, urandom_read) {
// args is from /sys/kernel/debug/tracing/events/random/urandom_read/format
bpf_trace_printk("%d\\n", args->got_bits);
return 0;
}
""")

# header
print("%-18s %-16s %-6s %s" % ("TIME(s)", "COMM", "PID", "GOTBITS"))

# format output
while 1:
try:
(task, pid, cpu, flags, ts, msg) = b.trace_fields()
except ValueError:
continue
print("%-18.9f %-16s %-6d %s" % (ts, task, pid, msg))
  1. TRACEPOINT_PROBE(random, urandom_read) 探测内核断点 random:urandom_read 。对于这些有稳定功能的 API,在条件允许时,我们不建议使用 kprobes 。你可以使用 perf list 命令来获取内核断点的清单。要想将 BPF 程序附加到内核断点需要 Linux 版本 >=4.7。

    译者注:perf list 命令有一相关依赖,这里给出一个样例的安装命令,其中 4.15.0-43-generic 可以通过 uname -a 查询,如果有必要请自行替换:

    1
    2
    3
    4
    5
    6
    sudo apt install \
    linux-tools-common \
    linux-tools-4.15.0-43-generic \
    linux-cloud-tools-4.15.0-43-generic \
    linux-tools-generic \
    linux-cloud-tools-generic
  2. args->got_bits 这里的 args 是一个具有自动填充功能的参数,他会和断点的参数进行匹配,返回一系列断点相关的成员。上方的注释表明了这些格式可以在那里查到(译者注:查询时可能需要 root 权限)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    $ sudo cat /sys/kernel/debug/tracing/events/random/urandom_read/format
    name: urandom_read
    ID: 1122
    format:
    field:unsigned short common_type; offset:0; size:2; signed:0;
    field:unsigned char common_flags; offset:2; size:1; signed:0;
    field:unsigned char common_preempt_count; offset:3; size:1; signed:0;
    field:int common_pid; offset:4; size:4; signed:1;

    field:int got_bits; offset:8; size:4; signed:1;
    field:int pool_left; offset:12; size:4; signed:1;
    field:int input_left; offset:16; size:4; signed:1;

    print fmt: "got_bits %d nonblocking_pool_entropy_left %d input_entropy_left %d", REC->got_bits, REC->pool_left, REC->input_left

    在这个例子中,我们打印了 got_bits 成员。

第13课 重构 disksnoop.py

将第 6 课中的 diaksnoop.py 转换成使用 block:block_rq_issueblock:block_rq_complete 断点形式的程序。

这是译者给出的一个参考代码

这段代码的书写过程异常诡异,感谢还二进制大手子 plusls 的帮助。主要有这些问题:

  1. 这是一个玄学问题:在没有 warning 的情况下,程序运行时有一个 R3 type=ctx expected=fp 的报错,然而 ctxfp 没有在任何地方出现过。经过研究他们是这里 的变量。在对一个 BPF hash 映射进行操作时,要求 key 和 value 都必须在栈上,参见这里。下面是一组正确和错误的示范:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // correct:
    u32 pid = bpf_get_current_pid_tgid();
    u64 tsp = bpf_ktime_get_ns();
    time.update(&pid, &tsp);
    int blocksize = args->bytes;
    size.update(&pid, &blocksize);

    // wrong:
    time.update(&bpf_get_current_pid_tgid(), &tspbpf_ktime_get_ns()
    size.update(&bpf_get_current_pid_tgid(), &args->bytes);
  2. 在处理完这个之后,我们会发现有很多读写过程没有输出。这个相对简单:将 if (tsp0 != 0 && blocksize != 0) 增加 else 处理一下会发现很多 hash 映射的查表结果为空,因为我们按照上方代码中的方式获取的 tgid 实际上是用户态的 pid 、是内核态的进程组 id。我们查看 block/block_rq_issue/format 等相关文件,将这个 id 换成 args->dev 就可以很好地解决这个问题。注意这个类型是 u64。

第14课 strlen_count.py

这是一个用于探测用户态中函数调用的例子,我们检测了 strlen() 库函数以及使用次数的计数。典型输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# ./strlen_count.py
Tracing strlen()... Hit Ctrl-C to end.
^C COUNT STRING
1 " "
1 "/bin/ls"
1 "."
1 "cpudist.py.1"
1 ".bashrc"
1 "ls --color=auto"
1 "key_t"
[...]
10 "a7:~# "
10 "/root"
12 "LC_ALL"
12 "en_US.UTF-8"
13 "en_US.UTF-8"
20 "~"
70 "#%^,~:-=?+/}"
340 "\x01\x1b]0;root@bgregg-test: ~\x07\x02root@bgregg-test:~# "

这些是探测调用这个库函数处理的字符串,以及他们调用处理次数。如 strlen()LC_ALL 中被调用了 12 次。

代码在 examples/tracing/strlen_count.py

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
from __future__ import print_function
from bcc import BPF
from time import sleep

# load BPF program
b = BPF(text="""
#include <uapi/linux/ptrace.h>

struct key_t {
char c[80];
};
BPF_HASH(counts, struct key_t);

int count(struct pt_regs *ctx) {
if (!PT_REGS_PARM1(ctx))
return 0;

struct key_t key = {};
u64 zero = 0, *val;

bpf_probe_read(&key.c, sizeof(key.c), (void *)PT_REGS_PARM1(ctx));
// could also use `counts.increment(key)`
val = counts.lookup_or_init(&key, &zero);
(*val)++;
return 0;
};
""")
b.attach_uprobe(name="c", sym="strlen", fn_name="count")

# header
print("Tracing strlen()... Hit Ctrl-C to end.")

# sleep until Ctrl-C
try:
sleep(99999999)
except KeyboardInterrupt:
pass

# print output
print("%10s %s" % ("COUNT", "STRING"))
counts = b.get_table("counts")
for k, v in sorted(counts.items(), key=lambda counts: counts[1].value):
print("%10d \"%s\"" % (v.value, k.c.encode('string-escape')))

要学的知识点:

  1. PT_REGS_PARM1(ctx) 这是取回所探测的目标函数 strlen() 的第一个参数,也就是处理的目标字符串。
  2. b.attach_uprobe(name="c", sym="strlen", fn_name="count") 将探针附加到 C library(如果这是主函数,需要使用绝对路径),探测的目标函数是 strlen(),探测到执行时调用 BPF C 程序中的 count()

第15课 nodejs_http_server.py

该程序用于探测用户定义的静态跟踪(USDT)探针,该探针是内核断点的用户级版本。 典型输出如下:

1
2
3
4
5
# ./nodejs_http_server.py 24728
TIME(s) COMM PID ARGS
24653324.561322998 node 24728 path:/index.html
24653335.343401998 node 24728 path:/images/welcome.png
24653340.510164998 node 24728 path:/images/favicon.png

相关代码在 examples/tracing/nodejs_http_server.py

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
from __future__ import print_function
from bcc import BPF, USDT
import sys

if len(sys.argv) < 2:
print("USAGE: nodejs_http_server PID")
exit()
pid = sys.argv[1]
debug = 0

# load BPF program
bpf_text = """
#include <uapi/linux/ptrace.h>
int do_trace(struct pt_regs *ctx) {
uint64_t addr;
char path[128]={0};
bpf_usdt_readarg(6, ctx, &addr);
bpf_probe_read(&path, sizeof(path), (void *)addr);
bpf_trace_printk("path:%s\\n", path);
return 0;
};
"""

# enable USDT probe from given PID
u = USDT(pid=int(pid))
u.enable_probe(probe="http__server__request", fn_name="do_trace")
if debug:
print(u.get_text())
print(bpf_text)

# initialize BPF
b = BPF(text=bpf_text, usdt_contexts=[u])

要学的知识点:

  1. bpf_usdt_readarg(6, ctx, &addr) 从 USDT 探针中读取第六个参数到 addr
  2. bpf_probe_read(&path, sizeof(path), (void *)addr)addr 指向 path 变量。
  3. u = USDT(pid=int(pid)) 针对给定的 PID 初始化 USDT。
  4. u.enable_probe(probe="http__server__request", fn_name="do_trace") 将我们编写的 do_trace() BPF C 代码附加到 Node.js 的 USDT 探针 http__server__request 上。
  5. b = BPF(text=bpf_text, usdt_contexts=[u]) BPF 对象创建时需要传入我们的 USDt 对象 u

译者注:这里调试时需要使用以下命令安装 dtrace:

1
sudo apt install systemtap-sdt-dev

随后启用 dtrace 选项编译 node:

1
2
3
4
git clone --depth=1 https://github.com/nodejs/node
cd node
./configure --with-dtrace
make

第16课 task_switch.c

到这里你已经学完了全部新知识了,本课作为一个彩蛋加在了这里,它是一个旧版本教程的内容。你可以用它复习和抽象概括你之前学到的知识。以下是旧版本的叙述:

这是一个比 Hello World 更加复杂的探测:内核中每个任务切换时,这个程序会被调用,并在 BPF 映射中记录新旧 PID。

下面 C 程序包含了两个概念:

  1. 第一个概念是宏 BPF_TABLE 。这里定义了一个类型为 hash 的映射(可以理解为一个 dict),key 和 value 的类型为 key_t 和 u64(这是一个独立的计数器)。映射名为 stats ,最多包含 1024 个条目。可以在表中使用 lookuplookup_or_initupdatedelete 操作条目。
  2. 第二个概念是呈递参数。这个参数在 BCC 的 Python 部分中被特别对待,以便从内核探针架构的上下文中获取数据,并呈递到 Python 中来访问。从位置 1 开始的参数应该与内核探针所需的参数相匹配(译者注:位置 0 为内核探针架构的上下文,即 ctx ,前面课程有说到)。这样就可以使得程序无缝访问任何函数参数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>

struct key_t {
u32 prev_pid;
u32 curr_pid;
};
// map_type, key_type, leaf_type, table_name, num_entry
BPF_HASH(stats, struct key_t, u64, 1024);
int count_sched(struct pt_regs *ctx, struct task_struct *prev) {
struct key_t key = {};
u64 zero = 0, *val;

key.curr_pid = bpf_get_current_pid_tgid();
key.prev_pid = prev->pid;

// could also use `stats.increment(key);`
val = stats.lookup_or_init(&key, &zero);
(*val)++;
return 0;
}

用户态的组件(即 Python 部分)加载上面的文件,并将其附加到内核函数 finish_task_switch 上。BPF 对象的 [] 运算符提供对程序中每个 BPf_TABLE 的访问,并允许对保存在内核中的值进行呈递和访问:就像使用任何一个 Python dict 对象一样,可以读取、更新和删除。

1
2
3
4
5
6
7
8
9
10
11
from bcc import BPF
from time import sleep

b = BPF(src_file="task_switch.c")
b.attach_kprobe(event="finish_task_switch", fn_name="count_sched")

# generate many schedule events
for i in range(0, 100): sleep(0.01)

for k, v in b["stats"].items():
print("task_switch[%5d->%5d]=%u" % (k.prev_pid, k.curr_pid, v.value))

这个例子已经被 merge 了,代码位于 examples/tracing/task_switch.py

第17课 进阶学习

更深入的学习可以参考 Sasha Goldshtein 的 linux-tracing-workshop,其中包含更多实验。在 bcc/tools 目录也有很多可以学习的工具。

流量探测

译者注:这一部分原文还没有填坑,相关代码在 bcc/examples/networking 目录。如果之后有空我会将这里尽可能细致的讲解。

0x03 bcc 参考手册(译)

主要是一些偏向于手册的内容,如函数列表、成员、错误处理和环境变量等,我在近期数周内会在这里更新。

如果你有兴趣,也可以通过 fcs98#sina,comWeChat Cyru1s 或在下方评论留言,我们可以一起完成。