本站为个人非经营性网站,仅用于技术学习与分享,不提供任何商品、服务、报价或收费咨询。 合规信息

Modbus RTU 从站实现笔记:
寄存器表、CRC 与异常码

Modbus 的协议本身很薄,难的是“怎么把自己的业务塞进一张寄存器表里”。 这篇笔记以一块液位采集小板(HC32F030)为例,记录我在实现从站时对寄存器规划、功能码处理、 校验与异常返回、半双工方向控制这几个环节的理解和踩过的坑。

Modbus RTU RS485 HC32F030 通信协议 已发布

一、先想清楚:从站到底要对外暴露什么

我最早写 Modbus 从站的时候,习惯是“先把协议解析写出来,再想寄存器怎么排”。 结果就是寄存器地址反复改,上位机那边跟着改,改到最后自己都记不清哪个地址是什么。 后来换了个顺序:先写寄存器表,再写协议解析,情况就好很多。

这块小板的功能很朴素:两路液位采集、两路温度采集、一路继电器输出。 我把它对外暴露的寄存器整理成三类:

  • 只读的实时值:采集值、补偿值、精度值(也就是经过滤波与标定后的最终值)
  • 可读写的阈值参数:开阀点、关阀点,每个通道各一对
  • 状态与版本:固件版本号、设备状态、继电器输出状态

整理成表之后,地址分配就自然出来了(下表是我实际使用的一张表,已做过精简):

地址含义读写说明
0x00版本号R把编译日期当作版本号,便于确认现场烧的是哪一版
0x01设备状态R当前故障码
0x02 / 0x03 / 0x04液位 1 采集值 / 补偿值 / 精度值R原始值 → 人工补偿 → 最终参与控制的精度值
0x05 / 0x06 / 0x07液位 2 采集值 / 补偿值 / 精度值R同上
0x08 ~ 0x0A温度 1 采集值 / 补偿值 / 精度值R同上
0x0B ~ 0x0D温度 2 采集值 / 补偿值 / 精度值R同上
0x0E ~ 0x11温度 1 / 2 的开、关阈值R/W加热控制用
0x12 ~ 0x15液位 1 / 2 的开、关阈值R/W加液控制用
0x16继电器组状态R/W按位对应 4 路输出

为什么把“采集值 / 补偿值 / 精度值”分成三个寄存器

这是我踩过坑之后才加的。最初的版本只暴露一个最终值,结果现场出现偏差时, 我无法判断到底是传感器本身飘了,还是标定系数不对,还是有人手动补偿过。 分成三个值之后,远程看一眼寄存器就能定位问题出在哪一环:

  • 采集值:ADC 滤波后的原始换算值,未经任何人为修正
  • 补偿值:现场人工加的偏移量,默认 0
  • 精度值:采集值 + 补偿值,真正拿去和阈值比较的那一个

这一点看起来很笨——多占了两个寄存器。但它把“数值不对”这个模糊的问题, 变成了“是哪一段不对”这个可以远程判断的问题。对现场调试来说,这个收益远大于两个寄存器的开销。

横轴为寄存器地址递增,把四类分区画成横向色块:版本与状态、实时值(采集/补偿/精度)、阈值参数(可读写)、输出状态位域
图 1 · 寄存器表的分区结构示意图

二、功能码:只实现用得上的两个

我在这块小板上只实现了两个功能码,因为实际用到的也只有这两个:

  • 0x03 读保持寄存器:上位机读实时值与阈值
  • 0x10 写多个寄存器:上位机下发阈值参数

没有实现 0x01(读线圈)/0x06(写单个寄存器)等等。 我一开始担心“不完整会不会不兼容”,实际用下来,只要上位机按标准方式组帧, 只支持这两个功能码完全够用,而且代码量小得多、出错面也小得多。 真正需要注意的是不支持的功能码要明确回异常,而不是不响应—— 不响应会让上位机一直等到超时,排查起来非常费劲。

异常码:三种情况要分清楚

协议里有三类错误是必须区分开的,混在一起会让上位机无法判断该怎么处理:

场景返回含义
CRC 校验不通过异常码 0x11报文在传输中被干扰,属于“这一次没收到”,上位机应重试
功能码不支持异常码 0x01请求本身不合法,重试没有意义,应换功能码
寄存器地址越界异常码 0x02地址规划不一致,重试没有意义,应改上位机配置

这里的取舍是:CRC 错误值得重试,功能码和地址错误不值得重试。 把这两类区分开,上位机的重试策略才能写得合理—— 否则要么该重试的不重试,要么对着一个永远不可能成功的请求死循环重试。

一条时间轴:主站发出请求帧 → 静默间隔 → 从站应答帧,标注两端的静默窗口,下方另画一条异常分支:CRC 错误时从站回
图 2 · 请求—应答时序图

三、CRC16:优先用查表法

Modbus RTU 用的是 CRC-16/MODBUS:多项式 0xA001(也就是 0x8005 反转), 初值 0xFFFF,输入输出均反转,结果不异或。 我最初写的是按位计算版本,逻辑直白但每字节要循环 8 次, 在 Cortex-M0+ 这种没有硬件 CRC 外设的核上,一帧 256 字节约 27 ms 的延时—— 在 9600 bps 下勉强能接受,但没必要。

/* 按位计算的版本:好懂,但慢,适合先用它把逻辑跑通 */
static uint16_t modbus_crc16(const uint8_t *buf, uint16_t len)
{
    uint16_t crc = 0xFFFF;
    uint16_t i;
    uint8_t  j;

    for (i = 0; i < len; i++) {
        crc ^= (uint16_t)buf[i];
        for (j = 0; j < 8; j++) {
            if (crc & 0x0001) {
                crc = (crc >> 1) ^ 0xA001;
            } else {
                crc >>= 1;
            }
        }
    }
    return crc;
}

后来换成查表法(512 字节的表,高低字节分开存),速度大约提升一个数量级。 CRC 在 Modbus 里是要先算低字节、再发高字节,这一点在第一次写的时候我弄反过, 表现为“上位机算出来对不上,但我的设备自己收发是自洽的”——因为两边都发反了。 这种错误只有跟第三方上位机对接时才会暴露出来。

帧接收的边界判断

从站接收的关键不是“怎么解析”,而是“怎么判断一帧结束了”。我在小板上的做法是:

  1. 串口每收到一个字节,就重置一个超时计数器
  2. 如果超过 3.5 个字符时间没有新字节,就认为本帧结束
  3. 帧结束之后才进入解析流程,解析前先校验长度与 CRC

在 9600 bps、8N1 下,一个字符是 10 bit,3.5 字符约 3.65 ms。 我用一个 2 ms 的定时节拍去累计,累计到 2 个节拍(约 4 ms)就算超时, 精度上够用,也比在中断里做浮点运算省事得多。

四、RS485 半双工:方向控制的时间账

RS485 收发共用一对差分线,所以需要一个 GPIO 控制收发器的方向引脚(DE/RE)。 这段代码看起来只有两行,但它是这类小板最容易出问题的地方:

/* 以宏的形式封装方向切换,避免每处都写散 */
#define EN485_Port    GpioPortA
#define EN485_Pin     GpioPin1

/* 切到接收:拉低方向脚,等收发器真正切换完成再开接收 */
#define UART_485RX    { delay1ms(1); Gpio_ClrIO(EN485_Port, EN485_Pin); }

/* 切到发送:拉高方向脚后再延时,保证第一个字节已经在总线上稳定 */
#define UART_485TX    { Gpio_SetIO(EN485_Port, EN485_Pin); delay1ms(1); }

为什么会“看起来多余”地加两次 delay1ms(1)?因为收发器从接收态切到发送态不是瞬间完成, 而 MCU 的 TX 引脚可能已经比方向引脚早一步开始发数据了。 如果切换次序不对,会表现为第一个字节的起始位被削掉—— 上位机看到的是“偶尔收到一帧乱码”,或者“CRC 校验失败率很高”,而且换线、换波特率都没用。

多机总线上的静默时间

板子挂在多机总线上时还有一个容易忽略的点:从站不能在收到请求后“抢答”。 标准要求从站处理完之后、开始回复之前,要留出足够的静默时间,让主站有机会完成方向切换。 我在实现里把方向切换的延时算在内,整体留了大约 1~2 ms, 在 9600~115200 bps 范围内实测都能稳定工作。

五、把业务逻辑放进 500 ms 节拍里

协议解析只负责“把寄存器的值改掉”,控制逻辑完全不管通信。 这块小板没有跑 RTOS,用的是时间片轮询:

  • ADC 扫描中断持续往环形缓冲区里填数据(20 点)
  • 主循环按节拍依次处理:液位 1 → 液位 2 → 温度 1 → 温度 2
  • 每个通道处理完,做滤波、补偿,再与阈值比较,决定继电器动作
  • 继电器状态写回寄存器,供上位机读取

这种做法的好处是:通信和控制互不阻塞。 即使上位机一直在密集读寄存器,采集节拍也不会被打乱; 反过来,即使采集通道在处理,协议响应慢一点也不会丢帧(因为接收是中断驱动的)。

关于滤波算法、阈值比较的迟滞处理,以及时间片轮询的具体写法, 我在另外两篇笔记里展开: 《采样数据的处理:20 点环形缓冲、中值滤波与迟滞控制》 和 《不用 RTOS 的固件怎么写:时间片轮询 + 消息队列》。

六、几条我反复用到的经验

  1. 先排寄存器表,再写解析。表一旦定下来,代码结构自然就清楚了。
  2. 不支持的功能码要回异常,不要沉默。沉默会让上位机等到超时,浪费大量排查时间。
  3. CRC 错误与参数错误分开处理。前者值得重试,后者不值得。
  4. 方向切换的延时是必要的,不是“保险起见加的”。省掉它在实验室可能看不出问题,在现场会很难查。
  5. 把中间量也暴露出来(采集值 / 补偿值 / 精度值)。远程调试时,多一个寄存器往往能省一次出差。
  6. 版本号要能读。现场设备到底烧的是哪一版固件,最好不用拆机就能确认。

参考资料与说明

  • Modbus 应用协议规范(Modbus Application Protocol Specification)中关于功能码与异常码的定义,属于公开标准。
  • 本文涉及的 MCU 型号、寄存器地址与代码均为个人学习项目中的实际实现, 其中的地址表已做精简,示例代码为按理解重写的最小片段,不代表任何产品或交付代码。
  • 文中出现的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。