尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

12:1992 年的天才之作——BPF 论文到底讲了什么

12:1992 年的天才之作——BPF 论文到底讲了什么 大家好我是毛衣哥。这一期聊聊 1992 年的一篇论文——它比你我都大。但直到今天你电脑里的 tcpdump 还在跑着它的设计。这期的开场白就是三十年前的人已经想明白了我们还在折腾的事。在 1992 年之前抓包是一件非常奢侈的事情——如果你想从 100 万个数据包中找到你关心的那 100 个你需要 CPU 把这 100 万个全搬一遍。然后有一个人叫 Steven McCanne在 Lawrence Berkeley Lab 工作。他写了一篇论文——他管它叫 BPFBerkeley Packet Filter。这篇论文的核心思想一直用到了今天。三十多年后你电脑里的 libpcap 和 tcpdump 底层跑的还是这套逻辑。1992 年之前抓包是把垃圾全部搬完再分类1992 年之前 Unix 系统上是怎么抓包的当时的系统提供了一个叫Network Tap的机制在 SunOS 上叫 NITDEC 上叫 Ultrix Packet Filter。这个机制的工作方式是这样的网卡收到所有数据包混杂模式 ↓ 内核把每个数据包原封不动复制一份 ↓ 通过 read() 系统调用送到用户态程序 ↓ 用户态程序在用户态做过滤这个是我要的吗 ↓ 不是 → 丢掉是 → 保留问题在哪你把所有垃圾数据都搬到用户态了。假设你的网络线速是 10 Mbps1992 年的主流每个包平均大小 256 字节每秒大约 5000 个包。你只关心 DNS端口 53的流量——大约占总流量的 0.5%。用户态程序每秒从内核读 5000 个包 → 发现 4975 个不是自己要的 → 丢掉 → 留下 25 个。4975 次 read() 系统调用、4975 次数据从内核态复制到用户态、4975 次用户态检查——全部白费。McCanne 的想法不要把垃圾搬到家里再分类McCanne 在 1992 年的论文《The BSD Packet Filter: A New Architecture for User-level Packet Capture》里提出了一个在那个年代非常前卫的想法把过滤器放到内核里去。不要让用户态程序处理它不关心的数据。具体来说用户态程序把过滤规则编译成一段极小的字节码通过系统调用把这个字节码注入到内核里内核注册这段字节码为一个数据包过滤器以后每个数据包到达时内核在这个字节码上运行一次返回 0 → 丢掉返回非 0 → 复制到用户态这样一来不在用户态处理的数据包就不会从内核复制到用户态了。BPF 之前 所有包 → 内核 → 全部搬 → 用户态99% 扔掉 BPF 之后 所有包 → 内核BPF 过滤99% 直接扔掉→ 只有需要的 1% 搬到用户态这个想法今天看来理所当然但在 1992 年是颠覆性的——因为当时的内核模块开发非常复杂很少有系统会把一个用户态的可编程逻辑注入到内核里去执行。BPF 虚拟机的指令集McCanne 不只是提出了在内核过滤这个想法。他还设计了一个实际的虚拟机来执行这个过滤。BPF 虚拟机的规格寄存器 累加器 A32 位用于算术运算和过滤结果 索引寄存器 X32 位用于随机访问数据包中的偏移量 内存 临时存储区可以存几个 32 位值 程序计数器PC指向当前正在执行的指令 指令类型共 11 种 LD / LDH / LDB —— 从数据包加载数据不同大小32 位、16 位、8 位 LDX —— 从数据包加载数据到 X 寄存器 ST / STX —— 存储到临时内存 ALU —— 算术运算ADD、SUB、MUL、DIV、AND、OR、XOR、LSH、RSH JMP / JEQ / JGT / JGE / JSET —— 跳转和条件跳转 RET —— 返回返回值 0 丢弃0 保留并复制前 N 字节 MISC —— 杂项主要用于报告长度最关键的设计决策没有循环指令。在 BPF 指令集中没有JMP_BACK向后跳转。所有跳转都是向前的。这意味着 BPF 程序的执行流是一个有向无环图——执行时间有固定上界。这个设计的伪代码——BPF 虚拟机的执行引擎// BPF 虚拟机核心执行逻辑 function execute_bpf(bpf_program[], packet_data[]): A 0 // 累加器 X 0 // 索引寄存器 PC 0 // 程序计数器 while PC len(bpf_program): instruction bpf_program[PC] PC switch instruction.opcode: case LD: // 从 packet 加载数据到 A A load_data(packet_data, instruction.offset, instruction.size) case LDH: // 加载 16 位 A load_16bit(packet_data, instruction.offset) case LDB: // 加载 8 位 A load_8bit(packet_data, instruction.offset) case JEQ: // 条件跳转等于 if A instruction.value: PC instruction.jump_target case JGT: // 条件跳转大于 if A instruction.value: PC instruction.jump_target case RET: // 返回 return A // 0 丢弃, 0 保留 case ALU: // 算术运算 A alu_op(instruction.alu_type, A, X) case ST: // 存储到临时内存 mem[instruction.addr] A case LDX: // 加载数据到 X 寄存器 X load_data(packet_data, instruction.offset, instruction.size) // 注意没有跳转到前一条指令的指令 // 所以程序一定会在有限步内结束 return 0 // 默认丢弃为什么为了防止用户在内核里写死循环。如果 BPF 程序有一个向后跳转并且条件永远不满足——它就会在内核里永远运行下去CPU 被锁死系统崩溃。这个设计让 BPF 程序在安全性和效率之间找到了一个极佳的平衡点。一条过滤规则的完整旅程我们用一个实际的例子来追踪tcpdump -i eth0 tcp port 8080这条命令在 BPF 层面的完整旅程。第一步tcpdump 解析命令参数识别tcp port 8080——一串人类可读的过滤表达式。第二步调用 libpcap 的pcap_compile()structbpf_programfcode;pcap_compile(handle,fcode,tcp port 8080,1,0);pcap_compile函数内部会把这个表达式解析成抽象语法树AST然后编译成 BPF 字节码。第三步编译后的 BPF 字节码使用tcpdump -d tcp port 8080可以看到编译后的指令(000) ldh [12] // 加载链路层头偏移 12 处的 2 字节到 A这里是 EtherType 字段 (001) jeq #0x800 // A 是否等于 0x0800IPv4是 → 跳到 002否 → 跳到 005 (002) ldb [23] // 加载偏移 23 处的 1 字节到 AIPv4 头的 Protocol 字段 (003) jeq #0x6 // A 是否等于 6TCP是 → 跳到 004否 → 跳到 005 (004) ldh [20,2] // 加载偏移 202 处的 2 字节到 ATCP 头的目的端口 (005) jeq #0x1f90 // A 是否等于 0x1f908080 的十六进制是 → 006否 → 007 (006) ret #65535 // 保留整个数据包 (007) ret #0 // 丢弃这个数据包逐条解读指令 000ldh [12] 从数据包的开头偏移 12 字节处读取 2 个字节16 位。 以太网帧的结构 0-6 字节目标 MAC 地址 6-12 字节源 MAC 地址 12-14 字节EtherType-ldh [12] 就是读这 2 字节 EtherType 0x0800 表示 IPv4。 指令 001jeq #0x800 如果 A 0x0800跳转到指令 002否则跳转到指令 005返回 0 丢弃。 这是在问这是 IPv4 协议的数据包吗 指令 002ldb [23] 以太网帧头 14 字节 IPv4 头偏移 9 字节 偏移 23 字节。 IPv4 头的第 10 个字节偏移 9是 Protocol 字段。 这个字段的值6 TCP17 UDP1 ICMP。 指令 003jeq #0x6 如果 A 6跳转到 004否则 005。 这是在问这是 TCP 协议的数据包吗 指令 004ldh [20,2] 以太网头 14 字节 IPv4 头 20 字节 TCP 头偏移 2 字节。 TCP 头的第 3-4 字节是目的端口。 读取这 2 字节。 指令 005jeq #0x1f90 如果目的端口 80800x1f90跳转到 006保留否则 007丢弃。看懂没有6 条指令没有循环从上到下顺序执行。在 CPU 上跑一次只需要几十到几百纳秒。第四步加载到内核通过setsockopt()系统调用使用SO_ATTACH_FILTER或SO_ATTACH_BPF把编译好的 BPF 程序附加到 socket 上。structsock_fprogfprog{.lenfcode.bf_len,.filterfcode.bf_insns,};setsockopt(sockfd,SOL_SOCKET,SO_ATTACH_FILTER,fprog,sizeof(fprog));内核收到后会验证 BPF 程序的安全性检查是否有循环、是否有越界访问、是否访问了未初始化的寄存器。验证通过后将过滤器挂载到 socket 的接收路径上。第五步运行以后每个数据包到达时内核在网络协议栈的早期阶段还在 IP 层的时候调用这个 BPF 程序。程序执行完毕后根据返回值决定是否继续处理。BPF 的性能到底有多好1992 年 McCanne 在他的论文里给出的基准测试数据在当时的硬件上25 MHz RISC CPU、10 Mbps 以太网BPF 可以在内核里以每微秒处理一个数据包的速度运行过滤而 Network Tap之前的机制每个包需要几十微秒因为系统调用的开销太大BPF 将抓包的系统总 CPU 占用率从80-90% 降到了 10% 以下今天的 BPF包括 eBPF更加强大——JITJust-In-Time编译器把 BPF 字节码编译成本地机器码每条指令的执行时间从几十纳秒降到了几纳秒。一个有意思的细节BPF 名字里的 BBPF 是 Berkeley Packet Filter。但很多人的理解是“B” “Berkeley” 来自加州大学伯克利分校。实际上不完全正确。BPF 最早是 McCanne 在 Lawrence Berkeley Lab劳伦斯伯克利国家实验室开发的。LBL 跟 UC Berkeley 是两回事虽然是同一座城市、有合作关系。MCCanne 的论文发表时LBL 的地址确实写的是 “Berkeley, CA”——所以这个 B 到底算是 Berkeley 城还是 Berkeley 大学已经说不清了。而且 BPF 的第一个实现在 BSD 系统上FreeBSD、NetBSD 等。后来 Linux 也实现了自己的 BPF。现在 eBPF 最初也是 Linux 特有的。所以 B 到底代表什么已经成了一个历史的玩笑——但大家都叫它 BPF。下期预告eBPF 崛起——从包过滤器到内核可编程平台。2014 年 Alexei Starovoitov 对 BPF 做了历史性的扩展。从此 BPF 不再只是抓包工具它变成了你能对内核做的几乎所有事情的接口。DumpAny 怎么做BPF 是 tcpdump 的底层引擎DumpAny 不直接使用 BPF我们走的是代理模式不是网卡抓包模式。但 BPF 的设计思想——在内核里就做过滤不要把垃圾搬回家——深刻影响了 DumpAny 的协议解析引擎设计在数据流的早期阶段就做协议识别和过滤只把用户关心的请求送到展示层。BPF之后: 内核态过滤所有包只保留1%网卡内核: BPF过滤器用户态: 只收到需要的BPF之前: 用户态过滤所有包网卡内核:全部复制到用户态用户态: 99%丢掉 聊几句1992 年之前抓包靠什么机制—— Network TapSunOS 叫 NIT内核把所有包复制一份送用户态再过滤。BPF 指令集最关键的安全设计—— 没有循环指令无向后跳转执行流是 DAG、时间有上界防死循环。BPF 虚拟机 RET 指令返回值的含义—— 0 丢弃0 保留并复制前 N 字节。BPF 把抓包 CPU 占用从多少降到多少—— 从 80-90% 降到 10% 以下。BPF 过滤器你其实每天都在用tcpdump 背后就是它。不想背过滤语法的话DumpAny 图形化直接点。官网 dumpany.cn开源免费。
返回列表