简介:面向 STM32F103 嵌入式开发者的 RS485 串口通信示例工程,针对长距离、多噪声工业场景,演示 UART 差分传输与一对多组网的关键实现方法。工程代码覆盖串口初始化、收发流程、RS485 转换芯片(MAX485/SP3485)的 DE/RE 方向切换,并兼顾 CRC 校验、握手应答、冲突避免等可靠通信设计,帮助开发者理解从裸机寄存器到外设库的完整配置思路。压缩包内共 213 个文件,含 39 个 H 头文件与 38 个 C 源文件,可组成完整驱动层;另有启动汇编文件、Keil 工程配置、hex/axf 烧录与调试文件,以及说明文档,整体仅 3.27MB,便于直接打开工程对照代码阅读。文档还提供编译、烧录与验证指引,配合 .map/.lst 等构建过程文件,可进一步分析编译链接细节。已有 1011 人学习,适合入门到中级开发者作为 RS485 通信的项目模板,快速迁移到主从轮询、传感器网络等分布式系统中。
1. RS485在STM32F103上比想象中多出的那根“方向线”
很多人第一次在STM32F103上做RS485串口通信,都以为把TXD、RXD接到MAX485就能跑通。实际接上后发现:自己发出去的帧自己收到了、从机响应永远读不到、波特率9600可以但19200乱码。问题出在RS485是半双工差分总线,MAX485这类转换芯片的RO、DI本身没有方向仲裁能力,必须由MCU控制DE/RE引脚。STM32F103的USART外设没有硬件RTS方向管理,因此软件切方向成了工程落地时绕不开的一步。这篇文章从物理层差分信号讲清楚为什么需要DE线,再给出一套基于标准外设库的发送/接收驱动写法,最后用一个一主多从的轮询示例把地址、CRC和超时处理串起来。适合正在做工业采集、多机通信或课程设计的开发者,尤其适合想搞懂RS485自动收发电路和GPIO控制方向之间差别的人。
2. RS485物理层解析与STM32F103的外设映射
2.1 为什么TTL串口不能直接拉长距离
标准UART输出的TTL电平是单端信号,逻辑1为3.3V或5V,逻辑0为0V。STM32F103的USART引脚驱动能力强一点,但抗共模干扰差,线长超过几米就会因压降和电磁干扰出现误码。RS485把单端信号转换成A、B两根线之间的差分电压:空闲时A比B高,逻辑1;A比B低为逻辑0。接收端只需比较两根线的电位差,共模噪声被抵消,传输距离可以到1200米,还能挂多条总线。正是这种多点拓扑催生了半双工收发模式,任何时刻只允许一个节点驱动总线,其他节点监听。
TTL转RS485看似只是加一颗转换芯片,实际等于把单片机的异步串口搬到了带方向控制的差分总线上。STM32F103的USART并不感知总线方向,它只负责发出字节流和接收字节流。所以RS485通信的本质是UART串口通信加一层外部方向控制,而不是换了一个串口协议。理解这一点后,再去分析RS485自动收发电路或者GPIO切方向方案,思路会清晰很多。
2.2 MAX485/SP3485与STM32F103的引脚级连接
常见做法是用MAX485或SP3485做TTL转RS485。MAX485的第1脚RO接STM32的USART1_RX(PA10),第4脚DI接USART1_TX(PA9),第2、3脚RE和DE短接后由任意一个GPIO控制。RE低电平有效,DE高电平有效,短接后一个引脚就能控制方向:输出高进入发送模式,输出低进入接收模式。
| MAX485引脚 | 功能 | STM32F103引脚 | 方向说明 |
|---|---|---|---|
| 1 (RO) | 接收数据输出 | PA10 (USART1_RX) | 芯片输出到MCU |
| 2 (RE) | 接收使能,低有效 | PC1(方向控制) | 与DE短接 |
| 3 (DE) | 驱动使能,高有效 | PC1(方向控制) | 与RE短接 |
| 4 (DI) | 发送数据输入 | PA9 (USART1_TX) | MCU输出到芯片 |
| 6 (A) | 差分正极 | RS485总线A | 总线接口 |
| 7 (B) | 差分负极 | RS485总线B | 总线接口 |
A、B之间要跨接120Ω终端电阻,阻值等于双绞线特性阻抗,用于吸收反射。距离短、波特率低时可以悬空,但多节点或线长超过10米最好接上。SP3485与MAX485引脚兼容,前者是3.3V供电版本,适合STM32F103C8T6这类3.3V逻辑平台。MAX485如果供电5V,RO输出高电平会接近5V,可能超过F103引脚容忍范围,需要在RO线上串联电阻或做电平转换。实际省事方案是直接用SP3485,供电和信号电平都对齐。
还有一种RS485自动收发电路,用三极管和RC电路根据DI信号自动切换方向,省掉一个GPIO。它的原理是在空闲时通过上拉电阻让DE保持低电平接收,发送起始位时三极管导通把DE拉高。这个方案在9600波特率下工作稳定,但在115200波特率下会由于RC充放电时间不匹配产生边沿畸变。我的习惯是优先用GPIO控制方向,逻辑清晰,出问题容易排查。
2.3 方向切换时序是RS485通信的关键
DE线从接收切到发送后,不能立刻往USART数据寄存器里写数据。MAX485的驱动器使能到输出电压稳定需要几十纳秒到几微秒,通常拉高DE后再延时一个位时间,或者直接先写第一个字节再等待TXE,靠发送过程把稳定时间覆盖掉。发送完成后,必须等待USART的TC(发送完成)标志置位,确认移位寄存器把最后一帧数据完全送出,才能拉低DE。如果只等TXE标志,最后一个字节可能还在移位寄存器里,总线尚未释放,其他从机就无法响应。这个顺序一旦反了,最后一字节就会被截断,整个帧结构错位。
3. 基于标准外设库的RS485驱动编写
3.1 使能时钟并初始化USART1与方向控制引脚
使用STM32F103标准外设库时,先使能GPIOA、GPIOC和USART1的时钟,然后分步配置TX、RX和方向引脚。USART1的TX和RX要分别配置为推挽复用输出和浮空输入,方向控制引脚配置为推挽输出。
void RS485_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOC | RCC_APB2Periph_USART1, ENABLE); // USART1_TX PA9 复用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // USART1_RX PA10 浮空输入 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); // PC1 控制 MAX485 的 RE/DE GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOC, &GPIO_InitStructure); GPIO_WriteBit(GPIOC, GPIO_Pin_1, Bit_RESET); // 默认接收模式 }这段代码先把RE/DE引脚复位为低,确保上电后处于接收状态,避免MCU复位瞬间MAX485误驱动总线。GPIO_Speed选择50MHz是为了让方向引脚翻转足够快,特别是在115200波特率下,一个位时间只有8.68μs,慢速输出模式会影响边沿。TX配置为复用推挽而非普通推挽,是因为PA9的信号源自USART外设的发送数据线,普通推挽输出会把外设信号强制钳制在高或低电平。
3.2 USART通信参数与中断配置
RS485的通信参数需要和总线上的其他节点一致。下面的代码配置波特率9600、8数据位、无校验、1停止位,使能接收中断,用于从站响应接收。
void RS485_USART_Init(uint32_t baudrate) { USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; USART_InitStructure.USART_BaudRate = baudrate; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &USART_InitStructure); USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); USART_Cmd(USART1, ENABLE); NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); }参数里没有把DE绑定到USART硬件,因为F103的USART不提供RTS自动方向输出。RS485内部最基本的做法就是把USART_Mode设置为Rx和Tx同时开启,方向由外部GPIO控制。中断只开了接收,发送部分在下一节通过轮询方式完成,这样逻辑更直观。接收中断里要根据帧长度或空闲时间判断一帧结束,不能每收到一个字节就当作完整帧。
3.3 DE方向切换与发送完整字节帧
发送数据时先拉高DE,调用USART_SendData写入第一个字节。之后不能马上写下一个字节,必须等待TXE置位,表示数据寄存器已空。最后一字节需要额外等待TC置位,确保移位寄存器发送完毕,再拉低DE。
void RS485_SendBuffer(uint8_t *buf, uint16_t len) { uint16_t i; GPIO_WriteBit(GPIOC, GPIO_Pin_1, Bit_SET); // 切换到发送模式 for (i = 0; i < len; i++) { USART_SendData(USART1, buf[i]); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); // 等数据寄存器空 } while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); // 等移位寄存器发完 GPIO_WriteBit(GPIOC, GPIO_Pin_1, Bit_RESET); // 释放总线,切回接收 }TXE标志表示数据从软件写入USART_DR后转移到了移位寄存器,但此时最后一个字节还在物理线路上没发完,因此需要TC标志确认结束。这个顺序不能反过来。如果在循环里只检查TXE,最后一字节的停止位还没出去就把DE拉低了,总线上从设备可能只收到9个位的数据,后续通信全部错位。还要注意RS485_SendBuffer执行期间最好关中断,或者保证不会被高优先级中断打断超过一个位时间,否则方向控制被长时间卡住,总线一直被本机占用。
3.4 波特率寄存器BRR的计算与误差控制
USART1挂在APB2上,时钟为72MHz。波特率寄存器BRR的计算公式是DIV = PCLK / (16 * 波特率)。例如9600波特率,DIV = 72000000 / (16 * 9600) = 468.75。BRR的低4位存放小数部分,高12位存放整数部分。库函数会把浮点转成整数和分数写入寄存器。常用配置如下:
| 目标波特率 | DIV | 实际波特率 | 误差 |
|---|---|---|---|
| 9600 | 468.75 | 9600 | 0.00% |
| 19200 | 234.375 | 19200 | 0.00% |
| 115200 | 39.0625 | 115200 | 0.00% |
| 250000 | 18 | 250000 | 0.00% |
| 460800 | 9.7656 | 454545 | 1.36% |
460800在72MHz下没法整除,误差达1.36%。UART接收端要求波特率误差一般在±2%以内,RS485自动收发电路还会再增加抖动,所以优先选能整除的波特率。如果必须用460800,通常改PCLK分频系数,让PCLK接近一个能被16整除的倍频值。我在STM32F103C8T6上测试过,系统主频72MHz、外部晶振8MHz时,9600到115200都是稳定区,往上就要用逻辑分析仪数位宽确认。
4. RS485一主多从轮询协议与实测排错
4.1 帧格式设计:地址、功能码、长度和CRC
RS485只是物理层,应用层需要自己约定帧格式。工业现场最常见的是Modbus RTU,但自己设计协议时建议留出地址、功能码、长度和数据校验。下面这个帧结构足够覆盖多数传感器采集场景:
| 字段 | 长度 | 说明 |
|---|---|---|
| 地址 | 1字节 | 从站地址,1~247有效,0为广播 |
| 功能码 | 1字节 | 0x01读、0x02写等 |
| 数据长度 | 1字节 | 后续数据字节数 |
| 数据 | N字节 | 寄存器值或命令参数 |
| CRC16 | 2字节 | 低字节在前 |
CRC16用于检测传输错误。RS485在工业现场会引入毛刺干扰,没有校验的协议很容易出现主站收到错误响应但无法判断数据是否可信。CRC16多项式采用0xA001,初值0xFFFF,查表法或位循环法都可以。我习惯把CRC函数单独放一个文件,主站和从站共用同一个实现,避免两端算法不一致。
4.2 主站轮询状态机的实现
一主多从组网时,主站按地址轮询,每次只发一帧请求,然后等待从站响应。用状态机管理轮询过程,比死等或延时阻塞更可靠。状态定义如下:
typedef enum { RS485_IDLE, RS485_WAIT_SEND, RS485_WAIT_RESPONSE, RS485_SEND_NEXT } RS485_State; RS485_State rs485_state = RS485_IDLE; uint8_t current_slave_id = 1; uint16_t timeout_cnt = 0;主循环里不断刷新状态机,发送请求后等待响应。每次轮询开始时调用RS485_SendBuffer发送地址和命令,切换到等待响应状态,同时清零超时计数。如果收到完整帧并校验通过,则处理数据并递增地址;如果超时达到预设值,则记录失败次数并跳到下一个从站。超时时间要大于从站处理时间加上总线空载时间,一般取20~100ms。
状态机的核心循环:
void RS485_Poll(void) { switch (rs485_state) { case RS485_IDLE: rs485_state = RS485_WAIT_SEND; break; case RS485_WAIT_SEND: // 构造请求帧并发送 rs485_tx_buf[0] = current_slave_id; rs485_tx_buf[1] = 0x01; // 功能码 rs485_tx_buf[2] = 0x02; // 数据长度 rs485_tx_buf[3] = 0x00; // 寄存器地址高字节 rs485_tx_buf[4] = 0x00; // 寄存器地址低字节 RS485_SendBuffer(rs485_tx_buf, 5); timeout_cnt = 0; rs485_state = RS485_WAIT_RESPONSE; break; case RS485_WAIT_RESPONSE: if (rs485_rx_done == 1) { // CRC校验通过则记录数据 rs485_rx_done = 0; current_slave_id++; if (current_slave_id > 247) current_slave_id = 1; rs485_state = RS485_IDLE; } else if (++timeout_cnt > 100) { timeout_cnt = 0; current_slave_id++; if (current_slave_id > 247) current_slave_id = 1; rs485_state = RS485_IDLE; } break; default: break; } }这段轮询逻辑没有阻塞延时,靠主循环计数做超时,适合放在带RTOS的工程里,也可以放在裸机while循环中。RS485_SendBuffer是同步阻塞的,发送几字节的时间约几毫秒,可以接受。要注意主站拉低DE后,从站响应到达MCU会有总线传播延迟和MAX485的RO输出延迟,所以主站切回接收后,需要等待几十微秒到几毫秒,才能收到第一个RXNE中断。这里的rs485_rx_done标志在USART1中断或定时器检查中置位,表示收到完整一帧。
4.3 从站中断接收与帧结束判断
从站不能主动发数据,只能响应请求。最直接的接收方式是在USART的RXNE中断里逐个字节把数据存进数组。判断一帧结束可以用两种办法:一是根据帧长度字段,收满N字节就算一帧;二是利用串口空闲时间,超过一定时间没有新字节则认为帧结束。STM32F103的USART没有硬件空闲中断,但可以引入一个定时器或者直接在中断里做超时判断。下面给出一种简单可靠的做法:主循环里定期检查距上次收到字节的时间,超过5ms认为一帧结束。
void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t data = USART_ReceiveData(USART1); if (rs485_rx_index < RS485_RX_BUF_SIZE) { rs485_rx_buf[rs485_rx_index++] = data; } last_rx_time = systick_ms(); } }主循环里用systick毫秒计数,当rs485_rx_index大于0时检查last_rx_time距当前时间是否超过5ms。一旦超时,把rs485_rx_done置位,同时将收到的地址、功能码、长度拿出来解析。如果当前帧是请求且地址等于本机地址,就调用RS485_SendBuffer发送响应。要注意从站在收到完整帧之前必须先保持DE为低,否则自身发送会打断接收。实际测试中发现,从站在解析CRC期间就开始构造响应,会因为DE引脚切换不及时导致响应帧头被截断,所以要么在响应函数一开始就拉高DE,要么把CRC校验和响应分两步执行。
4.4 示波器与逻辑分析仪定位RS485故障
RS485通信出问题时,不要先改代码,先看物理层。把示波器探头接A和B之间,观察差分波形。空闲时A-B应该为2V~6V(因为有偏置电阻),发送时波形翻转差值大于0.2V就有效。常见故障现象有两种。
第一种是只有发送没有接收。用示波器看MAX485的RO引脚,如果主站发送时RO有翻转说明芯片方向控制没问题,问题在从站的DE没有释放。常见原因是从站发送完数据后没有等待TC就拉低了DE,或者从站GPIO配置成了复用输出而不是普通推挽。第二种是总线冲突。两台设备同时拉高DE,总线被两个驱动源同时驱动,波形会出现台阶或毛刺。这时需要逐一断开节点,确认每个节点复位后是否默认处于接收状态。我的习惯是在每台设备的初始化里,最先执行GPIO配置并拉低DE,再配置USART。
| 故障现象 | 可能原因 | 检查手段 |
|---|---|---|
| 自发自收 | DE没有被拉高,或RE/DE未短接 | 测DE引脚电压 |
| 只能发不能收 | 发送完后未等TC就切回接收 | 逻辑分析仪看DE与TXD时序 |
| 长线乱码 | 缺少120Ω终端电阻 | 在总线两端并联电阻测试 |
| 偶发丢帧 | 波特率误差或从站处理太慢 | 检查BRR误差,延长超时 |
| 全总线冲突 | 多个设备上电时DE为高 | 初始化时先拉低DE再初始化USART |
这五个问题覆盖了调试RS485组网时遇到的绝大多数坑。STM32F103C8T6作为从站时,内部Flash和RAM比F103RCT6小,协议栈不要塞太多缓冲,轮询周期也要根据从站数量调整。
5. 提升RS485稳定性的三个进阶技巧
5.1 终端电阻和偏置电阻的搭配
RS485总线两端各接一个120Ω终端电阻,可以吸收反射。但只接终端电阻后,总线空闲时A、B之间电压可能接近0V,接收端误判。偏置电阻通常放在主站端,把A上拉到VCC、B下拉到GND,阻值330Ω到1kΩ。偏置电压要保证接收端差分电压大于200mV。计算方法是:VCC=5V,上下拉电阻R1=R2=470Ω,终端电阻RT=120Ω,差分电压约为5V*120/(470+470+120)=0.56V,满足要求。如果总线节点多、线缆电容大,可以适当减小偏置电阻,但会增大静态功耗。
5.2 从站上电延时与总线空闲检测
多节点上电时,如果某个从站的MCU复位瞬间GPIO输出高,MAX485会立刻驱动总线,和其他节点的上电行为冲突。解决办法是在GPIO配置之前,先通过外部硬件把RE/DE引脚拉低,然后再由软件接管。另外,从站收到一帧请求后不要立即回复,先等待1ms到3ms,目的是错开主站方向切换的拖尾时间。用示波器看主站DE下降沿到从站RO第一个电平翻转的间隔,如果小于2个位时间,就要延长从站的等待。
5.3 用DMA加定时器空闲检测降低中断负载
如果数据帧很长或通信频率高,每个字节都进中断会导致CPU争用。常见优化是USART接收用DMA环形缓冲,配合一个基本定时器做超时检测。初始化时把USART1的接收DMA通道指向接收缓冲区,开启DMA传输完成中断。定时器每1ms检查一次DMA剩余计数,如果两次检查之间DMA传输计数没有变化,说明总线空闲,此时解析缓冲区数据。发送仍然用轮询方式,因为主站发送频率低。下面的代码片段演示了DMA接收的启动方式:
DMA_DeInit(DMA1_Channel5); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)rs485_rx_buf; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = RS485_RX_BUF_SIZE; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel5, &DMA_InitStructure); USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); DMA_Cmd(DMA1_Channel5, ENABLE);DMA_Mode_Circular让数据源源不断写入环形缓冲区,无需在中断里读USART->DR。定时器空闲检测只在字节停止到达时被调用,开销远小于逐字节中断。这个方案在STM32F103上配合72MHz主频,可以轻松跑满115200波特率,同时留下充足时间处理协议解析和传感器读取。RS485串口通信调试时,建议优先把物理层波形调干净,再用逻辑分析仪记录DE和TXD的时序对齐,这样剩下的代码问题都会变得很好定位。
本文还有配套的精品资源,点击获取