简介:这是一份围绕STM32F103微控制器与RS485通信展开的入门实践文档,面向刚接触工业总线通信的嵌入式学习者和电子类学生,用于解决半双工485收发切换、串口驱动编写与传感器数据读取等常见入门问题。压缩包共1个docx文件,约1.22MB,以图文与代码混合的文档形式组织,便于边看原理边对照例程。文档从RS485总线抗干扰、远距离、多节点特性讲起,说明如何借助UART2与DE使能引脚完成收发模式切换,并给出RS485_Init、RS485_Send_Data、RS485_Receive_Data等函数的实现思路与波特率配置方法,还梳理了头文件中接收缓冲区、长度变量与模式控制宏的定义方式。主函数部分以土壤温湿度传感器为例,展示发送读取指令、延时等待、解析响应并在TFTLCD上显示数值的完整流程,同时涉及延时函数、中断优先级分组与数据换算等细节。目前已有2629人学习,适合作为RS485通信原理与STM32串口编程的对照参考。
1. 从一块土壤探头说起:这份 STM32 RS485 文档能省下你多少时间
STM32 RS485 这个组合,第一次上手几乎没人一次接对。我最早拿到的是一块土壤温湿度探头:四根线,两根供电、两根 A/B,探头不认串口助手,只认指令。接上 F103 开发板跑起来后,串口助手一片安静,示波器上只有自己发出去的波形,回来的线上什么也没有——最后发现只是方向控制脚没翻过来。
这份 STM32F103 RS485 传感器文档把整条链路摊开了:USB 转 485 收发芯片上那根方向脚怎么切,USART2 怎么初始化,01 03 00 06 00 02 C4 38这条读寄存器指令每个字节什么意思,回来的 9 个字节怎么截取,温湿度怎么从四个字节里凑出来显示到 TFTLCD 上。刚拿到 F103 板子和 485 探头、想一次把链路打通的人能靠它省下大量排查时间;已经能跑但搞不清为什么要delay_ms(10)的人,也能从里面看到自己代码里那句延时到底在等什么。
2. 硬件与协议底座:差分总线、收发器方向脚和 USART2 的三角关系
485 这一层如果没搞明白,软件调得再细也白搭。摄像头、串口屏、传感器这类外设接在同一个 485 总线上,谁说话、谁说完了、下一句什么时候轮到,全靠硬件和时序配合。这一章先把硬件和协议底座理顺,写驱动的时候才不至于写一句、改一句。
2.1 A/B 两根线撑起 1200 米:差分传输到底强在哪
RS485 用的是差分信号,逻辑“1”和“0”靠 A、B 两根线之间的电压差来判断,而不是靠对地的绝对电压。接收端只看两根线之间的差值,外界耦合到一对双绞线上的干扰往往是共模的,两线同时抬高或同时压低,差值不变,信号就还在。这是它能拉到上千米、能在车间里活下来的根本原因——不是协议多高级,是对干扰天然免疫。
接线的时候有几条硬规矩,我头几次全踩过:
- 手拉手串联拓扑,不要从总线上分叉出去接成星形。星形接法会让分支末端形成反射点,距离一长,波形上就会出现台阶和振铃。
- 总线两端各放一只 120Ω 终端电阻,位置在物理最两端,不在中间节点。
- 屏蔽双绞线优先,屏蔽层单端接地,别两头都接,否则会形成环流。
- A/B 的命名各家不统一,有的把 A 标成正、有的把 A 标成负。别信丝印,先看手册标注,或者拿万用表量空闲态压差确认一遍。
在长线或者节点多的场景里,总线空闲时可能会浮在一个不确定的电平上,常见做法是在 A 上拉到 3.3V、B 下拉到地,各加一只 1kΩ~4.7kΩ 的偏置电阻,让空闲状态稳定在逻辑 1。这一招在干扰大的现场很管用,能消掉一类“白天正常、晚上乱码”的玄学问题。
2.2 DE/RE 一个引脚定生死:收发切换的时序要点
F103 片上只有 UART,没有 485。板上一定有一颗收发器芯片,它有两个控制脚:DE(发送使能)和 RE(接收使能,低有效)。多数模块把这两个脚并用,一根 GPIO 就够:
- GPIO 输出高 → DE=1、RE=1 → 芯片进入发送模式;
- GPIO 输出低 → DE=0、RE=0 → 芯片回到接收模式。
半双工的意思就在这:同一时刻只能发或者只能收,两者互斥。总线上几十个设备,谁把发送使能拉高了谁就在说话,其他所有设备必须处于接收态,否则两条线互相打架,谁的数据都传不出去。
时序上最容易翻车的是“什么时候把 GPIO 拉低”。很多人发完最后一个字节就立刻拉低,结果最后一个字节的低位还没从移位寄存器里出去,被硬生生截断了。正确姿势是等 USART 的TC(Transmission Complete)标志位置位,而不是TXE(Transmit Data Register Empty)。TXE 只说明数据寄存器空了、可以塞下一个字节,但移位寄存器里还有东西在往外吐;TC 才表示真的发完了。文档里RS485_Send_Data用的正是USART_FLAG_TC,这一点没写错:
/* 发送完成后必须等 TC,不能等 TXE */ while (USART_GetFlagStatus(USART2, USART_FLAG_TC) == RESET); RS485_RX_CNT = 0; // 发完清接收计数,避免把旧数据当新回包 RS485_TX_EN = 0; // 切回接收模式最后那句RS485_RX_CNT = 0是很有用的一手。发送期间如果接收缓冲区里还残留着上一轮的字节,不清零的话接收函数会把它们拼到这次的回包前面,长度和内容全错,表现出来就是“偶尔能读出数、偶尔读出一堆乱码”。
2.3 F103ZET6 引脚分配:PA2、PA3 和那个对不上的方向脚
STM32F103ZET6 用的是 USART2 挂 485(USART1 留给串口打印和 LCD 调试)。引脚分配是固定的:
| 信号 | 引脚 | GPIO 模式 | 说明 |
|---|---|---|---|
| USART2_TX | PA2 | 复用推挽输出 AF_PP,50MHz | 连收发器 DI |
| USART2_RX | PA3 | 浮空输入 IN_FLOATING | 连收发器 RO |
| 方向控制 DE/RE | 文档头文件定义 PD7 | 推挽输出 Out_PP | 高=发送,低=接收 |
| 电源 | 3.3V / GND | — | 收发器与单片机共地 |
这里有份资料里一个必须指出的对不上的地方:rs485.h里宏定义写的是
#define RS485_TX_EN PDout(7) // 485 模式控制也就是方向脚放在PD7;但RS485_Init里实际配置的是:
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3; // 注释写 CS-485 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_Init(GPIOG, &GPIO_InitStructure); // 配的是 PG3也就是把PG3配成了推挽输出。宏和初始化对不上,编译不报错,跑起来就是“我明明拉高了,芯片却没进发送态”。落地之前必须先对着板子原理图确认方向脚到底接在哪个脚上,然后二选一统一:要么把宏改成PGout(3),要么把GPIO_Init里的端口和 Pin 改成 GPIOG 之外的 GPIOG…准确说是改成与宏一致的 PORTD 和GPIO_Pin_7。除此之外,GPIO_Mode_IN_FLOATING上面那行注释写的是“模拟输入”,这是笔误,浮空输入和模拟输入在 F103 里是两个不同的模式,工程笔记里最好顺手改掉,不然新人照抄会一头雾水。
还有一点:PA3 配成浮空输入是为了让收发器的 RO 直接驱动,别在这里开内部上拉。如果板上 RO 带上拉或者有外部上拉,浮空就够,开了反而在空闲态多吃一点电流;如果现场干扰大导致空闲误触发,优先在硬件上加偏置电阻,而不是在软件里把 PA3 改成上拉来凑合。
3. 驱动层逐段拆:从 RS485_Init 到 RS485_Receive_Data 的完整链路
驱动这一层是整份资料最厚的地方,也是最值得逐行看的地方。它分成四块:初始化、中断接收、发送、带空闲判定的接收。下面按调用顺序拆,顺便把原文里几个容易照抄错的地方标出来。
3.1 RS485_Init:四步配置和两处容易照抄错的细节
这个函数的骨架是标准的 F103 标准库流程:开时钟 → 配 GPIO → 配 USART → 配 NVIC。顺序不能乱,尤其是 NVIC 要在 USART 使能中断之前配好,否则中断可能在优先级还没设定时就抢进来一次。
void RS485_Init(u32 bound) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; /* 1. 开时钟:GPIOA 给 TX/RX,GPIOG 给方向脚,USART2 挂在 APB1 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOG | RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); /* 2. TX = PA2,复用推挽,必须是 AF_PP 而不是 Out_PP */ 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,浮空输入,由收发器 RO 直接带动 */ 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_ClearFlag(USART2, USART_FLAG_TC); USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); // 开接收非空中断 USART_Cmd(USART2, ENABLE); /* 5. NVIC:抢占 3、子优先级 2 */ 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 就是 9600,写 115200 是连不上的,这一层没有自适应。数据格式 8 位、1 停止位、无校验,是绝大多数 485 传感器的默认值,如果手册上写的是 8E1(偶校验),USART_Parity要改成USART_Parity_Even,USART_WordLength同步改成USART_WordLength_9b——这一点常被忽略,因为多数人抄代码时压根不看手册的格式那一栏。
两处容易照抄错的细节:一是优先级分组在main里设的是NVIC_PriorityGroup_2,2 位抢占 + 2 位响应,那抢占优先级最大只能是 3、子优先级最大也只能是 3,写成 4 以上会被截断成 0,表现是“优先级设了但排序怪怪的”;二是方向脚初始化必须先于USART_Cmd,否则从串口使能到方向脚配好之间有一个短暂窗口,那段时间引脚状态不确定。
3.2 中断服务函数:接收缓冲该在哪累加
文档给的中断函数是这样的:
void USART2_IRQHandler(void) { u8 res; if (USART_GetITStatus(USART2, USART_IT_RXNE) != RESET) { res = USART_ReceiveData(USART2); // 读 DR 自动清 RXNE RS485_TX_EN = 1; // 立刻切成发送 delay_ms(1); USART_SendData(USART2, res); // 把收到的字节原样回出去 while (USART_GetFlagStatus(USART2, USART_FLAG_TC) != 1); delay_ms(2); RS485_TX_EN = 0; } USART_ClearFlag(USART2, USART_FLAG_TC); }这段代码的本质是一个“回显测试”:收到什么就原样吐回去,用来快速验证硬件通路通不通,接 USB 转 485 在电脑上敲字符,能原样看到就说明 A/B、方向脚、波特率全对。作为自检手段它很好用。
但它不能和主循环的采集逻辑同时存在。原因有三个:
第一,中断里做了delay_ms(1)和delay_ms(2)。中断服务函数里做毫秒级阻塞,直接把主循环的时序打乱,传感器回 9 个字节可能只花 10ms 左右,中断每次进去阻塞 3ms,很容易在字节之间错过接收窗口。
第二,回显会把总线上其他设备的回包当成要转发的数据再发一遍,多节点场景下直接制造总线冲突。
第三,也是最关键的,这段代码里根本没有往RS485_RX_BUF里存数据、也没有累加RS485_RX_CNT。而RS485_Receive_Data要靠这两个全局量判断有没有收到、收到多长。也就是说,如果只跑这段中断,接收长度永远是 0,主循环里的if(len)分支永远进不去。
实际采集用的中断应该长这样——只搬运、不做业务:
void USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_RXNE) != RESET) { u8 res = USART_ReceiveData(USART2); // 读 DR,硬件自动清 RXNE if (RS485_RX_CNT < sizeof(RS485_RX_BUF)) { RS485_RX_BUF[RS485_RX_CNT++] = res; // 存进缓冲并累加长度 } // 不在这里发数据、不在这里延时 } }缓冲区大小 64 字节,对 8 字节请求 + 9 字节响应绰绰有余,但如果后面改成一次读 10 个寄存器(响应 25 字节)或者做多段拼接,就要往上抬到 128 甚至 256,并且加上溢出保护——上面那个if判断就是干这个的,不加的话一旦总线噪声把计数推过头,就会踩到相邻变量。
3.3 发一帧与收一帧:TC 标志位和 10ms 空闲判定
发送函数的逻辑很短,但每一句都有用:
void RS485_Send_Data(u8 *buf, u8 len) { u8 t; RS485_TX_EN = 1; // 抢占总线 for (t = 0; t < len; t++) { while (USART_GetFlagStatus(USART2, USART_FLAG_TC) == RESET); USART_SendData(USART2, buf[t]); // 逐字节塞 DR } while (USART_GetFlagStatus(USART2, USART_FLAG_TC) == RESET); // 等最后一个字节出完 RS485_RX_CNT = 0; // 清接收计数,开新一轮 RS485_TX_EN = 0; // 交还总线 }参数buf是报文首地址,len是字节数,按 Modbus 读寄存器的请求固定是 8。这里要注意循环里的等待放在发送之前,语义是“等上一个字节彻底发完再塞下一个”,跟“塞完再等”效果一样,但能保证第一次进循环时不会在 TC 未置位的情况下硬写 DR。
接收函数用的是一个很朴素的空闲判定:
void RS485_Receive_Data(u8 *buf, u8 *len) { u8 rxlen = RS485_RX_CNT; u8 i = 0; *len = 0; delay_ms(10); // 等 10ms,期间没有新字节就认为收完 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 再看一次,如果两次相同说明这 10ms 内没有新字节进来,判定一帧结束。这是在没有硬件空闲中断、也没有定时器的情况下最省事的做法。9600 波特率下一个字节约 1.04ms(10 位 / 9600),一帧 9 字节大概 9.4ms,10ms 的空闲窗口刚好卡在“帧内字节间隔远小于 10ms、帧间间隔大于 10ms”的边界上,属于能用但不算稳。
参数上要留意*len是输出参数,进来先清 0,只有真正判定收完才写实际长度。主循环里if(len)就是靠这个 0 值来判断“有没有新数据”。
代价也很明显:这个函数阻塞 10ms。一次采集循环里发一次、收一次,加上delay_ms(300)和delay_ms(3000),整轮三四秒,单点采集完全够用;但如果要挂 10 个节点、每 200ms 轮询一次,这个阻塞就会拖垮整个节奏,得换成定时器 + 空闲中断或者 DMA + 帧超时判定的写法。
3.4 报文解析:01 03 00 06 00 02 C4 38 每个字节是什么
主循环里那条指令是整份资料的核心,拆开看:
u8 TR_Read[8] = {0x01, 0x03, 0x00, 0x06, 0x00, 0x02, 0xC4, 0x38};| 字节位置 | 值 | 含义 |
|---|---|---|
| [0] | 0x01 | 从机地址,总线上区分设备用 |
| [1] | 0x03 | 功能码,读保持寄存器 |
| [2..3] | 0x00 0x06 | 起始寄存器地址 0x0006 |
| [4..5] | 0x00 0x02 | 读 2 个寄存器,共 4 字节数据 |
| [6..7] | 0xC4 0x38 | CRC16 校验,低字节在前 |
这是标准 Modbus RTU 帧,没有帧头帧尾,靠 3.5 个字符时间的静默来分帧。回包格式固定为:地址 + 功能码 + 字节数 + 数据 + CRC,读 2 个寄存器时字节数是 4,整帧 9 个字节。主循环里那句
if (len > 9) len = 9; WD = (rs485buf[3] * 256 + rs485buf[4]) / 100; SD = (rs485buf[5] * 256 + rs485buf[6]) / 10;rs485buf[0]是地址回显、[1]是功能码回显、[2]是字节数 04,真正的数据从[3]开始。第一个寄存器高字节在[3]、低字节在[4],拼成 16 位后除以 100;第二个寄存器在[5]、[6],拼完除以 10。
这里是整份资料里最需要对着传感器手册确认的地方:哪个寄存器是温度、哪个是湿度、缩放系数是 10 还是 100,不同厂家的定义完全不一样。代码里变量命名是WD(温)和SD(湿),但取的是第 1、第 2 个寄存器,缩放分别除 100 和除 10。我见过同一型号换批次后系数互换的情况,也见过干脆是两个寄存器都是除以 10 的。落地时不要照抄命名,先拿手册的量程表反推一遍:正常室温下读出来的原始值是多少,除以系数后是否落在合理区间,两分钟就能验完。
长度限制那句if(len > 9) len = 9是个防护,防止噪声导致长度异常时越界读rs485buf。但注意它只截上限不校验下限,如果len是 5,rs485buf[6]读的其实是上一次的残留数据,显示结果会莫名其妙。严格做法是要求len == 9才解析,其余一律丢弃重试——这一点在第 6 章的改造里会补上。
4. 把工程跑起来:文件清单、初始化顺序与上机自检三步法
代码能看懂和工程能跑起来是两回事。这一章按“文件在哪、谁先初始化、怎么验证”把复现路径走一遍,照这个顺序做,第一次上手不大会卡住。
4.1 工程骨架与文件分工
工程是标准库风格,没有上 RTOS,命名也很直白。典型文件构成如下:
| 文件 | 作用 | 是否可裁剪 |
|---|---|---|
main.c | 主循环,拼指令、收数据、刷 LCD | 否 |
rs485.c / rs485.h | 485 初始化、收发、缓冲区 | 否 |
usart.c / usart.h | USART1 初始化 + printf 重定向 | 可改用途 |
lcd.c / lcd.h | TFTLCD 驱动,显示用 | 可换 OLED/串口 |
delay.c / delay.h | SysTick 延时,毫秒/微秒 | 否 |
sys.c / sys.h | 位带操作宏,PDout(7)这类 | 否 |
key.c / key.h | 按键,主循环没用到 | 可删 |
sys.h里的位带宏是这套代码能写RS485_TX_EN = 1这种神语句的原因:它把某个 GPIO 的输出位映射成内存里一个 bit 的别名,赋值就是写寄存器。好处是快且好读,坏处是换芯片型号或者换端口时宏定义必须同步改,这也是上一章那个 PD7/G3 对不上的根源。
LCD 部分其实是可选件:显示只是把结果可视化,调试阶段完全可以把LCD_ShowxNum换成printf走 USART1 打到串口助手,改起来只动两行。uart_init(9600)是给 USART1 用的,跟 485 的 USART2 互不干扰,两路串口独立配置,共用 9600 只会影响打印速度,不影响通信。
4.2 初始化顺序:时钟、延时、USART1、LCD、USART2
main的初始化段落顺序不能随意调换,尤其是延时要最先:
int main(void) { u8 i, len = 0; delay_init(); // SysTick,最先,后面全靠它 NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); // 2 位抢占 + 2 位响应 uart_init(9600); // USART1,调试打印用 LCD_Init(); // TFTLCD 初始化 RS485_Init(9600); // USART2 + 方向脚,最后 POINT_COLOR = BLUE; delay_ms(300); // 等总线稳定 while (1) { RS485_Send_Data(TR_Read, 8); delay_ms(300); // 给传感器留处理时间 RS485_Receive_Data(rs485buf, &len); if (len) { if (len > 9) len = 9; WD = (rs485buf[3] * 256 + rs485buf[4]) / 100; SD = (rs485buf[5] * 256 + rs485buf[6]) / 10; LCD_ShowxNum(30, 190, WD, 4, 24, 0); LCD_ShowxNum(30, 230, SD, 4, 24, 0); } delay_ms(3000); } }delay_init()必须在所有需要延时的地方之前,包括LCD_Init内部往往就带延时;NVIC_PriorityGroupConfig要在任何 NVIC 初始化之前,一旦分组设晚了,后面配的优先级数值全按旧分组解释,中断顺序就跟预期不符。RS485_Init放最后是因为它会把方向脚拉低,如果 LCD 初始化过程中板上有共用引脚,先配 485 有可能被 LCD 的 GPIO 配置覆盖掉。
发送后那delay_ms(300)是给传感器留的响应时间,不是给的通信时间——9600 下 9 个字节只要 10ms 左右。有些低功耗传感器上电后要几百毫秒才响应,300ms 是一个偏保守但稳妥的值。轮询间隔 3000ms 对土壤温湿度来说完全够,土温变化本来就慢,采样太密没有意义还费电。
4.3 上机自检:先用电脑手动发一帧
血泪经验:先别急着往板子上接,用 USB 转 485 模块在电脑上手动发一帧,确认传感器是活的。这一步能砍掉一半的排查工作量。接好 A/B 和电源,用 pyserial 发原始字节:
import serial, struct # 与板子完全相同的请求帧:地址01 功能码03 起始0x0006 数量2 CRC=C4 38 req = bytes([0x01, 0x03, 0x00, 0x06, 0x00, 0x02, 0xC4, 0x38]) ser = serial.Serial('COM3', baudrate=9600, bytesize=8, parity='N', stopbits=1, timeout=1) ser.write(req) resp = ser.read(9) # 响应固定 9 字节 print('raw :', resp.hex(' ')) if len(resp) == 9 and resp[0] == 0x01 and resp[1] == 0x03: reg1 = (resp[3] << 8) | resp[4] reg2 = (resp[5] << 8) | resp[6] print('reg1=%d reg2=%d' % (reg1, reg2)) else: print('无响应或帧结构不对,先查 A/B 接反、地址、波特率') ser.close()这段脚本的关键是三件事:一是波特率和帧格式必须和板子完全一致,8N1、9600;二是timeout=1保证没响应时不会一直挂着;三是把原始字节打出来而不是只打解析值,因为排查时最需要看的是“到底一个字节都没回,还是回了但内容不对”,前者是硬件问题,后者是协议问题,两条完全不同的排查路线。
电脑上能稳定读出数,再把板子接上去。板子跑起来后如果读不到,问题就只在 MCU 侧:方向脚、初始化顺序、缓冲区、中断使能,范围一下子收窄了。
5. 避坑与排查:五条现场踩出来的经验
这一章按“现象 → 原因 → 解决”写,都是这套代码实际跑起来时会遇到的。每一条我都至少撞过一次,写下来省得重复交学费。
5.1 现象:程序在发,回线上永远是空的
原因:三类。一是方向脚宏定义和实际初始化对不上,RS485_TX_EN操作的是 PD7,但芯片实际接的是 PG3,写了等于没写,收发器一直待在接收或发送单边状态;二是 A/B 接反,收发器收到的是反相信号,一个字节都识别不出来;三是板子的收发器 RE 是低有效但硬件上拉成高,导致永远收不了。
解决:先用万用表量方向脚在发送瞬间有没有翻转,这是最快的判断;再拿示波器或者 USB 转 485 直接挂总线看有没有波形出去;A/B 对调试一次,两分钟的事,不要凭丝印较劲。确认是宏的问题就把PDout(7)和初始化里的端口统一,别只改一处。
5.2 现象:改了地址或者寄存器之后,传感器彻底不理人
原因:CRC 没有跟着重算。很多人手改报文时只改前面的数据字节,把最后两个字节原样保留,接收端一算 CRC 不匹配,按协议直接丢弃,连错误响应都不回。485 传感器在这种情况下的表现是“死寂”,非常容易被误判成硬件坏了。
解决:任何一个字节变了都要重算 CRC16,低字节在前。别手算,写个函数在发之前现算现填,第 6 章给了实现。另外注意 CRC 的初始值 0xFFFF、多项式 0xA001,跟有些资料里写的 0x1021 不是一套,用错了照样全部不通。
5.3 现象:len经常是 0,偶尔又有数据但内容是乱的
原因:中断里做了回显和delay_ms。文档里那段中断函数给的是回显测试代码,如果直接拿来跑采集,中断每次进去阻塞 3ms,9 字节的响应可能被打断,缓冲区里的字节数不对,RS485_Receive_Data里两次读RS485_RX_CNT不一致,判定成“还没收完”,直接返回长度 0。另一种情况是中断里压根没往RS485_RX_BUF存数据,长度永远是 0。
解决:中断只做搬运,把res存进缓冲、RS485_RX_CNT自增,别做任何发送和延时。回显测试代码单独留一个宏开关,验证完就关掉。
5.4 现象:数据能收到,但温度显示 655、湿度显示 0,或者两个值反了
原因:三种。一是起始寄存器地址不对,0x0006读到的是校准寄存器而不是测量值;二是缩放系数不对,代码里一个除 100 一个除 10,但实际型号可能都是除 10;三是高低字节顺序理解错,有的传感器手册写的是小端,实际发的是大端。
解决:把原始值打印出来对照手册的量程反推。室温 25℃ 左右,如果原始值读出 2500,系数就是 100;读出 250,系数就是 10。两个寄存器到底哪个是温度,用手捂一下探头看哪个值变,五秒钟就能确定,比翻手册快。
5.5 现象:单节点正常,一挂上第二个设备就时好时坏
原因:典型的总线问题。可能是两端没接 120Ω 终端电阻导致反射,可能是各设备没有共地导致共模电压超范围,也可能是两个设备地址配成了同一个,回包在总线上直接打架。还有一种容易被忽略的是星形接线,从主干上拉出两米线去接第二个设备,这段分支就是反射源。
解决:先查地址,逐个单独接上去确认地址不同;再查共地,所有设备的 GND 必须连在一起,只接 A/B 不接地是最常见的错;最后按手拉手改线,两端补终端电阻。现场干扰大的话补上偏置电阻,很多“白天好晚上坏”的问题就此消失。
6. 二次开发:从单点读温湿度到多节点轮询网关
把这块板子当玩具是一回事,做成能挂十几个探头、能上报的数据采集节点是另一回事。改造的核心只有两点:把写死的报文变成可配置的,把不可靠的一次收发变成带校验和重试的调用。
6.1 把指令拼装抽成通用 Modbus 读寄存器函数
原来的TR_Read是写死的 8 字节常量,加一个探头就要复制一份数组、手算一次 CRC。抽成函数之后,地址、起始寄存器、数量全变成参数:
/* Modbus CRC16:初值 0xFFFF,多项式 0xA001(0x8005 反序) */ static u16 crc16_modbus(const u8 *buf, u16 len) { u16 crc = 0xFFFF; u8 i; while (len--) { crc ^= *buf++; for (i = 0; i < 8; i++) { if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001; else crc >>= 1; } } return crc; }这段是查表法的位运算版,速度对 9600 波特率来说绰绰有余,没必要上 256 字节的表。注意返回值是 16 位整数,写进报文时要拆成低字节在前:req[6] = crc & 0xFF; req[7] = crc >> 8;,顺序反了对方一律不认。
6.2 加 CRC 校验与超时重试,别让一次丢包拖垮整个循环
原代码收到数据就解析,长度不对也硬解,这是最容易出脏数据的地方。改造成“校验通过才算成功、失败重试三次”:
/* 返回 1 成功,0 失败;out 至少 5+num*2 字节 */ u8 Soil_Read(u8 addr, u16 reg, u16 num, u8 *out, u8 *outlen) { u8 req[8], retry, len = 0; u16 crc; req[0] = addr; req[1] = 0x03; req[2] = reg >> 8; req[3] = reg & 0xFF; req[4] = num >> 8; req[5] = num & 0xFF; crc = crc16_modbus(req, 6); req[6] = crc & 0xFF; // CRC 低字节在前 req[7] = crc >> 8; for (retry = 0; retry < 3; retry++) { RS485_Send_Data(req, 8); RS485_Receive_Data(out, &len); /* 长度必须正好 5+2*num,且 CRC 对得上,地址和功能码回显也对 */ if (len == 5 + num * 2 && out[0] == addr && out[1] == 0x03 && crc16_modbus(out, len - 2) == (out[len - 2] | (out[len - 1] << 8))) { *outlen = len; return 1; } delay_ms(50); // 退让一下总线再重试 } *outlen = 0; return 0; }几个参数值得说明:num是寄存器数量,响应长度一定是5 + 2*num,用等式判断比len >= 9严格得多;retry三次是我的经验值,再多意义不大,总线本身有问题时重试只会拖慢轮询;重试之间的delay_ms(50)是给总线上其他节点让路,不加的话连续重试有可能一直撞在别人发送的窗口上。CRC 比对那行把两个字节拼回 16 位再和函数返回值比,注意拼的方向是低字节在前,写反了永远不会通过。
6.3 多节点轮询:地址表驱动,别写死循环
挂多个探头时,把地址和寄存器偏移做成一张表,主循环按表遍历:
typedef struct { u8 addr; // 从机地址,1~247 u16 reg; // 起始寄存器 u16 num; // 寄存器数量 u8 scale; // 缩放:10 或 100 } NodeCfg; static const NodeCfg nodes[] = { {0x01, 0x0006, 2, 10}, {0x02, 0x0006, 2, 10}, {0x03, 0x0006, 2, 10}, }; #define NODE_CNT (sizeof(nodes) / sizeof(nodes[0]))遍历时对每个节点调用Soil_Read,成功就刷屏、失败就标红或累计错误次数,连续失败超过阈值的节点做降级处理——比如打印一条告警然后跳过,不要让一个掉线的探头卡住整轮轮询。轮询间隔按NODE_CNT × (帧时间 + 处理时间)估,9600 下一个节点大约 30ms 往返,10 个节点 300ms 左右,主循环里再留 1~2 秒余量比较稳。
另一个实用改造是把RS485_Receive_Data里的delay_ms(10)换成定时器超时判定,让接收不再阻塞。做法不难:进函数时启动一个 10ms 定时器,中断里每收到一个字节就重置计时,超时中断里置“帧结束”标志,主循环轮询这个标志。改完之后整个循环的节奏就不再被固定的 10ms 卡死,想跑快跑慢都行。
最后说个我自己的习惯。这套代码我前后改过三版,第一版是照着抄能跑就行,第二版加了 CRC 和重试,第三版才把阻塞收发改成非阻塞。后来每次接新探头,我不再是先改代码再烧录,而是先在电脑上用一段脚本把地址、寄存器、缩放系数全部摸清楚,把确认过的参数填进nodes表,再一次性烧进去。从那以后我每次接新设备都强制走一遍“电脑先验证、参数再落表”的流程,省下的调试时间比多写几个函数划算得多。希望帮到你。
本文还有配套的精品资源,点击获取