写 BPF 程序时,verifier 报了一个 “invalid argument”
错误,日志里只有一行
R1 type=scalar expected=ptr_to_ctx。你知道 R1
应该存放 ctx 指针,但为什么你的 R1 变成了
scalar?这个问题只有在理解了 BPF 的寄存器角色、调用约定和
struct bpf_insn
的精确编码之后,才能真正定位。
本文建立 eBPF 虚拟机的精确心智模型——不设前提。从 11
个寄存器开始,到 struct bpf_insn 的 64-bit
编码,再到四类指令的语义,最后用 bpftool
反汇编把抽象概念落到具体字节码。读完这篇,verifier
日志里的寄存器编号、类别标签、imm
字段含义将不再是谜。
一、虚拟机全景
eBPF 虚拟机是一个 64-bit RISC 寄存器机。核心规格:
- 11 个 64-bit 通用寄存器:R0–R10,每个都有自己的角色和调用约定约束
- 512 字节栈帧:BPF 程序的运行时栈,从
BPF_REG_FP向下增长 - PC 程序计数器:指向当前执行的
bpf_insn,由 verifier 的do_check()隐式跟踪,程序不能直接读写 - fixed-size 指令:每条指令 64-bit(8 字节),全部对齐
- 无浮点、无向量、无特权模式:eBPF 是简化的通用计算模型,受限于 verifier 的安全边界
下图展示了 eBPF 虚拟机的核心资源布局:
flowchart LR
subgraph REG["寄存器文件 (11 x 64-bit)"]
R0["R0: return value"]
R1["R1: arg0"]
R2["R2: arg1"]
R3["R3: arg2"]
R4["R4: arg3"]
R5["R5: arg4"]
R6["R6: callee-saved"]
R7["R7: callee-saved"]
R8["R8: callee-saved"]
R9["R9: callee-saved"]
R10["R10: frame pointer (RO)"]
end
subgraph STACK["BPF Stack (512 bytes)"]
TOP["stack top (high addr)"]
BOT["R10 - 512 = begin (low addr)"]
end
subgraph EXEC["执行流水线"]
INS["bpf_insn[PC]"] --> DEC["指令解码"]
DEC --> ALU["ALU64/ALU32"]
DEC --> JMP["JMP/JMP32"]
DEC --> MEM["LD/LDX/ST/STX"]
DEC --> CALL["CALL"]
end
REG --> DEC
STACK --> MEM
与 x86-64 或 ARM64 这种物理处理器不同,eBPF 的寄存器模型有以下关键差异:
- R10 为只读:verifier 不允许程序写入
R10。所有栈访问必须通过
R10 + offset的方式,这确保 verifier 能精确追踪每个栈槽的状态。 - 调用约定固定:R1–R5 传参、R0
返回值,不因上下文类型而变化(除某些特殊的
bpfhelper 返回值放入 R0 之外,还有次要返回到用户态的方式)。 - callee-saved
寄存器由调用者负责保存:BPF 没有
push/pop指令,callee 必须显式用*(u64 *)(r10 - offset) = r6和r6 = *(u64 *)(r10 - offset)来保存/恢复。
二、struct bpf_insn:64-bit
指令编码
2.1 指令格式定义
eBPF 指令的 C 定义来自内核 UAPI 头文件:
/* Linux 6.6: include/uapi/linux/bpf.h:61 */
struct bpf_insn {
__u8 code; /* opcode — 8 bits */
__u8 dst_reg:4; /* 目标寄存器 — 4 bits */
__u8 src_reg:4; /* 源寄存器 — 4 bits */
__s16 off; /* 有符号偏移 — 16 bits */
__s32 imm; /* 立即数 — 32 bits */
};64-bit 的精确布局(小端序):
Byte: 0 1 2 3 4 5 6 7
[ code ][dst|src][ off ][ imm ]
bits: 8 4 4 16 32
这是理解一切的前提。无论 ALU 操作、内存访问还是条件跳转,语义都编码在这几个字段里。关键约束:
code的低 3 bits 编码指令类别(class),紧邻的高 bits 编码”source type”(是寄存器还是立即数),再高的 bits 编码具体操作。dst_reg和src_reg各占 4 bits,恰好容纳 0–10 共 11 个寄存器。off是 16-bit 有符号整数,范围[-32768, 32767],用于内存操作的栈偏移和跳转的分支偏移量。imm是 32-bit 有符号整数,用于立即数操作数、helper 函数 ID、跳转目标偏移的高位部分等。
2.2 指令类别编码
内核在 include/uapi/linux/bpf.h
中定义了指令类别的位掩码:
/* Linux 6.6: include/uapi/linux/bpf.h */
#define BPF_CLASS(code) ((code) & 0x07) /* 低 3 bits */
#define BPF_LD 0x00 /* 加载(非标准) */
#define BPF_LDX 0x01 /* 加载到寄存器 */
#define BPF_ST 0x02 /* 从立即数存储 */
#define BPF_STX 0x03 /* 从寄存器存储 */
#define BPF_ALU 0x04 /* 32-bit ALU */
#define BPF_JMP 0x05 /* 64-bit 跳转 */
#define BPF_JMP32 0x06 /* 32-bit 跳转 */
#define BPF_ALU64 0x07 /* 64-bit ALU */每个类别下的 source type 由 code 的 bit 3
控制:
/* Linux 6.6: include/uapi/linux/bpf.h */
#define BPF_SRC(code) ((code) & 0x08) /* bit 3 */
/* ALU/JMP 操作中表示操作数来源 */
#define BPF_K 0x00 /* 立即数(32-bit imm) */
#define BPF_X 0x08 /* 寄存器(src_reg) */
/* ALU 操作的子类别 */
#define BPF_ALU_OP(code) ((code) & 0xf0) /* bits 4-7 */
#define BPF_JMP_OP(code) ((code) & 0xf0)将指令类别、来源类型、操作码组合后的完整示例:
| 助记符 | code(hex) |
含义 |
|---|---|---|
BPF_ALU64\|BPF_X\|BPF_ADD |
0x0f | 64-bit
加法,源操作数在寄存器中:dst += src |
BPF_ALU\|BPF_K\|BPF_MOV |
0xb4 | 32-bit MOV 立即数:dst = (u32)imm |
BPF_JMP\|BPF_K\|BPF_JEQ |
0x15 | 跳转如果 dst == imm |
BPF_STX\|BPF_W\|BPF_MEM |
0x63 | 将 src_reg 存为 32-bit 到
[dst_reg + off] |
三、ALU 指令:计算引擎
3.1 ALU64(64-bit 操作)
BPF_ALU64 系列操作 64-bit
完整寄存器值。指令编码:
code = BPF_ALU64 | BPF_X(或BPF_K) | 具体操作码
dst = dst_reg
src = (BPF_X 时取 src_reg 的值) 或 (BPF_K 时取 imm)
完整操作码表:
/* Linux 6.6: include/uapi/linux/bpf.h */
#define BPF_ADD 0x00 /* dst += src — 加法 */
#define BPF_SUB 0x10 /* dst -= src — 减法 */
#define BPF_MUL 0x20 /* dst *= src — 乘法 */
#define BPF_DIV 0x30 /* dst /= src — 无符号除(src=0 时不更新 dst) */
#define BPF_OR 0x40 /* dst \|= src — 按位或 */
#define BPF_AND 0x50 /* dst &= src — 按位与 */
#define BPF_LSH 0x60 /* dst <<= src — 逻辑左移(低 6-bit 控制移位量) */
#define BPF_RSH 0x70 /* dst >>= src — 逻辑右移 */
#define BPF_NEG 0x80 /* dst = -dst — 取负(无源操作数) */
#define BPF_MOD 0x90 /* dst %= src — 无符号取模(src=0 时不更新 dst) */
#define BPF_XOR 0xa0 /* dst ^= src — 按位异或 */
#define BPF_MOV 0xb0 /* dst = src — 赋值 */
#define BPF_ARSH 0xc0 /* dst >>= s32(src) — 算术右移 */
#define BPF_END 0xd0 /* dst = endian(dst, imm) — 端序转换(特殊编码) */关键语义:
- 除法/取模除以零:BPF
不触发异常,也不修改目标寄存器。verifier 将
src=0的除法和取模视为未定义行为,会在 div/mod 之前插入if src != 0的隐式检查——如果 verifier 无法静态证明src != 0,程序将被拒绝(详见第 03 篇的状态跟踪)。 - 移位量:
BPF_LSH和BPF_RSH只使用src的低 6 bits(即移位范围 0–63)。BPF_ARSH先将src截断为 32-bit 有符号数再取低 6 bits。 BPF_NEG:无源操作数,src_reg字段未使用。BPF_MOV:对于BPF_K变体,code = BPF_ALU64|BPF_K|BPF_MOV = 0xb7,dst = (s64)imm。对于BPF_X变体,code = BPF_ALU64|BPF_X|BPF_MOV = 0xbf,dst = src_reg。
3.2 ALU32(32-bit 操作)
BPF_ALU 系列的操作对象是 32-bit
子寄存器。执行 32-bit ALU 后,目标寄存器的高 32-bit
被清零。这是 BPF ISA 最关键的语义之一:
dst = (u64)((u32)dst OP (u32)src)这意味着 BPF_ALU64 和 BPF_ALU
的区别不仅是宽度,还影响了高 32-bit。对 JIT
编译器而言,x86-64 的 32-bit 操作自动零扩展到
64-bit(mov eax, eax 就清零了 rax
高 32-bit),但 ARM64 需要显式的 uxtw
指令。
3.3 端序转换指令
BPF_END 是 ALU
操作中编码方式最特殊的一个:
/* code = BPF_ALU (或 BPF_ALU64) | BPF_K | BPF_END */
/* imm 字段编码:高位指定源字节序和目标字节序,低位指定宽度 */
#define BPF_TO_LE 0x00 /* 转换为小端 */
#define BPF_TO_BE 0x08 /* 转换为大端 */
/* 宽度指定在最下层 */
#define BPF_IMM_SIZE(imm) ((imm) & 0x60)
#define BPF_W 0x00 /* 32-bit 转换 */
#define BPF_H 0x08 /* 16-bit 转换 */
#define BPF_B 0x10 /* 8-bit 转换 */
#define BPF_DW 0x18 /* 64-bit 转换(LDX/STX 等) */示例:BPF_END | BPF_TO_BE | BPF_H
将目标寄存器的低 16-bit
从主机字节序转换为大端。这条指令用于跨平台数据解析(网络字节序是大端,x86
是小端)。
3.4
原子操作:BPF_ATOMIC
BPF_ATOMIC 是 BPF_STX
的一个特殊子类别(code = BPF_STX | BPF_W | BPF_ATOMIC),使用
imm 字段指定原子操作类型:
/* Linux 6.6: include/uapi/linux/bpf.h */
#define BPF_ATOMIC_OP(imm) ((imm) & 0x70) /* 操作类型(bits 4-6) */
#define BPF_ATOMIC_FETCH(imm) ((imm) & 0x80) /* 是否返回旧值(bit 7) */
#define BPF_ADD 0x00 /* 原子加 */
#define BPF_AND 0x10 /* 原子按位与 */
#define BPF_OR 0x20 /* 原子按位或 */
#define BPF_XOR 0x30 /* 原子按位异或 */
#define BPF_XCHG 0x40 /* 原子交换(无条件交换,不受 FETCH 影响) */
#define BPF_CMPXCHG 0x50 /* 原子比较交换(R0 包含旧值,R1 包含 compare 值) */语义:对 [dst_reg + off] 指向的内存执行原子
src_reg OP value 操作。如果
BPF_FETCH 置位,操作前的内存值写回
src_reg。BPF_CMPXCHG
使用隐式寄存器约定:R0
包含旧值,R1 包含 compare 值,只有
[dst_reg + off] == R0 时才会将内存更新为
src_reg 的内容。
verifier 对原子操作有严格的对齐要求——操作宽度必须与内存自然对齐匹配(16-bit 操作需要 2-byte 对齐,32-bit 需要 4-byte,64-bit 需要 8-byte)。未对齐访问将被 verifier 拒绝。
四、内存指令:加载与存储
4.1 加载指令
eBPF 有两种加载指令:
BPF_LDX(标准加载):将内存中的值加载到目标寄存器。
dst = *(size *)(src + off)
编码:code = BPF_LDX | BPF_{W,H,B,DW} | BPF_MEM
dst_reg = 目标寄存器
src_reg = 基址寄存器
off = 偏移
size:BPF_W(32-bit)、BPF_H(16-bit)、BPF_B(8-bit)、BPF_DW(64-bit)BPF_LD(特殊加载):主要用于加载
64-bit 立即数和访问上下文内置字段。
/* BPF_LD_IMM64: 加载 64-bit 立即数(占用两条指令) */
/* 指令 0: code=BPF_LD|BPF_DW|BPF_IMM, dst_reg, imm=低32-bit */
/* 指令 1: code=0x00 (保留), imm=高32-bit */BPF_LD_IMM64 是 eBPF ISA
中唯一的双宽指令,占用 16
字节。这两条指令必须连续出现,verifier
会在第二条指令上做完整性检查。如果第二条指令的
code 不是 0x00,程序被拒绝。
4.2 存储指令
BPF_STX(寄存器到内存):*(size *)(dst + off) = src
编码:code = BPF_STX | BPF_{W,H,B,DW} | BPF_MEM
dst_reg = 基址寄存器
src_reg = 值寄存器
off = 偏移BPF_ST(立即数到内存):*(size *)(dst + off) = imm
编码:code = BPF_ST | BPF_{W,H,B,DW} | BPF_MEM
dst_reg = 基址寄存器
imm = 立即数值
off = 偏移注意 BPF_ST 没有 src_reg
字段——立即数从 imm 读取。
4.3 存储宽度约束
| 大小 | 常量 | 位数 | 对齐 |
|---|---|---|---|
| 8-bit | BPF_B |
8 | 任意 |
| 16-bit | BPF_H |
16 | 2-byte |
| 32-bit | BPF_W |
32 | 4-byte |
| 64-bit | BPF_DW |
64 | 8-byte |
BPF_ST 不支持 BPF_DW(64-bit
立即数存储改为 BPF_LD_IMM64 + BPF_STX
组合)。
4.4 栈访问模式
BPF 程序的所有局部变量都在 512-byte 栈上。访问模式:
*(u64 *)(r10 - 8) = r0 ; BPF_STX_DW BPF_REG_10, BPF_REG_0, -8
r1 = *(u32 *)(r10 - 16) ; BPF_LDX_W BPF_REG_1, BPF_REG_10, -16
栈从高地址向低地址增长。R10 + 0
是栈顶(进入函数时的 SP),向下 512
字节为可用区域。R10 - 512
是栈底。任何超出此范围的访问被 verifier 的
check_stack_access_within_bounds() 拒绝。
4.5 数据包/上下文访问
XDP 和 socket filter 程序的 ctx
是指向包的受限指针。访问必须通过边界检查:
r1 = ctx->data ; BPF_LDX_DW r1, [r1(=ctx) + data_offset]
r2 = ctx->data_end ; BPF_LDX_DW r2, [r1 + data_end_offset]
; 边界检查(必须有)
if r1 + 4 > r2 goto +1 ; BPF_JGE r1, r2 -> 错误路径
r3 = *(u32 *)(r1 + 0) ; 现在可以安全地读 4 字节
verifier 在 check_mem_access()
中强制执行此模式——任何 PTR_TO_PACKET 的
dereference 必须伴随有效的边界检查(详见第 04 篇的模式
2)。
五、跳转指令:控制流
5.1 无条件跳转
BPF_JA(无条件跳转):
code = BPF_JMP | BPF_JA (= 0x05)
PC += off + 1
off 是相对偏移量(从下一条指令算起,偏移 0
代表继续执行下一条即空跳转)。BPF
跳转只能在静态已知的指令边界上移动。BPF_JA
的 forward/backward 均被允许,但 verifier
会检查跳转目标是否在有效指令范围内(check_jmp_op())。
5.2 条件跳转(BPF_JMP 和 BPF_JMP32)
条件跳转的形式为:
code = BPF_JMP(或BPF_JMP32) | BPF_X(或BPF_K) | 比较操作码
if dst OP src: PC += off + 1 ; 条件为真时跳过 off 条指令
else: PC += 1 ; 继续顺序执行
完整比较操作码:
/* Linux 6.6: include/uapi/linux/bpf.h */
#define BPF_JEQ 0x10 /* == (等于) */
#define BPF_JGT 0x20 /* > (大于,无符号) */
#define BPF_JGE 0x30 /* >= (大于等于,无符号) */
#define BPF_JSET 0x40 /* & (按位与不等于零) */
#define BPF_JNE 0x50 /* != (不等于) */
#define BPF_JSGT 0x60 /* > (大于,有符号) */
#define BPF_JSGE 0x70 /* >= (大于等于,有符号) */
#define BPF_JLT 0xa0 /* < (小于,无符号) */
#define BPF_JLE 0xb0 /* <= (小于等于,无符号) */
#define BPF_JSLT 0xc0 /* < (小于,有符号) */
#define BPF_JSLE 0xd0 /* <= (小于等于,有符号) */BPF_JMP 和 BPF_JMP32
的唯一区别是比较的位宽:
BPF_JMP:64-bit 比较。取dst_reg和src_reg(或imm)的完整 64-bit 值做比较。BPF_JMP32:32-bit 比较。只取低 32-bit 做比较。注意这里的 32-bit 子寄存器比较也有零扩展语义——比较之前不检查高 32-bit 是否为零,只是不参与比较。
BPF_JSET
的操作:if (dst & src) goto PC+off+1,用于高效的位掩码测试。
5.3 程序出口指令
#define BPF_EXIT 0x90 /* 从 BPF 程序返回 */
/* code = BPF_JMP | BPF_EXIT = 0x95 */
/* R0 包含返回值 */BPF_EXIT
没有目标和源操作数,必须出现在程序流图中。verifier 通过
check_cfg() 确保所有路径都能到达某个
BPF_EXIT(无死循环、无不可达指令);一个程序可以有多条分支各自以
BPF_EXIT 结束,但每条路径都必须能证明会终止于
EXIT,不能在中途用 EXIT 以外的机制“提前返回”。
5.4 函数调用指令
#define BPF_CALL 0x80 /* 函数调用 */
/* code = BPF_JMP | BPF_CALL = 0x85 */
/* Helper 函数调用:imm = helper function ID (负数或正数) */
/* 同一程序内调用:imm = BPF_PSEUDO_CALL (= 1),off = 目标指令偏移 */BPF_CALL 有两种模式,由 src_reg
字段区分:
| src_reg | 含义 | imm 说明 |
|---|---|---|
| 0 | Helper
函数调用(BPF_PSEUDO_KERNEL_CALL) |
imm = helper 函数 ID(如
bpf_map_lookup_elem = 1) |
BPF_PSEUDO_CALL |
程序内调用(BPF_PSEUDO_CALL = 1) |
imm = 1,off =
目标子程序的起始指令偏移 |
BPF_PSEUDO_KFUNC_CALL |
内核函数调用(kfunc,5.x+) | imm = 内核 BTF 函数 ID |
验证器行为差异:
- Helper 调用通过
check_helper_call()验证——它读取bpf_func_proto获取参数类型和返回值策略,验证所有 R1–R5 的类型匹配。 - 程序内调用(
BPF_PSEUDO_CALL)要求目标程序已注册为 subprog。verifier 在调用点保存当前状态,进入被调用函数时 R1–R5 被标记为NOT_INIT(参数需要重新初始化),R6–R9 保持 callee-saved 语义。 - Kfunc 调用通过 BTF 信息验证类型(第 12 篇详细讲解)。
六、调用约定:寄存器角色
eBPF 的调用约定定义了 11 个寄存器在函数调用中的行为。以下表总结了完整约定:
| 寄存器 | 角色 | 调用者保存? | 约束 |
|---|---|---|---|
| R0 | 返回值 | 是(调用覆盖) | 存放 helper 调用或程序退出时的返回值 |
| R1 | 第 1 参数 | 是 | BPF_PROG_LOAD 时为 ctx
指针;helper 调用时为第 1 参数 |
| R2 | 第 2 参数 | 是 | helper 调用参数 |
| R3 | 第 3 参数 | 是 | helper 调用参数 |
| R4 | 第 4 参数 | 是 | helper 调用参数 |
| R5 | 第 5 参数 | 是 | helper 调用参数 |
| R6 | callee-saved | 否(调用保留) | 跨 BPF_PSEUDO_CALL 保持值 |
| R7 | callee-saved | 否 | 同上 |
| R8 | callee-saved | 否 | 同上 |
| R9 | callee-saved | 否 | 同上 |
| R10 | 帧指针(只读) | 否(保持不变) | 始终指向栈顶,禁止写入 |
BPF_PROG_LOAD
时的初始寄存器状态(每种程序类型略有不同):
| 寄存器 | 初始值 | 来源 |
|---|---|---|
| R1 | ctx 指针 |
内核在调用 BPF 程序时自动设置 |
| R2–R5 | 未初始化 | NOT_INIT |
| R6–R9 | 未初始化 | NOT_INIT |
| R10 | 帧指针 | 初始化为 BPF 栈帧地址 |
| R0 | 未初始化 | NOT_INIT |
Helper 函数调用时的参数传递:
R1=arg0, R2=arg1, R3=arg2, R4=arg3, R5=arg4
调用 helper 后:R1--R5 被标记为 NOT_INIT(调用者不能再信任它们的值)
R0 被 helper 填充返回值(类型取决于 helper——PTR_TO_MAP_VALUE, SCALAR_VALUE 等)
程序内调用(BPF_PSEUDO_CALL)
的约定:
调用者必须显式将参数放在 R1–R5 中。callee 的 R6–R9 保留调用前的值。callee 对 R6–R9 的修改会反映到返回后的调用者(因为它们是同一个寄存器文件)。如果 callee 需要修改 R6–R9,必须在入口处保存到自己的栈帧,退出前恢复。
verifier 对程序内调用的检查在
check_func_call() 中——它创建一个新的
bpf_verifier_state,分支进入被调用函数,验证完成后将状态合并回调用者。被调用函数的
R0 返回类型和范围会传递回调用者的 R0。
七、从字节码到反汇编
7.1 获取字节码
使用 bpftool
查看任何已加载程序的字节码和反汇编:
# 查看程序的原始字节码(xlated = 翻译后的指令)
bpftool prog dump xlated id <PROG_ID>
# 查看带操作码的详细输出
bpftool prog dump xlated id <PROG_ID> opcodes
# 查看 JIT 编译后的本机码
bpftool prog dump jited id <PROG_ID>7.2 反汇编示例
以下是一个从 bpf_map_lookup_elem 开始的最小
BPF 程序的反汇编(xlated 模式):
# bpftool prog dump xlated id 42 opcodes
0: (b7) r1 = 0x0 ; BPF_ALU64_IMM(BPF_MOV, R1, 0)
1: (63) *(u32 *)(r10 -4) = r1 ; BPF_STX(BPF_W, R10, R1, -4)
2: (b7) r2 = 0x4 ; BPF_ALU64_IMM(BPF_MOV, R2, 4)
3: (bf) r3 = r10 ; BPF_ALU64_REG(BPF_MOV, R3, R10)
4: (07) r3 += -4 ; BPF_ALU64_IMM(BPF_ADD, R3, -4)
5: (18) r1 = map[id:1] ; BPF_LD_IMM64(R1, map_fd) [双宽指令]
7: (85) call bpf_map_lookup_elem#1 ; BPF_CALL(1)
8: (15) if r0 == 0x0 goto pc+1 ; BPF_JMP_IMM(BPF_JEQ, R0, 0, 1)
9: (b7) r0 = 0x0 ; BPF_ALU64_IMM(BPF_MOV, R0, 0)
10: (95) exit ; BPF_EXIT
逐条解码:
- 指令 0:
0xb7=BPF_ALU64|BPF_K|BPF_MOV,dst=R1,imm=0。将 R1 初始化为 0。 - 指令 1:
0x63=BPF_STX|BPF_W|BPF_MEM,dst=R10,src=R1,off=-4。存储 R1 的低 32-bit 到栈偏移 -4 处。 - 指令
2:
0xb7,dst=R2,imm=4。将 R2 初始化为 4(key 的大小)。 - 指令 3:
0xbf=BPF_ALU64|BPF_X|BPF_MOV,dst=R3,src=R10。将帧指针复制到 R3。 - 指令 4:
0x07=BPF_ALU64|BPF_K|BPF_ADD,dst=R3,imm=-4。计算r10 - 4(指向栈上 key 的地址)。 - 指令 5-6:
0x18=BPF_LD|BPF_DW|BPF_IMM,双宽指令。将 map 文件描述符(64-bit)加载到 R1。第二条保留指令imm包含高 32-bit。注意这里的 R1 被覆盖了(之前存的是 0,现在变成 map 指针)——R1 是 caller-saved 的体现。 - 指令 7:
0x85=BPF_JMP|BPF_CALL,imm=1(bpf_map_lookup_elem的 helper ID)。调用 helper,传入 R1=map 指针,R2=key 大小。R0 将包含返回的 value 指针或 NULL。 - 指令 8:
0x15=BPF_JMP|BPF_K|BPF_JEQ,dst=R0,imm=0,off=1。如果 R0 == 0(NULL 检查),跳过下一条指令(跳转到 exit)。 - 指令 9:只有在 R0 != 0 时执行。将 R0 设为 0(成功返回)。
- 指令 10:退出,R0 为返回值。
7.3 端序转换的实际字节码
# 从大端 16-bit 转换为主机字节序
0: (dc) r0 = be16 r0 ; BPF_ALU|BPF_K|BPF_END, dst=R0, imm=BPF_TO_LE|BPF_H
0xdc
的解码:BPF_ALU(0x04) | BPF_K(0x00) | BPF_END(0xd0) = 0xd4,但实际的编码是
BPF_ALU|BPF_K|BPF_END = 0xdc(BPF_END
在 opcode 位置时 0xd0|0x0c = 0xdc,其中
0x0c 来自 imm 的编码位)。
八、Mermaid:从 C 源码到本地执行的完整流程
flowchart TB
subgraph COMPILE["编译阶段(用户态)"]
C["C/BPF 源码<br/>e.g., xdp_filter.c"]
CLANG["clang -target bpf -O2<br/>-g (BTF 调试信息)"]
ELF["BPF 目标文件 (.o)<br/>ELF sections: .text, maps, .BTF, .BTF.ext"]
end
subgraph LOAD["加载阶段(用户态→内核)"]
LIBCALL["libbpf / bpftool"]
SYSCALL["bpf(BPF_PROG_LOAD, ...)"]
end
subgraph VERIFY["验证阶段(内核)"]
PARSER["指令流解析<br/>struct bpf_insn[]"]
CFG["控制流图构建<br/>check_cfg()"]
DOCHECK["逐条模拟执行<br/>do_check() 主循环"]
VERDICT["验证判决<br/>ACCEPT / REJECT"]
end
subgraph JIT_COMP["JIT 编译阶段(内核)"]
JIT_ENTRY["bpf_jit_compile()<br/>架构调度"]
X86["x86-64 JIT<br/>bpf_int_jit_compile()"]
ARM64["ARM64 JIT<br/>bpf_int_jit_compile()"]
JIT_IMAGE["JIT 镜像<br/>rwx kernel memory"]
end
subgraph EXEC["执行阶段"]
HOOK["attach 到 hook<br/>kprobe/XDP/TC/socket"]
NATIVE["本地指令执行<br/>native x86/ARM 码"]
MAPS["BPF maps<br/>内核态状态共享"]
end
C --> CLANG --> ELF --> LIBCALL --> SYSCALL
SYSCALL --> PARSER --> CFG --> DOCHECK --> VERDICT
VERDICT -->|ACCEPT| JIT_ENTRY
VERDICT -->|REJECT| REJ["返回错误 + verifier log"]
JIT_ENTRY --> X86
JIT_ENTRY --> ARM64
X86 --> JIT_IMAGE
ARM64 --> JIT_IMAGE
JIT_IMAGE --> HOOK --> NATIVE
NATIVE <--> MAPS
在这个流程中,本文覆盖的是验证阶段中的「指令解码」部分——即
struct bpf_insn[]
到具体指令语义的映射。verifier 的 do_check()
主循环(第 02 篇)和 JIT 的架构翻译(第 05
篇)是这个流水线的下游。
九、与后续篇章的关系
本文建立的寄存器角色和指令编码模型是后续所有讨论的基础:
- 第 02 篇(验证器框架):verifier 的
do_check()主循环逐条执行的就是bpf_insn。R1–R5 的 caller-saved 约定决定了 verifier 在check_helper_call()之后如何标记寄存器状态。 - 第 03
篇(验证器核心算法):
bpf_reg_state中的类型(PTR_TO_CTX,SCALAR_VALUE等)直接对应本文的寄存器模型。每个寄存器的类型和值域在每条bpf_insn执行后被更新。 - 第 04
篇(常见拒绝模式):大多数拒绝模式的根因是在错误的寄存器上使用了错误的操作——理解寄存器角色后,verifier
日志的
R1 type=...报文变得可解。 - 第 05 篇(JIT 编译器后端):JIT 后端的寄存器映射策略(BPF R0–R10 到 x86/ARM 寄存器的对应关系)直接基于本文的调用约定。
十、小结
eBPF 指令集的本质是一个简单的 64-bit RISC 寄存器机。11
个寄存器各有固定角色,每条指令
64-bit,编码了操作码、寄存器、偏移和立即数四个字段。这不是设计上的”简单粗暴”——这是有意为之:verifier
需要分析的指令类型越少,安全证明就越可行。固定宽度指令消除了变长指令带来的对齐和解析问题;有限的寄存器集合(11
个而非 32 个)约束了 verifier
需要跟踪的状态空间;固定的调用约定使得
check_helper_call()
的参数验证成为机械的查表操作而非复杂的推断过程。
当你在 bpftool prog dump xlated 中看到
(bf) r3 = r10
这样的输出时,你看到的不是”某种魔法字节码”——而是一个精确的
bpf_insn 结构体正在被 verifier
逐条解码和执行。
参考
内核源码(A 级,Linux 6.6)
include/uapi/linux/bpf.h— BPF 指令 opcode 与寄存器常量include/uapi/linux/bpf_common.h—BPF_W/BPF_H/BPF_B宽度编码include/linux/filter.h—struct bpf_insn定义kernel/bpf/core.c— 解释器___bpf_prog_run()
规范(A 级)
- IETF BPF Instruction Set Architecture
草案(
draft-ietf-bpf-isa)
下一篇:验证器框架:从 BPF_PROG_LOAD 到 do_check()(第 02 篇)
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【eBPF 内核实现深度拆解】非 Linux eBPF:Windows eBPF 平台、ubpf 与 rbpf 用户态运行时
eBPF 不只是 Linux 的技术——Windows eBPF (ebpf-for-windows) 如何把 BPF 字节码挂载到 Windows 内核的 NetBufferList 和 System Call、ubpf 的用户态 VM 实现(JIT 与解释器双模)、rbpf 的 Rust 生态、以及 IETF BPF ISA 标准化草案的进展。
【eBPF 内核实现深度拆解】验证器框架:从 BPF_PROG_LOAD 到 do_check()
跟踪 BPF_PROG_LOAD 系统调用的内核执行路径,逐层拆解 bpf_prog_load()→bpf_check()→do_check_main() 的调用链,建立 verifier 执行全景——这是理解 verifier 安全保证的入口。
【eBPF 内核实现深度拆解】JIT 编译器后端:x86-64 与 ARM64 的 BPF→Native 翻译管线
从 bpf_jit_compile() 入口出发,拆解 BPF 字节码到 x86-64/ARM64 本地指令的翻译过程——寄存器映射策略、ALU 指令的 one-to-one/many-to-one 翻译、尾调用与 call 的本地实现、JIT 镜像的 kallsyms 集成,以及 JIT 与 interpreter 的性能边界。
【eBPF 内核实现深度拆解】Map 内核实现(上):hash / array / per-CPU 的数据结构与并发模型
从 bpf_map_ops 虚函数表出发,逐层拆解 BPF_MAP_TYPE_HASH、BPF_MAP_TYPE_ARRAY、per-CPU 变体的内核实现——htab 的 bucket 链表与 prealloc、bpf_array 的零拷贝共享、per-CPU 分配器的无锁语义。