掘金 后端 ( ) • 2024-04-03 10:51

一、eBPF是什么?

eBPF是一种技术, 一种可以在不修改内核源码或者load内核模块的情况下扩展内核能力的技术,而且具有安全性能高的特点。

内核作为操作系统的核心部分,其稳定性和安全性至关重要。直接修改内核源码不仅风险高,而且可能导致系统不稳定或引入新的安全问题。此外,随着操作系统的不断发展和更新,对内核的修改可能很快过时,需要不断跟进和维护,这无疑增加了开发者的负担。

eBPF技术的出现解决了这一难题。它允许在内核中运行用户编写的程序,而这些程序可以用于各种功能,如网络过滤、系统跟踪和性能分析等,而无需对内核源码进行任何修改。也就是说开发者可以在不改变内核本身的情况下,为系统添加新的功能或优化现有功能。

eBPF具有广泛的应用场景,如网络过滤、网络监控和系统观察等:在现代数据中心和云计算环境中提供高性能的网络和负载均衡,低成本获取安全数据,帮助开发者排查性能问题,还有确保应用和容器运行时的安全。eBPF带来的创新才刚刚开始。

有了eBPF,你可以把自定义的“沙盒”代码附到内核通过符号表导出的几乎每个函数上,而且不用担心搞崩内核。实际上eBPF特别强调安全性,尤其是当你要越过用户空间边界的时候。内核验证器可不会加载任何发现无效指针引用或者堆栈太大的eBPF程序。而且不让用循环(除非在编译时就知道循环次数),在生成的字节码里只允许调用特定的eBPF helper函数。eBPF程序保证会在某个时候停下来,绝对不会狂占系统资源。当然,有人可能觉得eBPF相对于“自由度”超高的内核模块来说有点限制,但在我看来,权衡的结果更可能偏向eBPF,主要是因为eBPF程序保证不会对内核造成伤害。当然啦,好处还不止这些。

1.1 BPF、eBPF、cBPF傻傻分不清?

最初BPF(又叫伯克利数据包过滤器)仅仅是一种非常高效的网络数据包过滤机制,因为它直接在内核中操作网络数据包数据。BPF最为人熟知的应用应该是在tcpdump工具中使用的过滤表达式,在底层,这些表达式被编译并透明地转换为BPF字节码。然后将这个字节码加载到内核中,并应用于原始网络数据包流,从而有效地仅将符合过滤条件的数据包传递到用户空间。

但现在的eBPF(扩展BPF)可不只是过滤数据包了,所以这个首字母缩写已经没有意义了。现在大家都把eBPF当个独立名词,也不表示啥具体的东西。在Linux源代码里,还是能见到术语BPF,但在工具和文档中,通常BPF和eBPF这俩词经常混着用。原来的BPF有时候被叫做cBPF(classic BPF),就是为了跟eBPF区分开。

1.2 小结

总结一下,我们搞清楚了: eBPF是一个允许用户在操作系统内核中加载和运行自定义程序的技术。那么具体eBPF可以用来做什么呢?我们下一节分析。

二、eBPF可以用来做什么?

如上所述,能随时调整内核行为的能力非常有用。传统上,如果我们想看看我们的应用怎么运行,就得往应用里插入代码,生成各种日志和追踪信息。但有了eBPF,我们可以在不碰应用本身的情况下,从内核里窥探应用的行为,生成自定义信息。这个观察技能还能用来开发eBPF安全工具,从内核里探测甚至阻止可疑行为。而且,我们还可以借助eBPF搞出强大的网络功能,直接在内核里处理网络数据包,省去了在用户空间和内核之间频繁转换的烦恼。

从内核的角度观察应用程序的概念并不是一个新概念——比如perf,它也能从内核里搜集行为和性能信息,而不用改应用本身。但这些工具有一些限制,规定了能搜集哪些数据,以及以什么样的格式呈现。有了eBPF,我们就有更多的灵活性,因为我们可以写出完全定制的程序,这样我们就能为各种各样的目的搭建一堆工具。

我会在文章后面再详细讨论一些更高级的工具,但如果你想马上看基于eBPF的工具,BCC项目是一个很好的开始, 它有一堆追踪工具;光是看BCC的工具列表就能让你对我们可以用eBPF的操作范围有些概念,包括文件操作、内存使用、CPU统计,甚至是监视系统中任何地方输入的bash命令。

下面我介绍一些和eBPF相关的开源项目和项目的简介,你就能知道eBPF的应用范围有多广了。

总结:

eBPF的特点:

  1. 灵活性:不重新编译内核就能扩展功能,窥探行为。

  2. 安全性:BPF程序加载时会校验字节码,不会搞崩内核。

  3. 高性能:直接在内核中运行,没有用户态的损耗。

下面👇是一些基于eBPF或者相关的知名开源项目:

网络相关

  • Cilium:一个依赖于BPF和XDP技术的项目,提供了“基于eBPF程序动态生成的内核内高速网络和容器安全策略执行”。

  • Open vSwitch(OvS)及其相关项目Open Virtual Network(OVN,一种开源网络虚拟化解决方案)正在考虑在各个层面使用eBPF

  • Katran - Meta开源的基于XDP的L4负载均衡。

  • Project Calico: 知名的K8s网络插件,也是用了eBPF提供一个低延迟,高吞吐的数据面

可观测性相关

  • beyla:一个使用eBPF的应用指标采集工具,基于eBPF实现了内核层面http/https和应用层面go-rpc等指标的自动采集。

  • Hubble:和Cilium配套的监控系统,使用eBPF的K8s网络、服务、安全性可观测的平台。

  • pixie - 利用eBPF实现对Kubernetes的可观测性。其功能包括协议跟踪、用户应用性能profile,以及支持分布式bpftrace部署。

安全相关

  • Falco:使用eBPF检测主机和容器中的恶意行为。

工具类

  • bpftrace - 一个问题排查工具,使用自定义语言,基于eBPF。

看到这些开源项目是不是对eBPF的强大有所理解了,想动手试一试了?但是在写BPF程序之前,我们对eBPF程序本身的一些概念要理清楚,下面就让我们看看吧。

三、动手写eBPF程序之前需要了解的概念

在这一节中让我们把目光转向如何写BPF代码,我们需要知道BPF程序是运行在内核中的,那么一定需要将其加载到内核。所以我们需要考虑以下几方面的内容:

  • 运行在内核和用户态中的BPF代码是怎么写的?

  • BPF程序是怎么被触发的?

  • 内核和用户空间怎么交互?

3.1 用什么语言写?

首先要明确的是,BPF程序可以用什么语言写?内核是通过加载BPF字节码的,你可以手写字节码,但一般不这样做

以前(cBPF时代)人们通过原始的BPF指令集指令生成最终的字节码。这种方式有点像写汇编或者Java字节码。你可以手写字节码,但一般不这样做。因为clang编译器(LLVM前端的一部分)可以将C代码转换为eBPF字节码。

其他高阶语言可以用来编写BPF内核程序吗?

不行。其原因主要有两点:

  • 语言编译器需要支持生成内核期望的eBPF字节码格式,目前大多数语言不支持。

  • 许多语言具有运行时特性,例如Go/Java的内存管理和垃圾回收。

截至目前,编写eBPF程序的唯一选择是C(使用clang/llvm编译)和较近期的Rust。

当我们使用clang/LLVM编译使用C语言写的BPF程序为Object文件之后,接下来就要将其load到内核中,因此我们需要前面说的用户空间的代码将其load到内核并且将其attach到正确的事件上。有一些工具比如bpftool可以帮助我们load/attach,当然大多数打包好的bpf工具都不需要你操心这些,基于bpf的工具的用户空间代码负责将eBPF程序加载到内核中,并以用户友好的方式显示eBPF程序收集的信息。

eBPF用户空间的部分理论上可以用任何语言编写,比较流行的有:C、Go、Rust、Python等,因为这些语言有支持libbpf的库,而libbpf已成为使eBPF程序在不同内核版本之间可移植的标准选择(后面我们会讲libbpf)。

3.2 如何加载BPF程序到内核?

上面讲了bpf程序内核部分通常使用C或者Rust编写,并且生成编译后的Object文件,文件是ELF格式的,可以用readelf工具分析内部,其内部包含编译后的字节码和Map的定义(什么是Map我们待会讲)。

如下图所示用户空间程序读取这个BPF程序编译后产生的ELF格式文件,通过verifier的校验之后才能加载到内核。

当加载BPF程序到内核之后,必须要将其attach到一个event事件上,每当事件触发时,attach在这个事件上的BPF程序都会运行,事件的类型有很多,在下面会列举一些最常用的事件。

3.2.1 安全性保证——Verifier

verifier是 BPF 子系统的核心组件, 它通过一些规则确保 BPF 程序在执行时是安全的, 因为BPF程序是在内核中运行的, 如果不经过适当检查,BPF 程序可能对系统造成严重问题,比如破坏内存、泄露敏感信息、内核崩溃或内核挂起/死锁等。。

以下是一些verifier会校验的规则:

  • 如果代码里有循环, 循环次数必须静态定义, 不能是动态的, 防止死循环

  • BPF程序能够访问的内存地址是有限的, 不能访问任意内存, 可以通过bpf helper function受控的访问, 防止泄露敏感信息

  • 网络相关程序在访问网络数据包时不能访问网络数据包地址范围之外的内存, 原因类似上面.

  • BPF程序的指令数不能超过100000, 防止BPF程序执行时间过长

  • ...

这些规则定义的还是很严格的, 有时候明显正确的代码, 校验器还是认为有问题(我认为是校验器的问题), 高版本内核相对好一些, 建议想自己玩eBPF尽量用高版本的内核.

verifier校验通过之后的BPF代码就能在内核运行了, 具体是在eBPF VM里, 这是一个类似JVM的虚拟机, 下面就让我们一起看看吧.

3.2.2 eBPF VM

和JVM类似, eBPF也是一个虚拟机, 它可以把BPF字节码指令转换为运行在对应架构CPU的机器码。

早期实现中, 字节码指令是解释执行的, 也就是说每当eBPF程序运行时内核就会转换为相应的机器码执行. 目前由于性能问题已经基本上被JIT即时编译替代了, 在BPF程序加载到内核的时候只需要转为机器码一次就ok了。

3.3 BPF程序可以Attach的事件类型

下面会列举一些最常用的事件,BPF程序里根据事件的类型不同可以调用的BPF helper function也是不同的,这个接下来会讲到。

什么是BPF helper 函数?

BPF helper function是内核定义的BPF程序可以调用的内核函数, BPF程序可以利用这些helper function和内核交互, 随着内核的演进能够调用的helper function也越来越多, 常用的包括: Map操作相关, 获取pid, 获取当前内核时间, 读取用户/内核空间内存等.

3.3.1 任意函数的调用&返回

可以将eBPF程序attach到在内核函数进入或退出时触发。使用kprobes attach到内核函数入口点和kretprobes(函数退出)。

还可以使用uprobes和uretprobes将eBPF程序附加到用户空间函数。

(一般来说内联函数是无法attach的,这点要注意)

3.3.2 Tracepoint

也可以attach到内核预定义的一些tracepoint上,可以通过/sys/kernel/debug/tracing/events目录下查看,比如:

# ll /sys/kernel/debug/tracing/events/syscalls/ | more
total 0
-rw-r-----   1 root root 0 Oct 30 21:20 enable
-rw-r-----   1 root root 0 Oct 30 21:20 filter
drwxr-x---   2 root root 0 Oct 30 21:20 sys_enter_accept/
drwxr-x---   2 root root 0 Oct 30 21:20 sys_enter_accept4/
drwxr-x---   2 root root 0 Oct 30 21:20 sys_enter_access/
drwxr-x---   2 root root 0 Oct 30 21:20 sys_enter_acct/
drwxr-x---   2 root root 0 Oct 30 21:20 sys_enter_add_key/
drwxr-x---   2 root root 0 Oct 30 21:20 sys_enter_adjtimex/
drwxr-x---   2 root root 0 Oct 30 21:20 sys_enter_alarm/

如上是syscalls子目录下的,还有很多其他类型,就不一一赘述了。我们可以在这种类型的事件上获取参数和返回值,enter和exit类型的事件联合使用的话还能够计算系统调用的耗时。

3.3.3 Perf Events

Perf工具是lInux上的一个性能监控工具,perf支持监控多种内核事件,可以通过perf list查看,在我的ubuntu20.04输出如下:

# perf list

List of pre-defined events (to be used in -e):

  alignment-faults                                   [Software event]
  bpf-output                                         [Software event]
  cgroup-switches                                    [Software event]
  context-switches OR cs                             [Software event]
  cpu-clock                                          [Software event]
  cpu-migrations OR migrations                       [Software event]
  dummy                                              [Software event]
  emulation-faults                                   [Software event]
  major-faults                                       [Software event]
  minor-faults                                       [Software event]
  page-faults OR faults                              [Software event]
  task-clock                                         [Software event]

  duration_time                                      [Tool event]

  msr/tsc/                                           [Kernel PMU event]

  rNNN  

可以看到既有软件事件也有硬件事件,我们通过attach BPF程序到这些事件上,就能在事件发生时获取到额外的信息比如当前cpu上执行的函数还有调用栈等,然后我们可以将这些信息发到用户空间进一步分析等。

3.3.4 XDP

XDP全称是eXpress Data Path,可以让我们attach BPF程序到网卡(或者网卡驱动上)上,每当接收到数据包时就会触发BPF程序的调用,我们可以决定包是否PASS——即正常传入到协议栈,DROP——直接丢包,还能修改数据包的内容,还可以将包Redirect重定向到其他网络设备,可以说很多高性能的网络相关开源项目都和XDP相关。

3.3.5 Socket相关

我们可以在socket open或者socket收发消息的时候触发BPF程序的执行,在这里我们可以修改TCP头,也同样可以PASS、DROP或者重定向数据到其他socket。

3.3.6 小结

上述我们讲了常见的BPF程序可以attach的类型,一般一个BPF程序只能收集一种数据或者实现一种功能,但是大多数情况下是多个BPF程序协同工作的,这就涉及到数据传输问题,两个BPF程序之间如何传递数据呢?另外BPF程序在这些Hook点收集到数据之后该怎么传递用户态来进一步处理呢?这就需要用到eBPF Maps机制。

3.4 Maps

在eBPF中,maps是一种键值对数据结构,常用来:

  • 写入采集到的指标数据到Maps,以便于用户空间之后收集处理。(比如记录函数调用次数)

  • 用户空间写入控制内核BPF程序的配置(比如控制BPF内核程序收集特定的指标)

  • 用于在多个BPF程序之间共享数据

  • 用于多次执行BPF程序需要累计的数据,比如计数、平均值计算等。

当前Maps类型可参考Linux内核文档:https://docs.kernel.org/bpf/maps.html

⚠️注意⚠️

如果写用户空间的程序语言和内核BPF程序相同,只需要包含同一个包含Map定义的header文件就可以,但如果不同(比如用户空间是Python),那么就需要仔细创建结构定义,确保在字节级别上是兼容的

3.5 小结

在这一节里介绍了eBPF程序是用什么语言写的,以及其加载到内核的流程,并且介绍了其中的关键组件verifier和eBPF VM,接着我们看了eBPF可以attach的一些常见事件类型,最后我们介绍了Maps。

那么我们就可以回答本节开始的问题了:

  • 运行在内核/用户态中的BPF代码是怎么写的?

    • 答:用C语言写的。用户态程序可以用多种语言写,较多的是C、Rust、Python等。
  • BPF程序是怎么被触发的?

    • 答:用户态程序负责load内核态BPF字节码到内核,内核校验通过后,通过attach到不同事件上,每当事件触发时BPF程序就会调用。
  • 内核和用户空间怎么交互?

    • 答:通过eBPF Maps交互。

既然你已经了解了以上重要概念,那么接下来让我们分析一个实际的BPF程序源码吧!可以更好的帮你掌握上述概念。

四、Example

下面我们以bcc工具opensnoop为例讲解一个具体的BPF是如何编写的.

首先来介绍下opensnoop工具是干什么的(代码:https://github.com/iovisor/bcc/blob/master/libbpf-tools/opensnoop.c). opensnoop工具是用来跟踪open类型的系统调用的, 可以执行opensnoop, 输出如下:

# opensnoop
PID    COMM               FD ERR PATH
2042   kubelet            24   0 kubelet.slice/kubelet-kubepods.slice/kubelet-kubepods-burstable.slice/memory.swap.max
2042   kubelet            15   0 kubelet.slice/kubelet-kubepods.slice/kubelet-kubepods-burstable.slice/io.stat
2042   kubelet            15   0 kubelet.slice/kubelet-kubepods.slice/kubelet-kubepods-burstable.slice/cpu.stat
2042   kubelet            15   0 kubelet.slice/kubelet-kubepods.slice/kubelet-kubepods-burstable.slice/hugetlb.2MB.current
2042   kubelet            15   0 kubelet.slice/kubelet-kubepods.slice/kubelet-kubepods-burstable.slice/hugetlb.2MB.events
2042   kubelet            15   0 kubelet.slice/kubelet-kubepods.slice/kubelet-kubepods-burstable.slice/hugetlb.1GB.current
2042   kubelet            15   0 kubelet.slice/kubelet-kubepods.slice/kubelet-kubepods-burstable.slice/hugetlb.1GB.events
2042   kubelet            15   0 kubelet.slice/kubelet-kubepods.slice/kubelet-kubepods-burstable.slice/rdma.current
2042   kubelet            15   0 kubelet.slice/kubelet-kubepods.slice/kubelet-kubepods-burstable.slice/rdma.max

opensnoop的原理是通过attach到open和openat系统调用事件上, 每当应用执行open/openat系统调用时都会触发opensnoop的执行.

从输出中可以看到, 有每次调用的进程id, 进程可执行文件名称, 打开的fd, 返回的错误码还有打开的路径. 其中FD和ERR是open系统调用的返回值(正数为fd,负数代表err), PATH是参数, 而COMM和PID既不是参数也不是返回值,那现在提出以下几个问题:

  • 参数和返回值是如何获取到的?

  • PID和COMM不是参数也不是返回值是怎么拿到的?

  • 这些数据怎么上报到用户空间的?

带着问题现在让我们一起来看看它的代码是怎么写的吧!

4.1 BPF Program

我们先从内核部分看起, 在opensnoop.bpf.c中有如下程序定义:

SEC("tracepoint/syscalls/sys_enter_open")
int tracepoint__syscalls__sys_enter_open(struct trace_event_raw_sys_enter* ctx)
{
	// ...
}

SEC("tracepoint/syscalls/sys_exit_open")
int tracepoint__syscalls__sys_exit_open(struct trace_event_raw_sys_exit* ctx)
{
	// ...
}

SEC("tracepoint/syscalls/sys_enter_openat")
int tracepoint__syscalls__sys_enter_openat(struct trace_event_raw_sys_enter* ctx)
{
	// ...
}

SEC("tracepoint/syscalls/sys_exit_openat")
int tracepoint__syscalls__sys_exit_openat(struct trace_event_raw_sys_exit* ctx)
{
	// ...
}

可以看到这里定义了四个BPF程序, 分别处理open和openat两个系统调用的进入enter和返回exit(这两个的处理方式都差不多, 后面只分析处理open系统调用的BPF程序实现). 这里的SEC是ELF对象文件中的section的定义, 当BPF的elf格式文件创建时, 其中会包含每个bpf程序和map的section, 准备之后加载到内核, SEC就是用来定义这些section的.

并且可以看到它们每个都有一个struct trace_event_raw_sys_enter*类型的参数,可以从这里获取调用参数, 而struct trace_event_raw_sys_exit中可以获取调用返回值.

在vmlinux.h里可以找到这些结构体的定义, 由于BPF可以attach的事件非常多, 每个BPF程序的上下文都不一样, 因此如何在不同的上下文中获取到我们想要的数据是写BPF程序时的难点.

什么是vmlinux.h?

生成vmlinux.h文件的命令如下:

bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

include该vmlinux.h,就意味着程序可以使用内核中使用的所有数据类型定义,因此BPF程序在读取相关的内存时,就可以映射成对应的类型结构按照字段进行读取。

除了这些BPF程序之外, 还定义了两个Map:

struct {
	__uint(type, BPF_MAP_TYPE_HASH);
	__uint(max_entries, 10240);
	__type(key, u32);
	__type(value, struct args_t);
} start SEC(".maps");

struct {
	__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
	__uint(key_size, sizeof(u32));
	__uint(value_size, sizeof(u32));
} events SEC(".maps");

这里有两个Map: start 和 events. 刚刚讲过的SEC(".maps")表明它们在ELF对象文件中的.maps section里, 其中start用来存储调用的参数和调用开始的时间, events用来传递采集到的数据给用户空间.

看完了BPF程序和Maps的定义,现在让我们来看程序的实现部分, 首先看调用开始部分是如何实现的:

SEC("tracepoint/syscalls/sys_enter_open")
int tracepoint__syscalls__sys_enter_open(struct trace_event_raw_sys_enter* ctx)
{
	u64 id = bpf_get_current_pid_tgid(); // 1
	u32 tgid = id >> 32;
	u32 pid = id;

	struct args_t args = {}; // 2
	args.fname = (const char *)ctx->args[0];
	args.flags = (int)ctx->args[1]; 
	bpf_map_update_elem(&start, &pid, &args, 0); // 3
	return 0;
}

① 首先通过bpf_get_current_pid_tgid这个BPF Helper函数获取进程id:

u64 id = bpf_get_current_pid_tgid();

② 然后从ctx调用参数的文件名和flag,

args.fname = (const char *)ctx->args[0];
args.flags = (int)ctx->args[1];

③ 然后存到start中, key是进程id, value是函数调用参数

bpf_map_update_elem(&start, &pid, &args, 0);

接下来看函数返回部分的BPF程序实现:

static __always_inline
int trace_exit(struct trace_event_raw_sys_exit* ctx)
{
	struct event event = {};
	struct args_t *ap;
	uintptr_t stack[3];
	int ret;
	u32 pid = bpf_get_current_pid_tgid(); // 1

	ap = bpf_map_lookup_elem(&start, &pid); // 2

	/* event data */
	event.pid = bpf_get_current_pid_tgid() >  32;
	event.uid = bpf_get_current_uid_gid();
	bpf_get_current_comm(&event.comm, sizeof(event.comm)); // 3
	bpf_probe_read_user_str(&event.fname, sizeof(event.fname), ap->fname); // 4
	event.flags = ap->flags;
	event.ret = ret;

	/* emit event */
	bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU,
			      &event, sizeof(event)); // 5

cleanup:
	bpf_map_delete_elem(&start, &pid);
	return 0;
}

① 首先同样通过bpf_get_current_pid_tgid获取进程id

② 然后从start中获取调用时塞进去的参数fname和flag:

ap = bpf_map_lookup_elem(&start, &pid);

当前进程的可执行文件名称通过:

③ 当前进程的可执行文件名称通过:

bpf_get_current_comm(&event.comm, sizeof(event.comm));

直接获取, 直接存到event.comm字段里.

④ 注意到之前存参数的时候文件名称fname是一个char指针, 它是用户空间的一块内存, 在BPF程序中不能直接访问用户态内存, verifier必须确保安全的读取这块内存防止出错, 这里使用了:

bpf_probe_read_user_str(&event.fname, sizeof(event.fname), ap->fname);

来读取, bpf_probe_read_user_str专门用来读取用户空间的字符串到内核.

⑤ 最后通过bpf_perf_event_output写入event到perf缓冲区map:

bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU,&event, sizeof(event));

接下来的用户空间代码可以从event这个map中获取这里写入的数据, 这样就完成了内核态数据到用户态的传递!

4.2 Makefile

写完BPF程序后就需要将它构建为ELF格式的Object文件, 然后由用户空间的程序将其加载到内核. 接下来让我们看下Makefile是怎么将opensnoop构建为eBPF对象文件的.

这个Makefile很长, 我们只需要关注其中关键的即可.

4.2.1 生成bpf对象文件

$(OUTPUT)/%.bpf.o: %.bpf.c $(LIBBPF_OBJ) $(wildcard %.h) $(ARCH)/vmlinux.h | $(OUTPUT)
	$(call msg,BPF,$@)
	$(Q)$(CLANG) $(BPFCFLAGS) -target bpf -D__TARGET_ARCH_$(ARCH)	      \
		     -I$(ARCH)/ $(INCLUDES) -c $(filter %.c,$^) -o $@ &&      \
	$(LLVM_STRIP) -g $@

这个规则将x.bpf.c编译为bpf对象文件, 使用clang, target参数是bpf, 然后加上必要的头文件. 构建出来的文件输出到.output/opensnoop.bpf.o.

4.2.2 生成skel骨架文件

$(OUTPUT)/%.skel.h: $(OUTPUT)/%.bpf.o | $(OUTPUT) $(BPFTOOL)
	$(call msg,GEN-SKEL,$@)
	$(Q)$(BPFTOOL) gen skeleton $< > $@

这个规则利用bpftool gen skeleton命令生成一个skeleton header文件, 这里面包含了program(字节码)和map的定义. 在接下来将要看到的用户态程序里我们将会看到用户态程序通过include 这个头文件获取BPF程序的定义和Map的结构, 用户态程序就负责将BPF的对象文件加载到(实际字节码在这个骨架文件里)内核, 并且之后从events这个Map中获取内核态收集的数据.

4.2.3 生成可执行文件

$(OUTPUT)/%.o: %.c $(wildcard %.h) $(LIBBPF_OBJ) | $(OUTPUT)
	$(call msg,CC,$@)
	$(Q)$(CC) $(CFLAGS) $(INCLUDES) -c $(filter %.c,$^) -o $@

这条规则编译用户态程序生成一个二进制文件:.output/opensnoop.o

$(APPS): %: $(OUTPUT)/%.o $(COMMON_OBJ) $(LIBBPF_OBJ) | $(OUTPUT)
	$(call msg,BINARY,$@)
	$(Q)$(CC) $(CFLAGS) $^ $(LDFLAGS) -lelf -lz -o $@

这条规则link用户态程序对象文件生成可执行文件.

以上就是内核态程序和用户态程序生成的过程, 接下来我们看用户态程序是怎么写的.

4.3 UserSpace Program

在上面我讲了用户态程序负责load BPF内核态程序, 接下来看它做了哪些操作.

我们从main函数开始, 可以看到:

struct opensnoop_bpf *obj;
obj = opensnoop_bpf__open_opts(&open_opts);
err = opensnoop_bpf__load(obj);
err = opensnoop_bpf__attach(obj);

有这么几个调用, 实际上这些函数都是bpftool gen skeleton根据bpf程序的对象文件自动生成的, 生成的文件中包含了所有BPF程序, Map等.

也可以看到用户态处理BPF程序按照: open=>load=>attach进行, 只有attach成功了BPF程序才真正可以运行.

tracepoint类型的BPF程序是能够自动attach的(你可以看到这里的opensnoop_bpf__attach没有其他参数), 但是有些类型的BPF程序是需要手动attach的, 这个我们在后面的文章详细了解BPF程序类型的时候再说.

当BPF程序可以运行之后, 用户态程序就负责监听内核态通过events这个Map传递的数据, 将其打印出来.

pb = perf_buffer__new(bpf_map__fd(obj->maps.events), PERF_BUFFER_PAGES,
			      handle_event, handle_lost_events, NULL, NULL);

perf_buffer__new打开了perf buffer关联的fd(events Map), 然后设置handle_even作为回调函数, 处理内核态的数据.

while (!exiting) {
		err = perf_buffer__poll(pb, PERF_POLL_TIMEOUT_MS);
		// ...
	}

这里通过perf_buffer__poll等待perf buffer的数据到来, 知道PERF_POLL_TIMEOUT_MS超时时间. 如果获取到数据了, 就触发之前设置的回调函数handle_event:

void handle_event(void *ctx, int cpu, void *data, __u32 data_sz)
{
// ...
}

handle_event回调函数签名的第三个参数就是struct event, 用户态程序就根据struct event的结构, 进行格式化输出.

小结

总结一下本节, 我们可以看到opensnoop通过Map在系统调用的进入和返回之间传递参数 , 通过bpf helper function获取一些辅助信息(比如pid和可执行文件名称)等, 将这些信息存到perf buffer Map里, 然后用户态程序从perf buffer Map中获取打印出来. 我们还知道了一个完整的BPF程序是如何构建的, 和用户态程序是加载BPF程序到内核的大致流程.

当然eBPF的能力绝不仅限于系统调用, 我之后的文章将会介绍其他可以attach的点, 甚至可以用来做一些坏事😈.

五、总结

通过本篇文章相信你已经学会了:

  1. eBPF是什么

  2. eBPF的使用场景

  3. eBPF的关键概念

  4. eBPF程序的大致结构和基础写法

本篇文章是系列第一篇,在接下来的文章中我将详细介绍eBPF程序在各种场景下的应用,包括:网络请求耗时跟踪、故障注入等,敬请期待!