欢迎光临
我们一直在努力

【实战】STM32 从零实现 Modbus RTU 从站:寄存器映射、CRC 校验与串口中断解析

文章目录

    • 背景与问题
      • 现场改造的选型困境
      • 本文要解决的核心问题
    • 原理分析
      • 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 &reg_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 本身保证完整性。

差异点Modbus RTUModbus 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%。

指标单字节中断DMA + IDLE
每帧中断次数 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 功能码支持输入寄存器、按项目需要裁剪为只读从站。

赞(0)
未经允许不得转载:171主机测评 » 【实战】STM32 从零实现 Modbus RTU 从站:寄存器映射、CRC 校验与串口中断解析
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址