简介:一份STM32与RS485总线结合Modbus-RTU协议的主从机通信代码包,面向嵌入式开发学习者,解决多设备总线通信与主从角色切换问题。整个压缩包共162个文件,约3.02MB,核心源码包括37个.h头文件与36个.c源文件,另有Keil工程配置文件、编译生成的目标文件与调试辅助文件,便于直接打开工程阅读或重新编译验证。功能上,设备上电默认作为主机,可主动寻址01号从机获取数据;通过不同按键可切换访问01、02、03号从机,第四个按键则从主机切换为地址0x02的从机,并以LED状态直观反映当前工作模式。已有16231人学习下载,适合需要快速搭建STM32 Modbus通信或理解RTU主从机交互流程的读者参考。整个工程结构清晰,代码含注释与底层配置,可帮助理解USART中断、定时器以及RS485收发控制等要点。
1. STM32+RS485+MODBUS-RTU主从机代码:串口中断和定时器怎么配合,才是这套资源真正值钱的地方
之前帮某实验室调一套多节点采集系统,STM32做主机、三个从机挂在同一根RS485总线上,现象是上位机轮询时收包时好时坏,从机偶发不响应,主机超时重发又跟正常轮询撞在一起,总线直接被占死。排查到最后发现根子不在CRC,也不在功能码,而是MODBUS-RTU的帧结束判断——这个协议没有帧头帧尾,靠的是字节间的静默时间来区分一帧。这套资源把串口中断接收、定时器超时判定、主机轮询状态机、从机寄存器应答全部写好了,拿到就能跑通一条“主机发请求、从机回响应、上位机读数据”的完整链路。适合正在做多从机采集、PLC通信对接,以及想直接抄一套可运行主从机框架的开发者。
2. RS485与MODBUS-RTU的关键点:从电气接法到3.5字符间隔定时
2.1 RS485二线制接线与方向控制
RS485是差分传输,A、B两根信号线的相对电压决定逻辑电平,抗共模干扰能力强,传输距离在1200米左右,工业现场绝大多数走的是这个物理层。二线制接法下,所有设备A-A并联、B-B并联,接到同一根总线上,半双工通信。主机发送时占用总线,从机只能被动接收;从机需要回应时,主机会把发送状态切回接收。
具体到STM32上的RS485接口,常见做法是接一个RS485收发器芯片,它的DE(发送使能)和RE(接收使能)引脚通常合并成一个方向控制脚:高电平进入发送模式,低电平进入接收模式。这个控制脚要接到STM32的一个普通GPIO上,不能接在串口TX/RX上。很多初学者直接把DE接地,导致发送永远发不出去,这是第一层坑。
关键点是:方向切换不是瞬间完成的。收发器从发送切到接收有一个响应时间,发送完最后一字节后必须等移位寄存器把数据完全送出,再留一点余量,才能把DE拉低。否则最后一两个字节会被截断,从机收到的帧不完整,CRC校验直接失败。
在STM32这一侧,DE/RE我一般用PB1控制,串口用USART2的PA2(TX)和PA3(RX),这样GPIO和串口引脚不冲突。如果是做主机,发送完成后还需要保留一小段延时再切回接收;如果是做从机,平时一直处于接收态,只有回复时才短暂切到发送态。
2.2 MODBUS-RTU帧格式与CRC16排布
MODBUS-RTU的帧结构非常紧凑,从机地址加功能码加数据加CRC校验,最小帧才8个字节左右。典型的主机读保持寄存器请求是:
01 03 00 00 00 02 C4 0B前三个字节是功能描述,地址1、功能码3,后面是要读的寄存器起始地址和数量,最后两个字节是CRC16。从机正常响应是:
01 03 04 00 05 00 0A 7A 25功能码3后面跟的是字节数、具体寄存器值,最后两位依旧是CRC。这里最容易翻车的点是CRC的发送顺序:MODBUS-RTU规定CRC16低字节在前、高字节在后,也就是计算出的16位值先发低8位再发高8位。很多上位机调试工具显示时默认高字节在前,你照着工具显示去手拼报文,发出去从机一定回异常码。
CRC16-MODBUS的多项式是0xA001(反向),初始值是0xFFFF。实际工程里不需要理解数学原理,直接用查表法或者按位计算就行。按位计算在72MHz主频下耗时很短,几百字节的数据都毫无压力,不需要上查表。
uint16_t Modbus_CRC16(uint8_t *pData, uint16_t len) { uint16_t crc = 0xFFFF; while (len--) { crc ^= *pData++; for (uint8_t i = 0; i < 8; i++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; }这个函数返回的CRC是标准MODBUS布局,发送帧时按低字节在前、高字节在后填入。代码逻辑很直白:对每个字节先异或进CRC低位,再循环右移8次,遇到移出的位为1就异或多项式。参数上要注意pData指针必须指向完整数据段,长度要包含地址、功能码和所有数据部分,千万不要把CRC本身算进去。
2.3 T35定时计算:帧结束为什么不能靠固定长度
MODBUS-RTU规定帧与帧之间要有至少3.5个字符时间的静默间隔,接收方以这个间隔来判断一帧数据是否结束。这个机制是整个协议最容易被轻视的地方,用错之后表现非常诡异:有时候一帧被拆成两段处理,有时候两帧被粘成一个包。
3.5字符时间的计算公式是:
T35 = 3.5 × 11 / 波特率这里用11是因为一个字节在串口上实际传输需要1个起始位加8个数据位加1个停止位(无校验),如果使能校验则再加1位。以9600波特率计算,T35约等于3.85ms;如果是19200,则变成约1.92ms。
把T35映射到MCU上,最常见的做法是用一个定时器做基准,串口每收到一个字节就清零计数器,定时器中断里判断计数器值是否超过T35阈值。这里有个工程细节很多人没意识到:如果定时器时基是1ms,在9600波特率下阈值是3.85ms,取整成4ms后实际容忍度已经偏差了约4%,波特率稍有波动就可能在临界点误判。所以我建议把时基做到0.1ms,把阈值用整数40来表示,余量留得更足。
3. 串口与定时器配置实战:0.1ms时基下的帧超时检测
3.1 串口初始化:引脚复用、9600波特率与中断优先级
从机接收报文时,串口配置有一个比较关键的点:收发模式要设为半双工方向可控,但串口本身仍然配置成全双工模式,方向切换由GPIO控制。主机和从机的串口初始化代码几乎完全相同,下面是以某系列STM32标准外设库的写法。
void USART2_Init(void) { GPIO_InitTypeDef gpio; USART_InitTypeDef usart; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); gpio.GPIO_Pin = GPIO_Pin_2; gpio.GPIO_Speed = GPIO_Speed_50MHz; gpio.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_Init(GPIOA, &gpio); gpio.GPIO_Pin = GPIO_Pin_3; gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &gpio); usart.USART_BaudRate = 9600; usart.USART_WordLength = USART_WordLength_8b; usart.USART_StopBits = USART_StopBits_1; usart.USART_Parity = USART_Parity_No; usart.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; usart.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_Init(USART2, &usart); USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); NVIC_InitTypeDef nvic; nvic.NVIC_IRQChannel = USART2_IRQn; nvic.NVIC_IRQChannelPreemptionPriority = 1; nvic.NVIC_IRQChannelSubPriority = 1; nvic.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&nvic); USART_Cmd(USART2, ENABLE); }这段代码的关键参数是波特率9600、8数据位、1停止位、无校验,这是工业上最常见的配置组合,绝大多数上位机和PLC默认就是这个。PA2配置为复用推挽输出用于TX,PA3配置为浮空输入用于RX。如果实际接线中有外加上拉电阻,输入模式可以改成带上拉输入,但浮空输入在大多数RS485收发器上工作正常。
中断优先级的分配上,我一般把串口中断优先级放到1,定时器中断放到2,保证串口收到字节时能立刻处理的优先级更高。因为帧结束判断依赖定时器,如果定时器先抢占了串口中断,极端情况下可能延迟接收,在高波特率下丢字节,所以这个优先级顺序不要轻易调换。
3.2 定时器6配置:从1ms改为0.1ms的精度考量
帧超时检测的定时器,我习惯用基本定时器TIM6,因为它没有外部引脚、配置简单,不会跟PWM等功能打架。这里最关键的是时基选择,前面说过9600波特率下T35约3.85ms,如果用1ms时基,阈值在3到4之间取整,误差太大。实际工程中我会把定时器设为0.1ms一次中断,也就是10kHz的频率。
void TIM6_Init(void) { TIM_TimeBaseInitTypeDef tim; NVIC_InitTypeDef nvic; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM6, ENABLE); tim.TIM_Prescaler = 72 - 1; tim.TIM_Period = 100 - 1; tim.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM6, &tim); TIM_ITConfig(TIM6, TIM_IT_Update, ENABLE); nvic.NVIC_IRQChannel = TIM6_IRQn; nvic.NVIC_IRQChannelPreemptionPriority = 2; nvic.NVIC_IRQChannelSubPriority = 0; nvic.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&nvic); TIM_Cmd(TIM6, ENABLE); }预分频72-1是把72MHz主频分到1MHz,周期100-1让计数器数100次后溢出,所以溢出频率是1MHz除以100等于10kHz,即每次中断间隔0.1ms。阈值计算此时就非常直观:T35约3.85ms,在0.1ms时基下等于38.5个计数,我取整到40,也就是4ms。这样不仅精度够,而且容忍了一点点波特率偏差,实测误判概率比1ms时基低很多。
你可能想问为什么不直接再用一个外部晶振喂给串口的RTO硬件功能,有些STM32型号的串口本身就带接收超时中断。但考虑到代码要兼容多型号芯片,通用定时器方案移植性更好,这也是这套代码能在不同板子上快速落地的原因。逻辑不变,换型号时只需要改预分频和周期两个宏。如果你用的芯片串口有硬件RTO,可以保留这套代码的结构,只把超时判断改成RTO中断,逻辑更简洁,但前提是确认芯片型号支持。
3.3 接收中断与定时中断配合:完整收帧流程
帧超时检测的完整配合逻辑是:串口收到一个字节就写进接收缓冲区,同时把一个全局计数变量清零;定时器每次中断把该计数变量加一,一旦计数变量超过40,说明已经有4ms没有新字节到来,认为一帧数据接收完毕。
volatile uint8_t rxBuf[256]; volatile uint16_t rxCnt = 0; volatile uint16_t frameLen = 0; volatile uint8_t frameReady = 0; volatile uint16_t idleCnt = 0; void USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_RXNE) != RESET) { if (rxCnt < sizeof(rxBuf)) { rxBuf[rxCnt++] = USART_ReceiveData(USART2); } idleCnt = 0; USART_ClearITPendingBit(USART2, USART_IT_RXNE); } } void TIM6_IRQHandler(void) { if (TIM_GetITStatus(TIM6, TIM_IT_Update) != RESET) { if (rxCnt > 0) { if (++idleCnt >= 40) { frameLen = rxCnt; rxCnt = 0; idleCnt = 0; frameReady = 1; } } TIM_ClearITPendingBit(TIM6, TIM_IT_Update); } }这段代码需要注意的一个细节是rxCnt在串口中断和定时器中断中都被操作,但同一时刻只有串口中断会写rxCnt、定时器中断只读rxCnt并清零,所以不存在竞争风险。第二个细节是定时器只在rxCnt大于0时累加idleCnt,如果总线空闲就完全不计数,避免单片机长期空转中断。
帧接收完成后,主循环里只要发现frameReady标志置位,就把接收缓冲区和帧长度交给协议处理函数,解析地址、功能码、CRC,随后清掉frameReady。这个流程把不定长帧的接收问题拆解成了“字节堆积”加“超时定界”两个独立逻辑,比用固定长度硬拆帧要稳健得多。实践后发现,在9600波特率下即使从机响应时间抖动到几十毫秒,这套机制也能稳定区分每一帧边界。
4. 主从机代码实现:主机轮询状态机与从机寄存器应答
4.1 主机状态机设计:IDLE到WAIT_RESP再到超时重试
主机最关键的设计是轮询状态机。很多入门代码用阻塞式发送加Delay等待从机响应,这在单从机时勉强能用,一旦挂上三五个从机,某个从机不响应时整个总线被卡住,后面的从机全部饿死。这套代码把主机逻辑拆成IDLE、WAIT_RESP、RETRY三个阶段,用一个轮询表驱动。
typedef struct { uint8_t slaveAddr; uint16_t regAddr; uint16_t regCount; uint8_t retryCnt; } PollItem; PollItem pollTable[] = { {0x01, 0x0000, 2, 0}, {0x02, 0x0000, 2, 0}, {0x03, 0x0000, 2, 0}, }; #define POLL_TABLE_SIZE (sizeof(pollTable) / sizeof(pollTable[0])) #define MAX_RETRY_COUNT 3 volatile uint8_t hostState = 0; volatile uint8_t currentIdx = 0; volatile uint16_t hostTimeoutCnt = 0;状态定义我习惯用宏或枚举:0是IDLE,1是WAIT_RESP,2是RETRY。主机从IDLE取出当前轮询项,组帧并发送,然后把状态切到WAIT_RESP,同时启动一个发送后等待超时的计数器。正常收到从机响应且CRC校验通过,就将状态切回IDLE,currentIdx加一,取下一个从机的轮询项。如果超时计数到了还没收到帧,retryCnt加一,重发当前帧;重试次数超过3次就放弃当前从机,跳到下一个。
这里要特别注意:超时时间不能设成固定值,应该根据波特率合理估算。9600波特率下从机正常响应大概在5到20ms,我一般把等待超时设为50ms。如果设成10ms,从机稍微忙一点就误判超时,总线会频繁重发;设成500ms又会让故障从机拖垮整个轮询周期。
4.2 主机打包:03/06功能码和CRC计算函数
主机发送的数据帧用数组拼出来,不能直接发结构体指针,因为MODBUS帧是紧凑字节流,结构体存在对齐填充会破坏帧格式。03功能码是读保持寄存器,06功能码是写单个寄存器,两个是调试中最常用的。
void Host_BuildReadFrame(uint8_t slaveAddr, uint16_t regAddr, uint16_t regCount, uint8_t *frame) { frame[0] = slaveAddr; frame[1] = 0x03; frame[2] = (uint8_t)(regAddr >> 8); frame[3] = (uint8_t)(regAddr & 0xFF); frame[4] = (uint8_t)(regCount >> 8); frame[5] = (uint8_t)(regCount & 0xFF); uint16_t crc = Modbus_CRC16(frame, 6); frame[6] = (uint8_t)(crc & 0xFF); frame[7] = (uint8_t)(crc >> 8); }寄存器地址和数量都是16位,在帧里必须高字节在前,这是MODBUS协议的重要组成部分。需要注意的是帧的CRC计算长度是6,不包含CRC本身,然后把计算结果的低字节放到第6位、高字节放到第7位。这个顺序写反是新手最容易犯的错误,我在调试时曾经对着逻辑分析仪抓出来的波形看了半天才发现高字节低字节反了。
发送流程是这样的:先把DE引脚拉高,延时几百微秒等收发器切换完成,然后调用串口发送函数把8个字节发出去。发送完成后不能立刻拉低DE,要等停止位移位完毕,实际工程中我会在发送完成中断里加一个短延时再拉低DE。有些收发器的切换时间是几百纳秒,但保险起见,我会用定时器延时0.5ms到1ms再切回接收态。
4.3 从机状态机与寄存器读写
从机的核心是一个更紧凑的状态机:从IDLE进入接收态,接收完一帧后校验地址、CRC,再执行功能码对应操作,最后组帧回复。整个过程跑在主循环里,中断只负责收帧和通知,不处理业务逻辑,这样从机响应时间可控,不会在中断里做耗时操作。
#define S_IDLE 0 #define S_RECV 1 #define S_PROCESS 2 #define S_SEND 3 volatile uint8_t slaveState = S_IDLE; void Slave_Task(void) { if (frameReady) { frameReady = 0; slaveState = S_PROCESS; } if (slaveState == S_PROCESS) { uint8_t *frame = (uint8_t *)rxBuf; if (frame[0] == MY_ADDR || frame[0] == 0xFF) { uint16_t crcRecv = frame[frameLen - 2] | (frame[frameLen - 1] << 8); uint16_t crcCalc = Modbus_CRC16(frame, frameLen - 2); if (crcRecv == crcCalc) { Slave_HandleFrame(frame, frameLen); } } slaveState = S_IDLE; } }从机地址匹配时除了自己的地址,还要兼容广播地址0xFF。广播帧的规则是只接收、不回复,这个分支一定要处理,否则主机发广播时总线上多个从机同时回复,直接总线冲突。CRC计算时长度要减掉2个字节,计算范围是地址、功能码和数据部分,这个细节和主机打包时保持一致。
从机的寄存器一般用一个数组模拟,比如定义10个保持寄存器的uint16_t数组,地址从0x0000到0x0009。功能码03就是读取数组中的一段,功能码06是修改数组中的某一个。从机内部不关心数据含义,只负责读写寄存器数组,业务逻辑交给上层去映射。
4.4 异常响应与地址冲突处理
从机收到无法处理的请求时,要返回异常响应而不是沉默。MODBUS规定异常帧的功能码是原功能码最高位置1,例如03变成0x83,数据区放一个异常码。工程上最常用的三个异常码是01表示非法功能、02表示非法数据地址、03表示非法数据值。
void Slave_SendException(uint8_t funcCode, uint8_t errCode) { uint8_t frame[5]; frame[0] = MY_ADDR; frame[1] = funcCode | 0x80; frame[2] = errCode; uint16_t crc = Modbus_CRC16(frame, 3); frame[3] = (uint8_t)(crc & 0xFF); frame[4] = (uint8_t)(crc >> 8); RS485_SendFrame(frame, 5); }地址冲突是现场多个从机最容易踩的坑。两个从机设置成同一地址,轮询一个地址时两个从机同时应答,主机收到的数据会出现字节交叉,CRC大概率失败。我一般会在从机代码启动时打印一行当前地址,并且把地址放在Flash中通过工具修改,避免为改地址反复重新编译下载固件。
5. 避坑:从项目里踩出来的五个RS485调试问题
5.1 现象:发送完成立刻切回接收,最后一字节被吃掉
调试某项目X时发现一个诡异问题:主机发送读请求后,从机总是回异常码,而且从机收到的数据长度经常少一个字节。用逻辑分析仪看波形,主机发出的帧确实只有7个字节,丢的是最后一个CRC字节。
原因是发送完成后主机的发送完成中断里立刻把DE引脚拉低,但此时移位寄存器可能还没把停止位完全送完,收发器切换方向导致最后一个字节被截断。解决方法是发送完成中断里先延时一个字节时间再切换方向,9600波特率下一个字节约1.1ms,我直接延时2ms确保移位寄存器排空。
从那以后我做所有RS485半双工通信,方向切换前都强制走一遍固定的释放延时,不再依赖所谓的“发送完成标志”。
5.2 现象:1ms定时器帧间隔误判,粘包随机出现
这套代码最初版本用的是1ms时基,测试中主机连续轮询没问题,但偶尔会出现一帧数据被拼上下一帧的头,CRC校验失败后整帧丢弃。抓波形看到两个不同请求间的字节间隔明显小于4ms,说明从机响应很快,把前一帧的尾巴和下一帧的头部粘在一起了。
原因在于波特率变化时T35也跟着变,1ms时基在19200波特率下只能分辨到1ms,而T35只有约1.92ms,取整到2后如果实际间隔在1.5ms左右就会误判帧结束。解决方法是把定时器时基改到0.1ms,并把阈值做成一个与波特率关联的宏,切换波特率时只需要同步修改宏值。
5.3 现象:工具显示CRC对,从机却回复异常码
用某串口调试工具查看主机发出的报文,工具自带CRC校验显示框显示校验正确,但从机每次都回非法功能码。从机固件里的CRC计算逻辑和工具展示的不一致。
后来对照协议原文才发现问题:工具显示的是“CRC正确”的校验结果,但显示方式是高字节在前低字节在后的常规十六进制表示,而MODBUS-RTU线路上要求的发送顺序是低字节在前。于是从机按正确顺序收到的CRC和计算值不一致,校验失败。解决方法是把主机打包函数里的CRC填充改为低字节在前,并加注释提醒后人不要按调试工具的显示顺序去填。
5.4 现象:短距离没问题,长距离偶发一位错码
某次现场调试从机离主机大概100米,用示波器看A/B线波形发现信号边沿有明显过冲,总线空闲时还有毛刺。偶发的一位错码直接导致CRC失败,整帧重发。
原因是总线两端没有终端匹配电阻,信号反射叠加导致波形畸变。解决方法是按RS485规范在物理总线两端各接一个120Ω电阻,如果从机数量不多且布线简单,只在实际的末端设备上接一个也能明显改善。此外还要检查A/B线是否共地,距离远的情况要拉一根信号地线,否则A/B线相对电压会漂移,上电瞬间甚至可能出现反逻辑。
5.5 现象:接了两个从机之后总线直接卡死
主机单独轮询从机1、从机2都正常,两个同时接上后主机发送请求,总线上出现一团乱码,随后所有从机都不响应。用示波器抓A/B线,能看到两个从机几乎同时把数据放上了总线,波形重叠在一起。
原因是两个从机的地址被配置成了相同值,同时收到主机请求后都会在同一个延时窗口内回复,发送器推挽输出互相打架。解决方法是给每个从机重新分配地址,并且在从机代码里增加上电时的地址自检,如果发现本机地址和另一个设备相同,则在串口输出告警信息而不是保持沉默。
6. 验证与进阶:用模拟器和逻辑分析仪把主从链路跑透
6.1 用Modbus模拟器配对验证主从逻辑
我没有硬件PLC时验证这套代码,最顺手的办法是PC上跑一个Modbus主站模拟器,比如标准Modbus调试工具里的Poll模式。先把STM32从机通过USB转RS485接到PC,用模拟器作为主站去轮询STM32从机,能读寄存器、能写寄存器,立刻确认从机侧协议逻辑是否正常。
验证完从机后,把STM32的角色换成主机,PC上跑一个Modbus从站模拟器,让STM32按它的轮询表依次读PC模拟从机里的寄存器。如果PC能正常收到请求并回数据,主机的打包、发送、超时机制也就验证了。这一对验证做完,主从两端的协议栈都可以放心集成到真实系统里。
6.2 逻辑分析仪抓A/B线上的实际帧间隔
纯看软件日志无法确认字节间隔到底符不符合T35要求,我会用逻辑分析仪直接抓A/B线(实际抓的是收发器芯片的RO和DI引脚)上的电平波形,按波特率解码出十六进制数据,然后手动测量两帧之间的空闲时间。这个方法能看到协议层看不到的物理层细节:发送方向切换的毛刺、帧间静默时间、CRC字节顺序。
6.3 1000次轮询压测与误码统计
最后一步是给主机轮询任务接一个统计计数器,连续轮询1000次后记录成功次数、CRC失败次数、超时次数。成功率在999次以上且没有超时记录,我才认为这套主从链路可以交付。如果出现零星CRC失败,我会回到线缆长度和终端电阻上排查,很少再怀疑协议层代码。这套资源里的主机超时重试机制本身就是压测的安全网,测试过程中偶尔有人为断开总线,重试机制能把故障从机跳过而不是卡死整条链路。
从那以后,我每次拿到RS485相关代码的第一时间,不再是看功能逻辑,而是先找三处关键位置:DE方向切换延时、帧间隔定时器时基、CRC字节填充顺序。这三处没问题,总线通信基本能跑稳。希望帮到你。
本文还有配套的精品资源,点击获取