4. PDU与ADU:协议数据单元与应用数据单元的封装与解封装
各位工程师朋友,咱们今天聊点实在的。PDU和ADU这两个缩写,刚接触Modbus TCP的人很容易搞混。我当年第一次看协议栈文档时,也愣了半天——这俩到底啥关系?
说白了,PDU是Modbus的“灵魂”,ADU是它的“外衣”。一个负责表达“我要干什么”,一个负责告诉网络“怎么送过去”。
4.1 先搞清楚定义
PDU(Protocol Data Unit):协议数据单元。它由两部分组成:
- 功能码(Function Code):1个字节,告诉对方要做什么。比如0x03是读保持寄存器,0x06是写单个寄存器。
- 数据(Data):N个字节,具体操作的参数或结果。比如读寄存器的起始地址、数量,或者返回的寄存器值。
ADU(Application Data Unit):应用数据单元。它在PDU外面包了一层“网络信封”:
- MBAP报文头(Modbus Application Protocol Header):7个字节,包含事务处理标识、协议标识、长度、单元标识。
- PDU:就是上面说的那个。
核心公式:
ADU = MBAP头(7字节) + PDU(功能码 + 数据)
PDU = 功能码(1字节) + 数据(0~252字节)
4.2 封装过程:从“想法”到“报文”
我习惯把封装过程想象成“打包快递”。你想想看:
- 第一步:确定PDU
你想读从站地址为1的设备,从寄存器地址0x0000开始读10个寄存器。
PDU = 0x03(功能码) + 0x00 0x00(起始地址高8位、低8位) + 0x00 0x0A(数量高8位、低8位)
总共5个字节。 - 第二步:加上MBAP头
事务处理标识:随便给个值,比如0x0001(2字节)
协议标识:固定0x0000(2字节)
长度:后面还有多少字节?PDU是5字节,所以长度=5+0?不对,长度是从单元标识开始算的。单元标识1字节 + PDU 5字节 = 6字节。所以长度=0x0006(2字节)
单元标识:0x01(1字节)
MBAP头总共7字节。 - 第三步:组合成ADU
ADU = 0x0001 0x0000 0x0006 0x01 0x03 0x0000 0x000A
总共12字节。
我个人的小习惯:在调试时,我会先把PDU写出来,再套MBAP头。这样不容易漏掉字节。曾经有一次我忘了算单元标识占的长度,结果报文长度字段少算了1,对方直接返回异常——嗯,从那以后我再也不敢马虎了。
4.3 解封装过程:从“报文”到“数据”
收到ADU后,解封装就是反过来:
- 第一步:剥掉MBAP头
读取前7个字节,解析出事务处理标识、协议标识、长度、单元标识。
检查协议标识是否为0x0000,不是的话直接丢弃——这不是Modbus TCP。 - 第二步:提取PDU
从第8个字节开始,就是PDU。第一个字节是功能码,后面是数据。 - 第三步:解析功能码和数据
根据功能码知道操作类型,然后按协议解析数据。
注意:解封装时一定要校验长度字段。MBAP头里的长度值,应该等于“单元标识1字节 + PDU字节数”。如果对不上,说明报文被截断或篡改了。我在现场遇到过因为网线干扰导致长度字段出错的情况,从那以后我写的代码都会加长度校验。
4.4 用一张图看懂封装与解封装
下面这张SVG图,我画了封装和解封装的完整流程。你一看就明白:
4.5 实际项目中的注意事项
讲完理论,咱们聊聊实战中容易踩的坑:
| 问题 | 原因 | 解决办法 |
|---|---|---|
| 长度字段算错 | 忘了单元标识占1字节,或者把MBAP头长度也算进去了 | 长度 = 单元标识(1) + 功能码(1) + 数据长度(N) |
| 事务ID不匹配 | 请求和响应的事务ID对不上 | 发送时记录事务ID,接收时校验 |
| 协议ID非0 | 收到非Modbus TCP的报文 | 直接丢弃,不处理 |
| PDU数据超长 | 数据部分超过252字节 | Modbus TCP最大PDU是253字节(功能码1+数据252),超长需分包 |
避坑指南:我曾经在一个项目中,从站返回的响应报文长度字段比实际PDU少了1个字节。排查了半天,发现是从站固件把单元标识的长度漏算了。从那以后,我写上位机代码时都会加一个“报文长度自校验”——用实际收到的字节数去核对MBAP头里的长度字段,不一致就报错重发。
4.6 代码示例:封装与解封装
下面给一段C语言的伪代码,展示封装和解封装的核心逻辑。你可以在嵌入式设备或上位机中直接套用:
// 封装:构建ADU
uint8_t build_adu(uint8_t unit_id, uint8_t func_code,
uint8_t *data, uint16_t data_len,
uint8_t *adu_buf) {
uint16_t pdu_len = 1 + data_len; // 功能码1字节 + 数据
uint16_t total_len = 7 + pdu_len; // MBAP头7字节 + PDU
// MBAP头
adu_buf[0] = 0x00; // 事务ID高8位
adu_buf[1] = 0x01; // 事务ID低8位
adu_buf[2] = 0x00; // 协议ID高8位
adu_buf[3] = 0x00; // 协议ID低8位
adu_buf[4] = (total_len - 6) >> 8; // 长度高8位(从单元标识开始算)
adu_buf[5] = (total_len - 6) & 0xFF; // 长度低8位
adu_buf[6] = unit_id; // 单元标识
// PDU
adu_buf[7] = func_code; // 功能码
memcpy(&adu_buf[8], data, data_len); // 数据
return total_len;
}
// 解封装:解析ADU
int parse_adu(uint8_t *adu_buf, uint16_t adu_len,
uint8_t *unit_id, uint8_t *func_code,
uint8_t *data, uint16_t *data_len) {
// 检查最小长度
if (adu_len < 8) return -1; // 至少MBAP头7字节 + 功能码1字节
// 解析MBAP头
uint16_t proto_id = (adu_buf[2] << 8) | adu_buf[3];
if (proto_id != 0x0000) return -2; // 协议ID错误
uint16_t len_field = (adu_buf[4] << 8) | adu_buf[5];
if (len_field != adu_len - 6) return -3; // 长度校验失败
*unit_id = adu_buf[6];
// 提取PDU
*func_code = adu_buf[7];
*data_len = adu_len - 8; // 总长度 - MBAP头7字节 - 功能码1字节
memcpy(data, &adu_buf[8], *data_len);
return 0; // 成功
}
特别提醒:上面代码中的长度计算,我故意写成了 total_len - 6,因为MBAP头里的长度字段是从单元标识开始算的,不包括前面的6个字节(事务ID 2字节 + 协议ID 2字节 + 长度字段本身2字节)。这个细节很多人搞错,你一定要注意。
4.7 小结
PDU和ADU的关系,说白了就是“内容”和“包装”。PDU只关心“我要读哪个寄存器”,ADU关心“这个报文怎么在网络里跑”。
封装时,先造PDU,再套MBAP头。解封装时,先剥MBAP头,再解析PDU。顺序不能乱,长度不能错。
嗯,这一节就到这里。记住:调试时多打印报文,用Wireshark抓包看看,比看十遍文档都管用。