跳到主要内容

4.8 追踪进程的上帝视角——通过 perf 和 eBPF 工具探秘 execve

在上一节里,我们证明了动态 kprobe 在追踪内核模块函数时几乎无所不能。但那是针对“我们自己写的代码”。

现在,我们要把目光投向一个更底层、更普遍的问题:系统里谁在干什么?

具体来说,就是——新进程是怎么诞生的?

如果你在上一节结尾的实战中感到了一丝丝掌控感,那么这一节我们会遇到一堵墙:传统的 kprobe 方法在某些关键路径上会失效。这会逼迫我们去寻找更强大的武器——eBPF。

execve:进程的源头

在 Linux(以及所有 UNIX)世界里,用户空间的程序——也就是一个个进程——的启动,都依赖于一个所谓的 exec 系列函数库。

你可能见过这一大堆名字:execl(), execlp(), execv(), execvp(), execle(), execvpe(), 以及 execve()

这一大家子 API 里,其实只有一位是真正的“老大”。

前六个(execlexecvpe)本质上都只是 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,你的系统得满足两个条件:

  1. Linux 内核版本 4.1 或更新
  2. 内核开启了 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)以及内存泄漏打交道了吗?

我们下章见。