拓冰建站拓冰建站
首页 / 资讯中心 / 正文

STM32F103 Modbus主站例程:HAL库状态机与RS485通信实现

简介面向STM32F103系列的Modbus主站完整工程专为嵌入式、物联网及单片机开发者提供一套可直接运行与改写的通信例程。工程基于STM32 HAL库与KEIL MDK开发适配F103常见型号使用者按需修改芯片型号、Flash容量及下载器类型即可快速迁移。代码对模块接线有清晰定义并辅以注释能帮助理解主站轮询、寄存器读写、数据帧解析等关键流程同时涵盖USART、定时器等外设的初始化与调用方式可作为项目模板二次开发。压缩包共394个文件约10.4MB以C源码与H头文件为主并包含KEIL工程文件、hex烧录文件、map映射及编译过程生成的中间文件结构完整便于直接打开、编译与烧录验证。目前已有113人学习适合刚接触Modbus主机协议或HAL库的开发者作为快速上手的参考也可根据实际硬件差异灵活调整代码。1. Modbus 主站例程不是“发帧”那么简单很多从事设备集成和物联网网关开发的工程师第一次拿到 STM32F103 的 Modbus 主站代码时习惯性去找“发送函数”然后把从站地址、功能码和数据拼成数组发出去结果从站毫无响应。问题往往不在数据帧本身而在主站的事务管理什么时刻发送发完等待多久超时后如何处理收到一帧 CRC 错误的响应又如何复位状态机。这套基于 HAL 库的例程把主站逻辑封装成轮询调度本质上是演示如何把“串口发字节”升级成“可靠的现场总线事务”。如果你在写数据采集网关、PLC 协议转换器或传感器集中器这篇内容会直接派上用场若你之前只做过从站也能从主站视角重新理解 Modbus 的容错设计。下面从帧结构讲起再落到 HAL 库的串口、485 方向控制和具体调试细节。2. Modbus RTU 帧结构、CRC 与主站状态机2.1 主站比从站多考虑一个“等待窗口”Modbus RTU 在串行线上传输的帧格式包含四部分从站地址、功能码、数据段、CRC16。从站是被动响应的收到完整帧后计算 CRC若正确则执行操作并返回响应。主站则必须管理“一轮事务”的完整生命周期发送请求、等待响应、确认超时、重试。也就是需要一个状态机而不是一个单纯的发送函数。本资源中的主站代码核心正是这个状态机常见状态转移如下表状态进入条件执行动作IDLE无请求或上一事务结束取出轮询表下一项构造请求帧SEND请求帧构造完毕写串口启动超时定时器WAIT发送完成等待接收中断或超时回调PROCESS收到完整响应校验 CRC解析数据进入下一轮ERROR超时或 CRC 错误重试计数仍失败则回到 IDLE如果在 while 循环中连续发送而不进入等待态从站返回的数据会与下一帧请求混叠引起 CRC 无效。这也是很多人串口参数明明正确却读不到数据的主要原因。2.2 CRC16 的低字节在前与快速移位算法Modbus 常用的校验是 CRC-16/IBM多项式 0xA001。HAL 库本身不实现 CRC通常在主站代码中单独编写uint16_t Modbus_CRC16(uint8_t *buffer, uint16_t length) { uint16_t crc 0xFFFF; for (uint16_t i 0; i length; i) { crc ^ buffer[i]; for (uint8_t bit 0; bit 8; bit) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }调用时不要把待发送的 CRC 本身算进去。例如请求帧01 04 00 00 00 01共 6 字节先计算这 6 字节的 CRC得到两个校验字节后按低字节在前、高字节在后的顺序追加到帧尾。上述报文的 CRC 计算结果通常为 0x31CA完整发送字节为01 04 00 00 00 01 CA 31。如果你在小端模式的 STM32 上直接把 crc 变量强转为 uint8_t 数组刚好低字节在前但一旦移植到大端平台必须显式交换字节序。建议先用在线 CRC 计算器验证一遍算法再连接真实从站。2.3 用软件定时器实现状态机骨架状态机可以放在主循环中轮询也可以由定时器中断驱动。例程更常见的是主循环轮询避免在中断里做大量解析。一个精简的骨架uint8_t modbus_state MODBUS_IDLE; uint8_t retry_cnt 0; void Modbus_PollTask(void) { switch (modbus_state) { case MODBUS_IDLE: if (Modbus_GetNextRequest() 0) // 0 表示成功取到下一项 { Modbus_BuildFrame(); modbus_state MODBUS_SEND; } break; case MODBUS_SEND: HAL_UART_Transmit_DMA(huart1, tx_buf, tx_len); Modbus_TimerStart(100); // 100ms 超时 modbus_state MODBUS_WAIT; break; case MODBUS_WAIT: if (Modbus_RxComplete() 1) { if (Modbus_CheckCRC(rx_buf, rx_len) 0) { Modbus_ParseResponse(); modbus_state MODBUS_IDLE; } else { modbus_state MODBUS_ERROR; } } else if (Modbus_TimerExpired()) { modbus_state MODBUS_ERROR; } break; case MODBUS_ERROR: if (retry_cnt 3) { modbus_state MODBUS_SEND; // 重发 } else { retry_cnt 0; Modbus_LogError(); modbus_state MODBUS_IDLE; } break; default: modbus_state MODBUS_IDLE; break; } }这段代码里Modbus_TimerStart可以基于HAL_GetTick()实现比如记录deadline HAL_GetTick() 100每次循环判断HAL_GetTick() deadline。Modbus_RxComplete检查串口接收缓冲区是否收到一帧完整数据。100ms 是默认超时若波特率提高到 115200响应帧通常 5ms 内就能回来超时改为 20ms 即可。重试 3 次是工业现场比较折中的做法重试间隔至少等待一个超时周期避免总线冲突。3. HAL 库串口配置、485 方向控制与接收定界3.1 串口 1 与串口 3 在 HAL 库中的差异处理STM32F103 的 USART1 挂在 APB2 总线USART3 挂在 APB1 总线。HAL 库会自动计算波特率分频但时钟树配置错误时USART3 比 USART1 更容易出现波特率偏差。例程中基于 CubeMX 生成的初始化代码大致如下void MX_USART3_UART_Init(void) { huart3.Instance USART3; huart3.Init.BaudRate 9600; huart3.Init.WordLength UART_WORDLENGTH_8B; huart3.Init.StopBits UART_STOPBITS_1; huart3.Init.Parity UART_PARITY_NONE; huart3.Init.Mode UART_MODE_TX_RX; huart3.Init.HwFlowCtl UART_HWCONTROL_NONE; huart3.Init.OverSampling UART_OVERSAMPLING_16; HAL_UART_Init(huart3); }注意Mode必须同时包含 TX 和 RX不要只开发送。排查时重点确认校验位是否为 None因为 Modbus RTU 标准是 8 位数据位、无校验、1 停止位错设成偶校验会导致整个链路不通。另外USART1 与 USART3 的引脚不同例程中“stm32f103 串口1和串口3使用差异”主要体现在引脚复用和时钟宏上改型号时先核对GPIO_PinAFConfig或HAL_GPIO_Init中的复用映射。3.2 485 收发使能引脚的切换时机RS-485 半双工模式下MAX485 或 MAX3485 的 DE/RE 引脚必须由 GPIO 控制。例程中的接线定义一般在模块头文件里典型宏定义#define RS485_DIR_HIGH() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET) #define RS485_DIR_LOW() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET)发送前置高方向引脚发送完成后再置低。使用阻塞发送HAL_UART_Transmit时函数返回即表示数据已经发出可立刻拉低方向如果改用 DMA 发送则应在HAL_UART_TxCpltCallback中拉低否则 DMA 还在搬运你已经切到接收模式总线上的帧会被截断。接收到完整一帧后也要注意不要在解析过程中强行翻转方向这会干扰从站后续的响应。3.3 用空闲超时识别完整响应帧Modbus RTU 规定帧间隔至少为 3.5 个字符时间。在 HAL 库中最稳定的方案是单字节中断接收配合软件定时器判断帧结束void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART3) { if (rx_idx sizeof(rx_buf)) { rx_buf[rx_idx] rx_byte; } else { rx_idx 0; // 防溢出重置缓冲区 } last_rx_time HAL_GetTick(); HAL_UART_Receive_IT(huart, rx_byte, 1); } }主循环中判断帧是否完整if ((rx_idx 0) (HAL_GetTick() - last_rx_time FRAME_GAP_MS)) { Modbus_ProcessFrame(rx_buf, rx_idx); rx_idx 0; }FRAME_GAP_MS建议不小于 3.5 个字符时间。9600 波特率下一字节约 1.04ms3.5 字符约 3.65ms取 4~5ms 即可115200 下取 2ms 足够。注意HAL_GetTick()分辨率为 1ms在极高波特率下取 1ms 可能提前判断所以保守取 2ms 以上。缓冲区溢出时直接丢帧并打印错误计数便于定位总线干扰。4. 轮询表、异常码解析与 Modbus 调试工具实战4.1 定义轮询表来管理多个从站事务主站通常需要周期读取多个从站的多个寄存器例程中的轮询表结构体定义一般在文件顶部typedef struct { uint8_t slave_id; uint8_t func_code; uint16_t reg_addr; uint16_t reg_num; uint16_t interval_ms; } PollItem; const PollItem poll_table[] { {0x01, 0x04, 0x0000, 0x0002, 500}, {0x02, 0x03, 0x0100, 0x0004, 1000}, };功能码 0x04 用于读输入寄存器0x03 用于读保持寄存器。从站返回的寄存器数据每个占 2 字节高字节在前。解析时可以用如下方式提取uint16_t get_reg_value(uint8_t *frame, int offset) { return (frame[offset] 8) | frame[offset 1]; }假设从站返回01 04 02 12 34 CA 31那么从站地址 0x01功能码 0x04字节数 0x02数据12 34表示寄存器值 0x1234。注意帧索引从第 3 字节起才是寄存器数据。很多人把frame[1]当数据自然读出来是乱的。4.2 没有从站硬件时如何验证主站例程手边没有从站时可以用 PC 软件模拟。我习惯的调试链路是先用 Modbus Poll 只读监听主站发送的请求帧再用 Modbus Slave 软件模拟从站。Modbus Poll 本身是主站工具但可以把它当作监听器关闭自动轮询直接查看串口总线上原始请求。Modbus Slave 则用来配置寄存器值并响应请求。在 PC 上打开 Modbus Slave 后建立一个从站地址为 0x01 的映射表功能码选择 03起始地址 0寄存器数量 4波特率与单片机一致。运行主站例程如果串口交叉连接到 USB-TTL 的 485 接口就能在 Slave 软件上看到主站发起的请求和 Slave 作出的响应。若没有 485 转接直接用 TTL 电平的 RX/TX 交叉连接也可以但要确保主站的 DE/RE 方向控制时序正确否则 TX 自环会干扰接收。4.3 异常码与错误日志的快速定位当请求合法但寄存器不存在时从站返回异常码。Modbus 异常响应的功能码最高位置 1比如请求功能码 0x03异常响应的功能码是 0x83。常见异常码含义如下异常码含义常见原因0x01非法功能从站不支持该功能码0x02非法数据地址寄存器地址超出范围0x03非法数据值写入值超出允许范围0x04从站设备故障从站内部异常建议在Modbus_ParseResponse中加一个调试打印分支if (frame[0] (func_code | 0x80)) { printf(Exception: 0x%02X\r\n, frame[2]); }这里的frame[2]是异常码。打印结果能直接看出是否为地址越界。如果主站一直超时问题不一定在协议层先用示波器或逻辑分析仪确认主站 TX 引脚有无波形再用串口助手手工发送一条标准请求帧给从站看是否有响应。分步排除比乱调参数有效得多。5. 移植到其他 STM32F103 型号与参数微调5.1 更改 Keil 工程中的芯片型号与 Flash 容量例程原工程名为Fire_F103ZE.uvguix对应的是 512KB Flash 的 ZE 型号。如果你实际使用的是 C864KB、CB128KB或 RC/RB必须在 Keil 的 Device 选项里重新选择型号同时编译宏STM32F103xE需要相应改为STM32F103xB。这关系到启动文件以及 Flash 操作函数的边界判定尤其是用到 HAL 库 Flash 编程函数时容量宏错误会导致写入越界严重时锁死芯片。摘要里也提醒过KEIL 编译器建议使用 Version 5高版本或低版本在编译 HAL 库时可能报出不同的警告注意看提示信息即可。修改步骤打开 Options for Target - Device选择实际芯片型号。在 C/C 标签页的 Define 中确认STM32F103xB或STM32F103xE。如果代码中定义了超过 0x10000 地址的数组检查链接脚本 ROM/RAM 范围是否匹配。下载器选择同样重要Keil 的 Debug 设置里使用 STLink 要选ST-Link Debugger使用 JLink 则选J-LINK/J-TRACE。选错后连接报错容易误判为芯片损坏。5.2 不同波特率下的超时与轮询周期建议移植后的第一件事是确认波特率和超时。现场总线波特率由双方设备决定主站代码中的huart.Init.BaudRate要和从站一致。下表是常用波特率下的字符时间和建议超时波特率1字节时间(ms)3.5字符间隔(ms)建议超时(ms)96001.043.64100192000.521.82501152000.0870.3020轮询周期应大于“请求帧时间 从站响应时间 超时余量”。例如一条请求 8 字节在 9600 波特率下约 8.3ms响应 11 字节约 11.5ms加上 100ms 超时余量一个事务最多约 120ms轮询间隔取 500ms 以上比较稳妥。如果多个从站共用一个总线按间隔从短到长排序并确保所有周期落在同一个时间片内避免请求重叠。5.3 用自环和逻辑分析仪验证主站时序最后一个实用技巧是验证 DE/RE 方向引脚的切换时刻。把 STM32 的 A/B 输出经过一个 USB-TTL 模块直接接回同一个串口相当于自发自收此时主站会收到自己发出的请求帧CRC 当然正确但我们目的不是让它正确而是观察波形。使用逻辑分析仪的下降沿触发同时捕捉 TX 和方向引脚两路信号可以看到发送开始前方向引脚拉高、发送结束后拉低的过程。合理的切换点应该是方向引脚在 TX 起始位之前至少半个位时间拉高在停止位结束之前保持为高最后一位完全移出后再拉低。如果拉低太早总线会截断最后一个停止位从站表现为帧不完整拉低太晚则可能把从站发来的响应第一字节给“吃掉”。这个波形验证法比反复调整时序参数更直观能够在没有真实从站设备的情况下提前暴露硬件层问题。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门