拓冰建站拓冰建站
首页 / 资讯中心 / 正文

IP校验和原理与实现:反码求和、增量更新及工程避坑指南

干这行久了你会发现很多看似不起眼的“老代码”其实藏着最扎实的设计思想。IP校验和Checksum就是其中一个它不复杂却在整个TCP/IP协议栈里默默承担着差错检测的职责。IPv4头部的差错检测、ICMP报文完整性校验、TCP/UDP的伪头部校验底层全都用它。这篇文章我就带你把它的原理、手算过程、C语言实现、工程优化以及调试时踩过的坑一次性聊透适合刚接触网络编程的人也适合那些写了很久业务代码但没机会深究协议细节的朋友。1. IP校验和到底在解决什么问题1.1 一个包从网线到协议栈怎么知道头没坏先想一个场景一个IP数据包在网络上经过路由器转发每一跳路由器都要读取目的IP地址来决定下一跳方向。如果这个IP包在传输过程里发生了比特翻转路由器读到的是一个被改坏的地址轻则把包转发到错误的地方重则直接死循环。更麻烦的是如果TTL字段被写坏可能出现环路里的包永远不被丢弃最终把网络资源耗尽。所以协议栈必须有一种成本极低的办法在接收端检查“头部是否完好”。注意这里的范围限定是头部。IPv4的设计者没有给整个IP数据报做完整性保护只保护了IP头本身。为什么因为数据部分的可靠性是上层协议TCP、UDP的责任IP层只需要确保自己赖以转发的头字段是可信的。这个设计边界很清晰也直接决定了校验和算法覆盖哪些字节。具体来说IPv4头部在没有Options字段时是20字节校验和算法会把这20字节按16位为一组划分逐组累加取反码后写入头部的第11、12字节Header Checksum字段。接收端做同样的累加如果把校验和字段也算进去最终所有16位字的累加结果应该是全1也就是0xFFFF。如果结果不是0xFFFF说明头部在传输途中被破坏这个包会被直接丢弃不会交给上层协议。1.2 为什么偏偏是“反码求和”不是CRC也不是MD5这是初学者最容易问的问题现代协议里有CRC32这么成熟的算法有MD5、SHA这种高强度的哈希IP校验和为什么选了一个看起来不太“安全”的求和方案原因有三个每个都是工程权衡。第一算得快。路由器是高性能转发设备每秒要处理几百万个包每一个包都要过一遍校验和。如果每次都用CRC或者哈希计算开销会放大到不可接受。而反码求和只需要做16位加法硬件上几乎没有任何额外成本软件上也能通过SIMD、循环展开等手段极致优化。RFC 1071里甚至提到平均每个字节的检查成本可以低于一条指令。第二增量更新。这是反码求和最精妙的地方。一个包经过路由器时TTL会减1如果每次转发都要重新遍历整个IP头来算校验和那原本为了转发做的大量优化就白费了。反码求和支持“局部修改”只根据变化的字段就能推导出新的校验和不需要扫描整个头部。CRC虽然也可以增量更新但实现复杂度完全不在一个量级。第三反码求和有一个很特殊的性质不依赖字节序。无论在大端机还是小端机上用同样的代码计算得到的结果在网络传输层都是正确的。这一点我在后面“字节序”那一节会展开讲可以说这个特性决定了它在跨平台网络协议里的普适性。当然反码求和的检错能力确实有限。它对“单个bit翻转”基本能查出来但对“两个bit同时翻转导致数值互相抵消”的场景无能为力。不过IP层本来就只做尽力而为的差错检测真正的可靠性保障在TCP层那里的校验和覆盖范围更广配合序列号、超时重传机制一起工作。这个分层设计是理解整个协议栈的关键。2. 算法流程拆解发送端怎么算接收端怎么验2.1 发送端三步走发送端的流程其实就三步第一步把Header Checksum字段即偏移10、11的两个字节清零。这一步必须是计算前做否则等于把校验和自己的老值也算进去了结果必然错误。很多初学者写的代码里是先往缓冲区里填好整个头然后直接算校验和结果算冲了排查半天发现是忘了清零。第二步把IP头按16位一组切分逐组累加到一个32位的累加器里。注意因为16位数相加很容易产生进位进位不能直接丢掉而是要把高16位的进位回加到低16位上这个操作叫End-around Carry循环进位。比如累加过程中产生了0x1FFFF这种超过16位的结果要把高位的1拿下来加到低16位上变成0x10000如果还有进位就继续回加直到结果稳定在16位以内。第三步对累加结果按位取反得到一个16位的数写入校验和字段。这里取反的含义就是“某个数加上它的反码等于全1”所以接收端只要把所有16位字包括校验和字段自己全部累加得到0xFFFF就说明校验通过。这三步本身没有难度但有一个点容易忽略IP头长度不一定是20字节。IPv4的IHL字段表示头部长度单位是4字节普通头部IHL5也就是20字节如果带了Options字段IHL会更大。计算校验和时必须以IHL指定的实际长度为准把Options字段也包含进去。否则一个带Options的包到了接收端对方按你的方式也算不通。2.2 接收端一句代码就够接收端的验证比发送端更简单。把整个IP头包含校验和字段一起按16位一组全部累加执行完所有循环进位后结果一定是0xFFFF。如果不是直接丢包。这里有个很优雅的点发送端做的是“取反”接收端做的是“验证全1”两者并不对称但配合起来刚好。发送端计算了除校验和之外所有字段的反码和S校验和字段填的是~S接收端把S和~S相加在反码算术里就得到0xFFFF。这个设计自然得令人舒服不需要额外记录“正确值是多少”再做逐字节比较。还有一种验证方式是把累加结果取反如果取反后是0x0000也算正确。这两个写法本质一样不过是数学上绕了一个圈。实际工程里我用得最多的是直接判断累加结果是否等于0xFFFF。2.3 手算一个真实IP包把过程摊开看纸上谈兵不如真正手算一遍。假设我们要发送这样一个20字节的IPv4头十六进制45 00 00 3c 1c 46 40 00 40 06 00 00 ac 10 0a 63 ac 10 0a 0c45版本号为4IHL为520字节00TOS00 3c总长度60字节1c 46标识符40 00标志位和分片偏移40TTL6406上层协议是TCP00 00校验和字段先置零ac 10 0a 63源地址172.16.10.99ac 10 0a 0c目的地址172.16.10.12按16位切分得到10个16位数序号16位字累加过程10x45000x450020x003c0x453c30x1c460x618240x40000xa18250x40060xe18860x00000xe18870xac100x18d98循环进位后0x8d9980x0a630x97fc90xac100x1440c循环进位后0x440d100x0a0c0x4e19最终累加结果是0x4e19取反得到0xb1e6这个就是该IP头实际应该填写的校验和。验证一下接收端的视角把0xb1e6也加进去0x4e19 0xb1e6 0xffff正好是全1。整个过程逻辑闭环手算一遍之后你对这个算法的理解会比看十篇文章都深刻。3. 代码实现与工程优化从能用到好用3.1 最简可用的C语言实现RFC 1071给出了一个非常经典的参考实现几乎在所有操作系统协议栈里都能看到它的影子uint16_t checksum(uint16_t *addr, size_t count) { uint32_t sum 0; while (count 1) { sum *addr; count - 2; } // 如果头部长度是奇数最后剩下一个字节要单独处理 if (count 0) sum *(uint8_t *)addr; // 把所有高16位的进位回加直到16位以内 while (sum 16) sum (sum 0xFFFF) (sum 16); return (uint16_t)~sum; }这段代码有三个地方值得细品。第一个是为什么用uint32_t做累加器。累加10个16位数最大可能溢出到20位如果用uint16_t每次加法都会静默截断结果必然不对。用32位累加器可以容纳足够的进位最后统一折叠既保证正确性又减少了反复处理进位的次数。第二个是“末尾奇数”处理。IP头长度通常是偶数但TCP段、ICMP报文在计算伪头部校验和时数据部分不一定按16位对齐。RFC 1071规定如果最后剩一字节把它补零后当作一个16位字参与累加。这个细节在写TCP校验和时一定会遇到。第三个是循环进位的写法。while (sum 16)会一直折叠直到结果落在16位以内。这里也可以用一条sum (sum 0xFFFF) (sum 16)但如果sum是0x0001FFFF第一次折叠会得到0x10000还需要第二次折叠。所以用循环最稳妥。调用方式很简单假设ip_buf是一个填充好IP头且校验和字段为0的缓冲区struct iphdr *iph (struct iphdr *)ip_buf; uint16_t hdr_len iph-ihl * 4; // 单位是4字节 iph-check 0; iph-check checksum((uint16_t *)ip_buf, hdr_len);3.2 TTL递减时的增量更新不用重算整个头假设一个IP包经过一台路由器TTL字段从64变成63。按照最直接的办法路由器要把整个IP头重新校验一遍重新填校验和。对于核心路由器每秒几百万包的处理量这个开销不小。反码求和提供了增量更新的数学基础。已知原校验和HC以及某个字段的旧值m、新值m新的校验和HC可以用这个公式计算HC HC ~m m前提是做完整循环进位。推导思路是原校验和HC ~SS是除校验和字段外所有16位字的反码和。现在某个字从m变为m新的S S - m m新的HC ~S ~(S - m m) ~S m - m ... 这在反码算术里等价于HC HC ~m m。在TTL递减的场景里m的旧值是“TTL和协议类型”组成的16位字m则是TTL少1的对应值。写成代码// 假设IP头中TTL位于偏移8字节Protocol在偏移9字节 // 在小端机器上这两个字节拼成一个16位整数时数值是0xProtoTTL uint16_t *ttl_word (uint16_t *)(iphdr 8); uint16_t old_word *ttl_word; uint16_t new_word old_word - 0x0100; // TTL减1 uint32_t sum (uint16_t)~iphdr-check (uint16_t)~old_word new_word; while (sum 16) sum (sum 0xFFFF) (sum 16); iphdr-check (uint16_t)sum;这里0x0100的来源要说清楚。IP头在内存里的字节顺序是“TTL、Protocol”网络序下TTL在前面。在x86这种小端机器上把“TTL、Protocol”两个字节读成一个16位整数数值表现是0xProtoTTL所以TTL从64减到63相当于这个整数减少了0x0100。这个字节序的坑非常隐蔽搞反了校验和pair不上而且wireshark不会帮你找到原因。增量更新不光是TTLNAT修改源IP、负载均衡重写目的IP时只要知道某个字段的旧值和新值都能用同样的公式快速修正校验和不必重扫整个头部。3.3 性能优化与硬件卸载的几个实操细节真正的高性能网络栈里你不会只满足于“能用”。我见过几种比较主流的优化手段。第一种是32位并行累加。反码求和允许一次累加两个16位字也就是按32位读取数据累加。因为进位不会跨出32位范围最后折叠时再把高16位和低16位合并处理。用上uint32_t一次读4字节循环次数直接减半。再进一步可以用64位累加器一次读8字节前提是保证缓冲区可安全读取通常内核skb都满足对齐要求。第二种是循环展开。编译器在开启O2优化后通常会自动做这件事但手写网络栈代码时我习惯把循环展开4次或8次减少分支开销。实测在小包转发的benchmark里这能把校验和计算时间压缩到接近内存读取速度。第三种是延迟进位。上面RFC实现里每次加法都可能触发折叠折叠本身有分支和位运算。优化的思路是先用一个足够大的累加器比如64位把所有16位字全部累加完最后统一折叠。原理是中间过程允许进位累计在累加器高位里只要最终折叠一次结果不变。内核里很多校验和实现就是按这个思路写的。再往工程层走网卡硬件校验和卸载Checksum Offload是绕不开的。现代网卡在发送时会把校验和字段标记为0直接在硬件里帮协议栈算好再发出去接收时也会硬件校验并把结果写入描述符。这种情况下抓包软件Wireshark看到的校验和字段经常是0或者显示“incorrect”千万不要误判为协议错误。这是做网络抓包分析时最高频的误报来源之一。4. 实战中躲不开的坑与排查技巧4.1 校验和字段清零是头号新手坑我见过太多排查半天、最后发现是“计算前忘了清零”的案例。包括我自己早期写小协议栈时也踩过。清零点必须在组装头部之后、计算校验和之前顺序反了等于白算。有一种调试手段很管用如果你能在对端抓到包把对端收到的IP头按16位字全部打印出来手算一遍基本立刻定位问题。如果发现“两边算出来的校验和不一样”先看是不是字节序差异如果校验和字段本身是0x0000而且数据包又能正常工作大概率是网卡校验和卸载在干扰你的判断。4.2 字节序小端机器上为什么结果还是对的这个坑有一定深度。IP头字段定义在网络字节序大端校验和算法要求按照“网络字节序下的16位字”顺序累计。在小端CPU上内存里字节顺序天然是反的比如源地址172.16.10.99在内存里是ac 10 0a 63读成16位整数是0x10ac、0x630a并不是0xac10、0x0a63。那为什么RFC的标准实现在小端机上跑出来仍然正确关键就在反码求和的加法是“数值无关字节顺序”的。系统提供的htons/ntohs会把每个16位字转换到主机字节序再相加转换后所有字的相对位权保持一致最终结果再转回网络字节序。整个过程相当于两边各自做了一次坐标变换算出来的校验和字段在网络字节序视角下依然正确。如果你用字节交换的方式接收会发现结果天然匹配。在实践中只需要记住一点如果底层缓冲区已经按网络序填好了整个IP头那么计算校验和时直接把它当作“连续16位字的数组”累加即可不要在每一组上做ntohs再塞回去否则反而容易出错。标准实现之所以能跨平台是因为它通过一致的读写方式抵消了字节序差异。4.3 伪头部校验和与分层校验的关系IP头校验和只保护IP头但TCP和UDP的校验和还要额外覆盖一个“伪头部”Pseudo Header里面包含源IP、目的IP、协议号、TCP/UDP长度。计算TCP校验和时要把这个伪头部拼在TCP段前面一起累计。很多做NAT或四层代理的人会在这上面栽跟头一台NAT设备修改了源IP却没有同步重新计算TCP校验和接收端就会认为TCP段损坏而丢弃。这也是“五元组”概念在NAT/nginx转发场景里显得重要的原因之一。五元组是源IP、目的IP、源端口、目的端口、协议号其中源IP和目的IP的变化会直接影响TCP/UDP伪头部校验和。反向代理如果不处理这个客户端发来的请求经过代理转发后后端服务器校验TCP层必然失败。实际工程里操作系统协议栈会在重写地址后自动更新校验和但如果自己实现用户态协议栈必须手动处理。4.4 常见问题速查表现象可能原因处理建议Wireshark显示校验和为0x0000网卡校验和卸载抓包时关闭checksum offload或忽略该告警接收端总是丢包TCP重传频繁伪头部校验和未重算检查NAT/代理是否更新TCP/UDP校验和自己算的结果和抓包不一致字节序处理错误确认按网络序连续累加不要逐字段做ntohs校验和字段被写成了0xFFFF对全0结果误取反注意IPv4头校验和没有“全0填全1”的规则带Options的包校验总失败未按IHL计算实际头长校验范围要包含Options字段IHL单位为4字节增量更新后校验和报错TTL对应16位字搞错方向检查小端机上old_word的增减方向建议先用单字节结构定位再说一个经验抓包分析校验和问题时不要只看Wireshark的“Checksum Status”列。那个行状态只是软件帮你看了一眼真正的判断标准是你自己抓到原始字节后按反码求和逻辑快速验证。我在现场排查时经常随手用Python三行脚本验证原始报文里的校验和import struct def ip_checksum(data): s 0 for i in range(0, len(data) - 1, 2): s (data[i] 8) data[i1] while s 16: s (s 0xFFFF) (s 16) return (~s) 0xFFFF把Wireshark导出的十六进制流塞进去几秒钟就能确认是发送端问题还是接收端解析问题。这种“自己算一遍”的土办法往往比依赖工具告警更可靠。最后分享一个我自己的调试习惯写任何涉及协议头的代码时先在结构体里把check字段用注释标出来组装完头部、填充完数据之后再集中计算校验和顺序固定为“先填字段再清check再算check最后发送”。别在发送函数里顺手算也别在接收路径里顺便改这样能最大程度避免状态污染。IP校验和这东西原理不难真正的难点全在这些工程细节的拿捏上。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门