介绍

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_readsys_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 程序与内核中的特定事件(如 tracepointkprobe 等)关联起来。

编译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:指向包含操作所需参数的结构体。

  • sizeattr 结构体的大小。

但是直接调用这个系统调用过于复杂。于是就出现了一些封装。比如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):当应用程序通过系统调用进入内核空间时,操作系统会切换到内核模式,允许应用程序执行可以访问内核资源、操作硬件、进行上下文切换等特权操作。虽然应用程序仍然保持原来的虚拟地址空间,但它的访问权限从“用户模式”变为“内核模式”,系统会允许它访问内核空间的资源。

当应用程序需要执行系统调用时(例如,进行文件操作、网络通信、进程控制等),它会触发一个软件中断或陷入操作系统,进入内核模式。这个过程一般如下:

  1. 触发系统调用:应用程序通过执行系统调用指令(例如 int 0x80,或者更现代的 syscall)来发起一次请求。这个请求会导致 CPU 发生上下文切换。

  2. 陷入内核:系统调用会通过硬件机制触发一个中断或异常,使得 CPU 从用户模式切换到内核模式。在此过程中,CPU 会切换到一个特权级别更高的模式,可以访问到内核空间的资源和指令。

  3. 内核执行:操作系统根据系统调用的类型,执行内核空间中的相关代码。例如,它会处理文件系统请求、分配内存、调度进程等操作。

  4. 返回用户空间:完成内核操作后,操作系统将控制权返回给用户程序,恢复其在用户空间中的执行。

由此可见,从用户空间切换到内核空间,本质上是权限的变化。这个变化是由硬件(如 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 阿里云的文章