
我手头这个活儿是这样的某天在调试一台虚拟化宿主机的网络性能虚拟机里跑出来的吞吐数据只有物理网卡标称值的三分之一。顺着内核的网络路径一层层翻下去最后问题指向的不是网卡本身而是驱动对中断和队列的处理方式。运行起来之后有个很深的体会Linux 网络设备驱动开发跟字符驱动完全是两套思路想靠改两个 ioctl 的心态去碰它大概率会被数据路径上的各种异步交互磨到崩溃。这篇文章不打算从网卡硬件原理讲到协议栈源码太宽泛也没法落地。我想按照一条实际驱动从无到有、再被调出问题的完整链路聊聊网络设备驱动里那些绕不开的结构、收发路径上的关键抉择以及我踩过的一些坑。适合刚准备写第一个网络驱动的人也适合那些已经在调网络性能、却被莫名其妙的丢包和低吞吐困住的人。1. 网络驱动与字符驱动的分水岭它管理的是连接不是文件很多写字符驱动的人都容易陷入同一个思维定式设备节点、read、write、ioctl按部就班。但网络设备驱动从根上就不一样它的最终呈现形态不是一个 /dev 下的文件节点而是一个类似于 eth0 那样的命名设备。你在用户空间通过 socket 收发数据数据会经过协议栈最终和这个设备的 ndo_start_xmit 回调打交道而不是通过 open 和 write 操作一个文件描述符。1.1 驱动在收发链路里到底管到哪要写网络驱动第一步得把职责边界画清楚。驱动不是协议栈它不需要知道 TCP 怎么重传也不需要干预路由查找。驱动真正要做的是让协议栈想发出的数据能够变成硬件能够发送的格式以及把硬件收到的电信号/比特流还原成协议栈可以处理的 skb。所以收发两条链路的核心工作可以这么概括发送方向内核网络子系统调用 ndo_start_xmit把 sk_buff 交给驱动驱动负责把这段数据送到设备的发送队列触发硬件发送并在发送完成后回收资源、更新统计。接收方向硬件收到报文后通过中断或轮询机制通知驱动驱动把报文数据连同元信息组装成 sk_buff交给网络协议栈。这个边界决定了你写代码时的注意力分配。你不需要研究 TCP 窗口但你必须理解自己的网卡描述符环有多大、中断频率能压到多低、DMA 缓存一致性怎么处理。丢包问题有相当一部分不是协议栈的问题而是驱动在中断路径和 NAPI 调度上没配合好。1.2 net_device 的生命周期从分配、注册到注销所有网络驱动的核心对象只有一个struct net_device。要创建一个设备实例不能靠手写 malloc标准做法是通过alloc_netdev或者更好用的alloc_etherdev。内置以太网驱动语义比如它已经帮你设置好了默认的以太网头部开销和一组通用的 ethool 行为。初始化顺序很讲究。先分配结构体再设置netdev_ops然后注册。注册时register_netdev会让这个设备进入内核的网络设备列表用户立刻就能用ip link set或ifconfig看到它。不过这里有个容易踩的细节注册不代表连接已经就绪。设备还要等链路检测通过然后驱动调用netif_carrier_on用户才能看到 state UP 或者 LOWER_UP 这样的状态。注销时你就理解为什么字符驱动的卸载即删节点在这里不够用了。网络设备的注销必须考虑还有没有正在进行的收发不能直接清内存。需要先netif_stop、netif_carrier_off等到所有队列静止、所有发送请求都已返回再unregister_netdev并free_netdev。顺序错了轻则内核告警重则系统崩溃。2. 把数据面跑起来从描述符环到 start_xmit 的接力赛刚开始写网络驱动的人最容易犯的一个错是把收发路径想成协议栈调用驱动驱动立刻读写硬件。在真实世界这通常不会按顺序发生。发送是异步的接收更是异步的。为了把这种异步关系讲清楚我先从发送链路说起。2.1 发送方向驱动做的是递交接力棒而不是全程搬运当一个 socket 要往外发包时数据从协议栈经过流量控制和排队规则之后最终会调用驱动的ndo_start_xmit。注意这个函数不是在进程上下文里稳稳当当地跑完的它可能运行在软中断上下文里也可能在进程调度期间被触发。所以这个回调里绝对不能睡眠不能等锁等半天更不能直接去读磁盘。驱动在这个函数里要做的事看起来简单但每一步都有后果从struct sk_buff拿到要发往网卡的 DMA 地址。这中间可能涉及 cache 一致性处理因为 CPU 写过的数据不一定已经在内存里可见而 DMA 引擎读的是内存地址。把这个地址和长度填入一个发送描述符。描述符通常在驱动初始化时就分配好是一个固定大小的环形队列。写硬件寄存器触发发送。如果发送队列已经满了要调用netif_stop_queue主动告诉上层我这里堵了你先别发了否则数据堆积会带来延迟和丢包。后续硬件完成中断或轮询时机里回收这个描述符更新统计再调用netif_wake_queue让上层继续发包。之所以说这是接力棒是因为 skb 在那里驱动只是把它送到起跑线硬件接下来自己跑。等跑完的时候驱动再做收尾工作。很多性能问题都出在接力的节奏上描述符环太小硬件来不及消化队列频繁停启或者发送完成处理太慢描述符被占住新包只能排队等。打个比方这就像饭店前台接单后把菜单传给后厨。后厨硬件干活是另一个节奏前台驱动能做的只是确保菜单传到了后厨做完时能通知前台清台。要是前台自己跑进后厨做菜整个大厅就没法接待新客人了。2.2 接收方向数据进门和进门之后完全是两码事接收路径通常比发送路径复杂得多。首先物理网卡收到的报文会通过 DMA 直接写进预分配的内存缓冲区。驱动在初始化时就要准备一组接收缓冲区把这些缓冲区地址放进接收描述符环。硬件每收一个包就到一个空描述符上填充数据并置位上已使用标记然后触发中断。中断来了以后驱动要做的不是立刻把整个包发给协议栈。它先要读状态寄存器搞清楚是接收中断还是发送完成中断然后尽快释放缓冲描述符、处理链路状态。这个动作必须尽可能轻。如果每收一个小包就触发一次中断CPU 会被打断到怀疑人生——所以现代网卡和内核都走向了 NAPI 模式中断只是提醒的铃真正搬数据靠轮询。我把一个很简化的虚拟网卡驱动模拟项目X的核心结构放在这里。你可以把它当成理解真实驱动的起点struct packet_buf { unsigned char *data; unsigned int len; struct net_device *dev; }; struct simdev_priv { struct packet_buf *rx_ring[RX_RING_SIZE]; unsigned int rx_head; unsigned int rx_proc; struct napi_struct napi; spinlock_t lock; };真实的网卡驱动代码比这个长几十倍因为要管理 DMA 地址、中断寄存器、环形队列索引但骨架确实类似。收包中断里固定写法是这样的逻辑static irqreturn_t simdev_rx_irq(int irq, void *data) { struct simdev_priv *priv data; napi_schedule_irqoff(priv-napi); return IRQ_HANDLED; }napi_schedule_irqoff的意思是我现在在中断上下文不方便做重活但请软中断的轮询机制尽快来处理我的数据。这个细节极其重要把一个高频的中断动作转变成低频率的批量处理。2.3 为什么不能直接在中断里把整个 skb 交给协议栈有人会问既然中断来了包里已经有数据为什么不直接netif_receive_skb一发就完事因为中断上下文里你能做的事情极其有限不能调用可能睡眠的函数不能拿普通互斥锁还要尽快返回。而一个协议栈的接收路径上牵涉到匹配 netfilter 规则、分发到各层协议、甚至唤醒用户空间进程这些动作既费时又不安全。NAPI 的价值就在这里中断只负责挂号真正看病的是进程级别的软中断上下文它可以在更宽松的环境里操作。再加上 NAPI 天然支持预算budget一次最多处理多少个包超出的留到下一次CPU 不会被单一设备卡死。你可以把 NAPI 理解成医院的分诊护士病人都到齐之后护士开始批量叫号而不是每来一个人就立刻安排手术。3. NAPI、中断合并和队列数性能优化的三个旋钮网络驱动的性能问题十有八九跟中断和轮询之间的节奏失衡有关。写驱动代码能跑通只是及格线能在高负载下不掉速不丢包才是真正见功夫的地方。3.1 如果用纯中断收包会发生什么想象一个每秒收到 50 万个 64 字节小包的网络设备假设完全不用 NAPI每个包都触发一次硬件中断。中断本身有开销——CPU 要保存现场、跑中断处理函数、再恢复现场。这个开销在空转和实际工作之间来回消耗。当包速率超过 CPU 能处理中断的速率时中断就会成为瓶颈网卡的接收缓冲区溢出然后开始丢包。CPU 看起来忙得不行但真正花在处理数据本身的时间占比却很低。所以现在的主流驱动几乎都是 NAPI 中断合并的组合中断告诉 CPU这里有事CPU 切换到轮询模式批量处理处理完一轮驱动重新打开中断继续等下一波。这样把高频中断压缩成低频提醒吞吐和 CPU 占用率都能好看得多。3.2 napi_poll 的预算代表什么意思NAPI 的核心是一个 poll 函数由驱动注册。内核在每次软中断调度时调用它同时传入一个预算值budget。预算的含义是你这一次最多能处理多少个包。常见的默认值是 64 或者更小。驱动在这个函数里循环取读取硬件或虚拟队列的收包每处理一个计数器加一直到返回预算。预算设置太小的后果处理能力被主动限制高负载下 CPU 来不及在单位时间内收完所有包等待队列出现积压延迟升高。但预算过大的问题是一个设备的处理占用了太多 CPU 时间其他设备、其他软中断任务会被明显饿着。所以这是一个典型的权衡很多内核调优场景里会在系统级 softirq 时间去分析和调整而不是无脑调大。这里有个经验如果你的应用是高并发的短连接服务并且cat /proc/net/softnet_stat里出现大量 drop 计数优先检查预算和 net.core.netdev_budget 这两个位置而不是立刻跳到抓 tcpdump 上。3.3 中断合并和队列数真实网卡场景里的两个旋钮对于真实的有中断的物理网卡ethtool -C可以调节中断合并策略。类似的概念在网卡厂商文档里叫 coalesce。它控制的是硬件多久汇报一次中断。合并得越多CPU 被打断越少但延迟会变高合并不够CPU 空转在中断切换上。这不是一个能一劳永逸的值。如果你的服务是交易类低延迟场景合并参数就要尽量保守如果是大吞吐的存储场景则可以适当放大合并窗口。多队列网卡是另一个提升性能的根本手段。一个网卡只有一个 RX 队列时哪怕有 16 个 CPU中断也会固化到一个核上。可用的手段包括中断亲和性设置比如写/proc/irq/irq/smp_affinity把中断分到不同 CPU也可以靠 RPSReceive Packet Steering在内核层面把收包软中断分摊到多个核。这些调优手段的根源逻辑只有一个单个 CPU 的软中断处理能力是有上限的多条队列就是让更多 CPU 可以并行干活。驱动开发时如果发现吞吐到了瓶颈优先确认的不是代码逻辑有没有 bug而是你的硬件队列和 CPU 的映射关系是不是合理。4. 驱动如何翻译用户空间的意图netdev_ops 与 ethtool 的幕后数据面跑通之后控制面也要跟着跑通。用户空间看到的网卡状态、修改的 MTU、设置的 MAC、查询的统计信息全部都要落回驱动实现的某一组回调函数。这部分代码虽然在吞吐路径上没有直接关系但直接影响这个驱动好不好用、好不好排查问题。4.1 netdev_ops驱动会暴露给内核的核心回调struct net_device_ops是驱动对内核能力的声明。常见的一组回调我都列在这里方便对照回调名作用注意点ndo_open打开设备开启队列、初始化 NAPI要处理好重复打开的情况ndo_stop关闭设备停止收发回收资源保证不会在另一个 CPU 还在收包时就清内存ndo_start_xmit核心发包函数必须非睡眠不能长时间自旋ndo_set_rx_mode更新组播/混杂模式等需要同步网卡的过滤规则ndo_change_mtu修改 MTU要重新计算缓冲大小谨慎检查溢出ndo_validate_addr校验地址合法性通常用通用的 eth_validate_addrndo_tx_timeout发送超时处理描述符可能被卡住需要复位设备ndo_get_stats64返回实时统计信息要保证统计的原子性我写第一个网络驱动时犯过的最蠢错误是ndo_tx_timeout没实现结果发包卡死超时后内核只有告警设备却一直瘫着。真实的商用驱动里这个回调几乎总是会实现一个软复位相关的处理。4.2 ethtool_opsethtool 命令能看到的每一行都不是白来的ethtool这个工具在排查网络问题时非常顺手但它的输出不是凭空生成的而是驱动填充的struct ethtool_ops。比如ethtool -i eth0显示 drivername、version、firmware 版本来自ethtool_get_drvinfoethtool -S eth0显示收发统计来自ethtool_get_ringparam等系列回调ethtool -P eth0显示网卡端口标识符需要驱动提供如果一个新驱动没实现 ethtool_ops用户会发现自己手里的网卡像失明一样什么信息都查不到调试难度会直线上升。所以正经要交付一个驱动ethtool_ops 不是一个可选项而是基本配置项。尤其是在多人维护的项目里协议栈工程师拿到一个驱动第一件事一定是先 ethtool -i 确认驱动版本再 ethtool -S 看丢包率谁也不想面对一个啥数据都拿不到的设备。4.3 link 状态与 carrier为什么设备 UP 了却没有网络最常见的一个现象是ip link set eth0 up返回成功但ip link show显示NO-CARRIERping 也没响应。这其实不是代码 bug而是驱动没有正确处理物理链路的 carrier 信号。链路状态由netif_carrier_on/netif_carrier_off维护。驱动通常借助硬件的 link status 事件来更新这两个调用。比如网线插入时硬件产生一个 link change 中断驱动里对应 handler 调用netif_carrier_on上层协议才愿意认为这个设备可用。如果没有正确管理 carrier会出现一些让人摸不着头脑的行为设备明明 up 了路由也配好了但邻居发现ARP完全不通抓包又看不到任何报文发出。这时候先用cat /sys/class/net/dev/carrier看看值如果是 0别再查协议栈了去查驱动里对 link 检测的处理。5. 踩坑实录一次虚拟网卡吞吐异常的完整排查链路理论讲了这么多真正能印证这些概念价值的是在一个具体问题里把它们串起来的排查过程。那次的问题出现在某虚拟化环境中的一张虚拟网卡上虚拟机内部看吞吐只有预期的五成而且越接近满速时系统响应越差。我把排查过程按顺序回忆一下这套链路同样适用于物理网卡的类似问题。5.1 第一步先从统计信息里找丢在哪一段我当时的操作顺序是这样ip link show dev eth0 ethtool -S eth0 cat /proc/interrupts | grep eth0 cat /proc/net/softnet_statip link show看到 LINK 状态没问题速度也正常。ethtool -S显示 rx_packets 和 rx_bytes 的记录和虚拟机内部流量模型基本对得上所以物理介质环节没丢。问题只能出在中断处理或协议栈入口附近的某个环节。/proc/net/softnet_stat每一列对应不同的软中断处理统计其中一项 dropped 如果持续上涨说明网卡驱动已经接收了报文但软中断处理不过来在进入协议栈之前就被丢弃了。这是第一个明确信号瓶颈在收包路径的 NAPI 环节附近。5.2 第二步确认中断和队列的映射关系接着看/proc/interrupts。那个虚拟网卡的 PCI 中断号只有一个而且对应的 CPU 始终固定在同一个核心上。整个虚拟机的收包中断都压在一个逻辑 CPU 上。这立刻解释了为什么吞吐上不去单核软中断处理速率就是上限摊到多核也没用因为中断根本不往其他核走。再往下验证我打开了队列信息ethtool -l eth0结果 Current 和 Maximum 的 combined 都是 1。说明这网卡在当前虚拟化配置下只有一个收发队列中断自然也只有一个核在承担。收敛结论吞吐瓶颈是单队列 单核软中断处理。5.3 第三步修复动作的两个方向以及验证方式方向一尽量开启多队列。如果虚拟化平台和网卡驱动支持多队列配置把 queue pair 扩到 4 个或 8 个让每个队列能绑定独立的 CPU。这是一个治本手段。方向二在系统层面用 RPS 来做软中断分发。多队列不可用或不够用的时候/sys/class/net/dev/queues/rx-0/rps_cpus可以设置允许处理这个队列收包软中断的 CPU 掩码。把掩码放宽到多个 CPU内核会把网卡上报的包分发到多个核上处理。虽然这不如原生多队列彻底但在虚拟网卡的单队列场景里往往能立竿见影。我当时验证的方式很朴素改完 RPS 掩码之后再看/proc/net/softnet_stat里 dropped 列的增长是否停滞然后用 netcat 跑持续的 TCP 流观察吞吐。结果吞吐从五成拉到了九成左右CPU 分布也更平均了。顺带说一下softnet_stat的变化不需要重启观察两三分钟就能看到趋势。5.4 这类问题在真实硬件网卡上的类似表现回到物理裸机网卡同样的现象其实非常常见。很多服务端网络瓶颈排查到最后都是中断全在一个核上的老故事。区别只是物理网卡通常支持更多队列也更依赖 BIOS 或驱动参数来决定中断亲和性。我后来再遇到性能问题固定会先看三样东西ethtool -l看队列数、cat /proc/interrupts看中断分布、cat /proc/net/softnet_stat看有没有 dropped。这三样看完问题的方向基本不会偏。如果都不对再考虑是不是 MTU 不一致导致的性能劣化、TCP 分段卸载失效、或者驱动本身的超时复位机制在反复触发。但概率上来讲队列和中断的配置打头检查始终是对的。6. 最后分享一点驱动开发的学习路径先读链路再写代码聊到这如果你正打算动手写自己的第一个网络驱动我的建议可能和很多教程不一样不要一上来就瞄着某个真实网卡的 PCI 驱动源码啃。那个学习曲线太陡了里面一半代码在应对硬件怪癖另一半在维护各种兼容性刚入手的人很容易淹死在寄存器初始化里。我的路线建议是三条第一先把数据链路跑通一次。用内核自带的虚拟网卡驱动或者一个简单的模拟设备当教材完整走一遍 alloc、register、open、start_xmit、NAPI poll 的流程。不需要对接真实硬件时你已经能把 70% 的结构和回调摸明白了。README 里可能三句话就说完了但自己动手跑一遍收发包交互才知道 NAPI 的调度次序到底是怎么回事。第二对照真实的 PCI 网卡驱动做差异阅读。你不需要逐行读懂只看它怎么分配 DMA 缓冲区、怎么处理 cache that、怎么设置描述符环。这时候你已经有虚拟设备的基础再看那些寄存器读写就不会觉得是乱来了。第三训练通过现象倒推驱动的排查能力。比如 tx_timeout 发生时驱动如何复位设备丢包发生在 stats 的哪个字段NAPI budget 耗尽会带来什么表现。这套能力比多背几个 API 有用得多因为工程现场遇到的问题从来不是这个 API 怎么拼而是为什么这个计数器一直在涨。写驱动这件事最大的门槛不在代码量而在于你得同时理解它上下游的世界用户空间的 socket、内核的 netfilter 和协议栈、硬件的 DMA 中断机制。任何一层没接上表现出来的都只是简单的网卡不好使或吞吐低但原因可能在很远的地方。这也是为什么网络驱动的调试往往要花掉整个项目一半的时间。把链路读通再动手这比我写过多少个驱动都更重要。