简介:这份资源面向嵌入式开发初学者与工业自动化方向的工程师,围绕STM32F103平台讲解RS485通信的完整实现方法,帮助读者掌握多点、双向、长距离串行通信的软硬件设计思路。压缩包共122个文件,约826KB,以C源码与头文件为主体,包含38个c文件、39个h文件及33个s汇编启动文件,另附doc说明文档、pdf资料、hex固件、uvproj与uvopt工程文件及bat脚本,覆盖UART初始化、GPIO复用配置、DE/RE使能控制、收发中断处理与错误检测等关键环节。资源还涉及MAX485驱动器接线、120欧姆终端电阻匹配、多节点地址识别及串口助手调试等实践要点,便于读者对照工程结构快速搭建实验环境、理解收发时序并排查通信冲突。目前已有5516人学习下载,适合需要从零实现RS485通信或优化现有总线方案的开发者参考。
1. 从一块工控板说起:为什么STM32的485通信值得单独拆一遍
去年帮一个做环境监测的朋友调一块采集板,STM32F103连着七八个传感器,走的是RS-485总线。板子打回来第一次上电,主机发指令,从机一个都不回。示波器一挂,A、B线上的差分波形倒是干净,但方向控制引脚DE一直拉高,收发器永远在发送态,总线直接堵死。这个场景我后来在好几个项目里都遇到过——485通信本身不复杂,难的是把方向切换、时序、总线冲突这几件事同时处理对。这份STM32实现485通信的工程代码,核心就是解决这个问题:用USART配合一个GPIO控制收发方向,跑通Modbus-RTU这类主从问答协议,适合做工业采集、多机通信、长距离数据传输的开发者直接拿去改。它不依赖什么高级框架,标准外设库或HAL库都能落地,新手照着能跑通,熟手能看清中断收发和方向控制的配合逻辑。
2. 硬件链路与工程结构:485到底比232多做了什么
2.1 差分信号与收发器的角色
RS-485和RS-232最大的区别在物理层。232是单端信号,一根线对地参考,抗干扰差、距离短;485用两根线A、B传差分信号,靠两根线的电压差判断逻辑0和1,共模干扰会被差分接收端抵消掉,所以能拉到1200米、挂32个节点(加中继还能更多)。STM32的USART本身只出TTL电平的TX、RX,要变成485的差分信号,中间必须有个收发器芯片,常见的是MAX485、SP3485、ADM2483这类。
收发器上有个关键引脚叫DE(Driver Enable)和RE(Receiver Enable),很多模块把这两个脚并在一起引出,标成DE或者RS485_DIR。DE拉高,收发器进入发送态,把TTL的TX推到A、B线上;DE拉低,进入接收态,把A、B线上的差分信号还原成TTL给RX。所以STM32要做的,就是在发送数据前把DE拉高,发完最后一个字节后把DE拉低,切回接收。这个切换时机,就是整个485通信最容易翻车的地方。
2.2 一份典型的工程目录长什么样
拿到这份代码,目录结构大致是这样组织的,我按实际拆包的习惯列一下:
stm32_rs485/ ├── CMSIS/ # 内核启动与系统初始化 ├── FWLIB/ │ ├── stm32f10x_usart.c # 串口外设驱动 │ ├── stm32f10x_gpio.c # 方向控制引脚配置 │ └── stm32f10x_rcc.c # 时钟使能 ├── USER/ │ ├── main.c # 主循环与收发调度 │ ├── rs485.c # 485收发封装 │ └── rs485.h ├── HARDWARE/ │ └── timer.c # 超时与帧间隔定时 └── SYSTEM/ └── delay.c # 延时基准这个划分是标准外设库工程的常见做法。rs485.c是核心,把方向切换、发送、接收、超时判断都封在里面,main.c只负责业务逻辑。如果你用的是HAL库,结构会变成Core/Src和Core/Inc,但职责划分是一样的:外设初始化归初始化,收发逻辑单独成模块。
2.3 引脚分配与时钟使能
以STM32F103为例,USART1挂在APB2上,TX是PA9,RX是PA10,方向控制脚我一般选PA8或者PB1这种普通GPIO。初始化的时候三件事必须做全:使能USART1时钟、使能GPIOA时钟、配置TX为复用推挽输出、RX为浮空输入或上拉输入、方向脚为推挽输出。少一个时钟,外设就是不动,这种问题查起来最费时间。
// rs485.c 初始化片段(标准外设库) void RS485_Init(uint32_t bound) { GPIO_InitTypeDef gpio; USART_InitTypeDef usart; // 时钟:USART1和GPIOA都在APB2 RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); // TX PA9 复用推挽 gpio.GPIO_Pin = GPIO_Pin_9; gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); // RX PA10 浮空输入 gpio.GPIO_Pin = GPIO_Pin_10; gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &gpio); // 方向控制 PA8 推挽输出,默认接收态 gpio.GPIO_Pin = GPIO_Pin_8; gpio.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_Init(GPIOA, &gpio); GPIO_ResetBits(GPIOA, GPIO_Pin_8); // DE=0 接收 // USART参数:9600-8-N-1 usart.USART_BaudRate = bound; 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(USART1, &usart); USART_Cmd(USART1, ENABLE); }这段代码里,方向脚默认拉低很关键。上电瞬间如果DE是高,收发器会往总线上灌电平,别的节点正在通信就被你打断了。参数上波特率、数据位、停止位、校验位必须和总线上其他设备完全一致,Modbus-RTU常用9600或19200、8数据位、无校验、1停止位。硬件流控这里关掉,485是半双工,用不上RTS/CTS。
3. 收发方向切换:一个字节都不能早也不能晚
3.1 发送流程与TC标志的等待
485发送的坑几乎全在方向切换的时机上。很多人写完发送函数,现象是第一个字节发出去正常,后面全乱,或者从机收到的数据缺头少尾。根因是DE拉低的时机不对——数据还没从移位寄存器完全发出去,就把收发器切回接收态了,最后一个字节被截断。
正确的做法是:拉高DE,往USART数据寄存器写数据,然后等TC(Transmission Complete)标志置位,确认最后一个字节的停止位都发完了,再拉低DE。注意是TC不是TXE。TXE只表示发送数据寄存器空了,可以写下一个字节,但移位寄存器里可能还有数据在往外走。
// 发送一帧数据,len为字节数 void RS485_SendBuf(uint8_t *buf, uint16_t len) { uint16_t i; RS485_TX_EN(); // DE=1 进入发送态 for (i = 0; i < len; i++) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, buf[i]); // 逐字节写入 } // 等最后一个字节彻底发完 while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); RS485_RX_EN(); // DE=0 切回接收 }这里有个细节:等待TC之前不能提前拉低DE,但也不能死等太久。如果波特率很低、数据量大,这个while会占用CPU。常见做法是发送用中断或DMA,TC中断里再切方向,主循环不被阻塞。这份代码如果用的是查询方式,适合数据量小、实时性要求不高的场景;要跑高频采集,建议改成DMA发送加TC中断。
3.2 接收与帧间隔判断
485是半双工,同一时刻总线上只能有一个发送方。主机发完切回接收,从机应答,主机得知道一帧什么时候结束。Modbus-RTU用帧间隔来判断:3.5个字符时间内没有新字节,就认为一帧结束。9600波特率下,一个字符约1.04ms,3.5个字符大概3.6ms。
实现上一般用两种方式。一种是定时器超时:每收到一个字节就重置定时器,定时器溢出说明帧结束。另一种是空闲中断:STM32的USART有IDLE标志,总线空闲一个字符时间就触发,配合DMA接收非常省事。
// 串口接收中断:逐字节存入缓冲区 void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t ch = USART_ReceiveData(USART1); if (rx_cnt < RX_BUF_SIZE) { rx_buf[rx_cnt++] = ch; } TIM_SetCounter(TIM3, 0); // 重置帧间隔定时器 USART_ClearITPendingBit(USART1, USART_IT_RXNE); } } // 定时器中断:超过3.5字符时间判定一帧结束 void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { frame_ready = 1; // 通知主循环处理 TIM_ClearITPendingBit(TIM3, TIM_IT_Update); } }rx_cnt是接收计数,rx_buf是接收缓冲,frame_ready是帧完成标志。主循环检测到frame_ready后,把rx_buf交给协议解析,处理完清零rx_cnt。这里要注意缓冲区溢出保护,从机如果收到超长帧,rx_cnt不能越界写,否则就是内存踩踏,现象是程序跑飞或者数据莫名其妙。
3.3 中断优先级与总线冲突
多机通信时,如果两个从机同时应答,总线上的差分信号会叠加,主机收到的是乱码。协议层要靠地址区分,同一时刻只有一个从机被寻址。但中断优先级也得配好:接收中断优先级要高于发送,否则正在收数据时被发送任务打断,方向切换会乱。我一般把USART接收中断设成抢占优先级1,定时器中断设成2,主循环里的发送任务用标志位触发,不在中断里做长耗时操作。
4. 避坑与排查:那些让485不通信的细节
4.1 现象:从机完全不回,示波器看总线无波形
原因通常是方向控制脚没配对,或者DE一直处于接收态。检查GPIO初始化里方向脚的模式是不是推挽输出,默认电平是不是拉低。还有一种情况是收发器供电没接,A、B线有信号但收发器不工作。解决:万用表量收发器VCC,确认3.3V或5V正常;用代码强制拉高DE,量A、B线有没有差分输出。
4.2 现象:能收到但数据错位,首字节丢失
这是方向切换太早的典型表现。发送函数里如果只等TXE就拉低DE,最后一个字节的停止位还没发完,收发器已经切走,从机收到的帧头就缺了。解决:把等待标志从TXE改成TC,确保移位寄存器彻底空。如果用了DMA,要在DMA传输完成中断里再等TC,不能只等DMA完成。
4.3 现象:通信一段时间后死机,重启又正常
多半是接收缓冲区溢出或者帧间隔定时器没清。从机持续收到噪声,rx_cnt一直涨,越界写坏了其他变量。解决:在接收中断里加边界判断,rx_cnt达到上限就丢弃后续字节并置错误标志;帧处理完后必须清零rx_cnt。另外总线上的终端电阻不能忘,1200米长线两端各接一个120Ω,短距离可以不接,但不接在干扰环境下误码率会明显上升。
4.4 现象:多机通信时随机丢包
总线冲突或者地址判断有误。检查协议里从机地址是否唯一,主机轮询间隔是否大于从机应答时间。如果从机处理慢,主机发下一帧时从机还在应答,总线就撞了。解决:主机加超时重发机制,从机收到广播地址时不要应答,避免多个从机同时开口。
4.5 现象:波特率对但误码率高
地线没共或者共地不良。485虽然差分抗干扰,但A、B线对地电位差太大时收发器会误判。长距离布线时建议走双绞线,A、B绞在一起,地线单独走一根,屏蔽层单端接地。另外波特率越高距离越短,9600能跑1200米,115200可能只能跑几十米,这是物理规律,不是代码能解决的。
5. 二次开发:把这份代码改成你自己的采集节点
拿到这份工程,最直接的改法是把它当成一个从机模板。协议层加一个地址判断,收到主机指令后解析功能码,读传感器数据回填到发送缓冲,再调RS485_SendBuf发出去。下面是一个Modbus-RTU从机应答的骨架,我按实际项目里常用的写法给出来:
// 处理主机请求,构造应答帧 void Modbus_Slave_Handle(uint8_t *req, uint16_t len) { uint8_t addr = req[0]; uint8_t func = req[1]; uint8_t resp[64]; uint16_t resp_len = 0; if (addr != LOCAL_ADDR && addr != 0x00) return; // 非本机地址丢弃 if (func == 0x03) { // 读保持寄存器 uint16_t reg = (req[2] << 8) | req[3]; uint16_t num = (req[4] << 8) | req[5]; resp[0] = LOCAL_ADDR; resp[1] = 0x03; resp[2] = num * 2; // 字节数 for (uint16_t i = 0; i < num; i++) { uint16_t val = Read_Sensor_Reg(reg + i); // 读你的传感器 resp[3 + i * 2] = val >> 8; resp[4 + i * 2] = val & 0xFF; } resp_len = 3 + num * 2; // 追加CRC16 uint16_t crc = CRC16(resp, resp_len); resp[resp_len++] = crc & 0xFF; resp[resp_len++] = crc >> 8; RS485_SendBuf(resp, resp_len); // 发完自动切回接收 } }Read_Sensor_Reg是你自己的采集函数,把ADC、温湿度、电流这些量映射到寄存器地址上。CRC16用Modbus标准多项式0xA001,低字节在前。注意应答帧发完必须切回接收,否则下一帧主机指令你收不到。
改造时有几个点值得提前想清楚。第一,接收缓冲和发送缓冲分开,别共用一个数组,发送时DMA还在读,接收中断又往里写,数据就串了。第二,如果节点多,建议给每个从机加一个心跳或状态寄存器,主机轮询时能发现掉线节点。第三,波特率和地址最好做成可配置,存在Flash里,现场调试不用重新烧程序。我一般会在main开头读一个配置结构体,地址、波特率、校验方式都从里面取,改参数只改配置不改代码。
验证方法上,先用USB转485模块接电脑,用串口助手手动发帧,看从机回不回、回的CRC对不对。通了之后再上主机轮询。如果手头没有485模块,可以用两块STM32板子对连,A接A、B接B、地共地,一块跑主机一块跑从机,示波器挂A、B看波形,比猜快得多。
从那以后我每次调485,都强制先用示波器确认方向脚和总线波形,再去看协议层,因为十次通信失败里有七次是物理层和方向切换的问题,代码逻辑反而是最后才怀疑的。希望帮到你。
本文还有配套的精品资源,点击获取