7、实战案例1:网络协议头解析(IP头、TCP头)

好,前面讲了那么多内存对齐和位域的理论,咱们得真刀真枪干一场了。

我个人觉得,最能体现这两个知识点价值的地方,就是网络协议解析。你想想看,一个数据包从网卡进来,几十个字节里塞满了各种标志位、长度、偏移量。如果结构体定义不对,解析出来的数据全是错的——我在项目中就吃过这种亏。

7.1 为什么网络协议头是“对齐”的重灾区?

网络协议头是典型的“紧凑型”数据结构。设计者为了节省带宽,恨不得把每个bit都用上。但C语言的结构体,默认是会做内存对齐的。这就产生了矛盾:

  • 协议要求:字段必须连续排列,不能有空洞
  • 编译器默认行为:按成员最大类型对齐,插入填充字节

说白了,如果你直接定义一个结构体去映射IP头,大概率会多出几个字节。解析出来的源IP、目的IP全错位了。嗯,这里要注意,必须用 #pragma pack(1)__attribute__((packed)) 来强制取消对齐。

⚠️ 我曾经犯过的错: 早期写网络抓包工具时,没加 packed 属性,结果结构体大小比实际IP头多了2个字节。排查了整整一个下午,才发现是编译器偷偷塞了填充位。从那以后,凡是涉及协议头的结构体,我第一件事就是检查 sizeof。

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_ihlflags_frag 其实包含了多个位域。但为了简化,我习惯用位运算去提取。为什么?因为位域在跨平台时,字节序问题会让你头疼。我个人习惯是:

  • 能用 uint8_tuint16_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包
}
💡 我的小技巧: 定义标志位时,用宏而不是位域。因为位域在内存中的布局是编译器相关的。宏定义配合位运算,可读性不差,而且绝对可移植。我在一个嵌入式项目里,就是用这种方式同时支持了STM32和Linux x86平台。

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会偏大
  • 网络字节序转换:用 ntohsntohl 把大端转成主机字节序
  • 指针强转:直接把 uint8_t* 强转为结构体指针,前提是数据已经对齐到1字节边界
🔑 核心要点: 协议解析的本质,就是“用结构体去映射一段连续内存”。内存对齐控制好了,解析就成功了一半。另一半是字节序处理。

7.5 用SVG展示IP头与TCP头的关系

下面这张图,展示了数据包在内存中的布局。IP头和TCP头是紧挨着的,中间没有空洞:

网络数据包内存布局(IP头 + TCP头) 以太网头 14字节 IP头(IPv4) 20字节(固定) TCP头 20字节(固定) 数据载荷 可变长度 ver_ihl(1B) | tos(1B) | total_length(2B) id(2B) | flags_frag(2B) | ttl(1B) protocol(1B) | checksum(2B) src_addr(4B) | dst_addr(4B) src_port(2B) | dst_port(2B) seq_num(4B) | ack_num(4B) data_offset(1B) | flags(1B) window(2B) | checksum(2B) | urgent_ptr(2B) 所有结构体均使用 __attribute__((packed)),保证无填充字节 IP头中的 ihl 字段指示IP头实际长度(单位:4字节) TCP头紧跟在IP头之后,偏移量为 ihl 字节

7.6 避坑指南与经验总结

最后,把我这些年踩过的坑总结一下:

  1. 别忘了packed:定义协议头结构体时,__attribute__((packed)) 是必须的。少写了,sizeof 就不对。
  2. 字节序转换不能省:网络字节序是大端,x86是小端。所有16位、32位字段都要用 ntohs/ntohl 转换。
  3. 位域慎用:跨平台时,位域的bit顺序可能不同。能用位运算解决的,就别用位域。
  4. 指针强转要小心:如果原始数据没有对齐到1字节边界,强转会导致总线错误。用 memcpy 更安全。
  5. 长度检查不能少:解析前一定要检查数据包长度是否足够,否则会读到非法内存。
💡 我的习惯: 在实际项目中,我会写一个 parse_ip_header() 函数,内部用 memcpy 把字段逐个拷贝出来,而不是直接强转。虽然多几行代码,但绝对安全。性能敏感的场景再用指针强转。

好了,这一节的内容就到这里。IP头和TCP头的解析,是内存对齐和位域最经典的应用场景。你把这个搞懂了,其他协议(UDP、ICMP、DNS)的解析,思路完全一样。


公众号:蓝海资料掘金营,微信deep3321