要聊 eBPF,先从一个让人抓狂的夜晚说起。线上某服务的 CPU 飙到 80%,top打开只能看到一个内核线程名,strace -p挂上去又因为权限不足直接退出,perf top倒是能出图,但函数名全是偏移量,看了等于没看。那时候我就在想,如果有一种办法,能在不改内核、不重启进程的前提下,把内核内部正在发生的事情以“程序化”的方式拉出来,那就太好了。后来我才知道,这个办法就是 eBPF。
这个系列就叫“eBPF 入门教程实录”,第一篇先把最关键的链路走通:为什么要用 eBPF、环境怎么准备、第一个程序怎么写、怎么让它在真实内核上跑起来,以及运行过程中大概会踩到哪些坑。它适合有 Linux 基础、能敲命令行、但对 eBPF 还一知半解的同学,不需要你有内核开发经验,甚至不太需要懂汇编。我会尽量用“给内核装电表”这个思路把原理讲明白,让小白读得下去,也让已经在写 BPF 程序的人能回来看一眼基础。
1. 为什么我建议从“给内核装电表”的角度理解 eBPF
1.1 内核观测的老问题:不是看不见,而是看得不够“细”
Linux 内核本身并不是一个黑盒,它提供了大量观测接口,比如/proc、/sys、perf、ftrace、kprobes等。但实际排查问题时你会发现,这些手段总有一项不够用:/proc只能看到汇总数据,看到的是“结果”而不是“过程”;perf能拿到采样,但采样频率一高开销就上来;用kprobes动态插桩虽然强大,但写一个内核模块去插入探针,光编译模块和匹配内核版本就能劝退一大半人。更麻烦的是,很多观测能力需要 root 权限,线上环境又未必放行。
这些问题的本质是:观测代码和内核本身耦合太紧。你想看某个函数被调用的情况,要么靠内核已经预埋好的 tracepoint,要么就得自己去改内核、编模块。改内核这件事,别说在线上环境,就是在测试环境也是高危操作,稍有不慎就 panic。
1.2 eBPF 到底改了什么:不用改内核也能深入内核
eBPF 全称是 extended Berkeley Packet Filter,最早其实是从网络包过滤演变过来的。但现在的 eBPF 早就不是“包过滤器”了,它更像是一个运行在内核态的安全虚拟机:它允许你把一段用 C 写的逻辑,编译成字节码后加载进内核,内核会先对这段字节码做安全检查,再把它挂到指定的钩子上,等事件触发时运行。
我用“电表箱”来类比:传统观测方式是你要拆开电线、把电流表串进线路里,才能知道电流多大;而 eBPF 的方式是给内核“夹”上一个电表箱,不需要拆线,只需要选好“夹在哪根线上”,也就是挂到哪个 hook 点,就能实时读到电流、电压、温度,而且电表箱本身有熔断保护,不会因为你的操作把整栋楼的电给弄跳闸。
这个“熔断保护”就是 eBPF 的 verifier,它会逐条指令检查你的程序是否安全,比如有没有越界访问、有没有死循环、有没有非法指针操作。所以内核敢于让它跑在自己的地址空间里,而不必像内核模块那样承担那么高的风险。这一点,恰恰是 eBPF 能在运维、安全、网络、可观测性领域遍地开花的核心原因。
1.3 这个系列适合谁,第一篇要达成什么
如果你是做后端开发、SRE、运维、安全,或者只是对内核技术感兴趣,这个系列都合适。前提是你能熟练操作 Linux 命令行,知道什么是进程、系统调用、文件描述符,这就够了。你不需要先看懂内核源码,也不需要背下 eBPF 指令集,因为入门阶段真正重要的不是写多复杂的程序,而是把“开发—编译—加载—运行—取数”这一条链路完整跑通。
第一篇要达成的目标很具体:第一,让 eBPF 程序在你的机器上真正跑起来输出一个 hello;第二,让你明白这段程序在内核里经历了什么;第三,把那些没人写在文档里的坑提前告诉你。达成这三个目标之后,后续再学 map、ringbuf、追踪类程序就顺理成章了。
2. 环境准备:在动手之前把“电表箱”的材料备齐
2.1 内核版本和配置检查,一分钟确认
eBPF 从内核 4.x 开始逐步完善,到 5.4 之后基本进入了“好用”的阶段。我建议你至少用 5.4 以上的内核,最好是 5.10 或更高。先用uname -r看一下当前版本,我见过很多同学在一台老机器上折腾半天,最后发现是内核太老导致某些 helper 函数不存在,那真的很浪费时间。
除了内核版本,还要确认内核是否开启了 BPF 相关配置。现在主流发行版的内核基本都默认开启,但容器环境、云厂商的某些自定义内核不一定。可以这样检查:
cat /boot/config-$(uname -r) | grep BPF如果配置文件不存在,也可以试试:
zcat /proc/config.gz 2>/dev/null | grep BPF重点关注这几个配置项:CONFIG_BPF、CONFIG_BPF_SYSCALL、CONFIG_BPF_JIT、CONFIG_BPF_EVENTS、CONFIG_DEBUG_FS、CONFIG_KPROBE_EVENTS、CONFIG_TRACEPOINTS。如果这些基本项都有,环境基本可用。如果配置文件根本看不到,也别慌,后面我会讲怎么通过实际命令来判断。
还有一件事,虽然 eBPF 程序不依赖调试符号,但如果你想用 BTF 和 CO-RE 写“一次编译到处运行”的程序,内核得支持 BTF。检查方法很简单:看看是否存在/sys/kernel/btf/vmlinux这个文件。存在,恭喜你,后续学 CO-RE 会很顺利;不存在也没关系,第一篇的示例程序可以不依赖它。
2.2 装好三样工具:clang、bpftool、bpftrace
写 eBPF 程序最常用的方式是:用 C 写逻辑,用 clang 编译成 BPF 字节码,再用加载器把它送进内核。所以 clang 是必需品,版本建议不低于 10,推荐 12 以上。你可以用clang -v看版本。如果版本太老,-target bpf的代码生成质量会差一些,有些 helper 的调用约定也可能处理不好。
第二个工具是 bpftool,它像一个“万能遥控器”,可以查看已经加载的 BPF 程序、管理 BPF map、生成 skeleton 等,排查问题的时候非常有用。不同发行版安装方式不一样,有的直接叫bpftool,有的是在linux-tools包里。安装好之后,跑一下bpftool version确认可用。
第三个工具是 bpftrace,它适合写一行式命令快速验证能力,比如我想立刻看看某个 tracepoint 有没有触发,用 bpftrace 比写 C 程序快得多。它也是入门阶段最好的“环境测试仪”。把这三个工具装好之后,你基本就拥有了完整的 eBPF 开发环境。
2.3 关于内核和容器环境的几个提醒
如果你用的是云主机、虚拟机或者容器,需要额外注意权限问题。eBPF 程序加载通常需要CAP_BPF或CAP_SYS_ADMIN权限,简单粗暴的做法是直接用 root 运行。容器里则比较麻烦,默认容器即使以 root 运行,也可能因为缺少 capabilities 而加载失败。如果你只是想学习,建议直接用一台 Linux 主机或虚拟机,别在容器里死磕。
另外,尽量别为了“图新”去编译一个完全自研的内核,除非你对内核构建很有经验。我自己踩过一次:在某台机器上为了开 BTF 去编译了最新内核,结果内核编译好了,但 clang 版本太老,生成的 BPF 字节码在 verifier 阶段各种报错,最后还得回头升级 clang。步骤越少,排错范围越小,这是折腾 eBPF 环境的第一原则。
3. 上手实操:让第一个 eBPF 程序真正跑起来
3.1 为什么第一个 hook 选 openat 而不是 kprobe
eBPF 可以挂载的钩子点非常多,最常见的有 tracepoint、kprobe、uprobe、tc、XDP 等。第一次上手,我强烈建议选 tracepoint,而且最好是syscalls:sys_enter_openat这个系统调用 tracepoint。
原因有三。第一,tracepoint 是内核预埋的稳定事件接口,它的参数格式是固定的,不会因为内核版本升级而频繁变动,对初学者非常友好。第二,openat是几乎每一次文件打开操作都会触发的事件,你跑个cat或者ls就能立刻看到输出,容易获得正反馈。第三,从 tracepoint 开始的代码路径最干净,不会涉及复杂的参数解引用,适合理解 eBPF 的基本工作流。
kprobe 虽然也能挂到任意内核函数上,功能更强大,但它跟着内核函数名走,一旦内核内部函数改名,你的代码就失效了。新手阶段没必要在这种不稳定点上纠结,先跑通,再追求强大。
3.2 一段可以照抄的最小 eBPF 代码
下面这段代码,就是我这篇教程里的核心示例。它做的事情很简单:每当你调用openat系统调用,它就往内核跟踪缓冲区里打印一行 hello 字符串。之所以选择这个 hook,是因为它触发频率高,而且完全不需要解析复杂的参数,适合作为“第一个能跑的东西”。
#include <linux/bpf.h> #include <bpf/bpf_helpers.h> SEC("tracepoint/syscalls/sys_enter_openat") int hello_openat(void *ctx) { bpf_printk("hello eBPF from tracepoint\n"); return 0; } char LICENSE[] SEC("license") = "GPL";简单拆解一下:SEC("tracepoint/syscalls/sys_enter_openat")是声明这个程序要挂到哪个 hook 点;hello_openat是程序入口函数,它接收一个ctx指针,但我们在入门阶段可以暂时不解析里面的内容;bpf_printk是 eBPF 里最常见的输出函数,它会把字符串写到内核的 ftrace 缓冲区;最后的LICENSE宏用来声明许可证,如果你的程序用了 GPL-only 的 helper,却没有声明 GPL,verifier 会拒绝加载。
提示:如果你只需要跑通环境,可以把
void *ctx换成struct trace_event_raw_sys_enter *ctx,但后者依赖内核头文件或 BTF,复杂度会上升。第一篇先不拆参数字段,专注把链路打通。
3.3 编译、加载、触发、观测的完整流程
把上面的代码保存为hello.bpf.c,接下来执行编译:
clang -O2 -g -Wall -target bpf -c hello.bpf.c -o hello.bpf.o编译完成后,你会得到一个hello.bpf.o文件。有的教程会让你用bpftool gen skeleton生成用户态加载器,但那是 CO-RE 的玩法,第一篇没必要。我用一个更直接的方式:直接用bpftool把字节码加载进内核。
先用ls -l /sys/kernel/debug/tracing确认 tracefs 是否挂载,如果没有,先挂载:
sudo mount -t debugfs none /sys/kernel/debug然后加载程序。注意bpftool的版本不同,个别参数有差异,但主干命令如下:
sudo bpftool prog load hello.bpf.o /sys/fs/bpf/hello type tracepoint加载成功之后,bpftool prog show应该能看到一个名为hello_openat的程序。接着把它挂到sys_enter_openat这个事件的钩子上:
sudo bpftool prog attach pinned /sys/fs/bpf/hello tracepoint/syscalls/sys_enter_openat到这里,eBPF 程序已经在内核里“待命”了。现在随便打开一个文件,比如执行cat /etc/hostname,然后查看追踪输出:
sudo cat /sys/kernel/debug/tracing/trace_pipe如果一切正常,你会在输出里看到:
cat-12345 [001] .... 12345.678901: 0: hello eBPF from tracepoint这个输出其实来自bpf_printk,它写入的是内核追踪缓冲区,所以需要从 tracefs 里读。正常情况下,每执行一次cat或者ls,就会多一行输出。看到这一行,你第一个真正的 eBPF 程序就跑通了。
3.4 bpftrace 一行命令复现同样的效果
如果上面这个 C 程序你觉得步骤还是多,那可以用 bpftrace 来验证环境是否已经具备跑 eBPF 的能力,它把编译、加载、挂载、读取全部封装了:
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("hello eBPF from bpftrace\n"); }'然后另开一个终端执行任何文件操作,比如ls /tmp,bpftrace 终端就会打印对应的输出。这个一行命令看起来像是在“偷懒”,但它其实是环境自检的最好方式:如果 bpftrace 能跑通,说明内核、权限、tracepoint 都是好的;如果这里都失败,那问题大概率不在你的 C 代码,而在系统环境本身。
我个人的建议是:先用 bpftrace 一行命令确认环境 OK,再用 C 代码 + bpftool 走一遍完整的“编译—加载—挂载—读取”流程。两条路线你都亲手操作过之后,对 eBPF 的工作模式才算是真正有了体感。
4. 程序从提交到执行的完整链路
4.1 C 源码到字节码:clang 在背后做了什么
很多新手第一次看到.bpf.o文件时,会以为它和普通.o文件一样,运行时会在用户态通过系统调用交给内核加载。实际上,clang 在编译时做了两件事:一方面把 C 代码翻译成 LLVM 的中间表示,再生成针对 BPF 目标的字节码;另一方面,它会把你写的bpf_printk这类 helper 调用,转换成一个特定的 BPF 辅助函数序号。这个过程中,-target bpf起了决定作用,它告诉 clang 要生成的不是 x86 指令,而是 BPF 字节码。
所以.bpf.o和普通.o虽然都是 ELF 格式,但里面的代码段是 BPF 指令。如果你好奇,可以用llvm-objdump -d hello.bpf.o查看字节码。你会发现代码很短,也就是十几条指令,因为 eBPF 程序本身就是在内核态跑的一段轻量逻辑,它的指令集就是为了快速执行监控类任务而设计。
4.2 进入内核第一关:verifier 与 JIT
当用户态程序通过bpf()系统调用把字节码传给内核后,会遇到第一道关卡:verifier。它会像安检员一样,从头到尾读一遍指令,检查哪里有越界、哪里可能死循环、哪里用了未初始化变量。这也是 eBPF 敢在内核态运行的最大底气。
如果你写的程序违反了安全规则,verifier 会直接拒绝加载,并给出一个拒绝原因,比如invalid mem access、unbounded loop等。新手看到这些报错别慌,大部分情况下是你访问了不该访问的内存,或者用错了 helper。还有一点:即使 verifier 放行了,内核还会在首次运行前把 BPF 字节码通过 JIT 编译成当前 CPU 架构的原生指令,这样才能保证执行效率。所以严格来说,eBPF 程序在内核里最终跑的是 JIT 过后的机器码,而不是解释执行的字节码。
把这一整套过程理解成“给内核装电表”最形象:verifier 是安全熔断器,JIT 是电表里的校准芯片,你写的 C 代码只是申请书的草稿,真正进到内核里运行的,是要在熔断器和校准芯片都通过之后的成品。很多初学者在程序加载失败时会盯着代码反复看,其实更应该先去看dmesg或 verifier 的报错,那里才是真正的诊断入口。
4.3 数据出口:bpf_printk、map 与 ringbuf
程序被触发之后,总要把数据送出来。上面的演示用的是bpf_printk,它简单直接,适合调试,但不适合生产环境,因为它的输出其实是通过 tracefs 传出来的,读取效率不算高,而且频繁打印会拖累全局性能。真正在生产环境里常用的是 map 和 ringbuf。
map 是 eBPF 程序与用户态程序之间的一片共享内存,内核态往 map 里写数据,用户态用bpf()系统调用读数据。你可以把 map 想象成一块“仪表盘”,内核态往里填数字,用户态定时读取。ringbuf 则像一个“环形日志缓冲区”,支持高吞吐的事件流,适合记录每次事件发生的详细信息,比如文件名、PID、时间戳等。这类数据通路我会在系列第二篇里展开讲,这里先让你心里有个概念:eBPF 的价值不只是打印 hello,而是能把内核里发生的事件以结构化的方式零成本地输送给外部。
5. 实录中踩过的坑与排查速查
5.1 permission denied:权限与内核配置问题
跑第一个程序最容易撞见的错误就是permission denied。如果你是在容器里,哪怕容器以 root 运行,也可能因为缺少CAP_BPF或CAP_SYS_ADMIN而失败。把 eBPF 学习环境放在真实的 Linux 主机或虚拟机上,能省掉 80% 的权限烦恼。如果你是在云主机上跑,确认一下你的账号是不是有 root 权限;没有的话,建议直接换一台有 root 的机器。
另一个常见原因是perf_event_paranoid约束了 BPF 能力。可以检查一下:
cat /proc/sys/kernel/perf_event_paranoid如果值大于等于 2,很多 eBPF 程序(尤其是追踪类)会被拒绝。临时把它调到 1 或 -1 可以解决问题:
sudo sysctl kernel.perf_event_paranoid=1不过要记住,这只是解决学习环境的问题,生产环境要改这个值需要走正规变更流程。
5.2 vmlinux.h 与 BTF 缺失
我在前面提过,/sys/kernel/btf/vmlinux是 CO-RE 的关键。如果你的内核没有 BTF,而你的代码里又用了vmlinux.h,编译阶段就会报一堆结构体找不到的错误。遇到这种情况,要么升级内核到 5.4+ 并开启 BTF,要么先用不依赖 BTF 的写法,也就是本篇演示的void *ctx版本,先把环境跑通再说。
还有一个小坑:有些同学在编译时没有加-target bpf,导致 clang 直接生成了 x86 指令,结果是加载时报invalid ELF,或者 verifier 一脸懵。检查编译命令,确认-target bpf这个参数一定存在。
5.3 该出没出:没有输出的常见原因
程序加载成功、事件也触发了,但trace_pipe里就是没有输出。第一步先确认 tracefs 挂载路径是否正确,/sys/kernel/debug/tracing是默认路径,但有些系统挂载在/sys/kernel/tracing下,两个路径可能一真一假。第二步确认你的事件真的触发了,可以手动执行ls /tmp多刷几次。第三步确认你是不是在trace_pipe而不是trace文件里读取,trace_pipe是流式输出,读取时会阻塞等待;如果你用了cat trace,它只显示当前缓冲区内容,可能已经被刷掉了。
还有一个很隐蔽的问题:有些系统默认开启了 tracefs 的权限限制,非 root 用户即使能读到文件也没有内容。直接用 root 执行读取命令,不要在权限上做隔靴搔痒的操作。
5.4 几条实测沉淀的心法
踩过几次坑之后,我总结了几条很实用的经验。第一,先跑 bpftrace,再写 C 代码。bpftrace 一条命令就能验证环境好坏,它失败时给出的错误虽然也抽象,但比你自己编译 C 程序后满脸问号要快得多。第二,加载失败第一时间去看 verifier 报错,不要只盯着 C 代码猜。verifier 的报错信息通常精确定位到某一条 BPF 指令,哪怕你暂时看不懂,也比没有指引强。第三,多做小步实验,别一次性写大程序。把 hello 跑通,再把参数读出来,再加 map,每一步都确保可运行,再进入下一步。这比闷头写几百行代码再一次性排错舒服得多。
这些经验未必能写进官方文档,但它们能让你的试错半径小很多。尤其是“先 bpftrace 后 C”这个顺序,几乎能过滤掉一半环境类问题。
我个人在实际操作中最深的体会是:eBPF 最大的门槛不是语法,而是心智模型。很多人第一次接触它,容易陷入“这是什么神秘技术”的误区,其实它内核里的每一步都遵循着非常朴素的规则:加载前被检查,运行时有边界,输出靠 map 或 ringbuf。你把这一套链路亲手跑通一次,再回头去看那些看起来很唬人的术语,就会发现一切不过是换了个名字的“钩子 + 回调 + 数据共享”而已。
第一课先到这里。下一篇我会继续沿着这个示例,把openat的参数真正读出来,看一眼每次打开文件时的 PID、文件名和返回结果,然后加入一个计数器 map,让“能看到数据”变成“能汇总数据”。那个阶段才是真正体现 eBPF 价值的地方。