3. eBPF指令集与虚拟机执行模型

eBPF 程序并不是直接跑在 CPU 上的机器码,而是先被编译成一种面向 64 位寄存器的中间指令集,再由内核中的虚拟机负责执行。理解这套指令集与执行模型,是判断 eBPF 程序性能上限、写出高效代码、以及看懂 JIT 与解释器差异的前提。本节从寄存器与栈的约定讲起,再落到指令编码、尾调用与辅助函数调用约定,最后说明 JIT 编译与解释执行对性能的实际影响。

寄存器与栈:虚拟机的运行基础

eBPF 虚拟机提供 11 个 64 位寄存器,编号 r0 到 r10。它们的分工是固定的,写代码时必须遵守:

  • r0:函数返回值,也是辅助函数调用的返回寄存器。
  • r1–r5:函数调用参数寄存器,调用辅助函数或尾调用时用于传参。
  • r6–r9:被调用者保存寄存器,跨辅助函数调用后仍保留原值,适合存放长期变量。
  • r10:只读的栈帧指针,指向当前栈帧顶部,不能写。

栈是一块 512 字节的连续内存,从 r10 向下增长。也就是说,访问局部变量要用负偏移,例如 r10 - 8 表示栈顶下方 8 字节处。栈上读写必须按宽度对齐,越界访问会被校验器直接拒绝。由于只有 512 字节,栈上不适合放大结构体或大数组,超过这个尺寸的数据应放到 map 或 per-CPU 存储中。

指令编码:8 字节一条的定长格式

每条 eBPF 指令固定 8 字节,结构为:1 字节操作码(opcode)、1 字节目标寄存器与源寄存器编码、2 字节有符号偏移、4 字节立即数。操作码的高 3 位表示指令类别(如 ALU、跳转、加载/存储、辅助函数调用),低位区分具体操作与操作数宽度。这种定长设计让内核校验器和 JIT 都能快速解析。

指令类别大致分为:ALU 与 ALU64 算术逻辑运算、跳转(含条件跳转与无条件跳转)、加载/存储、以及用于辅助函数调用和尾调用的特殊指令。跳转偏移以指令条数为单位,而不是字节数,这一点在阅读反汇编时容易混淆。

辅助函数与尾调用:两种不同的“跳出去”

辅助函数(helper function)是内核暴露给 eBPF 的稳定接口,例如 bpf_map_lookup_elem、bpf_ktime_get_ns、bpf_redirect。调用约定是:参数放入 r1–r5,执行 call 指令,返回值在 r0。调用后 r1–r5 被视为已破坏,r6–r9 保持不变。因此需要跨调用保留的值必须放进 r6–r9 或栈上。

尾调用(tail call)通过 bpf_tail_call 实现,它把当前程序的执行“接力”给另一个 eBPF 程序,而不是返回。尾调用不会增加栈深度,最多可连续 32 次,超过后返回失败。它常用于把长逻辑拆成多个小程序,绕过单程序指令数上限,同时保持较低开销。注意尾调用后原程序的栈帧不再可用,参数通过 r1–r5 传递。

eBPF 虚拟机执行模型:寄存器、栈与调用路径 11 个 64 位寄存器 r0 返回值 r1–r5 参数 r6–r9 保留 r10 栈指针 512 字节栈(向下增长) r10 - 8 → 局部变量 越界访问 → 校验器拒绝 指令编码:定长 8 字节 opcode 1 字节 dst/src 1 字节 offset 2 字节 imm 4 字节 辅助函数调用 r1–r5 传参 → call 返回 r0,r6–r9 保留 如 bpf_map_lookup_elem 尾调用 bpf_tail_call 接力执行 不返回,最多 32 次 不增加栈深度 两种执行方式对性能的影响 解释执行 逐条解码 + 分派,开销大 适合调试与不支持 JIT 的架构 JIT 编译 翻译为本地机器码,接近原生速度 生产环境默认开启

JIT 与解释执行:性能差距从何而来

解释器的工作方式是取一条指令、解码、分派到对应处理函数、再取下一条。每条指令都要经过一次间接跳转和参数解析,分支预测压力大,循环体尤其吃亏。JIT 则在校验通过后把 eBPF 指令一次性翻译成目标架构的机器码,之后执行的是本地指令,寄存器直接映射到物理寄存器,跳转变成真实跳转,循环可以正常流水线执行。

实际差距取决于程序形态。对于以辅助函数调用为主、逻辑很短的 XDP 程序,两者差距可能只有几倍;对于包含大量算术运算和循环的包解析或统计程序,JIT 往往能带来一个数量级以上的提升。可以用下面的命令观察当前内核是否启用 JIT:

# 查看 JIT 开关状态
cat /proc/sys/net/core/bpf_jit_enable
# 1 表示启用,0 表示关闭

# 查看已加载程序的 JIT 信息(需内核支持)
bpftool prog show
# 输出中 jited 字段为 1 表示该程序已 JIT 编译

需要注意的是,JIT 编译本身有一次性成本,程序加载时会多花一点时间,但这对长期驻留的 eBPF 程序可以忽略。真正影响性能的是程序被调用的频率:每秒百万次调用的 XDP 程序,JIT 带来的收益会被放大到非常可观。

写高效 eBPF 代码的几条实际约束

第一,把频繁使用的值放在 r6–r9,避免每次辅助函数调用后重新从 map 读取。第二,栈只有 512 字节,大结构体应通过 map 或 per-CPU 数组传递。第三,尾调用虽然能拆分逻辑,但每次尾调用都有固定开销,且不能返回,适合“阶段式”处理而不是细碎拆分。第四,循环在旧内核中受限,新内核支持有界循环,但循环次数必须能被校验器证明有上界,否则加载失败。第五,辅助函数调用是相对昂贵的操作,能用一次调用取回的数据不要拆成多次调用。

判断一个 eBPF 程序是否值得优化,先看它的调用频率和单次执行指令数。高频短程序优先减少辅助函数调用;低频长程序优先减少循环和内存访问。

掌握寄存器分工、栈边界、指令编码和两种调用约定之后,再回头看 JIT 与解释执行的差异,就能把“为什么快”落到具体机制上:JIT 消除了逐条解码与分派,寄存器映射减少了内存往返,而尾调用和辅助函数调用则决定了程序能拆多细、能跑多快。这些约束共同构成了 eBPF 程序性能调优的基本盘。