BPF(BerkeleyPacketFilter),中文翻译为伯克利包过滤器,是类Unix系统上数据链路层的一种原始接口,提供原始链路层封包的收发。年,StevenMcCanne和VanJacobson写了一篇名为《BSD数据包过滤:一种新的用户级包捕获架构》的论文。在文中,作者描述了他们如何在Unix内核实现网络数据包过滤,这种新的技术比当时最先进的数据包过滤技术快20倍。BPF在数据包过滤上引入了两大革新:
一个新的虚拟机(VM)设计,可以有效地工作在基于寄存器结构的CPU之上;
应用程序使用缓存只复制与过滤数据包相关的数据,不会复制数据包的所有信息。这样可以最大程度地减少BPF处理的数据;
由于这些巨大的改进,所有的Unix系统都选择采用BPF作为网络数据包过滤技术,直到今天,许多Unix内核的派生系统中(包括Linux内核)仍使用该实现。
年初,AlexeiStarovoitov实现了eBPF(extendedBerkeleyPacketFilter)。经过重新设计,eBPF演进为一个通用执行引擎,可基于此开发性能分析工具、软件定义网络等诸多场景。eBPF最早出现在3.18内核中,此后原来的BPF就被称为经典BPF,缩写cBPF(classicBPF),cBPF现在已经基本废弃。现在,Linux内核只运行eBPF,内核会将加载的cBPF字节码透明地转换成eBPF再执行。
eBPF新的设计针对现代硬件进行了优化,所以eBPF生成的指令集比旧的BPF解释器生成的机器码执行得更快。扩展版本也增加了虚拟机中的寄存器数量,将原有的2个32位寄存器增加到10个64位寄存器。由于寄存器数量和宽度的增加,开发人员可以使用函数参数自由交换更多的信息,编写更复杂的程序。总之,这些改进使eBPF版本的速度比原来的BPF提高了4倍。
eBPF在Linux3.18版本以后引入,并不代表只能在内核3.18+版本上运行,低版本的内核升级到最新也可以使用eBPF能力,只是可能部分功能受限,比如我测试在CentOS73.10.以上都可以受限使用部分eBPF功能,低版本CentOS7可以yum升级内核,重启支持部分eBPF功能。
#yuminstallkernelkernel-develkernel-headers-y#reboot
eBPF分为用户空间程序和内核程序两部分:
用户空间程序负责加载BPF字节码至内核,如需要也会负责读取内核回传的统计信息或者事件详情;内核中的BPF字节码负责在内核中执行特定事件,如需要也会将执行的结果通过maps或者perf-event事件发送至用户空间;其中用户空间程序与内核BPF字节码程序可以使用map结构实现双向通信,这为内核中运行的BPF字节码程序提供了更加灵活的控制。
用户空间程序与内核中的BPF字节码交互的流程主要如下:
我们可以使用LLVM工具将编写的BPF代码程序编译成BPF字节码;然后使用加载程序Loader将字节码加载至内核;内核使用验证器(verfier)组件保证执行字节码的安全性,以避免内核panic,在确认字节码安全后将其加载对应的内核模块执行;BPF观测技术相关的程序程序类型可能是kprobes/uprobes/tracepoint/perf_events中的一个或多个,其中:
kprobes:实现内核中动态跟踪。kprobes可以跟踪到Linux内核中的导出函数入口或返回点,但是不是稳定ABI接口,可能会因为内核版本变化导致,导致跟踪失效。uprobes:用户级别的动态跟踪。与kprobes类似,只是跟踪用户程序中的函数。tracepoints:内核中静态跟踪。tracepoints是内核开发人员维护的跟踪点,能够提供稳定的ABI接口,但是由于是研发人员维护,数量和场景可能受限。perf_events:定时采样和PMC。内核中运行的BPF字节码程序可以使用两种方式将测量数据回传至用户空间maps方式可用于将内核中实现的统计摘要信息(比如测量延迟、堆栈信息)等回传至用户空间;perf-event用于将内核采集的事件实时发送至用户空间,用户空间程序实时读取分析;
eBPF技术虽然强大,但是为了保证内核的处理安全和及时响应,内核中的eBPF技术也给予了诸多限制,当然随着技术的发展和演进,限制也在逐步放宽或者提供了对应的解决方案。
eBPF程序不能调用任意的内核参数,只限于内核模块中列出的BPFHelper函数,函数支持列表也随着内核的演进在不断增加。eBPF程序不允许包含无法到达的指令,防止加载无效代码,延迟程序的终止。eBPF程序中循环次数限制且必须在有限时间内结束,这主要是用来防止在kprobes中插入任意的循环,导致锁住整个系统;解决办法包括展开循环,并为需要循环的常见用途添加辅助函数。Linux5.3在BPF中包含了对有界循环的支持,它有一个可验证的运行时间上限。eBPF堆栈大小被限制在MAX_BPF_STACK,截止到内核Linux5.8版本,被设置为;参见include/linux/filter.h,这个限制特别是在栈上存储多个字符串缓冲区时:一个char[]缓冲区会消耗这个栈的一半。目前没有计划增加这个限制,解决方法是改用bpf映射存储,它实际上是无限的。eBPF字节码大小最初被限制为条指令,截止到内核Linux5.8版本,当前已将放宽至万指令(BPF_COMPLEXITY_LIMIT_INSNS),参见:include/linux/bpf.h,对于无权限的BPF程序,仍然保留条限制(BPF_MAXINSNS);新版本的eBPF也支持了多个eBPF程序级联调用,虽然传递信息存在某些限制,但是可以通过组合实现更加强大的功能。
eBPF支持的内核探针(Kernelprobes)功能,允许开发者在几乎所有的内核指令中以最小的开销设置动态的标记或中断。当内核运行到某个标记的时候,就会执行附加到这个探测点上的代码,然后恢复正常的流程。对内核行为的追踪探测,可以获取内核中发生任何事件的信息,比如系统中打开的文件、正在执行的二进制文件、系统中发生的TCP连接等。
内核动态探针可以分为两种:kprobes和kretprobes。二者的区别在于,根据探针执行周期的不同阶段,来确定插入eBPF程序的位置。kprobes类型的探针用于跟踪内核函数调用,是一种功能强大的探针类型,让我们可以追踪成千上万的内核函数。由于它们用来跟踪底层内核的,开发者需要熟悉内核源代码,理解这些探针的参数、返回值的意义。
Kprobes通常在内核函数执行前插入eBPF程序,而kretprobes则在内核函数执行完毕返回之后,插入相应的eBPF程序。比如,tcp_connect()是一个内核函数,当有TCP连接发生时,将调用该函数,那么如果对tcp_connect()使用kprobes探针,则对应的eBPF程序会在tcp_connect()被调用时执行,而如果是使用kretprobes探针,则eBPF程序会在tcp_connect()执行返回时执行。后文会举例说明如何使用Kprobes探针。
尽管Kprobes允许在执行任何内核功能之前插入eBPF程序。但是,它是一种“不稳定”的探针类型,开发者在使用Kprobes时,需要知道想要追踪的函数签名(FunctionSignature)。而Kprobes当前没有稳定的应用程序二进制接口(ABI),这意味着它们可能在内核不同的版本之间发生变化。如果内核版本不同,内核函数名、参数、返回值等可能会变化。如果尝试将相同的探针附加到具有两个不同内核版本的系统上,则相同的代码可能会停止工作。
因此,开发者需要确保使用Kprobe的eBPF程序与正在使用的特定内核版本是兼容的。
Tracepoints是在内核代码中所做的一种静态标记,是开发者在内核源代码中散落的一些hook,开发者可以依托这些hook实现相应的追踪代码插入。
开发者在/sys/kernel/debug/tracing/events/目录下,可以查看当前版本的内核支持的所有Tracepoints,在每一个具体Tracepoint目录下,都会有一系列对其进行配置说明的文件,比如可以通过enable中的值,来设置该Tracepoint探针的开关等。与Kprobes相比,他们的主要区别在于,Tracepoints是内核开发人员已经在内核代码中提前埋好的,这也是为什么称它们为静态探针的原因。而kprobes更多的是跟踪内核函数的进入和返回,因此将其称为动态的探针。但是内核函数会随着内核的发展而出现或者消失,因此kprobes对内核版本有着相对较强的依赖性,前文也有提到,针对某个内核版本实现的追踪代码,对于其它版本的内核,很有可能就不工作了。
那么,相比Kprobes探针,我们更加喜欢用Tracepoints探针,因为Tracepoints有着更稳定的应用程序编程接口,而且在内核中保持着前向兼容,总是保证旧版本中的跟踪点将存在于新版本中。
然而,Tracepoints的不足之处在于,这些探针需要开发人员将它们添加到内核中,因此,它们可能不会覆盖内核的所有子系统,只能使用当前版本内核所支持的探测点。
eBPF程序的主要数据结构是eBPFmap,一种key-value数据结构。Maps通过bpf()系统调用创建和操作。
有不同类型的Map:
BPF_MAP_TYPE_HASH:哈希表BPF_MAP_TYPE_ARRAY:数组映射,已针对快速查找速度进行了优化,通常用于计数器BPF_MAP_TYPE_PROG_ARRAY:对应eBPF程序的文件描述符数组;用于实现跳转表和子程序以处理特定的数据包协议BPF_MAP_TYPE_PERCPU_ARRAY:每个CPU的阵列,用于实现延迟的直方图BPF_MAP_TYPE_PERF_EVENT_ARRAY:存储指向structperf_event的指针,用于读取和存储perf事件计数器BPF_MAP_TYPE_CGROUP_ARRAY:存储指向控制组的指针BPF_MAP_TYPE_PERCPU_HASH:每个CPU的哈希表BPF_MAP_TYPE_LRU_HASH:仅保留最近使用项目的哈希表BPF_MAP_TYPE_LRU_PERCPU_HASH:每个CPU的哈希表,仅保留最近使用的项目BPF_MAP_TYPE_LPM_TRIE:最长前缀匹配树,适用于将IP地址匹配到某个范围BPF_MAP_TYPE_STACK_TRACE:存储堆栈跟踪BPF_MAP_TYPE_ARRAY_OF_MAPS:地图中地图数据结构BPF_MAP_TYPE_HASH_OF_MAPS:地图中地图数据结构BPF_MAP_TYPE_DEVICE_MAP:用于存储和查找网络设备引用BPF_MAP_TYPE_SOCKET_MAP:存储和查找套接字,并允许使用BPF辅助函数进行套接字重定向
可以使用bpf_map_lookup_elem()和bpf_map_update_elem()函数从eBPF或用户空间程序访问所有Map.
使用manbpf查看bpf系统调用,intbpf(intcmd,unionbpf_attr*attr,unsignedintsize)
第一个参数cmd,如下
BPF_MAP_CREATE:创建一个map,并返回一个fd,指向这个map,这个map在bpf是非常重要的数据结构,用于bpf程序在内核态和用户态之间相互通信。BPF_MAP_LOOKUP_ELEM:在给定一个map中查询一个元素,并返回其值BPF_MAP_UPDATE_ELEM:在给定的map中创建或更新一个元素(关于key/value的键值对)BPF_MAP_DELETE_ELEM:在给定的map中删除一个元素(关于key/value的键值对)BPF_MAP_GET_NEXT_KEY:在一个特定的map中根据key值查找到一个元素,并返回这个key对应的下一个元素BPF_PROG_LOAD:验证并加载一个bpf程序。并返回与这个程序关联的fd。......等等
bpf_attr,第2个参数,该参数的类型取决于cmd参数的值,本文只分析cmd=BPF_PROG_LOAD这种情况,其中prog_type指定了bpf程序类型,eBPF程序支持attach到不同的event上,比如Kprobe,UProbe,tracepoint,Networkpackets,perfevent等。完整如下:
内核支持的当前eBPF程序类型集为:BPF_PROG_TYPE_SOCKET_FILTER:网络数据包过滤器BPF_PROG_TYPE_KPROBE:确定是否应触发kprobeBPF_PROG_TYPE_SCHED_CLS:网络流量控制分类器BPF_PROG_TYPE_SCHED_ACT:网络流量控制操作BPF_PROG_TYPE_TRACEPOINT:确定是否应触发跟踪点BPF_PROG_TYPE_XDP:从设备驱动程序接收路径运行的网络数据包过滤器BPF_PROG_TYPE_PERF_EVENT:确定是否触发perf事件处理程序BPF_PROG_TYPE_CGROUP_SKB:用于cgroups的网络数据包过滤器BPF_PROG_TYPE_CGROUP_SOCK:用于cgroups的网络数据包过滤器,允许修改socket选项BPF_PROG_TYPE_LWT_*:用于隧道的网络数据包过滤器BPF_PROG_TYPE_SOCK_OPS:用于设置socket参数的程序BPF_PROG_TYPE_SK_SKB:网络数据包过滤器,用于在socket之间转发数据包BPF_PROG_CGROUP_DEVICE:确定是否允许设备(device)操作
比如,cmd=BPF_PROG_LOAD使用,bpf_attr字段如下:
struct{/*UsedbyBPF_PROG_LOAD*/__u32prog_type;//设置为`BPF_PROG_TYPE_KPROBE`,表示是通过kprobe注入到内核函数。__u32insn_cnt;__aligned_u64insns;/*conststructbpf_insn**/__aligned_u64license;//指定license__u32log_level;/*verbositylevelofverifier*/__u32log_size;/*sizeofuserbuffer*/__aligned_u64log_buf;//用户buff__u32kern_version;/*checkedwhenprog_type=kprobe(sinceLinux4.1)*/};
size:第三个参数
表示上述bpf_attr字节大小。
当加载bpf程序时,BPF_PROG_LOAD表示的是加载具体bpf指令,对应SEC宏下面的函数代码段。每条指令的操作码由5部分组成:
structbpf_insn{__u8code;/*opcode(操作码)*/__u8dst_reg:4;/*destregister(目标寄存器)*/__u8src_reg:4;/*sourceregister(源寄存器)*/__s16off;/*signedoffset(偏移)*/__s32imm;/*signedimmediateconstant(立即数)*/};
详见
insns=[{code=BPF_LDX
BPF_DW
BPF_MEM,dst_reg=BPF_REG_1,src_reg=BPF_REG_1,off=,imm=0},{code=BPF_STX
BPF_DW
BPF_MEM,dst_reg=BPF_REG_10,src_reg=BPF_REG_1,off=-8,imm=0},{code=BPF_ALU64
BPF_X
BPF_MOV,dst_reg=BPF_REG_2,src_reg=BPF_REG_10,off=0,imm=0},{code=BPF_ALU64
BPF_K
BPF_ADD,dst_reg=BPF_REG_2,src_reg=BPF_REG_0,off=0,imm=0xfffffff8},{code=BPF_LD
BPF_DW
BPF_IMM,dst_reg=BPF_REG_1,src_reg=BPF_REG_1,off=0,imm=0x4},{code=BPF_LD
BPF_W
BPF_IMM,dst_reg=BPF_REG_0,src_reg=BPF_REG_0,off=0,imm=0},{code=BPF_JMP
BPF_K
BPF_CALL,dst_reg=BPF_REG_0,src_reg=BPF_REG_0,off=0,imm=0x3},{code=BPF_ALU64
BPF_K
BPF_MOV,dst_reg=BPF_REG_0,src_reg=BPF_REG_0,off=0,imm=0},{code=BPF_JMP
BPF_K
BPF_EXIT,dst_reg=BPF_REG_0,src_reg=BPF_REG_0,off=0,imm=0}
bpf系统调用调用bpf_prog_load来加载ebpf程序。bpf_prog_load大致有以下几步:
1.调用bpf_prog_alloc为prog申请内存,大小为structbpf_prog大小+ebpf指令总长度2.将ebpf指令复制到prog-insns3.调用bpf_check对ebpf程序合法性进行检查,这是ebpf的安全性的关键所在,不符ebpf规则的load失败4.调用bpf_prog_select_runtime在线jit,编译ebpf指令成x64指令5.调用bpf_prog_alloc_id为prog生成id,作为prog的唯一标识的id被很多工具如bpftool用来查找prog
ebpf从bpf的两个32位寄存器扩展到10个64位寄存器R0~R9和一个只读栈帧寄存器,并支持call指令,更加贴近现代64位处理器硬件
R0对应rax,函数返回值R1对应rdi,函数参数1R2对应rsi,函数参数2R3对应rdx,函数参数3R4对应rcx,函数参数4R5对应r8,函数参数5R6对应rbx,callee保存R7对应r13,callee保存R8对应r14,callee保存R9对应r15,callee保存R10对应rbp,只读栈帧寄存器
可以看到x64的r9寄存器没有ebpf寄存器对应,所以ebpf函数最多支持5个参数。
demo是在KaliLinux上开发的。环境搭建比较简单,由于内核不一样,可能修改部分参数:
apt-getinstallgolangclangllvm-y
CentOS7可以按下面搭建
添加yum源:c7-clang-x86_64.repo[c7-devtoolset-8]name=c7-devtoolset-8baseurl=