如果覺得文章對你有所啟發,可以考慮用 🌟 支持 Gthulhu 專案,短期目標是集齊 300 個 🌟 藉此被 CNCF Landscape 採納 [ref]。
經過前面三篇文章的鋪墊,我們已經了解了 eBPF 的歷史、架構,並搭建好了完整的開發環境。現在終於到了最激動人心的時刻 —— 編寫第一個 eBPF 程式!
今天我們將從最簡單的 "Hello World" 開始,學習 eBPF 程式的基本結構、編譯流程,以及如何使用 Go 語言載入和管理 eBPF 程式。
一個 eBPF 程式從編寫到執行需要經歷以下步驟:
每個 eBPF 程式都包含:
// 1. 標頭檔包含
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
// 2. 程式定義(使用 SEC 宏標記)
SEC("program_type")
int program_function(struct context *ctx) {
// 程式邏輯
return 0;
}
// 3. 授權聲明(必需)
char _license[] SEC("license") = "GPL";
mkdir -p ~/ebpf-hello-world/{src/kernel,src/userspace,headers,bin}
cd ~/ebpf-hello-world
# 建立專案結構
tree
預期結構:
ebpf-hello-world/
├── src/
│ ├── kernel/
│ └── userspace/
├── headers/
├── bin/
├── Makefile
└── go.mod
建立我們的第一個 eBPF 程式:
// src/kernel/hello.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
// 定義一個字符映射,用於格式化輸出
char LICENSE[] SEC("license") = "GPL";
// 定義一個簡單的追蹤點程式
SEC("tp/syscalls/sys_enter_openat")
int trace_openat(struct trace_event_raw_sys_enter *ctx)
{
// 獲取當前程序的 PID 和 TID
u64 pid_tgid = bpf_get_current_pid_tgid();
u32 pid = pid_tgid >> 32;
u32 tid = pid_tgid & 0xffffffff;
// 獲取使用者 ID
u64 uid_gid = bpf_get_current_uid_gid();
u32 uid = uid_gid & 0xffffffff;
// 獲取程序名稱
char comm[16];
bpf_get_current_comm(&comm, sizeof(comm));
// 使用 bpf_printk 輸出資訊
bpf_printk("Hello from eBPF! PID=%d, TID=%d, UID=%d, COMM=%s\n",
pid, tid, uid, comm);
return 0;
}
// 定義一個更簡單的追蹤程式
SEC("tp/syscalls/sys_enter_write")
int trace_write(struct trace_event_raw_sys_enter *ctx)
{
// 簡單的計數輸出
static u64 count = 0;
count++;
bpf_printk("Write syscall #%llu\n", count);
return 0;
}
// 定義一個 kprobe 程式
SEC("kprobe/do_sys_openat2")
int kprobe_openat(struct pt_regs *ctx)
{
// 從暫存器獲取參數
int dfd = (int)PT_REGS_PARM1(ctx);
const char __user *filename = (const char __user *)PT_REGS_PARM2(ctx);
// 獲取檔案名(前 64 個字符)
char fname[64];
bpf_probe_read_user_str(fname, sizeof(fname), filename);
bpf_printk("Opening file: %s (dfd=%d)\n", fname, dfd);
return 0;
}
# 生成包含所有內核型別定義的標頭檔
bpftool btf dump file /sys/kernel/btf/vmlinux format c > headers/vmlinux.h
# 檢查檔案大小(應該很大,幾MB)
ls -lh headers/vmlinux.h
# Makefile
CLANG := clang
LLVM_STRIP := llvm-strip
BPFTOOL := bpftool
GO := go
# 編譯器設定
CLANG_BPF_SYS_INCLUDES := $(shell $(CLANG) -v -E - </dev/null 2>&1 \
| sed -n '/<...> search starts here:/,/End of search list./{ //!p }')
BPF_CFLAGS := -g -O2 -Wall -Werror -target bpf -D__TARGET_ARCH_x86 \
$(foreach dir,$(CLANG_BPF_SYS_INCLUDES),-idirafter $(dir))
# 目錄定義
KERNEL_SRC := src/kernel
USERSPACE_SRC := src/userspace
HEADERS := headers
# 目標檔案
BPF_OBJ := hello.bpf.o
USERSPACE_BIN := bin/hello
.PHONY: all clean vmlinux.h userspace kernel
all: vmlinux.h kernel userspace
# 生成 vmlinux.h
vmlinux.h:
@echo "生成 vmlinux.h..."
$(BPFTOOL) btf dump file /sys/kernel/btf/vmlinux format c > $(HEADERS)/vmlinux.h
# 編譯 eBPF 程式
kernel: $(BPF_OBJ)
$(BPF_OBJ): $(KERNEL_SRC)/hello.bpf.c | vmlinux.h
@echo "編譯 eBPF 程式..."
$(CLANG) $(BPF_CFLAGS) -I$(HEADERS) -c $< -o $@
$(LLVM_STRIP) -g $@
# 編譯使用者空間程式
userspace: $(USERSPACE_BIN)
$(USERSPACE_BIN): $(USERSPACE_SRC)/main.go | $(BPF_OBJ)
@echo "編譯使用者空間程式..."
mkdir -p bin
$(GO) build -o $@ $<
# 執行程式
run: all
sudo ./$(USERSPACE_BIN)
# 檢視 eBPF 程式資訊
info: $(BPF_OBJ)
$(BPFTOOL) prog load $(BPF_OBJ) /sys/fs/bpf/hello_prog
$(BPFTOOL) prog show pinned /sys/fs/bpf/hello_prog
sudo rm -f /sys/fs/bpf/hello_prog
# 清理
clean:
rm -f *.o $(USERSPACE_BIN) $(HEADERS)/vmlinux.h
# 初始化 Go 模組
init:
$(GO) mod init ebpf-hello-world
$(GO) get github.com/aquasecurity/libbpfgo@latest
# 幫助
help:
@echo "可用目標:"
@echo " all - 編譯所有程式"
@echo " kernel - 編譯 eBPF 程式"
@echo " userspace - 編譯使用者空間程式"
@echo " run - 執行程式"
@echo " info - 顯示 eBPF 程式資訊"
@echo " clean - 清理建置檔案"
@echo " init - 初始化 Go 模組"
// src/userspace/main.go
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
bpf "github.com/aquasecurity/libbpfgo"
)
func main() {
// 檢查 root 權限
if os.Geteuid() != 0 {
log.Fatal("此程式需要 root 權限執行")
}
// 載入 eBPF 程式
bpfModule, err := bpf.NewModuleFromFile("hello.bpf.o")
if err != nil {
log.Fatalf("載入 eBPF 模組失敗: %v", err)
}
defer bpfModule.Close()
// 載入程式到內核
if err := bpfModule.BPFLoadObject(); err != nil {
log.Fatalf("載入 eBPF 物件失敗: %v", err)
}
fmt.Println("✓ eBPF 程式載入成功")
// 獲取並附加追蹤點程式
progOpenat, err := bpfModule.GetProgram("trace_openat")
if err != nil {
log.Fatalf("獲取 trace_openat 程式失敗: %v", err)
}
progWrite, err := bpfModule.GetProgram("trace_write")
if err != nil {
log.Fatalf("獲取 trace_write 程式失敗: %v", err)
}
progKprobe, err := bpfModule.GetProgram("kprobe_openat")
if err != nil {
log.Fatalf("獲取 kprobe_openat 程式失敗: %v", err)
}
// 附加到追蹤點
linkOpenat, err := progOpenat.AttachTracepoint(&bpf.TracepointOpts{
Group: "syscalls",
Name: "sys_enter_openat",
})
if err != nil {
log.Fatalf("附加 openat 追蹤點失敗: %v", err)
}
defer linkOpenat.Close()
linkWrite, err := progWrite.AttachTracepoint(&bpf.TracepointOpts{
Group: "syscalls",
Name: "sys_enter_write",
})
if err != nil {
log.Fatalf("附加 write 追蹤點失敗: %v", err)
}
defer linkWrite.Close()
// 附加 kprobe
linkKprobe, err := progKprobe.AttachKprobe(&bpf.KprobeOpts{
Symbol: "do_sys_openat2",
})
if err != nil {
log.Fatalf("附加 kprobe 失敗: %v", err)
}
defer linkKprobe.Close()
fmt.Println("✓ 所有程式附加成功")
fmt.Println("正在追蹤系統呼叫...")
fmt.Println("請在另一個終端執行一些檔案操作,如:")
fmt.Println(" ls /tmp")
fmt.Println(" echo 'test' > /tmp/test.txt")
fmt.Println(" cat /tmp/test.txt")
fmt.Println()
fmt.Println("檢視輸出:sudo cat /sys/kernel/debug/tracing/trace_pipe")
fmt.Println("按 Ctrl+C 退出...")
// 設定信號處理
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// 定期輸出統計資訊
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
startTime := time.Now()
for {
select {
case <-ctx.Done():
return
case sig := <-sigChan:
fmt.Printf("\n收到信號 %v,正在退出...\n", sig)
cancel()
case <-ticker.C:
uptime := time.Since(startTime)
fmt.Printf("程式運行時間: %v\n", uptime.Round(time.Second))
}
}
}
# 初始化 Go 模組
make init
# 建置所有組件
make all
# 檢查建置結果
ls -la *.o bin/
# 執行 Hello World 程式(需要 root 權限)
sudo make run
在另一個終端中,執行一些檔案操作來觸發追蹤:
# 在另一個終端執行
ls /tmp
echo "Hello eBPF!" > /tmp/test.txt
cat /tmp/test.txt
rm /tmp/test.txt
在第三個終端中觀察 eBPF 程式的輸出:
# 觀察追蹤輸出
sudo cat /sys/kernel/debug/tracing/trace_pipe
你應該看到類似這樣的輸出:
bash-1234 [001] .... 12345.678901: bpf_trace_printk: Hello from eBPF! PID=1234, TID=1234, UID=1000, COMM=bash
bash-1234 [001] .... 12345.678902: bpf_trace_printk: Opening file: /tmp/test.txt (dfd=-100)
cat-1235 [002] .... 12345.678903: bpf_trace_printk: Write syscall #1
cat-1235 [002] .... 12345.678904: bpf_trace_printk: Hello from eBPF! PID=1235, TID=1235, UID=1000, COMM=cat
SEC("tp/syscalls/sys_enter_openat")
SEC() 宏告訴編譯器和載入器:
syscalls/sys_enter_openat
追蹤點不同類型的 eBPF 程式有不同的 Context:
// 追蹤點程式的 Context
struct trace_event_raw_sys_enter *ctx
// kprobe 程式的 Context
struct pt_regs *ctx
// XDP 程式的 Context
struct xdp_md *ctx
我們使用了幾個重要的 helper 函數:
// 獲取程序 ID 和線程 ID
u64 pid_tgid = bpf_get_current_pid_tgid();
// 獲取程序名稱
bpf_get_current_comm(&comm, sizeof(comm));
// 讀取使用者空間字串
bpf_probe_read_user_str(fname, sizeof(fname), filename);
// 輸出除錯資訊
bpf_printk("格式化字串", 參數...);
Go 程式的主要職責:
讓我們改進程式,新增一個 Map 來統計檔案操作:
// src/kernel/hello_with_maps.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
// 定義一個 Hash Map 來統計檔案操作
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10000);
__type(key, u32); // PID
__type(value, u64); // 計數
} file_ops_count SEC(".maps");
// 定義一個陣列 Map 來儲存統計資訊
struct file_stats {
u64 total_opens;
u64 total_writes;
u64 total_reads;
};
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 1);
__type(key, u32);
__type(value, struct file_stats);
} global_stats SEC(".maps");
char LICENSE[] SEC("license") = "GPL";
SEC("tp/syscalls/sys_enter_openat")
int trace_openat_with_stats(struct trace_event_raw_sys_enter *ctx)
{
u32 pid = bpf_get_current_pid_tgid() >> 32;
u64 *count;
// 更新每個程序的計數
count = bpf_map_lookup_elem(&file_ops_count, &pid);
if (count) {
__sync_fetch_and_add(count, 1);
} else {
u64 initial_count = 1;
bpf_map_update_elem(&file_ops_count, &pid, &initial_count, BPF_ANY);
}
// 更新全局統計
u32 key = 0;
struct file_stats *stats = bpf_map_lookup_elem(&global_stats, &key);
if (stats) {
__sync_fetch_and_add(&stats->total_opens, 1);
} else {
struct file_stats initial_stats = {1, 0, 0};
bpf_map_update_elem(&global_stats, &key, &initial_stats, BPF_ANY);
}
// 獲取檔案名稱並輸出
char comm[16];
bpf_get_current_comm(&comm, sizeof(comm));
bpf_printk("PID %d (%s) opened file, total opens: %llu\n",
pid, comm, stats ? stats->total_opens : 1);
return 0;
}
對應的 Go 程式讀取 Map 資料:
// src/userspace/main_with_maps.go
package main
import (
"fmt"
"log"
"os"
"time"
"unsafe"
bpf "github.com/aquasecurity/libbpfgo"
)
type FileStats struct {
TotalOpens uint64
TotalWrites uint64
TotalReads uint64
}
func main() {
if os.Geteuid() != 0 {
log.Fatal("需要 root 權限")
}
// 載入程式
bpfModule, err := bpf.NewModuleFromFile("hello_with_maps.bpf.o")
if err != nil {
log.Fatalf("載入失敗: %v", err)
}
defer bpfModule.Close()
if err := bpfModule.BPFLoadObject(); err != nil {
log.Fatalf("載入物件失敗: %v", err)
}
// 附加程式
prog, err := bpfModule.GetProgram("trace_openat_with_stats")
if err != nil {
log.Fatalf("獲取程式失敗: %v", err)
}
link, err := prog.AttachTracepoint(&bpf.TracepointOpts{
Group: "syscalls",
Name: "sys_enter_openat",
})
if err != nil {
log.Fatalf("附加失敗: %v", err)
}
defer link.Close()
// 獲取 Maps
fileOpsMap, err := bpfModule.GetMap("file_ops_count")
if err != nil {
log.Fatalf("獲取 map 失敗: %v", err)
}
globalStatsMap, err := bpfModule.GetMap("global_stats")
if err != nil {
log.Fatalf("獲取 stats map 失敗: %v", err)
}
fmt.Println("eBPF 程式啟動,監控檔案操作...")
// 定期讀取統計資訊
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
fmt.Println("\n=== 統計資訊 ===")
// 讀取全局統計
key := uint32(0)
statsBytes, err := globalStatsMap.GetValue(unsafe.Pointer(&key))
if err == nil && len(statsBytes) == int(unsafe.Sizeof(FileStats{})) {
stats := (*FileStats)(unsafe.Pointer(&statsBytes[0]))
fmt.Printf("總檔案開啟次數: %d\n", stats.TotalOpens)
}
// 讀取前幾個程序的統計
fmt.Println("\n各程序檔案操作次數:")
iterator := fileOpsMap.Iterator()
count := 0
for iterator.Next() && count < 5 {
keyBytes := iterator.Key()
valueBytes, _ := iterator.Value()
if len(keyBytes) == 4 && len(valueBytes) == 8 {
pid := *(*uint32)(unsafe.Pointer(&keyBytes[0]))
ops := *(*uint64)(unsafe.Pointer(&valueBytes[0]))
fmt.Printf(" PID %d: %d 次操作\n", pid, ops)
count++
}
}
}
}
}
我們可以新增過濾,只監控特定的程序:
// 只監控特定程序名稱
SEC("tp/syscalls/sys_enter_openat")
int trace_filtered_openat(struct trace_event_raw_sys_enter *ctx)
{
char comm[16];
bpf_get_current_comm(&comm, sizeof(comm));
// 只監控 bash 和 cat
if (comm[0] == 'b' && comm[1] == 'a' && comm[2] == 's' && comm[3] == 'h') {
// bash
} else if (comm[0] == 'c' && comm[1] == 'a' && comm[2] == 't') {
// cat
} else {
return 0; // 忽略其他程序
}
bpf_printk("Filtered: %s opened a file\n", comm);
return 0;
}
# 解決方法:重新生成
make vmlinux.h
# 檢查 libbpf 版本
pkg-config --modversion libbpf
# 更新到最新版本
sudo apt update && sudo apt upgrade libbpf-dev
# 檢查內核支援
grep CONFIG_BPF /boot/config-$(uname -r)
# 檢查權限
sudo dmesg | grep bpf
# 啟用詳細 verifier 日誌
echo 2 | sudo tee /proc/sys/kernel/bpf_verbosity
# 檢視載入的程式
sudo bpftool prog list
# 檢視程式詳細資訊
sudo bpftool prog show id <ID> --pretty
# 反組譯程式
sudo bpftool prog dump xlated id <ID>
今天我們成功編寫並執行了第一個 eBPF 程式!我們學習了:
這個 Hello World 程式為我們後續的學習奠定了基礎。在下一篇文章中,我們將深入探討 eBPF Maps,學習如何在內核和使用者空間之間高效地共享資料。