在Linux上做网络抓包, tcpdump和libpcap几乎是绕不开的工具, 而它们底层真正干活的是BPF, 也就是Berkeley Packet Filter。应用程序提交给内核的不是一段普通表达式, 而是一串经过编译的BPF指令。同样的过滤需求, 指令写法不同, 内核执行的代价可能相差数倍。本文就用Ruby来实现一套BPF过滤器的生成与优化流程, 从指令结构讲起, 逐步加入指令合并, 跳转优化等手段, 并给出可运行的示例代码。

一, BPF指令集的基本结构
经典BPF的指令格式非常紧凑, 每条指令由四个字段组成: 操作码opcode, 跳转真值jt, 跳转假值jf, 以及一个32位的操作数k。指令集大致可以分为几类: 加载指令负责把数据包内容或长度装进累加器A, 算术逻辑指令对A做运算, 跳转指令根据条件跳转, 返回指令决定抓多少字节或者是否丢弃该包。
举个例子, 过滤目标端口为80的TCP包, 编译后的BPF伪指令大致是这样的流程: 先加载以太网协议类型字段, 判断是否为IP, 不是就直接返回0丢弃; 再判断IP头的协议字段是否为TCP; 最后加载TCP目的端口, 与80比较, 匹配则返回一个较大的捕获长度。可以看到, 指令的顺序和跳转目标的安排, 会对每个数据包需要执行的指令条数产生直接影响。
用Ruby来建模指令非常自然, 一个Struct或者简单的类就够了:
class BPFInsn
attr_accessor :code, :jt, :jf, :k
def initialize(code, jt = 0, jf = 0, k = 0)
@code = code
@jt = jt
@jf = jf
@k = k
end
def to_s
format("%-20s jt=%d jf=%d k=%d", @code.inspect, @jt, @jf, @k)
end
end有了这个基础结构, 后续所有的优化操作都可以归结为对指令数组BPFInsn列表的变换。理解这一点很重要: BPF优化本质上就是一段针对小型指令序列的编译器后端优化, 常见的窥孔优化思路都可以搬过来用。
二, 用Ruby构建过滤器生成器与优化器
接下来搭一个简单的生成器框架。我们的目标是接受一种简化的过滤描述, 产出原始的BPF指令序列, 再交给优化器处理。生成器部分负责语义翻译, 优化器部分负责指令级改进, 两个阶段解耦之后, 每一部分都容易单独测试。
class BPFOptimizer
def initialize(insns)
@insns = insns
end
# 优化入口, 依次执行各个pass
def run
passes = [:peephole, :fold_constants, :eliminate_dead_jumps]
passes.each do |p|
@insns = send(p, @insns)
end
@insns
end
# 窥孔优化: 合并相邻的等值比较与跳转
def peephole(insns)
result = []
insns.each_with_index do |insn, i|
if insn.code == :BPF_JMP_JEQ_K &&
insns[i + 1] && insns[i + 1].code == :BPF_RET_K
# 若比较失败后紧跟丢弃返回, 可将jf直接指向该返回, 省去一条指令
merged = BPFInsn.new(:BPF_JMP_JEQ_K, insn.jt, 1, insn.k)
result << merged
result << insns[i + 1]
skip_next = true
else
result << insn unless defined?(skip_next) && skip_next
skip_next = false
end
end
result
end
# 常量折叠: 简化可静态求值的比较
def fold_constants(insns)
insns.reject do |insn|
insn.code == :BPF_JMP_JGE_K && insn.k == 0
end
end
# 冗余跳转消除: 去掉指向下一条的跳转
def eliminate_dead_jumps(insns)
insns.map do |insn|
insn.jt = 0 if insn.jt == 1 && insn.jf != 1
insn
end
end
end上面这段代码实现了三个最经典的优化pass。窥孔优化扫描局部指令窗口, 把能合并的模式压成更短的序列; 常量折叠处理那些编译期就能确定结果的比较, 比如任何值与0做大于等于比较恒为真; 冗余跳转消除则清理掉指向紧邻下一条指令的跳转, 因为这类跳转等于没跳。每个pass都保持指令数只减不增, 这样优化器整体是安全的。
需要注意的是, 跳转偏移是相对当前指令的, 插入或删除指令后必须重算所有跳转目标。一个稳妥的做法是优化阶段先用绝对地址记录跳转目标, 全部优化完成后再统一转换回相对偏移。否则一条指令被删除, 引用它的跳转就会指错位置, 这类bug在线性扫描式BPF程序里非常隐蔽。
三, 性能统计与优化效果对比
优化做得好不好, 不能靠感觉, 要靠数据。可以给优化器挂一个统计模块, 记录优化前后的指令条数, 以及在样本pcap数据上的匹配耗时。Ruby的Benchmark模块足够完成这件事:
require 'benchmark'
class BPFBenchmark
def self.compare(raw, optimized, packets)
t1 = Benchmark.realtime do
packets.each { |p| run_bpf(raw, p) }
end
t2 = Benchmark.realtime do
packets.each { |p| run_bpf(optimized, p) }
end
puts "原始指令数: #{raw.size}, 优化后: #{optimized.size}"
puts format("优化前耗时: %.4fs, 优化后耗时: %.4fs, 提升: %.1f%%",
t1, t2, (1 - t2 / t1) * 100)
end
def self.run_bpf(insns, packet)
pc = 0
a = 0
while pc < insns.size
insn = insns[pc]
case insn.code
when :BPF_LD_H_ABS then a = packet[insn.k, 2].unpack1('n')
when :BPF_JMP_JEQ_K
pc += (a == insn.k ? insn.jt : insn.jf).next
next
when :BPF_RET_K then return insn.k
end
pc += 1
end
0
end
end在实际测试中, 一个包含多条协议判断的复合过滤器, 经过合并与折叠优化后, 指令数通常能下降两到三成。更重要的是, 最常见路径上每个包需要执行的指令数明显减少, 因为大部分流量会在第一个协议判断处就被快速丢弃。BPF的性能关键不在于总指令数, 而在于最热路径的长度, 优化时应该优先缩短高频路径。
最后补充两点工程建议。第一, Ruby解释执行BPF只适合做验证和教学, 生产环境应该通过Socket配合SO_ATTACH_FILTER选项把指令交给内核执行, 两者性能差几个数量级。第二, 把规则按命中率从高到低排序, 让最常见的判断放在过滤器最前面, 这种调度层面的优化往往比指令层面的微优化收益更大。把生成器, 优化器和基准测试三者配合起来迭代, 就能持续逼近BPF的执行效率上限。