bcc
原理
biolatency_bpf__attach(struct biolatency_bpf *obj) { return bpf_object__attach_skeleton(obj->skeleton); } static inline void biolatency_bpf__detach(struct biolatency_bpf *obj) { bpf_object__detach_skeleton(obj->skeleton); } static inline const void *biolatency_bpf__elf_bytes(size_t *sz); static inline int biolatency_bpf__create_skeleton(struct biolatency_bpf *obj) { struct bpf_object_skeleton *s; int err; s = (struct bpf_object_skeleton *)calloc(1, sizeof(*s)); if (!s) { err = -ENOMEM; goto err; } s->sz = sizeof(*s); s->name = "biolatency_bpf"; s->obj = &obj->obj; /* maps */ s->map_cnt = 5; s->map_skel_sz = sizeof(*s->maps); s->maps = (struct bpf_map_skeleton *)calloc(s->map_cnt, s->map_skel_sz); if (!s->maps) { err = -ENOMEM; goto err; } s->maps[0].name = "cgroup_map"; s->maps[0].map = &obj->maps.cgroup_map; s->maps[1].name = "start"; s->maps[1].map = &obj->maps.start; s->maps[2].name = "hists"; s->maps[2].map = &obj->maps.hists; s->maps[3].name = "biolaten.rodata"; s->maps[3].map = &obj->maps.rodata; s->maps[3].mmaped = (void **)&obj->rodata; s->maps[4].name = "biolaten.bss"; s->maps[4].map = &obj->maps.bss; s->maps[4].mmaped = (void **)&obj->bss; /* programs */ s->prog_cnt = 6; s->prog_skel_sz = sizeof(*s->progs); s->progs = (struct bpf_prog_skeleton *)calloc(s->prog_cnt, s->prog_skel_sz); if (!s->progs) { err = -ENOMEM; goto err; } s->progs[0].name = "block_rq_insert_btf"; s->progs[0].prog = &obj->progs.block_rq_insert_btf; s->progs[0].link = &obj->links.block_rq_insert_btf; s->progs[1].name = "block_rq_issue_btf"; s->progs[1].prog = &obj->progs.block_rq_issue_btf; s->progs[1].link = &obj->links.block_rq_issue_btf; s->progs[2].name = "block_rq_complete_btf"; s->progs[2].prog = &obj->progs.block_rq_complete_btf; s->progs[2].link = &obj->links.block_rq_complete_btf; s->progs[3].name = "block_rq_insert"; s->progs[3].prog = &obj->progs.block_rq_insert; s->progs[3].link = &obj->links.block_rq_insert; s->progs[4].name = "block_rq_issue"; s->progs[4].prog = &obj->progs.block_rq_issue; s->progs[4].link = &obj->links.block_rq_issue; s->progs[5].name = "block_rq_complete"; s->progs[5].prog = &obj->progs.block_rq_complete; s->progs[5].link = &obj->links.block_rq_complete; s->data = (void *)biolatency_bpf__elf_bytes(&s->data_sz); obj->skeleton = s; return 0; err: bpf_object__destroy_skeleton(s); return err; }
核心原理:三步测量延迟
biolatency 的核心思想是通过在内核中设置“起点”和“终点”两个检查点来测量 I/O 请求的耗时。
1. 起点:记录请求开始时间
2. 终点:记录请求完成时间,并计算差值
3. 聚合:将计算出的延迟时间存入直方图
这个过程完全由 biolatency.bpf.c 中的 eBPF 程序在内核态完成,效率极高。用户态程序 biolatency.c 只负责加载 eBPF 程序和读取最终的统计结果。
---
详细实现步骤
1. 如何得到内核数据:挂载追踪点 (Tracepoints)
eBPF 程序通过挂载到内核的追踪点(Tracepoint)来捕获 I/O 事件。追踪点是内核中预设的、稳定的探测点。biolatency 主要使用了以下三个追踪点:
* block_rq_issue: 当一个 I/O 请求被发送到块设备驱动时触发。这是计算延迟的默认起点。
* block_rq_insert: 当一个 I/O 请求被插入到 I/O 调度器队列时触发。如果用户使用了 -Q (queued)
参数,这个点会作为起点,这样测量的延迟就包含了请求在队列中等待的时间。
* block_rq_complete: 当一个 I/O 请求完成时触发。这是所有测量的终点。
在 biolatency.bpf.c 中,你可以看到如下代码,它们将 eBPF 函数挂载到这些追踪点上:
1 SEC("tp_btf/block_rq_issue")
2 int block_rq_issue_btf(u64 *ctx) { ... }
3
4 SEC("tp_btf/block_rq_insert")
5 int block_rq_insert_btf(u64 *ctx) { ... }
6
7 SEC("tp_btf/block_rq_complete")
8 int BPF_PROG(block_rq_complete_btf, struct request *rq, ...) { ... }
2. 如何计数延迟:利用 eBPF Map
eBPF 程序使用两种类型的 BPF Map 来存储和计算数据:
* `start` (BPF_MAP_TYPE_HASH): 这是一个哈希映射,用来临时存储一个 I/O 请求的开始时间。
* Key: struct request * (指向内核中 I/O 请求结构体的指针,是每个请求的唯一标识)。
* Value: u64 (纳秒级的时间戳)。
* `hists` (BPF_MAP_TYPE_HASH of HISTOGRAM): 这是一个哈希映射,其值是一个直方图结构体。它用来最终存储延迟分布。
* Key: struct hist_key (可以包含设备号、I/O 标志等,用于分类统计)。
* Value: struct hist (一个包含多个槽位的数组,每个槽位代表一个延迟范围的计数值)。
延迟计数流程如下:
1. 请求开始时 (触发 block_rq_issue 或 block_rq_insert):
* eBPF 程序获取当前的内核时间戳 (bpf_ktime_get_ns())。
* 它以 I/O 请求的指针 struct request *rq 为键,时间戳为值,存入 start 哈希映射中。
* 代码片段: bpf_map_update_elem(&start, &rq, &ts, 0);
2. 请求完成时 (触发 block_rq_complete):
* eBPF 程序再次获取 I/O 请求的指针 struct request *rq。
* 用这个指针作为键,去 start 哈希映射中查找对应的开始时间戳。
* 代码片段: tsp = bpf_map_lookup_elem(&start, &rq);
* 如果找到了开始时间戳,就计算延迟:delta = bpf_ktime_get_ns() - *tsp;。
* 计算完成后,将该请求的条目从 start 映射中删除,以防内存泄漏。
* 代码片段: bpf_map_delete_elem(&start, &rq);
3. 如何聚合数据:更新直方图
为了避免将海量的单个延迟数据发送到用户空间造成性能瓶颈,eBPF 程序在内核中就地完成了数据聚合。
1. 计算出延迟 delta (单位是纳秒) 后,程序会将其转换为微秒或毫秒,并计算它应该落入哪个直方图槽位 (slot)。通常使用 log2
来实现,这样可以有效地覆盖从几微秒到几秒的巨大范围。
* 代码片段: slot = log2l(delta);
2. 然后,程序更新 hists 映射中对应槽位的计数器,使其加一。
* 代码片段: __sync_fetch_and_add(&histp->slots[slot], 1);
4. 用户空间的角色
biolatency.c 中的用户空间程序相对简单:
1. 加载和附加:解析命令行参数,打开、加载 eBPF 对象文件 (.o),并将其中的程序附加到内核的追踪点上。
2. 循环读取:进入一个循环,定期 (sleep) 从内核的 hists BPF 映射中读取已经聚合好的直方图数据。
3. 显示结果:将直方图数据格式化并打印到终端上,形成我们看到的延迟分布图。
4. 清理:当程序退出时(例如按 Ctrl-C),它会负责分离 eBPF 程序并销毁 BPF 对象,释放所有资源。
总结
biolatency 的实现原理可以精炼为:
* 数据源:通过 eBPF 挂载到内核块设备层的追踪点,无侵入地获取 I/O 请求的开始和完成事件。
* 延迟计算:利用一个哈希映射 (`start`),以 I/O 请求指针为键,来配对开始和结束事件,并计算时间差。
* 数据聚合:在内核中直接使用一个直方图映射 (`hists`) 对延迟数据进行聚合,只将统计结果传递给用户空间。
* 用户接口:用户空间程序负责控制、参数配置
handle_block_rq_complete 函数并不是在 C 代码中被直接调用的,而是作为 eBPF 程序的一部分,在内核的特定事件发生时被触发执行。
具体来说,它被以下两个 eBPF 程序调用,这两个程序都附加到了内核的 block_rq_complete tracepoint(跟踪点)上:
block_rq_complete_btf(使用 BTF 的版本)block_rq_complete(使用原始 tracepoint 的版本)
您可以在 /smb/save/code/bcc/bcc/libbpf-tools/biolatency.bpf.c 文件的末尾找到这些调用点:
// ... existing code ...
SEC("tp_btf/block_rq_complete")
int BPF_PROG(block_rq_complete_btf, struct request *rq, int error, unsigned int nr_bytes)
{
return handle_block_rq_complete(rq, error, nr_bytes);
}
// ... existing code ...
SEC("raw_tp/block_rq_complete")
int BPF_PROG(block_rq_complete, struct request *rq, int error, unsigned int nr_bytes)
{
return handle_block_rq_complete(rq, error, nr_bytes);
}
char LICENSE[] SEC("license") = "GPL";
调用流程
-
当一个块设备(如硬盘或 SSD)完成了 I/O 操作(例如,读或写数据完成)时,Linux 内核会触发一个名为
block_rq_complete的内部事件。 -
biolatency工具的用户空间部分在启动时,会通过libbpf库将block_rq_complete_btf或block_rq_complete这两个 eBPF 程序中的一个附加(attach)到这个内核事件上。选择哪个版本取决于内核是否支持 BTF (BPF Type Format),BTF 提供了更丰富的类型信息,是更现代的方式。 -
一旦内核事件被触发,附加的 eBPF 程序就会被立即执行。
-
eBPF 程序(
block_rq_complete_btf或block_rq_complete)的唯一任务就是调用handle_block_rq_complete函数,并将内核传递给 tracepoint 的参数(如struct request *rq)原封不动地传递给它。
所以,handle_block_rq_complete 的调用是由内核事件驱动的,而不是由程序中的其他 C 函数直接调用的。这是 eBPF 工作模式的核心:将小型、高效的程序注入到内核的特定路径中,以响应内核事件。
浙公网安备 33010602011771号