
搞 Linux 网络开发这些年我经常被问到同一个问题内核网络栈这么大到底该从哪个文件开始读我的答案基本不变——先把net/core/dev.c从头过一遍。这个文件在 Linux 内核网络子系统里的地位我习惯用一句话概括它是整个网络栈的“总装车间”。不管你的数据包来自哪个驱动、要进哪个协议层或者要从协议层发到哪块网卡几乎都会在 dev.c 的代码路径上汇合一次。这也是为什么面试官总爱拿这个文件相关的题来试探候选人的内功懂不懂 dev.c基本就能看出你是真写过内核网络代码还是只在应用层调过 socket。这篇文章我想用比较粗糙但真实的方式把 dev.c 到底管理了什么、为什么它被称为核心入口、实际排查问题时怎么用好它一条线讲清楚。现在很多人看内核代码会用 AI 工具先定位文件比如豆包这类工具能直接告诉你路径和关键函数名。这确实能省不少事但真要把网络栈跑通、把问题定位准还是得回到源码本身。我尽量避开教科书式铺陈用调试现场会遇到的事来倒推代码逻辑这样对正在啃内核源码的人、被网卡丢包问题折磨的运维、以及想系统入门内网网络开发的工程师都更实用一些。1. dev.c 在网络栈里的定位不只是“一堆函数”1.1 为什么收发包最终都会汇到这里Linux 内核网络子系统主要由几个一级目录组成net/core放公共框架net/ipv4、net/ipv6放协议实现net/sched放流量控制net/packet放 AF_PACKET驱动则在drivers/net下面。很多人会想当然地以为核心逻辑都在ip_rcv、tcp_v4_rcv这些函数里实际上所有数据包要想从网卡驱动进入 TCP/IP 协议栈或者从协议栈出去到达网卡驱动都必须经过net/core/dev.c定义的那几条公共路径。收包方向驱动把包从硬件摘下后要么走 NAPI 的netif_receive_skb要么走老式中断的netif_rx最终在__netif_receive_skb_core里完成协议类型判断和向上分派。发包方向ip_finish_output往下调用邻居子系统后最终会进dev_queue_xmit然后依次经过 qdisc、发送队列、驱动注册的ndo_start_xmit把包真正交给硬件。可以说不管上层协议千变万化到了 dev.c 这一层大家都得按同一套规则走。这也是为什么把它叫“核心入口”它是协议无关的也是驱动无关的。网卡驱动只要实现struct net_device_ops里的操作函数就能挂在 dev.c 管理的数据通路里。协议层只要通过dev_add_pack注册一个 packet_type就能从 dev.c 收到属于自己协议号的包。这种注册机制让上下两层解耦扩展新协议、换新驱动都不需要动公共框架。1.2 dev.c 到底管了哪些事如果给 dev.c 划职责大致可以分成这么几块网络设备生命周期管理设备注册、注销、启停、命名、sysfs 与 netlink 事件通知数据包收发公共路径收包进入协议栈、发包离开协议栈的统一入口NAPI 调度与软中断处理net_rx_action、net_tx_action以及 per-CPU 的softnet_data维护协议类型与抓包钩子管理dev_add_pack、dev_remove_pack、ptype_all、ptype_base用户空间控制命令解析dev_ioctl处理 SIOCSIFFLAGS、SIOCSIFADDR 等老式 ioctl统计信息收集dev_get_stats、网卡层统计与 per-CPU 统计聚合一个初学者最容易犯的错误是以为 dev.c 只负责收发包的 memcpy。实际上设备管理在这份文件里占了很大比重。只是收发包路径更容易被关注因为丢包、延迟、吞吐问题都发生在这些路径上。我在实际带人时通常建议第一遍先只读收发包相关函数第二遍再补设备管理相关代码否则很容易被register_netdevice那一堆状态机绕晕。2. 初始化骨架从 net_dev_init 开始的世界2.1 内核启动时 dev.c 做了哪些准备工作dev.c 的起点是用户几乎感觉不到的net_dev_init。它是网络核心的初始化函数通过core_initcall(net_dev_init)注册在内核启动阶段就被调用。我以前一直以为这个函数只是初始化一个小链表真正读代码才发现里面的信息量非常大。首先它会为每个 CPU 初始化softnet_data结构。这个结构是软中断收发包的关键里面包含了待接收包的 backlog 队列、NAPI 轮询链表、processed、dropped、time_squeeze等统计字段。net_rx_action每次被触发时就是在对当前 CPU 的softnet_data做轮询。所以排查软中断问题时/proc/softnet_stat每一列基本都能对应到softnet_data里的某个字段。其次它会注册NET_RX_SOFTIRQ和NET_TX_SOFTIRQ两个软中断处理函数。你可能想不到网卡中断的下半部不是直接调用驱动函数而是触发软中断。中断上半部只负责把 NAPI 实例挂到 CPU 的 poll 链表上然后raise_softirq(NET_RX_SOFTIRQ)真正的数据收发在net_rx_action里完成。这样设计的目的很简单——避免中断上下文中做太多事导致优先级反转和中断风暴同时也方便内核批量处理数据包。此外net_dev_init还会初始化网络设备链表、注册网卡热插拔的 notifier、初始化 sysfs 相关属性等。这些前期工作看起来琐碎但缺一不可。比如设备链表是后面dev_get_by_name、dev_get_by_index这些查找函数的基础很多用户态的ip命令最终都会通过 netlink 触发这类查找。2.2 设备注册与注销register_netdevice 的完整链路一块网卡要被系统使用驱动需要先分配并填充struct net_device然后调用register_netdev实际执行的是register_netdevice。这个过程我在排查“网卡驱动加载成功但 ifconfig 看不到”时反复研究过。register_netdevice主要做几件事给设备分配 ifindex初始化 qdisc把设备加入全局链表调用通知链告诉上层子系统。注意这里有个非常容易踩坑的锁rtnl_lock。很多控制路径都要求持有这个大锁比如ip link set最终下发到 dev.c 的操作也必须先拿到它。如果你在驱动里自己写代码调用这些接口忘记加锁不定什么时候就会触发的死锁或崩溃。设备注销是另一个难啃的点。unregister_netdevice不会立刻释放内存而是把工作延迟到netdev_run_todo里执行。这是因为注销时可能还有引用计数残留在数据包处理路径上必须等 RCU 同步周期过后才能释放net_device。我之前见过一个第三方驱动在unregister_netdev后直接kfree了 net_device结果系统在下一个 RCU grace period 后访问野指针直接 panic。这就是没理解 dev.c 生命周期管理的典型反例。2.3 驱动加载到网卡上线一次完整的调用链把流程串起来看更直观。假设igb驱动加载成功完整链路大致是这样驱动模块初始化调用pci_register_driver网卡设备发现后驱动probe函数执行分配struct net_device驱动填充netdev_ops、ethtool_ops设置硬件特性调用register_netdev-register_netdevice之后由netdev_register_kobject建立 sysfs 节点并在/sys/class/net/下创建设备目录通过 netlink 向用户空间发送RTM_NEWLINK消息这也就解释了为什么 udev 能在插入网卡后立刻创建设备名并触发配置脚本。流程的核心调度者就是 dev.c 里的register_netdevice和它调用的list_netdevice函数。我建议读这部分时用ip link和udevadm monitor来做验证看内核态和用户态事件如何对应上比干读代码有效率得多。3. 收包路径从硬件中断到协议栈的公共通道3.1 两条收包路线NAPI 与 netif_rx收包路径是 dev.c 的精华也是面试必考的内容。通常我们说有 NAPI 和非 NAPI 两种收包方式。非 NAPI 是老式做法每个数据包到达后驱动直接调用netif_rx把包放到当前 CPU 的 backlog 队列然后触发NET_RX_SOFTIRQ。这种方式每个包都产生一次中断小包高吞吐场景下中断开销极大基本被现代驱动放弃了。NAPI 的设计更能体现 dev.c 的工程智慧中断进来先关中断把 NAPI 实例挂到softnet_data的 poll_list 上然后用轮询模式连续从网卡读取数据包直到预算用完或者队列空。这样做的好处是把“中断收包”变成“轮询收包”中断频率大幅下降批量处理时 CPU 缓存命中率也更高。NAPI 的调度入口就在 dev.c 的net_rx_action。这个函数从softnet_data-poll_list里取出 NAPI 实例调用驱动注册的poll方法并限制单次轮询的预算。预算相关参数包括net.core.netdev_budget和net.core.netdev_budget_usecs前者限制收包数量后者限制处理时间。你如果看到系统网络吞吐上不去但 CPU 软中断占用不高可以尝试调大netdev_budget但代价是其他软中断可能被饿死得权衡。3.2 __netif_receive_skb_core协议分发的中央处理器所有经过 NAPI 或 backlog 进入协议栈的包最终都会汇聚到__netif_receive_skb_core。这是 dev.c 里我建议读得最细的一个函数它是个“中央处理器”式的存在核心工作就是对skb做分类然后分发到不同类型的接收者。第一步是遍历ptype_all链表这些是所有包都要送的接收者。最典型的就是 tcpdump/libpcap 注册的类型还有net/bridge的一些钩子也可能出现在这里。所以你在tcpdump上看到的是已经在这个位置被复制过的包它对设备层协议栈的收发没有影响只是旁路“嗅探”。第二步是判断是否有rx_handler也就是二层协议转发钩子。桥接、macvlan、Open vSwitch 这类虚拟化组网都会注册 rx_handler。如果有__netif_receive_skb_core会把包交给它处理如果它返回特殊值包可能直接被消费不再走普通协议栈。这也是为什么在 br0 上抓包和物理网卡上抓包会看到重复包的原因之一。第三步才是遍历ptype_base根据skb-protocol字段找到对应协议的处理函数。比如ETH_P_IP对应ip_rcvETH_P_ARP对应arp_rcv。这里用的是哈希链表所以协议分发效率很高。读完这部分你才能真正理解为什么说 dev.c 是协议无关的它只认协议号不关心上层实现。3.3 RPS/RFS 与 backlog多核收包的加速器如果网卡本身不支持多队列或者 RSS 队列数不够所有包会被中断送到同一个 CPU单核会成为瓶颈。Receive Packet SteeringRPS是 ERP 在软层面的解决方案它的实现也藏在 dev.c 的收包路径里。当包进入netif_receive_skb或netif_rx时RPS 会根据包的哈希值选择目标 CPU并把包放进目标 CPU 的 backlog 队列然后触发NET_RX_SOFTIRQ。这样收包处理就分散到了多个 CPU 上。enqueue_to_backlog正是实现这一动作的关键函数。配合rps_flow_entries和/sys/class/net/dev/queues/rx-n/rps_cpus可以在多队列网卡上把 RFS 流表打开让同一个流的包尽量保持在同一个 CPU 上减少缓存漂移。我之前有台机器没有多队列网卡但跑的是高吞吐用户态服务单纯把 rps_cpus 设置成 f收包吞吐就明显改善。代价是跨 CPU 的排队锁变多。所以这不是一个无脑开的参数要根据业务特征测试。类似的还有net.core.rps_sock_flow_entries不设够值流表就装不下那么多流RFS 效果会打折扣。4. 发包路径dev_queue_xmit 与流量控制4.1 为什么所有发送都要经过 qdisc很多人以为数据包从 socket 发出去会直接进驱动实际上从协议层到驱动之间还有一个 qdisc排队规则层而 dev.c 的dev_queue_xmit就是这个层的入口。dev_queue_xmit内部会调用__dev_queue_xmit完成三件关键事选择发送队列、处理 qdisc 排队、调用驱动发送函数。选队列这一步很重要。如果没有配置 XPS它会根据skb-queue_mapping或者哈希值选择 TX 队列。如果开了 XPS会优先使用这个流绑定的 CPU 对应的队列。用ethtool -L可以看网卡有多少个 TX 队列结合 XPS 能有效降低锁竞争提高多队列网卡的发送吞吐。qdisc 就是流量控制的灵魂。默认网卡是pfifo_fast队列它内部有三个 band对应不同的优先级。如果你用tc规则配置了 HTB、TBF就是把处理逻辑插到dev_queue_xmit到驱动之间。调试时看到丢包不能只盯着驱动的tx_dropped还要看 qdisc 的统计因为流量控制丢包也会反映在tc -s qdisc show里。4.2 HARD_TX_LOCK 与 ndo_start_xmit 的真实代价真正把包交给硬件的地方是dev_hard_start_xmit它先拿到发送队列的锁然后调用网卡驱动的ndo_start_xmit。这个锁叫HARD_TX_LOCK锁定粒度极细目的是防止多个 CPU 同时往同一个队列写包。它在高并发时可能成为瓶颈所以有 XPS 做队列和 CPU 的映射尽量让每个 CPU 只管自己的队列减少锁冲突。驱动在ndo_start_xmit返回前可能已经把数据放入硬件 FIFO。如果硬件队列满驱动会返回NETDEV_TX_BUSY或者调用netif_stop_queue暂停队列等硬件发送完成产生完成中断后再通过netif_wake_queue重新唤醒。这个暂停/唤醒逻辑在 dev.c 的发送路径里也有钩子你在排查“网卡 all down 但接口显示还在正常跑”时第一反应就是留意netif_queue_stopped这个状态。用ethtool -S能直接看到tx_timeout这类计数器一旦发现持续增长基本是驱动或硬件挂住了。4.3 发送软中断 NET_TX_SOFTIRQ 的作用很多人以为发送过程完全在进程上下文完成实际上 net 核心会把一部分收尾工作放到NET_TX_SOFTIRQ软中断里做。net_tx_action主要处理两件事清理已完成发送的 skb以及重试那些因为NETDEV_TX_BUSY而被放回队列的包。驱动在硬中断里有时也会主动调用netif_tx_wake_queue触发软中断来恢复发送流程。排查性能问题时/proc/softirqs里的NET_TX计数很有参考价值。如果发现发送方向的软中断集中在某个 CPU 上说明队列选择或 XPS 配置不够合理。很多第三方驱动不支持 XPS这时可以考虑手动调整/sys/class/net/dev/queues/tx-n/xps_cpus把中断和发送处理分散开。调优本身并不复杂但前提是理解 dev.c 发包路径的调度逻辑。5. 调试手段、内核参数与踩坑实录5.1 与 dev.c 直接相关的内核参数很多网络调优文章会列一堆net.core.*参数但参数满天飞容易让人困惑。这里我挑几个和 dev.c 代码路径强相关的核心参数参数默认值作用影响路径net.core.dev_weight64单次 NAPI 轮询的收包预算net_rx_action里的 weightnet.core.dev_weight_rx_bias1让收包处理偏向更长时间或更多包调整 NAPI 收包与发包预算比例net.core.netdev_budget300每次软中断处理收包的总预算net_rx_action的总包数限制net.core.netdev_budget_usecs2000每次软中断处理的最大时长避免单次软中断占用 CPU 过久net.core.rps_sock_flow_entries0RFS 流表的大小get_rps_cpu流哈希表net.core.flow_limit_table_len4096流限制哈希表长度收包反压保护相关我见过不少人在高并发服务器上把这几个值和net.core.rmem_max、net.core.wmem_max混在一顿调最后发现 race 的还是 NAPI 预算。实际上dev_weight是每个设备单次 poll 的预算netdev_budget是整个软中断周期内所有设备共享的总预算这两个如果不匹配会出现某些设备被饿死的情况。5.2 tracepoint在 dev.c 关键节点埋点调试 dev.c 路径问题我不建议直接改代码加printk编译内核一套下来太慢。优先用内核自带的 tracepoint。常用的几个事件netif_receive_skb收包进入协议栈的入口net_dev_queue发包进入发送队列的入口net_dev_xmit驱动发送函数返回后发送路径的完成点netif_rx老式收包路径的入口napi_pollNAPI 轮询的开始和结束实际用法很简单perf record -e net_dev_xmit -e net_dev_queue -ag sleep 10 perf report或者在 tracefs 下直接开启事件echo 1 /sys/kernel/tracing/events/net/net_dev_xmit/enable echo 1 /sys/kernel/tracing/tracing_on cat /sys/kernel/tracing/trace有一次我排查“网卡吞吐上不去但 CPU 都不高”就是用napi_poll事件发现某块网卡几乎不触发 NAPI驱动一直在走非 NAPI 路径后来升级驱动才解决。学会用这些插桩点比天天猜内核变量靠谱得多。5.3 三个让我印象深刻的排查案例先说 tcpdump 抓包看不到的问题。很多人从veth或者bridge的一侧抓包发现抓不到发往主机协议栈的包。这就是 dev.c 里rx_handler的逻辑在起作用桥接模式下数据包被二层钩子提前消费根本没进普通协议栈。正确做法是在br0接口上抓或者在ptype_all钩子点抓。理解了__netif_receive_skb_core的分发顺序这个问题就非常直观。第二个案例是rx_dropped一直涨。我当时用ethtool -S看到驱动统计没有明显丢包但/proc/net/dev里的rx_dropped涨得很夸张。后来用 kprobe 在netif_receive_skb上加钩子发现是 RPS 的enqueue_to_backlog在分发时因为目标 CPU 的 backlog 队列满了而丢包。解决办法是调大netdev_budget和 backlog 队列长度同时优化 RPS 的 CPU 选择别每个包都挤到固定 CPU 上。第三个案例是发送方向锁竞争导致 CPU 飙高。多队列网卡在高并发时NET_TX_SOFTIRQ占用高查/proc/softirqs发现发送中断集中在 CPU0。这通常是 XPS 没配置或者驱动默认的 TX 队列映射不合理。我通过设置/sys/class/net/dev/queues/tx-n/xps_cpus把不同队列绑到不同 CPU最终把发送吞吐拉上来CPU 占用也平稳了。5.4 读 dev.c 源码的一些个人经验最后说点读源码的体会。dev.c 有几千行从头到尾可能很容易迷路。我建议先画出函数关系草图把入口函数列出来再往下钻。重点读这几个函数register_netdevice、netif_receive_skb、__netif_receive_skb_core、dev_queue_xmit、__dev_queue_xmit、net_rx_action、net_tx_action。把这几条链路读明白dev.c 的覆盖面就已经很全了。同时一定要结合最新内核版本看不要只看网上那些老掉牙的截图。不同内核版本之间函数名和逻辑有变化比如早期版本有一个单独的netif_receive_skb处理 RPS新版本里很多逻辑已经合并进核心函数注释也更新了不少。内核社区迭代很快以torvalds/linux上游代码为基准是最稳妥的。还有一点调试 dev.c 相关问题时CONFIG_DEBUG_NET和CONFIG_DEBUG_LIST这类内核配置能帮上大忙它们会在链表操作和网络结构体操作时做更多校验。此前一次诡异的net_device内存崩溃最后就是靠CONFIG_DEBUG_LIST抓到的非法链表操作节省了非常多排查时间。我个人这几年最大的体会是很多人喜欢直接把“内核网络”想象成高深莫测的黑盒实际上它只是比用户态多了一些并发、锁和内存管理的约束。而 dev.c 恰好是观察这些约束的最佳窗口。先用工具把路径摸清楚再结合源代码和 tracepoint 做验证整个网络栈就不再是零散知识点了。像豆包这类 AI 工具能帮你快速定位文件、解释函数签名但最终要建立起对这种关键路径的直觉还是得亲手跟踪几个数据包的完整生命周期才行。