eBPF 入门教程:从零跑通第一个内核观测程序 要聊 eBPF先从一个让人抓狂的夜晚说起。线上某服务的 CPU 飙到 80%top打开只能看到一个内核线程名strace -p挂上去又因为权限不足直接退出perf top倒是能出图但函数名全是偏移量看了等于没看。那时候我就在想如果有一种办法能在不改内核、不重启进程的前提下把内核内部正在发生的事情以“程序化”的方式拉出来那就太好了。后来我才知道这个办法就是 eBPF。这个系列就叫“eBPF 入门教程实录”第一篇先把最关键的链路走通为什么要用 eBPF、环境怎么准备、第一个程序怎么写、怎么让它在真实内核上跑起来以及运行过程中大概会踩到哪些坑。它适合有 Linux 基础、能敲命令行、但对 eBPF 还一知半解的同学不需要你有内核开发经验甚至不太需要懂汇编。我会尽量用“给内核装电表”这个思路把原理讲明白让小白读得下去也让已经在写 BPF 程序的人能回来看一眼基础。1. 为什么我建议从“给内核装电表”的角度理解 eBPF1.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 而不是 kprobeeBPF 可以挂载的钩子点非常多最常见的有 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却没有声明 GPLverifier 会拒绝加载。提示如果你只需要跑通环境可以把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 /tmpbpftrace 终端就会打印对应的输出。这个一行命令看起来像是在“偷懒”但它其实是环境自检的最好方式如果 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_paranoid1不过要记住这只是解决学习环境的问题生产环境要改这个值需要走正规变更流程。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 价值的地方。