eBPF 入门:给 Linux 内核装插件

浏览器插件类比:秒懂 eBPF

Chrome 不装插件也能用,装了广告拦截就能屏蔽广告,装了翻译插件就能划词翻译------浏览器本身不用改,功能却能无限扩展。

eBPF 就是 Linux 内核的"浏览器插件系统":

  • 内核 = 浏览器,功能强大但不一定满足你的特殊需求

  • eBPF 程序 = 插件,你写的小程序可以动态插入内核运行

  • Hook 点 = 插件安装位置,内核里预留了各种"插口"

  • 验证器 = 插件审核机制,确保插件不会搞崩浏览器

传统方式?改源码重编译,或写内核模块冒崩溃风险。eBPF 让你像装插件一样,写个程序、加载进去、立即生效

个人觉得最关键的一点:eBPF 兼具内核模块的灵活性和脚本的安全便捷性------验证器把关,程序过不了"安检"就根本不让跑,杜绝内核崩溃。


三方协作:eBPF 怎么工作

一个完整的 eBPF 应用由三部分协作完成,我把它理解成"指挥官-侦察兵-共享白板"模型:

复制代码
┌─────────────────────────────────────┐
│           用户空间                   │
│   用户态程序(Go/C)           │
│   · 提交字节码到内核                 │
│   · 从 Maps 读取结果                │
└──────────┬──────────────┬───────────┘
           │              │
     提交字节码       读写数据
           ▼              ▼
┌─────────────────────────────────────┐
│           内核空间                   │
│  ┌──────────┐  ┌───────────────┐    │
│  │ eBPF程序 │◄►│ BPF Maps      │    │
│  │ 挂Hook执行│  │ 共享白板      │    │
│  └────┬─────┘  └───────────────┘    │
│       │ Hook 触发                    │
│  ┌────▼─────┐                        │
│  │ 内核事件源│ 系统调用/网络包/调度… │
│  └──────────┘                        │
└─────────────────────────────────────┘
  • 用户态程序(指挥官):把 eBPF 程序提交到内核、挂载到事件点、读取结果

  • 内核态 eBPF 程序(侦察兵):在内核中执行观测逻辑,将数据写入 Maps

  • BPF Maps(共享白板):内核态和用户态之间的通信桥梁,双向传输,原生并发安全

三方各司其职,边界清晰。内核态程序有严格限制------不能随意调内核函数、不能无限循环、必须在有限时间内完成------这些限制恰恰是安全的保障。


五步流程:从写代码到内核执行

一个 eBPF 程序从编写到运行,我总结为五步:

复制代码
编写代码 → 编译字节码 → 加载验证 → JIT 编译 → 挂载执行
  1. 编写代码:用受限 C 写内核态程序。受限是故意的------不能随便调函数、不能无限循环,保证不搞崩内核

  2. 编译字节码clang -target bpf 把 C 编译成 eBPF 字节码,类似 Java 字节码------跨平台、可验证

  3. 加载验证 :通过 bpf() 系统调用提交到内核,验证器做安全检查。不通过就拒绝加载

  4. JIT 编译:即时编译器把字节码翻译成原生机器码,像同声传译,性能接近原生

  5. 挂载执行:程序挂到 Hook 点,事件触发时自动执行,数据写入 Maps

五步走完,你的程序就在内核里跑起来了。


核心组件速览

不需要逐个展开,一张表搞定:

组件 类比 要点
Hook 监控摄像头安装点 选型原则:稳定选 Tracepoint,灵活选 Kprobe,网络性能选 XDP
验证器 安检门 两阶段检查:结构检查(无环)+ 逐条校验(模拟执行),不通过就拒绝
JIT 同声传译 字节码→机器码,默认开启,还有常量盲化防注入攻击
Maps 共享白板 内核态/用户态通信桥梁,支持 Hash/Array/Ringbuf 等多种结构,原生并发安全
辅助函数 标准化接口 eBPF 程序调用内核能力的唯一合法通道,不同程序类型可调用的集合不同
CO-RE Java 一次编译到处运行 基于 BTF 自动适配不同内核版本的差异,编译一次到处跑
BTF 数据字典 记录内核数据结构元信息,体积小(几十 KB),CO-RE 的基石

⚠️ 关于使用限制:eBPF 不是万能的------栈空间最多 512 字节、不能无限循环、只能调辅助函数、指令数有上限。这些限制恰恰是安全的代价,推荐内核 5.x 以上使用,4.x 限制较多。


实战:Hello World

环境准备(Ubuntu):

复制代码
# 安装 Go(如果没有)
wget https://go.dev/dl/go1.21.0.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.21.0.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin
​
# 安装 eBPF 编译工具链
sudo apt-get install clang llvm libbpf-dev linux-headers-$(uname -r)
​
# 安装 bpf2go(cilium/ebpf 的代码生成工具)
go install github.com/cilium/ebpf/cmd/bpf2go@latest
export PATH=$PATH:$(go env GOPATH)/bin

内核态程序(hello.bpf.c)

复制代码
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
​
// SEC 宏指定程序类型和挂载点
SEC("kprobe/do_sys_openat2")
int hello_world(void *ctx)
{
    bpf_trace_printk("Hello, World!\n");  // 向内核调试日志输出
    return 0;
}
​
char LICENSE[] SEC("license") = "GPL";

用户态程序(hello.go)

复制代码
package main
​
import (
    "log"
    "os"
    "os/signal"
    "syscall"
​
    "github.com/cilium/ebpf/link"
    "github.com/cilium/ebpf/rlimit"
)
​
//go:generate bpf2go -type Hello hello hello.bpf.c
​
func main() {
    if err := rlimit.RemoveMemlock(); err != nil {
        log.Fatal("解除内存锁失败:", err)
    }
​
    var objs helloObjects
    if err := loadHelloObjects(&objs, nil); err != nil {
        log.Fatal("加载eBPF程序失败:", err)
    }
    defer objs.Close()
​
    kp, err := link.Kprobe("do_sys_openat2", objs.HelloWorld, nil)
    if err != nil {
        log.Fatal("挂载kprobe失败:", err)
    }
    defer kp.Close()
​
    log.Println("正在追踪文件打开操作,按 Ctrl+C 退出...")
​
    ch := make(chan os.Signal, 1)
    signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
    <-ch
    log.Println("已退出")
}

运行

复制代码
# 创建项目
mkdir hello-ebpf && cd hello-ebpf
go mod init hello-ebpf
go get github.com/cilium/ebpf
​
# 生成 Go 绑定代码并编译
go generate
​
# 运行
sudo go run hello.go
​
# 另开终端查看输出
sudo cat /sys/kernel/debug/tracing/trace_pipe
# <...>-1234  [001] d... 100.000: hello_world: Hello, World!

你用标准 BPF 头文件写了内核态函数,通过 cilium/ebpf 库加载并挂到 do_sys_openat2 入口------任何进程打开文件时,内核自动调用你的函数。bpf2go 自动生成 Go 绑定代码,让加载、挂载和交互都是类型安全的,整个流程清晰可控。

进阶方向:掌握 BPF Maps 是从"能跑"到"能用"的关键------Maps 让你采集聚合数据,而不只是打印字符串。生产环境也可以选择 libbpf-bootstrap(C 用户态脚手架),支持完整 CO-RE,部署无需目标机器装编译器。bpftrace 也值得了解------一行命令写追踪工具,临时排查利器。


常用命令速查

复制代码
# 查询可用的插桩点
sudo bpftrace -l
​
# 模糊查找包含 "open" 的插桩点
sudo bpftrace -l '*open*'
​
# 查看已加载的 eBPF 程序和 Maps
sudo bpftool prog list
sudo bpftool map list
​
# 检查 BTF 是否可用(CO-RE 的前提)
ls /sys/kernel/btf/vmlinux

核心概念速记卡

概念 类比 一句话
eBPF 浏览器插件系统 给内核装插件,扩展功能不动内核
用户空间/内核空间 前台/后台 eBPF 让你合法进入后台
Hook 监控摄像头安装点 指定"在哪里监控"
验证器 安检门 不通过安检不让进
JIT 同声传译 字节码实时翻译成机器码
Maps 共享白板 内核态和用户态的通信桥梁
辅助函数 标准化接口 调用内核能力的唯一合法通道
CO-RE 一次编译到处运行 编译一次,不同内核版本都能跑
BTF 数据字典 记录数据结构元信息,支撑 CO-RE

写在最后

eBPF 的学习曲线确实存在,但回报极高------掌握它,你就拥有了深入 Linux 内核的"超能力":不改内核代码,就能观测、追踪、控制内核行为。建议的学习路径:

  1. 一两天搞清概念:三方协作、五步流程、核心组件各干什么

  2. 搭环境跑通 Hello World:cilium/ebpf 是最快的起点,Go + bpf2go 就能加载内核程序

  3. 用 bpftrace 解决实际问题:一行命令追踪进程、文件操作、IO 延迟------先会用工具,再学写程序

  4. 进阶 libbpf-bootstrap:生产级部署,minimal 模板做实验,bootstrap 模板做项目