引言

可能很多工程师都听说过或者用过Solaris上的DTrace,都被其强大的功能,灵活的用法所吸引。但是,多年来Linux上一直没有对应的工具。虽然,systemtap可以部分替代,但是在很多方面systemtap都不尽如人意。

不过,在近几年随着eBPF的横空出世(最早合并于Linux 3.15),Linux终于也有了自己的DTrace。

eBPF源于很久前出现在BSD上的BPF(Berkley Packet Filter)技术,是一个主要用于过滤网络报文的技术。虽然名气不大,但是作用不小,是大名鼎鼎的tcpdump的技术基石。

感兴趣的同学可以尝试在机器上执行如下命令:

tcpdump -d -i lo tcp and dst port 7070

该命令的输出:

(000) ldh      [12]
(001) jeq      #0x86dd          jt 2    jf 6
(002) ldb      [20]
(003) jeq      #0x6             jt 4    jf 15
(004) ldh      [56]
(005) jeq      #0x1b9e          jt 14   jf 15
(006) jeq      #0x800           jt 7    jf 15
(007) ldb      [23]
(008) jeq      #0x6             jt 9    jf 15
(009) ldh      [20]
(010) jset     #0x1fff          jt 15   jf 11
(011) ldxb     4*([14]&0xf)
(012) ldh      [x + 16]
(013) jeq      #0x1b9e          jt 14   jf 15
(014) ret      #262144
(015) ret      #0

看着很像一段汇编代码有木有?这就是BPF(注意这里是BPF哟),用于定义filter的伪代码。Tcpdump就是用这样的伪代码和内核交互,内核当中专门实现了一个虚拟机用来解释伪代码。这就是BPF和eBPF技术的基础。BPF的虚拟机实现相对简单,只实现了2个32位寄存器,累加器A和通用寄存器X,指令集也就仅仅20几条指令。

eBPF设计

eBPF在BPF基础上扩展了虚拟机的实现,所以叫做Extend BPF(eBPF),顾名思义,全面扩展了BPF的功能,当然为了保持向后兼容性,传统BPF也保留下来,并被重命名为classic BPF。下面简单介绍一下eBPF所做的扩展。

新的指令集

eBPF支持更多的寄存器,一共支持10个64位的寄存器

R0:返回值 

R1 ... R5:函数参数

R6 ... R9:callee保存的寄存器

R10:frame pointer寄存器

除了常见的load/store,ALU,跳转指令外还新增加了jt/fall-through指令和bpf_call指令,可以调用其他kernel函数。kernel提供了一系列helper函数供eBPF程序调用,比如bpf_ktime_get_ns(),bpf_get_current_pid_tgid()等。

当然,eBPF的指令集也是在不断进化中的,新的指令也在不断加入。比如,4.14中就新加入了几条JMP有关的指令。

LLVM编译器

传统BPF的程序一般直接使用伪码汇编编写,虽然libpcap内嵌了小编译器,但是功能不强,因为classic BPF的指令集也并不复杂。但是,到了eBPF的时代就不一样了,指令集已经复杂太多,再用纯汇编的开发方式已经不合适,于是eBPF支持了C语言编程的方式。这样就需要一个C语言的编译器生成eBPF的伪码指令。

目前支持生成eBPF伪码的编译器只有LLVM一家,所以为了编译eBPF的程序需要clang。

程序类别

传统BPF程序只是用来做网络报文的filter,但是eBPF程序除了做socket filter以外,还可以attach到kprobe,uprobe,tracepoint等,用来实现tracing的功能,所以eBPF也就支持不同的程序类型

Map机制

eBPF程序需要一种全新的机制用于内核和用户空间通信,map机制就随之诞生了。位于用户空间中的应用在内核中辟出一块空间建立起一个数据库用以和 eBPF 程序交互;数据库本身以 Key-Value 的形式进行组织,无论是从用户空间还是内核空间都可以对其进行访问,两边有着相似的接口,最终在逻辑上也都殊途同归。

eBPF中常见的map类型有:array,hash,histogram,stack trace等,用于不同的应用场景。

In-kernel verifier

因为eBPF程序是在内核态运行,所以就要杜绝很多安全隐患,这就是in-kernel verifier的工作了,它会对eBPF程序做一系列的安全检查,以保证系统的安全。Verifier会对注入的程序做两轮检查:

• 对注入程序进行DAG检查,以确保程序里面没有循环

• 代码长度不能超多4K条指令

• 检查JMP的范围

• 是否存在无法运行的指令,比如exit后面的指令

• 次轮检查,模拟执行所有指令

JIT Compiler

eBPF程序默认是解释执行。为了提高性能,kernel也支持JIT compiler。目前主流的architecture都支持了JIT,比如x86,aarch64, S390等。

可使用如下命令启用JIT:

echo 1 > /proc/sys/net/core/bpf_jit_enable

eBPF JIT也支持debug模式

echo 2 > /proc/sys/net/core/bpf_jit_enable

kernel dmesg中可以看到:

[ 3389.935842] flen=6 proglen=70 pass=3 image=ffffffffa0069c8f
[ 3389.935847] JIT code: 00000000: 55 48 89 e5 48 83 ec 60 48 89 5d f8 44 8b 4f 68
[ 3389.935849] JIT code: 00000010: 44 2b 4f 6c 4c 8b 87 d8 00 00 00 be 0c 00 00 00
[ 3389.935850] JIT code: 00000020: e8 1d 94 ff e0 3d 00 08 00 00 75 16 be 17 00 00
[ 3389.935851] JIT code: 00000030: 00 e8 28 94 ff e0 83 f8 01 75 07 b8 ff ff 00 00
[ 3389.935852] JIT code: 00000040: eb 02 31 c0 c9 c3

通过tools/net/bpf_jit_disasm可以通过kernel dmesg生成eBPF伪码

./bpf_jit_disasm

70 bytes emitted from JIT compiler (pass:3, flen:6)
ffffffffa0069c8f + <x>:
   0:   push   %rbp
   1:   mov    %rsp,%rbp
   4:   sub    $0x60,%rsp
   8:   mov    %rbx,-0x8(%rbp)
   c:   mov    0x68(%rdi),%r9d
  10:   sub    0x6c(%rdi),%r9d
  14:   mov    0xd8(%rdi),%r8
  1b:   mov    $0xc,%esi
  20:   callq  0xffffffffe0ff9442
  25:   cmp    $0x800,%eax
  2a:   jne    0x0000000000000042
  2c:   mov    $0x17,%esi
  31:   callq  0xffffffffe0ff945e
  36:   cmp    $0x1,%eax  
  39:   jne    0x0000000000000042
  3b:   mov    $0xffff,%eax
  40:   jmp    0x0000000000000044
  42:   xor    %eax,%eax
  44:   leaveq
  45:   retq

eBPF系统调用

有了eBPF程序,就需要注入内核,比如attach到kprobe上。怎样做到的呢?这就需要bpf()系统调用了。bpf()是从用户空间进入eBPF的唯一入口,用来加载eBPF程序,创建和操作map。详细使用方法可参考bpf()的man page

Overview

eBPF整体设计架构如下

BPF程序实例

分析过eBPF的设计之后,我们来看一个eBPF程序的实例。在kernel repo的samples目录下有很多实例,这里贴出其中一例(tracex1)。一共有两个文件,tracex1_kern.c和tracex1_user.c。tracex1_kern.c是要真正load到kernel里面的eBPF程序。忽略一些编码细节,主体内容如下。这段代码用来在__netif_receive_skb_core()函数处添加一个kprobe event。

SEC("kprobe/__netif_receive_skb_core")
int bpf_prog1(struct pt_regs *ctx)
{
    /* attaches to kprobe netif_receive_skb,
     * looks for packets on loobpack device and prints them
     */
    char devname[IFNAMSIZ];
    struct net_device *dev;
    struct sk_buff *skb;
    int len;

    /* non-portable! works for the given kernel only */
    skb = (struct sk_buff *) PT_REGS_PARM1(ctx);
    dev = _(skb->dev);
    len = _(skb->len);

    bpf_probe_read(devname, sizeof(devname), dev->name);

    if (devname[0] == 'l' && devname[1] == 'o') {
       char fmt[] = "skb %p len %d\n";
       /* using bpf_trace_printk() for DEBUG ONLY */
       bpf_trace_printk(fmt, sizeof(fmt), skb, len);
    }

    return 0;
}

tracex1_user.c用来把上面的eBPF程序加载到kernel中。这段代码把tracex1_kern.o(注意是.o文件,也就是LLVM编译输出的目标文件)加载到内核中,attach到kprobe点,然后从trace_pipe读出trace结果。

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

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

     if (load_bpf_file(filename)) {
         printf("%s", bpf_log_buf);
         return 1;
     }

     f = popen("taskset 1 ping -c5 localhost", "r");
     (void) f;

     read_trace_pipe();

     return 0;
}

简化eBPF编程:BPF Compiler Collection(BCC)

通过上面的例子我们可以看到eBPF的开发过程很冗长,需要至少编译两个C文件才能把生成的eBPF程序注入内核。为了简化编程和使用,BCC就应运而生了。

BCC 是一个 python 库,但是其中有很大一部分的实现是基于 C 和 C++的,python 只不过实现了对 BCC 应用层接口的封装而已。使用 BCC 进行 eBPF 的开发仍然需要开发者自行利用 C 来设计 BPF 程序——但也仅此而已,余下的工作,包括编译、解析 ELF、加载 eBPF 代码块以及创建 map 等等基本可以由 BCC 一力承担,无需多劳开发者费心。

在ALI3000系列内核上使用BCC

在阿里内部,我们可以通过yum直接安装使用bcc:

yum install bcc llvm cfe

Bcc提供了很多现成的eBPF脚本用来执行一些常见的tracing任务,比如trace open()系统调用,disk I/O latency,等等。下面是一个简单的Hello World的例子,在每次调用clone()系统调用时,打印一个message。全部python实现源代码如下:

from bcc import BPF

# define BPF program
prog = """
int hello(void *ctx) {
    bpf_trace_printk("Hello, World!\\n");
    return 0;
}
"""

# load BPF program
b = BPF(text=prog)
b.attach_kprobe(event="sys_clone", fn_name="hello")

# header
print("%-18s %-16s %-6s %s" % ("TIME(s)", "COMM", "PID", "MESSAGE"))

# format output
while 1:
    try:
        (task, pid, cpu, flags, ts, msg) = b.trace_fields()
    except ValueError:
        continue
    print("%-18.9f %-16s %-6d %s" % (ts, task, pid, msg))

比上面的C语言的实现简化很多有木有?!当然,实际被kernel执行的eBPF程序(也就是上面代码中“prog =”定义的部分),还是需要用C实现。

执行上述脚本可得到如下结果:

TIME(s)            COMM             PID    MESSAGE
21.409921011       bash             1167   Hello, World!

除了可以使用python编程以外,bcc还支持lua。

具体如何使用BCC和怎样利用BCC编程,可以参考bcc tutorial,该文档提供了一个循序渐进的教程。

参考文献

  1. Documentation/bpf/bpf_design_QA.txt

  2. Documentation/networking/filter.txt

  3. eBPF简史 https://www.ibm.com/developerworks/cn/linux/l-lo-eBPF-history/index.html

  4. eBPF tools http://www.brendangregg.com/ebpf.html

  5. BCC代码 https://github.com/iovisor/bcc

  6. BCC tutorial https://github.com/iovisor/bcc/blob/master/docs/tutorial.md

  7. eBPF ISA spec https://github.com/iovisor/bpf-docs/blob/master/eBPF.md

  8. bpf() manpage http://man7.org/linux/man-pages/man2/bpf.2.html

相关文章