iT邦幫忙

2025 iThome 鐵人賽

DAY 5
0
Cloud Native

30 篇文帶你用 eBPF 與 Golang 打造 Linux Scheduler系列 第 5

第一個 eBPF 程式:Hello World

  • 分享至 

  • xImage
  •  

如果覺得文章對你有所啟發,可以考慮用 🌟 支持 Gthulhu 專案,短期目標是集齊 300 個 🌟 藉此被 CNCF Landscape 採納 [ref]

前言

經過前面三篇文章的鋪墊,我們已經了解了 eBPF 的歷史、架構,並搭建好了完整的開發環境。現在終於到了最激動人心的時刻 —— 編寫第一個 eBPF 程式!

今天我們將從最簡單的 "Hello World" 開始,學習 eBPF 程式的基本結構、編譯流程,以及如何使用 Go 語言載入和管理 eBPF 程式。

理論基礎

eBPF 程式的生命週期

一個 eBPF 程式從編寫到執行需要經歷以下步驟:

  1. 編寫:使用 C 語言編寫 eBPF 程式
  2. 編譯:使用 Clang 編譯為 eBPF 字節碼
  3. 載入:透過系統呼叫載入到內核
  4. 驗證:內核 Verifier 檢查程式安全性
  5. JIT 編譯:編譯為原生機器碼
  6. 附加:附加到特定的 Hook 點
  7. 執行:響應系統事件執行程式
  8. 卸載:程式生命週期結束時清理

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";

實作演練:Hello World 程式

第一步:建立專案結構

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 程式

建立我們的第一個 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;
}

第三步:生成 vmlinux.h

# 生成包含所有內核型別定義的標頭檔
bpftool btf dump file /sys/kernel/btf/vmlinux format c > headers/vmlinux.h

# 檢查檔案大小(應該很大,幾MB)
ls -lh headers/vmlinux.h

第四步:建立 Makefile

# 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 模組"

第五步:編寫 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

深度分析:Hello World 程式的工作原理

1. SEC() 宏的作用

SEC("tp/syscalls/sys_enter_openat")

SEC() 宏告訴編譯器和載入器:

  • 這是一個 eBPF 程式
  • 程式類型是追蹤點(tracepoint)
  • 要附加到 syscalls/sys_enter_openat 追蹤點

2. Context 結構

不同類型的 eBPF 程式有不同的 Context:

// 追蹤點程式的 Context
struct trace_event_raw_sys_enter *ctx

// kprobe 程式的 Context  
struct pt_regs *ctx

// XDP 程式的 Context
struct xdp_md *ctx

3. Helper 函數的使用

我們使用了幾個重要的 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("格式化字串", 參數...);

4. Go 載入器的工作

Go 程式的主要職責:

  1. 載入模組:讀取 .o 檔案
  2. 載入物件:呼叫 BPF 系統呼叫
  3. 獲取程式:找到特定的 eBPF 程式
  4. 附加程式:將程式附加到 Hook 點
  5. 管理生命週期:處理信號和清理

實戰應用:改進和擴展

1. 新增 Maps 支援

讓我們改進程式,新增一個 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++
                }
            }
        }
    }
}

2. 新增過濾機制

我們可以新增過濾,只監控特定的程序:

// 只監控特定程序名稱
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;
}

疑難排解指南

常見編譯錯誤

  1. 找不到 vmlinux.h
# 解決方法:重新生成
make vmlinux.h
  1. BPF helper 函數未定義
# 檢查 libbpf 版本
pkg-config --modversion libbpf

# 更新到最新版本
sudo apt update && sudo apt upgrade libbpf-dev
  1. 載入失敗
# 檢查內核支援
grep CONFIG_BPF /boot/config-$(uname -r)

# 檢查權限
sudo dmesg | grep bpf

除錯技巧

  1. 檢視 Verifier 日誌
# 啟用詳細 verifier 日誌
echo 2 | sudo tee /proc/sys/kernel/bpf_verbosity
  1. 使用 bpftool 除錯
# 檢視載入的程式
sudo bpftool prog list

# 檢視程式詳細資訊
sudo bpftool prog show id <ID> --pretty

# 反組譯程式
sudo bpftool prog dump xlated id <ID>

總結

今天我們成功編寫並執行了第一個 eBPF 程式!我們學習了:

  1. eBPF 程式結構:SEC() 宏、helper 函數、授權聲明
  2. 編譯流程:從 C 源碼到 eBPF 字節碼
  3. Go 載入器:使用 libbpfgo 管理 eBPF 程式
  4. 實際應用:系統呼叫追蹤和統計
  5. 進階特性:Maps 的使用和資料共享

這個 Hello World 程式為我們後續的學習奠定了基礎。在下一篇文章中,我們將深入探討 eBPF Maps,學習如何在內核和使用者空間之間高效地共享資料。


上一篇
開發環境搭建與工具鏈介紹
系列文
30 篇文帶你用 eBPF 與 Golang 打造 Linux Scheduler5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言