bigywater.tech
网络协议 · 17

校验和:IP / TCP / UDP 的反码求和,手算并核对

“反码求和”听起来很抽象,实际上就是几行代码。这篇先手算一个 IPv4 头和一个 UDP 数据报的校验和,再用本机抓到的 53 个真实包核对,看它的盲区、增量更新公式,以及 UDP 里那个“0 表示没有校验和”的特例。

53 / 53
用手写的校验和算法检验 en0 上抓到的真实 IP 包:IP 头、TCP、UDP、ICMP 全部通过
2.535%
随机翻转 2 个比特时,校验和没发现的比例(模拟);CRC-32 在同样 20000 次里一次也没漏
0xffff
按公式算出 0 的 UDP 校验和,线上实际发出的值

怎么算

实测 · 本机执行输出
  头部 10 个 16 位字(校验和字段先填 0): 4500 003c 1c46 4000 4011 0000 c000 0201 c633 6407
  直接相加 = 0x2cdce(超过 16 位);把高位的进位加回低位:0x2cdce → 0xcdd0;取反得校验和 0x322f
  填回去以后,接收方把整个头再加一遍:折回后的和 = 0xffff,取反 = 0x0000(全 1 取反得 0,表示校验通过)
  真实抓包(en0)共 53 个 IP 包,用同一种方法检验:
    收到 的 ICMP: 5/5 校验通过
    收到 的 IP 头: 25/25 校验通过
    收到 的 TCP : 15/15 校验通过
    收到 的 UDP : 5/5 校验通过
    本机发出 的 ICMP: 5/5 校验通过
    本机发出 的 IP 头: 28/28 校验通过
    本机发出 的 TCP : 18/18 校验通过
    本机发出 的 UDP : 5/5 校验通过

把数据按 16 位字相加,进位折回低位,再取反。校验和字段先填 0 参与计算;接收方把含校验和的整段再加一遍,折回后是 0xffff 就通过。

伪头部与 lo0

实测 · 本机执行输出
  伪头部 12 字节: c000 0201 c633 6407 0011 0017(源 192.0.2.1、目的 198.51.100.7、0、协议 17、UDP 长度 23)
  伪头部各字之和 0x1ec63;UDP 头 + 载荷各字之和 0x3ad05;合计折回后 0x996d,取反得 UDP 校验和 0x6692
  为什么要伪头部:让校验和也能发现“包被送到了错误的主机”——只有 UDP / TCP 头和载荷的话,地址被改错了也查不出来
  lo0 上抓到 32 个 TCP 包:IP 头校验和正确 0 个(字段为 0 的 32 个);TCP 校验和正确 0 个(字段为 0 的 0 个)
  lo0 上抓到 5 个 UDP 包:IP 头校验和正确 0 个(字段为 0 的 5 个);UDP 校验和正确 0 个(字段为 0 的 0 个)
  所以 lo0 上抓到的“错误校验和”不是传输错误:本机内的包校验和被卸载 / 省略了(en0 上抓到的同样检验全部通过,见第一节)

TCP / UDP 的校验和还覆盖一个不发出去的 12 字节伪头部(源 / 目的地址、协议、长度),用来发现地址错误。lo0 上抓到的校验和是 0 或不可验证,因为本机内部的包没有真的算校验和;en0 上的全部通过。

盲区

实测 · 本机执行输出
  数据 A = 1122334455667788:校验和 0xeeaa,CRC-32 0x9118e1c2
  数据 B = 3344112255667788(前两个 16 位字 1122 / 3344 对调):校验和 0xeeaa,CRC-32 0x9e3bdbe6
  校验和相同: True;CRC-32 相同: False
  数据 C = 1234abcd0000 和 D = 1235abcc0000(一个字 +1,另一个 −1):校验和 0x41fe / 0x41fe,相同: True;CRC-32 相同: False
  用真实抓到的一个 960 字节的 TCP 段做翻转比特实验(模拟,不是网络上的实测):
    翻转任意 1 个比特(共 7680 种):校验和能发现 7680 种(全部发现:True)
    随机翻转 2 个比特(20000 次):校验和没发现 507 次(2.535%);CRC-32 没发现 0 次
    校验和漏掉的 2 比特翻转:两个比特在各自 16 位字里位置相同、一个 0→1 一个 1→0,变化互相抵消

加法可交换,16 位字的顺序被调换、一个字加一个字减等错误查不出来。模拟显示 1 比特错误必被发现,2 比特错误漏掉约 2.5%,CRC-32 一个也没漏。

增量更新

实测 · 本机执行输出
  把 53 个真实抓到的包的 TTL 减 1:增量公式算出的新校验和与整头重算的相同 53 个
  NAT 式改写:把本机发出的 18 个 TCP 包的源地址改成 203.0.113.9、源端口改成 61234(3 个 16 位字),增量更新的 TCP 校验和与整段重算的相同 18 个
  Python 里的耗时(只看相对大小):整个 20 字节头重算 0.43 µs,增量更新一个字 0.17 µs;对 960 字节的 TCP 段整段重算 6.5 µs(增量更新则与段长无关)

只改了 TTL 或 NAT 改写了地址端口的包,不需要整个重算,用 RFC 1624 的公式更新即可,53 个 TTL 与 18 个 NAT 改写的结果都与整体重算一致。

UDP 的 0

实测 · 本机执行输出
  先让末尾的 16 位字为 0,各字之和折回后是 0x41b2;把末尾字设成 0xffff − 0x41b2 = 0xbe4d,现在载荷 22 字节,各字之和折回后 0xffff,按公式算出的校验和 = 0x0000
  线上抓到 1 个包:UDP 校验和字段 = 0xffff;按接收方的检验方法(含伪头部全部相加)结果:通过
  0 和 0xffff 在反码里都表示“零”,所以把 0 换成 0xffff 不改变校验;字段 0 留给“发送方没算校验和”

IPv4 的 UDP 校验和字段 0 表示没有校验和,所以算出来是 0 就改成 0xffff 发出,线上抓到 0xffff,接收方检验仍通过。

分步演示

校验和
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64(Apple 芯片)/ Python 3.14.8 / tcpdump(libpcap)/ scapy 2.8 / dpkt / dnspython / h2 / aioquic / OpenSSL / curl 8.7.1;抓包在 lo0(环回)上做,少数实验用真实网卡 en0(这台机器上 en0 是 Wi-Fi,tcpdump 看到的是系统转成以太网格式的帧,所以文中的“以太网头”都指这个 14 字节的头)或访问公网(会在文章里注明);需要丢包 / 延迟 / 限速时,用一个只作用于 lo0 端口 9400-9499 的 dummynet 辅助脚本(netlab);所有服务端都是本机自己起的进程;时间戳取自 tcpdump 的 -tt,数字每次运行会有小幅抖动。第一节的手算用文档地址构造头部,真实包检验来自 en0 上的 53 个 IPv4 包(ARP 等非 IPv4 帧已排除);第三节的翻转比特实验是对一个真实抓到的 960 字节 TCP 段的计算机模拟,不是在网络上注入错误;第四节的耗时是 Python 单线程的相对大小
    • 没有验证:IPv6(没有头校验和,只有传输层校验和);TCP 校验和卸载在网卡上的具体行为(只看到 lo0 不可验证、en0 通过);校验和与 CRC 在真实链路错误上的实际表现;部分覆盖的 UDP-Lite;漏掉的 507 次 2 比特翻转是否都属于抵消的情形
    京ICP备2026067502号-1京公网安备11010502063487号