BCC脚本执行链路

一条 BCC 脚本为什么能跑进内核?把执行链路一次讲清

很多人第一次用 BCC,都会有一种“像在变魔术”的感觉:明明只是运行了一段 Python 脚本,怎么就能监听系统调用、抓内核事件,甚至把内核里的数据实时打印出来?

如果把 BCC 的工作过程拆开看,它其实并不神秘。核心链路只有 6 步:启动用户态脚本、编译内嵌 eBPF 代码、通过bpf()加载进内核、借助perf_event_open()挂到事件上、在内核态执行、最后再把数据送回用户态。把这条链路看清楚,BCC 的运行机制也就通了。

一、先看全链路:BCC 到底做了什么

BCC(BPF Compiler Collection)最大的价值,不是发明了一套新的内核机制,而是把 eBPF 开发中原本琐碎、底层、容易踩坑的步骤包装起来,让工程师可以更快写出可运行的工具。

它的典型执行链路可以概括成下面这样:

用户态脚本启动 -> 内嵌 C 代码 -> Clang/LLVM 编译成 eBPF 字节码 -> 调用 bpf() 加载进内核 -> Verifier 校验通过 -> 通过 perf_event_open() 挂到目标事件 -> 事件发生时触发 eBPF 程序 -> 写入 BPF Map / perf buffer -> 用户态读取结果并输出

如果你把 BCC 看成一个“eBPF 开发加速器”,它的定位就很清楚了:用户态负责控制流程,内核态负责快速执行,BCC 负责把两边顺起来。

二、第一步:启动脚本,准备用户态控制逻辑

BCC 工具通常从一个 Python 脚本开始,比如:

sudopython3 opensnoop# 注意:opensnoop的名字和平台相关,比如Ubuntu平台安装bpfcc-tools包,那么它的名字会是opensnoop-bpfcc

这一步表面上只是“运行脚本”,本质上是在启动一段用户态控制程序。它会完成几件事:

  • 导入bcc
  • 准备内嵌的 eBPF C 代码
  • 设置挂载目标、过滤条件和输出逻辑
  • 准备和内核交换数据所需的 Map 或缓冲区

所以,BCC 脚本并不只是“展示结果”的壳,它本身就是整个 eBPF 工具链的控制平面。

三、第二步:把内嵌 C 代码编译成 eBPF 字节码

在很多 BCC 脚本里,你都会看到一大段字符串形式的 C 代码。那部分代码并不是给 Python 运行的,而是给 Clang/LLVM 编译的。

当执行到BPF(text=bpf_text)这类逻辑时,BCC 会调用编译工具链,把这段 C 代码即时编译成eBPF 字节码

这一步的价值在于,它让开发者可以直接用更熟悉的 C 语法描述内核态逻辑,而不用手写底层字节码。

不过这里要特别说明一点:BCC 的这套动态编译机制,并不等同于 CO-RE。BCC 更常见的做法,是依赖当前系统的内核头文件在本机完成编译,所以它更像“针对当前内核环境即时适配”,而不是“一次编译到处运行”。

四、第三步:通过bpf()把程序送进内核,并先过 Verifier

编译完成后,用户态的 BCC 库会调用bpf(BPF_PROG_LOAD, ...),把刚刚生成的 eBPF 字节码送入 Linux 内核。

但这还不是“送进去就能跑”。在真正加载成功之前,内核还会先让它过一遍Verifier

Verifier 的职责很明确:确保这段程序不会破坏内核安全。它会重点检查这些问题:

  • 是否存在非法内存访问
  • 是否可能出现越界读写
  • 是否存在不安全的指针操作
  • 是否有可能导致不可控执行路径

只有校验通过,程序才会真正被内核接受。随后,JIT 编译器还会把 eBPF 字节码转换成当前 CPU 的本地机器指令,以获得接近原生代码的执行效率。

所以你可以把这一阶段理解成:先审,再上岗

五、第四步:通过perf_event_open()把程序挂到事件上

程序加载进内核后,还不能凭空运行。它必须先“接到某个触发点上”,也就是挂到具体事件上。

这时,BCC 会自动处理很多底层细节,其中最关键的一个系统调用就是perf_event_open()

它通常承担这几件事:

  • 创建用于监听目标事件的 perf event fd
  • 把已加载的 eBPF 程序绑定到这个 fd 上
  • 通过ioctl(..., PERF_EVENT_IOC_ENABLE, ...)激活事件

这就是为什么很多基于 BCC 的工具,表面上看在用 eBPF,底层却总会和 perf 扯上关系。因为很多追踪和采样类场景,本来就是借助 perf 的事件基础设施来触发 eBPF 程序的。

六、第五步:事件一发生,eBPF 就在内核里快速执行

当目标事件真的发生时,比如某个进程调用了openat(),或者某个 tracepoint 被触发,已经挂载好的 eBPF 程序就会在内核态被快速拉起执行。

在这个阶段,eBPF 程序通常会做三类事:

  • 从上下文中提取关键信息,比如 PID、进程名、时间戳、文件名
  • 根据业务逻辑做过滤、统计或聚合
  • 把结果写入 BPF Map 或 perf buffer

这也是 eBPF 之所以适合做观测和性能分析的原因:它离事件发生点很近,运行路径很短,额外开销相对可控。

七、第六步:用户态再把结果读出来并格式化展示

内核态程序跑完之后,结果并不会自己出现在屏幕上。最后一步,还是要回到用户态来做消费和展示。

BCC 用户态程序一般会通过两种方式读取结果:

  • 轮询 BPF Map,读取统计状态
  • 监听 perf buffer,把内核态推送出来的事件流拉回来

拿到原始数据之后,Python 脚本再去做格式化处理,比如:

  • 把时间戳转成人类可读格式
  • 把 PID、进程名、文件路径拼成完整输出
  • 将结果打印到终端,或者转发到监控系统

所以从整体上看,BCC 的输出并不是“eBPF 直接打印出来”的,而是内核态负责采集,用户态负责解释和展示

八、为什么 BCC 总和 perf 一起出现?

eBPF 和 perf 在 Linux 内核里关系很深,至少体现在两个维度。

1. perf 是很多 eBPF 程序的触发通道

很多性能分析和追踪类程序,并不能自己主动运行,而是要依附某个事件源。perf 提供的perf_events基础设施,刚好承担了这个角色。

当你在 BCC 或 bpftrace 里写了一个基于采样、硬件计数器、Kprobe 或 Tracepoint 的程序时,底层通常都是 perf 在负责监听事件,而 eBPF 只是被挂在这个事件源上执行。

2. perf 也是一条高性能的数据回传通道

eBPF 运行在内核态,想把采集到的大量结构化数据传回用户态,常见方式之一就是通过 perf 环形缓冲区。

这时,内核态程序会调用bpf_perf_event_output()把数据写进去;用户态的 BCC 进程再通过 mmap 方式去读取,从而实现低开销的数据传输。

如果你看到BPF_MAP_TYPE_PERF_EVENT_ARRAY,基本就可以把它理解成:这是 eBPF 借助 perf 往用户态送数据的一条高速公路。

写在最后

如果你站在工程实现的角度再回头看 BCC,会发现它真正厉害的地方,不是把 eBPF 变简单了,而是把“编译、加载、挂载、采集、回传、展示”这条原本分散的链路,整理成了一套可直接上手的开发体验。

所以,BCC 不是魔法,也不是黑盒。它只是替你把复杂的底层接口封装好了,让你可以把更多精力放在“我要观察什么、统计什么、定位什么问题”上。

把这条执行链路看明白之后,再去看opensnoopexecsnoopbiolatency这类工具,你就会更容易理解:它们表面是脚本,背后其实都是同一套 eBPF 运行模型。