
聊到 eBPF绝大多数人的第一反应是 Linux 服务器很少有人会想到 Android。其实 Android 内核本身就支持 eBPF系统里的网络统计、Binder 观测、流量控制等多处能力已经有它的影子。最近我花了几天时间把一个自定义 eBPF 观测程序完整地在 Android 设备上跑通了从交叉编译、推到设备、借助 bpftool 和 libbpf 加载到读取 map 数据整个过程踩了不少坑。这篇东西就是把这个折腾过程、关键原理和报错清单一起写下来。内容适合两类人一类是做系统定制、ROM 或 AOSP 开发想在设备里加深度观测能力的工程师另一类是手上有 root 手机、想自己搞点不一样玩法的发烧友。默认你懂简单的 C 和 Linux 命令行但不需要有 eBPF 基础我会把原理和步骤尽量拆开讲。文章最后还有一份问题排查清单基本能覆盖大部分常见翻车场景。1. 为什么在 Android 上折腾 eBPF场景与思路拆解1.1 它到底能解决什么问题先泼一点冷水Android 普通应用默认是跑不了 eBPF 的bpf 系统调用会被 SELinux 和权限机制挡住。那为什么还要折腾因为一旦有 root 或者能改系统镜像eBPF 能做的事情非常值钱。最常见的一个场景是系统观测。你不需要改任何目标应用的代码就可以在内核里挂一个探针统计某个进程到底调用了多少次execve、openat、socket误差很小。因为 eBPF 程序是在内核态以字节码形式运行由内核 verifier 校验过指令集避免了用户态来回拷贝数据的开销。等你想查某个系统调用是不是异常频繁直接在 map 里看聚合结果就够了。第二个场景是安全审计。比如可以按 uid 聚合某个 syscall 的调用频次发现某个应用在后台大量拉起子进程或者监控某个可疑流程有没有访问特定文件。这类信息如果用传统的方式去抓要么要频繁打开/proc要么只能靠日志性能和时效都差很多。eBPF 加 map 的组合就像一个共享邮箱内核侧写数据用户侧读数据基本不打扰业务。第三个场景是网络方向。eBPF 可以做 cgroup 级别的网络过滤和统计在数据包进入协议栈的早期就完成拦截或者打点。Android 自身的网络统计功能实际上就用了这个思路把它扩展成自己的方案不管是做流量计量、限速还是按应用维度统计都有天然优势。还有一类是性能剖析和内核排障。比如用 kprobe 或者 tracepoint 观测某个内核函数的调用耗时定位锁竞争、异常重试等问题。这种排查手段在服务器上已经被验证得很成熟搬到 Android 上只是工具链和权限更麻烦一点。整体来看eBPF 在 Android 上的价值不是“跑一个 demo”而是把内核观测能力掌握在自己手里。1.2 不能把服务器经验直接搬过来的原因如果你在 Linux 服务器上玩过 eBPF第一次在 Android 上跑通常会卡在同一个地方权限和内核配置。Android 的权限模型比普通桌面发行版严格得多非 root 的 App 连/sys/fs/bpf都看不见更不用说调用bpf()。就算你有 rootSELinux 在 enforcing 模式下也会拦截非系统域的 bpf 操作经常是EPERM、EACCES这类看似模糊的报错。内核配置碎片化也是一个大头。Android 设备的内核从 4.9 到 5.10、5.15 都有是否开启CONFIG_BPF_SYSCALL、CONFIG_BPF_JIT、CONFIG_DEBUG_INFO_BTF完全取决于厂商。很多老机型虽然有 eBPF 支持但缺了这个缺了那个。BTF 缺失直接影响 CO-RE 方案能不能用如果你按服务器上的习惯直接依赖 vmlinux.h到设备上一跑就是No BTF found。工具链也不是标配。普通 Linux 发行版装一个libbpf-dev、bpftool就完事Android 系统镜像里普遍没有 bpftool内核头文件也不一定齐全。自己交叉编译是躲不过的一步而且必须注意静态链接因为设备上不一定有libelf、zlib这样依赖库。还有一点经常被忽略Android 自己有一套BpfLoader机制系统镜像里的 eBPF 程序并不是每个应用都能随便加载而是通过 init 阶段扫描指定目录完成加载和 pin。你不能把服务器的启动方式直接搬到 Android 上。1.3 两条可行的技术路线怎么选我这次踩完以后把可行方案分成两条路各有各的适用场景。路线适合人群主要操作缺点用户态工具链路线root 机、开发验证、自己折腾交叉编译 eBPF 对象用 bpftool/libbpf 加载需要有 root 或 userdebug 设备不适用于正式产品AOSP 系统集成路线做系统镜像、ROM、Android 平台开发在 Android.bp 里声明 eBPF 模块由 BpfLoader 加载编译周期长、SELinux 策略要跟着调迭代慢如果你只是想快速验证一个探针逻辑就选第一条路所有步骤都能在命令行完成不用反复刷机。如果是想在正式系统里持续跑一个观测任务就选第二条路让 BpfLoader 和 init 帮你把程序在开机阶段自动加载权限和生命周期都更可控。我下面的内容会把两条路都讲清楚可以先跑通第一条再决定要不要往系统里集成。2. 开工前准备内核检查、工具链与 bpftool2.1 编译 eBPF 的工具链怎么装eBPF 程序最终不是普通的 ARM64 或者 x86 二进制而是面向内核虚拟机的一类字节码所以编译器必须是能产出bpf目标文件的 clang/LLVM而不是 gcc。我在开发机上用的方案很简单sudo apt install clang llvm libbpf-dev linux-tools-generic版本方面clang 11 以后对 BPF 的支持已经比较完整clang 14 之后 CO-RE 和 BTF 相关功能明显更稳。如果机器上默认版本太老建议单独装新版本。Android NDK 自带的 clang 理论上也能用来编 BPF 目标但不同 NDK 版本对 target 的支持参差不齐我不建议一开始就依赖它直接用 PC 上的发行版 clang 更省事。还有一个经常被忽略的工具是llvm-strip。 eBPF 对象文件里往往带着调试信息和符号表推送到设备前最好瘦身一下避免不必要的体积和潜在的符号冲突。llvm-strip -g会把 DWARF 调试信息删掉同时保留.BTF段对于需要 CO-RE 的场景是对的。注意这里编译 eBPF 对象不是编译 Android 可执行文件。目标前缀是bpf不是aarch64-linux-android。很多人一开始会混非要弄一套交叉编译链去编 .o结果反而编出普通可执行文件。2.2 设备端先做一轮能力体检写代码之前先看看手里的设备到底能不能跑 eBPF。我把检查命令列一下适配的是adb shell环境adb shell uname -a adb shell cat /proc/config.gz adb shell ls -l /sys/fs/bpf adb root如果能看到/proc/config.gz就直接压缩包里查几个关键配置adb shell zcat /proc/config.gz | grep BPF重点看这些CONFIG_BPFy这个是总开关。CONFIG_BPF_SYSCALLy负责提供 bpf 系统调用。CONFIG_BPF_JITy有 JIT 性能才有意义。CONFIG_DEBUG_INFO_BTFyCO-RE 方案依赖它。CONFIG_FTRACE_SYSCALLSy如果后续想用 syscall tracepoint。很多设备的内核并不导出/proc/config.gz这时候可以换一个思路直接看/sys/kernel/btf/vmlinux是否存在。这个文件存在说明内核至少带了 BTF 信息。另外/sys/fs/bpf目录在 root 下能不能看到也很关键如果连这个目录都不存在得先检查内核是否真的挂载了 BPF 文件系统。设备选择上Pixel 系列或者能刷 GKI 内核的机型最省事系统自带 debug 工具多厂商魔改少。如果手上是某品牌的普通量产机玩之前最好先确认解锁和 root 方案可行否则很多权限问题根本绕不过去。2.3 给 Android 准备一个静态 bpftoolLinux 发行版里的 bpftool 是给主机用的不能直接推到 Android 里跑。最靠谱的办法是从 AOSP 源码或者 libbpf 仓库自己编译一个静态版本。如果手里有 AOSP 源码直接走平台构建source build/envsetup.sh lunch aosp_arm64-userdebug m bpftool产物会在out/target/product/.../system/bin/bpftool这是一个带 Android 平台编译环境的版本动态库问题一般已经处理好了。如果没有 AOSP也可以从 GitHub 拉 bpftool 源码配合 NDK 工具链交叉编译git clone https://github.com/libbpf/bpftool cd bpftool make -j8 LLCclang CLANGclang file bpftool交叉编译静态版本时要注意把 libelf 和 zlib 静态编进去否则推到设备上运行会报缺少.so。最直接的检验方式是file bpftool看输出里是不是statically linked。如果是动态链接就别浪费时间直接找一个静态产物。推到设备也很简单adb push bpftool /data/local/tmp/ adb shell chmod x /data/local/tmp/bpftool adb shell /data/local/tmp/bpftool version放到/data/local/tmp而不是/system/bin是因为后者是只读分区除非 remount否则写不进去。工具能跑起来以后整个流程就顺了一半。3. 核心阶段一在设备上用 bpftool 加载 eBPF 程序3.1 最小程序统计 execve 调用我选的第一个探针是统计execve系统调用的次数。原因很简单这个系统调用频率适中不容易触发意想不到的内核限制而且验证起来很直观——随便执行几条命令map 里的值就应该涨。#include linux/bpf.h #include bpf/bpf_helpers.h #include bpf/bpf_tracing.h struct { __uint(type, BPF_MAP_TYPE_ARRAY); __uint(max_entries, 1); __type(key, __u32); __type(value, __u64); } exec_count SEC(.maps); SEC(tracepoint/syscalls/sys_enter_execve) int count_execve(void *ctx) { __u32 key 0; __u64 *cnt bpf_map_lookup_elem(exec_count, key); if (cnt) { __sync_fetch_and_add(cnt, 1); } return 0; } char LICENSE[] SEC(license) GPL;这里有几处容易踩坑的地方。SEC(tracepoint/syscalls/sys_enter_execve)决定了程序类型也决定了它将来会挂到什么事件上。SEC(.maps)里的数组 map 用来做计数器key 固定为 0value 是 64 位整数因为 eBPF 内核态和用户态互动主要靠 map而不是靠返回值。LICENSE为什么必须是 GPL因为很多内核辅助函数比如bpf_trace_printk、bpf_probe_read_user这些只在 GPL 许可的程序里开放。如果 license 段缺失或者不是 GPL内核加载器会直接拒绝。这个报错特别隐晦所以先写在前面。3.2 编译、瘦身与传输有了源码以后在开发机上直接用 clang 编clang -O2 -g -target bpf -c exec_count.c -o exec_count.o llvm-strip -g exec_count.o file exec_count.o-target bpf是关键它让 clang 生成 eBPF 虚拟机指令而不是 ARM64 或 x86 指令。-O2是常规优化eBPF 程序本身要求指令数有限优化能帮助代码更紧凑。-g会把 BTF 信息带进去而llvm-strip -g又去掉 DWARF 调试信息两个动作不冲突。编出来的exec_count.o是平台中立的字节码对象不需要为 ARM64 单独交叉编译。这一点和普通 Android 二进制完全不同也是新手最容易迷惑的地方。把对象文件推到设备上adb push exec_count.o /data/local/tmp/ adb shell chmod 644 /data/local/tmp/exec_count.o保证文件权限不是可执行也许有点反直觉但 .o 文件不是直接执行而是要交给内核去加载普通文件权限就够了。3.3 加载、attach 和读取数据先说说bpftool prog load。这个命令会把对象加载到内核并且把程序 pin 到 BPF 文件系统可以快速验证程序能不能被内核接受adb shell su -c /data/local/tmp/bpftool prog load /data/local/tmp/exec_count.o /sys/fs/bpf/exec_count_prog adb shell su -c /data/local/tmp/bpftool prog show name count_execve但这里有一个非常容易被忽略的细节prog load只是加载并不代表探针已经在运行。对于 tracepoint 类型的程序还需要一个用户态进程通过 perf event 把它 attach 到事件上。如果你直接跑完prog load就去触发命令、读 map大概率看到的计数是 0。要让计数真正生效我给出一段用 libbpf 写的最小 loader。它在开发机上编译推到设备上运行负责加载对象、pin map并 attach 到sys_enter_execve#include bpf/libbpf.h #include stdio.h #include unistd.h int main(void) { struct bpf_object *obj; struct bpf_program *prog; struct bpf_link *link; int err; obj bpf_object__open_file(/data/local/tmp/exec_count.o, NULL); if (!obj) { perror(open_file); return 1; } err bpf_object__load(obj); if (err ! 0) { perror(load); return 1; } err bpf_object__pin_maps(obj, /sys/fs/bpf); if (err ! 0) { perror(pin_maps); return 1; } prog bpf_object__find_program_by_name(obj, count_execve); if (!prog) { fprintf(stderr, program not found\n); return 1; } link bpf_program__attach_tracepoint(prog, syscalls, sys_enter_execve); if (!link) { perror(attach); return 1; } printf(attached, wait 10 seconds...\n); sleep(10); bpf_link__destroy(link); bpf_object__close(obj); return 0; }交叉编译这段 loader 时需要用到 NDK 工具链和静态编译的 libbpf基本命令是aarch64-linux-android21-clang \ -static \ -I /path/to/libbpf/include/uapi \ -I /path/to/libbpf/include \ loader.c \ /path/to/libbpf/libbpf.a \ -lelf -lz \ -o loader路径按你本机实际环境替换核心就是让最终产物不依赖设备上的动态库。部署之后在 root shell 里把 loader 放后台运行同时触发一批 execveadb shell su -c /data/local/tmp/loader adb shell su -c for i in 1 2 3 4 5; do /system/bin/true; done然后读取 mapadb shell su -c /data/local/tmp/bpftool map dump pinned /sys/fs/bpf/exec_count如果看到类似key: 00 00 00 00 value: 05 00 00 00 00 00 00 00的输出说明探针确实被触发过true命令的几次执行已经累计到了计数里。第一次看到这个结果的时候整个链路才算真正打通。提示loader 演示里只 attach 了 10 秒。实际生产环境要把bpf_link的生命周期和应用进程绑定避免进程退出后探针直接失效。3.4 tracepoint 和 kprobe 怎么选在自己动手的过程中你会频繁遇到 tracepoint、kprobe、uprobe 这些词简单说下差异。tracepoint 是内核里预先埋好的桩接口相对稳定参数结构是内核维护的比如sys_enter_execve这类事件。优点是稳只要内核开启了对应配置即使后续函数内部改实现tracepoint 的语义一般不会大变。缺点是只能在内核有桩的地方用覆盖面有限。kprobe 是动态插桩能挂到任意内核函数入口比如kprobe/tcp_v4_connect、kprobe/do_sys_openat2。优点是非常灵活缺点是函数名会随内核版本变化同一个探针换个内核版本可能就失效而且参数结构需要自己根据源码去理解出错概率高。uprobe 面向用户态函数在 Android 上使用限制更多因为需要动态插桩用户态进程普通 root 环境下经常被 SELinux 或系统的安全策略挡住。我建议初学阶段先专注 tracepoint跑通了以后再考虑 kprobe。比如你要统计 connect 次数tracepoint/syscalls/sys_enter_connect就比kprobe/tcp_v4_connect省心很多。4. 核心阶段二集成到 AOSP 系统镜像4.1 BpfLoader 的运作机制如果只是在开发机上验证用用户态工具链就够了。但要让 eBPF 程序在正式系统里长期工作迟早要接触 AOSP 的 BpfLoader。Android 系统从很早的版本开始就在内部使用 eBPF比如网络统计、cgroup 过滤。这部分程序不是靠某个 app 在启动时偷偷加载而是以.bpf.o对象文件的形式编进系统镜像init 进程在开机阶段会通过BpfLoader去扫描指定目录把对象文件解析成字节码创建 map、加载 prog然后把需要的资源 pin 到/sys/fs/bpf。为什么要研究这个机制因为直接改系统镜像能让 eBPF 程序的加载时机、运行权限、资源 pin 路径都由系统统一管理。和我在第三部分写的 loader 相比BpfLoader 相当于构建系统和 init 都帮你把脏活干完了你只需要按规范写一个 eBPF C 文件补一条 Android.bp 模块再处理 SELinux 权限就够了。还有一个好处是 map 可以被多个进程共享。系统组件如果希望读取统计结果可以直接通过bpf_obj_get拿 map 的 fd不需要每个进程各自加载一遍程序。这在网络统计、Binder 监控这种多人协作的场景里价值很明显。4.2 用 Android.bp 加一个 eBPF 模块在 AOSP 里集成 eBPF核心是写一个bpf类型的模块。我这边做了一个最简单的示范源码用的还是第三部分的计数器bpf { name: exec_count, srcs: [exec_count.c], cflags: [ -Wall, -Werror, ], }构建时把这个模块加入系统镜像最粗暴的方式是在产品 mk 里追加PRODUCT_PACKAGES exec_count之后整个 Android 构建链会帮你编译出对应的 eBPF 对象文件转换格式放到系统初始化时会扫描的目录下。具体产物名和目录命名在不同 Android 版本里略有差异但思路是一致的源码丢给构建系统BpfLoader 负责加载。真正写业务逻辑的时候建议不要直接复制第三部分那段 demo。AOSP 环境下应该尽量使用平台提供的bpf_helpers.h和 BpfLoader 约定例如 map 的 pin path 需要遵循具体的命名规则避免多个模块同名冲突。你先在 root 的开发者设备上验证好 probe 逻辑再往 Android.bp 里挪能少走很多弯路。4.3 SELinux 策略和权限放行AOSP 集成路线最绕不开的就是 SELinux。普通 Linux 服务器上设置setenforce 0就可以临时绕过Android 量产系统则必须写对策略。第一次把 eBPF 模块编进镜像后最常见的现象是程序加载时没有问题但某个系统进程想读 map 的时候被拒绝。遇到这种情况第一时间去看 avc 拒绝日志adb shell dmesg | grep avc adb logcat -b kernel | grep avc日志里会明确告诉你哪个 domain 对哪个 bpf 文件或 bpf map 缺了什么权限。根据日志去补策略效率最高。比如某系统域需要读取 pin 在/sys/fs/bpf下的 map就要在其 te 文件里允许它对 bpf 文件系统执行read、open、getattr这类操作。工具方面可以借助 audit2allow 把 avc 日志翻译成候选规则但最终还是要根据实际场景精简不能无脑粘贴。这里分享一个排障技巧在 userdebug 版本上可以先用setenforce 0验证功能链路是否完整。如果关闭 SELinux 后程序能正常访问 map说明逻辑没问题只是策略缺了如果关闭后还是报错那就不用再纠结权限回去查内核或工具链。5. 常见报错与排查技巧实录5.1 高频问题速查表我这几天折腾下来把踩到的和身边朋友遇到的高频问题整理成一张表基本按优先级排了。报错现象常见原因解决思路bpf() syscall returned -1: Operation not permitted当前 shell 缺 root 或 CAP_SYS_ADMINSELinux 拦住了确认 adb root、su 权限必要时临时setenforce 0验证libbpf: failed to load program: Invalid argument内核 verifier 不通过程序里用了不支持的操作看内核日志里的verification failed逐条对指令Failed to load BTF from vmlinux/No BTF found内核没有开启CONFIG_DEBUG_INFO_BTF放弃 CO-RE使用该内核对应的固定头文件重新编译或者换支持 BTF 的 GKI 内核unknown func bpf_probe_read_user内核版本太老或对应的 eBPF helper 未开放换用其他 helper或升级内核Cant attach kprobe*: No such file or directory内核里没有你要挂的函数或事件名grep /proc/kallsyms找准确符号名注意版本差异bpftool: error while loading shared librariesbpftool 是动态链接版本Android 上没有对应库找静态编译产物或者回到 AOSP 用平台工具链编tracepoint attach 成功但 map 一直是 0只加载程序没有真正 attach 到事件上或者 attach 的进程退出了用 libbpf 写 loader 明确 attach保存 bpf_linkmap 无法 pin 到/sys/fs/bpf/sys/fs/bpf未挂载或文件系统不可写或已有同名 pin先mount -t bpf bpf /sys/fs/bpf再清理同名路径5.2 三步排查法遇到问题不要慌我一般按三步走。第一步确权。先看当前 shell 的权限和 SELinux 状态。adb shell id adb shell getenforce adb shell su -c getenforce如果id显示不是 root所有 bpf 相关操作都不用继续。如果是 root 但 SELinux 是 enforcing先考虑是产品场景还是调试场景调试场景可以临时setenforce 0。注意这只是临时手段别在正式系统里这么干。第二步看日志。eBPF 的很多失败原因不会直接打在bpftool的终端里而是写进内核日志。用adb logcat -b kernel或者adb shell dmesg | grep bpf能明显缩小问题范围。verifier 拒绝程序、helper 不存在、map 类型不匹配都会在这里出现线索。第三步看对象文件。有时候程序编译过了但加载就是报Invalid argument。这时候用llvm-objdump看一下字节码对照是不是有问题了llvm-objdump -S exec_count.o多看看.text里的指令再回到源码里对照能发现很多编译期隐藏的坑。比如循环展开过深、栈访问越界、指针类型不匹配这些都能在校验日志中找到蛛丝马迹。5.3 几个被低估的细节第一个细节不要一开始就追最新特性。Android 设备上的内核是很碎片化的我一开始按服务器上的习惯用 CO-RE结果普通厂商内核没 BTF加载永远失败。后来干脆把程序降级成固定内核头文件的写法立刻就跑通了。优先确认 BTF 是否存在再决定要不要上 CO-RE这是经验之谈。第二个细节bpf_trace_printk虽然调试方便但输出不一定在 logcat。它本质上是写 trace 文件系统你要去这里看adb shell cat /sys/kernel/debug/tracing/trace_pipe这个命令需要 root而且 tracefs 也要挂载。很多人盯着 logcat 找半天其实方向就错了。第三个细节静态编译 bpftool 时尽量选与设备 ABI 匹配的版本。aarch64设备别拿x86版工具file命令先看一眼不是什么坏习惯。同时也要确认 clang 版本足够新老版本的 clang 对 BPF target、BTF 的支持不完整编出来的对象经常在功能上踩雷。第四个细节触发测试事件时别拿太轻量的命令。我刚开始用几条echo去触发execve结果发现 shell 有很多内部优化不是每次命令都走真正的 exec 路径。后来用/system/bin/true循环执行计数才稳定增长。这个现象在排查 map 为 0 的时候非常容易误导人。从工具链到加载器再到系统镜像集成我自己走完这一圈之后最大的感受是Android 上折腾 eBPF真正难的往往不是 eBPF 本身而是内核、root、SE Android 这一层层限制叠在一起。但只要你先把环境检查做透再选择一个最稳的入口比如 tracepoint 计数整个过程反而比在服务器上多花的只是一点耐心。如果你也打算深入做这块建议下一步往 AOSP 集成方向走。把第三部分的代码写进一个 eBPF 模块让 BpfLoader 在开机自动加载然后用平台工具直接读 map那种“系统原生能力”的感觉是完全不一样的。跑通以后再试试网络方向的 cgroup 过滤你会发现同样一套思路能扩展的场景比想象中宽得多。