9. 网络基础
网络服务端在接收外部请求时,往往会遭遇一系列与本地文件 I/O 大相径庭的工程现象:客户端发送了一句看似完整的 GET /index.html\r\n,服务器单次调用 read 却可能只截获到前半截片段;服务端主动断开连接后若立即重新拉起进程,bind 阶段便会遭遇刺眼的 Address already in use 错误;换作 UDP 发送 100 字节的数据报,接收端若只分配了 64 字节的缓冲区,超额部分便如同蒸发一般被内核静默丢弃。这些行为不是应用层代码的逻辑缺陷,而是下层网络协议规范白纸黑字写就的固有契约,并非应用程序内部的控制流所能单方面逆转。编写健壮的网络系统程序,前提是清晰洞察 TCP 与 UDP 在可靠性、时序与边界划分上究竟作出了哪些承诺,又保留了哪些约束。
本章通过三篇专论深入协议机制本身:9.1 剖析网络分层与数据封装,9.2 拆解 TCP 面向连接的流式行为与拥塞控制,9.3 阐释 UDP 无连接数据报的原生特征。后续第 10~12 章将以此为基石,通过 BSD 套接字、非阻塞 I/O、多路复用与事件循环步步展开。在此之前,本篇首先确立套接字在操作系统协议栈中的分界定位,并通过实际内存组帧与反码求和的实测输出,直观呈现每一层协议首部的封装开销与完整性校验机理。
1. 本章内容
本章的三篇核心专论分别聚焦协议栈的不同侧面:
- 9.1 ISO 七层模型:梳理网络通信的层级抽象与各层职责划分;以典型的网页访问请求为例,观察数据报文如何逐层附加首部完成封装,以及中间交换机与路由器分别在协议栈的哪一层终止并转发。
- 9.2 传输控制协议(TCP):深入面向连接的可靠字节流协议。剖析三次握手、序号与累积确认、滑动窗口流量控制与拥塞控制算法,阐明 TCP 为什么将传输载荷抽象为无边界的连续字节流。
- 9.3 用户数据报协议(UDP):剖析无连接的数据报投递。每次发送独立封装为单个数据报,协议层不保证送达与次序;揭示当接收缓冲区不足以容纳整个数据报时,超出的字节为何被内核强制丢弃。
这三节侧重于沉淀纯粹的协议语义与行为模型,暂不引入繁复的 C 语言套接字函数调用。随后在第 10 章中,我们将真正步入代码实现:从基础的阻塞套接字编写出 TCP 与 UDP 通信链路,在第 11 章引入非阻塞机制与 poll/epoll,并在第 12 章中将这些多路复用原语统一调度为事件循环。
2. 协议与套接字
2.1 套接字是分层的分界
用户态程序并不直接与物理网卡打交道。当应用程序调用 send 时,实际上是将一段连续的用户空间缓冲区字节拷贝至内核的套接字缓冲区;内核的网络子系统随后依据 TCP 或 UDP 的传输规约打上对应的传输层首部,再向下移交至 IP 网络层打上源/目的 IP 并评估路由与分片,接着移交给数据链路层的网卡驱动封装为以太网帧,最后由物理网卡将帧转化为线路上的高低电平或光脉冲。接收方向则严格逆向:硬件产生中断,驱动将帧逐级向上传递,每一层只负责解析并剥离属于自身的首部,将内层有效载荷递交给上一层。分层的精髓在于关注点分离:每一层协议只认同级首部的控制字段,对其包裹的内容完全持透明态度。
套接字层是用户空间与内核的分界:socket、bind、connect、send、recv 在这里进出,往下的 TCP 或 UDP、IP 与网卡驱动都在内核里。第 10 章开头会把套接字概括成一句话:它是文件描述符(第 2 章),read、write、close 都能用,只是多了一组与地址有关的调用。
程序接触的是描述符,以及从这个描述符读出或写入的字节流、数据报。协议栈在中间做了什么,只能从行为、工具和抓到的字节里观察。
套接字接口以下的协议处理都在内核里
应用程序通过套接字系统调用把数据交给内核。TCP/UDP、IP、ARP 和网卡驱动由内核实现,程序不能直接填写这些层的首部。原始套接字 (raw socket) 可以让程序自己构造 IP 层以上的首部,需要特权,本教程不涉及。
send 的返回值只说明数据从用户空间复制进了内核的发送缓冲区。它可能还躺在缓冲区里,也可能在网卡队列里,也可能正在线路上,对端是否收到无从得知。需要确认对端收到,只能在应用层让对端回一条应答。
2.2 逐层加上首部
为了看清每一层加了什么,下面这个程序不调用套接字,只是在内存里按协议规定的格式拼出一个完整的帧:以太网首部 14 字节,IPv4 首部 20 字节,UDP 首部 8 字节,负载 2 字节。完整源码在 code/系统编程/9_网络基础/encap.c,编译运行后打印各层的偏移、长度和最终的字节。
$ gcc -std=c17 -Wall -Wextra -g encap.c -o encap
$ ./encap
layer offset length
Ethernet 0 14
IPv4 14 20
UDP 34 8
payload 42 2
total 44
02 00 00 00 00 01 02 00 00 00 00 02 08 00 45 00
00 1e 00 00 00 00 40 11 f7 60 c0 a8 01 0a c0 a8
01 14 d6 d8 52 08 00 0a eb 20 68 69
receiver check: 0xffff2
3
4
5
6
7
8
9
10
11
12
13
14
| 层 | 起始偏移 | 长度 | 首部里能读出的信息 |
|---|---|---|---|
| 以太网 | 0 | 14 | 目的 MAC、源 MAC、类型 0x0800(后面是 IPv4) |
| IPv4 | 14 | 20 | 首字节 0x45:版本 4、首部 5 个 32 位字;总长 0x001e;协议号 17(UDP);源地址与目的地址 |
| UDP | 34 | 8 | 源端口 55000、目的端口 21000、长度 0x000a、校验和 0xeb20 |
| 负载 | 42 | 2 | 68 69,即 hi |
拆这个帧时要看清三处细节。第一,IPv4 的「总长」字段 0x001e 是 30,等于 IPv4 首部加 UDP 首部加负载,不含以太网首部。第二,以太网的类型字段说明后面是 IPv4,IPv4 的协议号说明后面是 UDP,两个字段各管一段,不能混用。第三,以太网要求数据段至少 46 字节,不足的用 0 补齐;这个帧只有 44 字节,实际发送到线路上时会被填充到 60 字节(不含帧尾校验),但 IPv4 的总长字段仍然是 30。
最后一行的 receiver check 是把 UDP 校验和字段一起纳入求和的结果,0xffff 表示校验通过。接收方正是这样做校验的:它不需要知道发送方算了什么,只要把所有 16 位字加起来,全 1 就说明没有差错。
2.3 校验和
每一层都有校验和,覆盖的范围不一样。IPv4 首部的校验和只覆盖首部本身,数据出错交给上层负责;UDP 与 TCP 的校验和覆盖一个伪首部(源地址、目的地址、协议号和长度)再加上各自的首部与数据,这样目的地址被改错时传输层也能发现。
算法叫 16 位反码和:把相邻两个字节按大端拼成一个 16 位数,逐个相加;最高位的进位加回最低位;最后取反,得到的 16 位就是校验和。下面的程序用 RFC 1071 里的例子和习题数据中那个 UDP 帧的 IPv4 首部各算一次,源码在 code/系统编程/9_网络基础/checksum.c。
$ gcc -std=c17 -Wall -Wextra -g checksum.c -o checksum
$ ./checksum
RFC 1071 example: checksum = 0x220d
IPv4 header checksum = 0x9b12
sum over the header including the checksum = 0xffff2
3
4
5
RFC 1071 的例子里,字节序列 00 01 f2 03 f4 f5 f6 f7 的反码和是 0xddf2,取反得 0x220d。第二行算出的 0x9b12 正是习题数据 udp_frame.txt 里那个帧的 IPv4 首部校验和字段;把校验和字段放回去再求和,结果是全 1,也就是最后一行。
反码和能检出所有单比特错误。两个比特同时翻转时,如果一处在某个 16 位字里由 0 变 1、另一处在另一个 16 位字里由 1 变 0,并且两者位于同一比特位,它们对和的贡献正好抵消,校验和不变,这种错误检不出。所以校验和只说明数据大概率没坏,不能纠错,也当不了密码学意义上的摘要。
各层校验和覆盖的范围
IPv4 首部校验和只覆盖 IPv4 首部,不包含数据。UDP 与 TCP 的校验和覆盖一个伪首部(源地址、目的地址、协议号、长度)加上各自的首部与数据。UDP 发送方算出的校验和为 0 时按规定填 0xffff;接收方看到的校验和字段为 0 表示发送方没有计算校验和。
3. 概念演进与工程衔接
3.1 协议视角与编程视角的交汇
从分层模型(9.1)确立封装与路由器转发的职责边界,到 TCP(9.2)揭示滑动窗口、确认应答与字节流的本质,再到 UDP(9.3)展示无连接数据报的独立交付与截断行为,这三节构成了理解套接字行为的理论底座。对于已有网络背景的读者,9.2 节中关于字节流边界与分帧必要性的讨论,以及 9.3 节关于接收缓冲区截断丢弃的工程现实,尤其值得仔细审视——它们将直接决定后续编写网络通信代码时的架构决策:前者关乎应用层是否需要构建严密的定界解包逻辑,后者则关乎内核接收缓冲区的尺寸估算准则。
3.2 驱动后续套接字设计的核心事实
当我们在第 10 章着手调用 POSIX 套接字接口时,下列协议层的物理现实将直接投射为代码中的具体处理:
- 端口与地址字节序:TCP 与 UDP 皆采用 16 位端口号标识宿主机内的具体进程。由于 x86/ARM 等主流主机架构通常为小端序,而网络协议统一定义大端序为网络字节序(Network Byte Order),因此在填充套接字地址结构时,必须严格通过
htons与htonl施行转换。 - 三次握手与系统调用时序:TCP 的连接建立在内核底层完成。当客户端的
connect成功返回时,底层的 SYN/ACK 握手已经宣告闭环;服务端的accept调用并不介入三次握手的数据包往返,其核心功能仅仅是从内核已完成连接队列(accept queue)中取出一个已经就绪的连接描述符。 TIME_WAIT与快速重启:主动调用close终止 TCP 连接的一方,其套接字在断开后必须在内核中经历一个约为两倍报文最大生存时间(2MSL)的TIME_WAIT锁定期。若未在套接字上开启SO_REUSEADDR选项,服务进程崩溃重启时重新bind同一端口将直接遭遇EADDRINUSE阻断。- 连续字节流与应用层分帧:TCP 协议向应用层呈现的是无边界的八位字节流(byte stream),完全不保留每次
send的调用边界。发送方的多次推送可能被内核打包合并交付,单次写入也可能在传输途中被物理 MTU 切碎拆解。在第 10 章中,我们将通过分隔符定界(如换行符)与显式长度前缀(Length-Field Framing)两种范式实现可靠的应用层解包。 - UDP 数据报截断的静默丢弃:与 TCP 的流式缓冲截然相反,UDP 每次交付的是完整的数据报(datagram)。若调用
recvfrom时传入的用户接收缓冲区小于到达数据报的实际尺寸,内核在填满该缓冲区后,会将剩余的字节永久丢弃,且默认返回值仅指示成功拷贝的截断长度,不引发任何错误告警。因此处理 UDP 数据报时,缓冲区容量必须严格依据应用协议的最大报文长度进行硬性规划。
在架构选型中,选用 TCP 还是 UDP,核心权衡在于业务对「数据丢失是否具备容忍度」:文件分发、交互式终端登录、结构化数据库查询等容不得半点差错的场景,必须依靠 TCP 的可靠交付;而实时音视频会议、高频竞技游戏物理同步等场景下,过期的历史数据毫无保留价值,重传造成的时延抖动反倒会破坏实时性,因而天然倾向于 UDP。DNS 查询之所以选用 UDP,则是因为一问一答的交互极其轻量,在超时时代价更低的是直接重发查询请求,而非建立昂贵的三次握手。
4. 自测
TCP 是字节流,不记录每次 send 到哪里结束。一次 recv 可能只拿到第一个 100 字节中的一部分,也可能把三个 100 字节一次读完;具体切分取决于发送缓冲区、MSS、接收缓冲区大小和到达时机,程序不能依赖任何一种。
视频通话晚到的帧没有播放价值,丢了就丢,重传反而增加延迟,所以用 UDP;文件下载少一个字节文件就坏了,必须靠 TCP 的确认与重传保证完整。C 错在 UDP 的用途不限于广播,D 错在文件下载自己实现可靠传输没有必要。
5. 习题
习题的数据文件在 code/系统编程/9_网络基础/exercises/data/,由同目录的 make_data.c 生成,make data 可以重建。帧文件每行 16 个十六进制字节,帧与帧之间空一行;地址和 MAC 都是编造的。
数据文件是怎么生成的
exercises/make_data.c 只用到标准 C:按 2.2 节的格式拼帧、算校验和,再把结果写成十六进制。frames_many.txt 里的 100 个帧由循环生成:第 4、21、38、55、72、89 帧(从 1 数起)的负载被改坏,第 8、31、54、77 帧的源地址被改坏,两批帧互不重叠,所以验收值都是固定的。数据不依赖任何真实网络,也不含真实主机信息。
5.1 巩固
checksum.c 只算了两组数据。再补三组,打印每组的 16 位反码和与校验和:
01 02 03 04,期望和0x0406,校验和0xfbf9;01 02 03,奇数长度,末字节补一个 0 再算,期望和0x0402,校验和0xfbfd;ff ff ff ff,期望和0xffff,校验和0x0000。
第三组说明校验和为 0 时接收方求和仍然得 0xffff。再解释:为什么接收方把校验和字段一起纳入求和,结果为 0xffff 就认为数据没错。
运行正文的 encap.c,然后改两处:负载由 hi 改成 ping,目的端口由 21000 改成 21001。重新编译运行,写出新的 payload、total 两行和字节转储,并回答:
- IPv4 首部的总长字段变成了多少?它包含哪些部分?
- 哪些字节变了,哪些没变?为什么源端口
d6 d8没有变? receiver check是否仍然是0xffff?
参考:总长应为 0x0020,字节转储的最后一行为 01 14 d6 d8 52 09 00 0c 74 b4 70 69 6e 67。
5.2 编程
写一个 showframe FILE,读入一个十六进制帧文件,按层打印起始偏移、长度和关键字段,格式如下:
frame: 52 bytes
0..13 Ethernet type 0x0800, 02:00:00:00:00:02 -> 02:00:00:00:00:01
14..33 IPv4 192.168.1.10 -> 192.168.1.20, protocol 17, total length 38, header 20 bytes
34..41 UDP 40000 -> 21000, length 18, checksum 0x64b1
42..51 payload 10 bytes "hello udp\n"2
3
4
5
对 data/tcp_frame.txt 要能打印出 TCP 的源端口、目的端口、序号、确认号、标志和数据偏移,以及负载 GET / HTTP/1.0\r\n\r\n;对 data/frames_mixed.txt 只解析第一个帧即可。验收:udp_frame.txt 的输出与上面的格式逐行对照,偏移 0、14、34、42 和长度 14、20、8、10 一项不差;TCP 帧的确认号是 889901,数据偏移是 20 字节。
写一个 cksum FILE,对每个帧验证 IPv4 首部校验和,以及 UDP 或 TCP 的校验和。对每个帧输出两行:
frame 1: IPv4 checksum 0x9b12 ok
UDP checksum 0x64b1 ok2
用 data/frames_mixed.txt 验收,四个帧的结果依次是:
- UDP 帧,IPv4
0x9b12与 UDP0x64b1都正确; - TCP 帧,IPv4
0x9b08与 TCP0x4a7b都正确; - UDP 负载里的
hello被改成hellO,IPv4 正确、UDP 错误; - TCP 帧的源地址由
192.168.1.10改成192.168.1.11,IPv4 与 TCP 都错误。
第四帧最能说明伪首部的作用:IP 地址变了,IPv4 首部校验和失败,传输层校验和也因为伪首部跟着变而失败。
data/segments.txt 是一条 TCP 连接上按到达顺序记录的 9 段字节,每行一段的十六进制。写一个 lines FILE:把各段按顺序拼成字节流,按换行切出完整行,每行打印 line N: 内容;末尾不完整的部分单独打印。
验收:共 5 条完整行,依次是 GET /index.html、GET /style.css、GET /app.js、HELLO world、QUIT,最后一行是 partial: PARTIAL LINE (12 bytes)。注意段的分界与行的分界没有关系,某一行可能跨两段。
data/payload.txt 是 512 行文本,每行以换行结尾。写一个 udpsize FILE,按行统计每一行作为一个 UDP 数据报发送时的长度(含换行),输出:
- 行数、总字节数;
- 最短与最长的数据报长度;
- 长度超过 64 字节的行数。
验收:512 行、18624 字节,最短 9 字节,最长 69 字节,超过 64 字节的有 64 行。
再写一对 UDP 程序验证截断:服务器绑定 127.0.0.1 的 23456 端口,用 64 字节缓冲区 recvfrom;客户端依次发送 10、100、1000 字节。服务器的接收循环交替使用 flags 为 0 与 MSG_TRUNC。某次运行的服务器输出是:
flags=0 recvfrom returned 10
flags=MSG_TRUNC recvfrom returned 100
flags=0 recvfrom returned 64
flags=MSG_TRUNC recvfrom returned 10
flags=0 recvfrom returned 64
flags=MSG_TRUNC recvfrom returned 10002
3
4
5
6
解释第三、六行:返回值为什么分别是 64 和 1000,多出来的字节去了哪里。flags 为 0 时程序能不能知道发生了截断?MSG_TRUNC 是从哪个调用传进去的,它改变了返回值还是只改变数据?
data/frames_many.txt 有 100 个帧,混合了 ARP、UDP 和 TCP。写一个 capture FILE,输出:
- 总帧数、ARP 帧数、IPv4 帧数;
- IPv4 首部校验和错误的帧数;
- 在 IPv4 正确的帧中,UDP 与 TCP 各多少,传输层校验和错误的有几个;
- UDP 负载的字节总数;
- 目的端口的分布,按出现次数从多到少列出。
验收:100 帧、10 个 ARP、90 个 IPv4;IPv4 校验和错误 4 帧;剩下 86 帧里 UDP 51 帧、TCP 35 帧,其中传输层校验和错误共 6 帧(UDP 3、TCP 3);这 86 帧的 UDP 负载合计 1712 字节;目的端口按全部 IPv4 帧统计,21000 出现 54 次、21001 出现 36 次。
统计传输层校验和之前要先确认 IPv4 首部正确,源地址被改过的帧两个校验和都会失败,混在一起会把错误重复计数。端口的统计不依赖校验和,因为端口字段就在首部的前四个字节里。
5.3 综合与挑战
data/tcp_log.txt 记录了一条连接上客户端发送与服务器确认的事件。写一个 tcplog FILE,按顺序读入事件,输出:
SEND记录总数、不同段的个数、重传次数;- 最终确认号、已确认的字节数、发送过但尚未确认的字节数;
- 每次
ACK相对上一次推进了多少字节。
验收:7 条 SEND,6 个不同的段,1 次重传;最终确认号 2600,从初始序号 1000 算起共确认 1600 字节,没有未确认的数据;ACK 序列是 1500、2000、2300、2500、2600。
再回答两个问题:程序靠什么判断一条 SEND 是重传?确认号 2300 表示 2300 之前的字节都到了,序号 2300、长度 200 的那个段为什么在此之后又出现了一次,接收方收到这份重复数据会怎么处理?
data/tcp_stream.txt 记录了一条连接上 9 条到达记录,每行给出序列号、长度和数据,其中一条是重复段。写一个 reassemble FILE,按序列号把段放进缓冲区,输出:
- 从第一个段开始连续、没有缺口的前缀长度,以及第一个缺口的序列号;
- 重复段的条数;
- 前缀的文本内容。
验收:前缀 72 字节,第一个缺口序列号 5072,重复段 1 条;前缀内容是 GET /alpha HTTP/1.0、GET /bravo HTTP/1.0、GET /charlie HTTP/1.0 和 GET /de,最后一行不完整。
然后把缺失的那条段补进输入,序列号 5072、长度 12、数据 6c746120485454502f312e30,重新运行。新的前缀应是 108 字节,下一个缺口序列号 5108。最后说明:这段程序里的缓冲区、缺口和重复,分别对应 TCP 协议里的哪些机制;真实的内核收到乱序段时,会不会像这样把数据先存起来。
6. 参考资料
- RFC 791,Internet Protocol:https://www.rfc-editor.org/rfc/rfc791
- RFC 894,A Standard for the Transmission of IP Datagrams over Ethernet Networks:https://www.rfc-editor.org/rfc/rfc894
- RFC 768,User Datagram Protocol:https://www.rfc-editor.org/rfc/rfc768
- RFC 9293,Transmission Control Protocol (TCP):https://www.rfc-editor.org/rfc/rfc9293
- RFC 1122,Requirements for Internet Hosts -- Communication Layers:https://www.rfc-editor.org/rfc/rfc1122
- RFC 1071,Computing the Internet Checksum:https://www.rfc-editor.org/rfc/rfc1071
- Beej Jorgensen,Beej's Guide to Network Programming,第 2 章:https://beej.us/guide/bgnet/
- Michael Kerrisk,The Linux Programming Interface,第 58 章「SOCKETS: FUNDAMENTALS OF TCP/IP NETWORKS」:https://man7.org/tlpi/toc-detailed.html
- W. Richard Stevens、Bill Fenner、Andrew M. Rudoff,UNIX Network Programming, Volume 1: The Sockets Networking API,第 3 版,Addison-Wesley,ISBN 978-0-13-141155-5。