2. eBPF技术基础
eBPF 是 Cilium 数据路径的底层执行引擎。理解 eBPF 不能只停留在“内核里跑沙箱程序”这一层,而要把它拆成六个相互咬合的部件:程序类型决定“能挂在哪里、能访问什么上下文”,指令集决定“程序长什么样”,Map 决定“状态如何在内核与用户态之间持久化”,Helper 函数决定“程序能调用哪些内核能力”,验证器决定“程序是否被允许加载”,JIT 决定“程序以什么形态执行”。这六者共同构成一条从源码到内核执行的完整链路。
程序类型:决定挂载点与上下文
eBPF 程序类型(program type)是加载时向内核声明的第一项元数据,它直接约束三件事:可用的挂载/附着点、可访问的上下文结构、可调用的 Helper 子集。Cilium 最常用的是 BPF_PROG_TYPE_SCHED_CLS(tc 分类器)与 BPF_PROG_TYPE_XDP。前者挂在网络设备的 tc ingress/egress 钩子上,上下文是 struct __sk_buff,可读写 skb 元数据并返回 TC_ACT_OK、TC_ACT_SHOT、TC_ACT_REDIRECT 等动作码;后者挂在网卡驱动收包早期,上下文是 struct xdp_md,返回 XDP_PASS、XDP_DROP、XDP_TX、XDP_REDIRECT。此外还有 BPF_PROG_TYPE_SOCK_OPS、BPF_PROG_TYPE_CGROUP_SKB、BPF_PROG_TYPE_TRACEPOINT、BPF_PROG_TYPE_KPROBE 等,分别服务于连接跟踪、cgroup 策略、可观测性等场景。选择程序类型本质上是在选择“在数据路径的哪个位置介入、能看到什么、能改什么”。
指令集:11 个 64 位寄存器上的受限计算
eBPF 使用一套固定的 RISC 风格指令集,共 11 个 64 位寄存器:r0 存放返回值,r1–r5 传参,r6–r9 为被调用者保存寄存器,r10 是只读栈帧指针。指令宽度 64 位,主要类别包括:ALU/ALU64(算术逻辑)、JMP/JMP32(跳转与条件分支)、LD/LDX/ST/STX(加载与存储)、以及用于调用 Helper 的 CALL。程序栈上限 512 字节,且不允许任意指针运算——所有内存访问都必须能被验证器证明在界内。下面是一段概念性的指令序列,用于说明“从上下文取字段、比较、返回动作码”的基本形态:
; 伪指令序列:读取 skb->protocol,若为 IPv4 则放行,否则丢弃
r1 = ctx ; r1 指向 __sk_buff
r2 = *(u32 *)(r1 + 16) ; 读取 protocol 字段(偏移示意)
r3 = 0x0800 ; ETH_P_IP
if r2 != r3 goto +1
r0 = 2 ; TC_ACT_SHOT
exit
r0 = 0 ; TC_ACT_OK
exit
真实程序通常由 clang/LLVM 从受限 C 编译而来,上述形态用于建立“寄存器—内存—分支—返回”的直觉。需要强调的是,eBPF 没有无限循环:有界循环在较新内核中已被允许,但验证器必须能证明其迭代次数有上界。
Map:内核与用户态之间的共享状态
Map 是 eBPF 的持久化键值存储,也是 Cilium 实现连接跟踪、NAT、负载均衡后端表的核心结构。常见类型包括 BPF_MAP_TYPE_HASH、ARRAY、LRU_HASH、PERCPU_HASH、LPM_TRIE(最长前缀匹配,用于路由与 CIDR 策略)以及 RINGBUF(事件上报)。Map 由用户态通过 bpf() 系统调用创建,返回文件描述符;eBPF 程序通过 BPF_LD_MAP_FD 伪指令在加载时绑定该 fd。运行时,程序用 bpf_map_lookup_elem() 查询、bpf_map_update_elem() 写入、bpf_map_delete_elem() 删除。以连接跟踪为例,键通常是五元组,值是状态与计时信息;Cilium 的 cilium_ct4_global 就是一张全局哈希表。Map 的存在使 eBPF 程序不再是纯函数,而能跨包、跨 CPU 维护一致状态。
Helper 函数:受控的内核能力入口
eBPF 程序不能直接调用任意内核函数,只能调用 Helper 函数。Helper 由内核以稳定 ABI 提供,每个程序类型允许的 Helper 集合不同。网络场景中高频使用的包括:bpf_skb_load_bytes()、bpf_skb_store_bytes()、bpf_l3_csum_replace()、bpf_l4_csum_replace()、bpf_redirect()、bpf_clone_redirect()、bpf_get_current_pid_tgid()、bpf_ktime_get_ns()、bpf_trace_printk() 等。Helper 的调用约定与普通函数一致:参数放入 r1–r5,返回值在 r0。验证器会检查每个 Helper 的调用是否合法,例如 bpf_skb_load_bytes() 的目标缓冲区必须指向栈且长度可证。Cilium 的很多能力,如 L4 端口重写、校验和修正、重定向到 veth,都是通过组合 Helper 完成的。
验证器:加载前的安全证明
验证器是 eBPF 安全模型的核心。它在程序加载时对指令流做静态分析,确保:无越界内存访问、无未初始化寄存器读取、无非法指针运算、无不可达指令、循环有界、Helper 调用合法、程序规模与复杂度在限制内。验证器采用深度优先遍历所有可能路径,并为每条路径维护寄存器类型与范围信息。若程序访问 skb 数据区,必须先通过 bpf_skb_load_bytes() 或边界检查证明偏移合法。验证失败时,内核返回 EACCES 或 EINVAL,并可通过 bpftool prog load 的日志查看具体拒绝原因。对 Cilium 而言,验证器既是安全屏障,也是性能约束:过于复杂的程序可能因“状态空间爆炸”而无法加载。
JIT 编译:从字节码到原生指令
eBPF 字节码最终由内核 JIT 编译器翻译为宿主 CPU 的原生指令。JIT 将每条 eBPF 指令映射为一段机器码,并完成寄存器分配、常量折叠与部分内联。启用 JIT 后,程序执行不再经过解释器逐条分派,性能显著提升,通常接近手写内核模块的水平。可通过 /proc/sys/net/core/bpf_jit_enable 查看或设置 JIT 状态(1 为启用,2 为启用并输出调试信息)。JIT 还负责生成尾调用(tail call)跳转、Map 访问的辅助代码以及 Helper 调用的调用桩。对 Cilium 来说,JIT 是数据路径低延迟的关键:XDP 与 tc 程序在 JIT 后可在收包软中断上下文中以极短路径完成转发决策。
把六者串起来看
在 Cilium 中,一个典型的 tc 程序加载流程是:用户态用 clang 编译受限 C 得到 eBPF 目标文件;cilium-agent 通过 bpf() 创建所需 Map 并获取 fd;加载器把 Map fd 重定位进指令流,声明程序类型为 SCHED_CLS;内核验证器逐路径检查后,JIT 生成原生指令并附着到目标网卡的 tc 钩子。运行时,程序从 __sk_buff 读取五元组,查连接跟踪 Map,命中则按策略返回 TC_ACT_OK 或 TC_ACT_SHOT,未命中则调用 Helper 完成重定向或上送。理解这条链路,才能在排障时判断问题出在验证器拒绝、Map 未命中、Helper 返回错误,还是 JIT 未启用导致的性能回退。
bpftool prog load 配合 --log-level 2 查看具体被拒绝的指令与原因,再回到源码检查边界判断与寄存器初始化。