iT邦幫忙

2025 iThome 鐵人賽

DAY 3
0

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

前言

在上一篇文章中,我們回顧了 eBPF 的誕生與演進史,了解了從 Classic BPF 到 eBPF 的技術發展脈絡。今天,我們將深入 eBPF 的核心架構,探討它的虛擬機器設計、指令集架構、Verifier 機制以及 JIT 編譯器的工作原理。

理解 eBPF 的架構設計對於後續的程式開發至關重要。只有掌握了這些底層機制,我們才能寫出高效、安全的 eBPF 程式。

理論基礎

eBPF 虛擬機器架構

eBPF 虛擬機器是一個 64 位的 RISC(精簡指令集)架構,專門設計用於在 Linux 內核中安全高效地執行使用者程式碼。

暫存器設計

eBPF VM 包含 11 個 64 位暫存器:

// eBPF 暫存器定義
enum {
    BPF_REG_0 = 0,    // 返回值暫存器
    BPF_REG_1,        // 函數參數 1 / 程式 context
    BPF_REG_2,        // 函數參數 2
    BPF_REG_3,        // 函數參數 3
    BPF_REG_4,        // 函數參數 4
    BPF_REG_5,        // 函數參數 5
    BPF_REG_6,        // 被呼叫者保存暫存器
    BPF_REG_7,        // 被呼叫者保存暫存器
    BPF_REG_8,        // 被呼叫者保存暫存器
    BPF_REG_9,        // 被呼叫者保存暫存器
    BPF_REG_10,       // 堆疊指標(Read Only)
    __MAX_BPF_REG,
};

每個暫存器都有特定的用途:

  • R0:存放函數返回值和程式退出碼
  • R1-R5:函數參數傳遞
  • R6-R9:被呼叫者保存暫存器(callee-saved)
  • R10:堆疊指標,指向 512 字節的堆疊空間

記憶體模型

eBPF 程式的記憶體空間分為幾個部分:

  1. 程式記憶體:存放 eBPF 指令
  2. 堆疊記憶體:512 字節的堆疊空間
  3. Map 記憶體:透過 BPF Maps 存取的共享記憶體
  4. Context 記憶體:程式輸入參數(如封包資料)

eBPF 指令集架構

指令格式

每個 eBPF 指令都是 64 位(8 字節),格式如下:

struct bpf_insn {
    __u8    code;     // 操作碼
    __u8    dst_reg:4; // 目標暫存器
    __u8    src_reg:4; // 來源暫存器
    __s16   off;      // 偏移量
    __s32   imm;      // 立即數
};

指令分類

eBPF 指令可以分為以下幾類:

1. 算術指令

// 64 位加法:dst_reg += src_reg
BPF_ALU64 | BPF_ADD | BPF_X

// 32 位加法:dst_reg += imm
BPF_ALU | BPF_ADD | BPF_K

// 示例:計算兩個數的和
BPF_MOV64_IMM(BPF_REG_1, 10),        // r1 = 10
BPF_MOV64_IMM(BPF_REG_2, 20),        // r2 = 20
BPF_ALU64_REG(BPF_ADD, BPF_REG_1, BPF_REG_2), // r1 += r2

2. 跳躍指令

// 無條件跳躍
BPF_JMP | BPF_JA

// 條件跳躍:if (dst_reg == src_reg) goto pc + off
BPF_JMP | BPF_JEQ | BPF_X

// 示例:簡單的條件判斷
BPF_MOV64_IMM(BPF_REG_1, 42),        // r1 = 42
BPF_JMP_IMM(BPF_JEQ, BPF_REG_1, 42, 2), // if r1 == 42 goto +2
BPF_MOV64_IMM(BPF_REG_0, 0),         // r0 = 0 (false case)
BPF_EXIT_INSN(),                      // exit
BPF_MOV64_IMM(BPF_REG_0, 1),         // r0 = 1 (true case)
BPF_EXIT_INSN(),                      // exit

3. 記憶體指令

// 載入指令:dst_reg = *(size *)(src_reg + off)
BPF_MEM | BPF_LDX | BPF_W  // 載入 32 位
BPF_MEM | BPF_LDX | BPF_DW // 載入 64 位

// 儲存指令:*(size *)(dst_reg + off) = src_reg
BPF_MEM | BPF_STX | BPF_W  // 儲存 32 位
BPF_MEM | BPF_STX | BPF_DW // 儲存 64 位

// 示例:存取堆疊變數
BPF_MOV64_IMM(BPF_REG_1, 123),       // r1 = 123
BPF_STX_MEM(BPF_DW, BPF_REG_10, BPF_REG_1, -8), // *(u64*)(r10-8) = r1
BPF_LDX_MEM(BPF_DW, BPF_REG_2, BPF_REG_10, -8), // r2 = *(u64*)(r10-8)

4. 函數呼叫指令

// 呼叫 helper 函數
BPF_JMP | BPF_CALL

// 示例:呼叫 bpf_printk
BPF_LD_MAP_FD(BPF_REG_1, fmt_map_fd), // r1 = format string
BPF_MOV64_IMM(BPF_REG_2, 42),         // r2 = 42
BPF_CALL_FUNC(BPF_FUNC_trace_printk), // call bpf_trace_printk(r1, r2)

eBPF Verifier 機制深度解析

Verifier 是 eBPF 安全性的核心,它在程式載入時進行靜態分析,確保程式不會對系統造成危害。

Verifier 的工作流程

// 簡化的 Verifier 檢查流程
int bpf_check(struct bpf_prog **prog, union bpf_attr *attr)
{
    struct bpf_verifier_env *env;
    int ret = -EINVAL;
    
    // 1. 初始化驗證環境
    env = kzalloc(sizeof(struct bpf_verifier_env), GFP_KERNEL);
    
    // 2. 基本格式檢查
    ret = check_cfg(env);
    if (ret < 0)
        goto err_free_env;
    
    // 3. 指令層級檢查
    ret = do_check(env);
    if (ret < 0)
        goto err_free_env;
    
    // 4. 特權操作檢查
    ret = check_map_access(env);
    if (ret < 0)
        goto err_free_env;
    
    // 5. 最終最佳化
    ret = convert_pseudo_ld_imm64(env);
    
err_free_env:
    kfree(env);
    return ret;
}

關鍵檢查項目

1. 控制流程檢查

Verifier 會檢查程式的所有執行路徑:

// 檢查無限迴圈
static int check_cfg(struct bpf_verifier_env *env)
{
    int insn_cnt = env->prog->len;
    bool *visited;
    int ret = 0;
    int i;
    
    visited = kcalloc(insn_cnt, sizeof(bool), GFP_KERNEL);
    if (!visited)
        return -ENOMEM;
    
    // 深度優先搜尋,檢查所有路徑
    ret = visit_insn(env, 0, visited);
    
    // 檢查是否有未訪問的指令(dead code)
    for (i = 0; i < insn_cnt; i++) {
        if (!visited[i]) {
            verbose(env, "unreachable insn %d\n", i);
            ret = -EINVAL;
            break;
        }
    }
    
    kfree(visited);
    return ret;
}

2. 記憶體存取檢查

// 檢查記憶體存取的安全性
static int check_mem_access(struct bpf_verifier_env *env,
                           int regno, int off, int size,
                           enum bpf_access_type type)
{
    struct bpf_reg_state *reg = &env->cur_state.regs[regno];
    
    // 檢查暫存器類型
    if (reg->type != PTR_TO_STACK &&
        reg->type != PTR_TO_MAP_VALUE &&
        reg->type != PTR_TO_CTX) {
        verbose(env, "invalid read from register\n");
        return -EACCES;
    }
    
    // 檢查存取邊界
    if (off < reg->min_value || off + size > reg->max_value) {
        verbose(env, "invalid memory access\n");
        return -EACCES;
    }
    
    // 檢查對齊
    if (off % size != 0) {
        verbose(env, "misaligned memory access\n");
        return -EACCES;
    }
    
    return 0;
}

3. 暫存器狀態追蹤

Verifier 會追蹤每個暫存器在程式執行過程中的狀態:

struct bpf_reg_state {
    enum bpf_reg_type type;    // 暫存器類型
    s32 min_value;             // 最小可能值
    u32 max_value;             // 最大可能值
    u32 id;                    // 暫存器 ID
    u32 ref_obj_id;            // 引用物件 ID
    struct tnum var_off;       // 變數偏移
};

// 更新暫存器狀態
static void mark_reg_known(struct bpf_reg_state *reg, u64 imm)
{
    reg->min_value = imm;
    reg->max_value = imm;
    reg->var_off = tnum_const(imm);
    reg->type = SCALAR_VALUE;
}

JIT 編譯器原理

通過 Verifier 檢查的 eBPF 程式會被 JIT 編譯器編譯成原生機器碼。

JIT 編譯流程

// x86_64 JIT 編譯器的簡化流程
struct bpf_prog *bpf_int_jit_compile(struct bpf_prog *prog)
{
    struct bpf_binary_header *header;
    struct x64_jit_data *jit_data;
    int proglen, oldproglen = 0;
    u8 *image = NULL;
    int pass;
    
    // 多次遍歷最佳化
    for (pass = 0; pass < 20; pass++) {
        proglen = do_jit(prog, NULL, 0, &jit_data);
        
        if (proglen <= 0) {
            image = NULL;
            goto out;
        }
        
        // 如果程式大小穩定,分配記憶體並生成程式碼
        if (proglen == oldproglen) {
            header = bpf_jit_binary_alloc(proglen, &image,
                                        1, jit_fill_hole);
            if (!header) {
                prog = orig_prog;
                goto out;
            }
            
            proglen = do_jit(prog, image, proglen, &jit_data);
            break;
        }
        oldproglen = proglen;
    }
    
out:
    return prog;
}

指令翻譯示例

讓我們看看一個簡單的 eBPF 指令如何被翻譯成 x86_64 機器碼:

// eBPF 指令:r1 += r2
// BPF_ALU64_REG(BPF_ADD, BPF_REG_1, BPF_REG_2)

static int emit_add(u8 **pprog, u32 dst_reg, u32 src_reg)
{
    u8 *prog = *pprog;
    
    // 生成 x86_64 ADD 指令
    // 對應 "add %rsi, %rdi" (假設 r1->rdi, r2->rsi)
    *prog++ = 0x48; // REX prefix for 64-bit
    *prog++ = 0x01; // ADD opcode
    *prog++ = 0xfe; // ModR/M byte: add %rsi, %rdi
    
    *pprog = prog;
    return 0;
}

Hook 點與程式類型

eBPF 程式必須附加到特定的 Hook 點才能執行。不同的程式類型對應不同的 Hook 點和功能。

主要程式類型

enum bpf_prog_type {
    BPF_PROG_TYPE_UNSPEC,
    BPF_PROG_TYPE_SOCKET_FILTER,    // Socket 過濾器
    BPF_PROG_TYPE_KPROBE,          // 內核 probe
    BPF_PROG_TYPE_SCHED_CLS,       // 排程分類器
    BPF_PROG_TYPE_SCHED_ACT,       // 排程動作
    BPF_PROG_TYPE_TRACEPOINT,      // 追蹤點
    BPF_PROG_TYPE_XDP,             // eXpress Data Path
    BPF_PROG_TYPE_PERF_EVENT,      // perf 事件
    BPF_PROG_TYPE_CGROUP_SKB,      // cgroup skb
    BPF_PROG_TYPE_CGROUP_SOCK,     // cgroup socket
    BPF_PROG_TYPE_CGROUP_DEVICE,   // cgroup 裝置
    BPF_PROG_TYPE_SK_MSG,          // socket message
    BPF_PROG_TYPE_RAW_TRACEPOINT,  // 原始追蹤點
    BPF_PROG_TYPE_SOCK_OPS,        // socket 操作
    BPF_PROG_TYPE_SK_SKB,          // socket skb
    BPF_PROG_TYPE_STRUCT_OPS,      // 結構操作
    BPF_PROG_TYPE_EXT,             // 擴展程式
    BPF_PROG_TYPE_LSM,             // Linux Security Module
    BPF_PROG_TYPE_SK_LOOKUP,       // socket lookup
    BPF_PROG_TYPE_SYSCALL,         // 系統呼叫
};

Context 類型

每種程式類型都有對應的 Context 結構:

// XDP 程式的 Context
struct xdp_md {
    __u32 data;          // 封包資料開始
    __u32 data_end;      // 封包資料結束
    __u32 data_meta;     // metadata 區域
    __u32 ingress_ifindex; // 輸入介面索引
    __u32 rx_queue_index;  // 接收佇列索引
};

// 追蹤程式的 Context
struct pt_regs {
    unsigned long r15;
    unsigned long r14;
    unsigned long r13;
    // ... 其他暫存器
    unsigned long rip;   // 指令指標
    unsigned long cs;
    unsigned long eflags;
    unsigned long rsp;   // 堆疊指標
    unsigned long ss;
};

實戰演練:解析 eBPF ByteCode

讓我們寫一個簡單的工具來解析 eBPF ByteCode:

// bytecode_analyzer.c
#include <stdio.h>
#include <stdint.h>
#include <linux/bpf.h>

// eBPF 指令操作碼定義
#define BPF_CLASS(code) ((code) & 0x07)
#define BPF_OP(code)    ((code) & 0xf0)
#define BPF_SRC(code)   ((code) & 0x08)

const char* get_opcode_name(uint8_t code)
{
    uint8_t cls = BPF_CLASS(code);
    uint8_t op = BPF_OP(code);
    
    switch (cls) {
    case BPF_ALU64:
        switch (op) {
        case BPF_ADD: return "add64";
        case BPF_SUB: return "sub64";
        case BPF_MUL: return "mul64";
        case BPF_DIV: return "div64";
        case BPF_MOV: return "mov64";
        default: return "unknown_alu64";
        }
    case BPF_JMP:
        switch (op) {
        case BPF_JA:  return "ja";
        case BPF_JEQ: return "jeq";
        case BPF_JNE: return "jne";
        case BPF_JGT: return "jgt";
        case BPF_EXIT: return "exit";
        case BPF_CALL: return "call";
        default: return "unknown_jmp";
        }
    case BPF_LD:
        return "ld";
    case BPF_LDX:
        return "ldx";
    case BPF_ST:
        return "st";
    case BPF_STX:
        return "stx";
    default:
        return "unknown";
    }
}

void disassemble_insn(struct bpf_insn *insn, int idx)
{
    printf("%04d: %s", idx, get_opcode_name(insn->code));
    
    // 根據指令類型顯示操作數
    if (BPF_CLASS(insn->code) == BPF_ALU64 || 
        BPF_CLASS(insn->code) == BPF_ALU) {
        if (BPF_SRC(insn->code) == BPF_K) {
            printf(" r%d, #%d", insn->dst_reg, insn->imm);
        } else {
            printf(" r%d, r%d", insn->dst_reg, insn->src_reg);
        }
    } else if (BPF_CLASS(insn->code) == BPF_JMP) {
        if (insn->code == (BPF_JMP | BPF_CALL)) {
            printf(" %d", insn->imm);
        } else if (insn->code == (BPF_JMP | BPF_JA)) {
            printf(" %+d", insn->off);
        } else {
            printf(" r%d, r%d, %+d", insn->dst_reg, insn->src_reg, insn->off);
        }
    }
    
    printf("\n");
}

int main()
{
    // 簡單的 eBPF 程式:return 42
    struct bpf_insn program[] = {
        BPF_MOV64_IMM(BPF_REG_0, 42),  // r0 = 42
        BPF_EXIT_INSN(),               // exit
    };
    
    int insn_count = sizeof(program) / sizeof(program[0]);
    
    printf("eBPF ByteCode 反組譯:\n");
    printf("===================\n");
    
    for (int i = 0; i < insn_count; i++) {
        disassemble_insn(&program[i], i);
    }
    
    return 0;
}

編譯並執行:

# Makefile
CC = gcc
CFLAGS = -Wall -Wextra -std=c99

bytecode_analyzer: bytecode_analyzer.c
	$(CC) $(CFLAGS) -o $@ $<

clean:
	rm -f bytecode_analyzer

.PHONY: clean
make bytecode_analyzer
./bytecode_analyzer

預期輸出:

eBPF ByteCode 反組譯:
===================
0000: mov64 r0, #42
0001: exit

深度分析:eBPF 的設計權衡

1. 安全性 vs 效能

eBPF 在設計時面臨安全性和效能的權衡:

安全性措施

  • Verifier 靜態分析
  • 有界迴圈檢查
  • 記憶體存取限制
  • 特權操作控制

效能最佳化

  • JIT 編譯
  • 零拷貝操作
  • 內聯 helper 函數
  • 暫存器最佳化

2. 通用性 vs 特化

eBPF 既要支援多種應用場景,又要在特定領域有出色表現:

通用性特點

  • 統一的虛擬機器架構
  • 標準化的指令集
  • 一致的開發工具鏈

特化支援

  • 不同的程式類型
  • 專用的 helper 函數
  • 特定的 Context 結構

3. 簡潔性 vs 功能性

指令集設計的權衡:

簡潔性

  • 精簡指令集(RISC)
  • 統一的指令格式
  • 有限的暫存器數量

功能性

  • 64 位運算支援
  • 原子操作
  • 函數呼叫機制

總結

今天我們深入探討了 eBPF 的核心架構,包括:

  1. 虛擬機器設計:64 位 RISC 架構,11 個暫存器,512 字節堆疊
  2. 指令集架構:統一的 64 位指令格式,支援算術、跳躍、記憶體和函數呼叫
  3. Verifier 機制:多層次的安全檢查,確保程式安全性
  4. JIT 編譯:將 eBPF 程式編譯成高效的原生機器碼
  5. Hook 點系統:支援多種程式類型和執行環境

這些底層機制的理解對於編寫高效的 eBPF 程式至關重要。在下一篇文章中,我們將開始搭建 eBPF 開發環境,並實際編寫第一個 eBPF 程式。


上一篇
eBPF 的誕生與演進史
系列文
30 篇文帶你用 eBPF 與 Golang 打造 Linux Scheduler3
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言