本文讲解如何计算报文头部的 checksum。

UDP checksum 计算

计算方法

关于传输层 UDP 头部的介绍可以查看 这里

如何计算 UDP 的 checksum 值?

  1. 创建伪头部(对于 IPv4 报文,占 12 字节):把伪头部添加到 UDP 头部前面。
  2. 准备 UDP 头部(8 字节):计算前将头部的 checksum 字段 清零
  3. 将 UDP 头部和数据扩展到偶数字节的总长度:一般 UDP 头部字节数为偶数,如果 UDP 数据部分的长度为奇数,则需要在末尾“添加”一个字节的填充值(通常为 0),以确保总长度为偶数。注意,不需要修改 UDP 头部中的长度字段的值。
  4. 计算 checksum:
    • 把所有字段(伪头部、UDP 头部和数据)看作是(划分为)按顺序排列的 16 位字(2 字节),如果数据长度为奇数,补充的一个 0 字节(在报文地址的最高位)也需要包含在内。
    • 将所有 16 位字相加,如果有进位(超过 16 位),则将进位加回到最低有效位
    • 对结果按位取反,得到最终的 checksum 值

如果按位取反后的结果为 0,则需要将 checksum 设置为 0xFFFF,这是一种特殊情况,用于表示 checksum 为 0。因为,若发送方不愿意计算 checksum,它可以将 checksum 字段设置为 0x0000。接收方在接收这样的包时,会按照协议规定,忽略该包的 checksum 的验证。

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>

// Macro to handle carry when sum exceeds 16 bits
#define HANDLE_CARRY_OVERFLOW
// Macro to handle carry when the highest bit is set
#define HANDLE_CARRY_HIGHEST_BIT

/* UDP 伪头部 */
struct pseudo_header {
struct in_addr src_addr;
struct in_addr dst_addr;
uint8_t reserved; // 保留,设置为 0
uint8_t protocol; // 协议号,UDP 为 17
uint16_t length; // UDP 长度(头部 + 数据)
};

/* UDP 头部 */
struct udp_header {
uint16_t src_port;
uint16_t dst_port;
uint16_t length; // UDP 长度(头部 + 数据)
uint16_t checksum; // UDP 校验和
};

uint16_t calc_checksum(void* data, int length) {
uint32_t sum = 0; // 由于累加过程中可能会产生超过 16 位的数值,因此用 uint32 存储中间结果
uint16_t* ptr = (uint16_t*)data;

while (length > 1) {
sum += *ptr; // 将所有 16 位字相加
ptr++;
#ifdef HANDLE_CARRY_OVERFLOW
/* 若大于 0xffff,则立即处理进位 */
if (sum > 0xffff) {
sum = (sum & 0xffff) + (sum >> 16); // 将进位加回最低有效位
}
#else
/* 若 uint32 的最高位为 1,为了防止下次累加时溢出,通过进位操作来处理 */
if (sum & 0x80000000) {
sum = (sum & 0xffff) + (sum >> 16); // 将进位加回最低有效位
}
#endif
length -= 2;
}

/* 处理剩余数据长度为 1 的情况:在末尾(高地址)添加一个字节的填充值 -> 0x00**,也就是 0x** */
if (length) {
sum += *((uint8_t*)ptr);
}

/* 如果非最低 16bits 有数据,即有进位 */
while (sum >> 16) {
sum = (sum & 0xffff) + (sum >> 16);
}

return (uint16_t)(~sum);
}

int main() {
struct in_addr src_addr, dst_addr;

(void)inet_aton("10.20.111.10", &src_addr);
(void)inet_aton("10.20.11.11", &dst_addr);

uint8_t data[] = {0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0xf1, 0xf2, 0xf3}; // 13 bytes of data

struct udp_header udp;
udp.src_port = htons(0xaaaa);
udp.dst_port = htons(0xbbbb);
udp.length = htons(sizeof(struct udp_header) + sizeof(data));
udp.checksum = 0; // Initial value

struct pseudo_header pseudo;
pseudo.src_addr = src_addr;
pseudo.dst_addr = dst_addr;
pseudo.reserved = 0;
pseudo.protocol = IPPROTO_UDP;
pseudo.length = udp.length; // 伪头部的长度即位头部中的长度

// Combine pseudo header, UDP header and data for checksum calculation
uint8_t buffer[sizeof(struct pseudo_header) + sizeof(struct udp_header) + sizeof(data)];

memcpy(buffer, &pseudo, sizeof(struct pseudo_header));
memcpy(buffer + sizeof(struct pseudo_header), &udp, sizeof(struct udp_header));
memcpy(buffer + sizeof(struct pseudo_header) + sizeof(struct udp_header), data, sizeof(data));

// Calculate checksum
udp.checksum = calc_checksum(buffer, sizeof(buffer));
printf("UDP Checksum: 0x%4X\n", ntohs(udp.checksum)); // 0x43D7

return 0;
}

上面的代码中,有 HANDLE_CARRY_OVERFLOW 和 HANDLE_CARRY_HIGHEST_BIT 两种处理进位的逻辑,这是怎么回事?

在网络协议实现中,尤其是校验和计算时,有些实现会在特定条件下进行进位处理(如最高位为 1 时),而有些实现则会在累计和超过 16 位时进行处理(如 sum > 0xFFFF 时)。

最高位为 1(0x80000000)的处理:

这种处理方式是在累加过程中,立即检测是否出现了可能的溢出情况(累加值达到或超过 2^31)。这是为了在处理大数据包时,避免过早处理进位。虽然这种方式在实现上较为简单直接,但在一般的 UDP/TCP 校验和计算中,不如传统的进位处理方式常见。

累计和超过 16 位(sum > 0xFFFF)的处理:

这种处理方式更符合大多数校验和算法(如互联网校验和)的习惯:每次累加之后,立即检查总和是否超过 16 位。如果超过 16 位(即高于 0xFFFF),则将高位进位添加到低位部分,符合循环进位的概念。该方法确保每次累加后的数值都保持在 16 位以内,从而 避免逐字节累加大数值时可能出现的溢出情况

增量计算方法

在网络设备中,通常会修改某些字段,而这些字段一般只有几个字节(如源 IP、源 PORT),如果再次通过遍历完整的头部、数据部分重新计算 checksum,效率不高。通过比较修改的几个字节,进行增量计算 checksum,更高效。下面的代码是 RFC1624 中给出了计算方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/*
* 通过原 checksum 和修改前后的 uint16 值计算新的 checksum
* 注意:入参都是网络序、返回值也是网络序
* 修改前后的 uint16 值是报文中的偶数字节与后面的奇数字节,不能是奇数字节与后面的偶数字节
*/
uint16_t update_checksum(uint16_t cur_cksum, uint16_t old_val, uint16_t new_val) {
#ifndef RFC1624_EFFICIENT_CHECKSUM
uint32_t new_cksum = 0;

if (old_val == new_val) {
return cur_cksum;
}
cur_cksum = ntohs(cur_cksum);
old_val = ntohs(old_val);
new_val = ntohs(new_val);

/* 根据 RFC1624, new_cksum = (~cur_cksum) + (~old_val) + new_val */
new_cksum = ((~cur_cksum) & 0xffff) + ((~old_val) & 0xffff) + new_val;

/* 计算后可能有进位,需要处理,处理完后次处理潜在的进位 */
new_cksum = (new_cksum & 0xffff) + (new_cksum >> 16);
new_cksum += (new_cksum >> 16);

return (uint16_t)(~new_cksum);
#else
int32_t new_cksum = 0; // 有符号数

if (old_val == new_val) {
return cur_cksum;
}
cur_cksum = ntohs(cur_cksum);
old_val = ntohs(old_val);
new_val = ntohs(new_val);

/* 根据 RFC1624, new_cksum = cur_cksum - (~old_val) - new_val,更高效 */
new_cksum = (cur_cksum & 0xffff) - ((~old_val) & 0xffff) - new_val;

/* 若减出负值,如 new_cksum=0xffff35d6,则处理完后的 checksum 应为 0x35d5 */
new_cksum = (new_cksum & 0xffff) + (new_cksum >> 16); // 算术右移
new_cksum += (new_cksum >> 16);

return (uint16_t)(new_cksum);
#endif
}

int main() {
uint16_t cksum = update_checksum(0x43D7, 0xf1f2, 0xe0e3);
printf("UDP Checksum: 0x%4X\n", ntohs(cksum)); // 0x54E6
return 0;
}

对于右移操作,通常需要区分的是算术右移(也称为符号右移)和逻辑右移:

  • 算术右移:保留符号位,右移时高位用符号位填充。例如,负数右移后仍然是负数。
  • 逻辑右移:不保留符号位,右移时高位用 0 填充

在 C 语言中,对于有符号类型(比如 int32_t),右移操作通常被实现为算术右移(具体取决于编译器)。对于无符号类型(比如 uint32_t),右移操作是逻辑右移。

TCP checksum 计算

TCP 和 UDP 的 checksum 计算方式基本相同。TCP checksum 计算 涉及到 TCP 伪头部、TCP 头部和 TCP 数据三部分。TCP 伪头部的构成与 UDP 伪头部的构成完全一致,只是填充的成员值不同,如 TCP 的 protocol=6、UDP 的 protocol=17。

IPv4 checksum 计算

IPv4 的 checksum 计算方法与 TCP 和 UDP 的 checksum 计算方式相似,不同之处在于:IPv4 仅使用 IPv4 头部计算 checksum、不使用数据部分。

不管是 TCP、UDP 还是 IPv4,在计算其 checksum 前,都需要将头部中的原 checksum 字段清零。