首页 / 资讯中心 / 文章详情

网卡驱动架构与数据收发路径:从环形队列到NAPI的底层原理

网卡驱动架构与数据收发路径:从环形队列到NAPI的底层原理 ★ FEATURED ARTICLE
1. 网卡驱动在数据链路层扮演什么角色1.1 先搞清楚驱动与协议栈的分工边界很多人一谈到网卡驱动第一反应是“不就是控制网卡收发包的代码吗”。这个说法没毛病但不够准确。你如果写过应用层 socket 程序会发现自己根本没碰过网卡真正收发数据的是内核协议栈而协议栈再往下才轮到驱动接手。那边界在哪里一句话说清楚协议栈负责“规则”驱动负责“搬运”。TCP 要保证顺序、处理重传、计算校验和这些都在协议栈里完成而驱动做的事情更底层它要把协议栈交给它的网络帧内核里叫 sk_buff塞进网卡的发送队列让网卡通过物理介质发出去反过来网卡收到数据后驱动要把数据从硬件队列里搬出来组装成 sk_buff再交给协议栈去解析。说白了驱动就是协议栈和网卡硬件之间的翻译官和搬运工。这里有一个特别容易混淆的概念驱动工作在哪个层严格说网卡驱动属于数据链路层实现的一部分因为它直接面对的是设备的 MAC 控制器处理的是帧frame而不是包packet。你在抓包工具里看到的 Ethernet 头、VLAN 标签都是驱动和硬件配合完成填充和剥离的。很多刚学内核网络的人会在这里绕晕明明在 socket 层发一个 TCP 段为什么到了网上就变成带 MAC 头的帧了中间加头、拆头的过程有一部分就是驱动驱动的职责。我自己的理解是可以把驱动看成一堵承重墙墙的一侧是协议栈另一侧是硬件。协议栈不管墙是用砖砌的还是用混凝土浇的它只要求墙能按要求把数据传过去硬件厂商也不关心墙外面是什么装修风格只关心有没有人来取数据和送数据。驱动就是那堵墙它的好坏直接决定了墙壁两边的交通效率。1.2 两种上下文进程上下文与中断上下文看网卡驱动源码你会发现代码里反复出现“上下文”这词。网络驱动的收发并不是一个连续的流程它被硬生生切成了好几段分别在不同的执行环境里跑这是理解整个源码架构的钥匙。发送路径大多在进程上下文执行。进程调用 write 或 send最终会经过协议栈进到驱动提供的发送函数这时候进程还在睡眠中可以执行可能阻塞的操作比如申请内存、等待 DMA 映射。但接收路径就完全不同了网卡收到数据后通过 PCIe 中断通知 CPUCPU 中断处理程序立刻接管这时候你没法睡眠也几乎不能做太重的事只能快速把数据记录下来然后让更晚一些的软中断继续处理。所以 Linux 内核把收包流程拆成“上半部硬中断”和“下半部软中断或 tasklet/NAPI”目的就是让中断处理尽可能短避免长时间屏蔽中断导致丢包。这个区分不仅是理论概念在实际调试中会直接决定你写代码的方式。比如在硬中断上下文里绝对不能用可能导致睡眠的锁比如 mutex只能使用自旋锁打印日志也要非常克制一台跑到几十万 PPS 的网卡如果在中断里每个包 printk机器立刻会卡死因为打印本身就是巨大的性能黑洞。源码里那么多让人看得头大的内存屏障、READ_ONCE/WRITE_ONCE本质上都是在协调这两个上下文之间的并发访问。2. 网卡驱动的整体架构与框架设计2.1 驱动注册与接口导出内核如何认识一块新网卡打开 Linux 内核的目录结构drivers/net/ethernet/ 下面按厂商分成一堆子目录每个厂商的驱动代码动辄几万行。如果让你从零开始读第一件事不是去读收发函数而是找“驱动是怎么被内核发现的”也就是入口函数。几乎所有网卡驱动都遵循同一个套路用 module_init 或 subsys_initcall 导出入口函数在里面调用平台总线或 PCI 总线的注册接口。以 PCI 网卡为例驱动会定义一个struct pci_driver里面最关键的两个回调是id_table和probe。id_table是一张支持列表记录这个驱动能识别哪些 PCI 设备 ID厂商 ID、设备 ID 组合当总线枚举到一块网卡时内核通过 ID 匹配找到对应的驱动然后调用probe函数。这个probe函数就是驱动初始化的主战场。它要做的事情可以列出一长串读取并验证硬件配置空间映射 I/O 内存申请中断初始化 DMA 能力分配收发环形队列的内存注册net_device_ops操作集最后调用register_netdev把设备正式注册进内核网络子系统。完成后你就能在系统里看到一个 eth0 或 ens33。这里我建议你重点记住net_device_ops这个结构它几乎就是驱动的“目录页”。里面定义了 open、stop、start_xmit、set_rx_mode、get_stats64 等几十个回调。open 和 stop 对应网卡的上线和下线start_xmit 是发送入口get_stats64 用来上报收发统计。读源码时你不必从头到尾逐行看先找到这块“目录”再按需深入效率会高很多。2.2 不同形态网卡的架构差异与共性网卡驱动并不是只有物理 PCIe 网卡一种形态。如果写虚拟化或容器网络相关的场景你会遇到另一类“虚拟网卡驱动”比如常见的半虚拟化 I/O 方案。半虚拟化方案的思路很有意思宿主机上的网卡驱动照常收包但虚拟机里的“网卡”并不是真硬件它只是与宿主机共享了一段内存用一对环形队列模拟收发。客户机驱动把要发送的数据写入共享环形队列然后通过事件通道通知宿主机后端宿主机后端从队列里取数据走真实网卡发出去。接收方向反过来。这种架构避开了设备模拟的性能损耗是虚拟化网络高性能的关键也是云厂商能让你在虚拟机里跑出高网络吞吐量的底层原因。除了半虚拟化还有纯软件模拟的虚拟网卡以及最传统的物理网卡。它们的差别虽然大但抽象出来的接口是一致的都要有初始化、发送、接收、统计、控制。正因为内核提供了net_device这个统一抽象协议栈才能用同一套代码跟不同的网卡对话。所以当你读懂了一块驱动其他形态的驱动只是在这个框架上做增删改。我经常建议想做网络底层的人哪怕以后方向是云原生、虚拟化网络也要先啃一块真实物理网卡驱动的源码。物理网卡驱动的细节最全DMA、MSI-X 中断、多队列、流分类都有把这些机制吃透后再去看虚拟化网络方案的源码会觉得豁然开朗知道为什么那些框架要那样设计。3. 核心数据结构与内存管理细节3.1 绕不开的 net_device 与 sk_buff读网卡驱动之前必须先把两个核心数据结构的角色搞清楚。一个是net_device它代表一块网络设备实体驱动通过它跟内核网络子系统打交道另一个是sk_buff它代表一个正在传输或接收中的数据包。net_device是一块很大的结构体里面塞了设备的 MAC 地址、MTU、标志位、链路状态、统计信息、指向net_device_ops的指针等。驱动在 probe 阶段会通过alloc_netdev或alloc_etherdev分配这个结构体并把私有数据跟在它后面。很多驱动会用netdev_priv宏拿到自己的私有数据区里面保存了硬件基地址、中断号、队列指针、统计计数等。这是一个很经典的内核编程手法一次分配连续内存cache 友好。sk_buff则更复杂它本质上是一个带头部空间、数据区、尾部空间的管理结构用一组指针维护数据在哪。协议栈每加一个协议头就往头部空间前面 push每剥离一个头就往前 pull。驱动关心的是data指针指向的位置以及len字段还有frags片段。在发送方向驱动要读取skb-data和len告诉 DMA 控制器从内存哪里搬运数据在接收方向驱动要把 DMA 写好的数据封装成 sk_buff再往协议栈送。如果你在源码里看到一堆skb_reserve、skb_put、skb_push之类的函数不用慌它们只是调整指针而已。驱动通常只碰data和len复杂的头部操作留给协议栈。理解了它的运作方式后你才能明白为什么零拷贝收发这么难——因为要避免拷贝就得让 DMA 直接读写 sk_buff 的内部缓冲区前提是缓冲区必须满足 DMA 的对齐和连续性要求。3.2 收发环形队列与内存预分配现代网卡的收发通常基于环形队列也叫描述符环。这根队列是网卡与驱动共享的一块内存区域里面存放的是“描述符”描述符里记录某个缓冲区在内存中的物理地址和长度。驱动和硬件通过读写这些描述符来交接数据而不是直接把网卡收到的每个包拷贝到内存中去。发送方向驱动把要发送的 sk_buff 映射到 DMA 地址然后把地址和长度填进发送描述符再通过寄存器或门铃通知网卡“有活干”。网卡搬完数据后写回一个完成标记驱动回收描述符和 sk_buff。接收方向反过来驱动预先分配好一批空缓冲区并填充接收描述符网卡收到数据后直接把内容 DMA 到这些缓冲区同时更新描述符状态告诉驱动“这个包我已经填好了”。这个机制最大的特点就是“内存预分配”。接收队列在网卡启用前就要创建好多少描述符、每个缓冲区多大都提前算好不能等收包时再临时申请内存。因为收发路径在中断和软中断里跑临时申请内存很可能是致命的——内存紧张时等待回收内存的路径会睡眠中断上下文根本不能这么做。所以驱动宁可一次性多分配一些内存也要保证收包过程中不碰内存分配。这里还有一个细节接收缓冲区大小不是拍脑袋定的。驱动会根据net_device的 MTU 和内核要求的头部预留空间来计算通常是 MTU Ethernet 头 VLAN 头 内核对齐要求的额外长度再对齐到某个粒度。如果缓冲区太小超过 MTU 的巨型帧就会被硬件丢弃或截断。很多看不懂驱动的人在这块吃亏最多看着一个十六进制的常数以为是魔法数字其实背后就是一次普通的加法加对齐。3.3 DMA 映射与缓存一致性看似不起眼却最容易出问题硬件和 CPU 看到的内存是不一样的。CPU 通过虚拟地址访问有 cache网卡通过 DMA 直接读写物理内存绕过了 cache。如果双方同时访问一块内存就可能出现数据不一致CPU 刚更新的数据还在 cache 里没写回网卡 DMA 却读到旧数据或者网卡 DMA 写入的数据还在 cache 里没失效CPU 读到的却是脏数据。驱动解决这个问题的办法是 DMA 映射 API在发送前用dma_map_single映射 skb 的数据区如果是可写缓冲区就用dma_map_page映射之后CPU 要么写回 cache要么标记为失效确保硬件能读到最新数据。发送完成后还要dma_unmap_single解除映射防止安全隐患和 cache 污染。这个机制看似简单但出错率在驱动开发里排得上前几名。常见的 bug 是映射了不解除导致内存泄漏和 DMA 地址一直被占用或者方向搞反发送方向该用 DMA_TO_DEVICE 却用了别的方向虽然大多数平台上能跑但在某些架构上就是诡异随机故障。我读源码时会特别留意 dump 函数里的 map/unmap 是否成对出现以及消费完成描述符后是否第一时间 unmap这些细节是判断一个驱动是否靠谱的重要标准。4. 数据收发路径源码级拆解4.1 发送路径从协议栈到硬件队列的完整链路发送路径的入口在协议栈的dev_queue_xmit它会找到路由指定的输出设备经过流量控制队列最终调用net_device_ops里的ndo_start_xmit。从这一刻起就进入驱动的地盘了。一个典型的驱动发送函数要做这几步先检查队列是否停用如果硬件队列满了就要netif_stop_queue然后把 sk_buff 的线性数据区和分片数据区映射成 DMA 地址接着取下一个可用的发送描述符把地址、长度、标志位填进去最后写门铃寄存器通知硬件。注意硬件真正开始搬运数据是在你写完门铃之后所以大部分驱动会用内存屏障保证描述符的内容先于门铃可见。发完之后并不代表任务结束。硬件搬运完数据会更新完成队列触发中断或由驱动选择在合适时机去轮询。在完成处理里驱动统计发送字节数、释放 sk_buff、解除 DMA 映射还要检查是否有等待重传的包接着把发送队列重新开启。这一套流程在源码里体现得非常明显前半段的ndo_start_xmit是热路径写的极尽精简后半段的完成处理往往在一些名为tx_clean或tx_reclaim的函数里。如果你看到某个驱动的发送函数里不仅做了上面的事还偷偷做了校验和计算或者协议头的预填充也不用奇怪。很多网卡支持 TCP 分段卸载和校验和卸载驱动会在ndo_start_xmit里检查 sk_buff 的特性标志如果硬件能处理就设置描述符标志并清掉 skb 里的相应标志让协议栈不再重复计算。这是“卸载”思想的体现把 CPU 能耗高的操作交给硬件做。4.2 接收路径硬中断、软中断与 NAPI接收路径比发送路径复杂一个量级因为噪声来源多还涉及中断和轮询的博弈。老式驱动的接收流程是网卡收到包DMA 写内存发中断CPU 响应硬中断在中断处理里把包从描述符队列取出转成 sk_buff然后投递到协议栈再重新分配新缓冲区放回队列。这个流程简单直接但高流量下很有问题每来一个包就触发一次中断中断频繁抢占 CPU大量时间耗在中断处理吞吐量上不去甚至产生中断风暴这就是经典的“中断接收”瓶颈。现代驱动几乎都采用 NAPI 机制。切换到 NAPI 之后网卡收到包不再直接触发软中断而是先发一个硬中断硬中断处理里只做一件事把设备的 poll 回调挂到当前 CPU 的软中断队列上然后立即返回。之后软中断周期性地调用驱动注册的 poll 函数在 poll 里批量处理已经到包的描述符一次处理多个包直到预算耗尽或队列清空。这样大幅减少了中断次数吞吐量会显著提升。读接收路径源码建议盯着这几个关键函数napi_schedule、napi_poll和驱动实现的 poll 回调。特别留意 poll 返回值这是给内核用的预算机制返回值表示这轮处理了多少包如果等于预算上限说明还有流量待处理内核会把它继续挂回队列如果小于预算说明收干净了内核会关闭本轮调度。这块写错了会导致软中断调度异常严重的会造成吞吐掉到零。4.3 GRO 与多队列现代高速网卡必须懂的两个优化同一条接收路径上还有两个高频出现的优化机制GROGeneric Receive Offload和多队列RSS。它们不是一回事但经常被放在一起讨论因为都是为了提高高带宽场景下的吞吐。GRO 的原理是把多个“同流”的包合并成一个大包交给协议栈。比如接收端在做 TCP 接收网卡每秒收到成千上万的确认包或数据包如果每个包都走一次完整的协议栈处理协议栈开销很大。GRO 在驱动通过napi_gro_receive把包交给协议栈之前尝试与之前的包合并如果匹配同一连接并且满足可合并条件时间相近、序列连续等就把新包的数据拼到旧包里面减少协议栈处理的包数量。对你做性能调优来说确认驱动开启 GRO通常能让小包场景的 CPU 占用率降一个档次。多队列RSS解决的是另一个问题单 CPU 处理所有包性能上不去的瓶颈。现代网卡有多条 DMA 队列每条队列可以绑定不同的 CPU网卡硬件根据五元组哈希来判断包属于哪个队列从而把不同连接分布到不同 CPU 上处理。驱动要做的是在初始化为每队列分配独立的 NAPI 实例、独立的中断号并将中断绑定到不同 CPU。这样多核并行收包单核瓶颈被打破吞吐才能随着 CPU 核心数扩展。看源码时如果看到ethtool操作里有 RSS 相关的配置入口或者驱动里出现哈希表、间接查找表indirection table这类数据就说明这块网卡支持多队列。实际部署中配合 irqbalance 自动调整中断亲和性几乎是无脑提升性能的常规操作。5. 实战演示从零阅读一份网卡驱动源码5.1 第一步找到入口函数并画出初始化时序书读百遍不如自己走一遍。我建议你选一块相对简单的网卡驱动比如内核里某个虚拟网卡或老式简单 PCI 网卡的驱动代码量控制在几千行级别。太复杂的驱动几万行代码一打开很容易丧失耐心。入口函数的找法很简单在驱动目录里搜索module_init或pci_register_driver。从这里出发你会看到类似下面的结构static struct pci_driver my_driver { .name my_netdev, .id_table my_pci_ids, .probe my_probe, .remove my_remove, }; static int __init my_init(void) { return pci_register_driver(my_driver); } module_init(my_init);把probe函数当作主线索按顺序读读配置空间、映射 BAR 区域、设置 DMA 掩码、分配net_device、初始化私有数据、创建环形队列、申请中断、注册网络设备。每看一步对着代码注释或者手册确认一下这个函数是干什么的为什么在这时候调用。初始化时序是驱动里最容易明白的部分也是建立整体感的最快路径。5.2 第二步重点读队列初始化与缓冲区填充环形队列的初始化代码往往藏着驱动最核心的工程判断。你需要关注发送队列和接收队列各有多少描述符每个接收缓冲区大小是多少skb是怎么分配的是逐个alloc_skb还是用页拆分的有没有用到page_pool之类的缓冲池机制。接收缓冲区填充函数通常在一个循环里分配 skb 或页做 DMA 映射把地址和长度填进新描述符。这个函数在驱动运行期间不是只调一次每收一个包消耗一个缓冲区驱动在 poll 函数里就要补一个新的。所以你会发现接收路径源码总是重复出现“回收旧缓冲 分配新缓冲”的组合逻辑。如果你看到某个驱动的 producer 函数写得特别复杂十有八九是因为它在尽力复用旧的页减少alloc_skb调用次数。在读这些代码时我会顺手做一些笔记分配缓冲区的函数名是什么默认批量填充多少个有没有缓存回收机制。以一套“描述符回收-再填充”的循环为例static int my_refill_rx(struct my_ring *ring, int count) { int i; for (i 0; i count; i) { struct sk_buff *skb napi_alloc_skb(ring-napi, MY_RX_BUF_SIZE); dma_addr_t dma dma_map_single(dev, skb-data, MY_RX_BUF_SIZE, DMA_FROM_DEVICE); rx_desc[i].addr cpu_to_le64(dma); rx_desc[i].len cpu_to_le16(MY_RX_BUF_SIZE); rx_desc[i].flags cpu_to_le16(DESC_OWN); } return i; }这种代码里的隐患很多dma_map_single返回后必须判断失败如果某块内存没法映射要跳过这个描述符并做错误统计cpu_to_le64这些小端转换是给大端机器准备的很多人会忽略描述符标志位的OWN位表示所有权是归硬件还是驱动搞错会导致硬件和驱动同时踩一块内存。读代码时带着这些问题意识比你单纯看代码逻辑要有价值得多。5.3 第三步顺着中断和 NAPI 走一遍收包流程读懂了队列下一步就是收包流程。在驱动源码里搜索request_irq或napi_schedule你会找到中断处理和 NAPI poll 的入口。一个最简单的 NAPI 收包流程是这样的static irqreturn_t my_irq_handler(int irq, void *data) { struct my_ring *ring data; if (ring-napi_enabled) napi_schedule(ring-napi); return IRQ_HANDLED; } static int my_poll(struct napi_struct *napi, int budget) { struct my_ring *ring container_of(napi, struct my_ring, napi); int work_done 0; work_done my_receive_packets(ring, budget - work_done); my_refill_rx(ring, work_done); if (work_done budget) { napi_complete_done(napi, work_done); enable_irq(ring-irq); } return work_done; }细看这套流程你会发现关键点在于budget和中断开关的配合。poll 函数每轮最多处理 budget 个包这是内核防饿死其他软中断的机制。同时硬中断在napi_schedule之后要暂时关闭防止一边轮询一边又来新中断造成中断风暴。只有 poll 函数返回的 work_done 小于 budget说明队列已清空驱动才会重新打开中断。这段代码是所有 NAPI 驱动的模板理解它之后不管换成哪家驱动你都认得出这骨架。实际阅读时我建议你打印或脑补一个收包计数器的变化中断进来一次poll 连续处理几十个包再开中断。相比一个包一次中断这个过程中省了大量上下文切换也正是 NAPI 能提升高吞吐场景性能的本质。6. 调试手段与性能问题排查6.1 先看指标再定问题改动驱动之前一定要先学会看指标。这里最常用的工具是ethtool它既能查网卡能力也能查统计信息。发送方向看tx_bytes、tx_packets和tx_dropped接收方向看rx_bytes、rx_packets、rx_dropped、rx_missed、rx_crc_errors。这些统计值能帮你快速缩小问题范围。举个例子如果rx_dropped持续增长而rx_missed基本为零那丢包大概率发生在软中断或协议栈层面不是硬件丢的。反过来如果rx_missed在涨说明硬件没有足够空闲缓冲区可用这是驱动接收队列枯竭的信号常见原因包括 NAPI 预算用尽、缓冲区分配失败、中断没有及时打开。还有一个不能忽略的指标是 CPU 软中断占比。用 top 命令按 si软中断排序如果某个 CPU 的 si 接近 100%说明中断或软中断集中在单核上多队列没有生效或者中断绑定的 CPU 不合理。此时要去检查中断亲和性设置也有可能网卡本身只支持单队列那办法只剩优化协议栈侧的 GRO 和 RPS。6.2 常见故障模式中断风暴、队列枯竭与 OOM中断风暴是我在生产环境里最常遇到的一个问题。现象是某块网卡的中断包几百毫秒内指数增长CPU 挑一个核只处理中断其他进程全部卡顿。排查思路分几步先用mpstat -I看中断分布再用cat /proc/interrupts看各中断号计数然后查驱动有没有正确的 NAPI 机制和中断抑制。如果驱动是用老式每包中断的方式收包几百兆流量顶上来基本必然出问题那就只能考虑驱动升级或换用支持中断合并coalescing的配置。队列枯竭是另一种经典故障。我遇到过一次现场吞吐上不去网卡统计rx_dropped不高但cat /proc/net/dev里协议栈层的 dropped 在涨最终定位到驱动 poll 函数的预算用尽加上新包不断进来导致napi_complete从未被调用中断一直被关软中断一直在轮询但永远处理不完。顺着这个方向排查调整预算或者增加队列数量问题就解决了。这类 bug 的特点是单纯看一段代码很难发现必须把中断、软中断和队列深度三者放在一起观察才能判断。还有一个容易被忽略的大坑是 OOM内存耗尽。如果驱动在接收路径里用alloc_skb分配失败通常返回 NULL驱动的收包循环要能优雅处理这个情况跳过当前描述符并把旧缓冲区保留给硬件等待下一次重试。很多粗糙的驱动在这里直接回收缓冲结果就是丢包。更严重的如果驱动没有做分配失败的统计你连排查的方向都没有。所以读源码时遇到alloc_skb、napi_alloc_skb、page_pool_alloc这类调用多看一眼返回值分支是很有必要的。最后分享一个我自己惯用的调试思路先在环回口或简单环境里验证驱动的收发路径不要直接上生产流量再用perf定位热点函数如果热点集中在dma_map/unmap尝试用页池批量映射如果热点在skb分配释放尝试减少 skb 数量把语义改成更大粒度的页缓存。当你能从指标到源码再到修改验证这条链路跑顺网卡驱动的门就算是真正踏进来了。我个人做驱动的这几年最大的体会是不要指望从第一行看到最后一行。读源码的姿势应该是“带着问题找代码”先看入口再看队列最后沿着收发路径走一遍遇到不懂的结构去查原型和调用者。这套方法看起来慢实际上是最快的。下一次当你拿到一块全新的网卡硬件需要从零写驱动时你会发现脑子里那套“模板”已经成型了剩下的只是往里面填厂商特有的硬件细节而已。
阅读完成 · 觉得有帮助?
咨询建站