4.8 追踪进程的上帝视角——通过 perf 和 eBPF 工具探秘 execve
在上一节里,我们证明了动态 kprobe 在追踪内核模块函数时几乎无所不能。但那是针对“我们自己写的代码”。
现在,我们要把目光投向一个更底层、更普遍的问题:系统里谁在干什么?
具体来说,就是——新进程是怎么诞生的?
如果你在上一节结尾的实战中感到了一丝丝掌控感,那么这一节我们会遇到一堵墙:传统的 kprobe 方法在某些关键路径上会失效。这会逼迫我们去寻找更强大的武器——eBPF。
execve:进程的源头
在 Linux(以及所有 UNIX)世界里,用户空间的程序——也就是一个个进程——的启动,都依赖于一个所谓的 exec 系列函数库。
你可能见过这一大堆名字:execl(), execlp(), execv(), execvp(), execle(), execvpe(), 以及 execve()。
这一大家子 API 里,其实只有一位是真正的“老大”。
前六个(execl 到 execvpe)本质上都只是 glibc 的包装函数。它们的工作就是整理参数,把各种各样的调用形式转换成统一的标准,然后最终调用 execve()。
只有 execve() 是真正的系统调用。
一个小知识:
execvpe()其实是 GNU 的扩展(基本上只能在 Linux 上见到)。
这意味着什么?意味着几乎所有进程(以及应用程序)的执行,最终都会归结到内核里的 execve() 代码路径上!
一旦进入内核,execve() 系统调用会变身成内核函数 sys_execve()(这其实是通过 SYSCALL_DEFINE3() 宏间接定义的),而这个函数会去调用真正干活的苦力:do_execve()。
系统调用的内核映射规律
这在内核里是一个典型模式,虽然不是百分之百绝对:
用户态发出的系统调用 foo(),到了内核里通常变成 sys_foo()。
如果 sys_foo() 代码很短,它就自己干了;
如果逻辑比较复杂,它就会调用一个 do_foo() 函数来做实际工作。
以 execve(2) 为例,它的路径是:
fs/exec.c:sys_execve() → fs/exec.c:do_execve()。
但这只是故事的一半。
请看下面这张图,它展示了用户态调用是如何落入内核的,你会发现有些事情并不像看起来那么简单(比如 open(2))。
(图 4.13 – 用户态系统调用如何在内核中映射)
那条红线:用户态如何穿越到内核态?
顺便插一句非常有用的背景:一个没有特权的用户态任务(进程或线程),到底是怎么穿过那条红色分界线(图 4.13 中的竖线),从用户模式跳到特权内核模式的?
简单来说,每个处理器都支持一条或多条机器指令来干这事,通常被称为 调用门 或 陷阱。我们说进程从用户态“陷阱”到了内核态。
- x86:传统上用软中断
int 0x80;现代版本用syscall机器指令。 - ARM-32:使用
SWI(软中断)指令。 - AArch64 (ARM64):使用
SVC(监管者调用)指令。
如果你对细节感兴趣,可以翻一下 syscall(2) 的 man page。
好了,回到正题。还有一个孪生兄弟叫 execveat()。它和 execve() 几乎一样,唯一的区别是它的第一个参数是一个目录文件描述符,程序(第二个参数)会相对于这个目录来执行。
撞墙了:传统 kprobe 的局限性
既然知道了所有进程都是通过 execve() 执行的,那直觉告诉我们要想监控“谁执行了什么”,就应该在这个函数上插一个探针。
比如把 kprobe 注入到 sys_execve() 或者 do_execve() 上。
听上去很完美,对吧?
但是,这里有个“但是”。
在现代内核上,这招行不通。如果你尝试用静态 kprobe 的方法(写个内核模块去 register_kprobe()),操作会直接失败。
别信我说的,你自己试试看。记住,永远要用实验说话。
事实上,在我的 x86_64 Ubuntu 20.04 LTS 虚拟机上,连专门为此设计的封装工具——execsnoop-perf(虽然它内部用的是 ftrace 的 kprobe_events 接口)——也跪了:
$ sudo execsnoop-perf
Tracing exec()s. Ctrl-C to end.
ERROR: adding a kprobe for execve. Exiting.
这就很尴尬了。我们需要一把更锋利的刀。
终极武器:eBPF 登场
刚才那个让 perf-tools 黯然失色的问题,被更现代的 eBPF 工具一举解决了。
只要你安装并使用(作为 root)execsnoop-bpfcc(8),它就能完美工作!
接下来,我们就来快速窥探一下如何通过 eBPF 前端来追踪进程执行。
什么是 eBPF?
eBPF 是 extended BPF(扩展伯克利包过滤器)的缩写。老派的 BPF 主要用于内核网络包的过滤和跟踪。而 eBPF 是一项相对较新的内核创新(从 Linux 4.1 内核,即 2015 年 6 月才开始支持)。
它极大地扩展了 BPF 的概念,让你不仅能追踪网络栈,还能追踪几乎所有东西——无论是内核空间还是用户空间应用。
实际上,eBPF 及其前端工具集,已经成为了 Linux 系统上追踪和性能分析的现代标准做法。
要使用 eBPF,你的系统得满足两个条件:
- Linux 内核版本 4.1 或更新。
- 内核开启了 eBPF 支持。
直接使用底层的 eBPF 内核特性非常硬核(属于“地狱难度”),所以社区开发了几种更友好的前端工具:
- BCC (BPF Compiler Collection)
- bpftrace
- libbpf + BPF CO-RE (Compile Once – Run Everywhere)
最简单的开始方式是安装 BCC 的二进制包。
⚠️ 安装提示
你可以按照官方指南安装 BCC 工具包。
但在某些老旧发行版(比如 Ubuntu 18.04)上,直接安装 bpfcc-tools 可能只适用于预构建的发行版内核。
因为安装过程会依赖 linux-headers-$(uname -r) 包。这个头包只存在于发行版内核中,对于我们要跑的自定义 5.10 内核可能找不到匹配的。
不过,在 Ubuntu 20.04 LTS 上,即使跑自定义内核,通常也能正常工作。
安装好 bpfcc-tools 之后,你可以通过下面的命令感受一下它有多么庞大的工具库:
dpkg -L bpfcc-tools |grep "^/usr/sbin.*bpfcc$"
在我的 x86_64 Ubuntu 20.04 LTS 客户机(跑着我们自定义的 5.10.60-prod01 内核)上,这一命令显示安装了整整 112 个 *-bpfcc 工具(它们实际上是 Python 脚本)。
实战:execsnoop-bpfcc
上一节我们尝试用 perf-tools 追踪 execve() 失败了。现在,既然 eBPF BCC 前端就绪,我们卷土重来:
$ uname -r
5.10.60-prod01
$ sudo execsnoop-bpfcc 2>/dev/null
[...]
PCOMM PID PPID RET ARGS
id 7147 7053 0 /usr/bin/id -u
id 7148 7053 0 /usr/bin/id -u
git 7149 7053 0 /usr/bin/git config --global credential.helper cache --timeout 36000
cut 7151 7053 0 /usr/bin/cut -d= -f2
grep 7150 7053 0 /usr/bin/grep --color=auto ^PRETTY_NAME /etc/os-release
cat 7152 7053 0 /usr/bin/cat /proc/version
ip 7157 7053 0 /usr/bin/ip a
sudo 7159 7053 0 /usr/bin/sudo route -n
route 7160 7159 0 /usr/bin/sudo route -n
[...]
看,它就像一阵风一样工作起来了。
只要系统里有进程执行,execsnoop-bpfcc 就会打印一行详情,告诉你刚才谁干了什么。注意看,它甚至把执行命令的完整参数都显示出来了!
建议你跑一下 -h 参数看看帮助页,或者查一下 man page,里面有很多实用的一行示例。
和 perf-tools 一样,所有的 *-bpfcc 脚本都必须由 root 身份运行。另外,这工具刚启动时可能会有点吵(输出一堆噪音),我在上面把它重定向到了 /dev/null,这样清爽很多。
回顾:opensnoop-bpfcc
还记得本章最开头那个老掉牙的例子吗?追踪 do_sys_open()。
用 BCC 也能做,而且更简单:
$ sudo opensnoop-bpfcc 2>/dev/null
PID COMM FD ERR PATH
1431 upowerd 9 0 /sys/devices/LNXSYSTM:00/LNXSYBUS:00/PNP0A03:00/PNP0C0A:00/power_supply/BAT0/voltage_now
1431 upowerd 9 0 /sys/devices/LNXSYSTM:00/LNXSYBUS:00/PNP0A03:00/PNP0C0A:00/power_supply/BAT0/capacity
1431 upowerd -1 2 /sys/devices/LNXSYSTM:00/LNXSYBUS:00/PNP0A03:00/PNP0C0A:00/power_supply/BAT0/temp
[...]
431 systemd-udevd 14 0 /sys/fs/cgroup/unified/system.slice/systemd-udevd.service/cgroup.procs
431 systemd-udevd 14 0 /sys/fs/cgroup/unified/system.slice/systemd-udevd.service/cgroup.threads
[...]
^C
如果你想深入了解 eBPF 追踪工具的深度,Brendan Gregg 的这个页面是你的必经之地: https://www.brendangregg.com/ebpf.html
本章回响
这一章很长,但我们走完了从“土法炼钢”到“现代化工厂”的完整路径。
本章的核心认知是:可观测性是系统调试的基石。 我们最初不得不手写内核模块来插入探针,这是“静态”时代,改一行代码就要重新编译,在生产环境风险极大。
后来我们掌握了 ftrace 和 kprobe_events,那是“动态”时代的开始——不需要写一行 C 代码,仅仅通过 debugfs 的接口就能动态插入探针。这让生产调试变得可行。
最后,当我们在 execve() 这样特殊的系统调用上遇到阻碍时,eBPF 的出现把这一切推向了新的高度。它不仅解决了兼容性问题,还提供了一个沙盒化的、安全的、高性能的内核执行环境。
还记得开头那个问题吗——如何窥探系统的内部运作? 现在你手里不仅有了显微镜,还有了内窥镜。无论是文件打开、进程执行,还是未来可能遇到的各种内核事件,你都有办法在不清停服务的情况下抓到数据。
在继续之前,强烈建议你停一下。
去敲几个命令,去试着写一个简单的 kprobe 脚本,或者把 execsnoop 在你的板子上跑起来。光看是学不会内核的,你得把手弄脏。
下一章,我们将把视线从“代码执行”转向“内存管理”。那是一个更令人头痛,但也更值得征服的领域。准备好和 OOM(Out Of Memory)以及内存泄漏打交道了吗?
我们下章见。