4. 命令帧结构
命令帧是什么?说白了,就是DCS系统中主站跟从站说话的格式。你想想看,两个人讲同一门语言才能沟通,设备和设备之间也一样。我这些年调过的系统不下二十套,光是在帧结构上出的幺蛾子就够写一本小说了。今天我就把这六个段位掰开揉碎讲给你听。
我们先看一张总图,把六个字段在帧里的位置定下来:
4.1 起始码——帧开始的信号枪
起始码的作用只有一句话:告诉接收方“注意,我要发命令了”。但实现方式差别很大。
- 空闲时间法(赛前空场):比如Modbus RTU,总线空闲超过3.5个字符时间就算一帧开始。接收方监测到静默后第一个字节就是起点。
- 专用字符法(发令枪):比如某些PLC协议用0x02(STX)作为起始标记,后面跟着地址和正文。
- 电平变化法(跳变沿):RS485总线上,从空闲高电平变为低电平的边沿,很多驱动芯片用它来激活接收。
我遇到过一台进口变送器,它的起始码要求总线空闲时间至少5ms。当时用国产网关轮询,空闲只留了3ms,结果丢包率超过30%。查了半天,最后拿示波器量了电平持续才找到根儿。
4.2 地址码——这帧到底是给谁的?
地址码就是一个编号,范围通常是0~247(Modbus RTU从站地址)。主站发送时填上目标从站地址,从站收到后跟自己地址比对:匹配就处理,不匹配就扔。
- 地址0是广播地址,所有从站都要执行,但不回应答。
- 每个从站必须有唯一地址。我曾经手滑把两台AO卡都配成了地址3…结果主站每次发地址3,两台同时响应,总线崩得一塌糊涂。
- 有些系统支持地址掩码或多地址匹配,比如施耐德某些控制器允许一个从站响应多个地址。
| 地址范围 | 用途 | 是否应答 |
|---|---|---|
| 0 | 广播 | 不应答 |
| 1~247 | 单播 | 必须应答 |
| 248~255 | 保留/扩展 | 视协议而定 |
在DCS项目中,我习惯把地址编码规约定成与机架号、槽号对应。比如机架1槽位5就配1×10+5=15。这样一眼就能看出物理位置。
4.3 功能码——你想干什么?
功能码就是命令动词。Modbus标准功能码从1到127,比如01读线圈、03读保持寄存器、06写单个寄存器、16写多个寄存器。超过127的功能码通常是厂商自定义或者带故障响应的异常码。
// Modbus RTU 读保持寄存器命令帧(主→从):
// 地址码: 0x11 (17)
// 功能码: 0x03 (读保持寄存器)
// 数据域: 起始寄存器地址高字节 0x00, 低字节 0x6B
// 寄存器数量高字节 0x00, 低字节 0x03
// 校验码: 0x76 0x87 (CRC16)
//
// 完整帧: 11 03 00 6B 00 03 76 87
嗯,这里要注意:功能码还能分出正常与异常。如果从站收到一个它不支持的功能码,它会回复一个异常帧——把功能码的最高位置1,然后带一个异常码。举个例,功能码03变成83,后面跟01表示非法功能。
我的经验:调试初期,我总会先抓一个读寄存器的报文,看看功能码是否被篡改。有些网关会自作聪明地把03改成04(读输入寄存器),导致返回数据全错。那次查了一上午,最后用串口监听才发现是网关协议转换表配置错了。
4.4 数据域——具体的干活内容
数据域是帧里最灵活的部分,长度可长可短。包含三类信息:
- 读写参数:寄存器地址、数量、数据值。
- 子功能码:比如诊断命令里的子功能。
- 用户数据:如果是写命令,就携带要写的字节。
数据域没有结构规定,完全靠功能码来约定。举个例子:
- 功能码03(读保持寄存器)数据域=起始地址2字节+寄存器数量2字节。
- 功能码06(写单个寄存器)数据域=寄存器地址2字节+数据值2字节。
- 功能码16(写多个寄存器)数据域=起始地址2字节+寄存器数量2字节+字节计数1字节+数据N字节。
我自己写上位机程序时,常犯的错是字节顺序搞反。比如寄存器地址0x006B写成0x6B00,那就读到了完全不同的地址。所以现在每段报文我都会用计算器先算一次十六进制。
4.5 校验码——保证帧没被篡改
校验码是帧的“防伪标签”。接收方收到后用自己的算法算一遍,跟发送的校验码比对,不一致就扔掉。常用校验算法有:
| 协议 | 校验算法 | 校验码长度 |
|---|---|---|
| Modbus RTU | CRC-16 (Modbus polynomial) | 2字节 |
| Modbus ASCII | LRC (纵向冗余校验) | 1字节 |
| Profibus DP | FCS (帧校验序列,与CRC相关) | 2字节 |
| CANopen | CRC-15 | 2字节 |
CRC计算有个细节:多项式是多少?初始值是多少?有没有异或输出?比如Modbus CRC-16使用多项式0x8005,初始值0xFFFF,结果异或0x0000。搞错了就算不对。
// CRC-16 Modbus 计算伪代码(简化):
uint16_t crc = 0xFFFF;
for each byte in frame(start, addr, func, data):
crc ^= byte;
for (int i=0; i<8; i++):
if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001;
else crc >>= 1;
// 结果低位在前,高位在后(小端)
我曾经在一个项目中,用上位机发帧,CRC始终对不上。拿计算器算了半天才发现是多项式写成了0x8005变0x1021——两个完全不同的算法。从那以后我写CRC都会跟设备厂家确认具体算法参数。
4.6 结束码——收工,请勿打扰
结束码跟起始码对应,告诉接收方“这帧我说完了,你可以开始处理了”。实现方式也有几种:
- 空闲时间法:还是Modbus RTU的做法,发送完最后一个字节后,总线保持空闲3.5字符时间,接收方以此为帧结束。
- 帧尾字符法:比如Modbus ASCII用0x0D 0x0A(回车换行)作为结束。
- 长度计数法:有些协议在帧头就声明了总长度,接收方数够字节数就算结束,不需要额外的结束码。
我的建议:在单片机或嵌入式平台实现Modbus从站时,建议用一个单独的定时器来监控总线空闲。收到第一个字节就启动定时器,每收到一个字节就复位。定时器溢出3.5字符时间就认为一帧完成。这样能兼顾起始码和结束码的判断,代码结构也干净。
好,六个字段都讲完了。你现在再去翻任何DCS或PLC的通讯协议手册,应该一眼就能画出帧结构了。地址码、功能码、数据域是眼睛能看见的内容;起始码、结束码、校验码是保障传输可靠性的“幕后英雄”。下次调试时,不妨从帧结构入手,按这六个维度逐个核对,很多问题当场就水落石出。