十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32实战:RS485 MODBUS RTU主从机通信与帧边界处理

STM32实战:RS485 MODBUS RTU主从机通信与帧边界处理 简介资源为 STM32 结合 RS485 总线的 MODBUS 协议通信工程源码包面向嵌入式开发者与自动化控制学习者解决多机通信中主机轮询、从机响应及地址切换问题。代码包含完整主机与从机两种模式上电默认主机可定时通过串口发送请求读取从机 0x01 数据并支持按键 13 查询不同从机地址数据按键 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 接 PA9RO 接 PA10DE 和 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 等待或者某个函数阻塞 10ms4ms 的超时窗口就被拉长了高负载下会把两帧合并成一帧这种 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、0x03KEY4 不做地址切换而是切入从机模式。扫描函数按 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_buf3.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 大概率校验失败这时优先检查地址映射而不是检查代码。本文还有配套的精品资源点击获取
返回列表