eBPF
介绍
eBPF(extended Berkeley Packet Filter)是一种强大的内核技术,最初是为网络数据包过滤设计的。
eBPF 是基于传统的 BPF(Berkeley Packet Filter)扩展的。传统的 BPF 主要用于网络包过滤和捕获,能够直接在内核中处理数据包,从而减少了上下文切换的开销,提高了性能。
eBPF 允许开发者编写在内核执行的代码,以此来实现更为复杂的功能。通过这种方式,eBPF 提供了非常强大的内核级程序执行能力,而无需修改内核代码或加载新的内核模块。eBPF 程序本质上是在内核空间执行的小型、受限的程序。
工作原理:
eBPF 程序是由用户态代码编写的,并被加载到内核中。内核通过一个虚拟机(eBPF 虚拟机)来执行这些程序。eBPF 程序被限制在一个受控环境中,确保其不会对系统造成任何不良影响。
当 eBPF 程序被加载时,它会附加到内核的某个特定位置(例如,网络接口、系统调用、跟踪点等)。然后,内核会在相关事件发生时触发这些程序,从而执行相应的操作。
使用
在Linux机器上使用:eBPF- bpftrace
版本:
eBPF 支持从 Linux 3.15+ 开始,但某些功能(如 XDP、BPF Type Format (BTF) 等)需要更高的内核版本。建议使用至少 5.x 版本的 Linux 内核。
uname -r
bpftrace:
https://github.com/bpftrace/bpftrace
bpftrace 是一个专门用于运行 eBPF 程序的工具,它的语法更简洁,类似于 DTrace(用于 Solaris 系统的动态追踪工具)。它使用 eBPF 技术来追踪内核事件,执行轻量级的系统跟踪和性能分析。
bpftrace 脚本的工作原理
这行脚本:
tracepoint:syscalls:sys_enter_* { printf("syscall: %s\n", probe); }
表示 bpftrace 追踪所有进入的系统调用。让我们详细解释一下它的组成部分:
tracepoint:syscalls:sys_enter_\
tracepoint
是一种内核事件跟踪机制,**bpftrace 可以通过它来捕获和处理内核中发生的事件。syscalls
是事件的分类名称,表示我们要监听的是与系统调用相关的事件。sys_enter_\*
表示所有以sys_enter_
开头的系统调用。这些系统调用是进入内核空间之前触发的,比如sys_enter_read
、sys_enter_open
等。
{ printf("syscall: %s\n", probe); }
这是事件触发后要执行的动作。当系统调用进入时,bpftrace 会执行
{}
里面的代码。printf("syscall: %s\n", probe);
是打印系统调用的名称。
自动提供的变量,它代表当前触发的事件的名称。
虽然 bpftrace 脚本的语法看起来简洁易用,但它的核心仍然是通过 eBPF 程序来执行的,而 eBPF 程序本质上是 C 语言编写的。只是,bpftrace 提供了一个高层次的抽象,使得用户不必直接编写复杂的 C 代码来操作 eBPF。
C 语言 用于编写复杂的 eBPF 程序,您需要通过
clang
编译成 eBPF 字节码,并通过一些工具(如bpftool
)加载到内核中。bpftrace 更适合快速编写和执行短小的 eBPF 程序,且无需编写繁琐的 C 代码。它提供了类似
tracepoint:syscalls:sys_enter_*
的简单语法来指定事件和处理逻辑。
使用
1.在linux系统上安装bpftrace
查看安装成功的标志
bpftrace --version
bpftrace v0.14.0
2.用bpfTrace执行简单的脚本
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_* { printf("syscall: %s\n", probe); }'
syscall: tracepoint:syscalls:sys_enter_write
syscall: tracepoint:syscalls:sys_enter_write
syscall: tracepoint:syscalls:sys_enter_write
syscall: tracepoint:syscalls:sys_enter_write
syscall: tracepoint:syscalls:sys_enter_write
syscall: tracepoint:syscalls:sys_enter_write
syscall: tracepoint:syscalls:sys_enter_write
syscall: tracepoint:syscalls:sys_enter_write
syscall: tracepoint:syscalls:sys_enter_write
syscall: tracepoint:syscalls:sys_enter_write
syscall: tracepoint:syscalls:sys_enter_write
syscall: tracepoint:syscalls:sys_enter_write
syscall: tracepoint:syscalls:sys_enter_write
syscall: tracepoint:syscalls:sys_enter_write
syscall: tracepoint:syscalls:sys_enter_write
syscall: tracepoint:syscalls:sys_enter_write
syscall: tracepoint:syscalls:sys_enter_write
直接使用
编写eBPF代码
demo举例如下
#include <linux/bpf.h>
#include <linux/ptrace.h>
#include <linux/unistd.h>
#include <linux/filter.h>
#include <linux/version.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
#include <assert.h>
#include <linux/bpf.h>
#include <bpf/libbpf.h>
#define LOG_BUF_SIZE 4096
#define BPF_OBJ_NAME "my_bpf_prog"
// BPF program code (to be attached to sys_enter_* tracepoints)
SEC("tracepoint/syscalls/sys_enter_*")
int bpf_prog(struct trace_event_raw_sys_enter *ctx) {
char msg[128];
bpf_get_current_comm(&msg, sizeof(msg)); // Get the current process name
bpf_trace_printk("System call triggered by: %s\n", msg);
return 0;
}
char _license[] SEC("license") = "GPL";
以下是这段代码的详细解释
#include <linux/bpf.h> // 引入BPF相关的头文件,定义了与BPF相关的数据结构和常量
#include <linux/ptrace.h> // 引入ptrace相关的头文件,提供与进程追踪相关的功能
#include <linux/unistd.h> // 提供与系统调用号相关的常量
#include <linux/filter.h> // 提供BPF过滤器的功能
#include <linux/version.h> // 获取内核版本信息
#include <stdio.h> // 标准输入输出库
#include <stdlib.h> // 标准库,提供内存管理等功能
#include <unistd.h> // 提供系统调用封装(如read、write等)
#include <sys/types.h> // 定义数据类型,如pid_t等
#include <sys/stat.h> // 定义文件状态相关的结构体
#include <fcntl.h> // 定义文件控制相关的常量
#include <string.h> // 字符串操作函数
#include <errno.h> // 错误码处理
#include <assert.h> // 断言库
#include <linux/bpf.h> // 再次引入BPF的头文件,防止多次包含
#include <bpf/libbpf.h> // libbpf库,用于BPF程序的加载和管理
#define LOG_BUF_SIZE 4096 // 定义日志缓冲区的大小
#define BPF_OBJ_NAME "my_bpf_prog" // 定义BPF程序对象的名称
SEC("tracepoint/syscalls/sys_enter_*")
int bpf_prog(struct trace_event_raw_sys_enter *ctx) {
char msg[128]; // 定义一个 128 字节大小的字符数组来存储进程名称
bpf_get_current_comm(&msg, sizeof(msg)); // 获取当前进程的名称,存储到 msg 中
bpf_trace_printk("System call triggered by: %s\n", msg); // 使用 bpf_trace_printk 打印日志,输出当前进程名
return 0; // 返回0,表示程序执行成功
}
char _license[] SEC("license") = "GPL"; // 设置该BPF程序的许可证为GPL
SEC
宏在 C 语言中并不是标准的语法,它是一个 预处理宏(Preprocessor Macro),通常用于特定框架或库中的扩展。在 eBPF 程序中,SEC
宏的功能主要是为了指定程序所在的 section,它会影响程序如何与内核事件(如 tracepoint、kprobe 等)进行绑定。
SEC
宏本质上是一个宏,它通过 #define
定义,作用是将 eBPF 程序与内核中的特定事件(如 tracepoint
、kprobe
等)关联起来。
编译eBPF程序
eBPF 程序通常被编译为内核可加载的字节码(eBPF bytecode)。为了将上述 C 程序编译成 eBPF 字节码,我们需要使用 clang
。
Clang 是一个开源的 C、C++、Objective-C 和 Objective-C++ 编译器前端,它是 LLVM(Low-Level Virtual Machine)项目的一部分。Clang 的目标是提供一个高效、易用、兼容性好的编译器,特别是用于替代 GCC(GNU Compiler Collection)的一些功能。
clang -O2 -target bpf -c bpf_prog.c -o bpf_prog.o、
命令解释
-O2
:开启优化。-target bpf
:指定编译目标为 eBPF 字节码。-c
:编译源文件,而不进行链接。-o my_prog.o
:输出编译后的 BPF 字节码文件。
加载eBPF程序
与一般的应用程序不同,eBPF程序并不在用户空间内运行,而是直接运行在内核空间中。因此,编写并编译好eBPF程序后,不能直接运行,而是需要通过特定的系统调用加载进入。一般是通过bpf()
系统调用进行。
bpf()
系统调用是内核提供的一个接口,允许用户空间加载、管理和控制 eBPF 程序及其相关资源。这个系统调用的行为是多用途的,根据传入的参数,可以执行不同的操作。它的原型如下:
int bpf(int cmd, union bpf_attr *attr, unsigned int size);
cmd
:指定要执行的操作类型,比如加载 eBPF 程序、查看 eBPF 程序状态、附加程序等。attr
:指向包含操作所需参数的结构体。size
:attr
结构体的大小。
但是直接调用这个系统调用过于复杂。于是就出现了一些封装。比如libbpf与bpftool。
使用libbpf
libbpf 是一个强大的 C 语言库,用于加载、附加和管理 eBPF 程序。他封装了对 bpf()
系统调用的调用。它提供了高级 API 来简化 eBPF 程序的加载和管理。
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/if_packet.h>
#include <linux/filter.h>
#include <bpf/bpf.h>
#include <bpf/libbpf.h>
#include <stdio.h>
int main() {
struct bpf_program *prog;
struct bpf_object *obj;
int prog_fd;
// 加载 BPF 程序
obj = bpf_object__open_file("my_prog.o", NULL);
if (libbpf_errobj(obj)) {
perror("Failed to open BPF object");
return 1;
}
// 加载程序
prog = bpf_object__find_program_by_name(obj, "my_xdp_prog");
prog_fd = bpf_program__fd(prog);
// 将程序附加到 XDP
int ifindex = 1; // 网卡索引,通常是 eth0 的索引
int attach_fd = bpf_set_link_xdp_fd(ifindex, prog_fd, 0);
if (attach_fd < 0) {
perror("Failed to attach XDP program");
return 1;
}
printf("eBPF program loaded and attached!\n");
return 0;
}
这个代码和普通的c代码一样,都需要去编译然后执行。
使用 gcc
编译用户空间程序:
gcc -o user_prog user_prog.c -lbpf -lelf
-lbpf
和 -lelf
是编译时的库链接选项。libbpf
用于处理 eBPF 的加载与管理,libelf
用于读取 ELF 格式的 BPF 程序。
运行:
sudo ./user_prog
使用bpftool
bpftool
是一个用户空间工具,它是基于 libbpf
库来进行 eBPF 程序的加载、查看和管理的。bpftool
的工作原理和 libbpf
类似,它会通过 bpf()
系统调用与内核交互,执行各种 eBPF 相关操作。
bpftool
还可以用来查询已加载的程序、查看程序的状态、统计信息等。它通过 bpf()
系统调用查询内核中的 eBPF 程序信息。
bpftool prog load my_prog.o /sys/fs/bpf/my_prog
bpftool
会通过 bpf()
系统调用来将 my_prog.o
加载到内核中,并将其保存到 /sys/fs/bpf/
中。
bpftool net attach xdp pinned /sys/fs/bpf/my_prog dev eth0
bpftool
会利用 bpf()
来将程序附加到指定的接口(例如 eth0
)。
bpftool prog show
bpftool net show
bpftool
还可以用来查询已加载的程序、查看程序的状态、统计信息等。它通过 bpf()
系统调用查询内核中的 eBPF 程序信息。
原理
用户空间与内核空间
在 Linux 操作系统中,“用户空间”和“内核空间”并不仅仅是两段不同的内存区域,它们更本质的是操作系统内核为确保进程隔离、保护和控制而设计的内存划分机制。
用户空间是普通应用程序运行的环境。所有普通用户进程都在这个空间中运行。每个程序在用户空间都有自己独立的内存空间,它们不能直接访问内核空间的内容,从而保证了进程间的隔离。
内核空间是操作系统内核的运行环境,内核在此空间内管理系统资源、硬件和与外部设备的交互。操作系统内核本身、驱动程序、以及一些内核模块都运行在这个空间。
切换机制
进程从用户空间切换到内核空间时,通常是通过系统调用或中断触发的。当一个用户空间程序需要进行内核操作(例如读取磁盘文件、发送网络数据包等),它会通过系统调用进入内核模式,执行相应的内核代码。
系统调用:是用户空间进程请求内核空间服务的主要方式。例如,应用程序调用
read()
来读取文件内容时,它实际上是通过系统调用进入内核空间,内核读取文件内容并将其返回给用户空间进程。上下文切换:当内核空间执行完毕后,操作系统会执行上下文切换,将控制权返回到用户空间。这个过程涉及保存和恢复进程的状态,确保进程能够在中断后继续执行。
底层原理
应用程序进入内核空间,并不是指内存地址本身发生了变化,而是进程的执行权限发生了变化。
当应用程序进入内核空间时,它的虚拟内存地址空间并不会发生变化。也就是说,用户程序仍然使用的是自己在虚拟内存中的地址,但是操作系统会通过硬件的内存保护机制来控制程序的访问权限,使得不同模式下的执行拥有不同的访问权限。
用户模式(User Mode):应用程序运行在用户模式下,只有对用户空间的访问权限。此时,应用程序不能直接访问内核空间或执行特权指令(比如直接操作硬件)。它的虚拟地址空间和权限被限制在用户模式下。
内核模式(Kernel Mode):当应用程序通过系统调用进入内核空间时,操作系统会切换到内核模式,允许应用程序执行可以访问内核资源、操作硬件、进行上下文切换等特权操作。虽然应用程序仍然保持原来的虚拟地址空间,但它的访问权限从“用户模式”变为“内核模式”,系统会允许它访问内核空间的资源。
当应用程序需要执行系统调用时(例如,进行文件操作、网络通信、进程控制等),它会触发一个软件中断或陷入操作系统,进入内核模式。这个过程一般如下:
触发系统调用:应用程序通过执行系统调用指令(例如
int 0x80
,或者更现代的syscall
)来发起一次请求。这个请求会导致 CPU 发生上下文切换。陷入内核:系统调用会通过硬件机制触发一个中断或异常,使得 CPU 从用户模式切换到内核模式。在此过程中,CPU 会切换到一个特权级别更高的模式,可以访问到内核空间的资源和指令。
内核执行:操作系统根据系统调用的类型,执行内核空间中的相关代码。例如,它会处理文件系统请求、分配内存、调度进程等操作。
返回用户空间:完成内核操作后,操作系统将控制权返回给用户程序,恢复其在用户空间中的执行。
由此可见,从用户空间切换到内核空间,本质上是权限的变化。这个变化是由硬件(如 CPU)和操作系统协同管理的。大多数现代 CPU(例如 x86、ARM 等)支持两种或更多的特权级别(也叫“环”)。最常见的特权级别有两种:
用户模式(Ring 3):这是进程运行的常规模式,用户空间的进程运行在这个模式下。
内核模式(Ring 0):内核运行在这个模式下,拥有完全的访问权限,可以直接操作硬件、管理资源。
当用户进程发起系统调用时,CPU 会从用户模式切换到内核模式,从而给操作系统提供对硬件和其他关键资源的访问权限。
基于eBPF搭建可观测系统
参考文档
ebpf部分
https://networkop.co.uk/post/2021-03-ebpf-intro/
cilium的文档:https://docs.cilium.io/en/latest/reference-guides/bpf/architecture/
https://www.cnblogs.com/chnmig/p/17284141.html#%E5%8F%91%E5%B1%95%E5%8E%86%E7%A8%8B
系统搭建部分
https://www.cnblogs.com/alisystemsoftware/p/17929685.html 阿里云的文章