STM32实战:RS485 MODBUS RTU主从机通信与帧边界处理
2026/9/11 11:39:21 网站建设 项目流程

简介:资源为 STM32 结合 RS485 总线的 MODBUS 协议通信工程源码包,面向嵌入式开发者与自动化控制学习者,解决多机通信中主机轮询、从机响应及地址切换问题。代码包含完整主机与从机两种模式:上电默认主机,可定时通过串口发送请求读取从机 0x01 数据,并支持按键 1~3 查询不同从机地址数据,按键 4 可切换为从机模式(地址 0x02),同时以 LED 指示状态变化,可直接移植用于工业现场设备采集与控制原型验证。资源包共 163 个文件,主要以 H/C 源文件、工程配置及编译中间文件为主,含 uvprojx、hex、map 等,便于在 MDK 环境下打开工程重新编译下载,压缩包大小约 2.12MB。目前已有 455 人学习,适合具备基础 STM32 应用能力、希望快速掌握 MODBUS 主从机实现逻辑的读者参考借鉴。

1. 场景与选型:RS485 两线半双工为什么配 MODBUS RTU

一块 STM32F103 控制板,外接 SP3485 转成 RS485 差分电平,固件把 MODBUS RTU 主站和从站做进了同一个工程。上电之后什么都不按,它就是一个主机,自动去轮询地址 01 的从机寄存器;按 KEY1/2/3 可以切换轮询 01/02/03 号从机;按 KEY4 则从主机模式切到从机模式,本机地址变成 0x02,等待外部主站来读。每个事件同步驱动一颗 LED,方便观察当前状态。

这个场景在工业数据采集中很典型:一边要主动去读现场仪表,一边又希望设备本身能被上位机配置。很多初学者第一次碰 MODBUS 会直接去搜功能码和 CRC16 怎么算,结果上总线之后发现两台设备互相乱发,问题往往不在协议语法,而在帧边界判定。MODBUS RTU 是异步半双工协议,没有帧头和帧尾,靠字节间的静默时间区分帧。这套工程把串口中断、滴答定时器和方向控制绑在一起,正好能把这条链路拆开讲清楚。工程文件是 Keil MDK 工程,包含 USART.uvproj、USART.uvopt 以及清理脚本 keilkilll.bat,代码路径不依赖第三方库,直接能用。

2. 帧边界怎么定:USART 逐字节接收与 3.5T 超时机制

MODBUS RTU 的帧格式是地址、功能码、数据和 CRC,帧与帧之间靠静默时间切分。RS485 是半双工,同一时刻总线上只能有一个设备发言,所以接收方向必须知道“这一帧什么时候结束”。MODBUS 规范里对时间窗口有两个要求:同一帧内部相邻字节的间隔不能超过 1.5 个字符时间,帧结束要有 3.5 个字符时间的静默。工程默认采用 9600 波特率 8N1,按一个字符 10 位计算,3.5 个字符时间约 3.65ms;按部分从机使用的 11 位字符格式计算则是 4.01ms。工程代码里直接取 4ms 作为超时窗口,这个值在 9600 下足够可靠。

2.1 USART1 初始化和半双工方向引脚

通信口用的是 USART1,典型接法是 PA9 做发送、PA10 做接收,方向控制引脚接到 PA8。RS485 收发器 SP3485 的 DI 接 PA9,RO 接 PA10,DE 和 RE 合并后接 PA8。DE 为高时发送,为低时接收,这样一根 GPIO 就能完成总线方向切换。

先看串口初始化:

void MX_USART1_UART_Init(void) { huart1.Instance = USART1; huart1.Init.BaudRate = 9600; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; HAL_UART_Init(&huart1); }

参数注意三点:波特率 9600 与从机保持一致;8 个数据位、无校验、1 个停止位是 MODBUS RTU 最常见的组合;模式是 TX_RX 双向,半双工切换通过 DE/RE 引脚完成。如果后续要加校验位,需要同步修改 WordLength 和 Parity,并且把上位机 side 同样改成 8E1 或 8O1,否则从机会直接丢帧。

2.2 3.5T 在定时器里的落地方式

MODBUS 接收不能等一个字节到来后再去猜下一帧,比较务实的做法是逐字节接收,每收到一个字节就重置超时计数器。帧结束标志的产生依赖一个 1ms 的定时中断:主循环或 SysTick 中断里对计时变量做递减,减到 0 就认为本帧结束。

static uint8_t modbus_rx_buf[64]; static uint8_t modbus_rx_len; static uint8_t modbus_rx_byte; static uint16_t modbus_t3_5; static volatile uint8_t modbus_frame_ready; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { /* 字节装入缓冲区,同时刷新 3.5T 超时窗口 */ if (modbus_rx_len < sizeof(modbus_rx_buf)) modbus_rx_buf[modbus_rx_len++] = modbus_rx_byte; modbus_t3_5 = 4; /* 9600 波特率下 3.5T 约 4ms */ HAL_UART_Receive_IT(huart, &modbus_rx_byte, 1); } } void modbus_timer_tick(void) /* 每 1ms 调用一次 */ { if (modbus_t3_5 > 0) { if (--modbus_t3_5 == 0) modbus_frame_ready = 1; /* 一帧数据接收完毕 */ } }

这段逻辑的重点是 reset 和 decrement 都在独立时间源里完成。reset 发生在字节中断,decrement 发生在 1ms 定时中断,两边不会互相卡死。主循环只要查询 modbus_frame_ready 标志即可。若把递减放到主循环 while 里,一旦按键扫描有 while 等待或者某个函数阻塞 10ms,4ms 的超时窗口就被拉长了,高负载下会把两帧合并成一帧,这种 bug 很难复现。

工程里的滴答定时器一般会复用 HAL 的 SysTick,在 SysTick_Handler 里调用 modbus_timer_tick。需要注意 granny 在 115200 波特率下 3.5T 只有大约 300 多微秒,1ms 的滴答粒度反而会超时误判,此时要改用基本定时器做微秒级计数,或者干脆用串口空闲中断实现帧边界。

2.3 CRC16-MODBUS 校验和字节序

MODBUS RTU 的 CRC 计算覆盖从地址字节到最后一个数据字节,CRC 本身在帧尾占两个字节,低字节在前,高字节在后。工程里常见的逐位实现如下:

uint16_t modbus_crc16(const uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; while (len--) { crc ^= *data++; for (uint8_t i = 0; i < 8; i++) { if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001; else crc >>= 1; } } return (uint16_t)((crc << 8) | (crc >> 8)); /* 高低字节交换后返回 */ }

0xA001 是多边形式 0x8005 的反转形式,这是 CRC-16/MODBUS 的固定参数,和标准 CRC-16/IBM 不同,混用会导致校验失败。工程在 8MHz 主频下用逐位法算一帧 256 字节的数据,耗时可以忽略,不需要查表。发送方构造完帧之后把返回值低字节放到倒数第二个字节,高字节放最后;接收方把收进来的帧去掉末尾两个 CRC 字节,重新计算,再与收到的 CRC 比较。还有一个容易漏掉的问题:接收时如果一帧长度不足 4 字节(连地址、功能码、CRC 都凑不齐),直接丢弃,不要尝试解析。

3. 主机轮询从机:按键切换地址与请求帧状态机

主机模式的核心不是“发一帧数据”,而是按固定节奏完成 发请求、等响应、解析、超时重试 的周期循环。工程把按键扫描、轮询周期、帧解析分别拆成小任务,这样在调试时可以单步确认每个环节。

3.1 按键消抖和目标地址切换

四个按键在释放时触发动作,KEY1 到 KEY3 分别把目标从机地址改成 0x01、0x02、0x03,KEY4 不做地址切换而是切入从机模式。扫描函数按 10ms 周期执行,检测到稳定电平变化后才更新变量。

typedef enum { KEY_NONE = 0, KEY1 = 1, KEY2 = 2, KEY3 = 3, KEY4 = 4 } key_id_t; static uint8_t host_target_addr = 0x01; /* 上电默认轮询从机 01 */ void key_task(void) { static key_id_t key_last = KEY_NONE; key_id_t key_now = key_scan(); /* 内部完成 10ms 消抖 */ if (key_now != key_last) { key_last = key_now; switch (key_now) { case KEY1: host_target_addr = 0x01; break; case KEY2: host_target_addr = 0x02; break; case KEY3: host_target_addr = 0x03; break; case KEY4: switch_to_slave_mode(0x02); break; default: break; } } }

这段代码把按键事件和业务逻辑分开。key_scan 返回稳定后的按键值,如果按键一直按住,key_now 不会变化,就不会重复触发切换。实际使用时我一般还会给 key_scan 加一个“松开才生效”的边沿判断,避免从 KEY1 直接滑动到 KEY2 时连续触发多个地址。

3.2 请求帧构造与 RS485 方向控制

主机读从机数据时,用的最多的是功能码 03,读保持寄存器。构造一帧读取 2 个寄存器的请求,代码如下:

uint8_t req[8]; req[0] = host_target_addr; /* 从机地址 */ req[1] = 0x03; /* 读保持寄存器 */ req[2] = 0x00; /* 起始寄存器高字节 */ req[3] = 0x00; /* 起始寄存器低字节 */ req[4] = 0x00; /* 寄存器数量高字节 */ req[5] = 0x02; /* 寄存器数量低字节 */ uint16_t crc = modbus_crc16(req, 6); req[6] = crc & 0xFF; /* CRC 低字节在前 */ req[7] = crc >> 8; /* CRC 高字节在后 */

注意这里寄存器地址和数量都是大端字节序,也就是高字节在前。很多从机返回异常码 0x02,就是因为起始地址或数量在拼接时高低字节写反了。

发送之前必须把 RS485 方向切到发送,发送完成后要等串口彻底发完再切回接收,否则最后一个停止位会被截掉:

RS485_DE_PIN(1); /* 方向引脚 DE 拉高 */ HAL_UART_Transmit(&huart1, req, 8, 50); while (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TC) == RESET) {} RS485_DE_PIN(0); /* 发送完成,切回接收 */

逐行拆一下:HAL_UART_Transmit 函数返回时数据已经交给发送移位寄存器,但可能还没从引脚上物理发完;TC 标志表示移位寄存器完全空,数据已发完。此时再拉低 DE 才不会丢掉停止位。这个顺序问题在调试时最容易暴露,表现为主机发完请求后总线上有毛刺,或者从机根本收不到完整帧。

3.3 主机状态机的轮询周期和超时

主机的周期任务用一个枚举状态机实现:

typedef enum { HOST_IDLE, HOST_WAIT_RSP, HOST_PROC_RSP } host_state_t; void host_poll_task(void) { static host_state_t state = HOST_IDLE; static uint32_t last_send_tick = 0; static uint32_t wait_start = 0; uint32_t now = HAL_GetTick(); switch (state) { case HOST_IDLE: if (now - last_send_tick >= 100) /* 轮询周期 100ms */ { build_read_request(host_target_addr); send_rs485_frame(modbus_tx_buf, 8); state = HOST_WAIT_RSP; wait_start = HAL_GetTick(); } break; case HOST_WAIT_RSP: if (modbus_frame_ready) /* 3.5T 超时表示帧到达 */ { modbus_frame_ready = 0; parse_host_response(modbus_rx_buf, modbus_rx_len); modbus_rx_len = 0; last_send_tick = HAL_GetTick(); state = HOST_IDLE; } else if (now - wait_start > 200) /* 200ms 无响应判超时 */ { led_indicate_timeout(); last_send_tick = HAL_GetTick(); /* 避免超时后立即重发 */ state = HOST_IDLE; } break; default: state = HOST_IDLE; break; } }

轮询周期取 100ms 是为了兼容大多数慢速从机,有些继电器模块内部刷新周期要几十毫秒,发太快反而会连续超时。超时窗口 200ms 则留出了从机处理时间,又不至于让界面卡顿。超时后把 last_send_tick 刷成当前时刻,相当于等一个完整周期再重发,避免总线风暴。

4. 按键切到从机模式:站地址 0x02 与被动响应

按 KEY4 后设备从主动角色变成被动角色,本机不再发请求,而是等待外部主站来读。切换的关键不只是换一个标志位,还要把接收数据的分发路径改掉,否则主机解析函数会把主站发来的请求当成响应去处理。

4.1 主从共用的接收缓冲和分发逻辑

USART1 中断接收到的所有字节都进 modbus_rx_buf,3.5T 到期后只产生一个 modbus_frame_ready 标志,具体谁来解析由当前模式决定:

volatile uint8_t current_mode = MODE_HOST; volatile uint8_t slave_addr = 0x02; void handle_frame_dispatch(void) { if (current_mode == MODE_HOST) { parse_host_response(modbus_rx_buf, modbus_rx_len); } else { parse_slave_request(modbus_rx_buf, modbus_rx_len); } modbus_rx_len = 0; }

这种共享缓冲的做法在只有一个串口的场景下最省内存,因为主模式和从模式不会同时工作。需要注意切换瞬间的临界状态:KEY4 按下时,如果当前正在等待一个主机响应,此时切到从模式会导致那个响应还没收完就会被当成从机请求解析。我一般会在切换函数里先把 modbus_rx_len 清零、modbus_frame_ready 清零、modbus_t3_5 也清零,再改 current_mode,确保总线上残留的字节不会污染从机逻辑。

4.2 从机请求帧的合法性检查

从机的任务很简单:收地址、判断是否匹配、解析功能码、返回数据或异常。先做完整性和地址检查:

void parse_slave_request(uint8_t *buf, uint8_t len) { uint16_t crc = modbus_crc16(buf, len - 2); if (crc != (uint16_t)(buf[len - 1] << 8 | buf[len - 2])) return; /* CRC 错误直接丢弃 */ if (buf[0] != slave_addr && buf[0] != 0x00) return; /* 地址不匹配 */ if (buf[1] == 0x03) { uint16_t reg_start = (buf[2] << 8) | buf[3]; uint16_t reg_count = (buf[4] << 8) | buf[5]; if (reg_count == 0 || reg_start + reg_count > SLAVE_REG_TOTAL) { send_slave_exception(0x02); /* 非法数据地址 */ return; } send_slave_read_hold_response(buf[0], reg_start, reg_count); } }

从机地址 0x02 是工程里通过 KEY4 写入的。地址 0x00 是 MODBUS 广播地址,按规范广播帧从机要执行但不回复,所以这里对 buf[0] == 0x00 只做执行处理,不发响应。目前只实现功能码 03,如果主站发功能码 04,工程里会返回异常码 0x01,表示功能码不支持。

4.3 从机响应帧的组装和发送

从机响应格式为地址、功能码、数据字节数、数据、CRC。读取连续寄存器时,数据区域按“每个寄存器高字节在前”排列:

static const uint16_t slave_regs[4] = {0x1001, 0x0032, 0x00A6, 0x0000}; void send_slave_read_hold_response(uint8_t addr, uint16_t start, uint16_t cnt) { uint8_t rsp[1 + 1 + 1 + 2 * cnt + 2]; rsp[0] = addr; rsp[1] = 0x03; rsp[2] = cnt * 2; /* 后续数据字节数 */ for (uint16_t i = 0; i < cnt; i++) { rsp[3 + i * 2] = slave_regs[start + i] >> 8; rsp[4 + i * 2] = slave_regs[start + i] & 0xFF; } uint16_t crc = modbus_crc16(rsp, 3 + cnt * 2); rsp[3 + cnt * 2] = crc & 0xFF; rsp[4 + cnt * 2] = crc >> 8; RS485_DE_PIN(1); HAL_UART_Transmit(&huart1, rsp, 5 + cnt * 2, 50); while (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TC) == RESET) {} RS485_DE_PIN(0); }

从代码里能看出响应帧长度和请求帧长度不一致,请求是固定 8 字节,响应会随寄存器个数增长。调试时最容易犯的错是响应帧长度算错,比如漏算数据长度字节,导致发送出的帧 CRC 错位。工程里 4 个寄存器分别放运行计数值、采样值、开关状态和保留字段,按这个地址映射去读即可。

5. 验证手段与常见坑:从串口调试助手到 modbus poll

无论主机还是从机,最终都要挂到真实总线上验证。只开着串口调试助手看自发自收是不够的,因为串口助手模拟不出 RS485 的方向切换和总线竞争。

5.1 用串口调试助手发原始帧

设备处于从机模式时,用 USB-485 转换器把它连到电脑,打开 CH340 或 FTDI 类型的串口助手,波特率设 9600、8N1,以十六进制发送01 03 00 00 00 02 C4 0B。这是请求地址 01 从机读两个保持寄存器的标准帧。如果设备地址是 0x02,需要先用工具算好对应的 CRC,再把帧发出去。正常响应会返回 7 字节,形如02 03 04 10 01 00 32 CRC

5.2 用 modbus poll 模拟主站轮询

验证主机模式时,可以换思路:把目标从机先替换成 modbus poll 这个模拟主站软件。在窗口里设置串口号、波特率 9600、数据位 8、停止位 1、校验位 None,从站地址依次设 1、2、3。modbus poll 会周期性发送请求,并把响应数据显示出来。这时若 STM32 有按键切换地址的行为,软件里能看到请求地址随按键变化。若显示通信超时,先看 modbus poll 的报文统计,确认请求帧 CRC 是否正确发出。

5.3 自动收发电路的时序陷阱

不少 RS485 模块使用带自动收发转换的电路,发送和接收状态由硬件自动切换。这种电路有一个典型问题:发送结束后方向切换回接收的瞬间,如果 MCU 没有等待串口发送完成,最后一个字节的停止位会被截断。排查时用示波器看 A、B 两线,发送结束后应该有约 1 bit 时间的完整停止位。工程里等 TC 标志的写法可以规避这个问题,但外部模块内部判断方向是否合理的标准仍然要看能否完整收到 CRC。

RS485 组网还要确认一个细节:A、B 线接反时,从机收不到任何数据,但用万用表量 A-B 静态电压可能在约 200mV 到 1.5V 之间变动。标准接法是从机侧终端电阻并联在 A、B 之间,上电后 A 相对 B 为高。总线上两台设备地址相同时,主机会同时收到两个从机的响应,CRC 大概率校验失败,这时优先检查地址映射,而不是检查代码。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询