3、报文结构解析:以太网帧、IP头、TCP/UDP头、应用层载荷,逐层拆解
各位好,我是老周。今天咱们来聊聊报文结构。说实话,做嵌入式通信这十几年,我见过太多同事拿着抓包文件两眼一抹黑——明明数据就在那儿,就是看不懂。说白了,报文解析就是协议栈调试的“基本功”,就像练武要先扎马步一样。
我个人的习惯是,遇到设备入网失败,第一件事不是翻代码,而是先抓包、再拆包。你想想看,报文在网络上跑,每一层都加了“信封”,我们得一层层剥开,才能看到真正的数据。今天我就带大家把这个过程走一遍。
3.1 以太网帧:物理层的第一道“信封”
报文从网口出来,最先碰到的是以太网帧。嗯,这里要注意,以太网帧不是IP包,它是最底层的“运输容器”。
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| 前导码 | 7 | 同步时钟,实际抓包时通常不显示 |
| 帧起始定界符 | 1 | 标记帧开始 |
| 目的MAC地址 | 6 | 接收方网卡地址 |
| 源MAC地址 | 6 | 发送方网卡地址 |
| 类型/长度 | 2 | 0x0800表示IPv4,0x0806表示ARP |
| 载荷 | 46-1500 | 上层数据(IP包) |
| 帧校验序列 | 4 | CRC32校验 |
我在项目中遇到过一件事:有个同事调了一整天,设备就是连不上服务器。我过去一看抓包,发现目的MAC地址全是广播地址(FF:FF:FF:FF:FF:FF)。这哪是正常通信?分明是ARP请求没得到回应。所以你看,第一层就能发现问题。
3.2 IP头:网络层的“路由标签”
剥开以太网帧,里面就是IP包。IP头才是真正决定数据“去哪儿”的关键。我个人觉得,IP头里最容易被忽略的是“生存时间(TTL)”字段——很多入网失败其实跟它有关。
IP头结构(20字节固定部分):
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|版本| 首部长度 | 服务类型 | 总长度(字节) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 标识 |标志| 片偏移 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 生存时间(TTL) | 协议 | 首部校验和 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 源IP地址 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 目的IP地址 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
这里我重点说几个字段:
- 协议字段:6表示TCP,17表示UDP。抓包时如果看到协议号不对,那肯定是封装层出问题了。
- TTL:每经过一个路由器减1,减到0就丢弃。我曾经调试一个跨省入网项目,设备TTL设成了1,结果数据连第一个网关都没出去——直接死在门口了。
- 首部校验和:只校验IP头本身,不校验载荷。这个校验和错了,路由器直接丢包,而且不会通知发送方。
3.3 TCP/UDP头:传输层的“端口门牌”
IP头告诉我们数据要去哪台机器,TCP/UDP头则告诉机器把数据交给哪个应用程序。说白了,端口号就是应用程序的“门牌号”。
3.3.1 TCP头(20字节固定部分)
| 字段 | 长度(位) | 说明 |
|---|---|---|
| 源端口 | 16 | 发送方应用程序端口 |
| 目的端口 | 16 | 接收方应用程序端口 |
| 序列号 | 32 | 数据流中第一个字节的序号 |
| 确认号 | 32 | 期望收到的下一个字节序号 |
| 数据偏移 | 4 | TCP头长度(单位:4字节) |
| 标志位 | 9 | SYN、ACK、FIN、RST等 |
| 窗口大小 | 16 | 接收窗口(流量控制) |
| 校验和 | 16 | 覆盖TCP头和载荷 |
| 紧急指针 | 16 | URG标志置位时有效 |
你想想看,TCP三次握手的SYN、SYN-ACK、ACK,其实就是靠这些标志位来沟通的。我调试入网协议时,最喜欢看的就是“握手阶段”——如果只看到SYN发出,没有SYN-ACK回来,那八成是服务器端口没开或者防火墙拦了。
3.3.2 UDP头(8字节固定部分)
UDP头结构:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 源端口 | 目的端口 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 长度 | 校验和 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
UDP比TCP简单得多,没有握手、没有重传、没有流量控制。说白了就是“发出去就不管了”。很多物联网设备用UDP做入网广播,因为快、开销小。但代价是——丢包了也不知道。
3.4 应用层载荷:真正的“业务数据”
剥完前三层,剩下的就是应用层载荷。这才是设备入网协议真正要传递的数据。比如MQTT的CONNECT报文、CoAP的POST请求、或者自定义的二进制协议。
我见过最离谱的案例:一个同事抓包看到应用层数据全是0x00,但代码里明明写了发送“hello”。查了半天,发现是DMA传输时源地址指针指向了未初始化的内存区域。所以,应用层载荷一定要和协议文档逐字节比对。
// 一个典型的自定义入网请求报文(16进制):
// 帧头(2B) | 长度(1B) | 命令(1B) | 设备ID(4B) | 校验(1B) | 帧尾(2B)
// 0xAA 0x55 | 0x08 | 0x01 | 0x00 0x01 0x02 0x03 | 0xF7 | 0x0D 0x0A
// 解析代码片段:
uint8_t buffer[] = {0xAA, 0x55, 0x08, 0x01, 0x00, 0x01, 0x02, 0x03, 0xF7, 0x0D, 0x0A};
if (buffer[0] == 0xAA && buffer[1] == 0x55) { // 帧头校验
uint8_t len = buffer[2]; // 载荷长度
uint8_t cmd = buffer[3]; // 命令字
uint32_t dev_id = (buffer[4] << 24) | (buffer[5] << 16) |
(buffer[6] << 8) | buffer[7]; // 设备ID
// 继续解析...
}
3.5 逐层拆解实战:一张图看懂全过程
下面这张图是我自己画的一个报文逐层拆解流程。每次调试入网协议时,我都会在脑子里过一遍这个流程——从网线上的电信号,到应用程序能识别的数据,中间经历了什么。
这张图其实就说明了一件事:报文是“洋葱”,得一层层剥。我每次调试入网协议,都会在Wireshark里先看以太网帧的MAC地址对不对,再看IP头的TTL和校验和,然后看TCP/UDP的端口和标志位,最后才看应用层数据。顺序不能乱,乱了就容易漏掉关键线索。
好了,报文结构就讲到这里。下一层一层拆解的能力,说白了就是“眼力”——你看到的不再是乱码,而是有结构的协议数据。下次抓包时,试着按这个顺序走一遍,你会发现很多问题其实就藏在那些不起眼的字段里。