LWN原文链接:https://lwn.net/Articles/747640/

BPF虚拟机正在不断融入各个内核模块子系统。此前的文章中介绍了BPF编译套件(BCC)及相关工具。但BCC不只是一系列管理工具集,它还为那些希望创建基于BPF工具的开发者提供了一整套开发环境。阅读本文了解如何使用上述开发环境来创建BPF程序并将其绑定到对应的tracepoint上。

BCC运行时环境提供了一个宏定义“TRACEPOINT_PROBE”,通过该宏定义可以将一个回调函数附加到指定的tracepoint上并在该tracepoint被触发时调用上述函数。下面的代码片段展示了一个没有任何功能的BPF程序,当kmalloc()函数被调用时该函数就会被自动执行。

TRACEPOINT_PROBE(kmem, kmalloc) {
	return 0;
}

上述宏定义的参数是一个tracepoint的分类项以及希望绑定的事件。上述参数会直接映射到debugfs文件系统的对应层次结构中(比如:/sys/kernel/debug/tracing/category/event/)。为了简化BPF的使用,对应tracepoint在BPF程序加载后会被自动打开。

kmalloc()对应的tracepoint会传递一系列参数,这些参数可以通过与之相关联的format文件看到。在BPF程序中可以通过args变量来访问tracepoint参数。在我们上面的例子中就可以通过args->call_site来获得kmalloc()函数被调用时对应的指令地址。我们可以藉此统计不同函数对kmalloc()函数的调用情况。

BCC可以借助BPF_HASH和BPF_TABLE来完整访问内核导出的所有数据结构(这一内容在上一期文章中已经介绍过)。BCC中最基本的数据结构是映射(map),其他高级数据结构都可以构建在该数据结构之上。这其中最基础的数据结构是BPF_TABLE。通过对BPF_TABLE的封装BCC提供了BPF_HASH和BPF_ARRAY数据结构。因此他们也有一些共通的操作函数,比如map.lookup()、map.update()和map.delete()。

现在回到我们最开始的那个程序,我们可以使用BPF_HASH来保存kmalloc()函数的调用地址信息(以及调用次数),并在这之后使用Python对统计结果进行处理。

#!/usr/bin/env python

from bcc import BPF
from time import sleep

program = """"
	BPF_HASH(callers, u64, unsigned long);

	TRACEPOINT_PROBE(kmem, kmalloc) {
		u64 ip = args->call_site;
		unsigned long *count;
		unsigned long c = 1;

		count = callers.lookup((u64 *)&ip);
		if (count != 0)
			c += *count

		callers.update(&ip, &c);

		return 0;
	}
""""
b = BPF(text=program)

while True:
	try:
		sleep(1)
		for k,v in sorted(b["callers"].items()):
			print ("%s %u" % (b.ksym(k.value), v.value))

		print
	except KeyboardInterrupt:
		exit()

BPF_HASH宏定义的详细介绍可以参考BCC参考手册。这个宏定义有很多可选参数,但是对一般用户而言最基本的是指定哈希表的名字(比如上面例子中的callers)、关键字key的数据类型(u64)以及数值value的数据类型(unsigned long)。BPF哈希表项可以通过lookup()函数来访问,如果查找的key没有对应项则函数返回NULL。update()函数可以插入一个新的key-value键值对或者更新一个已有的key-value键值对。从上面的代码可以看到,BPF代码中使用哈希表是一件非常容易的事情,无论是插入还是更新一个键值对。

在上面的例子中,一旦count被统计到哈希表中,我们就可以通过Python来处理这些数据。这可以通过BPF对象(例子中的b)来索引。由此会生成一个Python哈希表对象并通过items()函数来进行访问。这里需要注意的是Python BCC maps提供的函数集合与BPF maps是有区别的。

items()函数返回一个Python c_long类型的键值对并可以通过value成员来获取数值。下面的例子展示了如何迭代callers哈希表中所有的统计结果并打印调用了kmalloc()函数的相关内核函数(使用BCCBPF.ksym()将内核地址转换成对应的符号):

for k,v in sorted(b["callers"].items()):
	print ("%s %u" % (b.ksym(k.value), v.value))

上面程序的输出结果是:

# ./example.py
i915_sw_fence_await_dma_fence 4
intel_crtc_duplicate_state 4
SyS_memfd_create 1
drm_atomic_state_init 4
sg_kmalloc 7
intel_atomic_state_alloc 4
seq_open 504
SyS_bpf 22

上面的程序非常浅显,但真正的大型工具就不太容易理解了。开发者需要更多更复杂的调试工具。幸好BCC提供了很多简便的方法来进行调试。

Controlling BPF program compliation and loading

当Python BPF对象实例化的时候,对应的BPF程序代码会自动编译并加载到内核中。编译过程可以通过向BPF类的构造函数传递编译器参数cflags来进行控制。这些参数会被直接传递给Clang编译器,因此通常的编译选项都可以使用。比如使用“cflags=[‘-Wall’]”将所有编译器报警打开。

一个常见的cflags用法是用来传递宏定义。比如在xdp_drop_count.py脚本中静态分配了一个足够大的数组以便使用Python的多进程库:

clfags=["-DNUM_CPUS=%d" % multiprocessing.cpu_count()]

BPF类的构造函数还可以接受一系列调试参数。这些参数均可以独立打开并提供额外的编译或加载信息。比如DEBUG_BPF参数可以输出BPF字节码以便在“绝望”的情况下做最后的“挣扎”:

0: (79) r1 = *(u64 *)(r1 +8)
1: (7b) *(u64 *)(r10 -8) = r1
2: (b7) r1 = 1
3: (7b) *(u64 *)(r10 -16) = r1
4: (18) r1 = 0xffff8801a6098a00
6: (bf) r2 = r10
7: (07) r2 += -8
8: (85) call bpf_map_lookup_elem#1
9: (15) if r0 == 0x0 goto pc+3
 R0=map_value(id=0,off=0,ks=8,vs=8,imm=0) R10=fp0
10: (79) r1 = *(u64 *)(r0 +0)
 R0=map_value(id=0,off=0,ks=8,vs=8,imm=0) R10=fp0
11: (07) r1 += 1
12: (7b) *(u64 *)(r10 -16) = r1
13: (18) r1 = 0xffff8801a6098a00
15: (bf) r2 = r10
16: (07) r2 += -8
17: (bf) r3 = r10
18: (07) r3 += -16
19: (b7) r4 = 0
20: (85) call bpf_map_update_elem#2
21: (b7) r0 = 0
22: (95) exit

from 9 to 13: safe
processed 22 insns, stack depth 16

上面的输出直接来自于内核中Clang/LLVM执行的每条字节码指令和寄存器状态。如果上述信息还不足以排查问题还可以使用DEBUG_BPF_REGISTER_STATE参数来输出更详细的信息。

对于运行时调试最简便的方式是使用bpf_trace_printk()函数,一个类似printk()的函数,向/sys/kernel/debug/tracing/trace_pipe文件中写入信息。这些信息可以使用BPF.trace_print()处理。

上述方式的问题是所有信息都会汇总到一起,不方便信息的过滤。更好的方法是使用BPF_PERF_OUTPUT并使用open_perf_buffer()kprobe_poll()处理。详细的例子可以参考open_perf_buffer()函数。

Using BPF with applications

本期文章主要聚焦在通过BCC来调试内核tracepoint上,但实际上BCC也可以调试用户态tracepoint。下一期的文章将会详细介绍如何使用用户静态定义跟踪探针(User Statically-Defined Tracing, USDT)来调试程序。