STM32 RS485总线Modbus RTU从站协议栈实现与CLion工程实践
简介面向 STM32 嵌入式开发者的 RS485 通信与 Modbus RTU 协议实践项目基于 HAL 库完成串口初始化、报文解析与收发控制以中菱轮毂电机 ZLAC8015D 为控制对象并采用 CLion 与 CMake 构建管理适合有一定单片机基础、希望学习工业现场总线和电机控制的工程师参考。压缩包内共 187 个文件整体约 1015KB。其中包含大量 C 语言源文件与头文件代码集中在 stm32f1xx_hal_uart、stm32f1xx_hal_tim 等标准外设驱动和 RS485_TX 相关通信逻辑中另有 ioc 图形化配置、CMake 构建脚本、编译中间文件以及 bin、hex、elf 固件输出便于直接查看完整工程结构并烧录验证。已有 1614 人学习下载。通过该项目可以掌握 RS485 半双工差分通信与串口参数配置方法理解 Modbus RTU 报文帧的构造、解析和读写寄存器流程并熟悉 CLion 环境下 STM32 工程的构建与调试流程对实现电机控制类工业设备通信具有实际参考价值。1. 工业现场没有Wi-Fi用STM32把Modbus RTU跑在RS485总线上工业现场的机柜里没有Wi-Fi也没有蓝牙设备之间通信靠的是一根双绞线和几十年前的Modbus协议。我在用CLion做嵌入式开发时经常遇到一个问题CLion原生支持CMake工程但网上能找到的STM32 RS485 Modbus示例大多是Keil工程换到CLion里跑不起来。这套源码就是解决这个问题的——它是一份在CLion环境下开发的Modbus RTU从站工程基于STM32F103C8T6最小系统板配合MAX3485/SP3485这类RS485收发器实现了功能码03读保持寄存器和06写单个寄存器并预留了扩展协议栈的结构。适合做工业数据采集、传感器网关、设备控制器二次开发的嵌入式开发者也适合想从Keil迁移到CLion的工程师拿来当工程模板。2. 硬件链路与协议设计从USART到RS485再到Modbus RTU帧2.1 硬件接线DE/RE控制引脚与收发器选型RS485是差分信号抗干扰能力强能支持多点组网成本也低所以工业现场几乎都在用它。但STM32的USART出来的是TTL电平不能直接挂到RS485总线上中间必须加一颗收发器芯片。常见的有MAX3485、SP3485两者的引脚兼容工作电压3.3V直接接STM32的PA9USART1_TX和PA10USART1_RX就行。收发器上有一个DEDriver Enable引脚和REReceiver Enable引脚负责切换发送和接收方向。我习惯把这两个引脚短接在一起用一个GPIO控制输出高电平进入发送模式输出低电平进入接收模式。在这套工程里方向控制引脚接在PB1上逻辑如下图所示STM32F103C8T6 MAX3485/SP3485 RS485总线 PA9 (USART1_TX) -- DI PA10 (USART1_RX) -- RO PB1 (GPIO_OUT) -- DE RE短接 A --------------- A线同相 B --------------- B线反相接线时有两个细节值得注意。一是终端电阻RS485标准要求在总线两端各接一个120欧电阻用来消除长线传输时的信号反射。如果只是两三个设备短距离调试不接也能凑合跑但会出现偶发性的帧错误我建议一开始就焊上。二是共地RS485的A、B是差分信号理论上不依赖地线但为了保证共模电压在收发器的容忍范围内设备之间最好还是把GND连起来否则距离稍长就容易出现乱码。2.2 Modbus RTU帧结构从地址到CRC的完整格式Modbus RTU的帧格式非常紧凑一帧报文由地址码、功能码、数据段和CRC校验组成。地址码占1字节有效范围是1到2470是广播地址广播帧从站接收但不回复。功能码决定操作类型这套工程实现了03和06两个最常用的。数据段的长度跟着功能码走03请求是“起始寄存器地址2字节 寄存器数量2字节”06请求是“寄存器地址2字节 写入值2字节”。帧尾的CRC16是Modbus协议特有的校验方式占2字节发送时低字节在前、高字节在后。一个完整的03读请求如下表所示字节序号内容示例值说明0从站地址0x01目标从站地址1功能码0x03读保持寄存器2-3起始地址0x0000从寄存器0开始读4-5寄存器数量0x0004读4个寄存器6-7CRC160x440A低字节在前帧与帧之间还有一个隐性的约束Modbus RTU要求帧间隔不小于3.5个字符时间否则接收方会把两帧当成一帧处理。在9600波特率下一个字符约1.04ms3.5个字符大约是3.65ms实际工程里按4ms处理。这个参数对后面的接收状态机设计有直接影响也是很多人在调试时踩坑的地方。2.3 寄存器映射表把传感器数据映射成Modbus地址Modbus从站的核心是寄存器表。主站通过地址来读写数据从站需要把硬件数据温度、湿度、状态映射到寄存器地址上。这套工程定义了4个保持寄存器按最常见的工业采集场景设计寄存器地址偏移数据类型含义400010uint16_t温度单位0.1℃400021uint16_t湿度单位0.1%400032uint16_t设备状态bit0为报警标志400043uint16_t固件版本号注意Modbus协议里的地址是从0开始的偏移量而PLC和组态软件里看到的地址是40001到40004这中间差了1。主站下发请求时用的是偏移量0而上位机软件显示的是40001做协议转换时不要搞混。寄存器值默认按大端序传输即高字节在前这个顺序在组帧时也要刻意处理。2.4 串口参数波特率、数据位、校验位、停止位RS485只规定了物理层波特率和帧格式由Modbus RTU决定。常见配置是9600波特率、8个数据位、无校验、1个停止位这套工程也按这个做。在STM32CubeMX里配置USART1时要打开USART1的全局中断并把接收方式设为中断接收这样数据到了会自动触发回调。波特率会影响帧间隙的计算9600只是入门配置。如果总线上的设备数量多、数据量大可以提到19200或38400但帧间隔要跟着缩短。改波特率时记得同步修改modbus.h里的MODBUS_FRAME_GAP_TICK宏不然接收状态机判断帧结束的时间窗口就不对了。3. CLion工程结构CMake配置、CubeMX生成与代码模块划分3.1 工程目录CubeMX生成的骨架和手写代码的分工这套工程是标准的STM32CubeMX CLion组合CubeMX负责生成芯片初始化和HAL库代码CLion负责构建、烧录和调试。CubeMX生成的项目可以直接导出为CMake工程CLion打开CMakeLists.txt就能识别。目录结构如下STM32-RS485-Modbus/ ├── Core/ │ ├── Inc/ # CubeMX生成的头文件 │ │ ├── main.h │ │ ├── gpio.h │ │ └── usart.h │ └── Src/ # CubeMX生成的源文件 │ ├── main.c │ ├── gpio.c │ ├── usart.c │ └── stm32f1xx_it.c ├── Drivers/ │ ├── STM32F1xx_HAL_Driver/ │ └── CMSIS/ ├── App/ │ ├── modbus.h # Modbus从站状态机与功能码接口 │ ├── modbus.c │ ├── crc16.h │ ├── crc16.c │ └── app_config.h # 从站地址、波特率、缓冲区大小配置 └── CMakeLists.txt我把手写的代码单独放在App目录下和CubeMX生成的Core、Drivers分开。这样做的好处是哪天重新用CubeMX生成代码Core和Drivers会被覆盖但App目录不受影响Modbus逻辑一份都不用改。这也是我从一个多从站项目里学到的教训——第一次我把Modbus代码直接塞在main.c里CubeMX重新生成后全部被清掉白写了半天。3.2 CMakeLists关键配置GCC工具链与链接脚本CLion本身不带ARM编译器需要先在Settings里配置Toolchain指向arm-none-eabi-gcc的安装路径。工具链配好后CMakeLists里要注意几个关键点指定C标准、把App目录加进头文件搜索路径、编译选项里带上-mcpu和-mthumb、链接时指定芯片对应的链接脚本。cmake_minimum_required(VERSION 3.16) project(STM32-RS485-Modbus C) set(CMAKE_C_STANDARD 11) include_directories( Core/Inc Drivers/STM32F1xx_HAL_Driver/Inc Drivers/CMSIS/Device/ST/STM32F1xx/Include Drivers/CMSIS/Include App ) add_executable(${PROJECT_NAME}.elf Core/Src/main.c Core/Src/gpio.c Core/Src/usart.c Core/Src/stm32f1xx_it.c App/modbus.c App/crc16.c ) target_compile_options(${PROJECT_NAME}.elf PRIVATE -mcpucortex-m3 -mthumb -Wall -fdata-sections -ffunction-sections ) target_link_options(${PROJECT_NAME}.elf PRIVATE -mcpucortex-m3 -mthumb -T STM32F103C8Tx_FLASH.ld -Wl,--gc-sections -Wl,-Mapoutput.map )链接脚本STM32F103C8Tx_FLASH.ld由CubeMX自动生成不要手动改Flash大小和RAM起始地址。C8T6的Flash是64KB选错型号或改错地址会导致烧录后程序跑飞。编译完成后在CLion里配置一个OpenOCD烧录器target选择stm32f1x连上ST-LINK就能一键烧录调试。3.3 代码模块划分各文件职责模块拆分的核心原则是“协议逻辑和硬件操作分离”。modbus.c只处理帧解析、功能码分发、响应组帧不关心数据是从USART还是从别的接口来的crc16.c只负责CRC计算真正碰寄存器和GPIO的代码只有main.c和usart.c。文件职责对外的接口main.c初始化外设、管理寄存器表、循环调用modbus_pollreg_table[]usart.c配置USART1中断、接收字节、调用modbus_rx_pushHAL_UART_RxCpltCallbackmodbus.c帧状态机、功能码03/06处理、异常响应modbus_poll / modbus_handle_framecrc16.c查表法CRC16计算modbus_crc16(buf, len)app_config.h从站地址、波特率、缓冲区大小等宏定义MODBUS_SLAVE_ADDR 等宏main.c里的主循环是一个轮询框架。由于Modbus帧到达是异步的接收靠中断处理靠主循环轮询所以主循环里不能有大的阻塞延时否则帧处理不及时。我一般只放一个modbus_poll()调用和一个LED翻转其余时间都是空闲等待。3.4 烧录调试OpenOCD配置用CLion调试STM32底层走的是OpenOCD。安装好OpenOCD后新建一个运行配置在Executable里选编译生成的.elf文件在Arguments里填烧录命令。我常用的OpenOCD命令是openocd -f interface/stlink.cfg -f target/stm32f1x.cfg -c program build/STM32-RS485-Modbus.elf verify reset exit这条命令会先用ST-LINK接口配置连接再加载STM32F1系列的目标配置把固件烧进去后自动复位并退出。第一次跑的时候如果提示找不到stlink.cfg多半是OpenOCD的scripts路径没配对在CLion的OpenOCD配置里把scripts目录指到OpenOCD安装路径下的share/openocd/scripts即可。4. Modbus核心实现收发状态机与CRC16校验逐段拆解4.1 接收状态机中断收字节、轮询判帧结束Modbus RTU的接收没有帧头帧尾标记只能靠字节间的时间间隔来切帧。我的做法是USART1中断每收到一个字节就调用modbus_rx_push()保存同时记录当前时间戳主循环里检查时间戳如果超过帧间隙3.5字符时间没有新字节就认为一帧收完了交给modbus_handle_frame()处理。void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { modbus_rx_push(rx_byte); HAL_UART_Receive_IT(huart1, rx_byte, 1); } } void modbus_rx_push(uint8_t byte) { /* 距上一字节超过帧间隙说明是新帧清空缓冲区 */ if (modbus_rx_len 0 (HAL_GetTick() - modbus_last_tick) MODBUS_FRAME_GAP_TICK) { modbus_rx_len 0; } if (modbus_rx_len MODBUS_RX_BUF_SIZE) { modbus_rx_buf[modbus_rx_len] byte; } modbus_last_tick HAL_GetTick(); modbus_waiting_frame 1; } void modbus_poll(void) { /* 主循环轮询帧结束后才处理 */ if (modbus_waiting_frame (HAL_GetTick() - modbus_last_tick) MODBUS_FRAME_GAP_TICK) { modbus_waiting_frame 0; modbus_handle_frame(modbus_rx_buf, modbus_rx_len); modbus_rx_len 0; } }这里的关键参数是MODBUS_FRAME_GAP_TICK它在app_config.h里按波特率换算得到。9600波特率下我把它定义为4ms如果波特率改成115200这个值要缩到1ms。注意一个细节HAL_UART_Receive_IT接收完一个字节后要立刻重新调用否则中断只会触发一次后续字节全丢。这段代码还有一个好处就是帧间隙判断用的是绝对时间戳主循环即使偶尔被其他任务卡住几十毫秒也不会误把一帧拆成两帧。4.2 CRC16计算查表法与逐位法Modbus的CRC16多项式是0x8005的反射形式实际计算用的异或多项式是0xA001初值为0xFFFF。查表法比逐位计算快而且代码可读性强。表生成和校验函数如下static uint16_t crc_table[256]; void crc16_table_init(void) { for (uint16_t i 0; i 256; i) { uint16_t crc i; for (uint8_t bit 0; bit 8; bit) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } crc_table[i] crc; } } uint16_t modbus_crc16(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { uint8_t index (crc ^ data[i]) 0xFF; crc (crc 8) ^ crc_table[index]; } return crc; }调用时需要注意modbus_crc16只对地址码到数据段计算不包含CRC本身。在功能码处理函数里接收方要先把收到的CRC拆出来再对前面的部分重新计算两者一致才算校验通过不一致就静默丢弃不返回任何错误码——这是Modbus的标准行为主站那边靠超时来判断通信异常。4.3 功能码处理03读保持寄存器与06写单个寄存器帧校验通过后接下来按功能码分发。03读保持寄存器的处理逻辑是把寄存器值按大端序填进响应包并重新计算CRC。06写单个寄存器则要先把写入值存进寄存器表再原样回显请求帧告诉主站“写成功了”。void modbus_handle_frame(uint8_t *buf, uint16_t len) { if (len 8) { return; /* 有效请求最短也是8字节 */ } uint16_t crc modbus_crc16(buf, len - 2); uint16_t crc_recv buf[len - 2] | (buf[len - 1] 8); if (crc ! crc_recv) { return; /* CRC失败静默丢弃 */ } if (buf[0] ! MODBUS_SLAVE_ADDR buf[0] ! 0) { return; /* 地址不匹配广播帧不回复 */ } switch (buf[1]) { case 0x03: modbus_read_holding_registers(buf, len); break; case 0x06: modbus_write_single_register(buf, len); break; default: modbus_send_exception(buf[0], 0x01); break; } } void modbus_read_holding_registers(uint8_t *buf, uint16_t len) { uint16_t start_addr (buf[2] 8) | buf[3]; uint16_t reg_num (buf[4] 8) | buf[5]; /* 03功能码单次最多读125个寄存器 */ if (reg_num 0 || reg_num 125) { modbus_send_exception(buf[0], 0x03); return; } if (start_addr reg_num REG_TABLE_SIZE) { modbus_send_exception(buf[0], 0x02); return; } uint8_t resp[256]; uint16_t idx 0; resp[idx] buf[0]; /* 从站地址 */ resp[idx] 0x03; /* 功能码 */ resp[idx] reg_num * 2; /* 字节数 */ for (uint16_t i 0; i reg_num; i) { resp[idx] (reg_table[start_addr i] 8) 0xFF; resp[idx] reg_table[start_addr i] 0xFF; } uint16_t crc modbus_crc16(resp, idx); resp[idx] crc 0xFF; /* CRC低字节在前 */ resp[idx] (crc 8) 0xFF; rs485_send(resp, idx); }06写单个寄存器的实现更简单把请求里的寄存器地址和值解析出来后直接写入reg_table数组对应位置然后把请求帧原样回发。要注意的是响应帧的CRC也要按完整请求帧计算。很多初学者在这块容易出错——把响应帧当成“只有地址和功能码”的短帧漏掉了数据段。4.4 方向切换用TC标志替代延时RS485半双工通信最关键的一步是方向切换。发送前把DE拉高发送完成后再拉低回到接收状态。问题在于“发送完成”这个时机不好判断HAL_UART_Transmit函数返回时移位寄存器里的最后一个字节可能还没发完这时候立刻拉低DE帧尾就会被截断。void rs485_send(uint8_t *data, uint16_t len) { HAL_GPIO_WritePin(RS485_DE_GPIO_Port, RS485_DE_Pin, GPIO_PIN_SET); HAL_UART_Transmit(huart1, data, len, 100); /* 等待发送完成标志确保最后一个字节完整发出 */ while (__HAL_UART_GET_FLAG(huart1, UART_FLAG_TC) RESET) { } HAL_GPIO_WritePin(RS485_DE_GPIO_Port, RS485_DE_Pin, GPIO_PIN_RESET); }这段代码里的while轮询就是正确姿势UART_FLAG_TC置位说明发送移位寄存器已经空了数据真正到了线上。如果不用TC标志而是靠delay波特率一变延时就要重调而且延时长影响吞吐延时短又会截断帧尾。这套工程里所有对外发送都走rs485_send功能码响应、异常响应、主站轮询通通复用这一个函数。5. RS485调试避坑收发切换、CRC失败与帧间隙排查记录5.1 现象回包总带最后一位杂波或者从站不回包原因分析发送最后一个字节后立刻拉低DE收发器方向切换时数据还在线上帧尾被硬生生切断主站收到的CRC校验不通过直接丢弃。解决办法改用UART_FLAG_TC标志判断发送完成后再拉低DE具体代码见4.4节。另外DE控制的GPIO不要用OD模式开漏输出推挽输出切换速度更快方向切换更干净。这个坑我在第一个版本踩过当时用delay(1ms)凑合了很久换个波特率就不稳定最后老老实实改成TC标志问题彻底消失。5.2 现象主站发请求一切正常但从站上电后第一次通信总是失败原因分析MCU复位瞬间RS485收发器处于接收态但此时总线电平还没稳定A、B线之间的毛刺会被当成一个字节收进来。如果这个毛刺刚好和后续请求的间隔小于3.5字符时间接收状态机就会把“毛刺 一帧”拼在一起导致CRC失败。解决办法上电初始化时先把DE置高几十毫秒让总线电平稳定后再放开。更稳妥的做法是在modbus_rx_push里做合法性过滤——帧长度小于8字节的丢弃地址不匹配的丢弃。校验失败的帧本来就会被静默丢弃所以只要毛刺不影响后续帧的完整性实际影响有限。5.3 现象CRC校验总是失败检查多项式也看不出问题原因分析这是新手最容易踩的坑。CRC计算的初始值是0xFFFF不是0x0000。查表法的表生成用的是0xA001反射多项式不是0x8005。发送顺序上CRC必须低字节在前、高字节在后。三个条件少一个主站那边校验就过不了。解决办法把modbus_crc16的初始值固定为0xFFFF组帧时按“crc 0xFF”先发低位再发高位。我建议协议栈里单独写一个自测函数用标准Modbus文档里的测试样例比如“01 03 00 00 00 04”的CRC是0x440A跑一遍确认结果一致后再往功能码方向走。这个自测函数在后续改协议时还能当回归测试用。5.4 现象用串口助手手动发十六进制帧从站没有响应原因分析串口助手下发的帧间隔往往小于3.5字符时间或者用户手动输入时字节间隔拖得太长。前者导致多帧被拼成一帧后者导致一帧被拆成多帧接收状态机都会判断出错。解决办法串口助手发送时在帧与帧之间至少留出10ms的静默期9600波特率下这个值足够宽裕。不要用“连续发送”模式去测Modbus每条请求之间都要有间隔。如果怀疑从站没进接收中断可以在串口助手里发“01 03 00 00 00 04 44 0A”这段标准帧这是03读请求的经典样例CRC固定不变方便快速定位问题是出在组帧还是出在收发方向。5.5 现象多从站组网时某台设备偶尔响应错乱或总线上出现撞包原因分析最常见的是从站地址重复或者主机在从站响应过程中又发了广播帧半双工总线上两个发送源碰上就会产生乱码。解决办法每个从站先确认地址唯一再确认从站的响应时间小于主机设置的超时时间。响应时间受代码里的组帧和发送循环影响如果某个从站初始化逻辑太重响应就会延迟。总线空载时用示波器抓A、B线波形看有没有两个低电平叠加的异常波形。组网规范上终端电阻只装总线两端各一个中间的节点不装。6. 从从站搬到主站一套框架的复用改造与扩展这套代码最值得复用的一点是把“协议解析”和“硬件收发”解耦了。从站改主站只加一个请求发起的接口CRC计算和RS485收发都能直接复用。把寄存器表从固定数组改成结构体再在枚举里定义寄存器地址索引二次开发时就不用反复数偏移量typedef enum { REG_TEMPERATURE 0, REG_HUMIDITY, REG_DEVICE_STATUS, REG_FW_VERSION, REG_TABLE_SIZE } reg_index_t; typedef struct { uint16_t temperature; /* 0.1℃ */ uint16_t humidity; /* 0.1% */ uint16_t status; /* bit0: 报警 */ uint16_t fw_version; } device_regs_t; static device_regs_t regs;当需要做数据采集网关时把从站角色换成主站就够。主站的轮询逻辑也很简单维护一张从站列表循环对每个从站发送03读请求收到响应就更新对应数据超时或CRC失败就重试或标记故障。typedef struct { uint8_t addr; uint16_t reg_addr; uint16_t reg_num; uint16_t *target; } poll_item_t; poll_item_t poll_list[] { {1, REG_TEMPERATURE, 1, shared_temperature}, {2, REG_TEMPERATURE, 1, shared_humidity}, }; void master_poll(poll_item_t *item) { uint8_t req[8]; req[0] item-addr; req[1] 0x03; req[2] (item-reg_addr 8) 0xFF; req[3] item-reg_addr 0xFF; req[4] 0x00; req[5] 0x01; uint16_t crc modbus_crc16(req, 6); req[6] crc 0xFF; req[7] (crc 8) 0xFF; rs485_send(req, 8); }当功能码需求变多比如要支持04读输入寄存器、05写单个线圈、16写多个寄存器自己维护状态机的成本就开始升高。那时候可以移植一个开源协议栈来替代手写的modbus.cF103的Flash和RAM都够。但我还是建议先在自写框架上把03、06跑通搞清楚状态机的边界条件和CRC的字节序再去用协议栈。我从一个量产项目里学到的教训是每个设备出厂前先跑一次CRC自测帧确认协议栈没被改坏。从那以后每次改动功能码或迁移到新主控我都强制走一遍自测帧和总线波形检查确认帧间隔、CRC字节序都对了再发布固件。希望帮到你。本文还有配套的精品资源点击获取