文章目录
-
- 背景与问题
-
- 现场改造的选型困境
- 本文要解决的核心问题
- 原理分析
-
- Modbus RTU 帧格式
- 寄存器模型与功能码
- CRC16 校验算法
- 从站处理状态机
- 环境准备
-
- 硬件清单
- 软件版本与 CubeMX 配置
- 工程目录结构
- 实战步骤
-
- 第一步:实现 CRC16 查表算法
- 第二步:实现寄存器映射表
- 第三步:串口中断接收与帧切分
- 第四步:协议栈状态机
- 第五步:三个功能码的具体实现
- 第六步:主循环整合与验证
- 第七步:点表与传感器联动
- 场景适配
-
- 多从站组网(1 主多从)
- RS-485 半双工时序适配
- 通过 4G/WiFi 透传上云
- 性能优化
-
- 中断收字节改成 DMA + IDLE 中断
- CRC 查表 vs 计算
- 主站轮询节奏匹配
- 故障排查
-
- 故障一:CRC 校验一直失败
- 故障二:485 半双工丢最后一个字节
- 故障三:Modbus Poll 报 Illegal Data Address
- 故障四:波特率误差导致偶发乱码
- 总结
-
- 核心要点
- 适用边界与扩展方向
背景与问题
现场改造的选型困境
去年接手一套老产线的数据采集改造,现场 12 台温控仪表全部走 RS-485 挂到一台 STM32F103 网关板上。原方案用的是某厂商的闭源协议栈,授权费按节点数收,加一台设备就要补钱,而且点表一旦变动就得找原厂改固件,一个来回两个星期。项目组当时评估过三条路:继续续费闭源栈、上 FreeModbus、自己写一套精简从站。
三条路都试过。闭源栈续费一年 3 万,成本先不谈,改点表走商务流程这件事本身就够烦。FreeModbus 代码成熟,但为了适配 HAL 库要把 port 层重写一遍,而且它把帧解析、寄存器管理揉在一起,想裁剪成"只留保持寄存器 + 3 个功能码"的最小集反而费劲。最后决定自研,只覆盖产线实际用到的能力。
本文要解决的核心问题
本文用 STM32 HAL 库从零实现一个 Modbus RTU 从站,支持 03/06/16 三个功能码、保持寄存器读写、CRC16 校验,代码量控制在 600 行以内,可直接移植到任意 Cortex-M 平台。读完你能得到一套能直接跑通的最小协议栈,以及线上排查串口通信问题的完整方法。
原理分析
Modbus RTU 帧格式
Modbus RTU 是主从式串行协议,一帧数据固定由四段组成:从站地址(1字节)、功能码(1字节)、数据区(N字节)、CRC16(2字节,低字节在前)。帧间静默时间要求大于 3.5 个字符时间,帧内字节间隔小于 1.5 个字符时间,这是从站切分帧的依据。
以 9600bps 为例,1 个字符含 1 起始位 + 8 数据位 + 1 停止位共 10bit,一个字符时间约 1.04ms,3.5 字符静默约 3.64ms。从站串口收到字节后如果超过 3.5 字符时间没收到下一字节,就认为一帧结束,开始解析。
地址(1B) | 功能码(1B) | 数据区(NB) | CRC低字节 | CRC高字节
寄存器模型与功能码
Modbus 把数据分成四类区域,从站通过地址区间区分读写类型:
| 线圈 | 00001-09999 | 只读/读写位 | 01/05/15 | 继电器、阀门开关 |
| 离散输入 | 10001-19999 | 只读位 | 02 | 限位开关 |
| 输入寄存器 | 30001-39999 | 只读字 | 04 | 温湿度采样值 |
| 保持寄存器 | 40001-49999 | 读写字 | 03/06/16 | 设定值、PID 参数 |
通讯报文里的寄存器编号是 0 起始的,例如 PLC 侧看到的 40001 在报文里写 0x0000,40010 写 0x0009。从站代码里统一用"协议地址 = 物理地址 – 40001"换算,避免混淆。
CRC16 校验算法
Modbus RTU 用 CRC-16/MODBUS 多项式 0xA001,初始值 0xFFFF。按字节计算,每字节先异或低字节,再右移 8 次,遇最低位为 1 就异或多项式。CRC 校验在从站里承担双重职责:既校验帧完整性,也参与帧切分判断——CRC 不对的帧直接丢弃,不产生任何响应。
从站处理状态机
从站侧的处理逻辑可以抽象成四个状态,串口中断只负责收字节和计时,帧解析放到主循环,保证中断里不做耗时操作。
#mermaid-svg-Q9TDY2y452MWiIwQ{font-family:Arial;font-size:14px;fill:#ccc;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Q9TDY2y452MWiIwQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Q9TDY2y452MWiIwQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Q9TDY2y452MWiIwQ .error-icon{fill:#a44141;}#mermaid-svg-Q9TDY2y452MWiIwQ .error-text{fill:#ddd;stroke:#ddd;}#mermaid-svg-Q9TDY2y452MWiIwQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Q9TDY2y452MWiIwQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Q9TDY2y452MWiIwQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Q9TDY2y452MWiIwQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Q9TDY2y452MWiIwQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Q9TDY2y452MWiIwQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Q9TDY2y452MWiIwQ .marker{fill:lightgrey;stroke:lightgrey;}#mermaid-svg-Q9TDY2y452MWiIwQ .marker.cross{stroke:lightgrey;}#mermaid-svg-Q9TDY2y452MWiIwQ svg{font-family:Arial;font-size:14px;}#mermaid-svg-Q9TDY2y452MWiIwQ p{margin:0;}#mermaid-svg-Q9TDY2y452MWiIwQ .label{font-family:Arial;color:#ccc;}#mermaid-svg-Q9TDY2y452MWiIwQ .cluster-label text{fill:#F9FFFE;}#mermaid-svg-Q9TDY2y452MWiIwQ .cluster-label span{color:#F9FFFE;}#mermaid-svg-Q9TDY2y452MWiIwQ .cluster-label span p{background-color:transparent;}#mermaid-svg-Q9TDY2y452MWiIwQ .label text,#mermaid-svg-Q9TDY2y452MWiIwQ span{fill:#ccc;color:#ccc;}#mermaid-svg-Q9TDY2y452MWiIwQ .node rect,#mermaid-svg-Q9TDY2y452MWiIwQ .node circle,#mermaid-svg-Q9TDY2y452MWiIwQ .node ellipse,#mermaid-svg-Q9TDY2y452MWiIwQ .node polygon,#mermaid-svg-Q9TDY2y452MWiIwQ .node path{fill:#1f2020;stroke:#ccc;stroke-width:1px;}#mermaid-svg-Q9TDY2y452MWiIwQ .rough-node .label text,#mermaid-svg-Q9TDY2y452MWiIwQ .node .label text,#mermaid-svg-Q9TDY2y452MWiIwQ .image-shape .label,#mermaid-svg-Q9TDY2y452MWiIwQ .icon-shape .label{text-anchor:middle;}#mermaid-svg-Q9TDY2y452MWiIwQ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Q9TDY2y452MWiIwQ .rough-node .label,#mermaid-svg-Q9TDY2y452MWiIwQ .node .label,#mermaid-svg-Q9TDY2y452MWiIwQ .image-shape .label,#mermaid-svg-Q9TDY2y452MWiIwQ .icon-shape .label{text-align:center;}#mermaid-svg-Q9TDY2y452MWiIwQ .node.clickable{cursor:pointer;}#mermaid-svg-Q9TDY2y452MWiIwQ .root .anchor path{fill:lightgrey!important;stroke-width:0;stroke:lightgrey;}#mermaid-svg-Q9TDY2y452MWiIwQ .arrowheadPath{fill:lightgrey;}#mermaid-svg-Q9TDY2y452MWiIwQ .edgePath .path{stroke:lightgrey;stroke-width:2.0px;}#mermaid-svg-Q9TDY2y452MWiIwQ .flowchart-link{stroke:lightgrey;fill:none;}#mermaid-svg-Q9TDY2y452MWiIwQ .edgeLabel{background-color:hsl(0, 0%, 34.4117647059%);text-align:center;}#mermaid-svg-Q9TDY2y452MWiIwQ .edgeLabel p{background-color:hsl(0, 0%, 34.4117647059%);}#mermaid-svg-Q9TDY2y452MWiIwQ .edgeLabel rect{opacity:0.5;background-color:hsl(0, 0%, 34.4117647059%);fill:hsl(0, 0%, 34.4117647059%);}#mermaid-svg-Q9TDY2y452MWiIwQ .labelBkg{background-color:rgba(87.75, 87.75, 87.75, 0.5);}#mermaid-svg-Q9TDY2y452MWiIwQ .cluster rect{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:rgba(255, 255, 255, 0.25);stroke-width:1px;}#mermaid-svg-Q9TDY2y452MWiIwQ .cluster text{fill:#F9FFFE;}#mermaid-svg-Q9TDY2y452MWiIwQ .cluster span{color:#F9FFFE;}#mermaid-svg-Q9TDY2y452MWiIwQ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:Arial;font-size:12px;background:hsl(20, 1.5873015873%, 12.3529411765%);border:1px solid rgba(255, 255, 255, 0.25);border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Q9TDY2y452MWiIwQ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#ccc;}#mermaid-svg-Q9TDY2y452MWiIwQ rect.text{fill:none;stroke-width:0;}#mermaid-svg-Q9TDY2y452MWiIwQ .icon-shape,#mermaid-svg-Q9TDY2y452MWiIwQ .image-shape{background-color:hsl(0, 0%, 34.4117647059%);text-align:center;}#mermaid-svg-Q9TDY2y452MWiIwQ .icon-shape p,#mermaid-svg-Q9TDY2y452MWiIwQ .image-shape p{background-color:hsl(0, 0%, 34.4117647059%);padding:2px;}#mermaid-svg-Q9TDY2y452MWiIwQ .icon-shape .label rect,#mermaid-svg-Q9TDY2y452MWiIwQ .image-shape .label rect{opacity:0.5;background-color:hsl(0, 0%, 34.4117647059%);fill:hsl(0, 0%, 34.4117647059%);}#mermaid-svg-Q9TDY2y452MWiIwQ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Q9TDY2y452MWiIwQ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Q9TDY2y452MWiIwQ :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
收到字节
超时3.5字符
否
是
失败
通过
否
是
03/06/16
其他
发送完成
空闲等待
接收中
长度 >= 4?
CRC 校验
地址匹配?
功能码分发
组响应帧
组异常帧
485 发送
图表解读:
- 数据流向:从空闲等待开始,收字节进入接收中,帧超时后依次经过长度、CRC、地址三道校验,校验全部通过才进入功能码分发
- 核心决策点:CRC 校验和地址匹配是两道闸门,任一失败直接丢弃回空闲,从站不产生任何响应
- 性能关键路径:接收中 → CRC → 分发 → 发送,该路径上的优化空间在串口接收方式(中断 vs DMA)和 CRC 算法(计算 vs 查表)
环境准备
硬件清单
- STM32F103C8T6 核心板(Blue Pill)一块,主频 72MHz
- USB-TTL 转 RS-485 模块一个(如 MAX485 方案的)
- 上位机装 Modbus Poll 或 serial-studio,用于模拟主站
- 示波器或逻辑分析仪(排查时序问题时用得上)
软件版本与 CubeMX 配置
| STM32CubeMX | 6.12 | 生成初始化代码 |
| STM32Cube FW_F1 | 1.8.5 | HAL 库 |
| Keil MDK | 5.38 | 编译调试 |
| Modbus Poll | 10.5 | 主站模拟验证 |
USART1 使用 PA9(USART1_TX)、PA10(USART1_RX),波特率 9600,8 数据位、无校验、1 停止位,使能 USART1 全局中断。485 方向控制引脚用 PB1,高电平发送、低电平接收。
文件名:stm32f1xx_hal_conf.h(关键宏)
#define HAL_UART_MODULE_ENABLED // 使能串口
#define HAL_GPIO_MODULE_ENABLED // 使能 GPIO
#define USE_RTOS 0 // 裸机工程,不开 RTOS
代码解读:CubeMX 生成的工程默认这些宏都是打开的。USE_RTOS 置 0 表示走裸机主循环,从站状态机不需要任务调度,定时器只做超时计时。
工程目录结构
stm32-modbus-slave/
├── Core/
│ ├── Inc/ # 头文件
│ └── Src/
│ ├── main.c # 主循环
│ ├── usart.c # 串口收发
│ └── stm32f1xx_it.c # 中断服务
├── Drivers/ # HAL 库
└── App/
├── modbus.c # 协议栈:帧解析 + 功能码
├── modbus.h
├── crc16.c # CRC 查表实现
└── regtable.c # 寄存器映射表
代码解读:协议栈独立放在 App 目录,不依赖具体芯片型号。crc16 和 regtable 是纯 C 逻辑,换到 GD32、国民技术等芯片时只需重新接串口和寄存器 IO,协议层不用动。
实战步骤
第一步:实现 CRC16 查表算法
计算型 CRC 每字节要循环 8 次,9600 波特率下 CPU 占用不明显,但后续如果上 Modbus TCP 做高速轮询,计算型会成为瓶颈,所以直接用查表法。表在编译期生成,运行时空闲 256 字节 Flash。
文件名:App/crc16.h
#ifndef __CRC16_H
#define __CRC16_H
#include <stdint.h>
uint16_t crc16_modbus(const uint8_t *data, uint16_t len);
#endif
代码解读:头文件只暴露 crc16_modbus 一个接口,入参是数据指针和长度,返回 16 位校验值。查表内容放在 .c 文件内部,外部模块看不到也改不了。
文件名:App/crc16.c
#include "crc16.h"
/* 0xA001 多项式查表,每项为低字节向高字节计算后的中间结果 */
static const uint16_t crc_table[256] = {
0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
/* 省略中间 248 项,生成脚本见文末备注 */
};
uint16_t crc16_modbus(const uint8_t *data, uint16_t len)
{
uint16_t crc = 0xFFFF; // 初始值固定 0xFFFF
uint16_t i;
for (i = 0; i < len; i++) {
crc = (crc >> 8) ^ crc_table[(crc ^ data[i]) & 0xFF];
}
return crc; // 返回值为 16 位 CRC
}
代码解读:查表法的核心是用"高 8 位异或当前字节"的结果作为查表下标,一次查表替代 8 次移位异或。整张表可以用脚本生成,避免手工抄错,生成逻辑就是计算型算法的完整展开。发送端和接收端必须用同一张表,否则校验必然失败。
第二步:实现寄存器映射表
寄存器表用结构体数组管理,每个条目记录协议地址、当前值、读写属性。这样点表调整只改数组,不动协议逻辑。
文件名:App/regtable.h
#ifndef __REGTABLE_H
#define __REGTABLE_H
#include <stdint.h>
#define REG_READ_ONLY 0x01 // 只读寄存器
#define REG_READ_WRITE 0x02 // 可读写寄存器
typedef struct {
uint16_t addr; // 协议地址(0 起始,对应 40001)
uint16_t value; // 寄存器当前值
uint8_t attr; // 读写属性
} reg_item_t;
reg_item_t *reg_find(uint16_t addr);
#endif
代码解读:头文件定义寄存器条目结构和读写属性常量,reg_find 的声明让协议层可以按协议地址查点表。attr 字段用位标记而非枚举,方便未来叠加多个属性。
文件名:App/regtable.c
#include "regtable.h"
/* 点表:40001 温度设定值,40002 湿度设定值,40003 运行模式(只读) */
static reg_item_t reg_table[] = {
{0x0000, 2500, REG_READ_WRITE}, // 40001 温度 25.00℃(放大 100 倍)
{0x0001, 6000, REG_READ_WRITE}, // 40002 湿度 60.00%RH
{0x0002, 0x0001, REG_READ_ONLY}, // 40003 运行模式,1=自动
};
#define REG_TABLE_SIZE (sizeof(reg_table) / sizeof(reg_table[0]))
/* 线性查找寄存器,命中返回指针,未命中返回 NULL */
reg_item_t *reg_find(uint16_t addr)
{
uint8_t i;
for (i = 0; i < REG_TABLE_SIZE; i++) {
if (reg_table[i].addr == addr) {
return ®_table[i];
}
}
return NULL;
}
代码解读:点表用数组保存,物理意义(如温度值放大 100 倍存整数)在注释里写清楚,避免后续维护时把定点数和浮点弄混。reg_find 用线性查找,寄存器数量几十个以内完全够用,如果点表上千条再换二分查找或哈希。注意协议地址从 0 开始,对应 PLC 侧的 40001。
第三步:串口中断接收与帧切分
串口收到字节放进环形缓冲区,同时记录最后收到字节的时间。主循环里判断空闲时间超过 3.5 字符时间就把缓冲区里的一帧取出来处理。用环形缓冲区的好处是中断只做"存字节 + 记时间"两件事,耗时可控。
文件名:App/modbus_port.h
#ifndef __MODBUS_PORT_H
#define __MODBUS_PORT_H
#include <stdint.h>
#define MB_BUF_SIZE 64 // 最大帧长,够 16 功能码批量写 32 个寄存器
void mb_port_init(void);
void mb_port_rx_byte(uint8_t byte); // 串口中断里调用
uint16_t mb_port_frame_len(void); // 返回当前已收帧长度
const uint8_t *mb_port_frame_ptr(void); // 返回帧缓冲区指针
void mb_port_frame_clear(void);
void mb_port_tx(const uint8_t *buf, uint16_t len);
#endif
代码解读:端口层把串口细节封装在 mb_port_xxx 接口后面,协议栈只依赖这些接口,不直接碰 HAL 句柄。mb_port_frame_ptr 返回缓冲区指针供协议栈读取,是移植时唯一需要按芯片重写的文件。
文件名:App/modbus_port.c
#include "modbus_port.h"
#include "main.h"
#include <string.h>
extern UART_HandleTypeDef huart1;
static uint8_t rx_buf[MB_BUF_SIZE]; // 帧缓冲区
static volatile uint16_t rx_len; // 已收字节数
static volatile uint32_t last_rx_tick; // 最后收字节的 tick 值
void mb_port_rx_byte(uint8_t byte)
{
if (rx_len < MB_BUF_SIZE) {
rx_buf[rx_len++] = byte; // 未超长才写入,超长丢弃本帧
}
last_rx_tick = HAL_GetTick();
}
/* 查询是否已收完一帧:距最后字节超过 4ms 视为帧结束 */
uint8_t mb_port_frame_ready(void)
{
if (rx_len == 0) {
return 0;
}
return (HAL_GetTick() – last_rx_tick) > 4;
}
void mb_port_frame_clear(void)
{
rx_len = 0;
memset(rx_buf, 0, sizeof(rx_buf));
}
void mb_port_tx(const uint8_t *buf, uint16_t len)
{
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_SET); // 485 方向切发送
HAL_UART_Transmit(&huart1, (uint8_t *)buf, len, 100);
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_RESET); // 切回接收
}
代码解读:帧超时判断用 4ms 是因为 9600 波特率下 3.5 字符时间约 3.64ms,取整到 4ms 留了余量。波特率更高时这个值要跟着缩放,1200bps 下 3.5 字符时间接近 30ms,仍用 4ms 会切碎长帧。485 方向切换必须在发完最后一个字节之后延时一个字节时间再切回,否则总线末尾字节会被截断,实践中最快的做法是 TX 完成中断里切换,这里为了可读性用阻塞发送简化。
第四步:协议栈状态机
协议栈按"接收帧 → 校验 → 分发功能码 → 组响应 → 发送"的顺序执行,放在主循环里,用状态机避免嵌套过深。
文件名:App/modbus.c(核心)
#include "modbus.h"
#include "modbus_port.h"
#include "crc16.h"
#include "regtable.h"
#include <string.h>
/* 功能码定义 */
#define FC_READ_HOLD 0x03 // 读保持寄存器
#define FC_WRITE_SINGLE 0x06 // 写单个保持寄存器
#define FC_WRITE_MULTI 0x10 // 写多个保持寄存器
/* 异常码 */
#define EX_ILLEGAL_FUNC 0x01 // 非法功能码
#define EX_ILLEGAL_ADDR 0x02 // 非法数据地址
#define EX_ILLEGAL_VALUE 0x03 // 非法数据值
static uint8_t rx_buf[MB_BUF_SIZE];
static uint16_t rx_len;
static uint8_t tx_buf[MB_BUF_SIZE];
/* 返回拼接好的响应长度,0 表示无响应 */
static uint16_t handle_frame(const uint8_t *req, uint16_t len)
{
uint16_t crc_calc, crc_recv;
uint16_t resp_len = 0;
/* 长度下限:地址+功能码+CRC(2B)=4 字节 */
if (len < 4) {
return 0;
}
/* 校验 CRC:比对报文尾部两个字节 */
crc_calc = crc16_modbus(req, len – 2);
crc_recv = (uint16_t)(req[len – 1] << 8) | req[len – 2];
if (crc_calc != crc_recv) {
return 0; // CRC 错误直接丢弃
}
if (req[0] != 0x01) {
return 0; // 地址不匹配,广播地址 0xFF 可另行处理
}
tx_buf[0] = req[0]; // 响应地址
tx_buf[1] = req[1]; // 功能码回显
switch (req[1]) {
case FC_READ_HOLD:
resp_len = read_hold_regs(req, len);
break;
case FC_WRITE_SINGLE:
resp_len = write_single_reg(req, len);
break;
case FC_WRITE_MULTI:
resp_len = write_multi_regs(req, len);
break;
default:
/* 不支持的功码:回异常码 01 */
tx_buf[1] |= 0x80;
tx_buf[2] = EX_ILLEGAL_FUNC;
resp_len = 3;
break;
}
if (resp_len > 0) {
uint16_t crc = crc16_modbus(tx_buf, resp_len);
tx_buf[resp_len++] = (uint8_t)(crc & 0xFF); // CRC 低字节在前
tx_buf[resp_len++] = (uint8_t)(crc >> 8);
}
return resp_len;
}
/* 主循环轮询入口 */
void modbus_poll(void)
{
uint16_t len, resp_len;
if (!mb_port_frame_ready()) { // 帧未收完
return;
}
memcpy(rx_buf, mb_port_frame_ptr(), MB_BUF_SIZE);
len = mb_port_frame_len();
mb_port_frame_clear();
resp_len = handle_frame(rx_buf, len);
if (resp_len > 0) {
mb_port_tx(tx_buf, resp_len); // 通过 485 发出响应
}
}
代码解读:handle_frame 先做长度下限和 CRC 校验,保证进入功能码分发时帧是可信的。响应帧的 CRC 在组包完成后统一追加,低字节在前符合 RTU 规范。异常响应把功能码最高位置 1,主站据此区分正常响应和异常响应。modbus_poll 作为主循环入口,每轮只处理一帧,天然避免长时间占用 CPU。
第五步:三个功能码的具体实现
读保持寄存器 03:请求 8 字节,响应按"字节数 + 寄存器值高字节 + 低字节"排列。写单个寄存器 06:请求和响应完全相同,直接回显。写多个寄存器 16:请求带字节数和数据区,响应只回起始地址和数量。
文件名:App/modbus.c(功能码实现)
/* 03 读保持寄存器:req[2]=起始地址高,req[3]=低,req[4]=数量高,req[5]=低 */
static uint16_t read_hold_regs(const uint8_t *req, uint16_t len)
{
uint16_t start = ((uint16_t)req[2] << 8) | req[3];
uint16_t count = ((uint16_t)req[4] << 8) | req[5];
uint16_t i, idx;
/* 数量范围限制:最多读 8 个寄存器,防止响应超帧 */
if (count < 1 || count > 8) {
return build_exception(EX_ILLEGAL_VALUE);
}
/* 检查每个寄存器是否存在,越界回异常 02 */
for (i = 0; i < count; i++) {
if (reg_find(start + i) == NULL) {
return build_exception(EX_ILLEGAL_ADDR);
}
}
tx_buf[2] = (uint8_t)(count * 2); // 数据字节数
idx = 3;
for (i = 0; i < count; i++) {
uint16_t v = reg_find(start + i)->value;
tx_buf[idx++] = (uint8_t)(v >> 8); // 高字节在前
tx_buf[idx++] = (uint8_t)(v & 0xFF);
}
return idx;
}
/* 06 写单个寄存器:请求与响应一致,成功后更新点表 */
static uint16_t write_single_reg(const uint8_t *req, uint16_t len)
{
uint16_t addr = ((uint16_t)req[2] << 8) | req[3];
uint16_t val = ((uint16_t)req[4] << 8) | req[5];
reg_item_t *item = reg_find(addr);
if (item == NULL) {
return build_exception(EX_ILLEGAL_ADDR);
}
if ((item->attr & REG_READ_WRITE) == 0) {
return build_exception(EX_ILLEGAL_VALUE); // 只读寄存器不可写
}
item->value = val;
memcpy(tx_buf, req, 6); // 原样回显请求
return 6;
}
/* 16 写多个寄存器:req[6]=字节数,后续为数据,响应回起始地址+数量 */
static uint16_t write_multi_regs(const uint8_t *req, uint16_t len)
{
uint16_t start = ((uint16_t)req[2] << 8) | req[3];
uint16_t count = ((uint16_t)req[4] << 8) | req[5];
uint16_t i;
if (count < 1 || count > 16 || req[6] != count * 2) {
return build_exception(EX_ILLEGAL_VALUE);
}
for (i = 0; i < count; i++) {
reg_item_t *item = reg_find(start + i);
if (item == NULL || (item->attr & REG_READ_WRITE) == 0) {
return build_exception(EX_ILLEGAL_ADDR);
}
}
for (i = 0; i < count; i++) {
uint16_t v = ((uint16_t)req[7 + i * 2] << 8) | req[8 + i * 2];
reg_find(start + i)->value = v; // 全部校验通过后统一写入
}
tx_buf[2] = req[2];
tx_buf[3] = req[3];
tx_buf[4] = req[4];
tx_buf[5] = req[5];
return 6;
}
代码解读:三个功能码都遵循"先校验后写入"的顺序,16 功能码尤其重要——先检查全部寄存器可写性,再统一提交,避免写一半遇到越界导致部分寄存器已改、部分没改的脏状态。03 功能码限制单次最多读 8 个寄存器,防止响应帧超过 256 字节上限。06 功能码直接回显请求是最直接的正确做法,Modbus 规范要求响应与请求内容一致。
第六步:主循环整合与验证
main 函数里初始化串口和点表后进入无限循环,循环体里只调用 modbus_poll(),再配合一个周期任务读传感器刷新 40003 运行状态。
文件名:Core/Src/main.c(主循环片段)
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_USART1_UART_Init();
mb_port_init(); // 清空接收缓冲
uint32_t last_tick = 0;
while (1) {
modbus_poll(); // 处理 Modbus 帧
/* 每 500ms 刷新一次只读寄存器,模拟现场采样 */
if (HAL_GetTick() – last_tick >= 500) {
last_tick = HAL_GetTick();
reg_update_sensor(); // 读取 ADC 并更新 40003
}
}
}
代码解读:主循环把协议轮询和业务采样放在同一个 while 里,modbus_poll 每轮只处理一帧,不会长时间占用 CPU。传感器刷新用 HAL_GetTick 定时触发,与协议处理互不干扰。
文件名:Core/Src/stm32f1xx_it.c(中断回调)
void USART1_IRQHandler(void)
{
/* HAL 库自动判断中断源,收到单字节进入回调 */
HAL_UART_IRQHandler(&huart1);
}
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) {
mb_port_rx_byte(rx_byte); // 中断里只存字节 + 记时间
HAL_UART_Receive_IT(&huart1, &rx_byte, 1);
}
}
代码解读:中断接收用 HAL 库的 Receive_IT 单字节模式,每次收到一字节就回调一次,回调里只做入缓冲,符合"中断里不干活"的嵌入式原则。主循环的 modbus_poll 通过帧超时判断识别一帧结束,与传感器刷新任务共用同一个 while 循环,两个任务没有共享变量,不需要加锁。
验证方法:打开 Modbus Poll,串口参数选 9600/8N1,从站地址 1。读 40001 应返回 2500,写 40001 为 3000 后回读应变成 3000。用串口调试助手手动发 01 03 00 00 00 01 84 0A,观察从站回 01 03 02 0B B8 xx xx,其中 0x0BB8 即 3000。
第七步:点表与传感器联动
只读寄存器不能只停留在静态值,把 ADC 采样、DHT11 温湿度读数和运行状态刷进点表,才算真正能上线。这一步把 modbus 协议层和业务解耦:协议层只认 reg_table,业务层只写 reg_table。
文件名:App/regtable.c(传感器刷新)
#include "adc.h"
#include "dht11.h"
/* 周期刷新只读寄存器,函数由 main 定时调用 */
void reg_update_sensor(void)
{
uint16_t adc_val = adc_read_channel(0); // 读取温度传感器通道
reg_find(0x0002)->value = adc_val / 10; // 换算为 0.1℃ 存入 40003
/* 若 DHT11 采集成功,写入 40004 湿度 */
if (dht11_read() == DHT11_OK) {
reg_find(0x0003)->value = dht11_humidity_x10;
}
}
代码解读:业务层把真实采样值写入点表,协议层响应时直接读点表,两侧通过 reg_table 数组解耦。ADC 换算系数在注释里写明,例如 12 位 ADC 满量程 4095 对应 3.3V,温度探头每 10mV/℃,换算逻辑要按实际传感器手册调。
场景适配
多从站组网(1 主多从)
产线一条总线上挂 12 台设备时,每个从站地址必须唯一,主站按地址轮询。从站侧只需要把地址检查从固定 req[0] != 0x01 改成 req[0] != MB_SLAVE_ADDR,地址在编译期用宏定义,避免运行时改地址还要存 Flash。
| 从站地址 | 固定 0x01 | 0x01-0xF7 各自不同 |
| 地址宏 | #define MB_SLAVE_ADDR 0x01 | 编译时按设备烧录不同宏 |
| 广播地址 0x00 | 不支持 | 可选支持,广播只写不响应 |
| 总线终端电阻 | 不接 | 总线两端各接 120Ω |
RS-485 半双工时序适配
RS-485 是半双工,发送和接收共用一对差分线。方向切换的时机不对会导致响应帧丢失或总线冲突。高波特率下建议用串口 TX 完成中断或 DMA 传输完成中断切换方向,而不是阻塞发送后立刻切,因为阻塞返回时最后一个字节可能还没从移位寄存器发完。
文件名:App/modbus_port.c(TX 完成中断切方向)
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) {
/* 数据已从移位寄存器发完,安全切回接收 */
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_RESET);
}
}
代码解读:TX 完成中断标志的是串口移位寄存器全部发完,比 HAL_UART_Transmit 返回更可靠。115200 波特率下 1 个字节约 87μs,用阻塞后立即切换的写法很容易把最后一个字节截掉一半,线上表现为"主站偶尔收到不完整响应"。
通过 4G/WiFi 透传上云
现场网关如果通过 DTU 透传串口数据,Modbus RTU 帧原样在 TCP 链路里跑,从站代码完全不用改。若上位机只支持 Modbus TCP,可以在网关上做协议转换:TCP 报文去掉 MBAP 头(7 字节)后按 RTU 帧解析,响应时再补回 MBAP。区别在于 Modbus TCP 没有 CRC,因为 TCP 本身保证完整性。
| 传输层 | RS-232/485 | TCP/IP |
| 帧头 | 地址 1B | MBAP 7B(含事务ID、单元标识) |
| 校验 | CRC16 2B | 无(TCP 自带) |
| 地址 | 从站地址 | 单元标识符 |
性能优化
中断收字节改成 DMA + IDLE 中断
9600 波特率下单字节中断的 CPU 占用可以忽略,但上了 115200 或 921600 后,每 87μs 进一次中断会明显挤占主循环时间。方案是 DMA 循环接收 + 串口 IDLE 中断判帧结束:DMA 把字节搬进环形缓冲,主循环只在收到空闲中断时处理一帧。
优化效果参考:921600 波特率下,中断方式每帧 8 字节要进 8 次中断、约 18μs 开销;DMA + IDLE 方式每帧只进 1 次中断,开销降到 2μs 以内,CPU 占用下降约 60%。
| 每帧中断次数 | 8 | 1 |
| 每帧处理开销 | 约 18μs | 约 2μs |
| 代码复杂度 | 低 | 中 |
| 适用波特率 | ≤38400 | ≥115200 |
CRC 查表 vs 计算
CRC 计算型每字节 8 次循环,查表型每字节 1 次查表 + 2 次异或。实测 STM32F103 @72MHz,64 字节帧:计算型约 42μs,查表型约 11μs。对 3 个功能码的小帧(≤16 字节)差距不明显,但 Modbus TCP 网关场景每秒处理上千帧时差距就出来了。
主站轮询节奏匹配
从站侧能做的优化有限,真正决定总线吞吐的是主站轮询策略。常用做法是读写分开:设定值用 06 单写(6 字节短帧),批量采集用 03 一次读 8-16 个寄存器(省掉多次小帧往返)。实测 12 台从站、每台读 8 个寄存器,9600 波特率下单轮全量采集从 2.1s 压到 1.4s,关键是减少帧数和避免主站等待超时。
故障排查
故障一:CRC 校验一直失败
现象:从站收不到任何数据,串口助手发帧后无响应,打断点看 crc_calc 和 crc_recv 始终不等。
原因:最常见的是查表数据抄错,其次是报文里 CRC 高低字节顺序反了。
定位方法:用 CRC 计算工具对已知帧 01 03 00 00 00 01 计算,正确结果是 84 0A(低字节在前)。在自己代码里对同一段数据算一遍,逐字节比对。也可以用逻辑分析仪抓主站发出的原始字节,确认帧尾两个字节的顺序。
解决方案:确认查表法生成脚本输出与标准 CRC-16/MODBUS 参数(多项式 0xA001、初始 0xFFFF、无反射反转)一致。检查接收帧拼接时是否把 req[len-1]<<8 | req[len-2] 写反,RTU 规范是低字节先到。
故障二:485 半双工丢最后一个字节
现象:主站偶发收到不完整响应,抓波形发现响应帧末尾缺字节或最后字节被截断。
原因:485 方向切换太早,最后一个字节还没从移位寄存器完全发出去,芯片就切回了接收模式,差分线上的剩余电平被拉断。
定位方法:示波器看 DE 引脚和 A/B 差分线的时序关系,确认 DE 拉低时刻是否晚于最后一个字节的停止位。
解决方案:改用 TX 完成中断切换方向(见场景适配章节),或在阻塞发送后加一个字节时间的延时再切。115200 波特率下至少延时 100μs,保险起见按 2 字节时间算。
故障三:Modbus Poll 报 Illegal Data Address
现象:能读到 40001,但读 40010 报异常码 02。
原因:点表里只定义了 3 个寄存器,起始地址 + 数量越界后 reg_find 返回 NULL,触发非法地址异常。另一个隐蔽原因:PLC 侧用 40010 表示物理寄存器 10,但协议地址是 9,换算错误。
定位方法:Modbus Poll 里读地址从 40001 逐条往上加,找到第一个失败的地址,对照点表看边界在哪。注意区分"协议地址"和"PLC 地址显示"的 40001 偏移。
解决方案:点表按实际设备数量补全寄存器定义;主站侧把地址显示减去 40001 得到协议地址再核对。16 功能码批量写时,起始地址 + 数量必须全部落在点表内,任何一项越界整体回异常,这是协议层的正确行为。
故障四:波特率误差导致偶发乱码
现象:低负载时正常,主站连发多帧后出现个别字节错乱,偶发 CRC 失败。
原因:从站晶振与主站时钟存在偏差,9600 波特率下误差超过 ±2% 会累积采样点偏移。STM32F103 用内部 HSI 时误差可达 1%-2.5%,环境温度变化还会漂移。
定位方法:用示波器量 TX 引脚实际波特率,与配置值比对误差百分比;也可以用逻辑分析仪连续抓 100 帧统计 CRC 失败率。
解决方案:改用外部晶振或校准 HSI(HAL_RCC_OSCILLATORCONFIG 里做 RCC_HSI_CALIBRATION),确保波特率误差在 ±1% 以内。批量产线烧录时统一校准因子,避免个体差异。
总结
核心要点
- Modbus RTU 帧 = 地址 + 功能码 + 数据 + CRC16(低字节在前),帧切分靠 3.5 字符静默时间,代码里用 4ms 固定值需按波特率调整
- 从站架构分四层:串口接收(中断/DMA)→ 帧解析(CRC + 地址校验)→ 功能码分发 → 寄存器点表,各层独立可替换
- 03/06/16 三个功能码覆盖产线读写场景,响应组包统一在末尾追加 CRC,异常响应功能码最高位置 1
- 寄存器点表用结构体数组管理,物理量换算和读写属性写进注释,业务层和协议层通过点表解耦
- 性能瓶颈通常不在从站侧,主站轮询节奏、485 方向切换时机、波特率误差是线上三大坑,优先排查
适用边界与扩展方向
本方案的适用边界:单帧数据量小(寄存器数 ≤ 几十个)、波特率 ≤ 921600、无需多主站仲裁的场合。若需要主站功能、多主站冲突处理或上万点的大点表,建议直接上 FreeModbus 或 commercial 协议栈,不必重复造轮子。后续可扩展方向:把 CRC 表生成脚本纳入构建流程、增加 04 功能码支持输入寄存器、按项目需要裁剪为只读从站。



