7、实战案例1:网络协议头解析(IP头、TCP头)
好,前面讲了那么多内存对齐和位域的理论,咱们得真刀真枪干一场了。
我个人觉得,最能体现这两个知识点价值的地方,就是网络协议解析。你想想看,一个数据包从网卡进来,几十个字节里塞满了各种标志位、长度、偏移量。如果结构体定义不对,解析出来的数据全是错的——我在项目中就吃过这种亏。
7.1 为什么网络协议头是“对齐”的重灾区?
网络协议头是典型的“紧凑型”数据结构。设计者为了节省带宽,恨不得把每个bit都用上。但C语言的结构体,默认是会做内存对齐的。这就产生了矛盾:
- 协议要求:字段必须连续排列,不能有空洞
- 编译器默认行为:按成员最大类型对齐,插入填充字节
说白了,如果你直接定义一个结构体去映射IP头,大概率会多出几个字节。解析出来的源IP、目的IP全错位了。嗯,这里要注意,必须用 #pragma pack(1) 或 __attribute__((packed)) 来强制取消对齐。
7.2 IP头结构解析(IPv4)
先看IPv4头的标准格式。它固定20字节,没有选项字段的话。我们直接用位域和普通字段混合定义:
// IPv4头(20字节,无选项)
typedef struct {
uint8_t ver_ihl; // 版本(4bit) + 首部长度(4bit)
uint8_t tos; // 服务类型
uint16_t total_length; // 总长度
uint16_t id; // 标识
uint16_t flags_frag; // 标志(3bit) + 片偏移(13bit)
uint8_t ttl; // 生存时间
uint8_t protocol; // 协议
uint16_t checksum; // 首部校验和
uint32_t src_addr; // 源IP地址
uint32_t dst_addr; // 目的IP地址
} __attribute__((packed)) ip_header_t;
这里有个细节:ver_ihl 和 flags_frag 其实包含了多个位域。但为了简化,我习惯用位运算去提取。为什么?因为位域在跨平台时,字节序问题会让你头疼。我个人习惯是:
- 能用
uint8_t、uint16_t直接映射的,就不用位域 - 只有那些真正需要按bit操作的字段,才用位域
比如提取版本号和首部长度:
uint8_t version = (header->ver_ihl >> 4) & 0x0F;
uint8_t ihl = header->ver_ihl & 0x0F;
这样写,不管是大端还是小端机器,结果都一样。你想想看,如果用了位域,在x86上解析没问题,换到ARM路由器上可能就全乱了。
7.3 TCP头结构解析
TCP头比IP头复杂一些,因为它有标志位(URG、ACK、PSH、RST、SYN、FIN)。这些标志位每个只占1bit,用位域再合适不过了:
// TCP头(20字节,无选项)
typedef struct {
uint16_t src_port; // 源端口
uint16_t dst_port; // 目的端口
uint32_t seq_num; // 序列号
uint32_t ack_num; // 确认号
uint8_t data_offset; // 数据偏移(4bit) + 保留(4bit)
uint8_t flags; // 标志位(6bit) + 保留(2bit)
uint16_t window; // 窗口大小
uint16_t checksum; // 校验和
uint16_t urgent_ptr; // 紧急指针
} __attribute__((packed)) tcp_header_t;
这里 flags 字段包含了6个标志位。我建议用宏定义来提取:
#define TCP_FLAG_URG 0x20
#define TCP_FLAG_ACK 0x10
#define TCP_FLAG_PSH 0x08
#define TCP_FLAG_RST 0x04
#define TCP_FLAG_SYN 0x02
#define TCP_FLAG_FIN 0x01
// 使用示例
if (tcp_hdr->flags & TCP_FLAG_SYN) {
// 这是SYN包
}
7.4 完整解析流程
现在我们把IP头和TCP头串起来,写一个完整的解析函数。假设我们已经从网卡拿到了原始数据包:
void parse_packet(const uint8_t *raw_data, uint32_t len) {
if (len < sizeof(ip_header_t)) {
printf("数据包太短,不是完整IP头\n");
return;
}
ip_header_t *ip = (ip_header_t *)raw_data;
uint8_t version = (ip->ver_ihl >> 4) & 0x0F;
uint8_t ihl = (ip->ver_ihl & 0x0F) * 4; // 单位是4字节
printf("IP版本: %d\n", version);
printf("首部长度: %d 字节\n", ihl);
printf("总长度: %d 字节\n", ntohs(ip->total_length));
printf("源IP: %d.%d.%d.%d\n",
(ip->src_addr >> 24) & 0xFF,
(ip->src_addr >> 16) & 0xFF,
(ip->src_addr >> 8) & 0xFF,
ip->src_addr & 0xFF);
// 检查是否是TCP协议
if (ip->protocol != 6) {
printf("非TCP协议,跳过\n");
return;
}
// TCP头紧跟在IP头后面
uint32_t tcp_offset = ihl;
if (len < tcp_offset + sizeof(tcp_header_t)) {
printf("数据包太短,不是完整TCP头\n");
return;
}
tcp_header_t *tcp = (tcp_header_t *)(raw_data + tcp_offset);
printf("源端口: %d\n", ntohs(tcp->src_port));
printf("目的端口: %d\n", ntohs(tcp->dst_port));
printf("序列号: %u\n", ntohl(tcp->seq_num));
if (tcp->flags & TCP_FLAG_SYN) printf("[SYN] ");
if (tcp->flags & TCP_FLAG_ACK) printf("[ACK] ");
if (tcp->flags & TCP_FLAG_FIN) printf("[FIN] ");
printf("\n");
}
这段代码有几个关键点:
- 强制packed:结构体定义必须加
__attribute__((packed)),否则sizeof会偏大 - 网络字节序转换:用
ntohs、ntohl把大端转成主机字节序 - 指针强转:直接把
uint8_t*强转为结构体指针,前提是数据已经对齐到1字节边界
7.5 用SVG展示IP头与TCP头的关系
下面这张图,展示了数据包在内存中的布局。IP头和TCP头是紧挨着的,中间没有空洞:
7.6 避坑指南与经验总结
最后,把我这些年踩过的坑总结一下:
- 别忘了packed:定义协议头结构体时,
__attribute__((packed))是必须的。少写了,sizeof 就不对。 - 字节序转换不能省:网络字节序是大端,x86是小端。所有16位、32位字段都要用
ntohs/ntohl转换。 - 位域慎用:跨平台时,位域的bit顺序可能不同。能用位运算解决的,就别用位域。
- 指针强转要小心:如果原始数据没有对齐到1字节边界,强转会导致总线错误。用
memcpy更安全。 - 长度检查不能少:解析前一定要检查数据包长度是否足够,否则会读到非法内存。
parse_ip_header() 函数,内部用 memcpy 把字段逐个拷贝出来,而不是直接强转。虽然多几行代码,但绝对安全。性能敏感的场景再用指针强转。
好了,这一节的内容就到这里。IP头和TCP头的解析,是内存对齐和位域最经典的应用场景。你把这个搞懂了,其他协议(UDP、ICMP、DNS)的解析,思路完全一样。
公众号:蓝海资料掘金营,微信deep3321