简介:面向 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的PA9(USART1_TX)和PA10(USART1_RX)就行。
收发器上有一个DE(Driver Enable)引脚和RE(Receiver 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到247,0是广播地址,广播帧从站接收但不回复。功能码决定操作类型,这套工程实现了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-7 | CRC16 | 0x440A | 低字节在前 |
帧与帧之间还有一个隐性的约束:Modbus RTU要求帧间隔不小于3.5个字符时间,否则接收方会把两帧当成一帧处理。在9600波特率下,一个字符约1.04ms,3.5个字符大约是3.65ms,实际工程里按4ms处理。这个参数对后面的接收状态机设计有直接影响,也是很多人在调试时踩坑的地方。
2.3 寄存器映射表:把传感器数据映射成Modbus地址
Modbus从站的核心是寄存器表。主站通过地址来读写数据,从站需要把硬件数据(温度、湿度、状态)映射到寄存器地址上。这套工程定义了4个保持寄存器,按最常见的工业采集场景设计:
| 寄存器地址 | 偏移 | 数据类型 | 含义 |
|---|---|---|---|
| 40001 | 0 | uint16_t | 温度,单位0.1℃ |
| 40002 | 1 | uint16_t | 湿度,单位0.1% |
| 40003 | 2 | uint16_t | 设备状态,bit0为报警标志 |
| 40004 | 3 | uint16_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 -mcpu=cortex-m3 -mthumb -Wall -fdata-sections -ffunction-sections ) target_link_options(${PROJECT_NAME}.elf PRIVATE -mcpu=cortex-m3 -mthumb -T STM32F103C8Tx_FLASH.ld -Wl,--gc-sections -Wl,-Map=output.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_poll | reg_table[] |
| usart.c | 配置USART1中断、接收字节、调用modbus_rx_push | HAL_UART_RxCpltCallback |
| modbus.c | 帧状态机、功能码03/06处理、异常响应 | modbus_poll / modbus_handle_frame |
| crc16.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.c,F103的Flash和RAM都够。但我还是建议先在自写框架上把03、06跑通,搞清楚状态机的边界条件和CRC的字节序,再去用协议栈。我从一个量产项目里学到的教训是:每个设备出厂前先跑一次CRC自测帧,确认协议栈没被改坏。从那以后,每次改动功能码或迁移到新主控,我都强制走一遍自测帧和总线波形检查,确认帧间隔、CRC字节序都对了再发布固件。希望帮到你。
本文还有配套的精品资源,点击获取