简介:本资源是一套基于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建立时间不足会导致驱动器未完全进入发送态,波形畸变引发从机误判起始位。
提示:用示波器抓CH1(TX)、CH2(DE)信号,观察DE上升沿是否早于TX首个下降沿至少1.5μs(SP3485 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 / (71+1) = 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立即输出高电平驱动DE;1us后自动归零。此方案将DE建立时间精度提升至±1个系统时钟周期(≈14ns),彻底规避软件延时抖动。
2.3 RE/DE引脚电平逻辑与硬件连接表
| 芯片引脚 | STM32 GPIO | 电平作用 | 推荐配置 |
|---|---|---|---|
| SP3485 DE | PA6 | 高电平发送,低电平接收 | 复用AF_PP,TIM3_CH1 |
| SP3485 RE | PA7 | 低电平接收,高电平禁止接收 | 普通推挽输出,初始拉低 |
// 初始化RE引脚(默认接收态) GPIOA->CRH &= ~(0xF << 28); GPIOA->CRH |= (0x1 << 28); // GPIO_Output_PP GPIOA->ODR &= ~GPIO_ODR_ODR7; // PA7=0 → RE=0(使能接收)注意:RE与DE为独立控制。DE=1时无论RE为何值,芯片均处于发送态;DE=0且RE=0时为接收态;DE=0且RE=1时为高阻态(总线释放)。务必避免DE=0且RE=0与DE=1同时存在——这会导致总线冲突。
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) / Baud(N为停止位,通常为1)T3.5 = 3.5 × (8 + 1 + N + 1) / Baud
例如9600bps、8N1:T3.5 = 3.5 × 10 / 9600 ≈ 3.646msT1.5 = 1.5 × 10 / 9600 ≈ 1.563ms
3.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.5=3646us // 此时DE已由TIM3自动拉低,RE保持接收态,总线空闲3.3 从机接收超时检测:用通用定时器捕获T1.5空闲
从机需在接收过程中持续监测字符间隔。若连续T1.5无新数据,则认为帧结束,启动CRC校验。推荐使用TIM2的输入捕获(IC)功能监听RX引脚电平跳变:
// TIM2 CH1(PA0)配置为输入捕获,检测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.5=1563us @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下降沿触发CC1IF,TIM2自动将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[i+1]; uint16_t len = 0; switch(func) { case 0x03: len = 2 + 2*rxActiveBuf[i+2] + 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[i+len-1] << 8) | rxActiveBuf[i+len-2]; if (crc_calc == crc_recv) { modbus_execute_function(addr, func, &rxActiveBuf[i+2], 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); // DE=1 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延时参数(72MHz) |
|---|---|---|---|
| 4800 | 3125 | 7292 | Delay_us(7292) |
| 9600 | 1563 | 3646 | Delay_us(3646) |
| 19200 | 781 | 1823 | Delay_us(1823) |
| 38400 | 391 | 911 | Delay_us(911) |
| 115200 | 130 | 304 | Delay_us(304) |
提示:实际应用中,建议T3.5延时值增加10%余量(如9600bps下设为4000us),以应对线路容性负载导致的信号边沿缓慢。
6. 串口调试助手验证技巧与RS485组网常见故障定位
6.1 用串口调试助手模拟主站:绕过STM32快速验证从机逻辑
当STM32从机代码烧录后无响应,先排除硬件问题:
- 将STM32的USART1_TX(PA9)与PC的USB转串口模块(如CH340)RX相连,STM32的USART1_RX(PA10)接PC TX;
- PC端打开“串口调试助手”,设置波特率、数据位等与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×2+9=521字节),否则循环模式下旧数据被覆盖; - 确认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的硬件资源足够支撑这一逻辑——只需在主循环中维护一个重试计数器和时间戳即可。
本文还有配套的精品资源,点击获取