1. 奇数字节问题是怎么冒出来的:先弄懂 UDP 校验和到底在算哪一段数据
前阵子帮人排查一个跨平台通信模块的怪问题:同一套 UDP 校验和代码,在一台主机上跑得好好的,抓包看校验和全是正确的;换到另一台主机上,包能发出去,但对端疯狂丢包,抓包软件直接标红 "invalid checksum"。两边核对半天,最后问题落在一个非常不起眼的地方——载荷长度是奇数的时候,剩下那一个字节到底该怎么参与求和。
UDP 校验和的"奇数字节"处理,确实是实现里最容易翻车的细节之一。它不复杂,但涉及字节序、补零位置、进位折叠几个点,任何一个搞错,结果都是"看起来差不多,实际差一整个字节位"。这篇文章就把这个点彻底讲透:什么时候会出现奇数字节、正确的补零规则是什么、代码怎么写才不出错,以及几种增量处理方式。
1.1 伪首部、UDP 头部和载荷是一起求和的
先复习一个关键前提:UDP 校验和不是只算 UDP 头和数据,它算的是一个"伪首部 + UDP 头 + 载荷"的拼接体。IPv4 场景下,伪首部是 12 个字节:
- 源 IP 地址:4 字节
- 目的 IP 地址:4 字节
- 一个全 0 字节
- 协议号:1 字节(UDP 是 0x11)
- UDP 长度:2 字节(这个长度等于 UDP 头 8 字节加载荷长度,不包含伪首部本身)
再加上 UDP 头部 8 字节(源端口 2、目的端口 2、长度 2、校验和 2),总共参与校验和计算的数据长度就是:12 + 8 + 载荷长度 = 20 + payload_len。
伪首部本身不传输,它只是参与校验和计算,目的是把 IP 层的地址信息也纳入校验范围。这样如果数据报被错误路由或者 IP 地址被改掉,UDP 层的校验和也能发现。IPv6 下的伪首部是 40 字节(源地址 16 + 目的地址 16 + 32 位长度 + 3 个零字节 + next header 1 字节),计算原理完全一样。
1.2 为什么奇数长度必然出现在载荷里
注意一个规律:伪首部 12 字节是偶数,UDP 头 8 字节也是偶数,所以"20 字节"这个基础部分是偶数。那么整个校验和区域的奇偶性,完全取决于载荷长度。
- payload_len 是偶数 → 校验和区域总长是偶数,所有字节都能两两配对成 16 位字,不会遇到剩余字节。
- payload_len 是奇数 → 校验和区域总长是奇数,最后一定剩下 1 个字节单独处理。
也就是说,你不需要去判断"伪首部是否奇数"——伪首部永远是偶数。真正要问的只有一句:这次 UDP 载荷的长度是不是奇数。实战中 DNS 查询请求经常是奇数长度(域名 QNAME 的每段标签加上类型、类别字段,很容易凑成奇数),还有 TFTP 的 RRQ/WRQ 报文、UDP 心跳的一字节载荷,都会触发这个分支。
1.3 一个 23 字节的例子:手工算出基线
为了后面验证代码,先手工算一个具体例子。假设:
- 源 IP:192.168.1.1 → C0 A8 01 01
- 目的 IP:192.168.1.2 → C0 A8 01 02
- 源端口:0x1234
- 目的端口:0x5678
- UDP 长度:8 + 3 = 11 → 0x000B
- 载荷:DE AD BE(3 字节,奇数)
伪首部 12 字节:C0 A8 01 01 C0 A8 01 02 00 11 00 0B UDP 头加载荷 11 字节:12 34 56 78 00 0B 00 00 DE AD BE
整个校验和区域就是 23 字节,按 16 位分成 11 个完整字加 1 个剩余字节 0xBE。手工逐字累加的结果是 0x88D6,取反后校验和为 0x7729。这个数字后面会当作测试基线反复使用。注意这个例子里 0xBE 的参与方式很关键,下一节专门说。
2. 剩余 1 个字节的正确姿势:补零位置与字节序
奇数字节处理的全部难点,其实就一句话:剩下的那 1 个字节,要当作一个 16 位字的哪一半来参与求和?
2.1 约定:剩余字节作为最后一个 16 位字的高字节
RFC 768 定义了 UDP 校验和的计算范围,但没有把奇数字节的处理写得特别细;具体怎么补零,工程上的约定来自 RFC 1071:数据不足偶数长度时,在末尾补一个 0 字节,凑成完整的 16 位字。
这个"末尾补零"非常关键。校验和区域的字节流是网络字节序,从左到右每两个字节组成一个 16 位字,第一个字节是这个字的高字节。现在剩余字节在字节流的最末尾,补零只能补在它后面,所以剩余字节必须是最后一个 16 位字的高字节。
拿上面的例子说,剩余字节 0xBE 补零后形成的 16 位字是 0xBE00,参与求和的贡献值是 0xBE00,不是 0x00BE。很多实现错就错在这里:直接把 1 个字节的数值 0xBE 加进去,或者把它当成低字节补成 0x00BE。这两种做法都差了一个字节位移,算出来的校验和跟对端一比对必然失败。
为了确认这一点,把两种结果摆在一起看。完整字累加到 0xCAD5 之后:
- 正确:0xCAD5 + 0xBE00 = 0x88D6(折叠进位后),取反得 0x7729
- 错误:0xCAD5 + 0x00BE = 0xCB93,取反得 0x346C
0x7729 和 0x346C,差得非常明显。所以实现时永远记住:剩余字节放在高字节,补零放在低字节。
2.2 两种等价写法:字节流组字与主机序读字加末尾交换
知道了"剩余字节放高字节",代码层面还有字节序的坑等着你。因为 UDP 在互联网上的格式是网络字节序(大端),而你的主机可能是小端(x86/ARM 都是),同样的内存内容,用不同方式读取,得到的 16 位数值完全不一样。
写代码时有两个自洽的模式可选:
模式 A:完全按字节流处理,每两个字节手工合成网络序的 16 位字:
sum += (uint16_t)((buf[i] << 8) | buf[i + 1]);这样的写法跟主机字节序无关,"buf[i]"永远对应高字节,"buf[i+1]"永远对应低字节。最后剩余的 1 个字节用buf[i] << 8加进去,天然就是高字节。这个模式最直接、最不容易错,也是我平时首选的实现方式。
模式 B:为了性能,用 16 位指针直接读内存(主机序),先把所有完整字加进累加器,最后对结果做一次字节交换再写入报文。这个模式下剩余字节必须放在低字节位(因为整个累加过程都是"主机序"视角,最后统一交换回来)。很多高性能协议栈走这条路,但代码里会带着#ifdef __LITTLE_ENDIAN之类的条件编译,一不留神就写串。
我见过最典型的错误就是:在模式 A 的代码里混入模式 B 的做法,或者在模式 B 里套用模式 A 的移位。两个模式混着用,大端机器上碰巧能过,小端机器上一片红。这里给个硬性建议:除非你非常清楚自己在干什么,否则一律用模式 A。性能损失在 UDP 校验和这种场景下几乎可以忽略,但"字节序正确性"的价值远大于那几纳秒的优化。
2.3 最常见的错误:直接把 byte 加进和值
社区里流传很广的一段 BSD 风格代码是这么写剩余字节的:
if (nleft == 1) sum += *(u_char *)data;直接加一个 char。这段代码在纯大端机器上,配合大端方式读字,结果是对的;但在小端机器上,如果完整字是用主机序读进来的,这个 char 加在低字节位,恰好与"整体字节交换"的模式自洽,需要最后把结果交换回去才能正确。问题在于,很多后来者复制这段代码时,只抄了 char 加法,没抄最后的字节交换,或者在读字的时候用了手工组字,那就全乱了。
一句话总结:sum += byte这种写法只有在"整个累加过程的所有字都是用同一种字节序表示"时才是安全的。最保险的做法是不碰这个写法,用sum += (uint16_t)byte << 8显式表达"高字节"语义,谁都能一眼看懂,也不会被宿主机的字节序坑到。
3. 三种可直接上手的实现
这一节给出三个可以直接抄进项目的实现。第一个是通用可移植版,第二个是 Python 原型(方便写测试),第三个是针对分片、零拷贝场景的增量版本。
3.1 可移植 C 实现(字节流组字)
把校验和函数拆成两个小函数:一个负责累加一段字节流,一个负责收尾折叠进位。这样代码清晰,也方便直接对"伪首部、UDP 头、载荷"三段分别调用。
#include <stdint.h> #include <stddef.h> #include <string.h> /* 把 len 字节按网络字节序合成 16 位字,累加到 sum 中 */ static uint32_t sum_bytes_be16(const uint8_t *buf, size_t len, uint32_t sum) { size_t i; for (i = 0; i + 1 < len; i += 2) { sum += (uint32_t)((buf[i] << 8) | buf[i + 1]); } /* 奇数字节:剩余 1 字节作为最后一个 16 位字的高字节 */ if (i < len) { sum += (uint32_t)(buf[i] << 8); } return sum; } /* 把 32 位累加和折叠成 16 位,并取反返回 */ static uint16_t finish_checksum(uint32_t sum) { while (sum >> 16) { sum = (sum & 0xFFFF) + (sum >> 16); } return (uint16_t)(~sum); } /* IPv4 UDP 校验和,src/dst 为 4 字节网络序地址 */ uint16_t udp4_checksum(const uint8_t *src, const uint8_t *dst, uint16_t src_port, uint16_t dst_port, const uint8_t *payload, size_t plen) { uint8_t pseudo[12]; uint8_t udp_hdr[8]; uint16_t udp_len; uint32_t sum = 0; udp_len = (uint16_t)(8 + plen); /* 构造伪首部 */ memcpy(pseudo, src, 4); memcpy(pseudo + 4, dst, 4); pseudo[8] = 0; pseudo[9] = 17; /* IPPROTO_UDP */ pseudo[10] = (uint8_t)(udp_len >> 8); pseudo[11] = (uint8_t)(udp_len & 0xFF); /* 构造 UDP 头,校验和字段置 0 */ udp_hdr[0] = (uint8_t)(src_port >> 8); udp_hdr[1] = (uint8_t)(src_port & 0xFF); udp_hdr[2] = (uint8_t)(dst_port >> 8); udp_hdr[3] = (uint8_t)(dst_port & 0xFF); udp_hdr[4] = (uint8_t)(udp_len >> 8); udp_hdr[5] = (uint8_t)(udp_len & 0xFF); udp_hdr[6] = 0; /* 校验和字段必须按 0 计算 */ udp_hdr[7] = 0; sum = sum_bytes_be16(pseudo, 12, sum); sum = sum_bytes_be16(udp_hdr, 8, sum); sum = sum_bytes_be16(payload, plen, sum); return finish_checksum(sum); }这个实现有几个细节值得注意。第一,校验和字段在参与求和前必须置 0,这几乎人人都知道,但总有人忘记在做校验和函数内部"清零"——正确做法是调用方构造报文时就把那 2 个字节填 0,再传进来。第二,累加器用uint32_t,因为一个最大长度的 UDP 报文(载荷接近 65507 字节)全部字加起来会超过 16 位,甚至接近 32 位边界,必须用 32 位承载。第三,折叠用 while 而不是 if,因为一次折叠不一定够,极端情况要折两三轮才能把高位彻底消掉。
3.2 Python 原型与自测向量
写协议代码时,我习惯先用 Python 做原型,因为它能把"整个校验和区域拼成一个字节串再求和"这件事写成人人可读的形式,拿来当测试参照最合适。
def udp4_checksum(src_ip: str, dst_ip: str, src_port: int, dst_port: int, payload: bytes) -> int: udp_len = 8 + len(payload) pseudo = ( bytes(map(int, src_ip.split("."))) + bytes(map(int, dst_ip.split("."))) + b"\x00\x11" + udp_len.to_bytes(2, "big") ) udp = ( src_port.to_bytes(2, "big") + dst_port.to_bytes(2, "big") + udp_len.to_bytes(2, "big") + b"\x00\x00" # 校验和字段占位,按 0 计算 + payload ) data = pseudo + udp # 关键:长度是奇数时,尾部补一个 0 字节 if len(data) % 2: data += b"\x00" s = 0 for i in range(0, len(data), 2): s += (data[i] << 8) + data[i + 1] s = (s & 0xFFFF) + (s >> 16) return (~s) & 0xFFFF # 用 1.3 节手工计算的例子做测试 assert udp4_checksum("192.168.1.1", "192.168.1.2", 0x1234, 0x5678, bytes.fromhex("DEADBE")) == 0x7729 print("odd-length checksum test passed")这段代码把奇数字节的处理写得很直白:拼完整个字节流后,如果长度是奇数,data += b"\x00"补在末尾。然后从第 0 字节开始,两两合成 16 位字,剩余的那个字节自然成为最后一个字的高字节。注意补零只是"参与求和",不是往网络报文里真的追加一个字节——这个 0 不会发送到对端,对端在复算校验和时,同样要自己在末尾补一个 0。两端补零规则一致,结果才对得上。
3.3 分片/增量场景:跨 chunk 的奇数处理
上面两个版本是"一次性拿到完整数据"的做法。但真实环境里有很多场景数据不是一整块内存,比如 DMA 描述符驱动的网络收包、协议栈对分片的重组、或者要做零拷贝的转发设备。这种情况下校验和要分段累加,而分段的问题在于:上一段是不是奇数决定了下一段的第一个字节该跟谁配对。
增量校验和需要维护一个上下文,记录"手里有没有攒着一个还没配对的上一个字节"。每来一段数据,先处理这个残余字节跟新数据第一个字节的组合,再去成对地处理剩余部分。
struct chksum_ctx { uint32_t sum; uint8_t pending; /* 上一次累积下来的单字节 */ int has_pending; }; static void chksum_update(struct chksum_ctx *ctx, const uint8_t *data, size_t len) { if (ctx->has_pending) { /* 残余字节作为高字节,与当前段的第一个字节组合成一个字 */ ctx->sum += (uint32_t)((ctx->pending << 8) | data[0]); data++; len--; ctx->has_pending = 0; } while (len >= 2) { ctx->sum += (uint32_t)((data[0] << 8) | data[1]); data += 2; len -= 2; } if (len == 1) { ctx->pending = data[0]; ctx->has_pending = 1; } } static uint16_t chksum_finish(struct chksum_ctx *ctx) { if (ctx->has_pending) { /* 最后一个残余字节,后面补 0 */ ctx->sum += (uint32_t)(ctx->pending << 8); } return finish_checksum(ctx->sum); }看一个具体例子就明白为什么必须这么做。假设分两段:第一段是 DE AD BE,第二段是 01 02。如果两段拼成一个完整流,字序列是 DEAD、BE01、0200。如果按段独立处理,第一段会把 BE 作为末尾单字节存起来,第二段进来时就必须用 (BE << 8 | 01) 合成 BE01,剩下的 02 再作为残余字节留到收尾时补零成 0200。如果第二段处理时忘了跟残余字节组合,而是直接把 01 02 当成一个完整字,总和就少了一个字位,校验和必错。
这种增量写法在驱动层尤其重要,因为在网卡收包描述符里,payload 可能被拆在多个内存页里,谁也不知道上一块的末字节什么时候会跟下一块的首字节拼成一对。
4. 容易忽略的两个边界:全 0 与全 1、IPv6 必算
奇数字节处理正确之后,还有两个边界情况经常被漏掉,它们都会导致"算出来的值压根不能发"或者"对端直接丢弃"。
4.1 计算结果是 0x0000 时必须改成 0xFFFF 发送
RFC 768 里明确规定:如果计算出来的 UDP 校验和是 0x0000,传输时要把这个值改成 0xFFFF。原因很朴素:在 IPv4 里,接收端看到校验和字段为 0,会认为发送端没有计算校验和(IPv4 允许 UDP 校验和关闭)。如果真实计算结果是 0x0000 又按原样发出去,接收端会把它误判成"未校验"或者校验失败。
有人可能会问,校验和求和加取反之后真的可能算出 0x0000 吗?可能的。当累加和折叠后恰好等于 0xFFFF(全 1)时,取反就是 0x0000。虽然概率不高,但这不是不可能事件,尤其是刻意构造的报文很容易命中。所以发送路径上必须有这个判断:
uint16_t csum = finish_checksum(sum); if (csum == 0) { csum = 0xFFFF; }接收端则相反:看到 0x0000 的校验和字段,在 IPv4 下不能简单地判为"失败",要意识到这可能是发送端主动关闭了校验和。许多抓包软件对校验和字段为 0 的 UDP 包会提示 "checksum offloaded" 或者"未校验",就是这个原因。校验和结果被改成 0xFFFF 发送的包,接收到完整的 0xFFFF 才是正常的。
4.2 接收端复算:把校验和字段一起求和最终应得 0xFFFF
接收端校验有一个常见误区:以为也要先把校验和字段清零再复算。其实接收端不需要清零,而是将整个校验和区域(伪首部 + UDP 头 + 载荷)连同已经收到的校验和字段一起求和,折叠后看结果是不是 0xFFFF。
原因是一补码算术的性质。发送端计算时把校验和字段按 0 加了一遍,得到累加和 S,然后发送的校验和是 ~S。接收端把校验和字段的实际值 ~S 也加进去,总和就是 S + (~S),在 16 位一补码里正好等于 0xFFFF。所以:
- 求和结果折叠后是 0xFFFF → 校验通过
- 不是 0xFFFF → 校验失败,丢弃
正是这个特性,让接收端的校验函数里不需要做"清零校验和字段"这个动作,代码比发送端简单很多。也正因为接收端把校验和字段本身算进总和里,校验和区域的长度永远是偶数:原本可能是奇数的数据段,加上 2 字节的校验和字段后,奇偶性不变(奇数 + 2 = 奇数),所以接收端同样要处理奇数字节——补零规则和发送端完全一致。
4.3 IPv6 下没有"不校验"选项
IPv4 下 UDP 校验和可以置 0 表示"不计算",但 IPv6 下 UDP 校验和是强制计算的,伪首部结构也变了:IPv6 头部有 40 字节,伪首部是"源地址 16 + 目的地址 16 + 上层报文长度 4 + 3 个 0 字节 + 下一个头 1 字节"。IPv6 伪首部加 UDP 头之后仍是偶数,所以奇数长度的判断依然只在载荷身上。如果你在做 IPv6 协议栈,发送路径上"校验和值为 0 就发 0xFFFF"的规则不再适用,因为整个校验和字段不允许为 0;但奇数字节的处理逻辑和 IPv4 完全一样,可以直接复用。
5. 实战排错:常见 Bug、排查步骤与测试建议
奇数字节的问题很少是"第一眼能看到"的,大多数时候的表现是"偶发丢包"或者"某些主机间通信正常,某些主机间不正常"。这里把常见故障、排查手段和测试方法整理一遍。
5.1 一张表对照常见故障
| 故障现象 | 根因 | 修复方向 |
|---|---|---|
| 奇数载荷时,自己抓包和计算器对不上,差一个字节位 | 剩余字节加成 0x00BE 而不是 0xBE00 | 统一用(byte << 8)组字 |
| 大端主机上正常,小端主机上全错 | 读完整字的方式与剩余字节处理用了不同模式 | 全部改用字节流组字,或全部主机序读字加末尾交换 |
| 大载荷时校验和偶尔不对 | 累加器溢出或只做一次进位折叠 | 用 uint32_t 累加,用 while 折叠到高位为 0 |
| 计算时正常,发送后对端校验失败 | 构造报文时没有把校验和字段清零就去求和 | 发送前先把校验和字段两个字节置 0 再调用 |
| 校验和算出来是 0x0000,直接发出去 | 未按 RFC 768 将 0 改成 0xFFFF | 发送路径增加csum == 0 ? 0xFFFF : csum判断 |
| 接收端复算时先清零,结果永远不对 | 接收端不应清零,应包含校验和字段求和,期待全 1 | 修正接收端校验逻辑 |
5.2 用测试向量和抓包工具建立回归
我个人的做法是给校验和函数配一组固定测试向量,重点覆盖奇数载荷,放进单元测试里每次构建都跑。上面 1.3 节那个 0x7729 的向量就是很好的基线,再补几个:
- 偶数载荷:确认奇数字节分支不误触发
- 1 字节载荷:只测剩余字节逻辑,最容易暴露移位错误
- 65507 字节这样的接近上限载荷:顺带验证大累加和与多轮折叠
- 计算结果恰好为 0 的构造包:可以在字节上精心凑一下,专门测 0→0xFFFF 的分支
测完自己的函数后,第二步是拿真实抓包做交叉验证。构造一个奇数载荷的 UDP 包发到回环口,用抓包工具看校验和列:显示 correct 就说明发送路径对;再把收到的包原样送给自己的接收校验函数,确认返回 0xFFFF。这个双端闭环测试,比任何代码审查都能更快地暴露奇数字节的偏移问题。
5.3 关于对齐访问与类型强转的提醒
最后提醒一个 C 语言层面的坑:不要用(uint16_t *)buf这样的强转去读取 16 位字。一是未对齐指针在部分架构上会触发异常,二是这种强转隐含着"按主机序解释内存"的字节序假设,跟奇数字节逻辑纠缠在一起容易翻车。即使你想追求性能,也建议用memcpy到局部变量再处理,编译器在大多数平台上会优化成一条汇编指令,几乎不损失性能。这个习惯能帮你把"字节序问题"和"对齐问题"从代码里彻底隔离出去。
6. 一点工程体会
我个人在实际项目中踩过几次坑之后,最大的体会是:校验和这类代码,问题的核心从来不是"能不能算出值",而是"发送端和接收端对同一段字节流的解释是否一致"。奇数字节看似只差一个字节位,但这个位移会在不同主机、不同编译选项、不同架构之间随机重现,排查成本远高于写代码的成本。所以我现在写任何校验和实现,都会坚持两个原则:用字节流组字而不是主机序强转,并且一定要保留一个独立的 Python 参照实现作为测试基线。
另外,UDP 校验和的奇数字节处理并不是 UDP 独有的——TCP 校验和完全一样的算法,TCP 段载荷也可能是奇数,代码直接复用。如果你手头有 TCP 校验和实现,检查一下它的剩余字节分支,多半也能暴露同样的问题。这个知识点藏得很深,但一旦理解透,无论在哪一层网络代码里遇到"差一个字节位"的怪现象,你都能一眼定位到它。