您现在的位置是:首页 > 行业发展

eBPF 的发展历史和核心设计

智慧创新站 2025-05-18【行业发展】175人已围观

简介前言本文翻译自2016年DanielBorkman在NetdevConf大会上的一篇文章:Ongettingtcclassifierfullyprogrammablewithcls_bpf[1]。Daniel是eBPF的核心开发之一,文章从技术层面介绍了eBPF的发展历史、核心设计,以及更重要的——...

前言

本文翻译自2016年DanielBorkman在NetdevConf大会上的一篇文章:Ongettingtcclassifierfullyprogrammablewithcls_bpf[1]。

Daniel是eBPF的核心开发之一,文章从技术层面介绍了eBPF的发展历史、核心设计,以及更重要的——在eBPF基础之上,cls_bpf如何使tc分类器变得完全可编程。

由于eBPF发展很快,文中有些描述今天已经过时(例如单个eBPF程序允许的最大指令数量),因此翻译时以译注的形式做了适当更新。插入的一些内核代码基于4.19。

由于译者水平有限,本文不免存在遗漏或错误之处。如有疑问,请查阅原文。

以下是译文。

摘要

BerkelyPacketFilter(BPF)是1993年设计的一种指令集架构(instructionsetarchitecture)[18][1]——作为一种通用数据包过滤方案(genericpacketfilteringsolution),提供给libpcap/tcpdump等上层应用使用。BPF很早就已经出现在Linux内核中,并且使用场景也不再仅限于网络方面,例如有对系统调用进行过滤(systemcallfiltering)的seccompBPF[15]。

近几年,Linux社区将这种经典BPF(classicBPF,cBPF)做了升级,形成一个新的指令集架构,称为“extedBPF”(eBPF)[21][23][22][24]。与cBPF相比,eBPF带了更大的灵活性和可编程性,也带来了一些新的使用场景,例如跟踪(tracing)[27]、KCM(KernelConnectionMultiplexor)[17]等。

KernelConnectionMultiplexor(KCM)isafacilitythatprov/networking/

除了替换掉解释器之外,JIT编译器也进行了升级,使eBPF[25]程序能达到平台原生的执行性能。

内核流量控制层的cls_bpf分类器添加了对eBPF的支持之后[8],tc对Linux数据平面进行编程的能力更加强大,并且该过程与内核网络栈、相关工具及底层编程范式的联系也更紧密。

本文将介绍eBPF、eBPF与tc的交互及内核网络社区在eBPF领域的一些最新工作。

本文内容不求大而全,而是希望作为一份入门材料,供那些对eBPF架构及其与tc关系感兴趣的人参考。

关键字:eBPF,cls_bpf,tc,programmabledatapath,Linuxkernel

1引言

经典BPF(cBPF)多年前就已经在Linux内核中实现了,主要用户是PF_PACKETsockets。在该场景中,cBPF作为一种通用、快速且安全的方案,在PF_PACKET收包路径的早期位置(earlypoint)解析数据包(packetparsing)。其中,与安全执行(safeexecution)相关的一个目标是:从用户程序向内核注入非受信代码,但不能因此破坏内核的稳定性。

1.1cBPF架构

cBPF是32bit架构[18],主要针对包解析(packetparsing)场景设计:

两个主寄存器A和X

A是主寄存器(mainregister),也称作累加器(accumulator)。这里执行大部分操作,例如alu、load、store、comparison-for-jump等。

X主要用作临时寄存器,也用于加载包内容(relativeloadsofpacketcontents)。

一个16wordscratchspace(存放临时数据),通常称为M

一个隐藏的程序计数器(PC)

使用cBPF时,包的内容只能读取,不能修改。

cBPF有8种的指令类型:

ld

ldx

st

stx

alu

jmp

ret

其他一些指令:用于传递A和X中的内容。

几点解释:

前四个是加载相关的指令,load和store类型分别会用到寄存器A和X。

jump只支持前向跳转(forwardjump)。

ret结束cBPF程序执行,从程序返回。

每个cBPF程序最多只能包含4096条指令(maxinstructions/programm),代码在加载到内核执行之前,校验器会对其进行静态验证(staticallyverify)。

具体到bpf_asm工具[5],它包含33条指令、11种寻址模式和16个Linux相关的cBPF扩展(extensions)。

1.2cBPF使用场景

cBPF程序的语义是由使用它的子系统定义的。由于其通用、最小化和快速执行的特点,如今cBPF已经在PF_PACKETsocket之外的一些场景找到了用武之地:

seccompBPF[15]于2012年添加到内核,目的是提供一种安全和快速的系统调用过滤方式。

网络领域,cBPF已经能

用作大部分协议(TCP、UDP、netlink等)的socketfilter;

用作PF_PACKETsocket的fanoutdemuxingfacility[14][13]

用于socketdemuxingwithSOREUSEPORT[16]

用于loadbalancinginteamdriver[19]

用于本文将介绍的tc子系统中,作为classifier[6]andaction[20]

其他一些场景

eBPF作为对cBPF的扩展,第一个commit于2014年合并到内核。从那之后,BPF的可编程特性已经发生了巨大变化。

2eBPF架构

与cBPF类似,eBPF也可以被视为一个最小“虚拟”机(minimalistic”virtual”machineconstruct)[21]。eBPF抽象的机器只有少量寄存器、很小的栈空间、一个隐藏的程序计数器以及一个所谓的辅助函数(helperfunction)的概念。

在内核其他基础设施的配合下,eBPF能做一些有副作用(sideeffects)的事情。

这里的副作用是指:eBPF程序能够对拦截到的东西做(安全的)修改,而cBPF对拦截到的东西都是只能读、不能改的。译注。

eBPF程序是事件驱动的,触发执行时,系统会传给它一些参数,这些输入(inputs)称为“上下文”(context)。对于tceBPF程序来说,传递的上下文是skb,即网络设备tc层的ingress或egress路径上正在经过的数据包。

2.0指令集架构寄存器设计

eBPF有

11个寄存器(R0~R10)

每个寄存器都是64bit,有相应的32bit子寄存器

指令集是固定的64bit位宽,参考了cBPF、x86_64、arm64和risc指令集的设计,目的是方便JIT编译(将eBPF指令编译成平台原生指令)。

eBPF兼容cBPF,并且与后者一样,给用户空间程序提供稳定的ABI。

解释器和JIT编译器

目前,x86_64、s390和arm64平台的Linux内核都自带了eBPF解释器和JIT编译器。还没有将cBPFJIT转换成eBPFJIT的平台,只能通过解释器执行。

此外,原来某些不支持JIT编译的cBPF代码,现在也能够在加载时自动转换成eBPF指令,接下来或者通过解释器执行,或者通过eBPFJIT执行。一个例子就是seccomBPF:引入了eBPF指令之后,原来的cBPFseccom指令就自动被转换成eBPF指令了。

指令编码格式

eBPF指令编码格式:

8bitcode:存放真正的指令码(instructioncode)

8bitdstreg:存放指令用到的寄存器号(R0~R10)

8bitsrcreg:同上,存放指令用到的寄存器号(R0~R10)

16bitsignedoffset:取决于指令类型,可能是

ajumpoffset:incasetherelatedconditionisevaluatedastrue

arelativestackbufferoffsetforload/storesofregistersintothestack

aincrementoffset:incaseofanxaddaluinstruction,itcanbean

32bitsignedimm:存放立即值(carriestheimmediatevalue)

新指令

eBPF带来了几个新指令,例如

工作在64位模式的alu操作

有符号移位(signedshift)操作

load/storeofdoublewords

agenericmoveoperationforregistersandimmediatevalues

operatorsforiannessconversion,

acalloperationforinvokinghelperfunctions

anatomicadd(xadd)instruction.

单个程序的指令数限制

与cBPF类似,eBPF中单个程序的最大指令数(instructions/programm)是4096。

译注:现在已经放大到了100万条。

这些指令序列(instructionsequence)在加载到内核之前会进行静态校验(staticallyverified),以确保它们不会包含破坏内核稳定性的代码,例如无限循环、指针或数据泄露、非法内存访问等等。cBPF只支持前向跳转,而eBPF额外支持了受限的后向跳转——只要后向跳转不会产生循环,即保证程序能在有限步骤内结束。

除此之外,eBPF还引入了一些新的概念,例如helperfunctions、maps、tailcalls、objectpinning。接下来分别详细讨论。

2.1辅助函数(HelperFunctions)

辅助函数是一组内核定义的函数集,使eBPF程序能从内核读取数据,或者向内核写入数据(retrieve/pushdatafrom/tothekernel)。

不同类型的eBPF程序能用到的helperfunction集合是不同的,例如,

socket层eBPF能使用的辅助函数,只是tc层eBPF能使用的辅助函数的一个子集。

flow-basedtunneling场景中,封装/解封装用的辅助函数只能用在比较低层的tcingress/egress层。

函数签名

与系统调用类似,所有辅助函数的签名是一样的,格式为:u64foo(u64r1,u64r2,u64r3,u64r4,u64r5)。

调用约定

辅助函数的调用约定(callingconvention)也是固定的:

R0:存放程序返回值

R1~R5:存放函数参数(functionarguments)

R6~R9:被调用方(callee)负责保存的寄存器

R10:栈空间load/store操作用的只读framepointer

带来的好处

这样的设计有几方面好处:

JIT更加简单、高效。cBPF中,为了调用某些特殊功能的辅助函数(auxiliaryhelperfunctions),对load指令进行了重载(overload),在数据包的某个看似不可能的位置(impossiblepacketoffset)加载数据,以这种方式调用到辅助函数;每个cBPFJIT都需要实现对这样的cBPF扩展的支持。而在eBPF中,每个辅助函数都是以透明和高效地方式进行JIT编译的,这意味着JIT编译器只需要emit一个call指令——因为寄存器映射(registermapping)的设计中,eBPF已经和底层架构的调用约定是匹配的了。

函数签名使校验器能执行类型检查(typechecks)。每个辅助函数都有一个配套的structbpf_func_proto类型变量,

/*eBPFfunctionprototypeusedbyverifiertoallowBPF_CALLsfromeBPFprograms*toin-kernelhelperfunctionsandforadjustingimm32fieldinBPF_CALLinstructionsafterverifying*/structbpf_func_proto{u64(*func)(u64r1,u64r2,u64r3,u64r4,u64r5);boolgpl_only;boolpkt_access;enumbpf_return_typeret_type;enumbpf_arg_typearg1_type;enumbpf_arg_typearg2_type;enumbpf_arg_typearg3_type;enumbpf_arg_typearg4_type;enumbpf_arg_typearg5_type;};

一个例子:

//net/core/_CALL_2(bpf_redirect,u32,ifindex,u64,flags){structbpf_redirect_info*ri=this_cpu_ptr(bpf_redirect_info);if(unlikely(flags~(BPF_F_INGRESS)))returnTC_ACT_SHOT;ri-ifindex=ifindex;ri-flags=flags;returnTC_ACT_REDIRECT;}staticconststructbpf_func_protobpf_redirect_proto={.func=bpf_redirect,.gpl_only=false,.ret_type=RET_INTEGER,.arg1_type=ARG_ANYTHING,.arg2_type=ARG_ANYTHING,};

校验器据此就能知道该helper函数的详细信息,进而确保该helper的类型与当前eBPF程序用到的寄存器内的内容是匹配的。

helper函数的参数类型有很多种,如果是指针类型(例如ARG_PTR_TO_MEM),校验器还可以执行进一步的检查,例如判断这个缓冲区之前是否已经初始化了。

2.2Maps

Map是eBPF的另一个重要组成部分。它是一种高效的key/value存储,map的内容驻留在内核空间,但可以**在用户空间通过文件描述符访问**。

Map可以在多个eBPF程序之间共享,而且没有什么限制,例如,可以在一个tceBPF程序和一个tracingeBPF程序之间共享。

map类型

Map后端是由核心内核(thecorekernel)提供的,可能是通用类型(generic),也可能是专用类型(specializedtype);某些专业类型的map只能用于特定的子系统,例如[28]。

通用类型map当前是数组或哈希表结构(arrayorhashtable),可以是per-CPU的类型,也可以是non-per-CPU类型。

创建和访问map

创建map:只能从用户空间操作,通过bpf(2)系统调用完成。

从eBPF程序中访问map:通过辅助函数。

从用户空间访问map:通过bpf(2)系统调用。

map相关辅助函数调用

以上设计意味着,如果eBPF程序想调用某个map相关的辅助函数,它需要将文件描述符编码到指令中——文件描述符会进一步对应到map引用,并放到正确的寄存器——BPF_LD_MAP_FD(BPF_REG_1,fd)就是一个例子。内核能识别出这种特殊src寄存器的情况,然后从文件描述符表中查找该fd,进而找到真正的eBPFmap,然后在内部对指令进行重写(rewritetheinstruction)。

2.3ObjectPinning(目标文件锚定)

eBPFmap和eBPFprogram都是内核资源(kernelresource),只能通过文件描述符(filedescriptor)访问;而文件描述符背后是内核中的匿名inode(backedbyanonymousinodesinthekernel)。

文件描述符方式的限制

以上这种方式有优点,例如:

用户空间程序能使用大部分文件描述符相关的API

在Unixdomainsocket传递文件描述符是透明的

但也有缺点:文件描述符的生命周期在进程生命周期之内,因此不同进程之间共享map之类的东西就比较困难。

这给tc等应用带来了很多不便。因为tc的工作方式是:将程序加载到内核之后就退出(而不是持续运行的进程)。

此外,从用户空间也无法直接访问map(bpf(2)系统调用不算),否则这会很有用。例如,第三方应用可能希望在eBPF程序运行时(runtime)监控或更新map的内容。

针对这些问题,提出了几种保持文件描述符alive的设想,其中之一是重用fuse,作为tc的proxy。这种情况下,文件描述符被fuseimplementation所拥有,tc之类的工具可以通过unixdomainsockets来获取这些文件描述符。但又也带来了很大的新问题:增加了新的依赖fuse,而且需要作为额外的守护进程安装和启动。大型部署中,都希望保持用户空间最小化(maintainaminimalisticuserspace)以节省资源。因此这样的额外依赖难以让用户接受。

BPF文件系统(bpffs)

为了更好的解决以上问题,我们在内核中实现了一个最小文件系统(aminimalkernelspacefilesystem)[4]。

eBPFmap和eBPFprogram可以pin(固定)到这个文件系统,这个过程称为objectpinning。bpf(2)系统调用也新加了两个命令用来pin或获取一个已经pinned的object。例如,tc之类的工具利用这个新功能[9]就能在ingress或egress上共享map。

eBPF文件系统在每个mount命名空间创建一个挂载实例(keepaninstancepermountnamespace),并支持bindmounts、hardlinks等功能,并与网络命令空间无缝集成。

2.4尾调用(TailCalls)

eBPF的另一个概念是尾调用[26]:从一个程序调用到另一个程序,且后者执行完之后不再返回到前者。

不同于普通的函数调用,尾调用的开销最小;

底层通过longjump实现,复用原来是栈帧(reusingthesamestackframe)。

程序之间传递状态

尾调用的程序是独立验证的(verifiedindepently),因此要在两个程序之间传递状态,就需要用到:

per-CPUmaps,作为自定义数据的存储区(asscratchbuffers),或者

skb的某些可以存储自定义数据的字段,例如cb(controlbuffer)字段

只有同类型的程序之间才可以尾调用,而且它们要么都是通过解释器执行,要么都是通过JIT编译之后执行,不支持混合两种模式。

底层实现

尾调用涉及两个步骤:

首先设置一个特殊的、称为程序数组(programarray)的map。这个map可以从用户空间通过key/value操作,其中value是各个eBPF程序的文件描述符。

第二步是执行bpf_tail_call(void*ctx,structbpf_map*prog_array_map,u32index)辅助函数,其中下面是这个辅助函数的进一步说明:

prog_array_map就是前面提到的程序数组,

index是程序数组的索引,表示希望跳转到这个位置的文件描述符所指向的程序。

//include/uapi/linux/*intbpf_tail_call(void*ctx,structbpf_map*prog_array_map,u32index)*Description*Thisspecialhelperisusedtotriggera"tailcall",orin*otherwords,*frameisused(butvaluesonstackandinregistersforthe*callerarenotaccessibletothecallee).Thismechanismallows*forprogramchaining,eitherforraisingthemaximumnumberof*availableeBPFinstructions,ortoexecutegivenprogramsin*,thereisanupper*limittothenumberofsuccessivetailcallsthatcanbe*performed.**Uponcallofthishelper,theprogramattemptstojumpintoa*programreferencedatindex*index*in*prog_array_map*,a*specialmapoftype**BPF_MAP_TYPE_PROG_ARRAY**,andpasses**ctx*,apointertothecontext.**Ifthecallsucceeds,thekernelimmediatelyrunsthefirst*,**fails,thenthehelperhasnoeffect,andthecallercontinues**destinationprogramforthejumpdoesnotexist(*index**issuperiortothenumberofentriesin*prog_array_map*),or*ifthemaximumnumberoftailcallshasbeenreachedforthis**macro**MAX_TAIL_CALL_CNT**(notaccessibletouserspace),*whichiscurrentlysetto32.*Return*0onsuccess,oranegativeerrorincaseoffailure.

内核会将这个辅助函数调用转换成一个特殊的eBPF指令。另外,这个programarray对于用户空间是只读的。

内核根据文件描述符(fd=prog_array_map[index])查找相关的eBPF程序,然后自动将相应mapslot程序指针进行替换。如果prog_array_map[index]为空,内核就继续在原来的eBPF程序中继续执行bpf_tail_call()之后的指令。

尾调用是一个非常强大的功能,例如,解析网络头(networkheaders)可以通过尾调用实现(因为每解析一层就可以丢弃一层,没有再返回来的需求)。另外,尾调用还能够在运行时(runtime)原子地添加或替换功能,改变执行行为。

2.5安全:锁定镜像为只读模式、地址随机化

eBPF有几种防止有意或无意的内核bug导致程序镜像(programimages)损坏的技术——即便这些bug跟BPF无关。

支持CONFG_DEBUG_SET_MODULE_RONX配置选项的平台,启用这个配置后,内核会将eBPF解释器的镜像设置为只读的[2]。

当启用JIT编译之后,内核还会将生成的可执行镜像(generatedexecutableimages)锁定为只读的,并且对其地址进行随机化,以使猜测更加困难。镜像中的缝隙(gapsintheimages)会填充trap指令(例如,x86_64平台上填充的是int3opcode),用来捕获跳转探测(catchingsuchjumpprobes)。

对于非特权程序(unprivilegedprograms),校验器还会对能使用的helper函数、指针等施加额外的限制,以确保不会发生数据泄露。

2.6LLVM

至此,还有一个重要方面一直没有讨论:如何编写eBPF程序。

cBPF提供的选择很少:libpcap里面的cBPFcompiler,bpf_asm,或者手写cBPF程序;相比之下,eBPF支持使用更更高层的语言(例如C和P4)来编写,大大方便了eBPF程序的开发。

LLVM有一个eBPF后端(back),能生成(emit)包含eBPF指令的ELF文件。Clang这样的前端(fronts)能用来编译eBPF程序。

用clang来编译eBPF程序非常简单:。一个很有用的选项是指定输出汇编代码:,或者用readelf之类的工具dump和分析ELFsections和relocations。

典型的工作流:

用C编写eBPF代码

用clang/llvm编译成目标文件

用tc之类的加载器(能与cls_bpf分类器交互)将目标文件加载到内核

3tccls_bpf和eBPF3.0cls_bpf和act_bpf可编程tc分类器cls_bpf

cls_bpf作为一种分类器(classifier,也叫filter),2013年就出现在了cBPF中[6]。通过bpf_asm、libpcap/tcpdump或其他一些cBPF字节码生成器能对它进行编程。步骤:

使用工具生成字节码(bytecode)

将字节码传递给tc前端

tc前端**通过netlink消息将字节码下发到tccls_bpf分类器**

可编程tc动作(action)act_bpf

后来又出现act_bpf[20],这是一种tcaction,因此与其他tcaction一样,act_bpf能被attach到tc分类器,作为分类器执行完之后对包要执行的动作(即,分类器执行完之后返回一个actioncode,act_bpf能根据这个code执行相应的行为,例如丢弃包)。

act_bpf功能与cls_bpf几乎相同,区别在于二者的返回码类型:

cls_bpf返回的是tcclassid(major/minor)

actbpf返回的是tcactionopcode

这里对cls_bpf/act_bpf的解释太简单。想进一步了解,可参考:[(译)深入理解tcebpf的direct-action(da)模式(2020)]({%link_posts/2021-02-21-%}"(译)深入理解tcebpf的direct-action(da)模式(2020)")译注。

act_bpf的缺点是:

只适用用于cBPF

无法对包进行修改(mangle)

因此通常需要用actionpipeline做进一步处理,例如act_pedit,代价是额外的包级别(packet-level)的性能开销。

eBPF对cls_bpf的支持

eBPF引入BPF_PROG_TYPE_SCHED_CLS[8]和BPF_PROG_TYPE_SCHED_ACT[7]之后也支持了cls_bpf和act_bpf。

这两种类型的fastpath都在RCU内运行(rununderRCU)

二者做的主要事情也就是**调用BPF_PROG_RUN()**,后者会解析到(*filter-bpf_func)(ctx,filter-insnsi),其中ctx参数包含了skb信息

bpf_func()里对skb进行处理,接下来可能会执行:

eBPF解释器(bpf_prog_run())

JIT编译器生成的JITimage

eBPFcls_bpf带来的好处

cls_bpf_classify()之类的函数感知不到底层BPF类型(eBPF还是cBPF),因此对于cBPF和eBPF,skb的穿梭路径是一样的。

cls_bpf相比于其他类型tc分类器的一个优势:能实现高效、非线性分类功能(以及directactions,后面会介绍),这意味着BPF程序可以得到简化,只解析一遍就能处理不同类型的skb(asingleparsingpassisenoughtoprocessskbsofdifferenttypes)。

历史上,tc支持attach多个分类器——前面的没有匹配成功时,接着匹配下一个。因此,如果一个包要经过多个分类器,那它的某些字段就会在每个分类器中都要解析一遍,这显然是非常低效的。

有了cls_bpf,使用单个eBPF程序(用作分类器)就可以轻松地避免这个问题,或者是使用eBPF尾调用结构,后者支持packetparser的某些部分进行原子替换。此时,eBPF程序就能根据分类或动作结果(classificationoractionoutcome),来返回不同的classid或opcodes了,下面进一步介绍。

3.1工作模式:传统模式和direct-action模式

cls_bpf在处理action方面有两种工作模式:

传统模式:分类之后执行tcf_exts_exec()

direct-action模式随着eBPF功能越来越强大,它能做的事情不止是分类,例如,分类器自己就能够(无需action参与)修改包的内容(manglepacketcontents)、更新校验和(updatechecksums)等。因此,社区决定引入一个directaction(da)mode[3]。使用cls_bpf时,这是推荐的模式。

在da模式中,cls_bpf对skb执行action,返回的是tcopcode,最终形成一个紧凑、轻量级的镜像(compact,lightweightimage)。而在此之前,需要使用tcaction引擎,必须穿越多层indirection和listhandling。对于eBPF来说,classid可以存储在skb-tc_classid,然后返回actionopcode。这个opcode对于cBPFdropaction这样的简单场景也是适用的。

这里对da的解释过于简单,很难理解。可参考下面这篇文章,其对da模式的来龙去脉、工作原理和内核实现有更深入介绍:[(译)深入理解tcebpf的direct-action(da)模式(2020)]({%link_posts/2021-02-21-%}"(译)深入理解tcebpf的direct-action(da)模式(2020)")译注。

此外,cls_bpf也支持多个分类器,每个分类器可以工作在不同模式(da和non-da)——只要你有这个需要。但建议fastpath越紧凑越好,对应高性能应用,推荐使用单个cls_bpf分类器并且工作在da模式,这足以满足大部分需求了。

3.2特性

eBPFcls_bpf带来了很多新特性,例如可以读写包的很多字段、一些新的辅助函数。这些特性或功能可以组合使用,产生强大的效果。

skb可读/写字段

Forthecontext(skbhereisoftypestructsk_buff),cls_bpf允许读写下列字段:

skb-mark

skb-priority

skb-tc_index

skb-cb[5]

skb-tc_classidmembers

允许读下列字段:

skb-len

skb-pkttype

skb-queuemapping

skb-protocol

skb-vlantci

skb-vlanproto

skb-vlanpresent

skb-ifindex(translatestonetdev’sifindex)

skb-hash

辅助函数

cls_bpf程序类型中有很多的helper函数可供使用。包括

对map进行操作(get/update/delete)的辅助函数

尾调用辅助函数

对skb进行mangle的辅助函数(storingandloadingbytesintotheskbforparsingandpacketmangling)

重新计算L3/L4checksum的辅助函数

封装/解封装(VLAN、VxLAn等隧道)相关辅助函数

重定向(redirection)

cls_bpf还能对skb进行重定向,包括,

通过dev_queue_xmit()在egress路径中重定向,或者

在dev_forward_skb()中重定向回ingresspath。

重定向有两种可能的方式:

方式一:在eBPF程序运行时(runtime)复制一份数据包(cloneskb)

方式二:无需复制数据包,性能更好需要cls_bpf运行在da模式,并且返回值为TC_ACT_REDIRECT。sch_clsact等qdisc在ingress/egresspath上支持这种这种action。eBPF程序在runtime将必要的重定向信息放到一个per-CPUscratchbuffer,然后返回相关的opcode,接下来内核会通过skb_do_redirect()来完成重定向。这种是一种性能优化方式,能显着提升转发性能。

调试(Debug)

可以使用bpf_trace_printk()辅助函数,它能将消息打印到tracepipe,格式与printk()类似,然后可以通过tcexecbpfdbg等命令读取。

虽然它作为helper函数有一些限制,能传递五个参数,其中前两个是格式字符串,但这个功能还是给编写和调试eBPF程序带来了很大便利。

还有其他一些helper函数,例如,

读取skb的cgroupclassid(net_clscgroup),

读取dst的routingrealm(dst-tclassid)

获取一个随机数(例如用于采样)

获取当前包正在被哪个CPU处理

获取纳秒为单位的当前时间(ktime_t)

可以attach到的tchooks

cls_bpf能attach到许多与tc相关的hook点。这些hook点可分为三类:

ingresshook

egresshook,这是最近才引入的

classificationhookinsideclassfulqdiscsonegress.

前两种可以通过sch_clsactqdisc(或sch_ingressfortheingress-onlypart)配置,而且是在RCU上下文中无锁运行的[12]。

可进一步参考:

[(译)深入理解tcebpf的direct-action(da)模式(2020)]({%link_posts/2021-02-21-%}"(译)深入理解tcebpf的direct-action(da)模式(2020)")

[(译)为容器时代设计的高级eBPF内核特性(FOSDEM,2021)]({%link_posts/2021-02-13-%}"(译)为容器时代设计的高级eBPF内核特性(FOSDEM,2021)")

译注。

egresshook在dev_queue_xmit()中执行(beforefetchingthetransmitqueuefromthedevice)。

3.3前端(Front)

tccls_bpf的iproute2前端[10][11][9]在将cls_bpf数据通过netlink发送到内核之前,在背后做了很多工作。iproute2包含了一个通用ELF加载器后端,适用于下面几个部分,实现了通用代码的共享:

f_bpf(classifier)

m_bpf(action)

e_bpf(exec)

编译和加载所涉及到的iproute2/tc内部工作:

当用clang编译eBPF代码时,它会生成一个ELF格式的目标文件,接下来通过tc加载到内核。这个目标文件就是一个容器(container),其中包含了tc所需的所有数据:它会从中提取数据、重定位(relocate)并加载到cls_bpfhook点。

在启动时,tc会检查(如果有必要还会mount)bpf文件系统,用于objectpinning。默认目录是/sys/fs/bpf。然后会加载和生成一个pinning配置用的哈希表,给map共享用。

之后,tc会扫描目标文件中的ELFsections。一些预留的section名,

maps:foreBPFmapspecifications(,keyandvaluesize,maximumelements,pinning,etc)

license:forthelicencestring,specifiedsimilarlyasinLinuxkernelmodules.

classifier:默认情况下,cls_bpf分类器所在的section

act:默认情况下,act_bpf所在的section

tc首先读取辅助功能区(ancillarysections),这包括ELF的符号表.symtab和字符串表.strtab。由于eBPF中的所有东西都是通过文件描述符来从用户空间访问的,因此tc前端首先需要基于ELF的relocationentries生成maps,它将文件描述符作为立即值(immediatevalue)插入相应的指令。取决于map是否是pinned,tc或者从bpffs的指定位置加载一个map文件描述符,或者生成一个新的,并且如果有需要,将它pin到bpffs。

处理Objectpinning

sharingmaps有三种不同的scope:

/sys/fs/bpf/tc/globals:全局命名空间

/sys/fs/bpf/tc/obj-sha:对象命名空间(objectnamespace)

自定义位置

eBPFmaps可以在不同的cls_bpf实例之间共享。不止通用类型map(例如array、hashtable)可以共享,专业类型的map,例如tracingeBPF程序(kprobes)使用的eBPFmaps也与cls_bpf/act_bpf使用的eBPFmaps实现共享。

Objectpinning时,tc会在ELF的符号表和字符串表中寻找mapname。map创建完成后,tc会找到程序代码所在的section,然后带着map的文件描述符信息执行重定位,并将程序代码加载到内核。

处理尾调用

当用到了尾调用且尾调用subsection也在ELF文件中时,tc也会将它们加载到内核。从tc加载器的角度看,尾调用可以任意嵌套,但内核运行时对嵌套是有限制的。另外,尾调用用到的程序数组(programarray)也能被pin,这样能在用户空间根据程序的运行时行为来修改这个数组(决定尾调用到哪个程序)。

tcexecbpfgraft

tc有个graft(嫁接)选项,

tcexecbpf[graftMAP_FILE][keyKEY]

它能在运行时替换section(replacingsuchsectionsduringruntime)。Grafting实际上所做的事情和加载一个cls_bpf分类器差不多,区别在于产生的文件描述符并不是通过netlink——而是通过相应的map——push到内核。

tccls_bpf前端还允许通过execvpe()将新生成的map的文件描述符传递给新创建的shell,这样程序就能像stdin、stdout、stderr一样全局地使用它;或者,文件描述符集合还能通过Unixdomainsocket传递给其他进程。在这两种情况下,cloned文件描述符的生命周期仍然与进程的生命周期紧密相连。通过bpffs获取文件描述符是最灵活也是最推荐的方式,[9]也适用于第三方用户空间程序管理eBPFmap的内容。

tcexecbpfdbg

tc前端提供了打印tracepipe的命令行工具:tcexecbpfdbg。这个命令会用到tracefs,它会自动定位tracefs的挂载点。

3.4工作流(Workflow)

一个典型的工作流是:将cls_bpf分类器以da模式加载到内核,整个过程简单直接。

来看下面的例子:

用clang编译源文件,生成的目标文件;中包含两个sectionp1和p2

启用内核的JIT编译功能

给网络设备em1添加一个clsactqdisc

将目标文件分别加载到em1的ingress和egress路径上

$$_jit_enable=1$tcqdiscadddevem1clsact$tcqdiscshowdevem1[]qdiscclsactffff:parentffff:fff1$$
$tcfiltershowdevem1ingressfilterp:[p1]direct-action$tcfiltershowdevem1egressfilterp:[p2]direct-action

最后将它们删除:

$tcfilterdeldevem1ingresspref49152$tcfilterdeldevem1egresspref49152
3.5编程

iproute2源码中examples/bpf/目录下包含很多入门示例,是用restrictedC编写的eBPF代码。实现这样的分类器还是比较简单的。

与传统用户空间C程序相比,eBPF程序在某些地方是受限的。每个这样的分类器都必须放到ELFsections。因此,一个目标文件会包含一个或多个eBPF分类器。

代码共享:内联函数或尾调用

分类器之间共享代码有两种方式:

__always_inline声明的内联函数clang需要将整个扁平程序(thewhole,flatprogram)编程成eBPF指令流,分别放到各自的ELFsection。eBPF不支持共享库(sharedlibraries)或可重入eBPF函数(eBPFfunctionsasrelocationentries)。像tc这样的eBPF加载器,是无法将多个库拼装成单个扁平eBPF指令流数组的(asingleflatarrayofeBPFinstructions)——除非它实现编译器的大部分功能。因此,加载器和clang之间有一份“契约”(contract),其中明确规定了生成的ELF文件中,特定section中必须包含什么样的eBPF指令。唯一允许的重定位项(relocationentries)是与map相关的,这种情况下需要先确定文件描述符。

尾调用前面已经介绍过了。

有限栈空间和全局变量

eBPF程序的栈空间非常有限,只有512KB,因此用C实现eBPF程序时需要特别注意这一点。常规C程序中常见的全局变量在这里不支持的。

eBPFmaps(在tc中对应的是structbpf_elf_map)定义在各自的ELFsections中,但可以在程序sections中访问到。因此,如果真的需要全局“变量”,可以这样实现:创建一个per-CPU或non-per-CPUarraymap,但其中只存储有一个值,这样这个变量就能被多个section中的程序访问,例如entrypointsections、tailcalledsections等。

动态循环

另一个限制是:eBPF程序不支持动态循环(dynamiclooping),只支持编译时已知的常量循环(compile-timeknownconstantbounds),后者能被clang展开。

编译时不能确定是否为常量次数的循环会被校验器拒绝,因为这样的程序无法静态验证(staticallyverify)它们是否确定会终止。

4总结及未来展望

cls_bpf是tc家族中的一个灵活高效的分类器(及action),它提供了强大的数据平面可编程能力,适用于大量不同场景,例如解析、查找或更新(例如mapstate),以及对网络包进行修改(mangling)等。当使用底层平台的eBPFJIT后端进行编译之后,这些eBPF程序能以平台原生性能执行。eBPF是为既要求高性能又要求高灵活性的场景设计的。

虽然一些内部细节看上去有点复杂,让人望而生畏,但了解了eBPF的限制条件之后,编写cls_bpfeBPF程序其实与编写普通用户空间程序并不会复杂多少。另外,tc命令行在设计时也考虑到了易用性,例如用tc处理cls_bpf前端只需要几条命令。

cls_bpf代码及其tc前端、eBPF内部实现及其clang编译器后端全部都是开源的,由社区开发和维护。

目前还有很多的增强特性和想法正在讨论和评估之中,例如将cls_bpfoffload到可编程网卡上。CRIU[2](checkpointrestoreinuserspace)目前还只支持cBPF,如果实现了对eBPF的支持,对容器迁移将非常有用。

参考资料

Begel,A.;Mccanne,S.;andGraham,+:Exploitinggl,123–134.

Borkmann,D.,andSowa,:bpf:,commit60a3b2253c41[3].

Borkmann,D.,andStarovoitov,_bpf:,commit045efa82ff56.

Borkmann,D.;Starovoitov,A.;andSowa,:addsupportforpersistentmaps/,commitb2197755b263[4].

Borkmann,:bpf_asm:,commit3f356385e8a4[5].

Borkmann,:sched:cls_bpf:,commit7d1d65cb84e1[6].

Borkmann,:,commita8cb5f556b56[7].

Borkmann,:,commite2e9b6541dd4[8].

Borkmann,,mgbpf:,commit32e93fb7f66d.

Borkmann,:addebpfsupporttof_bpf.

Borkmann,,bpf:,commit6256f8c9e45f.

Borkmann,,sched:,commit1f211a1b929c[9].

deBruijn,:,commit47dceb8ecdc1.

deBruijn,:,commitf2e520956a1a.

Drewry,:,commite2cfabdfd075.

Gallek,:setsockoptsoattachreuseport[ce],commit538950a1b752.

Herbert,:,commitab7ac4eb9832[10].

Mccanne,S.,andJacobson,:–269.

Pirko,:,commit01d7f30a9f96.

Pirko,:,commitd23b8ad8ab23[11].

Starovoitov,A.,andBorkmann,:filter:rework/optimizeinternalbpfinterpreter’,commitbd4cf0ed331a[12].

Starovoitov,:expandbpfsyscallwithprogramload/,commit09756af46893[13].

Starovoitov,:,commit99c55f7d47c0[14].

Starovoitov,:verifier(addverifiercore).Linuxkernel,commit17a5267067f3[15].

Starovoitov,:filter:x86:,commit622582786c9e.

Starovoitov,:,commit04fd61ab36ec[16].

Starovoitov,,perf:,commit2541517c32be[17].

Starovoitov,:,commitd5a3b1f69186[18].

参考资料

[1]

Ongettingtcclassifierfullyprogrammablewithcls_bpf:

[2]

CRIU:

[3]

60a3b2253c41:

[4]

b2197755b263:

[5]

3f356385e8a4:

[6]

7d1d65cb84e1:

[7]

a8cb5f556b56:

[8]

e2e9b6541dd4:

[9]

1f211a1b929c:

[10]

ab7ac4eb9832:

[11]

d23b8ad8ab23:

[12]

bd4cf0ed331a:

[13]

09756af46893:

[14]

99c55f7d47c0:

[15]

17a5267067f3:

[16]

04fd61ab36ec:

[17]

2541517c32be:

[18]

d5a3b1f69186:

很赞哦!(66)