简介面向STM32嵌入式开发者这套资源以RS485通信与Modbus RTU协议为主线基于HAL库与CLion环境实现轮毂电机ZLAC8015D的驱动控制适用于工业现场长距离、高噪声下的设备通信。压缩包共187个文件大小约1015KB以C源码、头文件、CMake构建配置为主包含串口驱动、Modbus报文处理、电机控制等模块并附有bin、hex、elf固件及调试日志便于对照工程结构理解完整开发流程。已有1614人学习下载。内容包括UART与RS485收发器连接方式、Modbus RTU寄存器读写与线圈操作、轮毂电机使能和速度控制实现还能参考CLion下CMake管理STM32工程的实践适合嵌入式初学者及需要对接工业设备的开发者。1. 这项目到底是干什么的一块可以扔进产线的 Modbus 从站样板如果你接过工业现场设备的活一定遇到过这种场景甲方要的是一块能并入既有 PLC 网络的板子协议固定是 Modbus RTU物理层固定是 RS485你翻遍手头的例程发现全是“串口打印”级别的东西上位机一开03功能码读寄存器板子直接没反应。这套STM32-RS485-Modbus就是干这个的它把 MCU 侧完整的 Modbus RTU 从站链路搭好从帧接收、CRC 校验、功能码解析到 RS485 方向切换全部落地烧进去就能被 Modbus 调试工具或组态软件识别。项目适合两类人一类是刚接了工控项目的单片机开发者需要一份不用改就能跑通的从站基础工程另一类是想用 CLion 替代 Keil 做 STM32 开发的这套工程本身就带了 CubeMX 生成的 HAL 代码和 CMake 构建配置环境搭好直接就能编译。下面我从工具链开始一步步把这套源码拆给你看。2. 工具链与工程骨架CubeMX 生成 HAL 代码CLion 做构建管理2.1 为什么选 CLion CubeMX 而不是 Keil 一把梭传统 STM32 开发在 Keil 里点灯没问题一旦代码量上来文件跳转、重构、Git 集成都很别扭。这套项目用 CLion 做 IDE配合 STM32CubeMX 生成底层初始化代码然后用 CMake 把两边接起来。CLion 的 CMake 支持是原生级别代码补全和引用查找比 Keil 舒服太多而且 CubeMX 每次修改外设配置后重新生成代码不会覆盖你写业务逻辑的 user 目录。工程顶层结构是这样分层的STM32-RS485-Modbus/ ├── CMakeLists.txt # CLion 构建入口 ├── Core/ │ ├── Inc/ # 头文件main.h、modbus_slave.h、rs485_ctrl.h │ └── Src/ │ ├── main.c # 主循环初始化 轮询协议栈 │ ├── modbus_slave.c # Modbus RTU 从站协议核心 │ ├── rs485_ctrl.c # RS485 方向控制引脚驱动 │ └── stm32f1xx_it.c # 串口中断、定时器中断 ├── Drivers/ │ └── STM32F1xx_HAL_Driver/ # HAL 库源码 ├── Modbus/ │ ├── modbus_crc.c # CRC16-Modbus 查表实现 │ └── modbus_regs.c # 寄存器映射表与读写回调 └── .ioc # CubeMX 工程文件可重新生成Modbus/目录是这套工程最有价值的部分它把协议栈和 HAL 驱动解耦了modbus_crc.c只做 CRC 算法modbus_regs.c管寄存器映射modbus_slave.c组合两者处理帧逻辑。改业务寄存器值不需要动串口代码改功能码处理不需要动硬件初始化。2.2 CubeMX 侧的串口和定时器配置工程里通过.ioc文件记录了 CubeMX 配置米乐M6关键参数按工业现场最常见的用法设置USART1 开 9600 波特率、8 数据位、无校验、1 停止位打开接收中断PA4 作为 RS485 收发方向控制引脚推挽输出TIM4 用作 Modbus 帧间隔定时器。CubeMX 生成完代码后业务文件都放在Core/Src下面不会因为重新生成代码被冲掉。启动流程里初始化顺序是有讲究的多个外设之间如果时序不对RS485 收发芯片可能直接处于错误状态int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); // 先初始化 GPIO把 RS485 方向引脚拉低到接收态 MX_USART1_UART_Init(); MX_TIM4_Init(); modbus_slave_init(); // 再初始化协议栈注册寄存器回调 rs485_ctrl_init(); // 最后确认方向引脚默认接收 while (1) { modbus_poll(); // 主循环轮询帧状态机 } }顺序上有两个点容易翻车RS485 芯片的 DE/RE 引脚如果初始化为高电平芯片上电瞬间会主动驱动总线多块从站板子同时上电就是总线冲突协议栈初始化必须在串口中断开启之前做否则上电时总线上的残留字节会触发一个半截帧状态机容易卡死。这套工程在rs485_ctrl_init()里把方向引脚强制拉低就是为了规避第一个问题。2.3 CMake 里串起源码和头文件路径CMakeLists.txt不需要写复杂逻辑但路径必须覆盖到所有源文件否则链接期会报 undefined reference。建议把file(GLOB)按目录分组写不要一把梭把所有.c都扫一遍CubeMX 生成的it.c和main.c跟协议栈文件混在一起调试时定位问题会多花时间。set(CORE_SOURCES Core/Src/main.c Core/Src/modbus_slave.c Core/Src/rs485_ctrl.c Core/Src/stm32f1xx_it.c ) set(MODBUS_SOURCES Modbus/modbus_crc.c Modbus/modbus_regs.c ) target_include_directories(${PROJECT_NAME} PRIVATE Core/Inc Modbus )CLion 里导入 CubeMX 工程后第一次构建会自动触发 CMake 重新生成编译命令头文件路径配好后代码跳转和语法高亮就正常了。别跳过这一步直接看代码CLion 不重新加载 CMake 配置的话HAL_GPIO_WritePin这种函数会全部标红。3. 核心模块拆解一个 Modbus RTU 请求从进入到返回的完整链路3.1 串口中断里只做搬运帧组装挪到状态机Modbus RTU 从站最忌讳在串口中断里做完整解析字节流是断断续续的一帧可能分成好几段到达。工程里的做法是中断里只把字节塞进 DMA 接收缓冲区同时记录字节数然后由主循环里的modbus_poll()去处理帧间隔和解析。这样既不会丢字节也不会在中断里跑 CRC 导致中断响应变慢。void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_RXNE)) { uint8_t byte (uint8_t)(huart1.Instance-DR 0xFF); rx_buffer[rx_count] byte; if (rx_count MAX_FRAME_LEN) { rx_count 0; // 超长直接清空避免覆盖内存 } __HAL_UART_CLEAR_FLAG(huart1, UART_FLAG_RXNE); } }接收缓冲用的是裸数组加计数器没有用环形队列原因很简单Modbus RTU 从站同一时刻只会和主站对话不存在并发收发的场景一个线性缓冲区加一个帧超时判定足够。真正要防的是串口波特率 9600 下帧间隔抖动比如一帧数据中间卡了 5 个字符时间的间隙modbus_poll()就会把这一帧拆成两半。工程里的处理思路是收到第一个字节后启动 TIM4 计时超过 3.5 个字符时间没有新字节就认为帧结束这个参数要和波特率联动调整9600 波特率下 3.5T 大概是 4ms。3.2 Modbus 状态机从空闲到响应的四个阶段帧处理不靠全局 flag 满天飞而是用一个小的状态机把流程固定住。代码在modbus_slave.c里维护了IDLE、RECEIVING、CHECK、RESPOND四个状态modbus_poll()每次被调用都会检查当前状态并推进下一步。void modbus_poll(void) { switch (state) { case MODBUS_IDLE: if (rx_count 0) { state MODBUS_RECEIVING; HAL_TIM_Base_Start_IT(htim4); // 启动帧间隔定时器 } break; case MODBUS_RECEIVING: if (frame_timeout) { frame_timeout 0; state MODBUS_CHECK; } break; case MODBUS_CHECK: if (modbus_validate_frame(rx_buffer, rx_count)) { modbus_process_function(rx_buffer, rx_count); } rx_count 0; state MODBUS_IDLE; HAL_TIM_Base_Stop_IT(htim4); break; default: state MODBUS_IDLE; break; } }这个状态机的好处在于每个状态下只做一件有限的事排查问题的时候只需要确认卡在哪个状态。比如主站发请求后从站没回复先用调试器看 state 是停在RECEIVING还是CHECK。停在RECEIVING说明帧不完整或超时没触发优先检查定时器中断停在CHECK说明帧收完了但 CRC 验证没过优先检查 CRC 计算。我一个血的教训之前偷懒用HAL_UART_Receive_IT配合回调函数重组帧结果高波特率下中断回调里的处理逻辑太重直接把后续字节丢了改成状态机轮询后问题消失。3.3 CRC16 不是简单的查表初始值和多项式必须对齐CRC 是 Modbus 帧最容易出错但又最容易被忽略的地方。工程里用的是查表法表项按 Modbus 协议约定生成初始值是 0xFFFF多项式是 0x8005。注意这里的坑是Modbus 的 CRC16 和 ModbusPlus 的 CRC 不同初始值错了整体就废了必须逐字节验证。推荐先在modbus_crc.c里写一组自测向量比如对0x01 0x03 0x00 0x00四个字节算 CRC输出必须是0x0A 0x09这种已知值。uint16_t modbus_crc16(const uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ data[i]; for (uint8_t bit 0; bit 8; bit) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return crc; }这段代码里 0xA001 是反射多项式 0x8005 的逆序形态移位时每次先异或字节再右移。工程在Modbus/modbus_crc.c里同时提供了查表版和这个按位计算版按位版牺牲一点 CPU 换代码量最小查表版适合波特率更高或者主频更低的场景。实际调试时我会用 Modbus Slave 工具连上板子手动把收到的 CRC 字节打印出来跟工具里的校验结果对一下能对上就说明链路是通的。3.4 寄存器和功能码怎么映射输入寄存器和保持寄存器分开协议栈没有把所有功能码揉在一起而是把寄存器按 Modbus 标准的四类区分线圈0x 区、离散输入1x 区、输入寄存器3x 区、保持寄存器4x 区。工程里默认实现了03读保持寄存器、06写单个寄存器16写多个寄存器可以自行扩展。寄存器表定义在modbus_regs.c结构清晰默认示例放了 3 个模拟量和一个状态量typedef struct { uint16_t addr; uint16_t value; uint16_t min; uint16_t max; } modbus_reg_t; #define REG_ADDR_BASE 0x0000 static modbus_reg_t holding_regs[] { { 0x0000, 0, 0, 100 }, // 设备温度 { 0x0001, 0, 0, 10000 }, // 转速 { 0x0002, 0, 0, 1 }, // 使能状态 };03功能码的处理函数里需要根据请求中给出的起始地址和数量校验地址是否越界。越界有两种请求的地址超过了寄存器表末尾或者起始地址 数量溢出了表边界。工程返回异常码0x02给主站主站看到后会把对应寄存器标红调试效率高很多。4. RS485 驱动细节收发切换、DMA 接收与链路参数4.1 方向引脚和串口数据发送的严格时序RS485 是半双工物理层收发共用一对差分线方向切换靠 MCU 的一个 GPIO 控制收发芯片的 DE/RE。这套工程里发送数据之前必须先拉高方向引脚然后延时等待收发芯片完成切换再调用HAL_UART_Transmit发完之后要等串口移位寄存器吐完最后一位才能拉低方向引脚否则最后一个字节高电平没发完就被切到接收态主站收到的帧尾会烂掉。void rs485_send(uint8_t *data, uint16_t len) { rs485_ctrl_tx_enable(); // 拉高 PA4芯片切到发送模式 HAL_UART_Transmit(huart1, data, len, HAL_MAX_DELAY); rs485_ctrl_tx_disable(); // 串口发送完成后拉低切回接收 }这个逻辑看着简单实际调试时我在这个地方翻过车HAL_UART_Transmit是阻塞函数数据全部写入发送寄存器后会等待 TC传输完成标志位但不同 HAL 版本的实现略有差异有的拿到 TC 直接返回移位寄存器的数据还在慢慢往外吐。所以工程里在tx_disable之前加了微秒级延时保险起见可以另加一个 1 字节时间的延时。拿 9600 波特率算1 个字节大约 1.04ms延时写 2ms 够用。4.2 接收端用 DMA 还是纯中断这个项目选纯中断的原因很多 RS485 工程会推荐UART DMA IDLE中断的方式中断里通过空闲检测判定一帧结束CPU 负担较小。这套工程没走 DMA原因在于 STM32F1 系列的 USART 在 DMA 接收时空闲中断的触发时机和 DMA 传输完成中断配合容易出竞态尤其 Modbus 帧并不长最多 256 字节纯中断 状态机完全扛得住。CPU 主频 72MHz 下接收 256 字节也就是几毫秒的事主循环轮询不会丢帧。void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { // 工程里仅保留 ORE溢出错误检测不做数据缓存 if (__HAL_UART_GET_FLAG(huart, UART_FLAG_ORE)) { __HAL_UART_CLEAR_OREFLAG(huart); } }这个回调里只清错误标志不处理数据是因为数据接收已经挪到了USART1_IRQHandler里。这里有个容易被忽略的点开启了HAL_UART_Receive_IT之后HAL 库内部也会注册一个回调如果你没有在初始化时调用这个函数就直接走原始中断线的逻辑反而少一层 HAL 封装逻辑更可控。4.3 RS485 硬件设计上的三个参数终端电阻、A/B 上下拉、波特率误差软件之外RS485 链路能不能稳定工作跟硬件参数强相关工程里虽然没有 PCB但代码注释里标了建议值。总线两端各并一个 120Ω 终端电阻匹配特性阻抗防止信号反射A/B 线对地加上下拉电阻保证空闲时总线电平确定波特率误差要控制在 2% 以内否则长线传输累积误差会让帧错位。// 9600bpsUSART_BRR 72MHz / (16 * 9600) // 常见晶振误差8MHz 晶振 9600bps 误差 0.0%修改波特率时要注意重新计算USART_BRR寄存器值CubeMX 里可以直接选但如果手写初始化代码借用HAL_UART_Init会自动算好。排查通信不稳定时不要一上来就怀疑软件先用示波器看 A-B 两线之间的差分波形正常的空闲电平应该是高电平如果电平是悬空的、乱跳的先查上下拉电阻有没有焊。5. 避坑排查RS485 乱码、帧错位与 CRC 校验失败5.1 现象主站能收到响应但数据每隔几帧就错一次原因多半不是协议栈而是串口波特率精度不够导致的采样偏移。STMF1 系类用 8MHz 晶振跑 9600 下误差理论为 0但如果你用的板子焊接的是 12MHz 晶振而代码按 8MHz 算误差会直接超 2%。解决方法是打开 CubeMX 看实际波形或者在main.c里打印CLOCK_SOURCE验证 PLL 配置。工程里默认在mx_usart1_uart_init前有SystemClock_Config()改晶振后务必同步改这里。5.2 现象发送完一个字节后总线上出现毛刺波形原因是方向切换时序问题。GPIO 拉高后立刻开始发数据收发芯片的DE使能到数据稳定输出之间存在延时不同芯片延时不同常见的是几十纳秒到几微秒。解决方法是拉高方向引脚后加一个for (volatile int i 0; i 100; i);短延时再发数据保守一点可以直接HAL_Delay(1)代价是每次响应慢一点点但从站场景完全无所谓。5.3 现象CRC 用 Modbus 工具算和从站内部算不一致但从站自己的测试又是对的这种玄学问题通常出现在协议栈处理 CRC 的字节序上。Modbus 传输时 CRC 低字节在前、高字节在后而工程里modbus_crc16返回的是(hi 8) | lo的形式和主机通信的帧直接在内存里按大端转成小端发送。判断标准用 Modbus 工具读取寄存器如果设备能正常响应CRC 收发一定是对的问题只会出现在你拿一个已知帧手工对比的时候。工程里modbus_validate_frame中重新计算 CRC 后做了字节交换再比较uint16_t crc_rx rx_buffer[len - 1] 8 | rx_buffer[len - 2]; uint16_t crc_cal modbus_crc16(data, len - 2); if (crc_rx ! crc_cal) { return 0; }5.4 现象两块从站板子接在总线上上电后两台都频繁报错这是 RS485 最典型的配置冲突终端电阻上拉/下拉电阻被重复添加总线电平被拉死或者从站地址写成了同一个值。工程里在modbus_regs.c的初始化函数里把从站地址固定成0x01多台设备挂载时需要把地址改为不同值常见做法是拨码开关或 EEPROM 存储但项目简化为宏定义常量#define MODBUS_SLAVE_ADDR 0x015.5 现象串口中断偶尔丢字节用示波器看总线波形正常排查思路先看中断优先级有没有和系统定时器冲突。STM32F1 的 HAL 库默认把 SysTick 设为最低优先级如果HAL_UART_Transmit阻塞读超时用了HAL_MAX_DELAY主流程卡住时中断还能响应但如果优先级配置错误接收中断被别的中断打断太长时间RXNE 标志会被后续数据覆盖产生 ORE 错误。工程里在NVIC配置时把USART1_IRQn设为1抢占优先级、0子优先级避免被打断。6. 二次开发扩展从单从站到多节点网关的改造路径拿到这套工程最实用的改造是把这个从站改成一个协议转换网关比如采集多个 RS485 传感器再用 Modbus TCP 暴露给上位机。迁移思路分三步第一步在modbus_regs.c里新增一组只读输入寄存器映射到通过轮询获取的外部传感器数据第二步在主循环里增加心跳式轮询逻辑每 100ms 依次向传感器发送读取请求收到后刷新寄存器值第三步把modbus_slave.c的响应接口抽象成modbus_reply_start/modbus_reply_add两个函数Modbus TCP 处理函数复用同一套寄存器表即可。改造的验证方法很关键先把旧的串口链路断开只让网关通过 USB 转串口连到电脑上用 Modbus Poll 软件同时打开两个窗口一个走 RTU 一个走 TCP地址设为一致然后手动修改寄存器值观察两边刷新是否同步。务必用同样的 CRC 函数和同样的寄存器地址否则排查混乱。针对项目里寄存器读写权限建议改造成这样对外提供一组modbus_reg_set_value(uint16_t addr, uint16_t value)接口默认情况下06功能码写入会调用它内部再写一条日志记录是谁在什么时候改了哪个寄存器。工业现场排查问题的时候这个日志就是后悔药能精准定位到设备被人为误改还是上位机 bug。bool modbus_reg_write(uint16_t addr, uint16_t value) { for (uint16_t i 0; i reg_count; i) { if (holding_regs[i].addr addr) { if (value holding_regs[i].min || value holding_regs[i].max) { return false; } holding_regs[i].value value; return true; } } return false; }这个函数里的边界检查几乎每次都会成为客户升级设备时改 bug 的入口。从那以后我每次做 Modbus 从站都会强制走一遍“CRC 自检、边界自测、通断测试”三件套任何一项不过都不敢烧给现场。希望这套 STM32 的 RS485 Modbus 工程也能让第一次做工业通信的你少踩几个坑。本文还有配套的精品资源点击获取