性能文章>高效入门eBPF>

高效入门eBPF原创

184122

今天主要进行eBPF的入门介绍,说起eBPF就不得不先了解一下eBPF的前身BPF。eBPF是extended BPF 的简称,而BPF的全称是Berkeley Packet Filter,即伯克利文过滤器。

BPF

在BPF之前,如果想做数据包过滤,则必须将所有数据包复制到用户空间中,然后在那里过滤它们,这种方式意味着必须将所有数据包复制到用户空间中,复制数据的开销很大。当然可以通过将过滤逻辑转移到内核中解决开销问题,我们来看BPF做了什么工作。

伯克利报文过滤器的设计思想来源于1992年的一篇论文"The BSD packet filter: A New architecture for user-level packet capture"(《BSD数据包过滤器:一种用于用户级数据包捕获的新体系结构》)。最初,BPF是在BSD内核实现的,后来由于其出色的设计思想,其他操作系统也将其引入,其中也包括Linux。

BPF原理

论文中展示的bpf架构原理如下图所示。

BPF是作为内核报文传输路径的一个旁路存在,当报文到达内核驱动程序后,内核在将报文上送协议栈的同时,会额外将报文的一个副本交给BPF。之后,报文会经过BPF内部逻辑的过滤(这个逻辑可以自己设置) ,然后最终送给用户程序 (比如tcpdump)。

BPF如何运行?

下面我们将带着如下问题通过代码来观察bpf是如何工作的。

  • bpf程序如何编写?
  • bpf程序如何载入到内核中?
  • 载入到内核中的bpf程序何时执行?

BPF用到的系统调用有:

socket(AF_PACKET, SOCK_RAW, ...)
bind(sockfd, iface)
setsockopt(sockfd, SOL_SOCKET, SO_ATTACH_FILTER,&Filter, sizeof(Filter))
recv(sockfd, ...)

通过 setsockopt 系统调用,将用户自定义的bpf过滤程序载入内核空间。

参数&Filter的类型是struct sock_fprog,结构如下:

struct sock_fprog       /* Required for SO_ATTACH_FILTER. */
{

        unsigned short          len;    /* Number of filter blocks */
        struct sock_filter      *filter;
};

其中的filter指针指向结构为struct sock_filter的BPF过滤代码。结构如下:

struct sock_filter      /* Filter block */
{

        __u16   code;   /* Actual filter code */
        __u8    jt;     /* Jump true */
        __u8    jf;     /* Jump false */
        __u32   k;      /* Generic multiuse field */
};

如何编写BPF并加载进内核

如下用户态代码中定义结构体bpfcode,使用bpf指令自定义了过滤程序。

#include <stdio.h>
#include <linux/filter.h>

#define OP_LDH (BPF_LD  | BPF_H   | BPF_ABS)
#define OP_LDB (BPF_LD  | BPF_B   | BPF_ABS)
#define OP_JEQ (BPF_JMP | BPF_JEQ | BPF_K)
#define OP_RET (BPF_RET | BPF_K)
static struct sock_filter bpfcode[6] = {
 { OP_LDH, 0012          }, // ldh [12]
 { OP_JEQ, 02, ETH_P_IP    }, // jeq #0x800, L2, L5
 { OP_LDB, 0023          }, // ldb [23]
 { OP_JEQ, 01, IPPROTO_TCP }, // jeq #0x6, L4, L5
 { OP_RET, 000           }, // ret #0x0
 { OP_RET, 00-1,         }, // ret #0xffffffff
};
int main(int argc, char **argv)
{

 struct sock_fprog bpf = { 6, bpfcode };
 …
 sock = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
 …
 if (setsockopt(sock, SOL_SOCKET, SO_ATTACH_FILTER, &bpf, sizeof(bpf))) {
  perror("setsockopt ATTACH_FILTER");
  return 1;
 }

 }

setsockopt提供 SO_ATTACH_FILTER 和 SO_DETACH_FILTER 两个操作枚举值,分别表示装载程序和卸载程序。

switch(optname) {
    /* some code omitted ... */
 case SO_ATTACH_FILTER:
  ret = -EINVAL;
  if (optlen == sizeof(struct sock_fprog)) {
   struct sock_fprog fprog;

   ret = -EFAULT;
            /* 从用户空间向内核空间拷贝描述bpf程序的结构体*/
   if (copy_from_user(&fprog, optval, sizeof(fprog)))
    break;
            /* 给 sock 挂载bpf程序 */
   ret = sk_attach_filter(&fprog, sk);
  }
  break;

 case SO_DETACH_FILTER:
        /* 卸载bpf程序 */
  ret = sk_detach_filter(sk);
  break;


sk_attach_filter 对用户自定义的bpf程序进行安全检测,通过后将程序挂载到钩子sk_filter 上。内核使用struct sk_filter结构体来记录保存用户编写的bpf指令。

struct sk_filter
{

 ......
 unsigned int        len;                /* BPF 指令的数目, 也就是 insns 的长度 */
 unsigned int  (*bpf_func)(const struct sk_buff *skb,  /*  For JIT */    
         const struct sock_filter *filter)
;
    ......
 struct sock_filter      insns[0];          /* BPF 指令 */
};

sk_attach_filter实现代码如下:

int sk_attach_filter(struct sock_fprog *fprog, struct sock *sk)
{
 struct sk_filter *fp, *old_fp;
 unsigned int fsize = sizeof(struct sock_filter) * fprog->len;
 int err;

 fp = sock_kmalloc(sk, fsize+sizeof(*fp), GFP_KERNEL);
    /* 从用户空间向内核空间拷贝代理程序 */
 if (copy_from_user(fp->insns, fprog->filter, fsize)) {
  sock_kfree_s(sk, fp, fsize+sizeof(*fp));
  return -EFAULT;
 }
    /* 在沙箱中对代理程序做安全检测 */
 err = sk_chk_filter(fp->insns, fp->len);
 if (err) {
  sk_filter_uncharge(sk, fp);
  return err;
 }
 rcu_read_lock_bh();
 old_fp = rcu_dereference(sk->sk_filter);
    /* 在 sk_filter 赋值经过检测的bpf程序 */
 rcu_assign_pointer(sk->sk_filter, fp);
 rcu_read_unlock_bh();

 return 0;
}

至此,我们已经知道了文章开始提到的前两个疑问,即如何编写bpf指令程序,并加载进内核。最后来看内核中的bpf程序何时会被触发执行。

何时运行BPF程序

网络包(接收/发送)达到数据链路层会调用预置的钩子函数packet_rcv,其中调用的run_filter函数会检测sk_filter中是否挂载了bpf程序,然后调用sk_run_filter函数执行bpf程序。

static int packet_rcv(struct sk_buff *skb, struct net_device *dev, struct packet_type *pt, struct net_device *orig_dev)
{
    sk = pt->af_packet_priv;
    snaplen = skb->len;
    /* 核心代理函数的触发逻辑 */
 res = run_filter(skb, sk, snaplen);
    if (!res)
  goto drop_n_restore;
}

run_filter 的功能就是获取 sk 上设置的 sk_filter 结构, 然后 使用宏定义SK_RUN_FILTER 执行这个结构。SK_RUN_FILTER 根据是否内核使用 JIT 有两种定义,JIT 是一种通过将 BPF 虚拟机指令码映射成主机指令,从而提升 BPF filter 性能的方式。

static unsigned int run_filter(const struct sk_buff *skb,
          const struct sock *sk,
          unsigned int res)
{
 struct sk_filter *filter;

 rcu_read_lock();
 filter = rcu_dereference(sk->sk_filter);
 if (filter != NULL)
  res = SK_RUN_FILTER(filter, skb); // For non-JIT: sk_run_filter(SKB, filter->insns) ; For JIT: (*filter->bpf_func)(SKB, filter->insns)
 rcu_read_unlock();
 return res;
}

sk_run_filter函数模拟了一套简单的处理器,由累加器、索引寄存器、小块内存、PC 组成,其实就是一个虚拟机。

unsigned int sk_run_filter(struct sk_buff *skb, struct sock_filter *filter, int flen)
{
 struct sock_filter *fentry; /* We walk down these */
 void *ptr;
 u32 A = 0;   /* Accumulator */
 u32 X = 0;   /* Index Register */
 u32 mem[BPF_MEMWORDS];  /* Scratch Memory Store */
 u32 tmp;
 int k;
 int pc;
 /*
  * Process array of filter instructions.
  */

 for (pc = 0; pc < flen; pc++) {
  fentry = &filter[pc];

  switch (fentry->code) {
  case BPF_ALU|BPF_ADD|BPF_X:
   A += X;
   continue;
  case BPF_ALU|BPF_ADD|BPF_K:
   A += fentry->k;
   continue;
  case BPF_ALU|BPF_SUB|BPF_X:
   A -= X;
   continue;

}

虚拟机执行虽然保证了内核的安全,但大量的网络包都需要经过这个虚拟机的检测,效率并不高。在 Linux 3.x 版本之后,BPF 开始引入 JIT 技术来提高这部分代码的执行效率。JIT就是将bpf指令翻译为当前系统架构下CPU可以直接执行的机器指令。

最后,bpf整个实现的原理如下图所示。

eBPF

在理解了bpf的工作原理之后,我们进入今天的主题eBPF。随着技术的不断进步,当前的bpf已经不再单纯代表原始的伯克利报文过滤器,也不再仅仅局限于网络包的过滤,bpf这个名词已经代表一项不断进步的技术。因此以下内容中提到的eBPF或者bpf都代表当前的bpf技术,不再进行区分。

首先通过eBPF架构图来理解eBPF的运行过程,其中有以下几个关键点对于我们理解eBPF的实现原理比较重要。

  • bpf指令集
  • bpf字节码
  • bpf虚拟机

eBPF样例程序

与上文介绍的采用bpf指令编写的bpf程序不同,架构图左上角的bpf程序使用更高级的C语言进行编写。Bpf程序分为两个,通常命名为xxx_kern.c和xxx_user.c,前者加载到内核空间中执行,后者在用户空间执行。在内核源码samples/bpf目录下提供了许多样例程序,可以让我们快速的体验eBPF。例如用于统计每种协议报文数据量的程序sockex1。

//sockex1_kern.c
#include <uapi/linux/bpf.h>
#include <uapi/linux/if_ether.h>
#include <uapi/linux/if_packet.h>
#include <uapi/linux/ip.h>
#include "bpf_helpers.h"

struct bpf_map_def SEC("maps") my_map = {
 .type = BPF_MAP_TYPE_ARRAY,
 .key_size = sizeof(u32),
 .value_size = sizeof(long),
 .max_entries = 256,
};

SEC("socket1")
int bpf_prog1(struct __sk_buff *skb)
{
 int index = load_byte(skb, ETH_HLEN + offsetof(struct iphdr, protocol));
 long *value;

 if (skb->pkt_type != PACKET_OUTGOING)
  return 0;

 value = bpf_map_lookup_elem(&my_map, &index);
 if (value)
  __sync_fetch_and_add(value, skb->len);

 return 0;
}
char _license[] SEC("license") = "GPL";
//sockex1_user.c

int main(int ac, char **argv)
{
 char filename[256];
 FILE *f;
 int i, sock;

 snprintf(filename, sizeof(filename), "%s_kern.o", argv[0]);

    /* 装载文件 sockex1_kern.o  */
 if (load_bpf_file(filename)) {
  printf("%s", bpf_log_buf);
  return 1;
 }

    /* 创建一个 socket, bind 到回环设备 */
 sock = open_raw_sock("lo");
    
    /* 设置 socket 的 SO_ATTACH_BPF 选项,传入 prog_fd */
 assert(setsockopt(sock, SOL_SOCKET, SO_ATTACH_BPF, prog_fd,
     sizeof(prog_fd[0])) == 0);
    
    /* 启动一个子进程执行 ping 命令 */
 f = popen("ping -c5 localhost""r");
 (void) f;

    /* 循环读取 map_fd[0] 对应存储区域的各个协议类型对应的统计计数并显示 */
 for (i = 0; i < 5; i++) {
  long long tcp_cnt, udp_cnt, icmp_cnt;
  int key;

  key = IPPROTO_TCP;
  assert(bpf_map_lookup_elem(map_fd[0], &key, &tcp_cnt) == 0);

  key = IPPROTO_UDP;
  assert(bpf_map_lookup_elem(map_fd[0], &key, &udp_cnt) == 0);

  key = IPPROTO_ICMP;
  assert(bpf_map_lookup_elem(map_fd[0], &key, &icmp_cnt) == 0);

  printf("TCP %lld UDP %lld ICMP %lld bytes\n",
         tcp_cnt, udp_cnt, icmp_cnt);
  sleep(1);
 }
 return 0;
}

BPF程序编写完成后就通过Clang/LLVM进行编译,在此目录下执行make命令就可以全部编译所有样例程序,分别生成字节码文件sockex1_kern.o和可执行文件sockex1。可执行文件执行结果如下:

root@travel:/usr/src/linux-source-4.15.0/samples/bpf # ./sockex1
TCP 0 UDP 0 ICMP 0 bytes
TCP 0 UDP 0 ICMP 184 bytes
TCP 0 UDP 0 ICMP 300 bytes
TCP 0 UDP 0 ICMP 480 bytes
TCP 0 UDP 0 ICMP 984 bytes

编译eBPF字节码

在编译bpf程序的时候遵守了bpf指令集规范,那么如何理解bpf的指令集?

BPF指令集区别于通用的X86和ARM指令集。BPF指令集采用虚拟指令集规范。BPF指令集中的指令类似于汇编,如汇编的无条件跳转指令为jmp, 而BPF指令集中则为BPF_JMP。X86和ARM指令集,每一条指令对应的是一条特定的逻辑门电路。BPF指令集不是直接的机器码而是称为字节码,对比普通C程序如下图所示。经过Clang和LLVM的编译生成BPF字节码,然后交由BPF虚拟机执行,虚拟机通过JIT或者解释器转变成可以本地执行的机器码。

什么是字节码?

字节码也是一种可以被执行的“机器码",只不过被虚拟机执行。之所以称之为字节码,是指这里面的操作码(opcode)是—个字节长。一般机器指令由操作码和操作数组成,字节码(虚拟的机器码)也是由操作码(opcode)和操作数(op)组成。对于字节码,它是按照一套虚机指令集格式来组织。类比java程序的编译过程如下图所示。

Bpf字节码如下图所示,红框内为16进制的字节码,右面表示对应的伪汇编指令,解释字节码代表的含义。

BPF指令集如何变成BPF字节码?

我们首先来看BPF常用的指令,如下图所示,有经典版BPF的指令和扩展版指令。

使用BPF指令宏将BPF程序声明为prog数组,那么下面一段BPF指令宏如何转换成字节码呢?

int main(int argc, char *argv[]){
struct bpf_insn prog[]={
BPF_MOV64_IMM(BPF_ REG_1,0xa21),
BPF_STX_MEM(BPF_H,BPF_REG_10,BPF_REG_1,-4),
BPF_MOV64_IMM(BPF__REG_1, 0x646c726f),
...
BPF__EXIT_INSN(),
};
...
return 0;
}

首先BPF_MOV64_IMM指令宏表示将64位的立即数移动到目的地,将其展开可以得到三个操作码,通过上文中的指令集表格可以查找到操作码对应的编号。三个操作码进行“或”操作之后得到0xb7,0x01代表寄存器编号,0xa21代表立即数,这三个数字组成了bpf指令格式的元数据。最后编译器按照指令集规范,生成字节码。

内核中使用struct bpf_insn结构体定义BPF指令规范。

struct bpf_insn {
 __u8 code;  /* opcode */
 __u8 dst_reg:4/* dest register */
 __u8 src_reg:4/* source register */
 __s16 off;  /* signed offset */
 __s32 imm;  /* signed immediate constant */
};

解释执行字节码

生成的BPF字节码需要由BPF虚拟机来解释执行,在不采用JIT编译的情况下,内核中实现的函数为__bpf_prog_run,

/**
 * __bpf_prog_run - run eBPF program on a given context
 * @ctx: is the data we are operating on
 * @insn: is the array of eBPF instructions
 *
 * Decode and execute eBPF instructions.
 */

static unsigned int __bpf_prog_run(void *ctx, const struct bpf_insn *insn)
{
 u64 stack[MAX_BPF_STACK / sizeof(u64)];
 u64 regs[MAX_BPF_REG], tmp;
 static const void *jumptable[256] = {
  [0 ... 255] = &&default_label,
  /* Now overwrite non-defaults ... */
  …
  [BPF_ALU64 | BPF_MOV | BPF_K] = &&ALU64_MOV_K,
  [BPF_ALU64 | BPF_ARSH | BPF_X] = &&ALU64_ARSH_X,
  …
 };

 ALU64_MOV_X:
  DST = SRC;
  CONT;
 ALU64_MOV_K:
  DST = IMM;
  CONT;
 …
  return 0;
}

我们找到操作码[BPF_ALU64 | BPF_MOV | BPF_K]对应的代码标号为ALU64_MOV_K,IMM表示立即数(常量),DST代表目的地(目标寄存器)。

加载eBPF字节码

在生成字节码以后,接下来的问题是字节码如何被加载进内核中的呢?

将字节码加载进内核必然涉及用户态到内核态的切换,也就需要介绍BPF相关的系统调用。结合BPF运行时模块图,我们发现BPF可以用系统调用完成对BPF数据存储模块Map的操作,以及加载字节码到内核中。其中Map是BPF内核程序与用户程序进行数据传输的桥梁,用户态程序通过系统调用获取Map中存储的数据。

BPF系统调用在内核中定义如下:kernel/bpf/syscall.c

SYSCALL_DEFINE3(bpf, int, cmd, union bpf_attr __user *, uattr, unsigned int, size)
{
 ...
 switch (cmd) {
 case BPF_MAP_CREATE:
  err = map_create(&attr);
  break;
 case BPF_MAP_LOOKUP_ELEM:
  err = map_lookup_elem(&attr);
  break;
 case BPF_MAP_UPDATE_ELEM:
  err = map_update_elem(&attr);
  break;
 case BPF_MAP_DELETE_ELEM:
  err = map_delete_elem(&attr);
  break;
 case BPF_MAP_GET_NEXT_KEY:
  err = map_get_next_key(&attr);
  break;
 case BPF_PROG_LOAD:
  err = bpf_prog_load(&attr);
  break;
 ...
 default:
  err = -EINVAL;
  break;
 }

 return err;
}

其中,bpf_prog_load(&attr)函数负责加载bpf字节码到内核中。每一个加载到内核的BPF程序都有一个文件描述符fd返回给用户,它对应一个bpf_prog结构。bpf_prog结构体描述了一个从用户空间加载进来的bpf字节码。

struct bpf_prog {
 u16   pages;  /* Number of allocated pages */
 kmemcheck_bitfield_begin(meta);
 u16   jited:1/* Is our filter JIT'ed? */
    gpl_compatible:1/* Is filter GPL compatible? */
    cb_access:1/* Is control block accessed? */
    dst_needed:1/* Do we need dst entry? */
 kmemcheck_bitfield_end(meta);
 u32   len;  /* Number of filter blocks */
 enum bpf_prog_type type;  /* Type of BPF program */
 struct bpf_prog_aux *aux;  /* Auxiliary fields */
 struct sock_fprog_kern *orig_prog; /* Original BPF program */
 unsigned int  (*bpf_func)(const struct sk_buff *skb,
         const struct bpf_insn *filter)
;
 /* Instructions for interpreter */
 union {
  struct sock_filter insns[0];
  struct bpf_insn  insnsi[0];
 };
};
len:程序包含bpf指令的数量;
type:当前bpf程序的类型(kprobe/tracepoint/perf_event/sk_filter/sched_cls/sched_act/xdp/cg_skb);
aux:主要用来辅助verifier校验和转换的数据;
bpf_func:运行时BPF程序的入口。如果JIT转换成功,这里指向的就是BPF程序JIT转换后的映像;否则这里指向内核解析器(interpreter)的通用入口__bpf_prog_run();
insnsi[]:从用户态拷贝过来的,BPF程序原始指令的存放空间;

回到上文的样例eBPF程序sockex1_kern.c和sockex1_user.c,上文通过编译生成了字节码文件sockex1_kern.o和可执行文件sockex1,sockex1就充当了加载器的角色,通过bpf()系统调用把BPF字节码文件送入内核。

Makefile通过调用clang+llvm将sockex1_kern.c编译成了BPF字节码文件,然后sockex1_user.c程序中使用load_bpf_file函数将字节码文件加载到内核中。load_bpf_file函数最终调用了do_load_bpf_file函数。

load_bpf_file
 |
 |- do_load_bpf_file
 
 int do_load_bpf_file(const char *path, fixup_map_cb fixup_map)
 
{
    fd = open(path, O_RDONLY, 0);
    ......
    /* load programs */
 for (i = 1; i < ehdr.e_shnum; i++) {
  ......
  if (memcmp(shname, "kprobe/"7) == 0 ||
      memcmp(shname, "kretprobe/"10) == 0 ||
      memcmp(shname, "tracepoint/"11) == 0 ||
      memcmp(shname, "xdp"3) == 0 ||
      memcmp(shname, "perf_event"10) == 0 ||
      memcmp(shname, "socket"6) == 0 ||
      memcmp(shname, "cgroup/"7) == 0 ||
      memcmp(shname, "sockops"7) == 0 ||
      memcmp(shname, "sk_skb"6) == 0) {
   ret = load_and_attach(shname, data->d_buf,
           data->d_size);
   if (ret != 0)
    goto done;
  }
 }
 }

do_load_bpf_file 会将输入的 .o 文件作为 ELF 格式 文件的逐个 section 进行分析,如 section 的名字是 特殊的(比如“socket”,该名称由SEC宏指定),那么就会将这个section 的内容作为 load_and_attach() 的参数。load_and_attach()会确定程序的类型并保存在prog_type中,继续调用 bpf_load_program, 填入的参数为程序类型 prog_type。

static int load_and_attach(const char *event, struct bpf_insn *prog, int size)
{
    bool is_socket = strncmp(event, "socket"6) == 0;
    ...

    if (is_socket) {
        prog_type = BPF_PROG_TYPE_SOCKET_FILTER;
    } 
    ...

    fd = bpf_load_program(prog_type, prog, insns_cnt, license, kern_version, bpf_log_buf, PF_LOG_BUF_SIZE);
}

bpf_load_program()函数就到达用户空间与内核空间的边界了, 会通过 BPF_PROG_LOAD 系统调用,将需要的信息传递给内核,返回一个文件描述符 fd。

bpf_load_program
  |
  |-- bpf_load_program_name

int bpf_load_program_name(enum bpf_prog_type type, const char *name,
     const struct bpf_insn *insns,
     size_t insns_cnt, const char *license,
     __u32 kern_version, char *log_buf,
     size_t log_buf_sz)
{
    int fd;
    union bpf_attr attr;
    __u32 name_len = name ? strlen(name) : 0;

    bzero(&attr, sizeof(attr));
    attr.prog_type = type;
    attr.insn_cnt = (__u32)insns_cnt;
    attr.insns = ptr_to_u64(insns);
    attr.license = ptr_to_u64(license);
    attr.log_buf = ptr_to_u64(NULL);
    attr.log_size = 0;
    attr.log_level = 0;
    attr.kern_version = kern_version;
    memcpy(attr.prog_name, name, min(name_len, BPF_OBJ_NAME_LEN - 1));

    fd = sys_bpf(BPF_PROG_LOAD, &attr, sizeof(attr));
    if (fd >= 0 || !log_buf || !log_buf_sz)
        return fd;
    ...
}

内核空间加载字节码过程:

SYSCALL_DEFINE3(bpf, int, cmd, union bpf_attr __user *, uattr, unsigned int, size)
{
    ......
    case BPF_PROG_LOAD:
        err = bpf_prog_load(&attr);  
}
static int bpf_prog_load(union bpf_attr *attr)
{
    struct bpf_prog *prog;

    ......
    /* 分配内核 bpf_prog 程序数据结构空间 */
    prog = bpf_prog_alloc(bpf_prog_size(attr->insn_cnt), GFP_USER);
    .....
    /* 将 bpf 字节码从用户空间拷贝到内核空间 */
    copy_from_user(prog->insns, u64_to_user_ptr(attr->insns), bpf_prog_insn_size(prog));
...
/*使用verifer 对BPF程序进行合法性扫描*/
err= bpf_check(&prog, attr);
    /* 分配一个 fd 与 prog 关联,最终这个 fd 将返回用户空间 */
    err = bpf_prog_new_fd(prog);
    ...

    return err;
}

经过上述过程,内核分配了相应的数据结构 struct bpf_prog,BPF字节码已经存储在了内核空间中,那么该字节码何时会执行呢?

执行eBPF程序的时机

eBPF 程序指令都是在内核的特定 Hook 点执行,不同类型的程序有不同的钩子,有不同的上下文(ctx)。将指令 load 到内核时,内核会创建 bpf_prog 存储指令,但只是第一步,成功运行这些指令还需要完成以下两个步骤:

  1. 将 bpf_prog 与内核中的特定 Hook 点关联起来,也就是将BPF程序挂到钩子上。
  2. 在 Hook 点被访问到时,取出 bpf_prog,执行这些指令。

以kprobe原理为例,若某个kprobe探测点的内核地址attach了一段BPF程序后,当内核执行到这个地址时发生陷入(trap,x86上的int3断点指令),断点处理程序do_int3()通过中断门调用,因此当控制到达那里时中断被禁用。

该处理程序通知 kprobe 发生了断点;kprobe 检查断点是否是由 kprobe 的注册函数设置。每个探针由struct kprobe 结构描述,并存储在一个哈希表中,该哈希表由探针所在的地址进行哈希处理。如果在探针被命中的地址上没有注册kprobe,它只返回 0。否则调用注册的kprobe函数。唤醒kprobe的回调函数(pre_handler函数将在被探测指令被执行前回调,post_handler会在被探测指令执行完毕后回调),后者又会触发attach的BPF程序执行。

内核中的Hook点也即bpf的程序类型有哪些呢?

在内核文件/include/uapi/linux/bpf.h中给出了定义。

enum bpf_prog_type {
 BPF_PROG_TYPE_UNSPEC,
 BPF_PROG_TYPE_SOCKET_FILTER,
 BPF_PROG_TYPE_KPROBE,
 BPF_PROG_TYPE_SCHED_CLS,
 BPF_PROG_TYPE_SCHED_ACT,
 BPF_PROG_TYPE_TRACEPOINT,
 BPF_PROG_TYPE_XDP,
 BPF_PROG_TYPE_PERF_EVENT,
 BPF_PROG_TYPE_CGROUP_SKB,
 BPF_PROG_TYPE_CGROUP_SOCK,
 BPF_PROG_TYPE_LWT_IN,
 BPF_PROG_TYPE_LWT_OUT,
 BPF_PROG_TYPE_LWT_XMIT,
    ...
 BPF_PROG_TYPE_SYSCALL, /* a program that can execute syscalls */
};

BPF常见类型介绍如下:

BPF同样为Map存储定义了多种类型:

enum bpf_map_type {
 BPF_MAP_TYPE_UNSPEC,
 BPF_MAP_TYPE_HASH,
 BPF_MAP_TYPE_ARRAY,
 BPF_MAP_TYPE_PROG_ARRAY,
 BPF_MAP_TYPE_PERF_EVENT_ARRAY,
 BPF_MAP_TYPE_PERCPU_HASH,
 BPF_MAP_TYPE_PERCPU_ARRAY,
 BPF_MAP_TYPE_STACK_TRACE,
 BPF_MAP_TYPE_CGROUP_ARRAY,
 BPF_MAP_TYPE_LRU_HASH,
 BPF_MAP_TYPE_LRU_PERCPU_HASH,
 ...
 BPF_MAP_TYPE_BLOOM_FILTER,
};

常用的map类型解释如下:

eBPF辅助函数

BPF辅助函数属于BPF的基础设施之一,因为BPF程序中不允许随意调用内核函数,内核专门提供了BPF可以调用的函数。

eBPF程序编程方式

前面我们通过一个简单的eBPF程序,并且介绍了其如何编译成字节码、加载到内核空间、解码执行的过程以及执行的时机,接下来我们看看BPF为我们提供了哪些可编程的方式。

首先是BPF指令集编程,难度最大,如下图所示。

BPF C编程就是上文介绍的样例程序,如下图所示,通过定义SEC宏指定节的名称,定义map结构体在用户空间与内核空间之间传递数据,通过内核提供的bpf帮助函数来操作map。

通过BPF前端进行编程是目前最简单也是最推荐的方式,BPF目前的前端主要有BCC,bpftrace和ply。BCC提供了其他高级语言(python、Lua、C++)环境来实现用户端接口,可以实现一些功能比较复杂的,BPF程序,bpftrace通常是一个单行程序,编写非常方便,但是不易实现复杂的功能。我们可以根据需求在bcc和bpftrace之间进行灵活选择。

BCC和bpftrace都是BPF的两个前端,源代码不在内核代码仓库中,托管在github上的一个名为IO Visor的Linux基金会项目。BCC其实提供了一种使用BPF编程的框架,这套框架提供给我们一些用户接口,并且屏蔽掉了一些加载,编译的复杂环节,只需要运行写好的BCC脚本,BPF程序就可以工作。

BCC程序由C和python两部分组成,C程序编写需要加载到内核空间运行的函数,python则提供了一系列封装的函数来加载bpf程序,处理Map,完成用户态的一系列操作。

BPF学习资料

书籍

  • 《Linux内核观测技术BPF》
  • 《BPF之巅:洞悉Linux系统和应用性能》
  • 《Systems Performance》
  • 《BPF Performance Tools》

Brendan Gregg大神的个人网站

https://www.brendangregg.com/index.html

Github

Linux基金会的IO Visor项目:https://github.com/iovisor https://github.com/zoidbergwill/awesome-ebpf

网站

Cilium eBPF:https://ebpf.io

BPF原始论文

https://www.tcpdump.org/papers/bpf-usenix93.pdf

本文原直播视频

https://www.bilibili.com/video/BV1LX4y157Gp?share_source=copy_web

 

点赞收藏
分类:标签:
Linux内核之旅

Linux内核之旅开源社区:http://kerneltravel.net/ 欢迎关注微信公众号“Linux内核之旅”

请先登录,查看2条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

BPF内核实现详解

BPF内核实现详解

eBPF 完全入门指南.pdf(万字长文)

eBPF 完全入门指南.pdf(万字长文)

使用 Go 语言开发 eBPF 程序

使用 Go 语言开发 eBPF 程序

eBPF 快速定位网络抖动

eBPF 快速定位网络抖动

【全网首发】内核是如何给容器中的进程分配CPU资源的?

【全网首发】内核是如何给容器中的进程分配CPU资源的?

【全网首发】细节拉满,80 张图带你一步一步推演 slab 内存池的设计与实现

【全网首发】细节拉满,80 张图带你一步一步推演 slab 内存池的设计与实现

2
2