一、先想清楚:从站到底要对外暴露什么
我最早写 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精度值:采集值 + 补偿值,真正拿去和阈值比较的那一个
这一点看起来很笨——多占了两个寄存器。但它把“数值不对”这个模糊的问题, 变成了“是哪一段不对”这个可以远程判断的问题。对现场调试来说,这个收益远大于两个寄存器的开销。
二、功能码:只实现用得上的两个
我在这块小板上只实现了两个功能码,因为实际用到的也只有这两个:
0x03读保持寄存器:上位机读实时值与阈值0x10写多个寄存器:上位机下发阈值参数
没有实现 0x01(读线圈)/0x06(写单个寄存器)等等。
我一开始担心“不完整会不会不兼容”,实际用下来,只要上位机按标准方式组帧,
只支持这两个功能码完全够用,而且代码量小得多、出错面也小得多。
真正需要注意的是不支持的功能码要明确回异常,而不是不响应——
不响应会让上位机一直等到超时,排查起来非常费劲。
异常码:三种情况要分清楚
协议里有三类错误是必须区分开的,混在一起会让上位机无法判断该怎么处理:
| 场景 | 返回 | 含义 |
|---|---|---|
| CRC 校验不通过 | 异常码 0x11 | 报文在传输中被干扰,属于“这一次没收到”,上位机应重试 |
| 功能码不支持 | 异常码 0x01 | 请求本身不合法,重试没有意义,应换功能码 |
| 寄存器地址越界 | 异常码 0x02 | 地址规划不一致,重试没有意义,应改上位机配置 |
这里的取舍是:CRC 错误值得重试,功能码和地址错误不值得重试。 把这两类区分开,上位机的重试策略才能写得合理—— 否则要么该重试的不重试,要么对着一个永远不可能成功的请求死循环重试。
三、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 里是要先算低字节、再发高字节,这一点在第一次写的时候我弄反过, 表现为“上位机算出来对不上,但我的设备自己收发是自洽的”——因为两边都发反了。 这种错误只有跟第三方上位机对接时才会暴露出来。
帧接收的边界判断
从站接收的关键不是“怎么解析”,而是“怎么判断一帧结束了”。我在小板上的做法是:
- 串口每收到一个字节,就重置一个超时计数器
- 如果超过 3.5 个字符时间没有新字节,就认为本帧结束
- 帧结束之后才进入解析流程,解析前先校验长度与 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 的固件怎么写:时间片轮询 + 消息队列》。
六、几条我反复用到的经验
- 先排寄存器表,再写解析。表一旦定下来,代码结构自然就清楚了。
- 不支持的功能码要回异常,不要沉默。沉默会让上位机等到超时,浪费大量排查时间。
- CRC 错误与参数错误分开处理。前者值得重试,后者不值得。
- 方向切换的延时是必要的,不是“保险起见加的”。省掉它在实验室可能看不出问题,在现场会很难查。
- 把中间量也暴露出来(采集值 / 补偿值 / 精度值)。远程调试时,多一个寄存器往往能省一次出差。
- 版本号要能读。现场设备到底烧的是哪一版固件,最好不用拆机就能确认。
参考资料与说明
- Modbus 应用协议规范(Modbus Application Protocol Specification)中关于功能码与异常码的定义,属于公开标准。
- 本文涉及的 MCU 型号、寄存器地址与代码均为个人学习项目中的实际实现, 其中的地址表已做精简,示例代码为按理解重写的最小片段,不代表任何产品或交付代码。
- 文中出现的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。