网络栈 入门
客户端跑 curl https://api.example.com/user/42。 这些字节要从 curl 进程出去、穿过内核、骑上线缆、在对端服务器进程里冒出来。 两者之间 5 层:HTTP(L7)→ TCP(L4)→ IP(L3)→ Ethernet(L2)→ 物理(L1)。 4 个 section 把栈走下去:5 层(每个 header 的代价和保护);socket 和端口(内核的 struct sock 和 bound 每个连接的 4-tuple);路由、ARP、MTU + 可交互的 packet 下行走读; 和 网卡驱动 —— ring buffer、NAPI、RSS、XDP、内核绕过。 最后是速查表。
OSI 童话 vs TCP/IP 现实 —— 真正在用的 5 层
curl https://api.example.com/user/42 跑起来时, 它的字节要跨过公网到达服务器。两个进程之间有 5 层; 每层出栈时加一个 header,入栈时剥掉。
大学教 OSI 7 层模型:物理、数据链路、网络、传输、会话、表示、应用。 是一段有用的历史。生产里你按 5 层(TCP/IP 模型)推理 —— 「会话」和「表示」层没有真实的实现;它们本该干的事被应用或 TLS 吞掉了。
层 名称 现实例子 头大小 L7 应用 HTTP、gRPC、DNS、SSH、SMTP 变长 L4 传输 TCP、UDP、QUIC 20 B(TCP)/ 8 B(UDP) L3 网络 IPv4、IPv6、ICMP 20 B(IPv4)/ 40 B(IPv6) L2 数据链路 Ethernet、Wi-Fi(802.11) 14 B + 4 B FCS L1 物理 铜、光纤、无线 不适用(线编码)
封装 —— 每层加什么
发一个对 /user/42 的 HTTP GET:
HTTP 请求体 ~500 B + TCP 头(源 / 目端口、seq、ack、flag、window) +20 B → segment 520 B + IPv4 头(源 / 目 IP、TTL、proto=6、校验和) +20 B → packet 540 B + Ethernet 头(目 MAC、源 MAC、EtherType=0x0800) +14 B → frame 554 B + 帧校验序列(CRC32,尾部) + 4 B → wire 558 B 线上 MTU 上限:标准以太网 1500 B。超过的在 L3 分片,或 TCP 下 按 MSS 切到 MTU 以下作 segmentation。
在接收端服务器上,网卡从线上抓位,驱动把一帧递上去, Ethernet 剥掉自己的 14 B 按 EtherType 派发,IP 剥 20 B 按协议号 (6 = TCP)派发,TCP 剥 20 B 按目的端口路由到监听的struct sock,应用通过 recv() 读。 每层唯一的活就是「剥掉我的 header、把剩下的递给楼上正确的邻居」。
实现它的内核子系统
Linux 内核里,下行由一串子系统实现,每个负责一层:
- Socket 层(
net/socket.c)—— 把 BSD socket API 翻译成协议相关的调用。 - TCP / UDP(
net/ipv4/tcp.c、net/ipv4/udp.c)—— 分段、序号、重传、流控(TCP); 只是长度 + 校验和(UDP)。 - IP(
net/ipv4/ip_output.c)—— 路由表查找、TTL、分片、下一跳 ARP 解析。 - qdisc(排队规则,
net/sched/)—— 每接口出口队列,决定包顺序、应用流量整形(fq、htb、codel)。 - 驱动 / 网卡 —— 把字节 DMA 到卡的 TX ring buffer; 卡把它们串行化到线上。
RX 上一切反向跑:网卡 IRQ → 驱动 NAPI poll → IP input → TCP input → socket 接收队列 → 用户 recv() 唤醒。 我们会在 section 03 的 demo 里端到端追踪这个下行。
要点。「生产代码里忘掉 OSI 7 层 —— 是 5 层: L7 应用、L4 传输(TCP / UDP)、L3 网络(IP)、L2 链路(Ethernet)、L1 物理。 每层出栈加自己的 header、入栈剥掉、把剩下的递给楼上正确的邻居。 一个典型 ~500 字节的 HTTP 请求到线上长成 ~558 字节 —— 54 字节的 header —— 每帧 MTU 封顶 1500 字节。」
Socket、端口和内核的 struct sock
Socket 是用户态对一个内核对象的句柄,这个对象持有发送 / 接收 buffer、 序号、拥塞状态,以及唯一识别一个流的 4-tuple。 线上方的所有读写最终都经过这个 struct。
curl 调 socket(AF_INET, SOCK_STREAM, 0) 时,内核分配一个struct socket 包着 struct sock(或 TCP 用的struct tcp_sock,它把 struct sock 嵌进第一个成员 —— 经典 Linux struct 继承)。这个 syscall 返回一个指向那对象的文件描述符, 和 open() 返回文件的方式一样。每次 send() /recv() / read() / write()都过 VFS 层、派发到 socket 的 file_operations、落到 TCP 特定例程上。
发送和接收 buffer
每个 socket 在内核内存里有两个每流的 buffer:
- SO_SNDBUF —— TCP 等 ACK 时
send()能持的字节数。 Linux 默认 ~200 KB;被net.core.wmem_max封顶(~4 MB)。 - SO_RCVBUF —— 从线上到达、应用还没读的字节数。 Linux 默认 ~200 KB;也是 TCP 通告接收窗口的基础。
这些上限和 TCP 的带宽时延积(BDP)交互。一条长肥网络链路 —— 1 Gbps × 100 ms RTT = 12.5 MB 在飞 —— 需要那么大的窗口, 不然吞吐塌成链路速率的一小部分。Linux 的自动调优 (tcp_rmem、tcp_wmem sysctl)通常处理这个; 手动设 SO_RCVBUF 会禁用自动调优,几乎总是个错误。
bind、listen、accept —— 服务端
服务器用 bind() 保留(本地 IP、本地端口)tuple,用listen(fd, backlog) 标为被动,然后 accept()从 accept 队列拉出已完全建立的连接。涉及两个队列:
SYN 队列 —— 半开:SYN 收到、SYN-ACK 已发、等 ACK
被 net.ipv4.tcp_max_syn_backlog 封顶
SYN flood 下,内核回退到 SYN cookie
Accept 队列 —— 完全建立:3 次握手完成、等 accept()
被 min(backlog, somaxconn) 封顶
溢出 → SYN-ACK 重传、最终 RST 或丢Accept 队列溢出是最常被漏掉的生产 bug 之一:内核悄悄丢新连接、 负载均衡器看到超时、应用啥也没记。看监听 socket 上ss -lnt 的 Recv-Q(当前 accept 队列深度)和Send-Q(配置的上限)。
4-tuple 唯一性规则
TCP 按 4-tuple 多路分解入站段:
(src IP, src port, dst IP, dst port) 任何两条「活动」连接,必须在这 4 个里至少差一个。 监听 (*, 443) 的服务器能持几百万连接,因为(src IP、src port)按客户端变。 连一个(dst IP、dst port)的客户端被自己的临时端口范围束缚。
Linux 默认临时端口范围是 32768–60999(net.ipv4.ip_local_port_range)—— 约 28K 个端口。 这也是一个源 IP 对单个(dst IP、dst port)同时出站连接的上限。 一个前端打单个后端池超过 ~28K 并发连接,会在 connect() 时撞EADDRNOTAVAIL。修法:扩宽端口范围、用多个源 IP、加更多后端端口, 或用通过 HTTP keep-alive 复用 TCP 连接的连接池。
UDP —— 同一个想法,更简单
UDP socket 也有 4-tuple、发送 buffer、接收 buffer —— 但没序号、没 ack、 没拥塞控制、没按序保证。每次 sendto() 是线上一个包 (MTU 内原子);每次 recvfrom() 恰好返回一个包。 接收 buffer 满了,内核默默丢。UDP 给你包管道;TCP 给你字节流。
epoll,readiness 信号
现代服务器有几千个 socket,不会一个个轮着读。它用 epoll_create +epoll_ctl 把所有 fd 注册进去,然后阻塞在 epoll_wait 里, 返回那个有数据就绪或 buffer 有空间的子集。Socket 层在它的接收队列拿到包、 或发送 buffer 排空时,向 epoll 实例发信号。我们在 I/O 模型 primer 里深入讲 readiness 模型 —— 这篇 primer 里知道内核把每 socket readiness 当一类一等通知暴露, 而备选方案(每 socket 一个线程)在几万连接以上不可扩展,就够了。
要点。「Socket fd 指内核 struct sock, 持发送 / 接收 buffer、TCP 状态、和 4-tuple(src IP、src port、dst IP、dst port)。 服务端:bind → listen → accept,SYN 队列和 accept 队列是悄悄的容量瓶颈。 客户端:临时端口范围把一个(dst IP、dst port)的同时出站连接封在 ~28K。 UDP 是同一个想法去掉可靠性;epoll 是你怎么同时等几千个的方式。」
IP 路由、ARP、MTU —— 包实际怎么找到路
一旦你的包离开 socket 层,它需要 3 样东西才能到线上:出去走的接口、下一跳 IP、目的 MAC。 路由回答前两个;ARP 回答第三个;MTU 限制结果能多大。
内核的路由表是按目的 IP 的最长前缀匹配。ip route show 打印它; 对每个出站包,内核找最具体的匹配前缀、用它的(接口、下一跳)对。
$ ip route show default via 10.0.0.1 dev eth0 proto dhcp metric 100 10.0.0.0/24 dev eth0 proto kernel scope link src 10.0.0.42 169.254.0.0/16 dev eth0 scope link metric 1000 目的 93.184.216.34 → 匹配 「default」 → 下一跳 10.0.0.1 经 eth0 目的 10.0.0.55 → 匹配 10.0.0.0/24 → 下一跳就是主机本身
ARP —— 内核需要 MAC
Ethernet 头要的是目的 MAC,不是 IP。ARP(地址解析协议)架桥: 「谁有 10.0.0.1?告诉 10.0.0.42。」回复(「10.0.0.1 在 00:1b:21:3a:7c:9f」) 在邻居表里缓存 ~60 秒;ip neigh show 打印它。
$ ip neigh show 10.0.0.1 dev eth0 lladdr 00:1b:21:3a:7c:9f REACHABLE 10.0.0.55 dev eth0 lladdr 52:54:00:12:34:56 STALE 10.0.0.99 dev eth0 INCOMPLETE ← 还没 ARP 回复
ARP 设计上无认证 —— 任何主机都能声称拥有任何 IP。ARP 欺骗 / 投毒利用这点:同 L2 段上的攻击者宣布「我是 10.0.0.1」、 受害者缓存更新、攻击者成了沉默的中间人。缓解手段在 L2 之上: 交换机上的动态 ARP 检查(丢可疑回复)、802.1X 端口认证, 或者干脆别把不可信主机和你服务器放同一 L2 段上。 云 VPC 通过把每个租户隔离在自己的虚拟 L2 里解决这点。
MTU 和 1500 字节天花板
Ethernet 的标准载荷是 1500 字节(MTU)。 20 B IP + 20 B TCP 下,TCP 每段能携带的最大载荷是1460 字节(MSS)。数据中心常启用jumbo frame(MTU 9000)来摊销每包开销 —— 对存储复制、内部 RPC、NVMe-over-TCP 有用。公网你呆在 1500。
大于路径 MTU 的包到达路由器,两种结果:
- IPv4、DF=0 —— 路由器把包分片成 MTU 大小、接收端重组。 实践中避免,因为分片杀性能:单个分片丢失强制整个包重传, 重组 buffer 又是 DoS 目标。
- IPv4 DF=1,或 IPv6 —— 路由器丢包、回送 ICMP「需要分片」 带下一跳 MTU。发送方缩小后续包。这是路径 MTU 发现(PMTUD)。
TCP 通过设 DF=1、握手时发现路径 MTU、然后用 MSS 让每段都在路径 MTU 内, 完全避免分片。
PMTUD 黑洞
PMTUD 依赖 ICMP Type 3 Code 4(「需要分片、DF 已设」)到达发送方。 一个错配的防火墙挡掉所有 ICMP —— 在「安全加固」模板里令人沮丧地常见 —— 悄悄吞掉这些消息。症状:小回复瞬间到、大回复挂住。 发送方永远重传同一个太大的包。修法是允许至少 ICMP Type 3、 或依赖 PLPMTUD(打包层 PMTUD,RFC 4821), 它从探测丢失推断 MTU 而非依赖 ICMP。
端到端走一遍
下面的 demo 展示同一个包在客户端下行 5 层、在服务端上行 5 层, 下行每步加 header、上行每步剥掉。看 size 列 —— 500 B 的 HTTP 到线上变 558 B, 然后在服务端进程里被拆回 500 B。
要点。「IP 路由按目的 IP 最长前缀匹配挑出口接口和下一跳网关。 ARP 把那下一跳 IP 翻成 Ethernet 头要的 MAC。MTU(标准 1500 B、jumbo 9000 B)限制帧大小。 TCP 通过路径 MTU 发现避免分片 —— 最常见的 bug 是防火墙挡掉它依赖的 ICMP, 产生一个小请求工作、大请求挂住的 PMTUD 黑洞。」
驱动、ring buffer、NAPI、RSS、XDP 和内核绕过
栈底是大多数工程师从来不看的那块 —— 也是决定你服务器封顶在 10 万还是 1000 万包每秒的那块。 Ring buffer、中断合并、每队列 CPU 亲和性,以及 eBPF / 内核绕过出口。
网卡和内核在主存里一对 ring buffer 上相会:
- RX ring —— 一个 descriptor 数组,每个指向内核分配的 buffer (通常 2 KB)。网卡持有「就绪」descriptor;包到时,把字节 DMA 进 buffer、推进尾指针。
- TX ring —— 反过来同样的想法:内核写指向出站包 buffer 的 descriptor、 网卡 DMA 出来串行化。
典型环大小 256 到 4096 项;你用 ethtool -G eth0 rx 4096 调。 当内核没及时清空、RX ring 满了,网卡丢包、并在ethtool -S eth0 里以 rx_dropped 或rx_missed_errors 可见的计数器加 1。负载下那计数器上涨, 就是「机器跟不上」的铁证。
从中断到 NAPI
2003 年之前,每个收到的包都触发一次硬件 IRQ。10 Gbps 下意味着 ~80 万 IRQ / 秒 —— 光中断开销就饱和了 CPU。修法是 NAPI(「新」API,现已 20 岁):
- 第一个包到 → 网卡触发 IRQ。
- 驱动的 IRQ 处理器禁掉那队列后续的 IRQ、调度一个 softirq。
- softirq 在进程上下文里跑
napi_poll,每次调用清空多达budget(默认 64)个包。 - ring 空时,NAPI 重新使能 IRQ。如果包还在快速到达, 循环保持在轮询模式、IRQ 保持关 —— 一点每包 IRQ 代价都没。
这和上一层的 epoll 同一个想法:负载下从每事件中断切到摊销轮询。top 里 softirq 计数升到两位数,正是 NAPI 干它该干的事; 只在单个 CPU 钉在 100% %si 时才担心。
RSS —— 跨核心散开
Receive Side Scaling 是让单张快网卡在多核机器上能用的网卡特性。 网卡有多个 RX ring(每队列一个,通常每 CPU 一个);接收时把包 4-tuple 哈希、 掩码挑队列、DMA 进那队列的 ring、IRQ 持那队列的 CPU。 内核只在 CPU N 上为队列 N 跑 napi_poll。同流 → 同队列 → 同 CPU → 热的 L1/L2 缓存、无核间同步。
诊断用 cat /proc/interrupts | grep eth0 —— 如果所有包堆在一个 CPU 上,RSS 配错了(或你看到了 4-tuple 拆不开的一头大象流)。 没足够队列的网卡的软件回退是 RPS(Receive Packet Steering), 内核在软件里做哈希、通过 IPI(处理器间中断)派发。
XDP —— 驱动层的 eBPF
XDP(eXpress Data Path)让你在驱动里面挂一个 eBPF 程序, 在任何 sk_buff 分配或协议处理之前。程序在原始包 buffer 上跑、返回一个判决:
XDP_DROP —— 立即丢(DDoS 清洗、ACL 丢) XDP_PASS —— 继续走正常栈 XDP_TX —— 从同一网卡反弹出去(负载均衡器) XDP_REDIRECT —— 送到另一张网卡或 AF_XDP socket
驱动里 XDP_DROP 每包代价 ~50 纳秒 —— 比在 iptables 里丢便宜数量级。 Cloudflare 的 L3 DDoS 清洗、Facebook 的 Katran L4 负载均衡器、 Cilium 的容器网络都用 XDP。限制:没飞行中的 TCP 状态、没分片包、 只装得下 BPF 程序的(现代 verifier 100 万条指令)。
内核绕过 —— DPDK、AF_XDP
如果连 XDP 都不够快,你完全跳过内核。DPDK(数据平面开发套件)通过 UIO 或 VFIO 把网卡绑到用户态驱动; 应用在钉住的 CPU 上忙循环轮询 ring buffer。每包延迟从 ~3 µs(内核栈)降到 ~300 ns(DPDK), 一核能撑 ~1400 万 pps 的 64 字节包 —— 10 Gbps 的线速。 交易系统、NFV 设备、高端防火墙住这里。
AF_XDP 是内核的答复:一种特殊 socket 类型, 应用把 RX/TX ring 映射进自己地址空间、驱动直接 DMA 进应用内存、完全绕过 sk_buff。 接近 DPDK 的吞吐,运维痛苦少得多(无网卡独占、无自定义驱动)。
注意:内核绕过意味着你来实现 TCP、重传、拥塞控制、ARP。 对存储复制和交易,值。对典型 web 服务器,内核栈已经能撑 ~100 万 pps / 核 —— 远低于大多数应用的瓶颈。
要点。「网卡和内核在 RAM 里的 RX/TX ring buffer 上相会; 满 ring 在 ethtool -S 里以 rx_dropped 显示。 NAPI 通过在负载下切到轮询来摊销中断。RSS 把 4-tuple 哈希到队列、把队列钉到 CPU、 把单张快网卡变成 N 条独立接收流水线。XDP 在驱动里跑 eBPF 实现 <100 ns 的丢; DPDK 和 AF_XDP 在那都太慢时完全绕过内核。」
速查表
6 道值得能冷讲清楚的核心问题、5 个 code review 时一眼看出来的红旗。
包走过的 5 层是啥、每层加什么?
L7 应用(HTTP,变长,名字和方法);L4 传输(TCP 20 B / UDP 8 B,端口 + 可靠性); L3 网络(IPv4 20 B,源 / 目 IP + TTL);L2 数据链路(Ethernet 14 B + 4 B FCS,目 / 源 MAC); L1 物理(线编码,1500 B MTU)。~500 B 的 HTTP 请求在线上 ~558 B。 OSI 的会话层和表示层没真实实现 —— 它们本该干的事被应用或 TLS 吞掉了。
4-tuple 是啥、为啥它束缚你的连接数?
(src IP、src port、dst IP、dst port)—— TCP 按这个多路分解入站段。 任何两条活动连接必须在 4 个里至少差一个。监听 (*, 443) 的服务器能持几百万入站连接 (客户端的 src IP/port 变)。单客户端到单(dst IP、dst port)被自己的临时端口范围束缚 (Linux 默认 32768-60999,~28K)。撞上 connect() 返EADDRNOTAVAIL。修法:扩宽端口范围、多源 IP、多后端端口、或 HTTP keep-alive。
路径 MTU 发现是啥、它坏了会怎样?
TCP 设 DF(Don't Fragment)位、通过监听 ICMP「需要分片」消息发现路径上最小 MTU。 然后用 MSS 让每段都在那个大小或更小。当防火墙挡掉所有 ICMP(常见的「安全加固」错误)时, PMTUD 悄悄失败:小回复瞬间到、大回复挂住,因为太大的包在瓶颈路由器被丢、 没有通知到发送方。修法:允许 ICMP type 3,或启用 PLPMTUD(RFC 4821)。
RSS 是啥、为啥它对高吞吐服务器重要?
Receive Side Scaling —— 网卡有多个 RX 队列、把每个入站包的 4-tuple 哈希挑一个、 IRQ 钉到那队列的 CPU。同流 → 同队列 → 同 CPU → 热缓存、无核间同步。 没 RSS,所有 RX 处理堆在一个核上、单张 10 Gbps 网卡清不干净。看cat /proc/interrupts | grep eth0 —— 平坦分布意味 RSS 在工作; 一列独大意味没在。
什么时候够得到 XDP 或 DPDK?
XDP 用于你需要在正常栈之前便宜地丢或做平凡的 L2/L3 处理:DDoS 清洗 (XDP_DROP 每包 ~50 ns)、L4 负载均衡(Katran、Cilium)、或每包 ACL。 DPDK / AF_XDP 用于你需要完全内核绕过和线速转发:交易系统、NFV 设备、高端防火墙。 代价是你自己实现 TCP、重传、拥塞控制 —— 对典型 web 服务器, 内核栈每核 ~100 万 pps 已经远过应用实际的瓶颈。
什么信号表示驱动级丢包?
ethtool -S eth0 | grep -E 'drop|miss|error'显示网卡自己的计数器;负载下上涨的 rx_dropped 或rx_missed_errors 意味 RX ring 在内核能清空前满了。 交叉看 /proc/net/softnet_stat(第三列是每 CPU 丢包, 因为每 CPU 输入队列溢出)和 nstat -az TcpExtListenDrops TcpExtListenOverflows看 socket 层 accept 队列溢出。用 ethtool -G(ring 大小)、ethtool -C(中断合并)、或扩展 RSS 队列调。
Code review 5 个红旗
- 没测 BDP 就调 SO_SNDBUF / SO_RCVBUF。手动设这些会禁用 Linux 自动调优、通常伤吞吐。正确值是链路的带宽 × RTT; 其他人就别动 sysctl 默认。
- 应用对单个(dst IP、dst port)开 10 万出站连接。临时端口范围把你封在 ~28K。生产上会拿到
EADDRNOTAVAIL。 用 HTTP keep-alive 的连接池、或散到多目的 / 源 IP。 - UDP 代码没显式处理消息边界。每次
recvfrom()恰好返回一个数据报或丢 buffer 尾部字节 (有 MSG_TRUNC 标)。如果代码假设「读到 N 字节为止」, 你就在 UDP 形状代码里有一个 TCP 形状的 bug。 - 生产 tcpdump 在热路径上没 BPF 过滤。没过滤的
tcpdump把每个包都拷到用户空间, 每包代价轻松 10×。至少传个 BPF 表达式 (tcp port 443),让内核来过滤拷贝。 - 边缘上完全挡 ICMP。路径 MTU 发现悄悄坏、邻居可达性检测坏、traceroute 坏。 至少允许 ICMP type 3(目的不可达)、type 4(源抑制)、type 11(超时)。 「挡所有 ICMP」是 1990 年代的安全套话,弊大于利。