news 2026/9/12 5:21:53

基于STM32的MODBUS-RTU从站:RS485收发切换与CRC16校验详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于STM32的MODBUS-RTU从站:RS485收发切换与CRC16校验详解

简介:面向STM32开发者的MODBUS通信完整工程包,适合需要实现RS485从机通信、与上位机进行读写交互的嵌入式工程师。资源以STM32F103为基础,包含Modbus协议解析、寄存器读写、RS485收发控制等核心代码,覆盖从底层UART配置到报文封装、功能码处理和CRC校验的完整链路。压缩包共1081个文件,以c源文件、h头文件、s汇编启动文件为主,同时包含uvproj/ewp等工程配置、hex/axf烧录文件及说明文档,便于直接导入Keil或IAR编译调试,整体大小约26.93MB。包内还有README与调试说明,可降低上手门槛。该资源已有1482人学习下载,目录按功能拆分,可快速定位从机示例、驱动封装和调试工具;既适合在工程模板上直接移植,也适合对照源码理解地址映射、收发方向切换与异常响应等核心设计,是一份兼顾参考与实战的MODBUS开发资料。

1. 为什么 STM32 的 MODBUS 从站难点不在协议而在 RS485 收发切换

做 PLC 和变频器通信的老工程师常碰到一个现象:STM32 单测时串口数据完全正确,接到 RS485 总线上后,读保持寄存器偶尔超时,多写几个寄存器还会收到一帧带空格的乱码。多数人不查硬件,先去翻 MODBUS 功能码,最后发现问题是 MAX485 的 DE/RE 引脚切换慢了几微秒。这套 STM32-MODBUS 资源就是以 STM32F103 + RS485 为例的 MODBUS-RTU 从站代码,它把半双工收发切换、CRC16 校验、03/06 功能码解析一起封装在一个完整工程里。无论是做数据采集、对接变频器,还是做基于 STM32 的毕业设计,这套代码都可以直接作为从机底子,关键是弄懂怎么处理“最后一字节没发完就切接收”这个隐藏坑。

2. STM32F103 + MAX485 的 RS485 半双工电路与工程文件结构

2.1 先确定硬件连接:MAX485 的 DI/RO 和 DE/RE

RS485 本身是物理层协议,MODBUS RTU 跑在它上面。以最常见的 MAX485 为例,芯片的 DI 接 STM32 的 TXD,RO 接 RXD,DE 和 RE 多数情况下并联后接到一个普通 GPIO。DE/RE 为高电平时 A-B 输出差分信号,低电平时芯片进入接收状态。也就是说,MCU 不仅要处理 UART 收发,还要在同一个通信过程中反复切换数据流向,这就是“半双工”。

我一般会把 DE 控制脚放在 PB1,原因很简单:F103 上 PB1 不影响 USART1 的重映射,也不会和 JTAG/SWD 冲突。常见的连接关系如下表:

STM32F103 引脚MAX485 引脚说明
PA9 (USART1_TX)DI数据从 MCU 进入总线
PA10 (USART1_RX)RO总线数据进 MCU
PB1DE / RE高发送,低接收
GNDGND必须共地
3.3VVCC注意 MAX485 是否 5V 容忍

注意 VCC 不能接完 3.3V 就结束。MAX485 的 A/B 两个端子之间通常要跨接一个 120Ω 终端电阻,并且它只应该接在总线物理两端。如果只是单块板子接 USB 转 485,可以暂时不接终端电阻,但两个设备距离超过一米时,反射杂波会导致 MODBUS CRC 错误率明显上升。

2.2 工程里 cstart_thumb2.asm 是什么,哪些文件才参与编译

打开这个 STM32-MODBUS 程序包,第一眼会看到 PROJTCT.uvgui.Administrator,这是 Keil 保存的窗口布局文件,关掉它也不影响编译。随后是多个同名 cstart_thumb2.asm,这是 Thumb2 指令环境下用于建立 C 运行时环境的启动文件。它做的事和标准 startup_stm32f10x_md.s 类似:设置初始 SP、调用 SystemInit、跳转 main。常见移植错误是保留了这个启动文件又添加了官方启动文件,两个都在链接脚本里,结果出现重复 vector 定义。

整个工程从文件角度看并不复杂:主循环里轮询 UART 接收缓冲区,收到完整 MODBUS 帧后调用解析函数,解析完成后再通过 RS485 发送响应。没有上 FreeRTOS,也不依赖 libmodbus,属于典型的裸机状态机实现。相比在 RTOS 里等信号量,这种方式更贴近 F103 的实际使用场景,也更容易在调试时单步看到 DE 切换的时序。

2.3 软件控制 DE 还是用 RS485 自动收发电路

网上常搜到“RS485 自动收发电路”,像是用一个 PNP 三极管把 TXD 反相后接到 DE,省掉一根 GPIO。它在低速和短报文下能用,但有个软肋:TX 停止位之后 DE 不能立刻降下来,RC 延时往往造成总线上多出一个位,导致后面一帧认不出边界。因此我一般只在波特率 9600 且数据帧很短时才推荐自动收发。真要跑 MODBUS RTU,尤其是一主多从轮询,还是用 GPIO 控制 DE 最稳,这也正是这套工程采用的方式。

2.4 UART 初始化与 DE 切换代码

下面这段代码是 USART1 以 9600-8-N-1 模式运行,并把 PB1 配置为推挽输出。所有参数都可以按实际板子调整:

void RS485_UART_Init(void) { GPIO_InitTypeDef gpio; USART_InitTypeDef usart; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); // PA9:USART1_TX,复用推挽输出,最高 50MHz gpio.GPIO_Pin = GPIO_Pin_9; gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); // PA10:USART1_RX,浮空输入 gpio.GPIO_Pin = GPIO_Pin_10; gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &gpio); // PB1:MAX485 DE/RE,推挽输出 gpio.GPIO_Pin = GPIO_Pin_1; gpio.GPIO_Mode = GPIO_Mode_Out_PP; gpio.GPIO_Speed = GPIO_Speed_2MHz; GPIO_Init(GPIOB, &gpio); RS485_DE_LOW(); // 默认接收模式 usart.USART_BaudRate = 9600; usart.USART_WordLength = USART_WordLength_8b; usart.USART_StopBits = USART_StopBits_1; usart.USART_Parity = USART_Parity_No; usart.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; usart.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_Init(USART1, &usart); USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); USART_Cmd(USART1, ENABLE); }

GPIOA 和 GPIOB 的时钟要同时打开,否则 PB1 写了也不起作用。浮空输入容易受噪声影响,波特率低时还能接受,如果发现空闲时单片机频繁进入接收中断,可以把 PA10 改成上拉输入,注意此时 MAX485 的 RO 空闲必须表现为高电平。

发送函数更关键。常见的错误是只判断 TXE,然后立刻拉低 DE,最后一个字节其实还在移位输出:

void RS485_Send_Modbus(uint8_t *buf, uint8_t len) { RS485_DE_HIGH(); USART_ClearFlag(USART1, USART_FLAG_TC); for (uint8_t i = 0; i < len; i++) { while ((USART1->SR & USART_FLAG_TXE) == 0); USART1->DR = buf[i]; } // 必须等 TC,等移位寄存器完全送完 while ((USART1->SR & USART_FLAG_TC) == 0); RS485_DE_LOW(); }

执行顺序是:先拉高 DE,再清掉 TC 标志;写 DR 后等 TXE;全部字节写完再等 TC;最后拉低 DE。如果省略最后的 TC 等待,在 9600 波特率下还不明显,波特率提高到 115200 后,最后一字节大概少发 1 位,Modbus Poll 里表现为偶尔 CRC 错误。

3. MODBUS RTU 报文解析:功能码与寄存器映射的实现

3.1 接收端怎样确定一帧的开始和结束

MODBUS RTU 帧没有固定的帧头,靠字符间隔划分边界。协议规定接收完一帧后至少要等 3.5 个字符时间才认为帧接收完成,收到某个字节后如果间隔超过 1.5 个字符时间也认为帧不完整。在 F103 上最省事的方式是每个 RXNE 中断进入后记录当前字节时间戳,下一次中断时对比时间戳差。

9600 波特率下,每个字符约 1.15ms(8 位数据加起始位和停止位),3.5 个字符约 4ms。若系统定时器 tick 是 1ms,那么当两次中断间隔大于 4 时可以判定新帧开始;接收完成后若主循环每次扫描时发现距最后一次接收已经超过 4ms,就认为一帧结束。这里不建议用固定延时的阻塞等待,因为 MODBUS 主站轮询多个从站时,帧间间隔会随主站程序变化。

void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE)) { uint8_t byte = USART_ReceiveData(USART1); if ((sys_tick - last_rx_tick) > FRAME_GAP_TICK) { rx_len = 0; // 上一帧间断,开始新帧 } if (rx_len < RX_BUF_SIZE) { rx_buf[rx_len++] = byte; } last_rx_tick = sys_tick; } }

FRAME_GAP_TICK 不能只按 3.5 字符算,还要加主站轮询延时的余量。很多人在 9600 波特率下把这个值设为 4ms,但如果系统时钟的 tick 用 SysTick 1ms 产生,中断存在几微秒抖动,偶尔会把连续两字节误判成两帧。我一般取 5 到 6ms,低于这个值收到的 MODBUS 帧在低速主站轮询下更容易被拆成两段。

3.2 CRC16-MODBUS 两种实现,工程里适合哪种

CRC 校验可以查表也可以位循环。查表快,但要多占 256 字节表格;位循环慢,但对 F103 这种 Cortex-M3 而言,计算一个不到 30 字节的 MODBUS 帧耗时不到 1ms,完全够用。下面这段是位循环实现,生成多项式是 0xA001:

uint16_t ModbusCRC16(uint8_t *p, uint8_t len) { uint16_t crc = 0xFFFF; while (len--) { crc ^= *p++; for (uint8_t i = 0; i < 8; i++) { crc = (crc & 0x0001) ? ((crc >> 1) ^ 0xA001) : (crc >> 1); } } return crc; }

注意返回的 CRC 是主机序,而 MODBUS RTU 报文规定低字节在前。也就是说,计算完若 crc=0x0A0B,发送时先发 0x0B 再发 0x0A。做上位机联调时,很多人用在线 CRC 工具算出来是 0x0A0B,然后直接按十六进制排列,结果 Modbus Poll 一直报错,原因就是大小端顺序反了。

3.3 从站地址与功能码处理流程

MODBUS 从站收到请求后第一件事是检查从站地址,等于本机地址或者广播地址才继续,否则直接丢弃。广播地址为 0,只允许写命令广播,从站收到广播后不应发送响应,否则总线上多个从站同时响应会冲突。常用功能码区别如下:

功能码名称请求最小长度响应特征
0x03读保持寄存器8 字节功能码加字节数加数据
0x06写单个寄存器8 字节原请求帧原样回送
0x10写多个寄存器11 字节以上回送地址加数量
0x01读线圈8 字节数据按位打包

下面这段处理 03 读保持寄存器的核心代码,寄存器数组直接映射到 reg_hold 变量:

void MODBUS_Parse(void) { uint16_t crc_rx, crc_calc; uint16_t reg_addr, reg_count; uint8_t i; if (rx_len < 8) return; if (rx_buf[0] != SLAVE_ADDR && rx_buf[0] != 0x00) return; crc_rx = (uint16_t)(rx_buf[rx_len - 1] << 8) | rx_buf[rx_len - 2]; crc_calc = ModbusCRC16(rx_buf, rx_len - 2); if (crc_rx != crc_calc) return; switch (rx_buf[1]) { case 0x03: // 读保持寄存器 reg_addr = (rx_buf[2] << 8) | rx_buf[3]; reg_count = (rx_buf[4] << 8) | rx_buf[5]; if (reg_count > 0x7D) { // 超过 125 个寄存器 MODBUS_Exception(0x03); break; } tx_len = 0; tx_buf[tx_len++] = SLAVE_ADDR; tx_buf[tx_len++] = 0x03; tx_buf[tx_len++] = (uint8_t)(reg_count * 2); for (i = 0; i < reg_count; i++) { uint16_t val = reg_hold[reg_addr + i]; tx_buf[tx_len++] = (uint8_t)(val >> 8); tx_buf[tx_len++] = (uint8_t)(val & 0xFF); } crc_calc = ModbusCRC16(tx_buf, tx_len); tx_buf[tx_len++] = (uint8_t)(crc_calc & 0xFF); tx_buf[tx_len++] = (uint8_t)(crc_calc >> 8); RS485_Send_Modbus(tx_buf, tx_len); break; case 0x06: // 写单个保持寄存器 reg_addr = (rx_buf[2] << 8) | rx_buf[3]; if (reg_addr < REG_HOLD_NUM) { reg_hold[reg_addr] = (rx_buf[4] << 8) | rx_buf[5]; RS485_Send_Modbus(rx_buf, rx_len); // 原帧回送 } else { MODBUS_Exception(0x02); // 非法地址 } break; } }

0x03 请求帧结构是:地址、功能码、寄存器起始地址高字节、起始地址低字节、数量高字节、数量低字节、CRC 低字节、CRC 高字节,共 8 字节。reg_count 最大 125,是因为响应里字节数计数字段只有 1 字节,最大 255,扣掉功能码和地址后最多 252 字节数据,即 125 个寄存器。如果 reg_addr 加 reg_count 超出本机寄存器数量,应该返回异常码 0x02,这个检查在工程里要加到 reg_addr < REG_HOLD_NUM 的判断中。

0x06 的功能是写单个寄存器,成功响应要求完全复制请求帧,而不是重发一个简化的 OK 帧。很多从站实现用一个固定长度响应,调试时容易让 Modbus Poll 认为寄存器数量不对。写功能码还可以扩展 0x10(写多个寄存器),解析逻辑与 0x06 类似,但要多读第 4 到 5 字节的字节数和后续数据段。

4. 上位机联调:Modbus Poll 与 RS485 串口验证

4.1 找一套能立刻复现的调试环境

从站代码编译烧进去后,上位机可以选择 Modbus Poll、Modbus Slave 或串口助手。Modbus Poll 是主站模拟器,用来读 STM32 从站;Modbus Slave 模拟从站,一般用于调试自己的主站程序。调试从站只需要用评估功能就能完成整个数据交换,也可以直接用 Python 自己拼帧,结果更透明。这里先按 Modbus Poll 的常用配置说明。

接线要特别注意:STM32 的 USART1 引脚是 TTL 电平,不能直接进电脑串口,需要 USB 转 RS485 模块。模块上的 A 接 MAX485 的 A,B 接 B,地线接 GND。如果模块的 A/B 标反,最直接的现象是收不到任何响应。

4.2 Modbus Poll 里的参数和寄存器表

在 Modbus Poll 主界面选择 Connection -> Connect,然后按下表设置:

设置项参数说明
Slave ID1与工程里的 SLAVE_ADDR 一致
Function03 Read Holding Registers对应解析代码 0x03
Address0从 modbus 地址 0 开始读
Quantity8连续读 8 个寄存器
Baudrate9600与 UART 初始化一致
Data Bits / Stop Bits / Parity8 / 1 / NoneMODBUS RTU 标准

连接成功后,Modbus Poll 会周期性发送 01 03 00 00 00 08 加 CRC 的报文,然后显示 8 个寄存器值。注意这里的地址 0 代表协议地址,和代码里的 reg_hold[0] 直接对应,不是有些上位机看到的标识符地址。Modbus Poll 支持把寄存器地址设为 1,此时很多从站在协议层会把地址减 1 再映射,容易造成寄存器整体偏移一个位置。

4.3 不依赖第三方工具的串口验证脚本

如果手边没有 Modbus Poll,或者想在自动化测试里验证从站,可以用 Python 的 pyserial 直接做主站:

import serial ser = serial.Serial('COM3', baudrate=9600, timeout=0.5) def crc16_modbus(data: bytes) -> int: crc = 0xFFFF for b in data: crc ^= b for _ in range(8): crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1 return crc # 读从站地址 1,保持寄存器 0-1 req = bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x02]) crc = crc16_modbus(req) req += bytes([crc & 0xFF, crc >> 8]) ser.write(req) resp = ser.read(9) print('RX:', resp.hex()) print('reg0 =', int.from_bytes(resp[3:5], 'big')) ser.close()

这段脚本里 read(9) 的长度来自响应格式:地址 1 字节、功能码 1 字节、字节数 1 字节、2 个寄存器数据 4 字节、CRC 2 字节,共 9 字节。如果把读取寄存器数量从 2 改成 8,read 长度也要相应改成 21,不能固定不变。脚本最大的好处是把请求和响应都用十六进制打印出来,可以直接和 Modbus 协议手册比对,排查功能码或 CRC 大小端问题比图形界面直观。

实际上,可以先把 req 的 CRC 字节打印出来和计算器对比,或者故意发送一个错误的 CRC 观察从站是否响应。这个做法能验证“CRC 错误时从站丢弃报文”的逻辑。

4.4 一主多从汇聚场景怎么测

当多个 STM32 板卡挂到同一条 RS485 总线时,需要把 SLAVE_ADDR 分别改成 1、2、3。主站轮询顺序为 1 读、2 读、3 读,循环往复。每个从站收到跟自己地址无关的请求后必须立刻丢弃,不能发送任何字节,否则会占用总线导致下一帧冲突。验证方法是在串口助手里抓主站发的报文,正常情况下每个请求后只能看到一个从站回包;若看到两个从站同时回包,多半是地址检查没做,或者 DE 引脚默认电平不对。

5. 进阶:异常码、广播写入与多从机轮询的抗干扰技巧

5.1 返回规范的异常码

从站收到非法请求时,只丢弃不响应会让主站反复重试。正确做法是返回异常响应:功能码的最高位置 1,后跟异常码。例如 0x03 的非法地址异常是 01 83 02 CRC。发送异常码的代码可以在 MODBUS_Parse 里复用一个函数:

void MODBUS_Exception(uint8_t code) { tx_buf[0] = SLAVE_ADDR; tx_buf[1] = rx_buf[1] | 0x80; tx_buf[2] = code; uint16_t crc = ModbusCRC16(tx_buf, 3); tx_buf[3] = crc & 0xFF; tx_buf[4] = crc >> 8; RS485_Send_Modbus(tx_buf, 5); }

Modbus Poll 左侧会显示 0x82,同时给出 Illegal Data Address,比干等超时排错效率高很多。

5.2 广播地址 0 的边界条件

广播地址适用写命令。STM32 收到地址为 0 的 0x06 写请求后执行寄存器写入,但不应发送任何响应。代码里如果沿用普通从站的回包逻辑,多从机同时回包会直接击穿 485 差分电平,所以广播分支必须单独判断。读功能码和广播地址同时出现属于非法请求,按协议直接返回异常码 0x01,甚至可以直接丢弃。

5.3 现场抗干扰和轮询延时参数

总线布线、终端电阻、偏置电阻三个因素都影响 CRC。两块板子距离超过 1 米时,应在总线两端各接 120Ω 电阻;超过 30 米还要在 A 对电源、B 对地加 470Ω 偏置电阻。主站轮询时相邻请求之间至少留出 5ms 间隔,如果从站里同时在做 ADC 采样和 EEPROM 操作,响应可能延迟到 20ms,主站超时设置不能只按波特率倒推。

一个容易被忽略的技巧是:在从站发送完响应后,不要立刻进入接收等待,而是继续维持 DE 低电平至少 1 个字符时间。这可以让 RS485 总线上的差分电平完全稳定下来,避免因为 MAX485 驱动关闭太快,在停止位后产生下降沿毛刺,被下一个从站误认为起始位。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 5:21:19

周报跨周任务延续机制:上周未完成事项的智能继承与对齐

周报跨周任务延续机制&#xff1a;上周未完成事项的智能继承与对齐在日常职场周报汇报中&#xff0c;一个优秀的工程师与一个普通流水账记录者的核心差距&#xff0c;往往体现在**工作任务的“闭环性与跨周延续感”**上。 很多工程师写周报时常常“顾头不顾尾”&#xff1a; 上…

作者头像 李华
网站建设 2026/9/12 5:20:37

四偏振图像处理:用MATLAB还原偏振角与偏振度图像

简介&#xff1a;面向光学成像与机器视觉学习者的偏振图像分析资源包&#xff0c;覆盖偏振椭圆、偏振角、偏振角图像、四偏振图像及椭圆偏振率等核心概念&#xff0c;包含从基础理论到MATLAB实现的可运行示例&#xff0c;便于快速搭建偏振信息处理流程。压缩包共2个文件&#x…

作者头像 李华
网站建设 2026/9/12 5:20:32

ESP32-S3 N16R8开发实战:环境搭建、项目结构与进阶玩法

最近一直在折腾ESP32-S3的开发板&#xff0c;手头这块N16R8算是被我玩了个遍。从最开始装环境、点灯&#xff0c;到后头接摄像头、搞串口工具&#xff0c;中间踩的坑和摸出来的门道都不少。这篇东西就是奔着“开发环境搭建”和“项目结构”这两个点去的&#xff0c;目标很纯粹&…

作者头像 李华
网站建设 2026/9/12 5:20:25

text-to-cad:工程语义翻译与STEP合规建模实战指南

1. 这不是“文字变模型”的魔法&#xff0c;而是工程设计链路的底层重构“text-to-cad”这个词最近在工程师群里刷屏&#xff0c;但很多人点开搜索结果后一脸懵——它既不像Stable Diffusion那样能生成酷炫海报&#xff0c;也不像Copilot写代码那样直接输出可运行逻辑。我第一次…

作者头像 李华
网站建设 2026/9/12 5:20:14

MATLAB中DOMFluor工具箱实现EEM平行因子分析全流程指南

简介&#xff1a;面向环境科学与水文学研究者&#xff0c;DOMFluor.zip是一个MATLAB环境下的平行因子分析&#xff08;PARAFAC&#xff09;工具箱&#xff0c;专用于溶解有机物&#xff08;DOM&#xff09;荧光光谱数据的成分解析与来源识别。压缩包共116个文件&#xff0c;大小…

作者头像 李华