14:环形缓冲区——内核和用户态之间的快递中转站
抓包原理探秘 14:环形缓冲区——内核和用户态之间的快递中转站
大家好,我是毛衣哥。这一期聊一个你可能没注意到但极其重要的组件——环形缓冲区。没有它,tcpdump 自己就能把你的 CPU 吃光。名字虽然土,但设计是真的骚。
前两篇我们聊了 BPF 怎么在内核里做过滤——但过滤完之后呢?数据怎么从内核空间送到用户态的 tcpdump 进程里?
如果每个数据包都触发一次从内核到用户态的传输——那 CPU 就全浪费在切换上了。
环形缓冲区就是这个问题的解。它是 BPF 架构中一个容易被忽略但极其精妙的设计。
没有缓冲区会怎样
假设没有环形缓冲区,tcpdump 的读取流程是这样的:
数据包通过 BPF 过滤(需要保留) → 内核立刻唤醒 tcpdump 进程 → tcpdump 调用 read() 读取这个包 → 切换回内核态,把数据复制到用户态 → 切换回用户态,tcpdump 处理数据、打印输出 → tcpdump 再次调用 read() 等待下一个包一次完整的切换过程需要做:
- 保存当前进程的寄存器
- 切换页表(虚拟内存映射)
- 执行系统调用入口代码
- 从内核态返回用户态
- 恢复进程的寄存器
这个过程大约需要1-3 微秒。
如果你每秒处理10 万个数据包(10 万 PPS,在千兆网络上非常常见),仅仅是切换开销就达到了100-300 毫秒/秒——占了一颗 CPU 核心的 10-30%。
如果没有缓冲区,tcpdump 自身就会成为系统的性能瓶颈。
环形缓冲区的设计
环形缓冲区解决的核心问题就是:批量传输,减少切换次数。
它在内核维护了一个环状的缓冲队列:
预处理内存:4 MB 的连续物理内存(固定分配) 结构: ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐ │ P1 │ P2 │ P3 │ P4 │ P5 │ │ │ │ └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘ ↑ ↑ 写指针 读指针 (内核往这写) (用户态从这里读)工作原理:
- 内核把通过 BPF 过滤后的数据包复制到写指针位置
- 写指针向前移动,指向下一个空闲位置
- 用户态程序从读指针位置开始读(通过 read() 系统调用)
- 读完后,读指针向前移动
- 如果写指针写满了整个环——它会回到开头开始写(因此叫"环形")
- 如果写指针追上了读指针——缓冲区满了,新包无处可放
// 环形缓冲区的读写伪代码 struct RingBuffer { buf: byte[], // 固定大小的缓冲区 size: int, // 缓冲区总大小 write_ptr: int, // 内核写指针(位置) read_ptr: int, // 用户态读指针(位置) } // 内核侧:BPF 过滤后写入缓冲区 function kernel_write_packet(rb, packet): data_len = len(packet) // 检查有没有空间(写指针 + 数据长度 会超过读指针吗?) if rb.write_ptr + data_len <= rb.read_ptr + rb.size: // 有空闲空间 copy_to_ring(rb.buf, rb.write_ptr % rb.size, packet) rb.write_ptr += data_len else: // 缓冲区满了!丢包计数++ stats.dropped_by_kernel++ // 不阻塞,直接丢弃 // 用户态侧:tcpdump 读取缓冲区 function user_read_packets(rb): while rb.read_ptr < rb.write_ptr: // 从读指针位置读一个包 packet_size = peek_u32(rb.buf, rb.read_ptr % rb.size) // 用户态收到这个包 user_buffer = copy_from_ring(rb.buf, rb.read_ptr % rb.size, packet_size) process_packet(user_buffer) rb.read_ptr += packet_size // 读指针前进 // 如果读指针超过缓冲区末尾,写指针可能也绕回来了 // 但 write_ptr 永远是单调递增的——通过取模访问数组缓冲区满了怎么办?
内核必须做选择——没有完美的答案:
- 丢弃最新的包:保留老的,丢掉新的。公平——先到的包不会被丢弃。
- 丢弃最老的包:保留新的,丢掉老的。用户可能更关心最近的流量。
- 阻塞生产者:让内核停下来等待用户态读取。绝对不能做!如果阻塞了,整个网络协议栈都会停——因为内核的软中断上下文不能阻塞等待。
tcpdump 的选择是:丢弃最新到达的包。即写指针越过读指针时,新包被丢弃——tcpdump 统计里的dropped by kernel就是被丢弃的数据包的数量。
tcpdump 的 -B 参数到底在调什么
当你运行tcpdump -B 4096时(注意单位是 KB),你实际上是在设置内核 BPF 环形缓冲区的大小。
tcpdump -B 1024 → 缓冲区 1 MB tcpdump -B 4096 → 缓冲区 4 MB(默认) tcpdump -B 65536 → 缓冲区 64 MB(大流量场景)增大缓冲区的作用:
- 缓冲区越大,能缓冲的数据包越多
- 写指针追上读指针的概率越低
- 丢包的概率越低
增大缓冲区的代价:
- 缓冲区是内核空间的内存(非连续,但物理上需要连续性高)
- 内核非连续内存分配有限——不能无限大
- 实际限制通常在 32-256 MB
什么情况下你需要调大缓冲区?
看 tcpdump 的输出:
tcpdump: 68723 packets captured tcpdump: 241799 packets received by filter tcpdump: 93616 packets dropped by kernel如果dropped by kernel的数量很大(超过 captured 的 10%),说明环形缓冲区满了。你需要:
- 调大缓冲区(
-B 16384) - 或者收缩过滤条件(少抓一些包)
- 或者把数据写到文件而不是终端(写终端的 I/O 开销很大)
mmap 零拷贝:更高级的优化
环形缓冲区虽然好,但还有一个问题:数据从内核复制到用户态,还是需要一次 copy。
内核(BPF 环形缓冲区)→ 复制到用户态(tcpdump 的 read buffer)→ 处理 ↑ 这里有一次内存复制现代的 BPF 实现(包括 eBPF)支持mmap 模式(内存映射)。用户态程序通过 mmap 把内核的环形缓冲区映射到自己的地址空间。
mmap 之后:
内核(BPF 环形缓冲区) → tcpdump 直接从映射内存中读取数据 ↑ 不需要复制!两个地址空间共享同一块物理内存这样就消除了"一次内存复制"的开销。在高速率抓包场景下,mmap 模式可以将 CPU 利用率降低 30-50%。
一个具体的数字
你打开tcpdump -i eth0 port 80,在千兆网络(每秒约 10 万个包)上运行。以下是"有缓冲"和"无缓冲"两种模式的数据:
无缓冲(每个包一个 read): 系统调用次数:100,000 次/秒 CPU 占用(仅切换):10-30% CPU 占用(含处理):40-60% 有缓冲(mmap,4MB 缓冲区): 系统调用次数:10-20 次/秒(每积累几千个包才叫一次 read) CPU 占用(仅切换):< 1% CPU 占用(含处理):10-20%这就是环形缓冲区的威力——把"每包一次系统调用"变成了"每几千个包一次系统调用"。切换开销从 CPU 占比 30% 降到了 1% 以下。
下期预告:libpcap——全世界抓包工具共同的老父亲。tcpdump、Wireshark、nmap、Snort——这些工具看似毫无关系,但它们底层都在调用同一个库。我们从 C 函数的维度看这个库的设计。
DumpAny 怎么做:环形缓冲区解决的是"生产者(内核)和消费者(用户态程序)速度不匹配"的问题。DumpAny 的代理引擎面临同类的挑战:解密线程(生产者)每秒产出几千条明文请求,UI 渲染线程(消费者)每秒只能刷 60 帧。DumpAny 的解法跟环形缓冲区如出一辙——中间加一个无锁环形队列,生产者往队尾写,消费者从队头批量取。满了就丢最旧的,不阻塞生产者。如果你在高流量抓包时发现界面偶尔漏了几条请求——那就是这个队列溢出了,跟 tcpdump 的
dropped by kernel一个道理,只不过发生在用户态。
写指针 → ┌───┬───┬───┬───┬───┬───┬───┬───┐ │ P1│ P2│ P3│ P4│ P5│ │ │ │ └───┴───┴───┴───┴───┴───┴───┴───┘ ↑ ↑ 读指针 写指针 (用户态读) (内核写) 缓冲区满了的情况: ┌───┬───┬───┬───┬───┬───┬───┬───┐ │ P9│ P2│ P3│ P4│ P5│ P6│ P7│ P8│ └───┴───┴───┴───┴───┴───┴───┴───┘ ↑ 写指针追上了读指针!丢包! dropped by kernel++💬 聊几句:
- tcpdump 的
-B调的是什么?单位?—— 内核 BPF 环形缓冲区大小,KB(-B 4096=4MB 是默认)。 - 缓冲区满时内核绝对不能怎么做?—— 阻塞生产者,软中断上下文不能阻塞,一阻塞整个协议栈停摆。
- 一次用户态/内核态切换要多久?—— 约 1-3 微秒。
- mmap 零拷贝能降多少 CPU?—— 高吞吐抓包场景降 30-50%。
ring buffer 丢包这茬,DumpAny 帮你把缓冲区调好了,基本不会因为 buffer 太小丢包。官网 dumpany.cn,开源免费。