STM32F103 Modbus RTU主站:HAL库与RS485轮询状态机

📅 发布时间:2026/9/13 20:06:04
STM32F103 Modbus RTU主站:HAL库与RS485轮询状态机
简介这是一份基于HAL库的STM32F103 Modbus主站设备完整例程面向嵌入式、物联网与单片机开发人群。代码使用KEIL编译开发以STM32F103ZE为运行参考同系列其他型号只需在工程中调整芯片选型与Flash容量即可移植程序中对模块接线和引脚定义均作了注释适合需要快速搭建Modbus主站通信或学习HAL库工程结构的中级开发者。资源包共394个文件压缩后约10.4MB以C源代码与头文件为主体同时包含Keil工程文件、hex烧录文件、map映射表及调试配置可直接导入工程编译、烧录并定位链接问题压缩包还保留调试链接文件便于对照修改后重新构建。目前已有113人学习内容覆盖从外设初始化到主站轮询的完整框架读者可结合自身硬件微调逻辑提升协议栈集成与调试效率。1. STM32F103 做 Modbus Master主站比从站更考验帧节奏做从站是等命令做主站是压节奏。STM32F103 配合 HAL 库实现 Modbus RTU 主站时协议本身不复杂真正决定能不能稳定跑满 24 小时的是三件事帧与帧之间的静默间隔、等待超时和 485 换向时序。很多刚接触的人用阻塞式 HAL_UART_Transmit 直接发请求结果最后一字节被截断或者从站明明在回复却一直被当成噪声丢弃。这篇文章按 RTU 帧结构、HAL 串口配置、主站状态机、联调工具的顺序拆开适合正在做变频器、温控表、电表数据采集或产线联调手里只有一块 STM32F103 最小系统和 USB 转 485 适配器的工程师。2. Modbus RTU 帧结构与主站 CRC16先定协议底子主站代码的第一步不是写串口而是把帧结构钉死。Modbus RTU 每帧由从站地址、功能码、数据区和 CRC16 组成。主站与从站的区别在于从站收到坏帧可以不理主站发出去的帧错了整条链路都不会有回复而且你很难分清是接线问题还是组帧问题。2.1 主站视角的帧格式地址、功能码与字节序RTU 帧的字段顺序是固定的主站组帧时唯一容易错的是 CRC 的字节序。下表把常用功能码的请求和响应格式列出来后面组帧函数直接按这个表填。功能码含义请求数据区响应数据区0x03读保持寄存器起始地址(2B) 数量(2B)字节数(1B) 寄存器值0x06写单个寄存器地址(2B) 值(2B)原样回显请求0x10写多个寄存器地址(2B) 数量(2B) 字节数(1B) 数据地址(2B) 数量(2B)0x01读线圈起始地址(2B) 数量(2B)字节数(1B) 位打包数据寄存器地址和数量都是大端序高字节在前CRC16 低字节在前。比如读 1 号从站的协议地址 0x0000 寄存器请求帧是01 03 00 00 00 01 CRC_L CRC_H。从站地址范围 1~247地址 0 是广播广播帧从站不回复主站必须单独处理这种发了不用等的场景。2.2 查表法 CRC16 的实现与参数含义Modbus RTU 的 CRC16 使用多项式 0xA001初始值 0xFFFF。HAL 库不提供现成的 Modbus CRC 计算一般自己写移位版本或查表版本下面的移位实现适合直接贴进例程。uint16_t modbus_crc16(const uint8_t *buf, uint16_t len) { uint16_t crc 0xFFFF; while (len--) { crc ^ *buf; for (uint8_t i 0; i 8; i) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; /* LSB 为 1 时异或多项式 */ } else { crc 1; } } } return crc; }函数返回的 crc 低字节在前组帧时先放crc 0xFF再放crc 8。校验时对整帧含 CRC 重新算一遍结果应为 0x0000这是排查到底是发错还是从站不回最快的手段。常见错误是把多项式写成 0x8005或者忘记初始值 0xFFFF这两种错在短帧上不一定暴露帧稍长就会出错。2.3 3.5T 帧间隔与超时参数怎么设RTU 协议规定帧内字符间隔不超过 1.5T帧与帧之间的静默间隔不小于 3.5TT 是一个字符的传输时间。9600 波特率、8N1 格式下一个字节约 1.15ms3.5T 约 4ms。工程上做主站建议把帧间隔放宽到 5ms 以上避免对时序敏感的从站把前后两帧拼在一起。波特率1 字节时间(8N1)3.5T 理论值工程推荐间隔96001.15 ms4.0 ms5 ms192000.58 ms2.0 ms3 ms1152000.10 ms0.35 ms1 ms主站的等待超时是另一组独立参数。RTU 标准没有规定从站必须在多少毫秒内回复实际设备差异很大变频器一般 10~50ms温控表可能到 100ms。例程里我一般把超时设成 200ms重试 2 次单站最大耗时约 600ms轮询 8 个站一个周期 5 秒出头多数采集场景够用。提示3.5T 间隔和等待超时是两个不同的时间前者控制什么时候能发下一帧后者控制等不到回复什么时候重发不要混用同一个定时器。3. STM32F103 HAL 库串口与 RS485 换向配置主站的数据链路是串口加 RS485 收发器。STM32F103 的 USART 本身是全双工但 485 是半双工总线必须先解决换向再谈收发。这一章给 CubeMX 的配置参数和 HAL 库收发封装的常见写法。3.1 CubeMX 里串口参数8N1 还是 8E1打开 CubeMX 后把 USART1 的模式设为 Asynchronous波特率按从站手册填。特别注意奇偶校验很多国产仪表和电表默认是 8E1 偶校验而 Modbus 标准默认 8N1。从站手册写无校验就选 NONE写偶校验就选 EVEN选 EVEN 时 Word Length 会自动变为 9bit这是正常的因为校验位占了一位。static 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; huart1.Init.OverSampling UART_OVERSAMPLING_16; if (HAL_UART_Init(huart1) ! HAL_OK) { Error_Handler(); } }如果从站是 8E1把 Parity 改成UART_PARITY_EVENHAL 内部会按 9bit 处理。HwFlowCtl 必须保持 NONERS485 场景下硬件流控不仅没用还会占用 GPIO 导致 DE 引脚配置冲突。3.2 DE 方向控制换向要等 TC 标志485 收发器的 DE 引脚决定方向高电平发送、低电平接收。HAL_UART_Transmit 返回时数据只进了移位寄存器可能还没发完立刻把 DE 拉低会掐断最后一个字节。正确做法是等发送完成标志 TC或者换向后再补 1ms 延时。#define RS485_DE_PORT GPIOA #define RS485_DE_PIN GPIO_PIN_4 void rs485_dir_tx(void) { HAL_GPIO_WritePin(RS485_DE_PORT, RS485_DE_PIN, GPIO_PIN_SET); } void rs485_dir_rx(void) { HAL_GPIO_WritePin(RS485_DE_PORT, RS485_DE_PIN, GPIO_PIN_RESET); } void rs485_send_frame(UART_HandleTypeDef *huart, uint8_t *buf, uint16_t len) { rs485_dir_tx(); HAL_UART_Transmit(huart, buf, len, 50); while (__HAL_UART_GET_FLAG(huart, UART_FLAG_TC) RESET) { } rs485_dir_rx(); }HAL_UART_Transmit 最后一个参数是超时时间单位 ms超时返回 HAL_TIMEOUT。50ms 在 9600 波特率下足够发完 20 字节帧长超过 30 字节时建议放大。while等 TC 这一行不能省很多例程漏掉它换向瞬间把最后一字节截成半帧从站收到后 CRC 永远对不上。3.3 中断接收与帧完成判定主站接收从站响应不能用阻塞方式因为不知道从站什么时候回复。常见做法是单字节中断接收收一个字节重使能一次帧完成判定放到主循环里做。uint8_t rx_byte; uint8_t rx_buf[256]; volatile uint16_t rx_len 0; volatile uint32_t rx_last_tick; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { rx_buf[rx_len] rx_byte; rx_last_tick HAL_GetTick(); HAL_UART_Receive_IT(huart, rx_byte, 1); } } int main(void) { /* ... 初始化 ... */ HAL_UART_Receive_IT(huart1, rx_byte, 1); while (1) { /* 状态机调度 */ } }回调里要防 rx_len 越界响应最长 256 字节但保险起见加if (rx_len sizeof(rx_buf))溢出时丢弃。主循环里若发现rx_len 0且距rx_last_tick超过 3.5T就认为一帧接收完毕交给状态机校验。4. 主站轮询状态机HAL 库例程的核心调度Modbus 从站代码可以写得像中断驱动主站不行。主站要处理发了请求等回复、超时重发、重试失败换下一个站这一连串动作用状态机组织才能把每帧时序控制在确定范围内。4.1 主站代码为什么必须状态机化如果直接在 main 里发一帧再阻塞等回复代码看起来简单但阻塞接收会让其他任务全部卡死。HAL 库裸机例程的常见写法是发送用阻塞、接收用中断、超时判断放主循环。状态机覆盖一个从站从发请求到判定失败的全过程。typedef enum { MB_STATE_IDLE, /* 空闲可发送下一个请求 */ MB_STATE_WAIT_RESP, /* 已发请求等待响应 */ MB_STATE_RETRY, /* 超时或校验失败准备重试 */ MB_STATE_NEXT_SLAVE /* 重试耗尽切换下一个从站 */ } mb_state_t;状态机的好处是每个状态的进入和退出条件都明确。WAIT_RESP 状态下要么收到合法帧跳到 IDLE要么超时跳到 RETRY绝不会出现还没回复又发下一帧的竞争问题。4.2 轮询表结构从站、寄存器与超时需要分开配置多从站轮询时把地址、寄存器、超时做成一张表比在代码里写死一堆 if 清晰得多。不同从站响应速度差异很大统一用一个全局超时不合适所以结构体里把超时和重试次数都放进去。typedef struct { uint8_t addr; /* 从站地址 1~247 */ uint8_t func; /* 功能码 0x03 / 0x06 / 0x10 */ uint16_t reg_addr; /* 寄存器协议地址 */ uint16_t reg_cnt; /* 寄存器数量 */ uint16_t timeout_ms; /* 等待超时 */ uint8_t retry_max; /* 最大重试次数 */ uint8_t enabled; /* 是否参与轮询 */ } mb_poll_tbl_t; static mb_poll_tbl_t poll_tbl[] { { 0x01, 0x03, 0x0000, 2, 200, 2, 1 }, /* 1号变频器读2个寄存器 */ { 0x02, 0x03, 0x0100, 4, 300, 2, 1 }, /* 2号温控表读4个寄存器 */ { 0x03, 0x06, 0x0050, 1, 200, 1, 0 }, /* 3号未启用 */ };reg_addr是协议地址不是 PLC 地址读 40001 对应协议地址 0x0000读 40010 对应 0x0009。reg_cnt对功能码 0x06 固定为 1。enabled置 0 可临时屏蔽某个从站不用改代码删数组。4.3 请求组帧与轮询主循环实现组帧函数把轮询表里的字段拼成 RTU 帧。读保持寄存器的数据区是地址高字节、低字节、数量高字节、低字节共 6 字节最后附 2 字节 CRC。uint16_t mb_build_read_req(uint8_t addr, uint16_t reg, uint16_t cnt, uint8_t *frame) { frame[0] addr; frame[1] 0x03; frame[2] reg 8; frame[3] reg 0xFF; frame[4] cnt 8; frame[5] cnt 0xFF; uint16_t crc modbus_crc16(frame, 6); frame[6] crc 0xFF; frame[7] crc 8; return 8; }reg必须声明为 uint16_t如果写uint8_t reg再右移 8 位会变成 0这是组帧最常见的隐性 bug。主循环里按状态机推进轮询核心结构如下。uint8_t tx_buf[32]; uint32_t req_tick; uint8_t frame_done 0; uint8_t retry_cnt 0; uint16_t idx 0; while (1) { if (rx_len 0 HAL_GetTick() - rx_last_tick 5) { frame_done 1; /* 距最后字节超过 3.5T帧接收完毕 */ } switch (state) { case MB_STATE_IDLE: if (frame_done || rx_len 0) { rx_len 0; frame_done 0; uint16_t len mb_build_read_req( poll_tbl[idx].addr, poll_tbl[idx].reg_addr, poll_tbl[idx].reg_cnt, tx_buf); rs485_send_frame(huart1, tx_buf, len); req_tick HAL_GetTick(); state MB_STATE_WAIT_RESP; } break; case MB_STATE_WAIT_RESP: if (frame_done) { if (mb_check_response(tx_buf, rx_buf, rx_len) 0) { /* 解析 rx_buf[3] 起的寄存器数据 */ idx (idx 1) % poll_cnt; retry_cnt 0; state MB_STATE_IDLE; } else { state MB_STATE_RETRY; } frame_done 0; rx_len 0; } if (HAL_GetTick() - req_tick poll_tbl[idx].timeout_ms) { state MB_STATE_RETRY; /* 超时重试 */ } break; case MB_STATE_RETRY: if (retry_cnt poll_tbl[idx].retry_max) { retry_cnt; state MB_STATE_IDLE; } else { retry_cnt 0; idx (idx 1) % poll_cnt; state MB_STATE_IDLE; } break; } }发送后rx_last_tick的旧值需要处理否则上一帧的时间戳会污染下一帧的完成判断。frame_done在进入 WAIT_RESP 前必须清掉避免把旧响应当新响应。超时判断用HAL_GetTick()毫秒级分辨率对 Modbus RTU 完全够用不需要上硬件定时器。4.4 响应校验地址、功能码与 CRC收到一帧先做三道校验顺序不能乱从站地址是否等于请求地址、功能码是否为请求值或异常功能码、CRC 是否正确。异常功能码是原功能码与 0x80 相或数据区第 1 字节是异常码0x02 表示非法地址0x03 表示非法数据值。int mb_check_response(uint8_t *req, uint8_t *rsp, uint16_t len) { if (len 5) return -1; if (rsp[0] ! req[0]) return -2; if ((rsp[1] 0x7F) ! (req[1] 0x7F)) return -3; if (modbus_crc16(rsp, len) ! 0) return -4; if (rsp[1] 0x80) return -5; return 0; }功能码比较用 0x7F屏蔽高位把正常和异常响应归到同一逻辑异常统一返回 -5由上层决定重试还是切下一个从站。CRC 校验用整帧 CRC 为 0 的写法比把 CRC 取出来和重算值比较少一步字节序处理。5. Modbus Poll 联调 485 主站三个必查的坑代码写完不要直接上实物先用 Modbus Poll 这类上位机把从站摸一遍再让板子去对话。5.1 先用 Modbus Poll 确认从站侧参数Modbus Poll 是 Windows 下最常用的 Modbus 主站模拟器。打开后点 Connection选 RTU、串口号和波特率把 Setup 里的从站地址、功能码、寄存器地址填进去。如果 Poll 能稳定读到数据说明从站参数、接线和 USB 转 485 适配器都没问题问题在板子端。检查项方法常见异常波特率/校验Poll 里逐个试数据乱码或超时从站地址改 Setup 地址一直 Timeout接线A/B 对调要么全通要么全不通终端电阻总线两端并联 120Ω长线误码5.2 板子联调时必查的三个坑第一个坑是 485 换向截断。现象是请求帧发出去了从站不回用带波形显示的 485 分析仪看到最后一字节只有半个。原因是 TC 标志没等DE 提前拉低。把等 TC 的while加上或换向后延时 1ms问题立刻消失。第二个坑是帧间隔不足。轮询多个从站时上一个响应处理完马上发下一帧中间没有 3.5T 静默有些从站会把前后两帧当成一帧处理CRC 直接失败。解决办法是在 IDLE 状态进入时记时间戳距上次收发结束不足 5ms 就再等。第三个坑是超时参数设得太短。Modbus Poll 默认超时 3000ms很多人照抄例程却用 50ms从站还没准备好就被判超时越重试越乱。例程里timeout_ms建议从 200ms 起步实测从站最大响应时间后取 3 倍再写进表。另一个容易忽略的细节485 总线上超过两个设备时每个从站地址必须唯一两个从站共用地址会互抢总线现象是或偶发乱码或一直超时和代码没有关系。本文还有配套的精品资源点击获取