BTF và CO-RE: Một Lần Biên Dịch, Chạy Mọi Kernel

K
Kai··4 min read

Ở Bài 0, mỗi chương trình mang một btf_id. Ở Bài 3, ta khai map bằng cú pháp BTF. Giờ là lúc nói thẳng BTF là gì, và vì sao nó cho phép một chương trình eBPF biên dịch sẵn đọc đúng dữ liệu bên trong nhân — dù layout của nhân đổi theo từng phiên bản. Đây là mảnh nền cuối của Part I, và là thứ làm cho việc tự viết công cụ ở Part III khả thi.

Vấn đề: layout nhân không cố định

Một chương trình tracing thường cần đọc cấu trúc nội bộ của nhân — ví dụ task_struct (mô tả một tiến trình) để lấy pid của tiến trình cha. Nhưng task_structhàng trăm field, và thứ tự/offset của chúng khác nhau giữa các bản kernel: field real_parent ở offset 2680 trên kernel này có thể ở offset khác trên kernel kia. Nếu hardcode offset, chương trình chỉ chạy đúng trên một bản kernel — biên dịch lại cho mỗi bản là cực hình vận hành.

BTF: nhân tự mô tả kiểu của mình

BTF (BPF Type Format) là metadata mô tả kiểu dữ liệu — "thông tin debug về kiểu, struct, layout field", như debug-info nhưng nhân hiểu được. Kernel build sẵn BTF của chính nó và phơi ra:

ls -la /sys/kernel/btf/vmlinux
7005028 /sys/kernel/btf/vmlinux      # ~7MB: mô tả MỌI kiểu trong kernel này

Đây là "bản thiết kế" đầy đủ của các struct nhân đúng cho kernel đang chạy. Từ nó, sinh ra vmlinux.h — header chứa định nghĩa mọi kiểu nhân, để chương trình eBPF include thay vì lục các header kernel:

sudo bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
wc -l vmlinux.h ; grep -c 'struct task_struct {' vmlinux.h
165625      # 165k dòng định nghĩa kiểu
1           # có định nghĩa task_struct

CO-RE: biên dịch một lần, chạy mọi kernel

CO-RE (Compile Once Run Everywhere) dùng BTF để giải bài toán offset. Viết một chương trình đọc ppid bằng cách lần task->real_parent->tgid:

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>

SEC("tracepoint/sched/sched_process_exec")
int on_exec(void *ctx)
{
    struct task_struct *task = (struct task_struct *)bpf_get_current_task();
    __u32 ppid = BPF_CORE_READ(task, real_parent, tgid);   // <- CO-RE relocation
    char comm[16];
    bpf_get_current_comm(comm, sizeof(comm));
    bpf_printk("exec %s ppid=%d", comm, ppid);
    return 0;
}

BPF_CORE_READ(task, real_parent, tgid) không hardcode offset. Khi biên dịch, clang ghi vào object một CO-RE relocation: "tôi cần field real_parent của task_struct, rồi field tgid của cái đó". Biên dịch chỉ cần vmlinux.h, không cần header kernel:

clang -O2 -g -target bpf -I/tmp -c ppid.bpf.c -o ppid.bpf.o

Khi nạp, libbpf đọc BTF của kernel đang chạy (/sys/kernel/btf/vmlinux), tra offset thật của real_parenttgid trên kernel này, rồi vá vào chương trình trước khi verifier chạy. Nạp và thử:

sudo bpftool prog loadall ppid.bpf.o /sys/fs/bpf/ppid autoattach
sudo timeout 2 cat /sys/kernel/debug/tracing/trace_pipe | grep 'exec .* ppid='
   sudo-357811    [001] ....1 47863.911994: bpf_trace_printk: exec sudo ppid=357804
   sleep-357812   [000] ....1 47863.911994: bpf_trace_printk: exec sleep ppid=357810
   grep-357813    [001] ....1 47863.912715: bpf_trace_printk: exec grep ppid=357804
   timeout-357814 [000] ....1 47863.920067: bpf_trace_printk: exec timeout ppid=357811

Chương trình đọc đúng real_parent->tgidsleep có cha 357810, grepsudo cùng cha 357804 (cái shell). Nó lần qua hai tầng con trỏ vào sâu trong task_structkhông hề biết offset lúc viết: libbpf điền offset đúng cho kernel 6.17 này lúc nạp. Cùng file ppid.bpf.o đó, mang sang một máy chạy kernel khác — nơi real_parent nằm ở offset khác — vẫn chạy đúng, vì libbpf relocate lại theo BTF của máy kia. Đó là ý nghĩa "compile once, run everywhere".

(Một chi tiết liên quan: BPF_CORE_READ dùng helper đọc bộ nhớ nhân an toàn chứ không deref thẳng — verifier ở Bài 2 không cho deref con trỏ nhân tùy tiện; CO-RE lo cả offset lẫn cách đọc an toàn.)

🧹 Dọn dẹp

sudo rm -rf /sys/fs/bpf/ppid
rm -f /tmp/ppid.bpf.* /tmp/vmlinux.h

Gỡ pin là xong; node về 140 chương trình. Mã nguồn ở github.com/nghiadaulau/ebpf-from-scratch, thư mục 05-btf-core.

Tổng kết

Struct nhân như task_struct có offset field khác nhau giữa các bản kernel, nên một chương trình hardcode offset chỉ chạy đúng một bản. BTF là metadata mô tả mọi kiểu của nhân, kernel phơi sẵn ở /sys/kernel/btf/vmlinux (~7MB), và bpftool btf dump ... format c sinh ra vmlinux.h (165k dòng, có task_struct) để chương trình include. CO-RE dựa trên BTF: BPF_CORE_READ(task, real_parent, tgid) ghi một relocation lúc biên dịch ("cần field này") thay vì offset cố định; lúc nạp, libbpf đọc BTF của kernel đang chạy, tìm offset thật và vá vào. Ta đọc được ppid xuyên hai tầng con trỏ vào task_struct mà không biết offset lúc viết — và cùng binary đó chạy được trên kernel khác. Đây là lý do công cụ eBPF hiện đại (bcc-libbpf, Cilium, Tetragon) phân phối một binary chạy mọi nơi.

Part I khép lại: ta đã có máy ảo (Bài 1), verifier (Bài 2), maps (Bài 3), program type & hook (Bài 4), và BTF/CO-RE (bài này) — đủ bộ khung để hiểu bất kỳ chương trình eBPF. Part II bước vào dùng nó để quan sát hệ thống một cách thực dụng với bpftrace: viết chương trình tracing trong một dòng, không cần biên dịch C, để trả lời những câu hỏi thật về nhân.

Related Posts