当前位置:首页>Linux>解构 Linux RX 路径:数据页“躺”在那,怎么跟网卡完成一次 DMA 握手?

解构 Linux RX 路径:数据页“躺”在那,怎么跟网卡完成一次 DMA 握手?

  • 2026-10-11 05:35:06
解构 Linux RX 路径:数据页“躺”在那,怎么跟网卡完成一次 DMA 握手?

我前面那篇讲收包拷贝路径的时候,提到 ring buffer 那一页"安静地躺在那等网卡来写"。顺着这个,就自然要问:那一页到底怎么跟网卡对接的,描述符又是什么东西。这问题问到点子上了——ring buffer 这个名字容易让人以为它就是一块连续的大缓冲,网卡往里写、内核从里读。其实完全不是。

RX ring 是一圈描述符,描述符里只存一个指向数据页的地址。真正装帧数据的是另外分配的内存页。网卡、描述符、内存页,三者靠地址和状态位完成一次握手。

描述符环长什么样

以 Intel 网卡为例,RX 描述符环是一块用 dma_alloc_coherent 分配的一致内存(consistent DMA memory),里面是一排 struct e1000_rx_desc(这是 legacy 描述符格式,igb 的 union igb_rx_desc 也包含这个布局,足以说明交互原理):

struct e1000_rx_desc {
__le64 buffer_addr; /* 数据页的总线地址,网卡往这写 */
__le16 length; /* 网卡回写:帧长度 */
__le16 csum; /* 校验和 */
u8 status; /* 网卡回写:DD / EOP 等位 */
u8 errors;
__le16 special;
};

关键看两个字段:buffer_addr 是给网卡写数据的目标地址(总线地址,不是虚拟地址);status 里的 DD(Descriptor Done)位是网卡告诉驱动"我写完了"的信号。

注意这个结构体才 16 字节,里面不含任何帧数据。ring 的大小由 ethtool -g 看(不同网卡默认不同,这里以某 Intel 网卡为例):

$ ethtool -g eth0
Current hardware settings:
RX: 512

512 个描述符,每个 16 字节,描述符环总共才 8KB。但它背后挂着 512 个 4KB 的数据页——真正的存储开销在那些页上。

数据页是另一块内存

每个描述符 buffer_addr 指向一个独立的数据页。这个页不是描述符环的一部分,是驱动用 alloc_page 分配、再用 dma_map_page 映射出总线地址后填进 buffer_addr 的:

这里有个容易被忽略的点:数据页的地址关系是驱动自己记的。网卡回写描述符时只会写 length、status 等字段(并不记录这是哪个 struct page),驱动光看描述符没法反查出对应的页和 dma 句柄。所以每个驱动都维护一张影子表 rx_buffer_info[](igb 里叫 struct igb_rx_buffer),存着 page、dma、page_offset 这些元数据,下标和描述符一一对应。

一次 DMA 握手的全过程

把三者串起来,一个帧从网卡到 skb 的握手是这样走的:

一步一步说:

  1. 驱动分配一个页,dma_map_page 得到总线地址,写进某个空闲描述符的 buffer_addr,状态清零,描述符交给硬件(驱动前进 tail 指针)。
  2. 网卡收到帧,按描述符里的 buffer_addr 把帧字节 DMA 写入那一页物理内存。
  3. 网卡在描述符里回写 length,并置上 DD 位(EOP 表示这是包的最后一描述符)。
  4. 驱动的 NAPI poll 循环读描述符状态,先用 dma_rmb() 内存屏障保证看到的是网卡写回的值,再检查 DD。
  5. DD 置位 → 驱动对这页做 dma_unmap_page,用 build_skb 把它包装成 skb,交给协议栈。
  6. 驱动立刻给这个位置 refill 一个新页的地址,前进 tail,把环补满。

整圈下来,网卡和驱动不直接传数据,只传"地址"和"状态位"。数据从头到尾只在那页物理内存里待着,被 DMA 写一次、被 copy_to_user 读一次。

head 和 tail:环是怎么转起来的

描述符环是环形的,靠两个指针转:

  • head(硬件维护):网卡写完一个描述符,head 前进一格。
  • tail(软件维护):驱动 refill 一个新页,tail 前进一格。

驱动永远在 head 和 tail 之间补页。当 head 追上 tail(环满了,没有空闲描述符),新到的帧没地方放,网卡只能丢弃——这时候 ethtool -S eth0 | grep rx_missed 的丢包计数就会涨。所以 ring size 太小、收包慢,会直接体现在 rx_missed_errors 上。

ring 的下标用 index & (size - 1) 取模回绕(size 必须是 2 的幂),这就是"环形"的实现方式,不需要真把内存首尾相接。

page_pool:省掉每次的 map/unmap

上面第 1、5 步的 dma_map_page / dma_unmap_page 每个包都要做一次,在高 PPS 下是不小的开销。现代内核引入了 page_pool:数据页用完后不立刻释放,标记回收,下次 refill 直接复用同一页,跳过重新映射。相当于给 RX 路径做了个页缓存,把 per-packet 的 DMA 映射成本摊薄掉。

这也是为什么你 free 看内存时,会看到一块固定的、删不掉的内核内存——那批 ring 页被 page_pool 长期持有,等着下一轮收包。

几个能马上用上的查看命令

ethtool -g eth0                   # 看 ring 大小
ethtool -l eth0 # 看网卡队列数(channels)
ethtool -S eth0 | grep rx_missed # ring 满丢弃计数
cat /proc/slabinfo | grep skb # skb 元数据缓存

ring buffer、描述符、内存页、DMA,四者各管一摊:描述符是"索引+状态",内存页是"实际存储",DMA 是"搬运工",驱动是"调度"。搞清楚谁是谁,收包路径上那些"丢包""延迟""内存占用"的锅,才知道该扣在谁头上。

最新文章

随机文章