结合例子学eBPF和bcc:更好的输出机制
引
如果有这样一个需求:抓取某个系统调用对应的参数,你会如何实现呢?
ChatGPT这样回答:要抓取某个系统调用的参数,您可以使用strace工具。Strace是一个跟踪系统调用和信号的工具,可以帮助您查看应用程序与操作系统之间的交互。
那如果是获取磁盘I/O的情况呢?
这是《结合例子学习eBPF和bcc》系列的第二篇文章。本文将介绍如何获取追踪目标函数的参数信息以及如何更好的进行结果输出。
disksnoop
disksnoop可以用来追踪磁盘I/O情况,执行如下:

我们来看看disksnoop的代码,看看是如何实现的。
首先,定义了一个常量,这个常量和内核有关:
REQ_WRITE = 1 # from include/linux/blk_types.h
接着让我们来看看eBPF相关的C代码:

可以看到其中有两个处理函数,trace_start在请求开始时调用,记录下当前请求的时间。这里的struct request *req是磁盘请求函数blk_start_request的参数,也即我们可以通过这种方式获取到blk_start_request的参数。而trace_completion则是在磁盘请求结束时调用,负责计算每个请求的延时,并输出到用户态。可以看到在这里输出的时候我们使用了req指针中的内容:

这里req指针的内容和内核有关,我们可以在内核中找到这两个变量的定义:
struct request {
unsigned int cmd_flags; /* op and common flags */
/* the following two fields are internal, NEVER access directly */
unsigned int __data_len; /* total data len */
// ....
}
这里bcc将解引用重写成了bpf_probe_read_kernel调用,有时候解引用会很复杂,需要我们手动调用。
值得注意的是,这里我们用struct request *req作为哈希表start的key是很有用的,因为两个结构体不会有相同的指针地址,所以相对而言能够减少重复的情况,只要我们能够做好指针释放和重新使用的管理。
接下来我们需要将这两个函数挂载到不通的挂载点上来实现我们的追踪:

这里get_kprobe_functions()函数是用于获取内核中所有可用的kprobe探测函数的函数。
blk_start_request()和blk_mq_start_request()都是用于启动块设备请求的函数,但它们是在不同的I/O路径上使用的。
blk_start_request()是传统的I/O路径上使用的函数,而blk_mq_start_request()是多队列I/O路径上使用的函数。blk_mq_start_request()是通过多队列I/O路径来提高I/O性能的,它可以同时处理多个请求,从而提高了系统的吞吐量和响应速度。
在Linux内核4.14版本之前,blk_start_request()是唯一可用的启动块设备请求的函数。但是,在Linux内核4.14版本中,多队列I/O路径被引入并成为默认的I/O路径,blk_mq_start_request()取代了blk_start_request()成为了启动块设备请求的主要函数。
__blk_account_io_done和blk_account_io_done也一样,前者作为传统的路径,后者是新版本路径。
程序整体的执行过程如下:

最后,我们就是通过trace_fields来获取数据并输出即可:

hello_perf_output
在以前我们曾说过,bpf_trace_printk并不适合正式的使用,因此,在这里我们来学习一下BPF_PERF_OUTPUT接口,这也标志着我们不能再通过trace_fields来获取数据了。我们直接从代码看起:

可以看到整个函数的想法很简单:抓取数据,填充到结构体中,然后放到BPF_PERF_OUTPUT构建的通道中基于perf ring buffer传输到用户态。
那么这里传输的数据是我们定义的结构体,也即:

我们看看如何接收的:

可以看到,C程序中的perf_submit是一个类似生产者的角色,将数据丢到perf ring buffers中;perf_buffer_poll则是消费者的角色,将对应通道的数据从其中取出来(open_perf_buffer)并给到对应的回调函数(print_event)上:

我们发现在回调函数print_event中,还有cpu、data、size三个参数,这三个参数是默认传入的参数。
perf_submit的原型如下:
int perf_submit((void *)ctx, (void *)data, u32 data_size)
ctx我们可以先认为是默认参数,data则是数据的地址,后者是数据的大小。
sync_perf_output
现在让我们用BPF_PERF_OUTPUT来改写一下sync_timing。
首先明确需求:当在一秒钟内出现两次sync操作时,输出发生距离启动的时间和两次sync的时间。整体的逻辑是没有什么太大的改变的,我们只需要修改输出的流程就可以了。
首先我们定义一个叫做output的通道:
BPF_PERF_OUTPUT(output);
接着可以定义一个结构体来记录时间信息:
struct data_t {
u64 ms; // delta
u64 ts; // 总时间
};
C代码中主要的逻辑无需改变,只要改变输出的部分就可以:

接着我们修改bcc的代码,首先是回调函数:

接着接上通道并轮询就可以了:

为了验证效果,我们将sync_timing和sync_perf_output一起运行,看看结果会怎么样:

只能说:完美。
sync_perf_output

小结
今天我们学习到了如下的知识点:
- 通过往
eBPF挂载函数中添加参数可以捕获对应函数的参数; - 用指针的地址作为
BPF的HASH的key很有效; BPF_PERF_OUTPUT是很好的输出方法,通过perf ring buffers进行输出,bcc前端需要用perf_buffer_poll来轮询这块区域,并通过open_perf_buffer(callback)来捕获对应的数据并传给callback函数调用;
下次再见!
关注公众号:程栩的性能优化笔记,了解更多性能优化知识。