4. 命令帧结构

命令帧是什么?说白了,就是DCS系统中主站跟从站说话的格式。你想想看,两个人讲同一门语言才能沟通,设备和设备之间也一样。我这些年调过的系统不下二十套,光是在帧结构上出的幺蛾子就够写一本小说了。今天我就把这六个段位掰开揉碎讲给你听。

我们先看一张总图,把六个字段在帧里的位置定下来:

标准命令帧结构(Modbus RTU 示例) 起始码 3.5字符空闲 地址码 1字节 功能码 1字节 数据域 N字节 校验码 2字节(CRC) 结束码 3.5字符空闲 关键提示 • 起始码与结束码在许多协议中是“隐式的”——由总线空闲时间定义。 • 地址码=0 通常表示广播帧,所有从站都要接收但不应答。 • 校验失败会怎样?整帧丢弃,从站不回复,主站会超时重发。
我的习惯:拿到一个新设备的通讯协议手册,我第一个看的就是帧结构定义。很多现场问题翻到这一页就能判断出八成——地址码对不对、校验算法用了哪种、数据域字节顺序是大端还是小端。先把这个图谱刻在脑子里,后面调试才不会抓瞎。

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 数据域——具体的干活内容

数据域是帧里最灵活的部分,长度可长可短。包含三类信息:

  1. 读写参数:寄存器地址、数量、数据值。
  2. 子功能码:比如诊断命令里的子功能。
  3. 用户数据:如果是写命令,就携带要写的字节。

数据域没有结构规定,完全靠功能码来约定。举个例子:

  • 功能码03(读保持寄存器)数据域=起始地址2字节+寄存器数量2字节。
  • 功能码06(写单个寄存器)数据域=寄存器地址2字节+数据值2字节。
  • 功能码16(写多个寄存器)数据域=起始地址2字节+寄存器数量2字节+字节计数1字节+数据N字节。

我自己写上位机程序时,常犯的错是字节顺序搞反。比如寄存器地址0x006B写成0x6B00,那就读到了完全不同的地址。所以现在每段报文我都会用计算器先算一次十六进制。

注意:数据域长度不能超过协议上限。Modbus RTU单帧数据域最大253字节,再多就得分包。我见过一个同事用写多个寄存器写了500个寄存器,一帧出去直接把从站缓冲区撑爆了——从站直接死机。

4.5 校验码——保证帧没被篡改

校验码是帧的“防伪标签”。接收方收到后用自己的算法算一遍,跟发送的校验码比对,不一致就扔掉。常用校验算法有:

协议校验算法校验码长度
Modbus RTUCRC-16 (Modbus polynomial)2字节
Modbus ASCIILRC (纵向冗余校验)1字节
Profibus DPFCS (帧校验序列,与CRC相关)2字节
CANopenCRC-152字节

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的通讯协议手册,应该一眼就能画出帧结构了。地址码、功能码、数据域是眼睛能看见的内容;起始码、结束码、校验码是保障传输可靠性的“幕后英雄”。下次调试时,不妨从帧结构入手,按这六个维度逐个核对,很多问题当场就水落石出。