简介:围绕STM32F103实现RS485传感器通信的docx文档,面向刚接触嵌入式串口通信、需要完成土壤温湿度采集实验的初学者与课程设计者,重点解决半双工总线收发切换、DE使能控制及UART2驱动配置等入门难点。压缩包内共1个docx文件,约1.22MB,以原理说明、引脚图、函数注释和C代码示例为主,便于集中查阅。已有2629人学习,其示例组织对同类实验具有参考价值。文档从RS485半双工原理与硬件收发切换讲起,结合STM32F103ZET6的USART2初始化、波特率设置、发送接收函数、接收缓冲区及中断优先级配置展开,并给出主函数发送读取指令、解析土壤温湿度数据并在TFTLCD显示的流程;头文件中的模式控制宏与接收使能宏也有说明,可帮助读者理解驱动分层、核对数据帧并排查通信异常。
1. 一份 docx 加一块 F103 板:先弄清这套 RS485 采集链路能干什么
手上这份《STM32F103 RS485传感器.docx》不是纯理论文档,它把一条能跑通的采集链路写完整了:F103ZET6 主控、USART2 挂 485 收发器、外接一只土壤温湿度探头、结果打到 TFTLCD 上。想真正搞懂 stm32 rs485 这套 485 通讯方式原理的人,看协议框图容易飘,看这份代码反而扎实——它把一帧 8 字节的读取指令怎么发出去、10ms 静默后怎么把 9 字节响应拆成温度和湿度,全摊在纸面上。适合刚上手 485 的嵌入式新手,也适合手里有探头但一直收不到数的老手回头对一遍时序。下面按接线、初始化、收发、排错、多节点、进阶的顺序拆。
2. Modbus-RTU 帧结构与 F103 的串口映射:先把协议立住再动线
485 只规定了电气层:差分信号、半双工、总线可挂几十个节点。至于总线上跑什么内容,靠的是上层协议,工业现场九成以上是 Modbus-RTU。这份文档里TR_Read[8]={0x01,0x03,0x00,0x06,0x00,0x02,0xc4,0x38}就是一帧标准的 Modbus-RTU 读保持寄存器指令,理解这一帧,比背十页协议手册有用。
2.1 一帧 8 字节为什么这样排
Modbus-RTU 的帧结构是固定的:地址 + 功能码 + 数据段 + CRC,中间没有起始符也没有结束符,靠总线静默时间判帧边界。把这 8 个字节拆开看:
| 字节序号 | 值 | 含义 | 说明 |
|---|---|---|---|
| buf[0] | 0x01 | 从站地址 | 探头的 485 地址,出厂常见 0x01,可改 |
| buf[1] | 0x03 | 功能码 | 读保持寄存器(Holding Register) |
| buf[2] | 0x00 | 起始寄存器高字节 | 组合成 0x0006 |
| buf[3] | 0x06 | 起始寄存器低字节 | 从第 6 号寄存器开始读 |
| buf[4] | 0x00 | 寄存器数量高字节 | 组合成 0x0002 |
| buf[5] | 0x02 | 寄存器数量低字节 | 连读 2 个寄存器 |
| buf[6] | 0xC4 | CRC 低字节 | 先发低字节 |
| buf[7] | 0x38 | CRC 高字节 | 后发高字节 |
注意最后两个字节的顺序,Modbus 规定 CRC 低字节在前、高字节在后,这和大部分人对"高字节在前"的直觉相反,手搓帧时错在这一步的人很多。CRC 覆盖范围是前 6 个字节,不含自身。校验值不用死记,跑一段脚本就能复核,改地址或改寄存器时也用同一段脚本重算:
# 校验/生成 Modbus-RTU 帧尾,地址或寄存器改了以后用来重算 def crc16_modbus(data: bytes) -> int: crc = 0xFFFF for b in data: crc ^= b for _ in range(8): if crc & 0x0001: crc = (crc >> 1) ^ 0xA001 # 多项式 0xA001,即 0x8005 反序 else: crc >>= 1 return crc frame = bytes([0x01, 0x03, 0x00, 0x06, 0x00, 0x02]) c = crc16_modbus(frame) print(hex(c & 0xFF), hex(c >> 8)) # 低字节在前,与帧尾对照这段脚本的逻辑很直白:初值 0xFFFF,逐字节异或进 CRC 寄存器,再对每个 bit 判断最低位,是 1 就右移并异或 0xA001,是 0 就只右移。返回值拆成低字节和高字节,正好对应帧尾那两个位置。改从站地址为 0x02 时,帧尾必须跟着变,否则探头收到地址匹配但 CRC 不过,会直接丢掉不响应——现象就是"发出一串,什么都没回来",很容易被误判成接线问题。
2.2 USART2 与 DE 方向脚:半双工的方向切换靠一根 GPIO
485 收发器芯片上有个 DE/RE 引脚,高电平发送、低电平接收,这就是半双工的全部秘密。STM32 侧不需要特殊外设,普通 USART 加一根普通 GPIO 就能做,文档里的映射是 PA2 作 TX、PA3 作 RX、PG3 作方向控制,代码里封装成RS485_TX_EN。这三点必须在初始化时一次配好:
// 方向脚:推挽输出,速度不用太高,50MHz 足够 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // 推挽输出,驱动 DE 脚 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOG, &GPIO_InitStructure); RS485_TX_EN = 0; // 上电默认接收,别一上来就占着总线发送脚 PA2 必须是复用推挽GPIO_Mode_AF_PP,因为它要输出 USART2 的 TX 波形;接收脚 PA3 配成浮空输入GPIO_Mode_IN_FLOATING,让收发器输出的电平直接进 USART。这两个模式配错的后果不一样:TX 配成普通推挽,串口发不出波形,示波器上是一条直线;RX 配成上拉或下拉,空闲电平被拉死,偶尔能收但误码率高。
这里有个细节值得单独拎出来说:头文件里宏定义写的是#define RS485_TX_EN PDout(7),而RS485_Init里真正初始化的却是 GPIOG 的 Pin3。两处对不上,说明这份代码大概率是从别的板子上移植过来的,移植时只改了初始化的端口没改宏。我一般会先把宏统一改成PGout(3),再顺手把初始化里那行注释掉的GPIO_Pin_3 / CS-485清理干净,否则后来接手的人一定会在这里绕半天。
注意:方向脚接哪个引脚不重要,重要的是宏定义、初始化、原理图三者必须指向同一个物理引脚,改一处就三处一起改。
2.3 硬件侧:A/B 极性、终端电阻与共地
协议再对,线接错也白搭。485 只有 A、B 两根差分线,收发器的 A 接总线的 A、B 接 B,看起来简单,但不同厂家对 A/B 的命名有反着来的,实际接过反了照样能通信——只是差分极性颠倒,收不到正确数据。现场判定极性的土办法是:万用表直流档量 A、B 之间电压,空闲态一般在 0.2V 到 1V 之间,接反了是负值,对上号再上电。
终端电阻是第二个高频问题。485 总线两端各需要一只 120Ω 匹配电阻,短距离桌面上不接也能通,一旦拉到几十米或者波特率往上提,反射就会把波形打得七零八落。偏偏这种故障最迷惑人:在工位上测得好好的,装到现场就不稳定,很多人第一反应是怀疑代码,实际上是线的问题。
第三个是共地。485 虽然叫差分传输,但收发器的共模电压范围有限,两块板子各自用隔离电源、地电位差一大,收发器就可能进入异常状态,表现为"时好时坏"。常见做法是三根线一起走:A、B 加一根地线,地线不参与信号但在两端把参考点拉到同一个电位上;如果现场干扰大或者距离远,直接上隔离型 485 模块,省掉后面一堆扯皮。
3. 工程落地:从 RS485_Init 到 LCD 显示的四段代码拆解
知道协议长什么样,下一步是把代码跑起来。文档里的工程分成四块:初始化、发送、接收、解析显示。这四段按顺序读一遍,基本能把整条链路吃透,也能看出哪些地方是"能跑"和"稳跑"之间的差距。
3.1 RS485_Init 逐行读:时钟、GPIO 模式与中断优先级
初始化函数看着长,实际就做四件事:开时钟、配引脚、配 USART、配中断。
void RS485_Init(u32 bound) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; // 1. 时钟:GPIOA/GPIOG 挂 APB2,USART2 挂 APB1,别开错总线 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOG | RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); // 2. TX = PA2,复用推挽,由 USART2 接管 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 3. RX = PA3,浮空输入,电平直接进 USART GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); // 4. USART2:9600 8N1 无流控,收发都开 USART_InitStructure.USART_BaudRate = bound; 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(USART2, &USART_InitStructure); USART_Cmd(USART2, ENABLE); USART_ClearFlag(USART2, USART_FLAG_TC); // 5. 接收中断 + NVIC 分组 2 下的抢占 3、子优先级 2 USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); NVIC_InitStructure.NVIC_IRQChannel = USART2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 3; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 2; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); RS485_TX_EN = 0; // 默认接收 }几个参数值得说清楚。波特率走形参bound传进来,主函数里传的是 9600,这是土壤类探头的默认值,8 位数据、1 位停止、无校验,也就是常说的 8N1,探头手册里如果写的是 8E1(带偶校验),这里的USART_Parity_No就必须改成USART_Parity_Even,忘了改就是满屏乱码。
中断优先级分组在主函数里设成了NVIC_PriorityGroup_2,2 位抢占、2 位响应,所以这里抢占 3、子优先级 2 才在合法范围内。分组和优先级这两处是一套的,单独改任意一个都可能让优先级数值越界或者行为不符合预期。另外USART_ClearFlag(USART2, USART_FLAG_TC)这行容易被忽略,它的作用是清掉使能前遗留的发送完成标志,不清的话第一个字节发送时while等待会立刻通过,看起来"发得快",实际可能丢首字节。
3.2 发送函数与 TC 标志:为什么每个字节都要等
发送函数的骨架是:拉高方向脚、循环写数据、等最后一个字节真正移出去、再拉低方向脚。
void RS485_Send_Data(u8 *buf, u8 len) { u8 t; RS485_TX_EN = 1; // 切到发送模式,占住总线 for (t = 0; t < len; t++) { // 等 TXE 更合适:发送数据寄存器空就可以写下一个 while (USART_GetFlagStatus(USART2, USART_FLAG_TC) == RESET); USART_SendData(USART2, buf[t]); } // 关键:等最后一个字节完全移出移位寄存器再放开总线 while (USART_GetFlagStatus(USART2, USART_FLAG_TC) == RESET); RS485_RX_CNT = 0; // 清接收计数,准备收响应 RS485_TX_EN = 0; // 切回接收模式 }TC(Transmit Complete)和TXE(Transmit Empty)是两个不同的标志,新手最容易混。TXE 置位表示发送数据寄存器空了,可以塞下一个字节,但此时上一个字节可能还在移位寄存器里往外吐;TC 置位才代表最后一个 bit 真正从引脚上出去了。在循环里用 TC 等待,每发一个字节都要等整帧时间,效率低但绝对安全;更常见的写法是循环里等 TXE,循环结束后再等一次 TC。这份代码用的是全 TC 等待,9600 波特率下一个字节约 1.04ms,8 个字节光等待就 8ms 左右,对轮询周期 3 秒的采集场景完全无所谓。
真正致命的是把"等 TC"省掉。如果最后一个字节刚写进数据寄存器就把方向脚拉低,收发器立刻从发送切到接收,那个还没移完的字节会被硬生生截断——探头收到的是 CRC 不完整的帧,直接丢弃,现象就是"有时候能读、有时候读不到",而且换块板子现象还不一样,属于典型的玄学故障。稳妥做法是在拉低方向脚前补一个几十微秒到 1ms 的短延时,给收发器和总线留出切换时间。
3.3 接收函数与 10ms 静默判帧:这个土办法的边界
接收部分没有用协议层的长度字段,用的是"静默判帧":中断里逐字节往缓冲区塞并累加计数,主循环里隔 10ms 看计数有没有再变,没变就认为一帧收完了。
// 中断:只做搬运和计数,不做解析 void USART2_IRQHandler(void) { u8 res; if (USART_GetITStatus(USART2, USART_IT_RXNE) != RESET) { res = USART_ReceiveData(USART2); if (RS485_RX_CNT < 64) { // 防溢出,缓冲区是 64 字节 RS485_RX_BUF[RS485_RX_CNT++] = res; } } USART_ClearFlag(USART2, USART_FLAG_TC); } void RS485_Receive_Data(u8 *buf, u8 *len) { u8 rxlen = RS485_RX_CNT; u8 i = 0; *len = 0; delay_ms(10); // 静默窗口 if (rxlen == RS485_RX_CNT && rxlen) { // 计数没变且非零,判为一帧结束 for (i = 0; i < rxlen; i++) buf[i] = RS485_RX_BUF[i]; *len = RS485_RX_CNT; RS485_RX_CNT = 0; } }这个思路能用,但边界很清楚。10ms 是按 9600 波特率拍的:一个字节 10 个 bit 约 1.04ms,Modbus-RTU 规定帧间隔至少 3.5 个字符时间,9600 下约 4ms,取 10ms 留了余量。要是把波特率提到 115200,一个字节只有 87μs,3.5 字符时间不到 0.3ms,10ms 的窗口就太长了,总线上一旦有别的从站插话,两帧会被拼成一帧。
另一个隐患在中断里。文档给出的原始中断函数里,收到一个字节后会拉高方向脚、delay_ms(1)、再把数据原样发回去,也就是做了个回显。这段逻辑在中断上下文里做毫秒级阻塞,主循环的时序会被拖慢;更麻烦的是回显会把自己发的请求帧又送回总线,接收缓冲里混进一份自己的指令,解析时下标全错位。我在自己板子上第一件事就是把这段回显删干净,中断里只留搬运和计数,任何解析、打印、延时都挪到主循环。
3.4 数据解析:除以 100 和除以 10 的来历
响应帧回来是 9 个字节:地址、功能码、字节数、4 个数据字节、2 个 CRC。两个寄存器拼成温度和湿度,各占 2 字节,高字节在前。
RS485_Send_Data(TR_Read, 8); delay_ms(300); // 给探头留响应时间 RS485_Receive_Data(rs485buf, &len); if (len) { if (len > 9) len = 9; // 只认前 9 字节 // 下标 3、4 是第一个寄存器(温度),5、6 是第二个(湿度) WD = (rs485buf[3] * 256 + rs485buf[4]) / 100; // 温度:放大 100 倍存 SD = (rs485buf[5] * 256 + rs485buf[6]) / 10; // 湿度:放大 10 倍存 LCD_ShowxNum(30, 190, WD, 4, 24, 0); LCD_ShowxNum(30, 230, SD, 4, 24, 0); } delay_ms(3000); // 3 秒一轮询*256 + 低字节就是把两个 8 位拼成 16 位,等价于左移 8 位再或。除以 100 和除以 10 是探头的定点数约定:温湿度寄存器里存的是放大后的整数,比如 2530 代表 25.30℃。不同厂家这个比例可能不同,有的是 10 倍,有的是原值,接到新探头时先拿已知环境(室温、干燥空气)对一遍量级,比对着手册逐位猜快得多。
if (len > 9) len = 9这行是个防御性截断,防止缓冲区里混进多余字节导致越界读取。但它只截不校验,真正的健壮做法是加一层帧头判断:rs485buf[0]==0x01 && rs485buf[1]==0x03 && rs485buf[2]==0x04,三个条件全满足才认为是一帧合法响应。少了这一步,总线上任何一段杂波只要长度够,都会被当成温湿度数据送进 LCD,屏幕上就会跳出一串莫名其妙的数字。
提示:主函数里
uart_init(9600)和RS485_Init(9600)是两个不同的串口,都写死 9600。提速时只改一处,另一处还是 9600,调试口和总线口对不上,抓日志会以为探头没回。
4. 联调排查:五条踩过的坑,按现象对号入座
代码抄完上电,第一次就能出数的概率不高。下面这五条是我在不同板子上反复撞见的,按"现象 → 原因 → 解决"整理,出问题时从上往下排,比盲目改代码省时间。
4.1 发得出去收不回来,LCD 一直是 0
现象:用示波器能看到 PA2 上有完整的 8 字节波形,但从头到尾没有响应,len一直是 0。
原因:两种可能最常见。一是方向脚切回接收太早,最后一个字节没移完总线就被放开,探头收到残帧直接丢;二是方向脚压根没生效,收发器始终在发送态,把总线拉死,谁也发不出来。
解决:先在发送函数末尾的RS485_TX_EN = 0前加 1ms 延时,确认是不是切换时序问题;再用万用表量方向脚在发送前后有没有电平翻转,不翻转就回头查宏定义和初始化端口是否一致。
4.2 温度跳成 655 或者几万,明显不对
现象:能收到数据,len也有值,但解析出来的温度是个荒唐的大数,偶尔还会变。
原因:缓冲区里混进了不该有的帧。最常见的是中断里的回显逻辑把自己的请求帧也收了进来,rs485buf[3]拿到的是请求帧的寄存器地址而不是温度高字节,下标整体错位。另外总线上如果有第二个从站同时应答,两帧拼在一起也会这样。
解决:把中断里的回显整段删掉;解析前加帧头三条件判断buf[0]==0x01 && buf[1]==0x03 && buf[2]==0x04,不满足直接丢弃本帧,不参与解析。这一步加上之后,误码基本不会再变成错误数据。
4.3 偶发乱码,重启后又能好一阵
现象:大部分时候正常,跑几小时后开始出乱码,断电重启恢复。
原因:中断里做了阻塞操作。原始代码在中断里delay_ms(1)加发送,主循环的时序被打乱,接收缓冲在忙的时候被新字节覆盖,帧就碎了。
解决:中断只保留ReceiveData加写缓冲加计数三行,所有延时、发送、解析全部挪回主循环。顺带把接收缓冲的边界判断补上,RS485_RX_CNT < 64这个条件不能省,超了就不写,防止把内存踩穿。
4.4 工位上好好的,拉到现场就不稳
现象:桌面短线一切正常,换到几十米的双绞线后误码率飙升,或者干脆收不到。
原因:终端电阻缺失、A/B 极性接反、没共地,这三件事任何一个都够。桌面短线分布参数小,问题被掩盖了。
解决:总线两端各并一只 120Ω;万用表量 A、B 之间空闲电压确认极性;A、B 之外补一根地线,两端共地。做完这三步再测,还不行就考虑换隔离型收发模块。
4.5 换了新探头,一个字节都收不到
现象:原来的探头能读,换一只同类型的新探头就完全没响应。
原因:新探头的从站地址、波特率或者校验位跟代码里的不一致,也可能是寄存器起始地址不同,读的地址根本没有数据,探头按协议返回异常码 0x83,而代码只认功能码 0x03,直接把异常帧当垃圾扔了。
解决:先用 USB 转 485 工具配合串口助手手动发一帧,把地址从 0x01 扫到 0x0F,看哪个地址有回应;再看回应的功能码是不是 0x83,是的话读返回的异常码,0x02 是地址非法、0x03 是数据非法,比盲改代码快得多。
5. 从单点到总线:多节点轮询、CRC 校验与超时重发
单只探头跑通只是及格线,485 真正的价值在一条总线上挂多只设备。文档只给了单节点读法,实际项目里往往是温湿度、光照、pH 几只探头共用一对差分线,这时候代码结构要跟着变。
5.1 地址轮询与轮询周期的分配
多节点的基本玩法是主站轮询:主站按地址顺序一个个问,从站只在被点到名时回答,其余时间闭嘴。这就要求每帧之间的间隔要留够,让上一个从站彻底释放总线,也让下一个从站有时间准备。常见的周期分配是这样:
| 环节 | 时间预算(9600bps) | 说明 |
|---|---|---|
| 发送 8 字节请求 | 约 8.3ms | 含每字节 10bit |
| 从站处理 + 应答 9 字节 | 约 12ms | 从站响应时间看手册,常见 10~50ms |
| 主站静默判帧 | 10ms | 代码里的固定窗口 |
| 帧间保护间隔 | ≥5ms | 保证从站状态机复位 |
| 单节点合计 | 约 40ms | 留一倍余量按 80ms 排 |
按每节点 80ms 算,8 个节点一轮 640ms,主循环里那个delay_ms(3000)就明显偏长了。改成按节点数动态算:delay = 节点数 * 80 + 500,既不会把总线占满,也不至于让刷新慢到看不出变化。轮询表用一个数组存地址,循环里改TR_Read[0]并重算 CRC 就行。
5.2 手写 CRC16:发送前算一次,接收后校验一次
固定帧可以提前把 CRC 算好写死,一旦地址可变就必须运行时计算。F103 上没有硬件 CRC 外设能直接对上 Modbus 的多项式,软件实现最省事:
// Modbus-RTU CRC16,返回值为最终结果,发送时低字节在前 u16 crc16_modbus(const u8 *buf, u16 len) { u16 crc = 0xFFFF; u16 i, j; for (i = 0; i < len; i++) { crc ^= buf[i]; for (j = 0; j < 8; j++) { if (crc & 0x0001) { crc >>= 1; crc ^= 0xA001; // 多项式 0x8005 的反序 } else { crc >>= 1; } } } return crc; } // 用法:发送前拼帧 void build_read_frame(u8 addr, u16 reg, u16 num, u8 *out) { u16 crc; out[0] = addr; out[1] = 0x03; out[2] = reg >> 8; out[3] = reg & 0xFF; out[4] = num >> 8; out[5] = num & 0xFF; crc = crc16_modbus(out, 6); out[6] = crc & 0xFF; // 低字节在前 out[7] = crc >> 8; }参数说明:addr是从站地址,范围 1~247,0 是广播地址不能用于读;reg是起始寄存器,注意是 16 位;num是要读的寄存器个数,一次别超过 125 个,超了从站会返回异常。接收侧的处理是先用len判断长度够不够,再算一遍前len-2字节的 CRC,跟帧尾两个字节比对,不等就丢弃。这一步加上以后,总线上偶发的干扰帧基本进不来,比单纯判帧头可靠得多。
5.3 超时重发与失败降级
工业现场没有"一定能通"这回事,代码必须假设会失败。基本结构是:发一帧、等响应、超时就重发,连续三次失败标记该节点离线,跳过它继续轮询下一个,别让一只坏探头把整条总线拖死。
u8 poll_node(u8 addr, u8 *buf, u8 *len) { u8 retry; for (retry = 0; retry < 3; retry++) { build_read_frame(addr, 0x0006, 0x0002, TR_Read); RS485_Send_Data(TR_Read, 8); delay_ms(100); // 等从站应答,按手册留余量 RS485_Receive_Data(buf, len); if (*len == 9 && buf[0] == addr && buf[1] == 0x03) { return 1; // 收到合法响应 } } return 0; // 三次都失败,标记离线 }retry上限取 3 是个经验值:1 次太脆,5 次以上会把一轮轮询拉得很长。重发间隔不能太短,探头从收到指令到准备好数据往往要十几毫秒,间隔给到 100ms 比较稳。离线节点不要立刻剔除,隔几轮再试一次,有些探头是上电瞬间没准备好,过一会儿自己就活了,直接判死反而要人工重启。
6. 进阶:把 10ms 静默判帧换成空闲中断加 DMA
主循环加delay_ms(10)判帧,在单节点 3 秒轮询的场景里完全够用,但它把 CPU 绑住了:判帧期间什么都干不了,波特率一往上提,10ms 的窗口又变得不准。想再往前走一步,常见做法是换成串口空闲中断(IDLE)加 DMA 接收,让硬件帮你数字节,帧结束由 IDLE 事件告诉你。
配置顺序是:先按原来的方式配好 USART2 和方向脚,再把 RX 接到 DMA1 通道 6,最后打开 IDLE 中断。
// DMA1 通道6 对应 USART2_RX void RS485_Init_DMA(u32 bound) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel6); DMA_InitStructure.DMA_PeripheralBaseAddr = (u32)&USART2->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (u32)RS485_RX_BUF; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = 64; 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_Normal; DMA_InitStructure.DMA_Priority = DMA_Priority_Medium; DMA_Init(DMA1_Channel6, &DMA_InitStructure); DMA_Cmd(DMA1_Channel6, ENABLE); USART_DMACmd(USART2, USART_DMACmd_Receive, ENABLE); USART_ITConfig(USART2, USART_IT_IDLE, ENABLE); // 关键:开空闲中断 } // 空闲中断里取长度,比软件延时判帧准得多 void USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_IDLE) != RESET) { USART_ReceiveData(USART2); // 读 DR 清 IDLE 标志 DMA_Cmd(DMA1_Channel6, DISABLE); rs485_len = 64 - DMA_GetCurrDataCounter(DMA1_Channel6); DMA_SetCurrDataCounter(DMA1_Channel6, 64); // 重新装填 DMA_Cmd(DMA1_Channel6, ENABLE); } }参数上要注意两点:DMA_Mode用 Normal 不用 Circular,因为每次帧结束都要手动重装计数器,Circular 模式下长度算不准;IDLE 标志必须靠"先读 SR 再读 DR"这个序列清除,只调USART_ClearFlag在部分型号上清不掉,现象是中断进去一次就再也不进了。
验证这套东西有没有真的生效,我一般走三步。第一步,用 USB 转 485 工具接在总线上,串口助手手动发一帧读取指令,看探头是否有回应,先把探头本身排除掉;第二步,把rs485_len和缓冲区前 9 个字节通过调试串口打出来,确认01 03 04开头,长度正好 9;第三步,把帧尾两个字节和crc16_modbus(buf, len-2)的结果对一遍,对得上说明整帧无损。三步都过,才谈得上稳定。
血泪经验是:不要在主循环里delay_ms(10)判完帧就直接解析,一定要先过帧头三条件和 CRC 两道闸。我早前图省事跳过 CRC,桌面上跑了一周都没事,装到现场第二天就开始在 LCD 上显示负温度,查了半天才发现是一帧干扰数据被当成了正常响应。从那以后我每次接新探头,都强制走一遍"抓帧 → 对帧头 → 算 CRC → 对量级"这四步,一步不省。希望帮到你。
本文还有配套的精品资源,点击获取