Skip to content

反码和:网络包怎么自己证明自己没坏

网络包在网线上跑,任何一个 bit 都可能在半路翻转——线路噪声、串扰、设备缓冲出错,这些都不是小概率。接收方拿到一个包,第一件事得问:这包在路上坏过没有?要回答这个问题,发送方得在包里夹一个"指纹",这包哪怕只坏了一个 bit,指纹就对不上,接收方重算一遍就能发现。这种指纹有个专门的名字——校验和。可网络协议要的校验和,有两个苛刻要求,普通求和满足不了,得换一套专门为它设计的算术——反码和。整个 IP 族(IP 头、ICMP、UDP、TCP)共用它这一种算法,RFC 1071 把它定下来,几十年没动过。这一篇咱们就把反码和讲透:它是什么、为什么非这么设计不可、那两个特别漂亮的性质从哪来。

先垫底:校验和是什么,网络包为什么离不了它

校验和的思路朴素得不能再朴素:拿一段数据,塞进一个函数,吐出一个固定大小的值(网络协议里通常 16 位),把这个值附在数据后面一起发出去。接收方拿到数据,用同一个函数重算一遍,跟附带的那个值比——对得上,就认为数据没坏;对不上,就扔掉重传。这里头有个天然的妥协:校验和查错,但不纠错——它告诉你"这包坏了",却不告诉你"哪里坏了、怎么修",修只能靠重传。这是个刻意的取舍,纠错要贵得多(得带足够多的冗余,把错算出来并改掉),网络协议选了便宜的那条路:查错 + 重传。

你可能要问:既然要查错,为什么不直接上更厉害的 CRC(循环冗余校验)?CRC 查错确实更强,能抓出更多花样百变的错。可它算起来也比校验和重,而网络包是海量数据,每一个收到的包都得查一遍,这笔开销乘以包数非常可观。协议设计者在这里选了"够用就好、算得便宜"的校验和——它查不尽所有错(某些精心巧合的多 bit 错它也漏),但传输中最常见的单 bit、双 bit 错它基本都能抓,而算的成本只有 CRC 的一小部分。这就是为什么 IP 族每个包头都带一个 16 位的校验和字段,而不是 CRC。

朴素做法:把数据当数加起来,取低 16 位

最直白的校验和怎么算?把这段数据切成一个个 16 位的字(两个字节算一个字),全加起来,只留最低 16 位当校验和。这能工作吗?能,大多数时候数据一变、和就变,接收方重算一比就发现。可这个朴素和有个藏不住的毛病:累加时高位会进位,普通加法把超过 16 位的那点进位直接丢了。丢一点进位,看着无所谓,它恰恰把后面两个漂亮性质一起丢了——咱们马上要的"端序无关"和"可分段合并",在普通求和里都不成立。

更深的麻烦在于:校验和还得能塞回数据里自己证明自己(下面讲),朴素和做不到这点。得换一套加法。

反码和:把进位"回卷"回去

这套专门的加法叫反码和(ones' complement sum),核心就一个动作:累加时,凡是溢出 16 位的高位进位,不丢,而是绕回来,加到低位上。这个动作有个专门的名字——进位回卷(end-around carry)。这么一来,16 位里没有一位是"装不下就扔"的:每一位产生的进位,不管低位向高位进、还是最高位溢出,最终都折回了低位,参与了最终结果。从算术上看,这等价于在"模 2¹⁶−1"的世界里做加法——所有进位都在这个模里转圈,谁也没漏掉。

为什么叫"反码"?反码(ones' complement)是一种二进制数的表示法,在反码表示下做加法,天生就带这个"进位回卷"的动作——这是反码算术的固有性质,不用额外规定。不过你要在代码里实现它,完全不必真去用反码表示数:用普通的补码加法把字一个个加上去,攒一个 32 位的部分和(普通加法在 32 位里不会溢出),然后手动把那 32 位里高出 16 位的部分折回低位加进去,反复折,直到整个和塞得进 16 位——结果跟真用反码加法一模一样。这个"反复折",就是你将在下一篇实现里看到的那个折叠循环的来历。

最后取反:为了让"自校验"成立

反码和算完,还有一个收尾动作:把结果按位取反,才得到最终的校验和。这一下取反,看着多余,其实是最妙的一笔,它让校验和能自己证明自己

道理是这样:发送方算出数据的反码和 s,取反得校验和 ~s,塞进包头。接收方拿到整个包——数据加上那个校验和字段——对这个整体再算一次反码和。因为校验和字段正好是 ~s,它跟数据本身的反码和 s 加起来正好是全 1(s 和它的反码逐位互补,每位加起来都是 1,且不产生进位),于是接收方算出的整体反码和就是全 1,取反之后是 0。检验规则因此简单到一句话:把整个包(含校验和字段)反码和取反,等于 0 就没坏,不等于 0 就坏了。

注意这一步省下了什么:接收方不用单独记住"原始数据应该是什么"、不用比对自己存的一份值——它把校验和当成数据的一部分一起算,结果为 0 就过。校验和"嵌进"了数据,自我验证,一次运算定生死。这就是那个取反动作的全部理由。

两个漂亮性质:端序无关 与 可分段合并

进位回卷这一招,不只是把丢掉的进位捡回来,它还白送了两个协议设计者特别想要的性质——这也是 RFC 1071 之所以几十年不动的根本原因。

第一个是端序无关。 同一组字节,你按大端序读成 16 位的字(高位字节在前)、还是按小端序读(低位字节在前),算出来的反码和是一样的——只要最后存进包里时统一成网络字节序(大端)。这对一个要在 x86(小端)、老式 SPARC(大端)、可切换的 ARM 之间跑来跑去的协议,是刚需:谁也不用为字节序特判,发送方怎么算、接收方就怎么算。直觉上为什么成立?大端和小端,无非是把一个字里那两个字节谁高谁低换了个个儿;而进位回卷把"低位向高位进位"和"高位溢出折回低位"对称化了,字节一高一低换个位置,进位一来一回正好抵消,和不变。普通求和没有这个对称性,换字节序结果就跟着变,撑不起这个性质。

第二个是可分段合并。 一段数据,切成几块分别算反码和,再把各自的部分和加起来折叠,结果跟一次性把整段算完完全一样。这听着像个无关紧要的算术巧合,实际上给协议留了巨大的方便:IP 包可以在途中被路由器分片(一块大包切成几个小片走),每个分片自带校验和,接收方不用等所有片到齐重算整体——每片的校验和独立有效。还有增量更新:TCP 改包头里某个字段(比如窗口大小),不必把整个包重算一遍校验和,只把"老值换成新值"产生的差值,补算进旧的校验和就行。这两种便利,都仰仗反码和"可分段合并"的性质,普通求和同样撑不起。

这一篇留下了什么

把反码和拆开看,它其实就三件事:反码加法、进位回卷、最后取反。三件事合起来,换来的是一份查错够用、算得便宜、还能零成本自检、跨字节序、跨分段的校验和——这五样要求压在一套 16 位的算术里同时满足,是它几十年不动的底气。下一篇咱们就看 Cinux 的网络栈怎么把它实现进 IPv4 头和 ICMP 的校验和里,又怎么跟 Linux 那套"按架构拆成几个原语"的工程做法对得上。

035_multi_terminal-45-gf25de18 · f25de18 · 2026-08-04