TCP/IP卷1学习:UDP 与 IP 分片详解
第10章:用户数据报协议(UDP)导论
1. UDP 是什么?
UDP(User Datagram Protocol,用户数据报协议) 是传输层的一个简单协议。
用一句话理解:
UDP 就像寄明信片——你写好内容直接投进邮筒,不需要对方签收确认,也不保证一定送达,但胜在快速简单。
与之对比,TCP 就像快递,需要签收回执、按顺序送达、出错重寄。
2. UDP 的特性清单
2.1 UDP 有什么
| 特性 | 说明 |
|---|---|
| 保留消息边界 | 应用层写入多少数据,对方就收到多少,不会被拆分或合并 |
| 错误检测 | 包含校验和(Checksum),可检测传输中的数据损坏 |
| 无连接 | 不需要建立连接,直接发送 |
2.2 UDP 没有什么
| 缺失特性 | 含义 |
|---|---|
| 错误纠正 | 检测到错误只是丢弃,不会重传 |
| 排序 | 包可能乱序到达,UDP 不管 |
| 去重 | 重复的包不会被过滤 |
| 流量控制 | 发送方不管接收方有没有来得及处理 |
| 拥塞控制 | 网络拥堵时不会自动降速,可能加剧拥堵 |
3. UDP 的封装结构
参考图 10-1,UDP 数据报封装在 IPv4 数据报中:
<--------------------- IPv4 数据报 --------------------->
+--------------------+----------+----------------------+
| IPv4 Header | UDP | UDP Data |
| (20 字节) | Header | (应用数据) |
| (无 IP Options) | (8 字节) | |
+--------------------+----------+----------------------+
<------ UDP 数据报 ---------------------------------------->
- IPv4 头部:标准情况下 20 字节(无选项),其中 Protocol 字段值为 17,表示上层是 UDP
- UDP 头部:固定 8 字节
- UDP 数据:应用程序写入的内容
对于 IPv6,封装方式类似,但 UDP 头部跟在 IPv6 头部链(Header Chain)的末尾,Next Header 字段同样使用值 17。
3.1 各层大小关系
IPv4 数据报总长 = 20 (IPv4 头) + 8 (UDP 头) + 数据长度 \text{IPv4 数据报总长} = 20\text{(IPv4 头)} + 8\text{(UDP 头)} + \text{数据长度} IPv4 数据报总长=20(IPv4 头)+8(UDP 头)+数据长度
UDP 数据报长度 = 8 (UDP 头) + 数据长度 \text{UDP 数据报长度} = 8\text{(UDP 头)} + \text{数据长度} UDP 数据报长度=8(UDP 头)+数据长度
4. 为什么要用 UDP?
既然 UDP 这么"不可靠",为什么还要用?
4.1 UDP 的优势
4.2 典型应用场景
| 应用 | 原因 |
|---|---|
| DNS 查询 | 请求/响应简单,丢了重发更快 |
| 视频流 / 语音通话 | 宁可花屏也不要卡顿,对延迟敏感 |
| 游戏状态同步 | 旧数据即使丢了也无所谓,要最新 |
| DHCP / TFTP | 在没有连接的情况下需要广播发现 |
| NTP(时间同步) | 简单查询,一来一回 |
4.3 与 TCP 的对比
特性 UDP TCP
-------- -------- --------
连接 无 三次握手建立
可靠性 不保证 保证(重传)
顺序 不保证 保证
流量控制 无 有(滑动窗口)
拥塞控制 无 有
消息边界 保留 不保留(字节流)
头部开销 8 字节 最少 20 字节
延迟 低 较高(确认机制)
5. 消息边界的重要性
UDP 保留消息边界,这是与 TCP 最直观的区别:
UDP 发送:
write("Hello") → 一个 UDP 数据报,对方 read() 得到 "Hello"
write("World") → 一个 UDP 数据报,对方 read() 得到 "World"
TCP 发送:
write("Hello")
write("World") → 可能被合并,对方 read() 得到 "HelloWorld"
也可能被拆分,对方需要多次 read()
6. 关于 IP 分片(预告)
当 UDP 数据报的大小超过链路的 MTU(Maximum Transmission Unit,最大传输单元) 时,IP 层需要将其**分片(Fragment)**成多个较小的 IP 数据包分别发送,接收方再重组。
以太网的 MTU 通常为 1500 字节:
UDP 数据最大不分片大小 = 1500 − 20 (IPv4 头) − 8 (UDP 头) = 1472 字节 \text{UDP 数据最大不分片大小} = 1500 - 20\text{(IPv4 头)} - 8\text{(UDP 头)} = 1472\text{ 字节} UDP 数据最大不分片大小=1500−20(IPv4 头)−8(UDP 头)=1472 字节
超过 1472 字节的 UDP 数据就需要 IP 层分片处理(详见后续章节)。
7. 完整 C++ 示例代码
以下代码演示 UDP 数据报头部的结构、计算校验和,以及基本的发送/解析逻辑。
#include <cstdint>
#include <cstring>
#include <iostream>
#include <string>
#include <vector>
#include <arpa/inet.h> // htons, ntohs, inet_pton
#include <netinet/ip.h> // struct iphdr(Linux)
// -------------------------------------------------------
// UDP 头部结构(固定 8 字节)
// 字段均以网络字节序(大端)存储
// -------------------------------------------------------
#pragma pack(push, 1)
struct UDPHeader {
uint16_t src_port; // 源端口(2字节)
uint16_t dst_port; // 目的端口(2字节)
uint16_t length; // UDP 总长度 = UDP头 + 数据(2字节)
uint16_t checksum; // 校验和(2字节),0 表示不使用校验和
};
#pragma pack(pop)
// -------------------------------------------------------
// UDP 伪头部(Pseudo Header)
// 用于计算校验和时,加在 UDP 头部之前
// 注意:伪头部不会真正发送,只用于校验和计算
// -------------------------------------------------------
#pragma pack(push, 1)
struct UDPPseudoHeaderv4 {
uint32_t src_ip; // 源 IPv4 地址
uint32_t dst_ip; // 目的 IPv4 地址
uint8_t zero; // 填充为 0
uint8_t protocol; // 协议号,UDP = 17
uint16_t udp_len; // UDP 总长度(与 UDPHeader.length 相同)
};
#pragma pack(pop)
// -------------------------------------------------------
// 计算 Internet 校验和(RFC 1071)
// 对输入数据每 2 字节求和,处理进位后取反
// -------------------------------------------------------
uint16_t internet_checksum(const uint8_t* data, size_t len) {
uint32_t sum = 0;
// 每次处理 2 字节,以网络字节序累加
for (size_t i = 0; i + 1 < len; i += 2) {
uint16_t word;
memcpy(&word, data + i, 2);
sum += ntohs(word);
}
// 若长度为奇数,处理最后 1 字节(高位补 0)
if (len % 2 != 0) {
sum += static_cast<uint16_t>(data[len - 1]) << 8;
}
// 将 32 位累加结果的进位折叠到低 16 位
while (sum >> 16) {
sum = (sum & 0xFFFF) + (sum >> 16);
}
// 取反得到校验和
return static_cast<uint16_t>(~sum);
}
// -------------------------------------------------------
// 为 UDP 数据报计算校验和
// 需要源/目的 IP 地址(用于构造伪头部)
// -------------------------------------------------------
uint16_t compute_udp_checksum(
const std::string& src_ip_str, // 点分十进制源 IP
const std::string& dst_ip_str, // 点分十进制目的 IP
const UDPHeader& udp_hdr, // UDP 头部(网络字节序)
const uint8_t* payload, // UDP 数据
size_t payload_len // UDP 数据长度
) {
// 将 IP 字符串转换为网络字节序的 uint32_t
uint32_t src_ip, dst_ip;
inet_pton(AF_INET, src_ip_str.c_str(), &src_ip);
inet_pton(AF_INET, dst_ip_str.c_str(), &dst_ip);
// 构造伪头部
UDPPseudoHeaderv4 pseudo;
pseudo.src_ip = src_ip;
pseudo.dst_ip = dst_ip;
pseudo.zero = 0;
pseudo.protocol = 17; // UDP 协议号
pseudo.udp_len = udp_hdr.length; // 与 UDP 头中的 length 相同
// 将伪头部 + UDP 头部 + 数据拼接成一个缓冲区
size_t total = sizeof(pseudo) + sizeof(udp_hdr) + payload_len;
std::vector<uint8_t> buf(total, 0);
size_t offset = 0;
memcpy(buf.data() + offset, &pseudo, sizeof(pseudo));
offset += sizeof(pseudo);
memcpy(buf.data() + offset, &udp_hdr, sizeof(udp_hdr));
offset += sizeof(udp_hdr);
if (payload && payload_len > 0) {
memcpy(buf.data() + offset, payload, payload_len);
}
// 计算整体校验和
return internet_checksum(buf.data(), total);
}
// -------------------------------------------------------
// 构造一个 UDP 数据报(头部 + 数据)
// 返回完整的字节序列(不含 IP 头)
// -------------------------------------------------------
std::vector<uint8_t> build_udp_datagram(
uint16_t src_port,
uint16_t dst_port,
const std::string& src_ip,
const std::string& dst_ip,
const std::string& message
) {
const uint8_t* payload = reinterpret_cast<const uint8_t*>(message.c_str());
size_t payload_len = message.size();
// UDP 总长度 = 头部(8字节)+ 数据
uint16_t udp_length = static_cast<uint16_t>(8 + payload_len);
// 先填写 UDP 头部(校验和先置 0)
UDPHeader hdr;
hdr.src_port = htons(src_port);
hdr.dst_port = htons(dst_port);
hdr.length = htons(udp_length);
hdr.checksum = 0; // 先置 0,之后填入
// 计算校验和
uint16_t csum = compute_udp_checksum(
src_ip, dst_ip, hdr, payload, payload_len);
hdr.checksum = htons(csum);
// 拼接头部和数据
std::vector<uint8_t> datagram(8 + payload_len);
memcpy(datagram.data(), &hdr, 8);
memcpy(datagram.data() + 8, payload, payload_len);
return datagram;
}
// -------------------------------------------------------
// 解析并打印 UDP 数据报内容
// -------------------------------------------------------
void parse_and_print_udp(const std::vector<uint8_t>& datagram) {
if (datagram.size() < 8) {
std::cerr << "[错误] 数据报太短,不是合法的 UDP\n";
return;
}
// 读取 UDP 头部
UDPHeader hdr;
memcpy(&hdr, datagram.data(), 8);
uint16_t src_port = ntohs(hdr.src_port);
uint16_t dst_port = ntohs(hdr.dst_port);
uint16_t length = ntohs(hdr.length);
uint16_t checksum = ntohs(hdr.checksum);
size_t data_len = length - 8; // 数据部分长度
std::cout << "=== UDP 数据报解析 ===\n";
std::cout << " 源端口 : " << src_port << "\n";
std::cout << " 目的端口 : " << dst_port << "\n";
std::cout << " UDP 总长度 : " << length << " 字节"
<< "(头部 8 + 数据 " << data_len << ")\n";
std::cout << " 校验和 : 0x" << std::hex << checksum
<< std::dec << "\n";
// 打印数据内容
if (datagram.size() >= 8 + data_len) {
std::string payload(
reinterpret_cast<const char*>(datagram.data() + 8), data_len);
std::cout << " 数据内容 : \"" << payload << "\"\n";
}
}
// -------------------------------------------------------
// 演示 IP 分片阈值计算
// -------------------------------------------------------
void show_fragmentation_threshold() {
const int IPV4_HEADER = 20; // 标准 IPv4 头(无选项)
const int UDP_HEADER = 8; // UDP 头固定大小
const int ETHERNET_MTU = 1500; // 以太网 MTU
int max_udp_data = ETHERNET_MTU - IPV4_HEADER - UDP_HEADER;
std::cout << "\n=== IP 分片阈值 ===\n";
std::cout << " 以太网 MTU : " << ETHERNET_MTU << " 字节\n";
std::cout << " IPv4 头部 : " << IPV4_HEADER << " 字节\n";
std::cout << " UDP 头部 : " << UDP_HEADER << " 字节\n";
std::cout << " UDP 数据最大不分片大小 : "
<< max_udp_data << " 字节\n";
std::cout << " 超过此大小 IP 层将分片!\n";
}
// -------------------------------------------------------
// 主函数
// -------------------------------------------------------
int main() {
std::string src_ip = "192.168.1.10";
std::string dst_ip = "192.168.1.20";
uint16_t src_port = 54321;
uint16_t dst_port = 53; // DNS 默认端口
std::string message = "Hello, UDP!";
std::cout << "构造 UDP 数据报:\n";
std::cout << " " << src_ip << ":" << src_port
<< " → " << dst_ip << ":" << dst_port << "\n";
std::cout << " 数据:\"" << message << "\"\n\n";
// 构造 UDP 数据报
auto datagram = build_udp_datagram(
src_port, dst_port, src_ip, dst_ip, message);
// 打印原始字节
std::cout << "原始字节(十六进制):\n ";
for (size_t i = 0; i < datagram.size(); ++i) {
printf("%02X ", datagram[i]);
if ((i + 1) % 16 == 0) std::cout << "\n ";
}
std::cout << "\n\n";
// 解析并打印
parse_and_print_udp(datagram);
// 显示分片阈值
show_fragmentation_threshold();
return 0;
}
https://godbolt.org/z/3n7Gn1aYK
编译运行:
g++ -std=c++17 -o udp_demo udp_demo.cpp && ./udp_demo
预期输出:
构造 UDP 数据报:
192.168.1.10:54321 → 192.168.1.20:53
数据:"Hello, UDP!"
原始字节(十六进制):
D4 31 00 35 00 13 XX XX 48 65 6C 6C 6F 2C 20 55
44 50 21
=== UDP 数据报解析 ===
源端口 : 54321
目的端口 : 53
UDP 总长度 : 19 字节(头部 8 + 数据 11)
校验和 : 0xXXXX
数据内容 : "Hello, UDP!"
=== IP 分片阈值 ===
以太网 MTU : 1500 字节
IPv4 头部 : 20 字节
UDP 头部 : 8 字节
UDP 数据最大不分片大小 : 1472 字节
超过此大小 IP 层将分片!
8. 核心知识点总结
UDP 的定位:
传输层最简单的协议
像"明信片"——投出去不管有没有收到
封装结构(IPv4):
[IPv4 头 20B] + [UDP 头 8B] + [数据]
Protocol 字段 = 17
UDP 有: 消息边界保留、错误检测(校验和)、无连接
UDP 没有:可靠传输、顺序、去重、流控、拥塞控制
最大不分片数据 = MTU(1500) - IPv4头(20) - UDP头(8) = 1472 字节
超过此大小 → IP 分片(后续章节详述)
适合 UDP 的场景:
DNS、视频/音频流、游戏、广播/组播、实时控制
UDP 头部详解(第10章 10.2节)
1. UDP 数据报结构总览
UDP(User Datagram Protocol,用户数据报协议)的头部固定为 8 字节,结构极其简单。整个 UDP 数据报由两部分组成:
+---------------------------+---------------------------+
| Source Port Number | Destination Port Number |
| (2 字节) | (2 字节) |
+---------------------------+---------------------------+
| Length | Checksum |
| (2 字节) | (2 字节) |
+-----------------------------------------------------------+
| |
| Payload Data(有效载荷数据,可变长度) |
| |
+-----------------------------------------------------------+
<-- 位编号: 0 15 16 31 -->
- UDP 头部:固定 8 字节,包含 4 个字段,每个字段 2 字节(16 位)
- 有效载荷:长度可变,可以为 0 字节
2. 各字段详细说明
2.1 源端口号(Source Port Number)
- 大小:2 字节(16 位)
- 范围: 0 ∼ 65535 0 \sim 65535 0∼65535(正整数)
- 作用:标识发送方进程,相当于"寄件人信箱编号"
- 特殊规则:源端口号是可选的,如果发送方不需要对方回复,可以将其设置为 0 0 0
类比理解:想象你寄一封信,收件地址必须写清楚,但寄件地址可以不写(如果你不需要回信)。
2.2 目的端口号(Destination Port Number)
- 大小:2 字节(16 位)
- 范围: 0 ∼ 65535 0 \sim 65535 0∼65535
- 作用:标识接收方进程,是**解复用(demultiplex)**的关键字段
什么是解复用?
网络中有很多数据包同时到达一台主机,操作系统需要判断"这个数据包该交给哪个应用程序处理",这个过程就叫解复用。
IP层收到数据包
|
读取 Protocol 字段(IPv4)
或 Next Header 字段(IPv6)
|
+----------+---------+
| |
是 TCP 是 UDP
| |
TCP端口号解复用 UDP端口号解复用
| |
交给对应 交给对应
TCP应用 UDP应用
解复用流程如下:
2.3 端口号的独立性
关键结论:TCP 的端口号和 UDP 的端口号是相互独立的,互不干扰。
这意味着:
- TCP 的 80 端口和 UDP 的 80 端口是两个完全不同的端口
- 两个服务可以同时使用相同的端口号和 IP 地址,只要它们使用不同的传输协议
完整的套接字标识 = { 协议 , IP地址 , 端口号 } \text{完整的套接字标识} = \{\text{协议}, \text{IP地址}, \text{端口号}\} 完整的套接字标识={协议,IP地址,端口号}
注意:虽然 TCP 和 UDP 的端口号相互独立,但在实践中,一个"知名服务"如果同时支持 TCP 和 UDP,通常会分配相同的端口号(纯粹是为了方便记忆,协议本身并不要求这样做)。
例如 DNS 服务同时使用 TCP/53 和 UDP/53。
2.4 长度字段(Length)
- 大小:2 字节(16 位)
- 含义:UDP 头部 + UDP 数据的总字节数
Length = UDP 头部长度 + UDP 数据长度 = 8 + 数据长度(字节) \text{Length} = \text{UDP 头部长度} + \text{UDP 数据长度} = 8 + \text{数据长度(字节)} Length=UDP 头部长度+UDP 数据长度=8+数据长度(字节) - 最小值: 8 8 8(即只有头部,数据为 0 字节)
- 特殊情况:在 IPv6 超大数据报(Jumbogram)中,最小值规则有所不同
Length 字段是冗余的:
| 场景 | UDP数据长度计算方式 |
|---|---|
| UDP over IPv4 | UDP数据报长度 = IPv4总长度 − IPv4头部长度 \text{UDP数据报长度} = \text{IPv4总长度} - \text{IPv4头部长度} UDP数据报长度=IPv4总长度−IPv4头部长度 |
| UDP over IPv6 | UDP数据报长度 = IPv6载荷长度 − 扩展头部长度之和 \text{UDP数据报长度} = \text{IPv6载荷长度} - \text{扩展头部长度之和} UDP数据报长度=IPv6载荷长度−扩展头部长度之和 |
为什么说它"冗余"?因为从 IP 层的信息就能算出 UDP 数据报的长度,Length 字段只是重复了这个信息。尽管如此,Length 字段的值应当与 IP 层计算出的长度一致。
2.5 校验和字段(Checksum)
- 大小:2 字节(16 位)
- 作用:端到端的错误检测,确保数据在传输过程中没有损坏
重要特性:校验和是**端到端(end-to-end)**的,计算范围包括: - UDP 伪头部(Pseudo-header)—— 来自 IP 头部的字段:
- 源 IP 地址
- 目的 IP 地址
- 协议类型
- UDP 长度
- UDP 头部本身
- UDP 数据
UDP 伪头部示意:
+-------------------------------+
| 源 IP 地址(4字节) | <-- 来自 IP 头部
+-------------------------------+
| 目的 IP 地址(4字节) | <-- 来自 IP 头部
+-------------------------------+
| 全0 | 协议(17) | UDP长度 | <-- 构造字段
+-------------------------------+
| UDP 头部 + 数据 |
+-------------------------------+
为什么要包含 IP 地址来计算校验和?
因为校验和覆盖了 IP 地址,所以一旦 NAT(网络地址转换)修改了 IP 头部中的源地址或目的地址,必须同步更新 UDP 校验和,否则接收方校验会失败。
Checksum = ∼ ( 伪头部 + UDP头部 + UDP数据 ) 16位反码求和 \text{Checksum} = \sim\left(\text{伪头部} + \text{UDP头部} + \text{UDP数据}\right)_{\text{16位反码求和}} Checksum=∼(伪头部+UDP头部+UDP数据)16位反码求和
其中 ∼ \sim ∼ 表示按位取反(one’s complement)。
3. 完整的 C++ 代码示例
以下代码演示如何手动构造和解析 UDP 头部,并计算校验和:
#include <iostream>
#include <cstdint>
#include <cstring>
#include <arpa/inet.h> // htons, ntohs, inet_addr
// ============================================================
// UDP 头部结构体(严格按照协议格式,共8字节)
// ============================================================
struct UDPHeader {
uint16_t src_port; // 源端口号(2字节)
uint16_t dst_port; // 目的端口号(2字节)
uint16_t length; // UDP头部+数据总长度(2字节)
uint16_t checksum; // 校验和(2字节)
};
// ============================================================
// UDP 伪头部结构体(用于计算校验和,不实际传输)
// ============================================================
struct PseudoHeader {
uint32_t src_ip; // 源IP地址(4字节)
uint32_t dst_ip; // 目的IP地址(4字节)
uint8_t zero; // 固定为0(填充字节)
uint8_t protocol; // 协议号:UDP = 17
uint16_t udp_length; // UDP长度(与UDP头部中的length相同)
};
// ============================================================
// 计算16位反码校验和
// 参数: data - 待计算的数据指针
// len - 数据字节数
// 返回: 16位校验和(网络字节序)
// ============================================================
uint16_t calculateChecksum(const uint8_t* data, size_t len) {
uint32_t sum = 0;
// 每次取2字节(16位)累加
while (len > 1) {
// 将两个字节组合成16位整数并累加
sum += (static_cast<uint16_t>(data[0]) << 8) | data[1];
data += 2;
len -= 2;
}
// 如果数据长度为奇数,最后一个字节单独处理(高位补0)
if (len == 1) {
sum += static_cast<uint16_t>(*data) << 8;
}
// 将32位累加结果折叠成16位:把高16位加到低16位
while (sum >> 16) {
sum = (sum & 0xFFFF) + (sum >> 16);
}
// 取反得到最终校验和
return static_cast<uint16_t>(~sum);
}
// ============================================================
// 计算 UDP 校验和
// 参数: pseudo - 伪头部
// udphdr - UDP头部(checksum字段需预先置0)
// payload - 载荷数据指针
// pay_len - 载荷数据字节数
// 返回: 16位校验和
// ============================================================
uint16_t udpChecksum(const PseudoHeader& pseudo,
const UDPHeader& udphdr,
const uint8_t* payload,
size_t pay_len)
{
// 将伪头部、UDP头部、载荷拼接到一个缓冲区中
size_t total = sizeof(PseudoHeader) + sizeof(UDPHeader) + pay_len;
uint8_t* buf = new uint8_t[total];
size_t offset = 0;
// 拷贝伪头部
memcpy(buf + offset, &pseudo, sizeof(PseudoHeader));
offset += sizeof(PseudoHeader);
// 拷贝UDP头部
memcpy(buf + offset, &udphdr, sizeof(UDPHeader));
offset += sizeof(UDPHeader);
// 拷贝载荷
memcpy(buf + offset, payload, pay_len);
// 计算校验和
uint16_t cs = calculateChecksum(buf, total);
delete[] buf;
return cs;
}
// ============================================================
// 构造并打印一个 UDP 数据报(演示用)
// ============================================================
void buildAndShowUDPDatagram(
const char* src_ip_str, // 源IP字符串,如 "192.168.1.1"
const char* dst_ip_str, // 目的IP字符串,如 "192.168.1.2"
uint16_t src_port, // 源端口
uint16_t dst_port, // 目的端口
const uint8_t* payload, // 载荷数据
size_t pay_len // 载荷长度(字节)
) {
// ---------- 1. 构造 UDP 头部 ----------
UDPHeader udphdr;
// htons: host to network short,将主机字节序转为网络字节序(大端)
udphdr.src_port = htons(src_port);
udphdr.dst_port = htons(dst_port);
// UDP总长度 = 8字节头部 + 载荷长度
udphdr.length = htons(static_cast<uint16_t>(8 + pay_len));
udphdr.checksum = 0; // 计算前先置0
// ---------- 2. 构造伪头部 ----------
PseudoHeader pseudo;
pseudo.src_ip = inet_addr(src_ip_str); // 字符串IP转32位整数(网络字节序)
pseudo.dst_ip = inet_addr(dst_ip_str);
pseudo.zero = 0;
pseudo.protocol = 17; // UDP 协议号固定为 17
pseudo.udp_length = udphdr.length; // 与UDP头部中的length相同
// ---------- 3. 计算校验和 ----------
udphdr.checksum = udpChecksum(pseudo, udphdr, payload, pay_len);
// ---------- 4. 打印结果 ----------
std::cout << "========== UDP 数据报构造结果 ==========\n";
std::cout << "源IP: " << src_ip_str << "\n";
std::cout << "目的IP: " << dst_ip_str << "\n";
std::cout << "源端口: " << src_port << "\n";
std::cout << "目的端口: " << dst_port << "\n";
std::cout << "UDP总长度: " << (8 + pay_len) << " 字节\n";
// ntohs: network to host short,将网络字节序转为主机字节序(用于显示)
std::cout << "校验和: 0x" << std::hex << ntohs(udphdr.checksum) << std::dec << "\n";
std::cout << "载荷内容: ";
for (size_t i = 0; i < pay_len; ++i) {
std::cout << static_cast<char>(payload[i]);
}
std::cout << "\n";
std::cout << "========================================\n";
}
// ============================================================
// 主函数:演示UDP数据报的构造
// ============================================================
int main() {
// 模拟一段 DNS 查询载荷(简化示意,非真实DNS格式)
const char* msg = "Hello UDP";
const uint8_t* payload = reinterpret_cast<const uint8_t*>(msg);
size_t pay_len = strlen(msg);
// 构造并显示一个 UDP 数据报
// 源IP: 192.168.1.10, 目的IP: 192.168.1.1
// 源端口: 12345(随机临时端口), 目的端口: 53(DNS服务)
buildAndShowUDPDatagram(
"192.168.1.10",
"192.168.1.1",
12345,
53,
payload,
pay_len
);
return 0;
}
https://godbolt.org/z/n6obadT3e
编译命令(Linux/macOS):
g++ -std=c++17 -o udp_demo udp_demo.cpp && ./udp_demo
4. UDP 数据报传输过程演示
发送方(192.168.1.10:12345)
|
| 构造 UDP 数据报
| [源端口=12345][目的端口=53][长度=17][校验和=0xXXXX][Hello UDP]
|
v
路由器/NAT
|
| 若 NAT 修改了 IP 地址
| --> 必须同步修改 UDP 校验和!
|
v
接收方(192.168.1.1:53)
|
| 1. IP层读取 Protocol=17,识别为UDP
| 2. 将数据报交给 UDP 模块
| 3. UDP 读取目的端口=53,找到对应的 DNS 进程
| 4. 验证校验和
| 5. 将载荷 "Hello UDP" 交给 DNS 应用
5. 知识要点总结
| 字段 | 大小 | 是否必须 | 核心作用 |
|---|---|---|---|
| 源端口号 | 2字节 | 可选(可为0) | 标识发送方进程 |
| 目的端口号 | 2字节 | 必须 | 解复用,找到目标进程 |
| 长度 | 2字节 | 必须 | 指明UDP报文总长度(冗余但需一致) |
| 校验和 | 2字节 | 实践中通常必须 | 端到端错误检测(含IP伪头部) |
核心公式回顾:
UDP 总长度:
UDP Length = 8 + 数据字节数 \text{UDP Length} = 8 + \text{数据字节数} UDP Length=8+数据字节数
IPv4 中 UDP 数据报长度验证:
UDP数据报长度 = IPv4总长度 − IPv4头部长度 \text{UDP数据报长度} = \text{IPv4总长度} - \text{IPv4头部长度} UDP数据报长度=IPv4总长度−IPv4头部长度
IPv6 中 UDP 数据报长度验证:
UDP数据报长度 = IPv6载荷长度 − ∑ 扩展头部长度 \text{UDP数据报长度} = \text{IPv6载荷长度} - \sum\text{扩展头部长度} UDP数据报长度=IPv6载荷长度−∑扩展头部长度
校验和计算(16位反码求和再取反):
Checksum = ∼ ( ∑ i W i ) m o d 2 16 \text{Checksum} = \sim\left(\sum_{i} W_i\right)_{\bmod 2^{16}} Checksum=∼(i∑Wi)mod216
其中 W i W_i Wi 是每个16位字, ∼ \sim ∼ 表示按位取反。
UDP 校验和详解(第10章 10.3节)
1. 什么是 UDP 校验和?
UDP 校验和是第一个我们遇到的端到端传输层校验和(ICMP 虽然有校验和,但它不是真正的传输层协议)。
它的覆盖范围是:
校验和覆盖范围 = 伪头部(Pseudo-Header)+ UDP头部 + UDP数据
端到端的含义是:
- 由最初的发送方计算
- 由最终的接收方验证
- 中途路由器不重新计算(除非经过 NAT)
2. 与 IPv4 头部校验和的区别
| 比较项 | IPv4 头部校验和 | UDP 校验和 |
|---|---|---|
| 覆盖范围 | 仅 IPv4 头部 | UDP头部 + 数据 + 伪头部 |
| 是否每跳重算 | 是(因为 TTL 每跳减1) | 否(端到端) |
| 是否必须 | 必须 | IPv4中可选(强烈建议),IPv6中必须 |
为什么 IPv6 中 UDP 校验和是强制的?
因为 IPv6 头部本身没有头部校验和字段,所以传输层必须自己负责错误检测,不能依赖网络层。
3. 校验和计算的两个特殊细节
3.1 奇数字节的填充(Padding)
UDP 数据的长度可能是奇数字节,但校验和算法每次处理 16 位(2字节)。
解决方案:在奇数长度的数据末尾虚拟追加一个值为 0 0 0 的填充字节,仅用于计算,不实际发送。
若数据为奇数字节:
[数据字节 0][数据字节 1] ... [数据字节 N][虚拟填充 0x00]
^^^^^^^^^^^^^^^^
仅用于计算,不传输
3.2 伪头部(Pseudo-Header)
UDP 校验和计算引入了一个"虚拟"的伪头部,它来自 IP 层的字段:
IPv4 伪头部(12 字节)结构:
0 15 16 31
+----------------------------------+
| 源 IP 地址(4字节) | <-- 来自 IPv4 头部
+----------------------------------+
| 目的 IP 地址(4字节) | <-- 来自 IPv4 头部
+--------+---------+---------------+
| Zero | Proto | UDP Length | <-- Zero=0, Proto=17, Length来自UDP头部
| (1字节) | (1字节) | (2字节) |
+--------+---------+---------------+
IPv6 伪头部(40 字节)结构类似,但地址更长(各16字节)。
伪头部的目的:让 UDP 层能够验证数据报是否到达了正确的目的地,防止:
- IP 层接受了一个地址错误的数据报
- IP 层把一个属于其他传输协议的数据报错误地交给了 UDP
这是一种"层违规(layering violation)":UDP(传输层)直接处理了 IP(网络层)的字段。但这被认为是可以接受的小代价,用于换取更可靠的错误检测。
4. 校验和的完整计算过程
将以下内容拼接后,对所有 16 位字做反码求和,再取反:
Checksum = ∼ ( ∑ i W i ) m o d ( 2 16 − 1 ) \text{Checksum} = \sim \left( \sum_{i} W_i \right)_{\bmod (2^{16}-1)} Checksum=∼(i∑Wi)mod(216−1)
其中 W i W_i Wi 是每个 16 位字, ∼ \sim ∼ 表示按位取反(one’s complement),求和采用反码加法(进位回卷到最低位)。
拼接顺序如下:
+=======================+
| 伪头部(12字节) | <-- 不传输,仅用于计算
+=======================+
| UDP 头部(8字节) | checksum字段先置0
+=======================+
| UDP 数据(N字节) |
+=======================+
| 填充字节(若N为奇数) | <-- 0x00,不传输
+=======================+
注意:UDP Length 字段在校验和中出现了两次——一次在伪头部,一次在 UDP 头部。
5. 校验和特殊值的含义
| 校验和值 | 含义 |
|---|---|
| 0xFFFF \text{0xFFFF} 0xFFFF | 计算结果为全1,正常存储(当计算结果为0x0000时,用0xFFFF代替) |
| 0x0000 \text{0x0000} 0x0000 | 发送方未计算校验和(表示"我没算,接收方无需验证") |
为什么计算结果为 0 0 0 时要存成 0xFFFF \text{0xFFFF} 0xFFFF?
在反码算术中, 0x0000 \text{0x0000} 0x0000 和 0xFFFF \text{0xFFFF} 0xFFFF 是等价的(互为反码),因此用 0xFFFF \text{0xFFFF} 0xFFFF 表示"校验和恰好计算为0",而保留 0x0000 \text{0x0000} 0x0000 专门表示"未计算校验和"。
∼ 0 x 0000 = 0 x F F F F (反码取反) \sim 0x0000 = 0xFFFF \quad \text{(反码取反)} ∼0x0000=0xFFFF(反码取反)
0 x 0000 ⇒ 存储为 0 x F F F F 0x0000 \Rightarrow \text{存储为} \; 0xFFFF 0x0000⇒存储为0xFFFF
6. 接收方的处理逻辑
“静默丢弃”:UDP 检测到校验和错误后,直接丢弃数据报,不通知发送方,只更新内部统计计数器。这与 TCP 不同,TCP 会通过重传机制恢复数据。
7. 为什么校验和不能被关闭?
历史教训:1980年代,部分厂商(如 Sun)为了加速 NFS(网络文件系统,基于 UDP)而默认关闭 UDP 校验和。
这引发了严重问题:
问题1:路由器软硬件 Bug
路由器在转发数据报时可能悄悄修改其中的比特
--> 没有校验和 --> 接收方无法检测 --> 数据静默损坏
问题2:数据链路层保护不够
部分老旧协议(如 SLIP,串行线路IP)没有数据链路层校验
--> 单靠链路层 CRC 不足以覆盖所有情况
问题3:NAT 的存在
NAT 修改 IP 地址后必须同步更新 UDP 校验和
--> 若校验和被关闭,NAT 的改动就无法被验证
RFC 1122 明确规定:
- UDP 校验和必须默认开启
- 若收到的数据报校验和非零,接收方必须验证它
8. NAT 与 UDP 校验和的关系
由于伪头部包含了 IP 层的源/目的地址,当数据报经过 NAT 时:
原始数据报:
源IP = 192.168.1.10 目的IP = 8.8.8.8
校验和 = 0xABCD(基于上述IP计算)
经过 NAT 后:
源IP = 203.0.113.5(公网IP) 目的IP = 8.8.8.8
校验和 = ???(必须重新计算!)
NAT 必须同时修改:
- IP 头部的地址字段
- IP 头部的校验和
- UDP 伪头部隐含的地址(重算 UDP 校验和)
- 若端口号也变了,还需更新端口相关校验
这意味着 NAT 在同一时刻修改了多个协议层的内容,是一种"层违规"。但这是由伪头部机制本身决定的,NAT 别无选择。
9. 部分校验和(UDP-Lite)
对于多媒体应用(如视频流、语音通话),少量数据损坏通常可以接受(人耳/人眼有一定容错性),但为了保护头部信息(端口号等),提出了部分校验和的概念:
- 只对载荷的指定部分(由应用层决定)计算校验和
- 头部仍然被完整保护
- 在 UDP-Lite 协议(Section 10.6)中实现
10. 完整 C++ 代码示例
#include <iostream>
#include <cstdint>
#include <cstring>
#include <vector>
#include <iomanip>
#include <arpa/inet.h> // htons, ntohs, inet_addr
// ============================================================
// UDP 头部结构(8字节)
// ============================================================
struct UDPHeader {
uint16_t src_port; // 源端口号
uint16_t dst_port; // 目的端口号
uint16_t length; // UDP总长度(头部+数据)
uint16_t checksum; // 校验和(计算时先置0)
};
// ============================================================
// IPv4 伪头部结构(12字节,仅用于校验和计算,不传输)
// ============================================================
struct PseudoHeaderV4 {
uint32_t src_ip; // 源IPv4地址(4字节)
uint32_t dst_ip; // 目的IPv4地址(4字节)
uint8_t zero; // 固定为0(填充)
uint8_t protocol; // 协议号:UDP = 17(0x11)
uint16_t udp_length; // UDP长度(与UDP头部中length字段相同)
};
// ============================================================
// 16位反码求和(Internet Checksum 核心算法)
// 参数: data - 数据指针
// len - 数据字节数(可以是奇数,内部自动填充)
// 返回: 最终校验和(已取反,即存入头部的值)
// ============================================================
uint16_t internetChecksum(const uint8_t* data, size_t len) {
uint32_t sum = 0;
// 每次取 2 字节累加(大端序:高字节在前)
while (len > 1) {
uint16_t word = (static_cast<uint16_t>(data[0]) << 8)
| static_cast<uint16_t>(data[1]);
sum += word;
data += 2;
len -= 2;
}
// 若还剩 1 个字节(奇数长度),虚拟在末尾补 0x00 后处理
// 相当于:[最后一字节, 0x00] 组成 16 位字
if (len == 1) {
sum += static_cast<uint16_t>(data[0]) << 8;
// 低8位为虚拟的 0x00,不需要加
}
// 反码折叠:将 32 位结果折叠成 16 位
// 原理:超出 16 位的进位(高 16 位)加回低 16 位(反码加法)
while (sum >> 16) {
sum = (sum & 0xFFFF) + (sum >> 16);
}
// 取反得到校验和
// 特殊处理:若结果为 0x0000,按协议规定存储为 0xFFFF
uint16_t result = static_cast<uint16_t>(~sum);
if (result == 0x0000) {
result = 0xFFFF; // 0x0000 保留给"未计算校验和",真正为0时用0xFFFF表示
}
return result;
}
// ============================================================
// 计算 UDP/IPv4 校验和
// 参数: src_ip - 源IP(网络字节序,来自inet_addr等)
// dst_ip - 目的IP(网络字节序)
// udphdr - UDP头部(checksum字段应预先置0)
// payload - UDP数据
// pay_len - UDP数据字节数
// 返回: 校验和(主机字节序,存入udphdr.checksum前无需再转换)
// ============================================================
uint16_t computeUDPChecksum(uint32_t src_ip,
uint32_t dst_ip,
const UDPHeader& udphdr,
const uint8_t* payload,
size_t pay_len)
{
// 构造伪头部
PseudoHeaderV4 pseudo;
pseudo.src_ip = src_ip;
pseudo.dst_ip = dst_ip;
pseudo.zero = 0;
pseudo.protocol = 17; // UDP协议号
pseudo.udp_length = udphdr.length; // 与UDP头部length相同
// 拼接:伪头部 + UDP头部 + UDP数据(+ 可能的填充字节)
// 注意:填充字节由 internetChecksum 内部自动处理
size_t buf_size = sizeof(PseudoHeaderV4) + sizeof(UDPHeader) + pay_len;
std::vector<uint8_t> buf(buf_size, 0); // 初始化为0,天然处理了奇数填充
size_t offset = 0;
// 拷贝伪头部
memcpy(buf.data() + offset, &pseudo, sizeof(PseudoHeaderV4));
offset += sizeof(PseudoHeaderV4);
// 拷贝UDP头部
memcpy(buf.data() + offset, &udphdr, sizeof(UDPHeader));
offset += sizeof(UDPHeader);
// 拷贝UDP数据
memcpy(buf.data() + offset, payload, pay_len);
// 计算校验和(buf_size可能为奇数,internetChecksum内部处理填充)
return internetChecksum(buf.data(), buf_size);
}
// ============================================================
// 接收方验证校验和
// 原理:将整个(伪头部+UDP头部+数据)再做一次反码求和
// 若结果为 0xFFFF,则校验和正确
// 返回: true = 校验和正确,false = 校验和错误
// ============================================================
bool verifyUDPChecksum(uint32_t src_ip,
uint32_t dst_ip,
const UDPHeader& udphdr,
const uint8_t* payload,
size_t pay_len)
{
// 校验和为0表示发送方未计算,直接接受
if (udphdr.checksum == 0x0000) {
std::cout << "[验证] 发送方未计算校验和,直接接受\n";
return true;
}
// 构造用于验证的缓冲区(与计算时完全相同)
PseudoHeaderV4 pseudo;
pseudo.src_ip = src_ip;
pseudo.dst_ip = dst_ip;
pseudo.zero = 0;
pseudo.protocol = 17;
pseudo.udp_length = udphdr.length;
size_t buf_size = sizeof(PseudoHeaderV4) + sizeof(UDPHeader) + pay_len;
std::vector<uint8_t> buf(buf_size, 0);
size_t offset = 0;
memcpy(buf.data() + offset, &pseudo, sizeof(PseudoHeaderV4));
offset += sizeof(PseudoHeaderV4);
memcpy(buf.data() + offset, &udphdr, sizeof(UDPHeader));
offset += sizeof(UDPHeader);
memcpy(buf.data() + offset, payload, pay_len);
// 接收方对整个数据(含已填入的checksum)做反码求和
// 若无错误,结果应为 0xFFFF(所有位为1)
uint32_t sum = 0;
const uint8_t* data = buf.data();
size_t len = buf_size;
while (len > 1) {
uint16_t word = (static_cast<uint16_t>(data[0]) << 8)
| static_cast<uint16_t>(data[1]);
sum += word;
data += 2;
len -= 2;
}
if (len == 1) {
sum += static_cast<uint16_t>(data[0]) << 8;
}
while (sum >> 16) {
sum = (sum & 0xFFFF) + (sum >> 16);
}
// 结果应为 0xFFFF 表示无错误
return (static_cast<uint16_t>(sum) == 0xFFFF);
}
// ============================================================
// 主函数:演示发送方计算和接收方验证 UDP 校验和
// ============================================================
int main() {
// ---------- 发送方 ----------
const char* src_ip_str = "192.168.1.10";
const char* dst_ip_str = "8.8.8.8";
uint16_t src_port = 54321;
uint16_t dst_port = 53; // DNS 端口
// 模拟一段奇数长度的 UDP 数据(5字节,触发填充逻辑)
const uint8_t payload[] = {'H', 'e', 'l', 'l', 'o'}; // 5字节(奇数)
size_t pay_len = sizeof(payload);
// 构造 UDP 头部
UDPHeader udphdr;
udphdr.src_port = htons(src_port);
udphdr.dst_port = htons(dst_port);
udphdr.length = htons(static_cast<uint16_t>(8 + pay_len));
udphdr.checksum = 0; // 计算前必须置0
uint32_t src_ip = inet_addr(src_ip_str);
uint32_t dst_ip = inet_addr(dst_ip_str);
// 计算校验和
uint16_t cs = computeUDPChecksum(src_ip, dst_ip, udphdr, payload, pay_len);
udphdr.checksum = htons(cs); // 转为网络字节序存入头部
// 打印发送方结果
std::cout << "========== 发送方构造 UDP 数据报 ==========\n";
std::cout << "源IP: " << src_ip_str << "\n";
std::cout << "目的IP: " << dst_ip_str << "\n";
std::cout << "源端口: " << src_port << "\n";
std::cout << "目的端口: " << dst_port << "\n";
std::cout << "UDP总长度: " << (8 + pay_len) << " 字节\n";
std::cout << "数据: Hello(5字节,奇数,自动填充0x00)\n";
std::cout << "计算校验和: 0x" << std::hex << std::uppercase
<< std::setw(4) << std::setfill('0') << cs << std::dec << "\n\n";
// ---------- 接收方(无损传输) ----------
std::cout << "========== 接收方验证(无误码) ==========\n";
bool ok = verifyUDPChecksum(src_ip, dst_ip, udphdr, payload, pay_len);
std::cout << "[验证结果] " << (ok ? "校验和正确,接受数据报" : "校验和错误,静默丢弃!") << "\n\n";
// ---------- 模拟传输中数据被篡改 ----------
uint8_t corrupted_payload[] = {'H', 'e', 'l', 'l', 'X'}; // 最后一字节被改
std::cout << "========== 接收方验证(数据被篡改: Hello -> HellX) ==========\n";
bool ok2 = verifyUDPChecksum(src_ip, dst_ip, udphdr, corrupted_payload, pay_len);
std::cout << "[验证结果] " << (ok2 ? "校验和正确,接受数据报" : "校验和错误,静默丢弃!") << "\n";
return 0;
}
https://godbolt.org/z/ezhzjzY5M
编译与运行:
g++ -std=c++17 -o udp_checksum udp_checksum.cpp && ./udp_checksum
预期输出:
========== 发送方构造 UDP 数据报 ==========
源IP: 192.168.1.10
目的IP: 8.8.8.8
源端口: 54321
目的端口: 53
UDP总长度: 13 字节
数据: Hello(5字节,奇数,自动填充0x00)
计算校验和: 0x????
========== 接收方验证(无误码) ==========
[验证结果] 校验和正确,接受数据报
========== 接收方验证(数据被篡改: Hello -> HellX) ==========
[验证结果] 校验和错误,静默丢弃!
11. 校验和计算全流程 ASCII 示意
发送方计算校验和:
伪头部(12字节)
+-----------------+
| 192.168.1.10 | 源IP(4字节)
+-----------------+
| 8.8.8.8 | 目的IP(4字节)
+-----------------+
| 0x00 | 17 | 13 | Zero + Proto + UDP Length(4字节)
+-----------------+
UDP头部(8字节)
+-----------------+
| 54321 | 53 | 源端口 + 目的端口
+-----------------+
| 13 | 0000 | Length + Checksum(先置0)
+-----------------+
UDP数据(5字节 + 1虚拟填充字节)
+-----------------+
| H e l l o | 00 | (0x00是虚拟填充,不传输)
+-----------------+
|
v
对所有16位字做反码累加求和
|
v
结果取反 --> Checksum
(若结果为0x0000,存储0xFFFF)
接收方验证(将checksum也纳入计算):
同样的拼接(含已填入的checksum)
|
v
反码求和
|
v
结果 == 0xFFFF?
是 --> 正确,接受
否 --> 错误,静默丢弃
12. 知识要点总结
| 要点 | 说明 |
|---|---|
| 端到端 | 发送方计算,接收方验证,中途不改动 |
| 伪头部 | 12字节(IPv4),含源/目的IP,不传输,仅用于计算 |
| 奇数填充 | 数据为奇数字节时虚拟补一个 0 x 00 0x00 0x00,不传输 |
| 0 x 0000 0x0000 0x0000 | 表示发送方未计算校验和,接收方无需验证 |
| 0 x F F F F 0xFFFF 0xFFFF | 计算结果为0时的替代存储值 |
| NAT 影响 | NAT 改IP地址后,必须同步重算 UDP 校验和 |
| RFC 1122 | 要求默认开启,且收到非零校验和时必须验证 |
公式回顾:
计算时(发送方),checksum 字段先置 0 0 0:
Checksum = ∼ ( ∑ i = 1 N W i ) 反码 \text{Checksum} = \sim \left( \sum_{i=1}^{N} W_i \right)_{\text{反码}} Checksum=∼(i=1∑NWi)反码
验证时(接收方),checksum 已填入,对整体求和:
∑ i = 1 N W i = 反码 0 xFFFF ⇒ 正确 \sum_{i=1}^{N} W_i \stackrel{\text{反码}}{=} 0\text{xFFFF} \Rightarrow \text{正确} i=1∑NWi=反码0xFFFF⇒正确
其中 W i W_i Wi 是每个 16 位字, ∼ \sim ∼ 表示按位取反。
UDP 实例分析 & IPv6 伪头部详解(第10章 10.4~10.5节)
1. 实验环境说明
本节使用 sock 程序向目标主机发送 UDP 数据报,并用 tcpdump 抓包观察。
实验拓扑:
发送方 接收方
10.0.0.5 ─────────────────► 10.0.0.3
port 46274 port 9 (discard)
tcpdump 抓包命令:
tcpdump -n -p -s 1500 -vvv host 10.0.0.3 and \( udp or icmp \)
各参数含义:
| 参数 | 含义 |
|---|---|
-n |
不将 IP 地址转换为主机名(直接显示数字IP) |
-p |
不将网络接口置为混杂模式 |
-s 1500 |
最多捕获每个包的前 1500 字节 |
-vvv |
极详细的输出模式 |
host 10.0.0.3 and \( udp or icmp \) |
只捕获与 10.0.0.3 之间的 UDP 或 ICMP 流量 |
2. 场景一:服务端正常运行
sock 命令:
sock -v -u -i 10.0.0.3 discard
参数说明:
-v:显示详细信息(含临时端口号)-u:使用 UDP(默认是 TCP)-i:主动发送数据而非等待标准输入
程序输出:
connected on 10.0.0.5.46274 to 10.0.0.3
wrote 1024 bytes
... (重复 1023 次)
tcpdump 抓包输出(Listing 10-1):
1 22:52:53.102838 10.0.0.5.46274 > 10.0.0.3.9:
[udp sum ok] udp 1024 (DF) (ttl 64, id 24462, len 1052)
2 22:52:53.102964 10.0.0.5.46274 > 10.0.0.3.9:
[udp sum ok] udp 1024 (DF) (ttl 64, id 24463, len 1052)
3 22:52:53.103091 10.0.0.5.46274 > 10.0.0.3.9:
[udp sum ok] udp 1024 (DF) (ttl 64, id 24464, len 1052)
4 22:52:53.103215 10.0.0.5.46274 > 10.0.0.3.9:
[udp sum ok] udp 1024 (DF) (ttl 64, id 24465, len 1052)
... 重复 1020 次 ...
2.1 数据包大小分析
每个 UDP/IPv4 数据报的总长度 = 1052 = 1052 =1052 字节,拆分如下:
总长度 = 20 ⏟ IPv4头部 + 8 ⏟ UDP头部 + 1024 ⏟ UDP载荷 = 1052 字节 \text{总长度} = \underbrace{20}_{\text{IPv4头部}} + \underbrace{8}_{\text{UDP头部}} + \underbrace{1024}_{\text{UDP载荷}} = 1052 \text{ 字节} 总长度=IPv4头部
20+UDP头部
8+UDP载荷
1024=1052 字节
2.2 从 tcpdump 输出中读到的信息
| 字段 | 观察值 | 含义 |
|---|---|---|
udp sum ok |
出现在每个包 | UDP 校验和已启用且经 tcpdump 验证正确 |
DF |
每个包都有 | Don’t Fragment 位置1,不允许分片 |
ttl 64 |
固定为 64 | IPv4 生存时间字段 |
id 24462... |
每包递增1 | IPv4 标识字段,每个数据报唯一 |
| 包间隔 | 约 100 μ s 100\,\mu s 100μs | 相邻包时间戳之差约为 0.000126s |
2.3 UDP 无确认机制的含义
发送方 接收方
| |
|──── UDP datagram 1 ─────────►|
|──── UDP datagram 2 ─────────►|
|──── UDP datagram 3 ─────────►| (无任何ACK返回)
|──── ... |
|──── UDP datagram 1024 ──────►|
| |
发送完毕,但发送方不知道
对方是否真的收到了!
与 TCP 不同,UDP 不握手、不确认,发送方无法知道数据是否真正到达。
3. 场景二:服务端已关闭(ICMP Port Unreachable)
第二次运行 sock,但 discard 服务已停止:
connected on 10.0.0.5.46294 to 10.0.0.3
wrote 1 bytes
write returned -1, expected 1024: Connection refused
tcpdump 抓包输出(Listing 10-2):
1 22:55:07.223094 10.0.0.5.46294 > 10.0.0.3.9:
[udp sum ok] udp 1024 (DF) (ttl 64, id 37874, len 1052)
2 22:55:07.223134 10.0.0.3 > 10.0.0.5: icmp:
10.0.0.3 udp port 9 unreachable for
10.0.0.5.46294 > 10.0.0.3.9:
udp 1024 (DF) (ttl 64, id 37874, len 1052)
[tos 0xc0] (ttl 255, id 63302, len 576)
3.1 交互流程
3.2 ICMP Port Unreachable 消息详解
当目的主机收到 UDP 数据报,但对应端口没有任何进程在监听时,内核会自动生成一个 ICMPv4 目的不可达(端口不可达) 消息返回给发送方。
ICMP 报文中包含了原始数据报的前 556 字节,让发送方能够确认是哪个端口不可达。
ICMP Port Unreachable 消息结构:
+-----------------------------+
| ICMP 类型=3 代码=3 | 目的不可达,端口不可达
+-----------------------------+
| 原始 UDP 数据报(前556字节) | 帮助发送方定位问题
+-----------------------------+
3.3 临时端口号的变化
| 运行次数 | 源端口号 |
|---|---|
| 第一次 | 46274 |
| 第二次 | 46294 |
每次运行程序,操作系统会分配不同的临时端口(Ephemeral Port)。
RFC 建议临时端口范围为 49152 ∼ 65535 49152 \sim 65535 49152∼65535,但此处观察到的 46274 46274 46274 和 46294 46294 46294 均低于此范围,属于不符合规范的行为。
Linux 系统可通过以下方式查看/修改本地端口范围:
# 查看当前范围
cat /proc/sys/net/ipv4/ip_local_port_range
# 修改范围(例如改为 32768 ~ 60999)
echo "32768 60999" > /proc/sys/net/ipv4/ip_local_port_range
4. UDP over IPv6 的伪头部(40 字节)
4.1 IPv6 伪头部结构
0 15 16 31
+----------------------------------+
| |
| 源 IPv6 地址(16字节) | 来自 IPv6 头部
| |
| |
+----------------------------------+
| |
| 目的 IPv6 地址(16字节) | 来自 IPv6 头部
| |
| |
+----------------------------------+
| Length(4字节) | 32位,比IPv4伪头部更大
+----------------------------------+
| Reserved(0)(3字节) |NextHdr| Next Header = 17(UDP)
+----------------------------------+
<-- 共 40 字节 -->
4.2 IPv4 伪头部 vs IPv6 伪头部对比
| 字段 | IPv4 伪头部(12字节) | IPv6 伪头部(40字节) |
|---|---|---|
| 源地址 | 4 字节 | 16 字节 |
| 目的地址 | 4 字节 | 16 字节 |
| 填充/保留 | 1字节零 | 3字节零 |
| 协议/Next Header | 1字节(值=17) | 1字节(值=17) |
| 长度字段 | 2字节(16位) | 4字节(32位) |
| 总计 | 12 字节 | 40 字节 |
4.3 为什么 IPv6 中 UDP 校验和是强制的?
IPv4 : IP头部校验和 ⏟ 保护IP头部 + UDP校验和(可选) ⏟ 保护UDP \text{IPv4}: \underbrace{\text{IP头部校验和}}_{\text{保护IP头部}} + \underbrace{\text{UDP校验和(可选)}}_{\text{保护UDP}} IPv4:保护IP头部
IP头部校验和+保护UDP
UDP校验和(可选)
IPv6 : 无IP头部校验和 ⏟ ! + UDP校验和(强制) ⏟ 必须保护所有内容 \text{IPv6}: \underbrace{\text{无IP头部校验和}}_{\text{!}} + \underbrace{\text{UDP校验和(强制)}}_{\text{必须保护所有内容}} IPv6:!
无IP头部校验和+必须保护所有内容
UDP校验和(强制)
IPv6 设计时为了提高转发效率,去掉了 IP 头部校验和(由路由器每跳重算会带来性能开销)。因此,UDP 在 IPv6 中必须启用校验和,否则数据错误将无法被检测。
4.4 Next Header 字段的含义
IPv6 使用扩展头部链的设计,数据报可能经过多个扩展头:
伪头部中的 Next Header 字段,取的是最后一个扩展头部中的 Next Header 值(即直接指向 UDP 的那个,值为 17 17 17),而不是 IPv6 基本头部中的值。
5. 完整 C++ 代码示例
以下代码演示 IPv4 和 IPv6 两种伪头部的构造与校验和计算:
#include <iostream>
#include <cstdint>
#include <cstring>
#include <vector>
#include <iomanip>
#include <arpa/inet.h> // htons, htonl, ntohs, inet_addr, inet_pton
// ============================================================
// UDP 头部(8字节,IPv4和IPv6通用)
// ============================================================
struct UDPHeader {
uint16_t src_port;
uint16_t dst_port;
uint16_t length;
uint16_t checksum;
};
// ============================================================
// IPv4 伪头部(12字节)
// ============================================================
struct PseudoV4 {
uint32_t src_ip; // 源IPv4地址(网络字节序)
uint32_t dst_ip; // 目的IPv4地址(网络字节序)
uint8_t zero; // 固定为0
uint8_t protocol; // UDP = 17
uint16_t udp_len; // UDP总长度(网络字节序)
};
// ============================================================
// IPv6 伪头部(40字节)
// ============================================================
struct PseudoV6 {
uint8_t src_ip[16]; // 源IPv6地址(16字节)
uint8_t dst_ip[16]; // 目的IPv6地址(16字节)
uint32_t udp_len; // UDP总长度(32位,网络字节序)
uint8_t reserved[3];// 保留字段,固定为0
uint8_t next_hdr; // Next Header = 17(UDP)
};
// ============================================================
// 16位反码校验和计算(Internet Checksum)
// 参数: data - 数据缓冲区指针
// len - 字节数(可以为奇数,内部自动补0处理)
// 返回: 校验和(若结果为0x0000则返回0xFFFF)
// ============================================================
uint16_t internetChecksum(const uint8_t* data, size_t len) {
uint32_t sum = 0;
// 每次取2字节(16位)做反码累加
while (len > 1) {
uint16_t word = (static_cast<uint16_t>(data[0]) << 8)
| static_cast<uint16_t>(data[1]);
sum += word;
data += 2;
len -= 2;
}
// 奇数字节:最后1字节高位对齐,低位虚拟补0
if (len == 1) {
sum += static_cast<uint16_t>(data[0]) << 8;
}
// 反码折叠:将进位加回低16位
while (sum >> 16) {
sum = (sum & 0xFFFF) + (sum >> 16);
}
uint16_t result = static_cast<uint16_t>(~sum);
// 0x0000保留给"未计算"标记,真正为0时存0xFFFF
return (result == 0x0000) ? 0xFFFF : result;
}
// ============================================================
// 计算 UDP/IPv4 校验和
// ============================================================
uint16_t udpChecksumV4(const char* src_ip_str,
const char* dst_ip_str,
const UDPHeader& hdr,
const uint8_t* payload,
size_t pay_len)
{
PseudoV4 pseudo;
pseudo.src_ip = inet_addr(src_ip_str); // 字符串IP转网络字节序整数
pseudo.dst_ip = inet_addr(dst_ip_str);
pseudo.zero = 0;
pseudo.protocol = 17;
pseudo.udp_len = hdr.length; // 已经是网络字节序
// 拼接:伪头部 + UDP头部 + 载荷
size_t total = sizeof(PseudoV4) + sizeof(UDPHeader) + pay_len;
std::vector<uint8_t> buf(total, 0);
size_t off = 0;
memcpy(buf.data() + off, &pseudo, sizeof(PseudoV4)); off += sizeof(PseudoV4);
memcpy(buf.data() + off, &hdr, sizeof(UDPHeader)); off += sizeof(UDPHeader);
memcpy(buf.data() + off, payload, pay_len);
return internetChecksum(buf.data(), total);
}
// ============================================================
// 计算 UDP/IPv6 校验和
// ============================================================
uint16_t udpChecksumV6(const char* src_ip6_str,
const char* dst_ip6_str,
const UDPHeader& hdr,
const uint8_t* payload,
size_t pay_len)
{
PseudoV6 pseudo;
memset(&pseudo, 0, sizeof(PseudoV6));
// inet_pton: 将IPv6字符串地址转为16字节二进制(网络字节序)
inet_pton(AF_INET6, src_ip6_str, pseudo.src_ip);
inet_pton(AF_INET6, dst_ip6_str, pseudo.dst_ip);
// IPv6伪头部的长度字段是32位
uint32_t udp_total = ntohs(hdr.length); // 先转为主机字节序
pseudo.udp_len = htonl(udp_total); // 再转为32位网络字节序
pseudo.next_hdr = 17; // UDP协议号
// 拼接:伪头部(40字节) + UDP头部(8字节) + 载荷
size_t total = sizeof(PseudoV6) + sizeof(UDPHeader) + pay_len;
std::vector<uint8_t> buf(total, 0);
size_t off = 0;
memcpy(buf.data() + off, &pseudo, sizeof(PseudoV6)); off += sizeof(PseudoV6);
memcpy(buf.data() + off, &hdr, sizeof(UDPHeader)); off += sizeof(UDPHeader);
memcpy(buf.data() + off, payload, pay_len);
return internetChecksum(buf.data(), total);
}
// ============================================================
// 打印十六进制校验和
// ============================================================
void printChecksum(const std::string& label, uint16_t cs) {
std::cout << label << ": 0x"
<< std::hex << std::uppercase
<< std::setw(4) << std::setfill('0') << cs
<< std::dec << "\n";
}
// ============================================================
// 主函数:分别演示 IPv4 和 IPv6 的 UDP 校验和计算
// ============================================================
int main() {
// 模拟1024字节的载荷(全部填充为 'A')
const size_t PAY_LEN = 1024;
std::vector<uint8_t> payload(PAY_LEN, 'A');
// -------- UDP/IPv4 场景(对应 Listing 10-1)--------
{
UDPHeader hdr;
hdr.src_port = htons(46274); // 客户端临时端口
hdr.dst_port = htons(9); // discard 服务端口
hdr.length = htons(static_cast<uint16_t>(8 + PAY_LEN));
hdr.checksum = 0; // 计算前必须置0
uint16_t cs = udpChecksumV4("10.0.0.5", "10.0.0.3",
hdr, payload.data(), PAY_LEN);
hdr.checksum = htons(cs);
std::cout << "===== UDP/IPv4 校验和计算 =====\n";
std::cout << "源: 10.0.0.5:46274\n";
std::cout << "目的: 10.0.0.3:9 (discard)\n";
std::cout << "载荷大小: " << PAY_LEN << " 字节\n";
std::cout << "UDP总长: " << (8 + PAY_LEN) << " 字节\n";
printChecksum("UDP校验和", cs);
std::cout << "\n";
}
// -------- UDP/IPv6 场景 --------
{
const char* src6 = "2001:db8::5"; // 示例源IPv6地址
const char* dst6 = "2001:db8::3"; // 示例目的IPv6地址
UDPHeader hdr;
hdr.src_port = htons(46274);
hdr.dst_port = htons(9);
hdr.length = htons(static_cast<uint16_t>(8 + PAY_LEN));
hdr.checksum = 0;
uint16_t cs = udpChecksumV6(src6, dst6,
hdr, payload.data(), PAY_LEN);
hdr.checksum = htons(cs);
std::cout << "===== UDP/IPv6 校验和计算 =====\n";
std::cout << "源: " << src6 << ":46274\n";
std::cout << "目的: " << dst6 << ":9\n";
std::cout << "载荷大小: " << PAY_LEN << " 字节\n";
std::cout << "伪头部: 40 字节(IPv6地址各16字节+32位长度+Next Header)\n";
printChecksum("UDP校验和", cs);
std::cout << "注意: IPv6中UDP校验和是强制的!\n";
}
return 0;
}
https://godbolt.org/z/Ez3qYTb6v
编译与运行:
g++ -std=c++17 -o udp_v4v6 udp_v4v6.cpp && ./udp_v4v6
6. 两种场景完整对比(ASCII 流程图)
场景一:服务端正在运行
─────────────────────────────────────────────────
客户端 10.0.0.5:46274 服务端 10.0.0.3:9
│ │
├──UDP(1024字节)──────────────►│ discard服务接收
├──UDP(1024字节)──────────────►│ 直接丢弃数据
├──...(共1024个)──────────────►│ 不回复任何内容
│ │
结果:全部发出,但UDP无法确认是否到达
场景二:服务端已关闭
─────────────────────────────────────────────────
客户端 10.0.0.5:46294 服务端 10.0.0.3:9
│ │
├──UDP(1024字节)──────────────►│ 端口9无监听进程
│ │
│◄── ICMPv4 Port Unreachable ──┤ 内核自动生成ICMP
│ (含原始包前556字节) │
│ │
write()返回-1
"Connection refused"
后续数据报不再发送
7. 知识要点总结
| 要点 | 说明 |
|---|---|
| UDP 无确认 | 发送方不知道数据是否到达,与 TCP 相比缺乏可靠性保证 |
| ICMP Port Unreachable | 目的端口无监听时,接收方内核返回此 ICMP,应用层表现为 Connection refused |
| 临时端口号 | 每次运行程序可能分配不同端口,Linux 可通过 /proc/sys/net/ipv4/ip_local_port_range 配置 |
| IPv6 伪头部 | 40 字节(IPv4 是 12 字节),地址更长,长度字段扩展为 32 位 |
| IPv6 强制校验和 | 因 IPv6 头部无校验和字段,UDP 在 IPv6 中必须启用校验和 |
| Next Header | 取扩展头链中最后一个头部的 Next Header 值( = 17 = 17 =17 对应 UDP) |
IPv4 vs IPv6 伪头部大小:
4 + 4 + 1 + 1 + 2 ⏟ IPv4伪头部 = 12 字节 \underbrace{4 + 4 + 1 + 1 + 2}_{\text{IPv4伪头部}} = 12 \text{ 字节} IPv4伪头部
4+4+1+1+2=12 字节
16 + 16 + 4 + 3 + 1 ⏟ IPv6伪头部 = 40 字节 \underbrace{16 + 16 + 4 + 3 + 1}_{\text{IPv6伪头部}} = 40 \text{ 字节} IPv6伪头部
16+16+4+3+1=40 字节
UDP 与 IPv6 详解(第10章 10.5节)
1. UDP 在 IPv6 下的主要变化
UDP 协议本身非常简单,从 IPv4 切换到 IPv6 时只需做少量调整。最核心的两点变化是:
IPv4 下的 UDP IPv6 下的 UDP
───────────────── ─────────────────
地址长度:32 位(4字节) → 地址长度:128 位(16字节)
IP头部有校验和 → IP头部无校验和
UDP校验和:可选(建议开启) → UDP校验和:强制必须开启
伪头部:12 字节 → 伪头部:40 字节
Length字段:16位 → 伪头部中Length:32位
2. 为什么 IPv6 中 UDP 校验和是强制的?
IPv4 头部本身有一个校验和,能够保护 IP 层的地址信息(源/目的 IP 地址)。即便 UDP 校验和被关闭,至少 IP 头部校验和还能捕获地址错误。
IPv6 则完全去掉了 IP 头部校验和(为了提高路由器转发速度):
IPv4 的错误保护层次:
┌───────────────────────────────────┐
│ 应用层 │
├───────────────────────────────────┤
│ UDP(校验和可选,覆盖头部+数据) │ ← 第2道防线
├───────────────────────────────────┤
│ IPv4(头部校验和,仅覆盖IP头部) │ ← 第1道防线
└───────────────────────────────────┘
IPv6 的错误保护层次:
┌───────────────────────────────────┐
│ 应用层 │
├───────────────────────────────────┤
│ UDP(校验和强制,覆盖伪头部+数据)│ ← 唯一防线,不能省略!
├───────────────────────────────────┤
│ IPv6(无头部校验和) │ ← 没有防线
└───────────────────────────────────┘
如果 UDP/IPv6 也禁用校验和,那么从源到目的的整个传输过程中,没有任何机制能检测 IP 地址是否正确、数据是否被修改。这是不可接受的,因此 RFC 2460 第8节规定:UDP over IPv6 必须计算并验证校验和。
3. IPv6 对 UDP 包大小的两个影响
3.1 最小 MTU 变大
| 协议 | 最小 MTU(所有主机必须支持) |
|---|---|
| IPv4 | 576 字节 |
| IPv6 | 1280 字节 |
MTU(最大传输单元)决定了一个数据报不需要分片就能通过网络的最大尺寸。IPv6 将最小 MTU 从 576 字节提高到 1280 字节,意味着 UDP 在 IPv6 网络中可以发送更大的单个数据报而不必担心分片。
3.2 超大数据报(Jumbogram)
IPv6 支持超大数据报(Jumbogram),即长度超过 65535 65535 65535 字节的数据包。
通过 IPv6 的 Jumbo Payload 选项,载荷长度字段扩展到 32 位:
最大 Jumbogram 载荷长度 = 2 32 − 1 = 4,294,967,295 字节 ≈ 4 GB \text{最大 Jumbogram 载荷长度} = 2^{32} - 1 = 4{,}294{,}967{,}295 \text{ 字节} \approx 4 \text{ GB} 最大 Jumbogram 载荷长度=232−1=4,294,967,295 字节≈4 GB
这对于高性能计算、超大文件传输等场景非常有用。
4. Jumbogram 下 UDP Length 字段的问题
UDP 头部中的 Length 字段只有 16 位,最大只能表示:
2 16 − 1 = 65535 字节 2^{16} - 1 = 65535 \text{ 字节} 216−1=65535 字节
但 Jumbogram 的载荷可以远超这个值,产生矛盾:
UDP 头部 Length 字段(16位):
┌──────────────────────────────────────┐
│ 最大表示值:65535 字节 │
│ 实际数据:可能超过 4GB ! │ <-- 放不下!
└──────────────────────────────────────┘
解决方案(RFC 2675):
当 UDP/IPv6 数据报超过 65535 字节时,UDP 头部的 Length 字段强制设置为 0,用这个特殊值表示"这是一个 Jumbogram,真实长度请从 IPv6 Jumbo Payload 选项中读取"。
4.1 发送方如何计算 Jumbogram 的真实 UDP 长度
UDP真实长度 = UDP头部(8字节) + UDP数据长度 \text{UDP真实长度} = \text{UDP头部(8字节)} + \text{UDP数据长度} UDP真实长度=UDP头部(8字节)+UDP数据长度
这个值写入伪头部的 Length 字段(32位,放得下),而 UDP 头部自身的 Length 字段置 0 0 0。
4.2 接收方如何还原 Jumbogram 的 UDP 长度
UDP数据报长度 = Jumbo Payload 选项中的总载荷长度 ⏟ 32位 − ∑ IPv6扩展头部长度 \text{UDP数据报长度} = \underbrace{\text{Jumbo Payload 选项中的总载荷长度}}_{\text{32位}} - \sum \text{IPv6扩展头部长度} UDP数据报长度=32位
Jumbo Payload 选项中的总载荷长度−∑IPv6扩展头部长度
其中 IPv6 基本头部固定为 40 字节,不计入载荷长度。
4.3 特殊情况:Length=0 但没有 Jumbo Payload 选项
若收到一个 UDP 头部 Length=0,但 IPv6 头部中并没有 Jumbo Payload 选项,则可以从 IPv6 的普通 Payload Length 字段(非零)推算出 UDP 数据报长度:
UDP长度 = IPv6 Payload Length − ∑ 扩展头部长度 \text{UDP长度} = \text{IPv6 Payload Length} - \sum \text{扩展头部长度} UDP长度=IPv6 Payload Length−∑扩展头部长度
5. 各场景下 UDP Length 字段的处理规则
6. IPv6 伪头部 Length 字段为何不冗余?
在 UDP 场景下,伪头部的 Length 字段与 UDP 头部的 Length 字段相同,是冗余的。
但在 TCP 中,TCP 头部本身没有独立的 Length 字段(TCP 数据长度由 IP 层的总长度减去头部长度推算),因此伪头部的 Length 字段对于 TCP 来说是不冗余的,是唯一明确记录载荷长度的地方。
这就是为什么即使对 UDP 是冗余的,IPv6 伪头部仍然保留了这个 32 位的 Length 字段——为了 TCP/IPv6 的需要,统一使用相同的伪头部结构。
伪头部 Length 字段的冗余性:
UDP/IPv4 UDP/IPv6 TCP/IPv4 TCP/IPv6
───────── ───────── ───────── ─────────
冗余? 是 是 否 否
用途 一致性检查 一致性检查 唯一来源 唯一来源
7. 完整 C++ 代码示例
以下代码演示普通 UDP/IPv6 和 Jumbogram 两种场景下的长度处理与校验和计算:
#include <iostream>
#include <cstdint>
#include <cstring>
#include <vector>
#include <iomanip>
#include <arpa/inet.h> // htons, htonl, ntohs, ntohl, inet_pton
// ============================================================
// UDP 头部(8字节)
// ============================================================
struct UDPHeader {
uint16_t src_port;
uint16_t dst_port;
uint16_t length; // 普通模式填实际长度;Jumbogram 模式置 0
uint16_t checksum;
};
// ============================================================
// IPv6 伪头部(40字节)
// 用于 UDP/IPv6 校验和计算,不实际传输
// ============================================================
struct PseudoV6 {
uint8_t src_ip[16]; // 源 IPv6 地址(16字节)
uint8_t dst_ip[16]; // 目的 IPv6 地址(16字节)
uint32_t udp_length; // UDP总长度(32位,网络字节序)
uint8_t reserved[3]; // 保留,固定为 0
uint8_t next_hdr; // Next Header = 17(UDP)
};
// ============================================================
// 16位反码校验和(Internet Checksum)
// ============================================================
uint16_t internetChecksum(const uint8_t* data, size_t len) {
uint32_t sum = 0;
// 每次取 2 字节累加
while (len > 1) {
uint16_t w = (static_cast<uint16_t>(data[0]) << 8)
| static_cast<uint16_t>(data[1]);
sum += w;
data += 2;
len -= 2;
}
// 奇数字节:末尾虚拟补 0x00
if (len == 1) {
sum += static_cast<uint16_t>(data[0]) << 8;
}
// 反码折叠:进位回卷
while (sum >> 16) {
sum = (sum & 0xFFFF) + (sum >> 16);
}
uint16_t result = static_cast<uint16_t>(~sum);
// 结果为 0 时用 0xFFFF 代替(0x0000 保留给"未计算"标记)
return (result == 0x0000) ? 0xFFFF : result;
}
// ============================================================
// 计算 UDP/IPv6 校验和(同时支持普通模式和 Jumbogram 模式)
// 参数:
// src6 - 源 IPv6 地址字符串,如 "2001:db8::1"
// dst6 - 目的 IPv6 地址字符串
// udphdr - UDP 头部(checksum 字段须预先置 0)
// payload - 载荷数据指针
// pay_len - 载荷字节数
// is_jumbo - 是否为 Jumbogram 模式
// ============================================================
uint16_t udpChecksumV6(const char* src6,
const char* dst6,
const UDPHeader& udphdr,
const uint8_t* payload,
size_t pay_len,
bool is_jumbo)
{
PseudoV6 pseudo;
memset(&pseudo, 0, sizeof(PseudoV6));
// 将 IPv6 字符串地址转为 16 字节二进制(网络字节序)
if (inet_pton(AF_INET6, src6, pseudo.src_ip) != 1) {
std::cerr << "无效的源 IPv6 地址: " << src6 << "\n";
return 0;
}
if (inet_pton(AF_INET6, dst6, pseudo.dst_ip) != 1) {
std::cerr << "无效的目的 IPv6 地址: " << dst6 << "\n";
return 0;
}
// 伪头部的 Length 字段(32位)始终填真实长度
// 即使 UDP 头部 Length 字段置 0(Jumbogram 情况),伪头部仍填真实值
uint64_t real_udp_len = 8 + static_cast<uint64_t>(pay_len);
pseudo.udp_length = htonl(static_cast<uint32_t>(real_udp_len));
pseudo.next_hdr = 17; // UDP 协议号
// 拼接:伪头部(40字节) + UDP 头部(8字节) + 载荷
size_t total = sizeof(PseudoV6) + sizeof(UDPHeader) + pay_len;
std::vector<uint8_t> buf(total, 0);
size_t off = 0;
memcpy(buf.data() + off, &pseudo, sizeof(PseudoV6)); off += sizeof(PseudoV6);
memcpy(buf.data() + off, &udphdr, sizeof(UDPHeader)); off += sizeof(UDPHeader);
memcpy(buf.data() + off, payload, pay_len);
return internetChecksum(buf.data(), total);
}
// ============================================================
// 构造并打印一个 UDP/IPv6 数据报(普通或 Jumbogram 模式)
// ============================================================
void buildUDPv6(const char* src6,
const char* dst6,
uint16_t src_port,
uint16_t dst_port,
size_t pay_len,
bool is_jumbo)
{
// 构造虚拟载荷(全填充为 'X')
std::vector<uint8_t> payload(pay_len, 'X');
UDPHeader hdr;
hdr.src_port = htons(src_port);
hdr.dst_port = htons(dst_port);
hdr.checksum = 0; // 计算前必须置 0
if (is_jumbo) {
// Jumbogram:UDP 头部 Length 字段置 0
// 真实长度通过 IPv6 Jumbo Payload 选项携带
hdr.length = 0;
} else {
// 普通模式:填写真实长度(8 + 载荷字节数)
hdr.length = htons(static_cast<uint16_t>(8 + pay_len));
}
// 计算校验和(伪头部 Length 字段始终使用真实长度)
uint16_t cs = udpChecksumV6(src6, dst6, hdr, payload.data(), pay_len, is_jumbo);
hdr.checksum = htons(cs);
// 打印结果
std::cout << (is_jumbo ? "===== Jumbogram 模式 =====" : "===== 普通模式 =====") << "\n";
std::cout << "源: " << src6 << ":" << src_port << "\n";
std::cout << "目的: " << dst6 << ":" << dst_port << "\n";
std::cout << "载荷大小: " << pay_len << " 字节\n";
std::cout << "UDP总长度: " << (8 + pay_len) << " 字节\n";
if (is_jumbo) {
std::cout << "UDP Length字段: 0(Jumbogram,真实长度由IPv6 Jumbo Payload选项携带)\n";
} else {
std::cout << "UDP Length字段: " << (8 + pay_len) << "\n";
}
std::cout << "伪头部Length: " << (8 + pay_len) << "(32位,始终为真实长度)\n";
std::cout << "UDP校验和: 0x"
<< std::hex << std::uppercase
<< std::setw(4) << std::setfill('0') << cs
<< std::dec << "\n\n";
}
// ============================================================
// 主函数:演示普通 UDP/IPv6 和 Jumbogram 两种场景
// ============================================================
int main() {
const char* src6 = "2001:db8::1";
const char* dst6 = "2001:db8::2";
// 场景 1:普通 UDP/IPv6(载荷 1024 字节,远小于 65535)
buildUDPv6(src6, dst6, 54321, 9, 1024, false);
// 场景 2:Jumbogram(载荷 100000 字节,超过 65535)
// 注意:此处演示逻辑,实际发送需要操作系统和网卡支持
buildUDPv6(src6, dst6, 54321, 9, 100000, true);
// 场景 3:验证 IPv6 最小 MTU 边界
// IPv6 要求所有链路至少支持 1280 字节 MTU
// UDP 头部8字节 + IPv6头部40字节 = 48字节开销
// 因此单包不分片最大载荷(在最小MTU下)= 1280 - 40 - 8 = 1232 字节
uint32_t ipv6_min_mtu = 1280;
uint32_t ipv6_hdr_size = 40;
uint32_t udp_hdr_size = 8;
uint32_t max_payload_no_frag = ipv6_min_mtu - ipv6_hdr_size - udp_hdr_size;
std::cout << "===== IPv6 最小 MTU 分析 =====\n";
std::cout << "IPv6 最小 MTU: " << ipv6_min_mtu << " 字节\n";
std::cout << "IPv6 基本头部: " << ipv6_hdr_size << " 字节\n";
std::cout << "UDP 头部: " << udp_hdr_size << " 字节\n";
std::cout << "最大不分片 UDP 载荷: "
<< max_payload_no_frag << " 字节\n";
// Jumbogram 理论最大载荷
uint64_t jumbo_max = (1ULL << 32) - 1 - 8; // 32位长度最大值减去UDP头部8字节
std::cout << "\nJumbogram 理论最大 UDP 载荷: "
<< jumbo_max << " 字节 (~"
<< (jumbo_max / (1024*1024*1024)) << " GB)\n";
return 0;
}
https://godbolt.org/z/9zr93d4dT
编译与运行:
g++ -std=c++17 -o udp_ipv6 udp_ipv6.cpp && ./udp_ipv6
8. Jumbogram 下 Length 字段处理流程(ASCII 示意)
发送方(Jumbogram,数据 > 65535 字节):
UDP 头部:
┌──────────┬──────────┬──────────┬──────────┐
│ src_port │ dst_port │ length=0 │ checksum │
└──────────┴──────────┴──────────┴──────────┘
↑
置0!真实长度
放在IPv6选项里
IPv6 扩展选项(Jumbo Payload Option):
┌──────────────────────────────────┐
│ Jumbo Payload Length = 实际值 │ ← 32位,容纳超大长度
└──────────────────────────────────┘
伪头部(用于校验和):
┌──────────────────────────────────┐
│ Length = 8 + 实际数据长度 │ ← 始终填真实值
└──────────────────────────────────┘
接收方还原 UDP 长度:
Jumbo Payload Length
│
│ 减去所有扩展头部长度
↓
UDP 数据报长度 = Jumbo Payload Length - Σ(扩展头部长度)
9. MTU 对比
| 场景 | 最大 UDP 载荷 |
|---|---|
| IPv4 最小 MTU(576字节,不分片) | 576 − 20 − 8 = 548 576 - 20 - 8 = 548 576−20−8=548 字节 |
| IPv6 最小 MTU(1280字节,不分片) | 1280 − 40 − 8 = 1232 1280 - 40 - 8 = 1232 1280−40−8=1232 字节 |
| 普通 IPv6(无 Jumbogram) | 65535 − 8 = 65527 65535 - 8 = 65527 65535−8=65527 字节(UDP Length 16位限制) |
| IPv6 Jumbogram | 2 32 − 1 − 8 ≈ 4 GB 2^{32} - 1 - 8 \approx 4\text{ GB} 232−1−8≈4 GB |
10. 知识要点总结
| 要点 | 说明 |
|---|---|
| IPv6 无 IP 层校验和 | UDP 校验和在 IPv6 中强制启用,是唯一的端到端保护 |
| 伪头部扩大到 40 字节 | 源/目的 IPv6 地址各 16 字节,Length 扩展为 32 位 |
| IPv6 最小 MTU = 1280 | 比 IPv4 的 576 字节更大,有利于减少分片 |
| Jumbogram | 超过 65535 字节的数据报,通过 IPv6 Jumbo Payload 选项支持 |
| Jumbogram 的 UDP Length | UDP 头部 Length 字段置 0,真实长度放在 IPv6 扩展选项中 |
| 伪头部 Length 不置 0 | 即使是 Jumbogram,伪头部的 Length 字段仍填真实 32 位长度 |
| Length 字段对 TCP 不冗余 | TCP 头部本身无长度字段,伪头部的 Length 是 TCP 长度的唯一来源 |
关键公式:
普通 UDP/IPv6 最大不分片载荷(最小 MTU 下):
最大载荷 = 1280 ⏟ IPv6最小MTU − 40 ⏟ IPv6头部 − 8 ⏟ UDP头部 = 1232 字节 \text{最大载荷} = \underbrace{1280}_{\text{IPv6最小MTU}} - \underbrace{40}_{\text{IPv6头部}} - \underbrace{8}_{\text{UDP头部}} = 1232 \text{ 字节} 最大载荷=IPv6最小MTU
1280−IPv6头部
40−UDP头部
8=1232 字节
Jumbogram 下接收方还原 UDP 长度:
UDP长度 = L jumbo ⏟ Jumbo Payload Length − ∑ i L ext , i ⏟ 各扩展头部长度 \text{UDP长度} = \underbrace{L_{\text{jumbo}}}_{\text{Jumbo Payload Length}} - \sum_{i} \underbrace{L_{\text{ext},i}}_{\text{各扩展头部长度}} UDP长度=Jumbo Payload Length
Ljumbo−i∑各扩展头部长度
Lext,i
Jumbogram 下 UDP 伪头部 Length(32位真实长度):
伪头部 Length = 8 + UDP数据字节数 ( ≤ 2 32 − 1 ) \text{伪头部 Length} = 8 + \text{UDP数据字节数} \quad (\leq 2^{32}-1) 伪头部 Length=8+UDP数据字节数(≤232−1)
Teredo:IPv6穿越IPv4网络的隧道技术详解
1. 背景与动机
IPv6的推广远比预期缓慢,因此出现了多种过渡机制。6to4(RFC3056)是其中一种,但它存在NAT穿透难题和扩展性问题。Teredo(RFC4380)正是为解决这些问题而设计的——它的名字来自一种蛀船虫的拉丁学名,因为它能"钻穿"IPv4网络的障碍。
Teredo 在 Microsoft Windows 中被广泛内置,是目前最常见的 IPv6 过渡机制之一。
2. 整体架构
2.1 三种角色
| 角色 | 类比 | 功能 |
|---|---|---|
| Teredo 客户端 | 普通用户 | 双栈主机,通过 UDP/IPv4 封装收发 IPv6 报文 |
| Teredo 服务器 | STUN 服务器 | 帮助客户端获取 IPv6 地址,探测 NAT 类型 |
| Teredo 中继(Relay) | TURN 服务器 | 转发客户端与原生 IPv6 主机之间的流量(最后手段) |
服务器必须包含中继的全部功能,但反之不成立。
2.2 网络拓扑示意
+------------------+ +------------------+
| Teredo 客户端 | | Teredo 服务器 |
| (IPv4/IPv6双栈) |<-------->| teredo.ipv6. |
+--------+---------+ 资格认证 | microsoft.com |
| +--------+---------+
| NAT N1 |
| | IPv4 Internet
+--------v---------+ +--------v---------+
| NAT 网关 N1 | | Teredo 中继 |
| (映射地址/端口) | | (广播 2001::/32) |
+--------+---------+ +--------+---------+
| |
| IPv4 Internet | IPv6 Internet
+-----------------------------+
|
+--------v---------+
| 原生 IPv6 主机 C |
+------------------+
3. Teredo 地址结构(128位)
Image 3 展示了完整的地址格式:
|<----------------------- 128 bits IPv6 地址 ---------------------->|
+------------------+------------------+-------+--------+------------+
| Teredo IPv6前缀 | 服务器IPv4地址 | Flags | 映射端口| 映射IPv4 |
| (32 bits) | (32 bits) |(16bit)|(16 bits)| (32 bits) |
+------------------+------------------+-------+--------+------------+
2001::/32 Teredo服务器 客户端映射地址
(每位取反做混淆)
3.1 Flags 字段(16位)细节
Flags = C ⏟ 1 0 ⏟ 1 Random1 ⏟ 4 U ⏟ 1 G ⏟ 1 Random2 ⏟ 8 \text{Flags} = \underbrace{C}_{1} \underbrace{0}_{1} \underbrace{\text{Random1}}_{4} \underbrace{U}_{1} \underbrace{G}_{1} \underbrace{\text{Random2}}_{8} Flags=1 C1 04 Random11 U1 G8 Random2
| 位字段 | 含义 |
|---|---|
| C(锥形NAT位) | 曾用于标识 Cone NAT,现已废弃,应置 0 |
| 0 | 固定为 0 |
| Random1(4位) | 随机数,防止地址猜测攻击(RFC5991) |
| U(Universal) | 保留,置 0 |
| G(Group) | 保留,置 0 |
| Random2(8位) | 随机数,进一步增强安全性 |
3.2 地址混淆
映射端口和映射IPv4地址使用**逐位取反(bitwise invert)**的方式存储,目的是防止某些NAT设备错误地修改这些字段。
存储值 = ∼ 实际值 \text{存储值} = \sim \text{实际值} 存储值=∼实际值
4. 资格认证(Qualification)流程
客户端必须完成资格认证才能获得 Teredo 地址。
客户端 服务器
| |
|--- ICMPv6 RS (路由器请求) --->| 使用链路本地地址,经 Teredo 服务端口
| (Origin Indication封装) | 默认端口 UDP 3544
| |
|<-- ICMPv6 RA (路由器通告) ----| 包含 Prefix Information 选项
| (Origin Indication封装) | 携带有效的 Teredo 前缀
| |
| 客户端根据前缀和 Origin 信息 |
| 构造 Teredo IPv6 地址 |
| --> 客户端状态变为"已认证" |
认证成功后,客户端依据图10-7的格式组装出自己的 Teredo IPv6 地址。
5. 封装格式(图10-6)
Teredo 有两种封装格式:
5.1 简单封装(Simple Encapsulation)
+--------------------+
| IPv4 头部 | 20 字节,无选项
+--------------------+
| UDP 头部 | 8 字节
+--------------------+
| 封装的IPv6数据报 | 可变长度
+--------------------+
| Trailers(可选) | 可变长度,若存在
+--------------------+
5.2 Origin Indication 封装
在 UDP 头和 IPv6 数据报之间插入一个 8 字节的 Origin Indication 字段:
+--------------------+
| IPv4 头部 | 20 字节
+--------------------+
| UDP 头部 | 8 字节
+--------------------+
| Origin Indication | 8 字节(仅 Origin Indication 封装时存在)
| Zero(0) | 源端口 | <- 端口经逐位取反混淆
| 源(映射)IPv4地址| <- 地址经逐位取反混淆
+--------------------+
| 封装的IPv6数据报 | 可变长度
+--------------------+
| Trailers(可选) | 可变长度
+--------------------+
Origin Indication 的作用:告知客户端自己的映射地址和端口(NAT出口处的公网地址/端口)。
6. 三种通信场景
场景一:与同一链路上的 Teredo 客户端通信
使用 IPv4 组播地址 224.0.0.253 进行发现。
发送bubble包(无数据载荷的最小 Teredo 包)探测目标是否在同一链路。
bubble 包的 IPv6 头中 Next Header 字段置为 0x3b(无下一头部)。
场景二:与 IPv4 Internet 上的 Teredo 客户端通信
由于 Teredo 地址中已经编码了对方的映射 IPv4 地址和端口,可以直接发送封装包到对方 NAT。
对于限制性 NAT,使用 bubble 包做**UDP打洞(hole punching)**建立 NAT 映射。
场景三:与原生 IPv6 主机通信
客户端 Teredo服务器 IPv6主机C Teredo中继
| | | |
|-- ICMPv6 Echo Request ->|-- 转发到IPv6主机 --->| |
| (含64位随机数) | | |
| | |-- Echo Reply --->|
| | | (路由到最近中继)|
|<--------------------------------------- Echo Reply 转回 ----------|
| |
| 记录中继的IPv4地址,后续直接发往中继(Simple Encapsulation) |
7. Teredo Trailers(扩展尾部)
Trailers 附加在 IPv6 载荷之后,采用 TLV(类型-长度-值)格式,与 ICMPv6 ND 选项格式相同。
7.1 类型字段的高两位含义
Type [ 7 : 6 ] = { 01 不识别则丢弃该包 其他 不识别则跳过,继续处理后续 Trailer \text{Type}[7:6] = \begin{cases} 01 & \text{不识别则丢弃该包} \\ \text{其他} & \text{不识别则跳过,继续处理后续 Trailer} \end{cases} Type[7:6]={01其他不识别则丢弃该包不识别则跳过,继续处理后续 Trailer
7.2 已定义的 Trailer 类型
| 类型 | 名称 | 长度 | 用途 |
|---|---|---|---|
| 0x01 | Nonce | 0x04 | 32位随机数,防重放攻击;与 HP 或 SNS 配合使用 |
| 0x02 | Random Port | 0x02 | 发送方预测的映射端口号,用于 PP 扩展 |
| 0x03 | Alternate Address | [8,26] | 列出同一 NAT 后的其他可用地址/端口(最多4组),用于 HP 扩展 |
| 0x04 | ND Discovery Option | 0x02 | 包含 TeredoDiscoverySolicitation(0x00) 或 TeredoDiscoveryAdvertisement(0x01),用于 SLR 扩展 |
7.3 各扩展说明
| 扩展缩写 | 全称 | 功能简述 |
|---|---|---|
| SNS | Symmetric NAT Support | 支持对称型 NAT |
| UP | UPnP-Enabled Symmetric NAT | 支持启用 UPnP 的对称 NAT |
| PP | Port-Preserving Symmetric NAT | 支持端口保留型对称 NAT |
| SP | Sequential Port-Symmetric NAT | 支持端口顺序分配的对称 NAT |
| HP | Hairpinning | 支持 NAT 发夹转发 |
| SLR | Server Load Reduction | 使用直连 bubble 刷新 NAT 状态,降低服务器负载 |
UP 和 PP 扩展依赖 SNS 扩展,其余扩展可独立使用。
8. NAT 类型与 Teredo 兼容性
NAT类型 Teredo支持情况
----------- ---------------
Cone NAT(端点无关映射+过滤) 无需扩展,直接支持
对称型 NAT(地址/端口相关映射) 需要 SNS 等扩展
Cone NAT 是家庭网络最常见的类型,Teredo 对其支持最好。
对称型 NAT 需要配合相应扩展(SNS/UP/PP/SP)才能工作。
9. C++ 代码示例:解析 Teredo IPv6 地址
下面的代码演示如何从一个 128 位 IPv6 地址中提取 Teredo 各字段。
#include <cstdint>
#include <cstring>
#include <cstdio>
#include <arpa/inet.h>
// Teredo 地址结构(对应128位IPv6地址的各字段)
struct TeredoAddress {
uint32_t teredo_prefix; // 前32位:Teredo前缀(2001:0000)
uint32_t server_ipv4; // 32位:Teredo服务器IPv4地址
uint16_t flags; // 16位:标志字段
uint16_t mapped_port_obs; // 16位:映射端口(已混淆,即取反)
uint32_t mapped_ip_obs; // 32位:映射IPv4地址(已混淆,即取反)
};
// 从原始IPv6地址字节流(16字节,网络字节序)解析Teredo地址
void parse_teredo(const uint8_t ipv6[16], TeredoAddress &out) {
// 逐字段复制,注意网络字节序(大端)
uint32_t tmp32;
uint16_t tmp16;
// 前缀:字节0~3
memcpy(&tmp32, ipv6 + 0, 4);
out.teredo_prefix = ntohl(tmp32);
// 服务器IPv4:字节4~7
memcpy(&tmp32, ipv6 + 4, 4);
out.server_ipv4 = ntohl(tmp32);
// Flags:字节8~9
memcpy(&tmp16, ipv6 + 8, 2);
out.flags = ntohs(tmp16);
// 映射端口(混淆):字节10~11
memcpy(&tmp16, ipv6 + 10, 2);
out.mapped_port_obs = ntohs(tmp16);
// 映射IPv4(混淆):字节12~15
memcpy(&tmp32, ipv6 + 12, 4);
out.mapped_ip_obs = ntohl(tmp32);
}
// 去除混淆:逐位取反(complement)还原实际值
uint16_t deobfuscate_port(uint16_t obs) {
return ~obs; // 按位取反
}
uint32_t deobfuscate_ip(uint32_t obs) {
return ~obs; // 按位取反
}
// 将uint32_t的IPv4地址格式化为点分十进制字符串
void format_ipv4(uint32_t ip, char buf[INET_ADDRSTRLEN]) {
uint32_t net = htonl(ip);
inet_ntop(AF_INET, &net, buf, INET_ADDRSTRLEN);
}
int main() {
// 示例:构造一个 Teredo IPv6 地址的字节表示
// 格式:2001:0000 | server_ip | flags | ~port | ~client_ip
// 假设:
// 服务器IP = 65.54.227.120 (0x4136E378)
// flags = 0x0000
// 客户端IP = 192.168.1.100 (0xC0A80164)
// 客户端端口= 12345 (0x3039)
uint8_t ipv6[16] = {
0x20, 0x01, // Teredo前缀高16位
0x00, 0x00, // Teredo前缀低16位
0x41, 0x36, 0xE3, 0x78, // 服务器IPv4: 65.54.227.120
0x00, 0x00, // Flags = 0
// 映射端口取反: ~12345 = ~0x3039 = 0xCFC6
0xCF, 0xC6,
// 映射IP取反: ~0xC0A80164 = 0x3F57FE9B
0x3F, 0x57, 0xFE, 0x9B
};
TeredoAddress addr;
parse_teredo(ipv6, addr);
// 还原真实端口和IP
uint16_t real_port = deobfuscate_port(addr.mapped_port_obs);
uint32_t real_ip = deobfuscate_ip(addr.mapped_ip_obs);
char server_ip_str[INET_ADDRSTRLEN];
char client_ip_str[INET_ADDRSTRLEN];
format_ipv4(addr.server_ipv4, server_ip_str);
format_ipv4(real_ip, client_ip_str);
printf("=== Teredo 地址解析结果 ===\n");
printf("Teredo 前缀: 0x%08X (2001::/32)\n", addr.teredo_prefix);
printf("服务器 IPv4: %s\n", server_ip_str);
printf("Flags: 0x%04X\n", addr.flags);
printf(" C位(Cone NAT): %d (已废弃)\n", (addr.flags >> 15) & 1);
printf(" Random1(4bit): %d\n", (addr.flags >> 10) & 0xF);
printf(" U位: %d\n", (addr.flags >> 9) & 1);
printf(" G位: %d\n", (addr.flags >> 8) & 1);
printf(" Random2(8bit): %d\n", addr.flags & 0xFF);
printf("映射端口(混淆值): 0x%04X\n", addr.mapped_port_obs);
printf("实际映射端口: %u\n", real_port);
printf("实际映射 IPv4: %s\n", client_ip_str);
return 0;
}
编译与运行:
g++ -o teredo_parse teredo_parse.cpp
./teredo_parse
预期输出:
=== Teredo 地址解析结果 ===
Teredo 前缀: 0x20010000 (2001::/32)
服务器 IPv4: 65.54.227.120
Flags: 0x0000
C位(Cone NAT): 0 (已废弃)
Random1(4bit): 0
U位: 0
G位: 0
Random2(8bit): 0
映射端口(混淆值): 0xCFC6
实际映射端口: 12345
实际映射 IPv4: 192.168.1.100
10. 完整流程图(Mermaid)
11. 关键要点总结
- Teredo 的本质:用 UDP/IPv4 作为载体传输 IPv6 报文,绕过 IPv4-only 基础设施。
- 地址构造规则: Teredo地址 = 前缀 ( 2001 : : / 32 ) + 服务器IP + Flags + 端口 ~ + 客户端IP ~ \text{Teredo地址} = \text{前缀}(2001::/32) + \text{服务器IP} + \text{Flags} + \widetilde{\text{端口}} + \widetilde{\text{客户端IP}} Teredo地址=前缀(2001::/32)+服务器IP+Flags+端口 +客户端IP ,其中 x ~ \widetilde{x} x 表示按位取反。
- 两种封装:Simple Encapsulation 用于普通传输;Origin Indication Encapsulation 用于传递映射地址信息。
- Trailers 的作用:通过 TLV 格式附加扩展信息,支持对称 NAT、防重放、发夹转发等高级特性。
- 使用原则:Teredo 是最后手段——一旦发现其他 IPv6 连接方式(如直连或 6to4),节点应立即停止使用 Teredo 隧道。
Teredo:IPv6穿越IPv4网络的隧道技术详解
1. 背景与动机
IPv6的推广远比预期缓慢,因此出现了多种过渡机制。6to4(RFC3056)是其中一种,但它存在NAT穿透难题和扩展性问题。Teredo(RFC4380)正是为解决这些问题而设计的——它的名字来自一种蛀船虫的拉丁学名,因为它能"钻穿"IPv4网络的障碍。
Teredo 在 Microsoft Windows 中被广泛内置,是目前最常见的 IPv6 过渡机制之一。
2. 整体架构
2.1 三种角色
| 角色 | 类比 | 功能 |
|---|---|---|
| Teredo 客户端 | 普通用户 | 双栈主机,通过 UDP/IPv4 封装收发 IPv6 报文 |
| Teredo 服务器 | STUN 服务器 | 帮助客户端获取 IPv6 地址,探测 NAT 类型 |
| Teredo 中继(Relay) | TURN 服务器 | 转发客户端与原生 IPv6 主机之间的流量(最后手段) |
服务器必须包含中继的全部功能,但反之不成立。
2.2 网络拓扑示意
+------------------+ +------------------+
| Teredo 客户端 | | Teredo 服务器 |
| (IPv4/IPv6双栈) |<-------->| teredo.ipv6. |
+--------+---------+ 资格认证 | microsoft.com |
| +--------+---------+
| NAT N1 |
| | IPv4 Internet
+--------v---------+ +--------v---------+
| NAT 网关 N1 | | Teredo 中继 |
| (映射地址/端口) | | (广播 2001::/32) |
+--------+---------+ +--------+---------+
| |
| IPv4 Internet | IPv6 Internet
+-----------------------------+
|
+--------v---------+
| 原生 IPv6 主机 C |
+------------------+
3. Teredo 地址结构(128位)
Image 3 展示了完整的地址格式:
|<----------------------- 128 bits IPv6 地址 ---------------------->|
+------------------+------------------+-------+--------+------------+
| Teredo IPv6前缀 | 服务器IPv4地址 | Flags | 映射端口| 映射IPv4 |
| (32 bits) | (32 bits) |(16bit)|(16 bits)| (32 bits) |
+------------------+------------------+-------+--------+------------+
2001::/32 Teredo服务器 客户端映射地址
(每位取反做混淆)
3.1 Flags 字段(16位)细节
Flags = C ⏟ 1 0 ⏟ 1 Random1 ⏟ 4 U ⏟ 1 G ⏟ 1 Random2 ⏟ 8 \text{Flags} = \underbrace{C}_{1} \underbrace{0}_{1} \underbrace{\text{Random1}}_{4} \underbrace{U}_{1} \underbrace{G}_{1} \underbrace{\text{Random2}}_{8} Flags=1 C1 04 Random11 U1 G8 Random2
| 位字段 | 含义 |
|---|---|
| C(锥形NAT位) | 曾用于标识 Cone NAT,现已废弃,应置 0 |
| 0 | 固定为 0 |
| Random1(4位) | 随机数,防止地址猜测攻击(RFC5991) |
| U(Universal) | 保留,置 0 |
| G(Group) | 保留,置 0 |
| Random2(8位) | 随机数,进一步增强安全性 |
3.2 地址混淆
映射端口和映射IPv4地址使用**逐位取反(bitwise invert)**的方式存储,目的是防止某些NAT设备错误地修改这些字段。
存储值 = ∼ 实际值 \text{存储值} = \sim \text{实际值} 存储值=∼实际值
4. 资格认证(Qualification)流程
客户端必须完成资格认证才能获得 Teredo 地址。
客户端 服务器
| |
|--- ICMPv6 RS (路由器请求) --->| 使用链路本地地址,经 Teredo 服务端口
| (Origin Indication封装) | 默认端口 UDP 3544
| |
|<-- ICMPv6 RA (路由器通告) ----| 包含 Prefix Information 选项
| (Origin Indication封装) | 携带有效的 Teredo 前缀
| |
| 客户端根据前缀和 Origin 信息 |
| 构造 Teredo IPv6 地址 |
| --> 客户端状态变为"已认证" |
认证成功后,客户端依据图10-7的格式组装出自己的 Teredo IPv6 地址。
5. 封装格式(图10-6)
Teredo 有两种封装格式:
5.1 简单封装(Simple Encapsulation)
+--------------------+
| IPv4 头部 | 20 字节,无选项
+--------------------+
| UDP 头部 | 8 字节
+--------------------+
| 封装的IPv6数据报 | 可变长度
+--------------------+
| Trailers(可选) | 可变长度,若存在
+--------------------+
5.2 Origin Indication 封装
在 UDP 头和 IPv6 数据报之间插入一个 8 字节的 Origin Indication 字段:
+--------------------+
| IPv4 头部 | 20 字节
+--------------------+
| UDP 头部 | 8 字节
+--------------------+
| Origin Indication | 8 字节(仅 Origin Indication 封装时存在)
| Zero(0) | 源端口 | <- 端口经逐位取反混淆
| 源(映射)IPv4地址| <- 地址经逐位取反混淆
+--------------------+
| 封装的IPv6数据报 | 可变长度
+--------------------+
| Trailers(可选) | 可变长度
+--------------------+
Origin Indication 的作用:告知客户端自己的映射地址和端口(NAT出口处的公网地址/端口)。
6. 三种通信场景
场景一:与同一链路上的 Teredo 客户端通信
使用 IPv4 组播地址 224.0.0.253 进行发现。
发送bubble包(无数据载荷的最小 Teredo 包)探测目标是否在同一链路。
bubble 包的 IPv6 头中 Next Header 字段置为 0x3b(无下一头部)。
场景二:与 IPv4 Internet 上的 Teredo 客户端通信
由于 Teredo 地址中已经编码了对方的映射 IPv4 地址和端口,可以直接发送封装包到对方 NAT。
对于限制性 NAT,使用 bubble 包做**UDP打洞(hole punching)**建立 NAT 映射。
场景三:与原生 IPv6 主机通信
客户端 Teredo服务器 IPv6主机C Teredo中继
| | | |
|-- ICMPv6 Echo Request ->|-- 转发到IPv6主机 --->| |
| (含64位随机数) | | |
| | |-- Echo Reply --->|
| | | (路由到最近中继)|
|<--------------------------------------- Echo Reply 转回 ----------|
| |
| 记录中继的IPv4地址,后续直接发往中继(Simple Encapsulation) |
7. Teredo Trailers(扩展尾部)
Trailers 附加在 IPv6 载荷之后,采用 TLV(类型-长度-值)格式,与 ICMPv6 ND 选项格式相同。
7.1 类型字段的高两位含义
Type [ 7 : 6 ] = { 01 不识别则丢弃该包 其他 不识别则跳过,继续处理后续 Trailer \text{Type}[7:6] = \begin{cases} 01 & \text{不识别则丢弃该包} \\ \text{其他} & \text{不识别则跳过,继续处理后续 Trailer} \end{cases} Type[7:6]={01其他不识别则丢弃该包不识别则跳过,继续处理后续 Trailer
7.2 已定义的 Trailer 类型
| 类型 | 名称 | 长度 | 用途 |
|---|---|---|---|
| 0x01 | Nonce | 0x04 | 32位随机数,防重放攻击;与 HP 或 SNS 配合使用 |
| 0x02 | Random Port | 0x02 | 发送方预测的映射端口号,用于 PP 扩展 |
| 0x03 | Alternate Address | [8,26] | 列出同一 NAT 后的其他可用地址/端口(最多4组),用于 HP 扩展 |
| 0x04 | ND Discovery Option | 0x02 | 包含 TeredoDiscoverySolicitation(0x00) 或 TeredoDiscoveryAdvertisement(0x01),用于 SLR 扩展 |
7.3 各扩展说明
| 扩展缩写 | 全称 | 功能简述 |
|---|---|---|
| SNS | Symmetric NAT Support | 支持对称型 NAT |
| UP | UPnP-Enabled Symmetric NAT | 支持启用 UPnP 的对称 NAT |
| PP | Port-Preserving Symmetric NAT | 支持端口保留型对称 NAT |
| SP | Sequential Port-Symmetric NAT | 支持端口顺序分配的对称 NAT |
| HP | Hairpinning | 支持 NAT 发夹转发 |
| SLR | Server Load Reduction | 使用直连 bubble 刷新 NAT 状态,降低服务器负载 |
UP 和 PP 扩展依赖 SNS 扩展,其余扩展可独立使用。
8. NAT 类型与 Teredo 兼容性
NAT类型 Teredo支持情况
----------- ---------------
Cone NAT(端点无关映射+过滤) 无需扩展,直接支持
对称型 NAT(地址/端口相关映射) 需要 SNS 等扩展
Cone NAT 是家庭网络最常见的类型,Teredo 对其支持最好。
对称型 NAT 需要配合相应扩展(SNS/UP/PP/SP)才能工作。
9. C++ 代码示例:解析 Teredo IPv6 地址
下面的代码演示如何从一个 128 位 IPv6 地址中提取 Teredo 各字段。
#include <cstdint>
#include <cstring>
#include <cstdio>
#include <arpa/inet.h>
// Teredo 地址结构(对应128位IPv6地址的各字段)
struct TeredoAddress {
uint32_t teredo_prefix; // 前32位:Teredo前缀(2001:0000)
uint32_t server_ipv4; // 32位:Teredo服务器IPv4地址
uint16_t flags; // 16位:标志字段
uint16_t mapped_port_obs; // 16位:映射端口(已混淆,即取反)
uint32_t mapped_ip_obs; // 32位:映射IPv4地址(已混淆,即取反)
};
// 从原始IPv6地址字节流(16字节,网络字节序)解析Teredo地址
void parse_teredo(const uint8_t ipv6[16], TeredoAddress &out) {
// 逐字段复制,注意网络字节序(大端)
uint32_t tmp32;
uint16_t tmp16;
// 前缀:字节0~3
memcpy(&tmp32, ipv6 + 0, 4);
out.teredo_prefix = ntohl(tmp32);
// 服务器IPv4:字节4~7
memcpy(&tmp32, ipv6 + 4, 4);
out.server_ipv4 = ntohl(tmp32);
// Flags:字节8~9
memcpy(&tmp16, ipv6 + 8, 2);
out.flags = ntohs(tmp16);
// 映射端口(混淆):字节10~11
memcpy(&tmp16, ipv6 + 10, 2);
out.mapped_port_obs = ntohs(tmp16);
// 映射IPv4(混淆):字节12~15
memcpy(&tmp32, ipv6 + 12, 4);
out.mapped_ip_obs = ntohl(tmp32);
}
// 去除混淆:逐位取反(complement)还原实际值
uint16_t deobfuscate_port(uint16_t obs) {
return ~obs; // 按位取反
}
uint32_t deobfuscate_ip(uint32_t obs) {
return ~obs; // 按位取反
}
// 将uint32_t的IPv4地址格式化为点分十进制字符串
void format_ipv4(uint32_t ip, char buf[INET_ADDRSTRLEN]) {
uint32_t net = htonl(ip);
inet_ntop(AF_INET, &net, buf, INET_ADDRSTRLEN);
}
int main() {
// 示例:构造一个 Teredo IPv6 地址的字节表示
// 格式:2001:0000 | server_ip | flags | ~port | ~client_ip
// 假设:
// 服务器IP = 65.54.227.120 (0x4136E378)
// flags = 0x0000
// 客户端IP = 192.168.1.100 (0xC0A80164)
// 客户端端口= 12345 (0x3039)
uint8_t ipv6[16] = {
0x20, 0x01, // Teredo前缀高16位
0x00, 0x00, // Teredo前缀低16位
0x41, 0x36, 0xE3, 0x78, // 服务器IPv4: 65.54.227.120
0x00, 0x00, // Flags = 0
// 映射端口取反: ~12345 = ~0x3039 = 0xCFC6
0xCF, 0xC6,
// 映射IP取反: ~0xC0A80164 = 0x3F57FE9B
0x3F, 0x57, 0xFE, 0x9B
};
TeredoAddress addr;
parse_teredo(ipv6, addr);
// 还原真实端口和IP
uint16_t real_port = deobfuscate_port(addr.mapped_port_obs);
uint32_t real_ip = deobfuscate_ip(addr.mapped_ip_obs);
char server_ip_str[INET_ADDRSTRLEN];
char client_ip_str[INET_ADDRSTRLEN];
format_ipv4(addr.server_ipv4, server_ip_str);
format_ipv4(real_ip, client_ip_str);
printf("=== Teredo 地址解析结果 ===\n");
printf("Teredo 前缀: 0x%08X (2001::/32)\n", addr.teredo_prefix);
printf("服务器 IPv4: %s\n", server_ip_str);
printf("Flags: 0x%04X\n", addr.flags);
printf(" C位(Cone NAT): %d (已废弃)\n", (addr.flags >> 15) & 1);
printf(" Random1(4bit): %d\n", (addr.flags >> 10) & 0xF);
printf(" U位: %d\n", (addr.flags >> 9) & 1);
printf(" G位: %d\n", (addr.flags >> 8) & 1);
printf(" Random2(8bit): %d\n", addr.flags & 0xFF);
printf("映射端口(混淆值): 0x%04X\n", addr.mapped_port_obs);
printf("实际映射端口: %u\n", real_port);
printf("实际映射 IPv4: %s\n", client_ip_str);
return 0;
}
编译与运行:
g++ -o teredo_parse teredo_parse.cpp
./teredo_parse
预期输出:
=== Teredo 地址解析结果 ===
Teredo 前缀: 0x20010000 (2001::/32)
服务器 IPv4: 65.54.227.120
Flags: 0x0000
C位(Cone NAT): 0 (已废弃)
Random1(4bit): 0
U位: 0
G位: 0
Random2(8bit): 0
映射端口(混淆值): 0xCFC6
实际映射端口: 12345
实际映射 IPv4: 192.168.1.100
10. 完整流程图(Mermaid)
11. 关键要点总结
- Teredo 的本质:用 UDP/IPv4 作为载体传输 IPv6 报文,绕过 IPv4-only 基础设施。
- 地址构造规则: Teredo地址 = 前缀 ( 2001 : : / 32 ) + 服务器IP + Flags + 端口 ~ + 客户端IP ~ \text{Teredo地址} = \text{前缀}(2001::/32) + \text{服务器IP} + \text{Flags} + \widetilde{\text{端口}} + \widetilde{\text{客户端IP}} Teredo地址=前缀(2001::/32)+服务器IP+Flags+端口 +客户端IP ,其中 x ~ \widetilde{x} x 表示按位取反。
- 两种封装:Simple Encapsulation 用于普通传输;Origin Indication Encapsulation 用于传递映射地址信息。
- Trailers 的作用:通过 TLV 格式附加扩展信息,支持对称 NAT、防重放、发夹转发等高级特性。
- 使用原则:Teredo 是最后手段——一旦发现其他 IPv6 连接方式(如直连或 6to4),节点应立即停止使用 Teredo 隧道。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)