一起读经典 · Linux 内核网络(1):为什么我们要走进协议栈深处
「本文是「一起读经典 · Linux 内核网络」系列第 1 篇。系列以 Christian Benvenuti《深入理解 Linux 网络内幕》为骨架,以 Linux 6.x 内核为血肉,沿数据包在内核中的真实路径,逐篇拆解 sk_buff、NAPI、桥接、路由、邻居、TCP、Netfilter 等核心机制。全系列计划 18 篇,每两周一篇。」
一、三个让你睡不着觉的问题
先把三个真实场景摆在桌上。
场景一:ping 通,telnet 却不通。
服务部署完成后,ping 10.20.30.40 毫秒级回应,但 telnet 10.20.30.40 8080 卡住超时。一句"ping 通就是网通"足以让一小时排查走入歧途——服务实际并未启动。**问题在于"网通"这个词被严重模糊化:**ping 用的是 ICMP,在 IP 层就能由内核直接应答,根本不到 TCP,更不会摸到应用。telnet 走的却是完整的 TCP 路径——SYN 要进半连接队列,ACK 要进全连接队列,应用要 accept,中间任何一道防火墙、TCP wrapper、SELinux、应用 listen backlog 都能把你卡住。
严谨地说,这条路径还要再拆细:如果目标端口根本没有进程在 listen,内核通常直接回 RST,谈不上"进队列";只有存在 listen socket 时,SYN 才会创建 request_sock(半连接结构),等到第三次握手 ACK 到来,内核才生成 established child socket 进入 accept 队列。所以"应用 accept() 慢"和"三次握手能不能完成",是两件相互独立的事——这正是后面 TCP 状态机篇会反复用到的反直觉点。
这个问题的真正答案,不在"网通与否",而在"包停在了哪一层"。
场景二:tcpdump 抓到了包,应用却 recv 不到。
更让人头大的版本。tcpdump -i eth0 port 8080 明明白白看到对端发来的数据,应用层一脸茫然。哪丢了?可能丢在 Netfilter PREROUTING 上(iptables 一条规则就能让包消失),可能丢在 conntrack 的状态校验上,可能丢在 IP 校验和、TCP 校验和、socket 接收队列 SO_RCVBUF、应用 read 慢拖死了滑动窗口。
到了今天,可疑点清单还要再加四类:
- cls_bpf (tc ingress):tc 上挂的 eBPF 程序在
__netif_receive_skb_core 之后、ip_rcv 之前丢包——tcpdump 能看到,应用收不到,容器/服务网格环境里最容易踩。 - XDP_DROP:XDP 程序在驱动 RX/NAPI 层就丢弃,早于
ptype_all 投递点——这种丢包反过来,tcpdump 也看不见,是排障时最隐蔽的死角。 - VLAN 子接口不匹配:报文带 VLAN tag,但接口未配置对应 VLAN 子接口,内核在 VLAN 处理层默默丢弃。
- 校验和 / GRO / GSO / TSO offload 制造的"幻象包":抓发送方向时,tcpdump 看到的可能是网卡尚未填校验和、尚未分段的合并大包,与线上真实在线包形态不一致。
每一种"可能"都对应内核里一段具体的代码、一个具体的 sysctl、一个具体的 /proc 计数器。
「抓包不是判决书,只是证人证言。它证明包经过了某个观测点,不证明包抵达了应用。」
问题不是"看哪个日志",问题是"知道有哪些可疑点,以及哪些可疑点观测不到"。
场景三:每秒几十万 SYN 涌来,内核怎么扛?
SYN flood 是教科书上的经典话题,但教科书很少讲清:Linux 究竟是怎么扛的?半连接队列、全连接队列、SYN Cookies、tcp_max_syn_backlog、somaxconn、tcp_synack_retries ——这些参数之间是什么关系?为什么开了 SYN Cookies 之后,即使队列满了,合法 SYN 仍能建立连接?答案在 inet_csk_reqsk_queue_hash_add 与 cookie_v4_init_sequence 这些函数里,不读这些函数,你只能背参数,不能解决问题。
这三个问题有一个共同点:答案不在某一层,而散落在协议栈的每一个折角。要回答它们,你得先把内核网络协议栈的全貌看清——这正是这个系列想做的事。
二、先拿到三张地图:数据面、控制面、性能面
读 Linux 网络栈,最怕把所有名词摊成一张平面表。先把问题分到三张地图上,后面 17 篇每一篇都可以"打卡到某张地图的某一格"。
| | |
|---|
| | DMA、NAPI、GRO、L2/L3/L4、socket |
| | 路由、邻居、Netfilter、sysctl、Netlink |
| | RSS/RPS/XPS、qdisc、GSO/GRO、XDP、offload |
回过头看刚才三个场景:ping/telnet 是数据面分岔(走 ICMP 还是 TCP 路径);tcpdump/recv 是观测点与控制面策略的错位(抓包能看见 ≠ 应用能收到);SYN flood 既是性能面压力测试,也是数据面的状态机底线。
「这一篇只负责建地图,不负责把每个结构体讲完。 sk_buff 的字段留到第 02 篇;net_device 的生命周期留到第 03 篇; 中断、NAPI、GRO、路由、TCP、Netfilter 都只在这里先放坐标,后面专章展开。」
三、我们要去哪里:一个 HTTP 请求的内核之旅
用最熟悉的一个动作开局:你在终端敲下 curl http://example.com。
回车那一刻,内核里发生了什么?
为了先看清主干路径,下面这张图做了几个简化假设:域名已经解析为一个 IPv4 地址;没有 HTTP 代理;不讨论 TLS;进程位于 init_net;目标经由以太网下一跳直达。真实世界里的 DNS、IPv6、TLS、容器 namespace、代理、策略路由,会在这条主干旁长出各种分支——这些是后面专章的素材,这一篇先聚焦主干。
图 01-1:HTTP 请求的内核全景发送方向(左→右):
curl 调用 write() / sendmsg() → 进入 socket 层,数据进入 socket 发送缓冲区。tcp_sendmsg 把用户数据写入 TCP 发送缓冲区(sk->sk_write_queue)并更新 tp->write_seq;真正的分段、TCP 头封装、拥塞窗口决策在后续 tcp_write_xmit → tcp_transmit_skb 里完成。ip_queue_xmit使用已缓存的 dst_entry(路由结果对象,通常在 connect() 时由 ip_route_output_flow 经 FIB Trie 查出并存到 sk->sk_dst_cache),决定出口网卡;缓存失效时才回退到 fib_lookup。- 邻居子系统通过 ARP(IPv4)/ NDP(IPv6)查下一跳 MAC 地址。
- 驱动的
ndo_start_xmit 被调用,sk_buff 通过 DMA 描述符递交给网卡。
接收方向(右→左):
- 网卡收到帧,DMA 写入预分配的 RX Ring,产生中断(现代多队列物理网卡通常是 MSI-X,虚拟/嵌入式可能是 MSI 或 legacy)。
- 硬中断处理函数尽量精简,核心动作是
napi_schedule 把收包推迟到软中断;实际驱动还需读 ICR/清中断状态、回收 TX 完成描述符等次要工作。 NET_RX_SOFTIRQ 被唤醒,net_rx_action 调用驱动的 poll,把帧打包成 sk_buff(如果驱动启用了 native XDP,这里会先运行 XDP 程序;只有 XDP_PASS 才会继续构造普通 sk_buff,XDP_DROP/TX/REDIRECT 早于协议栈生效)。- GRO 合并:N 个相邻 TCP 段被拼成 1 个超大 sk_buff,整条 L3/L4 协议栈只需走一次,显著降低 per-packet 处理开销。
- 遍历
ptype_all 链表,给每个注册的 sniffer(如 AF_PACKET / tcpdump)投递一份 clone;ptype_base 按协议号(0x0800 = IPv4)分发到协议层。 ip_rcv 做长度、校验和、IP 选项的头部合法性校验;真正的本机/转发判定在 ip_rcv_finish → ip_route_input_noref,由路由子系统决定 dst->input 指向 ip_local_deliver 还是 ip_forward。tcp_v4_rcv 通过 __inet_lookup_skb(再分流到 __inet_lookup_established / __inet_lookup_listener)找到对应 socket,进入 TCP 状态机。- Fast Path 走通后,数据进入 socket 接收队列,触发
sk_data_ready 回调(基类默认是 sock_def_readable,TCP 在 SO_RCVLOWAT 等场景下可能走 tcp_data_ready),最终 wake_up_interruptible_sync_poll 唤醒等待在 epoll/select/recv 上的进程。
这 16 步是数据包旅程的里程碑——18 篇文章是围绕里程碑展开的专题。不必每篇恰好对应一步,横切篇(RPS/RFS/XPS、容器网络、eBPF/XDP)会跨越多个里程碑。
读法建议:顺序读,效果最好;但每篇都尽量独立成文,哪个问题困扰你,直接翻到那一篇也行。
四、读经典:为何"读经典"还能读出新东西
写"读经典"系列,绕不开 Christian Benvenuti 的《Understanding Linux Network Internals》(中文译本《深入理解 Linux 网络内幕》)。这本 2005 年 O'Reilly 出版的书,是少数几本以完整网络栈为主线的英文专著之一——并不是说没有其他好书(Stevens、Comer、Rosen 各有各的角度),而是说能把"接收/发送两条路径从驱动到 socket 都串起来"的,二十年来仍数它最系统。
但这本书有一个绕不开的事实:它的内核基准是 2.6.x——大约二十年前。
二十年里,Linux 网络栈发生了什么?
- NAPI 加入了 threaded 模式(5.12+),
ksoftirqd 不再是唯一选择。 - 默认 qdisc 从
pfifo_fast 换成了 fq_codel(4.12+,且受 mq 包装、虚拟设备 noqueue 等条件影响),抗 bufferbloat 是新课题。 - 全局 IPv4 路由缓存(
rtable hash)在 3.6 起被移除,现代内核以 FIB Trie 与 dst 结果对象为主。 - per-CPU 引用计数(
pcpu_dev_refcnt)替换了 atomic_t refcnt,大幅降低 cache line 竞争。 io_uring(5.1+)把应用 I/O 从阻塞/epoll 模型推进到 SQ/CQ 双环模型,在批量提交、SQPOLL、registered buffer/files 等场景下显著减少系统调用与上下文切换。- XDP(4.8+)、AF_XDP(4.18+,5.4 起补齐
XDP_USE_NEED_WAKEUP,5.11 引入 busy polling) 把"在协议栈之前处理包"变成了主流。 - 容器化把
netns、veth、CNI 推上了网络栈的最前排。 - nftables 成为现代 Netfilter 规则框架的主线之一;许多发行版默认把
iptables 命令转接到 nft 后端,但生产环境里 iptables-legacy、iptables-nft、原生 nftables 仍可能并存。 - 拥塞控制由此分化为 loss-based 与 model-based 并存的格局:CUBIC 仍是 loss-based(只是把 Reno 的线性窗口换成三次函数,适应高 BDP);BBR 则把带宽与 RTT 估计带入主流,把"通过丢包看拥塞"换成了"建立网络模型再下手"。
如果只读 2.6 时代的经典,你会以为 rtable 哈希还是路由的核心——它已经被删了十几年。如果只追新论文、新博客,你又会丢掉协议栈"为什么这么设计"的来龙去脉——那些设计哲学并没有变。
这个系列的策略,简单粗暴:
- 以 Benvenuti 为骨架:路径思维、sk_buff 容器模型、net_device 统一抽象——这些设计哲学仍然成立。但具体实现已大幅换骨:全局
rtable hash 早在 3.6 被删,sk_buff 的 nh/h/mac 联合指针被偏移量取代,默认 qdisc 从 pfifo_fast 换到 fq_codel,atomic_t refcnt 换成 per-CPU。后续每一篇,我会明确标注:哪根骨头还在,哪根已经被换。 - 以 6.x 为血肉:每一篇结尾固定一节"今日内核的演进",把这二十年里被改过、加过、删过的东西讲明白。
- 以 Stevens 为对照:RFC 视角的协议字段验证,用《TCP/IP 详解》卷一。
- 以 樊东东、Rami Rosen 等为补充:前者用于落到具体源码行(尽管基准是 2.6.20,概念仍然有效),后者用于 Netlink、Netfilter、IPv6 等高级专题。
一句话:这不是"复述经典",是"用经典的眼睛重看今天的内核"。经典提供路径,源码决定事实。
五、打破第一个误解:协议栈不按 OSI 七层
学计算机网络,第一节课讲 OSI 七层。物理层、数据链路层、网络层、传输层、会话层、表示层、应用层。这张图考过你,也将继续考你的学生。
但当你打开 Linux 内核源码,你会发现:OSI 七层根本不是源码的组织方式。
OSI 是 ISO 在 1980 年代搞的教学模型,清晰、对称、好背诵。Linux 内核是 Linus 等几代工程师按"数据包怎么流"实打实写出来的工程产物。两者目的不同,组织方式也不同。
图 01-2:OSI 七层 vs Linux 工程实现具体地说,Linux 的网络栈是这么组织的:
- 驱动 + L2:
drivers/net/ethernet/*,处理 DMA、中断、链路层头。 - 核心调度:
net/core/dev.c,负责 NAPI、softirq、ptype 分发——这是 OSI 七层里没有的"调度层"。 - L3:
net/ipv4/、net/ipv6/,IP 路由、分片、Netfilter 钩子。 - L4:
net/ipv4/tcp_*.c、net/ipv4/udp.c,TCP/UDP 实现。 - Socket 层:
net/socket.c、net/core/sock.c,VFS 与协议栈的桥梁。 - 系统调用层:
fs/read_write.c、io_uring/。
在 OSI 七层里:
- 第 5、6 层(会话层、表示层)不是 Linux 内核网络栈的主组织边界。TLS、SSH 等加密会话通常在用户态完成;内核里 kTLS(4.17+)负责处理 TLS 记录层、MPTCP 负责多路径会话管理、SMC 负责 RDMA 会话建立——但这些是性能/安全能力的嵌入,不是把会话层/表示层原样重建出来。
- 第 4 层和第 7 层之间被"socket 层"占据,socket 层不属于 OSI 七层中的任何一层——它是 Linux 把 BSD socket API 嫁接到 VFS 上的工程产物。
- "调度层"——
net/core/dev.c 里的 __netif_receive_skb_core、net_rx_action、ptype 链表——也不在 OSI 七层里,但它是 Linux 网络栈的真正心脏。
更关键的一个反直觉点:Linux 的接收路径和发送路径并不对称。
接收路径是"硬件中断 → softirq → 协议栈逐层向上 → socket 队列 → 系统调用拷贝";发送路径是"系统调用 → socket → 协议栈逐层向下 → qdisc 队列 → 驱动 → 网卡"。两条路径的瓶颈、锁、内存管理策略都不一样。后面看 sk_buff 的克隆、看 qdisc 的入队、看 NAPI 的预算分配,会一遍又一遍看到这种不对称的影子。
图 01-3:接收/发送路径骨架所以,当你看到一个网络问题,不要本能地"按 OSI 层定位",要按"包在 Linux 路径上的位置"定位。这两个思维方式的差别,后续每一篇都会反复出现。
六、协议栈的两块基石:sk_buff 与 net_device
要把后面 17 篇的故事讲通,有两个数据结构必须先认识——它们是协议栈的"水"与"管道"。
6.1 sk_buff:数据包的"快递盒"
把 sk_buff 想成一个快递盒。
寄件人(应用)把货品装进盒子的中间一格,贴上一张写着"TCP 数据"的标签。盒子接下来要被层层封装:套上 IP 头(再贴一张大标签写"源 IP,目的 IP"),再套上以太网头(再贴"源 MAC,目的 MAC")。每一层都不是"重新做一个盒子",而是在已有盒子的外面加一层。
为了"层层加包装"这件事高效,sk_buff 的设计有一个反直觉的细节:它预留了头部空间(headroom),也预留了尾部空间(tailroom)。新分配的 sk_buff,数据指针 data 不在缓冲区开头,而是被故意往后挪——这样下层封装时不需要重新分配内存,直接往前(headroom)写新头部就行。
图 01-4:sk_buff 四指针布局只是知道 sk_buff 有四个指针不够。你还要记住一句话:sk_buff 不仅是一个缓冲区,它是一个"贯穿协议栈各层的通用容器"。
- 进入 L3,
skb_pull 把 data 向后移 14 字节,跳过以太网头,现在指向 IP 头。 - 进入 L4,
skb_pull 再把 data 向后移 20 字节(假设 IP 头无选项),现在指向 TCP 头。
这是 sk_buff "头部指针级零开销"的核心:协议层穿越只移动指针,不搬数据。
但要破除一个常见误解:这不等于"包从网卡到应用全程零拷贝"。标准 TCP 接收路径里,至少有这些拷贝绕不开:
- 网卡 DMA 把帧写入 RX page(由硬件 DMA 完成,不消耗 CPU——这步严格说不算"内核拷贝");
- 驱动 poll 时,如果不能
build_skb() 在 RX page 上直接构造 sk_buff,就要把数据拷贝进 sk_buff 的线性区或 frags; recv()必须把数据从 sk_buff 拷贝到用户态缓冲区——除非启用了 AF_XDP、io_uring registered buffer、PACKET_MMAP 这些旁路机制。
sk_buff 消除的是"协议层之间"的拷贝,不是"内核到用户态"的拷贝。后面 18 篇里讲到 AF_XDP / io_uring 时,会回头看这条不等式怎么进一步被压平。
「常见误解修正:有人说"sk_buff 的 len 等于 tail - data"。这只在数据完全线性时成立。现代 sk_buff 经常带有 frags(分片页)和 frag_list(链表),非线性长度记在 data_len 字段里,真正的总长度是 len = (tail - data) + data_len。GRO 合并出来的 sk_buff、Scatter/Gather DMA 收上来的 sk_buff,都不是线性的。详细解剖留到第 02 篇。」
sk_buff 还有一个让初学者绕不过的细节:克隆。skb_clone() 分配一个新的 struct sk_buff 结构体(约 224 字节),复制所有元数据指针,但共享 skb->head 指向的底层数据缓冲区(包括线性区和 frags)。两个 sk_buff 各自有独立的 data/tail 指针——这让 tcpdump 抓包不影响协议栈推进。这里有两层引用计数:新 sk_buff 的 users=1(管的是 sk_buff 头本身),数据区的 skb_shared_info->dataref 加 1(管的是底层数据)。这两个计数器经常被混为一谈,我们到第 02 篇专章拆解。
6.2 net_device:网络设备的"门牌"
把 net_device 想成公司里的"部门门牌"。
你寄一封内部邮件,信封上写"市场部 - 张三"。邮件中心不关心市场部是几楼几号、有几个工位、用什么咖啡机,它只看门牌上的"市场部"三个字,把信送到那扇门口。门后的事,门后的部门自己处理。
Linux 内核就是这样对待网卡的。无论你是物理网卡 eth0、回环 lo、桥 br0、虚拟网卡 veth0、隧道 tun0,在内核眼里都是同一个抽象——struct net_device。协议栈调用 dev_queue_xmit(skb, dev) 时,只关心 dev 的"门牌"(netdev_ops 虚函数表),不关心门后是真硬件还是软件桥。
net_device 上有几十个字段,但你第一次只需要认这六个:
name:"eth0"、"br0"、"lo"——ip link show 看到的那个名字。mtu:最大传输单元,以太网默认 1500,jumbo frame 可以到 9000。state:__LINK_STATE_START(管理 up)、__LINK_STATE_NOCARRIER(物理 down)等位标志,合起来决定 ip link 看到的状态。netdev_ops:虚函数表的指针。整张表上最重要的一个是 ndo_start_xmit——发包的入口。qdisc:绑定的队列规则。默认 qdisc 不能一句话写死:多队列物理网卡的 root qdisc 是 mq,叶子 qdisc 受 net.core.default_qdisc sysctl 控制(主流发行版默认 fq_codel,4.12 起替代 pfifo_fast);lo、veth 等虚拟设备默认 noqueue。所以严谨地说:"是否 fq_codel"取决于设备类型 + 发行版配置,不是无条件的 6.x 事实。
这六个字段,基本能解释你日常用 ip、ethtool、ifconfig 命令看到的一切。其他字段——per-CPU 引用计数、网络命名空间指针、ptype_all 链表、NAPI 实例——我们到第 03 篇专章展开。
图 01-5:net_device 的统一抽象6.3 一句话总结
sk_buff 是水,net_device 是管道。后续 17 篇,讲的全是水流过管道每一段时,内核做了什么。
七、版本契约与动手实验
7.1 本系列的版本契约
写源码解读最危险的事,是把"我读到的那一版"当成"所有 Linux 的事实"。为了让这 18 篇经得起读者用别的内核版本对照,先把规则讲清楚。
| |
|---|
| Benvenuti 提供 2.6 时代的完整路线图——用来建立路径思维和对象关系 |
| 以 Linux 6.6 LTS / 6.12 Stable 源码为准,辅以 docs.kernel.org、iproute2/man |
| 不把 2.6 字段当 6.x 事实;不把发行版默认当内核默认;不把典型路径写成所有设备必经路径 |
| 以 IPv4 / TCP / 以太网直连为主线;IPv6、容器、XDP、Netfilter 只先放入口,后续专章展开 |
「内核默认、发行版默认、驱动默认、云厂商默认,是四件不同的事。」
这条规则会反复在后续文章里出现。如果以后看到我写"默认 X",请条件反射地问一句:是哪一种默认?
7.2 三个 1 分钟动手实验
文章末尾,留三个 1 分钟就能做的实验,让你和内核网络栈"碰一下手"。
实验 1:列出你机器上的所有 net_device。
cat /proc/net/dev
每一行就是一个 net_device,从 lo 到 eth0、docker0、veth*、br*,内核眼里它们都是平等的。
实验 2:看你的网卡用什么驱动、和什么固件。
ethtool -i eth0
driver 字段对应内核源码的子目录:物理网卡多在 drivers/net/ethernet/<vendor>/(igb、ixgbe、i40e、ice、mlx5_core、bnxt_en...),虚拟网卡如 virtio_net、veth、tun、wireguard 在 drivers/net/ 根目录或独立子目录里。后面第 04 篇讲网卡硬件时,我们会回到它们。
实验 3:查接口收发统计与丢包。
ip -s link show eth0
输出的 RX errors 与 RX dropped 是排查接收侧丢包的入口线索,不是结论。
errors 多由硬件 / 帧级异常引起(CRC、frame too long、MII 链路抖动)。dropped 的来源就丰富了:netdev_max_backlog 满、内存分配失败、VLAN 未配置、Bridge 学习失败、XDP_DROP / eBPF 程序丢弃、策略丢弃……
要进一步缩范围,需要结合 ethtool -S eth0(驱动级硬件计数)、/proc/net/softnet_stat(每 CPU softirq 计数)、nstat(全协议栈计数)和抓包位置一起看。详细排障思路留到第 05 篇 NAPI 与第 18 篇排障实战展开。
下篇预告:
第 02 篇,我们正式打开"快递盒"。sk_buff 的四指针怎么动?headroom 究竟该预留多少?skb_clone 共享数据时如何避免脚踩脚?frags 与 frag_list 各自负责什么场景?这些问题的答案,藏在 include/linux/skbuff.h 的几百行里——我们一行一行读。
如需提前热身,可先浏览此文件:
「https://elixir.bootlin.com/linux/v6.6/source/include/linux/skbuff.h」
挑战题:为什么 skb_clone 之后,两个 sk_buff 的 data 指针可以独立移动,但修改数据内容会互相影响?这个"指针独立、数据共享"的设计,解决了什么实际问题?
下周见。
延伸阅读
- Christian Benvenuti, Understanding Linux Network Internals, O'Reilly, 2005, Chapter 1-3
- Robert Love, Linux Kernel Development, 3rd ed., Chapter 13
- 官方文档:
docs.kernel.org/networking/napi、docs.kernel.org/networking/skbuff、docs.kernel.org/admin-guide/sysctl/net - 内核源码入口:
include/linux/skbuff.h、include/linux/netdevice.h、net/core/dev.c - 在线核验:
https://elixir.bootlin.com/linux/v6.6/source/ —— 文中任何函数名、结构体字段都可直接搜索定位