
简介本资源是一套基于STM32平台实现Modbus-RTU通信的完整工程实践代码面向嵌入式初学者与工业通信开发人员解决RS485多节点主从通信系统的设计与调试难题。工程包含主机与从机双模式功能默认为主机轮询地址01从机通过4个物理按键可动态切换目标从机01–03或一键切换本设备为地址0x02的从机配合LED状态指示便于实时验证通信逻辑与协议响应。压缩包共162个文件以37个.h头文件和36个.c源文件构成核心驱动与应用层辅以.d依赖文件、.o目标文件、.axf/.hex可执行镜像及Keil工程配置uvprojx/uvoptx等整体大小2.84MB结构规范适合作为Modbus协议学习、RS485硬件接口调试及STM32外设协同开发的参考范例。目前已有917人学习下载代码注释清晰含串口收发、定时器精准延时、Modbus帧解析与校验等关键实现细节可直接编译运行并快速拓展至实际工业现场通信场景。1. STM32做MODBUS主从机不是接上线就通——RS485电平切换、定时器帧间隔、串口DMA搬运缺一不可很多刚上手工业通信的工程师以为STM32引脚连上RS485芯片比如SP3485串口发几个字节再用Modbus Poll一连就能读到寄存器数据。结果卡在“无响应”“超时”“校验错”三连击里反复调试三天。真相是MODBUS RTU协议对帧间空闲时间T1.5/T3.5有严格要求而STM32标准库或HAL库的串口发送完成中断TXE/TC无法精确控制这个毫秒级间隙RS485收发方向切换若靠软件延时极易因中断延迟导致首字节丢失更隐蔽的是当主站轮询多个从机或从机需响应多字节写入时串口接收缓冲区若未配DMA环形队列必然丢帧。本文聚焦STM32F103/F407等主流型号用滴答定时器SysTick精准生成T3.5空闲间隔用GPIO控制RE/DE引脚实现硬件级自动收发切换配合串口DMA双缓冲接收给出主机轮询与从机响应两套可直接烧录验证的C代码框架——不依赖任何第三方库所有配置参数均标注物理意义适配Keil MDK与STM32CubeIDE环境。2. RS485硬件电路与方向控制为什么必须用GPIO硬切而非软件延时2.1 RS485自动收发电路的核心矛盾驱动能力 vs 切换时序RS485是半双工总线同一时刻只能收或发。典型电路中SP3485等芯片的RE接收使能和DE发送使能引脚共用一个STM32 GPIO。常见错误做法是发送前置高DE发送完成后延时几微秒再拉低DE并置高RE。问题在于——ARM Cortex-M内核执行GPIO_ResetBits()指令需3~5个周期若系统主频72MHz单条指令耗时约42ns但实际延时函数如Delay_us(10)受编译器优化、中断嵌套影响误差可达1~2μs。而MODBUS RTU规定帧间最小空闲时间T3.5 3.5 × (8 1 N 1) / 波特率N为数据位通常为0。以9600bps、8N1为例T3.5 ≈ 3.5 × 10 / 9600 ≈ 3.65ms。若方向切换误差超±100μs虽不影响T3.5但首字节发送时DE建立时间不足会导致驱动器未完全进入发送态波形畸变引发从机误判起始位。提示用示波器抓CH1TX、CH2DE信号观察DE上升沿是否早于TX首个下降沿至少1.5μsSP3485 datasheet要求。实测发现HAL库HAL_UART_Transmit()后直接操作GPIO常出现DE滞后TX 2~3μs。2.2 推荐方案TIMx输出比较通道触发方向切换放弃软件延时改用定时器输出比较OC功能在串口发送完成瞬间TC中断启动一个单脉冲定时器其CC1通道直连DE引脚// 以TIM3为例PA6复用为TIM3_CH1同时配置为DE控制引脚 void MODBUS_RS485_Init(void) { RCC-APB1ENR | RCC_APB1ENR_TIM3EN; // 使能TIM3时钟 RCC-APB2ENR | RCC_APB2ENR_IOPAEN; // 使能GPIOA时钟 // PA6配置为复用推挽输出TIM3_CH1 GPIOA-CRL ~(0xF 24); GPIOA-CRL | (0x2 24); // AF_PP, 50MHz TIM3-PSC 71; // 72MHz / (711) 1MHz计数频率 TIM3-ARR 1; // 单脉冲宽度1us满足DE建立时间 TIM3-CCMR1 | TIM_CCMR1_OC1M_2 | TIM_CCMR1_OC1M_1; // PWM模式1 TIM3-CCER | TIM_CCER_CC1E; // 使能CH1输出 TIM3-CR1 | TIM_CR1_ARPE; // 预装载使能 }关键逻辑串口发送完成中断USART1_IRQHandler中检测USART_SR_TC触发TIM3-EGR TIM_EGR_UG强制更新影子寄存器CH1立即输出高电平驱动DE1us后自动归零。此方案将DE建立时间精度提升至±1个系统时钟周期≈14ns彻底规避软件延时抖动。2.3 RE/DE引脚电平逻辑与硬件连接表芯片引脚STM32 GPIO电平作用推荐配置SP3485 DEPA6高电平发送低电平接收复用AF_PPTIM3_CH1SP3485 REPA7低电平接收高电平禁止接收普通推挽输出初始拉低// 初始化RE引脚默认接收态 GPIOA-CRH ~(0xF 28); GPIOA-CRH | (0x1 28); // GPIO_Output_PP GPIOA-ODR ~GPIO_ODR_ODR7; // PA70 → RE0使能接收注意RE与DE为独立控制。DE1时无论RE为何值芯片均处于发送态DE0且RE0时为接收态DE0且RE1时为高阻态总线释放。务必避免DE0且RE0与DE1同时存在——这会导致总线冲突。3. MODBUS RTU帧结构解析与STM32定时器精准空闲间隔实现3.1 T1.5与T3.5的物理意义及计算公式MODBUS RTU帧格式为[Address][Function][Data...][CRC16]。帧间必须保持空闲状态否则从机无法识别新帧起始。标准定义T1.5字符间最大空闲时间用于判断帧结束从机据此关闭接收超时T3.5帧间最小空闲时间主站发送完一帧后必须等待≥T3.5才发下一帧计算公式以波特率Baud为准T1.5 1.5 × (8 1 N 1) / BaudN为停止位通常为1T3.5 3.5 × (8 1 N 1) / Baud例如9600bps、8N1T3.5 3.5 × 10 / 9600 ≈ 3.646msT1.5 1.5 × 10 / 9600 ≈ 1.563ms3.2 滴答定时器SysTick实现μs级T3.5等待HAL库的HAL_Delay()最小分辨率为1ms无法满足T3.5的亚毫秒精度。改用SysTick直接计数// SysTick初始化系统时钟72MHz void SysTick_Init_Us(void) { if (SysTick_Config(SystemCoreClock / 1000000)) { // 1us中断 while (1); } } volatile uint32_t usTicks 0; void SysTick_Handler(void) { usTicks; } // 精确延时usCount微秒 void Delay_us(uint32_t usCount) { uint32_t start usTicks; while ((usTicks - start) usCount); }主机发送流程中插入T3.5等待// 主机发送完整MODBUS帧后 HAL_UART_Transmit(huart1, txBuffer, txLen, 100); Delay_us(3646); // 9600bps下T3.53646us // 此时DE已由TIM3自动拉低RE保持接收态总线空闲3.3 从机接收超时检测用通用定时器捕获T1.5空闲从机需在接收过程中持续监测字符间隔。若连续T1.5无新数据则认为帧结束启动CRC校验。推荐使用TIM2的输入捕获IC功能监听RX引脚电平跳变// TIM2 CH1PA0配置为输入捕获检测RX下降沿起始位 void MODBUS_Slave_Timer_Init(void) { RCC-APB1ENR | RCC_APB1ENR_TIM2EN; RCC-APB2ENR | RCC_APB2ENR_IOPAEN; GPIOA-CRL ~(0xF 0); GPIOA-CRL | (0x4 0); // AF_PP for USART1_RX TIM2-PSC 71; // 1MHz计数 TIM2-ARR 1563; // T1.51563us 9600bps TIM2-CCMR1 | TIM_CCMR1_IC1F_1 | TIM_CCMR1_IC1F_0; // 滤波4个采样周期 TIM2-CCER | TIM_CCER_CC1E; // IC1使能 TIM2-DIER | TIM_DIER_CC1IE; // 捕获中断 TIM2-CR1 | TIM_CR1_CEN; // 启动计数 } // 在TIM2_IRQHandler中处理 void TIM2_IRQHandler(void) { if (TIM2-SR TIM_SR_CC1IF) { TIM2-SR ~TIM_SR_CC1IF; // 清除计数器重新开始T1.5倒计时 TIM2-CNT 0; // 若本次捕获距上次1563us说明帧结束 if (TIM2-CCR1 1563) { modbus_frame_complete(); // 解析已接收数据 } } }提示此处利用TIM2的输入捕获自动重载特性无需手动清零CNT。每次RX下降沿触发CC1IFTIM2自动将CNT值存入CCR1并清零CNT——因此CCR1值即为上一字符到当前字符的时间间隔。4. 串口DMA双缓冲接收与MODBUS帧解析状态机4.1 为何必须用DMA双缓冲传统中断接收的致命缺陷假设从机需响应主机读保持寄存器0x03请求返回10个寄存器值20字节数据2字节CRC共25字节帧长。若用UART RXNE中断逐字节接收每字节触发一次中断CPU频繁进出中断上下文中断服务程序中需判断帧头地址字节、累加长度、校验CRC逻辑复杂当主站高速轮询如100ms间隔时若某次中断处理超时后续字节被覆盖在USART_RDR寄存器中造成丢帧。DMA双缓冲方案配置USART1_RX DMA为循环模式两个缓冲区rxBufA[64], rxBufB[64]交替填充。当DMA填满rxBufA时自动切换至rxBufB并触发传输完成中断TC此时rxBufA中已存满一帧或部分帧数据交由主循环解析。4.2 DMA初始化与缓冲区管理代码#define MODBUS_RX_BUF_SIZE 64 uint8_t rxBufA[MODBUS_RX_BUF_SIZE]; uint8_t rxBufB[MODBUS_RX_BUF_SIZE]; uint8_t *rxActiveBuf rxBufA; uint16_t rxIndex 0; void MX_USART1_UART_DMA_Init(void) { __HAL_RCC_DMA1_CLK_ENABLE(); hdma_usart1_rx.Instance DMA1_Channel5; hdma_usart1_rx.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_usart1_rx.Init.PeriphInc DMA_PINC_DISABLE; hdma_usart1_rx.Init.MemInc DMA_MINC_ENABLE; hdma_usart1_rx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_usart1_rx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_usart1_rx.Init.Mode DMA_CIRCULAR; // 关键循环模式 hdma_usart1_rx.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_usart1_rx); __HAL_LINKDMA(huart1, hdmarx, hdma_usart1_rx); // 启动DMA双缓冲 HAL_UART_Receive_DMA(huart1, rxBufA, MODBUS_RX_BUF_SIZE); HAL_UARTEx_ReceiveToIdle_DMA(huart1, rxBufB, MODBUS_RX_BUF_SIZE); __HAL_DMA_ENABLE_IT(hdma_usart1_rx, DMA_IT_TC); // 传输完成中断 }4.3 帧解析状态机从原始字节流到功能码执行DMA传输完成中断中需判断当前填充的是哪个缓冲区并移交解析void DMA1_Channel5_IRQHandler(void) { HAL_DMA_IRQHandler(hdma_usart1_rx); } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { // 切换活动缓冲区指针 if (rxActiveBuf rxBufA) { rxActiveBuf rxBufB; rxIndex MODBUS_RX_BUF_SIZE; } else { rxActiveBuf rxBufA; rxIndex MODBUS_RX_BUF_SIZE; } // 触发解析任务避免在中断中处理复杂逻辑 parse_modbus_frame_flag 1; } } // 主循环中调用 void MODBUS_Parse_Task(void) { if (parse_modbus_frame_flag) { parse_modbus_frame_flag 0; // 从rxActiveBuf中搜索MODBUS帧头地址字节0x01~0xFF for (uint16_t i 0; i rxIndex; i) { if (rxActiveBuf[i] 0x01 rxActiveBuf[i] 0xF7) { uint8_t addr rxActiveBuf[i]; uint8_t func rxActiveBuf[i1]; uint16_t len 0; switch(func) { case 0x03: len 2 2*rxActiveBuf[i2] 2; break; // 读保持寄存器 case 0x10: len 6; break; // 写多个寄存器 default: continue; } if (i len rxIndex) { uint16_t crc_calc modbus_crc16(rxActiveBuf[i], len-2); uint16_t crc_recv (rxActiveBuf[ilen-1] 8) | rxActiveBuf[ilen-2]; if (crc_calc crc_recv) { modbus_execute_function(addr, func, rxActiveBuf[i2], len-2); break; // 找到有效帧即退出 } } } } } }注意modbus_crc16()需按MODBUS标准实现——先预置0xFFFF对每个字节异或后移位最终取反。网上常见错误是未取反或字节序颠倒。5. 主机轮询与从机响应代码框架可直接编译烧录的最小可行实现5.1 主机发送帧构造函数支持0x03/0x10功能码// 主机向从机0x01读取10个保持寄存器地址0x0000开始 uint8_t modbus_master_tx_buffer[256]; uint16_t modbus_master_tx_len 0; void MODBUS_Master_ReadHoldingRegisters(uint8_t slaveAddr, uint16_t startAddr, uint16_t regNum) { uint16_t crc; modbus_master_tx_len 8; // [addr][0x03][startH][startL][numH][numL][crcL][crcH] modbus_master_tx_buffer[0] slaveAddr; modbus_master_tx_buffer[1] 0x03; modbus_master_tx_buffer[2] (startAddr 8) 0xFF; modbus_master_tx_buffer[3] startAddr 0xFF; modbus_master_tx_buffer[4] (regNum 8) 0xFF; modbus_master_tx_buffer[5] regNum 0xFF; crc modbus_crc16(modbus_master_tx_buffer, 6); modbus_master_tx_buffer[6] crc 0xFF; modbus_master_tx_buffer[7] (crc 8) 0xFF; } // 使用示例每200ms轮询一次 void main_loop(void) { static uint32_t last_poll 0; if (HAL_GetTick() - last_poll 200) { last_poll HAL_GetTick(); MODBUS_Master_ReadHoldingRegisters(0x01, 0x0000, 10); HAL_UART_Transmit(huart1, modbus_master_tx_buffer, modbus_master_tx_len, 100); Delay_us(3646); // T3.5 wait } }5.2 从机功能码执行函数0x03读保持寄存器// 模拟保持寄存器数组实际项目中映射到Flash或RAM uint16_t holding_registers[100] {0}; void modbus_execute_function(uint8_t addr, uint8_t func, uint8_t *data, uint16_t len) { uint8_t tx_buf[256]; uint16_t tx_len 0; uint16_t crc; switch(func) { case 0x03: // 读保持寄存器 uint16_t start_addr (data[0] 8) | data[1]; uint16_t reg_num (data[2] 8) | data[3]; if (start_addr reg_num 100) return; // 地址越界 tx_len 3 2 * reg_num; // [addr][0x03][byte_count][data...][crc] tx_buf[0] addr; tx_buf[1] 0x03; tx_buf[2] 2 * reg_num; // byte count for (uint16_t i 0; i reg_num; i) { tx_buf[3 2*i] (holding_registers[start_addr i] 8) 0xFF; tx_buf[3 2*i 1] holding_registers[start_addr i] 0xFF; } crc modbus_crc16(tx_buf, tx_len - 2); tx_buf[tx_len - 2] crc 0xFF; tx_buf[tx_len - 1] (crc 8) 0xFF; // 发送响应帧注意此时DE需置高 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_6, GPIO_PIN_SET); // DE1 HAL_UART_Transmit(huart1, tx_buf, tx_len, 100); Delay_us(1); // 确保DE建立 break; case 0x10: // 写多个寄存器略逻辑类似 break; } }5.3 关键参数速查表不同波特率下的T1.5/T3.5值波特率T1.5 (μs)T3.5 (μs)SysTick延时参数72MHz480031257292Delay_us(7292)960015633646Delay_us(3646)192007811823Delay_us(1823)38400391911Delay_us(911)115200130304Delay_us(304)提示实际应用中建议T3.5延时值增加10%余量如9600bps下设为4000us以应对线路容性负载导致的信号边沿缓慢。6. 串口调试助手验证技巧与RS485组网常见故障定位6.1 用串口调试助手模拟主站绕过STM32快速验证从机逻辑当STM32从机代码烧录后无响应先排除硬件问题将STM32的USART1_TXPA9与PC的USB转串口模块如CH340RX相连STM32的USART1_RXPA10接PC TXPC端打开“串口调试助手”设置波特率、数据位等与STM32一致手动发送十六进制帧01 03 00 00 00 0A C4 0B从机0x01读10个寄存器若STM32正确响应01 03 14 00 00 00 00 ... 3C 2D说明从机软件逻辑正常问题在RS485硬件或主站代码。6.2 RS485组网终端电阻与接地干扰排查表现象可能原因验证方法解决方案单节点通信正常多节点丢帧总线未加120Ω终端电阻用万用表测A-B间电阻空载应≈无穷大两端各接120Ω后≈60Ω在总线最远两端各并联120Ω电阻通信距离超过300米失败信号反射叠加噪声示波器看A-B差分波形过冲1V或振铃严重增加终端电阻降低波特率≤9600bps检查双绞线屏蔽层单点接地某些从机偶发无响应从机电源地与RS485地未共地用万用表测各从机GND与主站GND间电压0.5V即异常增加隔离DC-DC模块确保所有设备共地非浮地6.3 STM32串口DMA接收丢帧的终极诊断法若DMA接收仍丢帧执行以下三步检查DMA缓冲区大小MODBUS_RX_BUF_SIZE必须 ≥ 最长可能帧长如0x10功能码最大写256寄存器→ 256×29521字节否则循环模式下旧数据被覆盖确认DMA优先级在MX_DMA_Init()中将hdma_usart1_rx.Init.Priority设为DMA_PRIORITY_VERY_HIGH避免被其他DMA抢占验证中断抢占在stm32f1xx_it.c中检查DMA1_Channel5_IRQHandler是否被更高优先级中断如SysTick阻塞——临时注释掉HAL_IncTick()调用观察丢帧是否消失。最后提醒MODBUS协议本身不提供重传机制工业现场务必在应用层添加超时重试如主站发送后等待500ms无响应则重发最多3次。而STM32的硬件资源足够支撑这一逻辑——只需在主循环中维护一个重试计数器和时间戳即可。本文还有配套的精品资源点击获取