简介:围绕STM32F103ZET6、RS485、DMA与Modbus协议展开的嵌入式通信工程资料,面向已具备单片机基础、希望打通工业主从通信链路的开发者与高校学习者。内容以Modbus RTU读写为核心,覆盖RS485方向控制、串口参数配置、DMA自动收发、0x03与0x06功能码解析、超时重试及通信恢复等关键环节,适合课程设计、毕业设计及工业物联网原型验证。压缩包共171个文件,约3.32MB,以38个.h头文件、37个.c源文件为主体,并含Keil工程配置、编译中间文件、HEX与MAP产物及批处理脚本,便于直接打开、编译和对照移植。已有8318人学习下载,说明其工程组织与主从通信示例具备较高参考价值。读者可据此梳理DMA与RS485的收发流程,理解Modbus报文构建与寄存器读写方法,并借鉴错误处理、超时机制和目录结构来搭建自己的通信框架。
1. 一条总线上挂八个节点,为什么偏偏第五个总是超时
去年帮一个做环境监测的团队看现场问题:一条 RS485 总线上挂了八个采集节点,主机轮询到第五个必超时,示波器抓波形毫无异常,MCU 侧收到的却永远是上一帧的尾巴。最后定位到 DE 方向引脚早了三个微秒翻转,把最后一个字节的停止位削掉了。这套 STM32 + RS485 + DMA + Modbus 协议的组合,真正难的地方从来不是把数据发出去,而是让 CPU 从字节级收发里彻底脱身:DMA 负责搬运,IDLE 中断负责切帧,定时器负责判超时,CPU 只在整帧落地后处理一次。它适合做工业采集、电表水表、传感器网关、PLC 外设这类"一串口挂多机、7×24 跑"的场景;如果你还在用while(HAL_UART_Receive())一个字节一个字节地轮询,这套东西值得从头拆一遍。
2. 硬件与工程骨架:三根线、四个外设和一套分层目录
动手前得先接受一个事实:485 是全靠硬件扛干扰的差分总线,软件写得再漂亮,接错一根线照样全是玄学问题。这一章先把物理层和工程骨架定死,后面三章才有地方落代码。
2.1 收发器这一侧必须先解决的四个硬件点
STM32 的 USART 出来是 TTL 电平,中间的 A/B 差分要靠一只 8 脚半双工收发器转换。它的 RE(接收使能,低有效)和 DE(发送使能,高有效)通常短接在一起,由一个 GPIO 控制:拉高进发送态,拉低回接收态。这个 GPIO 的翻转时机,就是后面所有时序问题的源头。
四个容易被忽略的点:一是 A/B 之间那只 120Ω 终端电阻只能挂在总线的最两端,中间节点挂了会把总线负载压得过重,通信距离越短越明显;二是所有节点的 GND 必须共地,长距离下拉一根地线比多绞一对双绞线管用;三是总线空闲时要有确定的电平,A 上拉、B 下拉的偏置电阻在节点多的时候能防止空闲误触发;四是如果现场有变频器、继电器这类干扰源,用隔离型收发器加隔离电源,比在软件里加十层滤波都有效。
| 硬件项 | 典型取值 | 作用 | 常见错误 |
|---|---|---|---|
| 终端电阻 | 120Ω | 吸收信号反射 | 每个节点都焊一只 |
| 偏置电阻 | 上拉 4.7k / 下拉 4.7k | 空闲态电平确定 | 只在一端加 |
| DE/RE 控制 | 单 GPIO 推挽 | 收发方向切换 | 开漏输出导致翻转慢 |
| 隔离 | 磁耦 + 隔离电源 | 切断地环流 | 只隔离信号不隔离电源 |
DE 引脚我一般配成推挽输出并打开最高速度档,别用开漏。开漏靠外部上拉拉高,边沿斜率受 RC 影响,在 115200 这种速率下,那个"慢半拍"的上升沿正好覆盖在最后一个字节上,就是第 1 章说的削停止位。
2.2 目录分层:把"能跑的 Demo"和"能维护的工程"分开
一个只有main.c的 Modbus 工程,三个月后自己都不敢改。我一般的分法是四层,接口尽量窄:
Project/ ├── bsp/ # 板级支持,只跟硬件打交道 │ ├── bsp_uart.c # USART + DMA + IDLE 中断封装 │ ├── bsp_rs485.c # DE 引脚控制、收发方向切换 │ └── bsp_timer.c # 微秒级计时,供帧超时判断 ├── driver/ │ ├── crc16.c # CRC16/MODBUS 查表实现 │ └── ringbuf.c # 环形缓冲,解耦中断与业务 ├── protocol/ │ ├── mb_rtu_slave.c # 从机状态机:地址过滤、功能码分发 │ ├── mb_rtu_master.c # 主机轮询状态机(可选) │ └── mb_regs.c # 寄存器映射表 └── app/ └── app_main.c # 业务逻辑,只碰 mb_regs 的接口这样分的好处很实际:换一块板子只改bsp/,换协议版本只改protocol/,业务代码一行不动。ringbuf.c是关键中间件,中断里只管往里塞字节,主循环慢慢取,两边不用知道对方的存在。
2.3 初始化顺序:先时钟、再 GPIO、最后外设
顺序错了会出现"外设配置成功但第一帧必丢"这种查半天的问题。稳妥的次序是:时钟 → GPIO(含 DE 默认拉低进入接收态)→ DMA 通道 → USART → 定时器 → 开中断。下面是一段典型的初始化骨架,省掉了 CubeMX 生成的样板代码,只留关键判断。
/* bsp_uart.c —— 串口 + DMA + IDLE 接收的初始化骨架 */ void BSP_UART_Init(uint32_t baud) { /* 1. 先把 DE 拉到接收态,防止初始化期间误驱动总线 */ HAL_GPIO_WritePin(RS485_DE_PORT, RS485_DE_PIN, GPIO_PIN_RESET); HAL_GPIO_Init(RS485_DE_PORT, &de_gpio_init); /* 推挽、高速 */ /* 2. 串口参数:8N1,无硬件流控——Modbus RTU 就是这么定的 */ huart1.Instance = USART1; huart1.Init.BaudRate = baud; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart1.Init.OverSampling = UART_OVERSAMPLING_16; if (HAL_UART_Init(&huart1) != HAL_OK) { Error_Handler(); } /* 3. 启动 DMA 接收:长度给满缓冲,循环模式由上层决定 */ /* 新版 HAL 用 ReceiveToIdle_DMA,老版本自行开 IDLE 中断 */ HAL_UARTEx_ReceiveToIdle_DMA(&huart1, g_rx_dma_buf, RX_DMA_BUF_SIZE); /* 4. 半传输中断在这里没用,关掉能少一次无谓进中断 */ __HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT); }这段代码里三个参数值得单独说。RX_DMA_BUF_SIZE不要拍脑袋定 256,Modbus 一帧最大 256 字节,但实际寄存器读写很少超过 64 字节,给 128 足够,剩下的空间留给 IDLE 切帧时的边界保护。UART_OVERSAMPLING_16是默认值,只有在波特率误差算不下来时才改 8 倍采样,改完记得重新核对误差率,别超 2%。HAL_UARTEx_ReceiveToIdle_DMA是较新版本 HAL 才有的封装,如果你的库版本比较老,就自己使能UART_IT_IDLE,在USART1_IRQHandler里读 SR 再读 DR 清标志,效果一样。我一般习惯自己写 IDLE 处理,因为能顺手把"这一帧多长"直接算出来,少一次查 DMA 剩余计数的调用。
初始化完别忘了把 USART 的 NVIC 优先级排好。DMA 通道的优先级要低于或等于串口,让 IDLE 中断能及时抢到。这个细节第 5 章还会再提一次,因为它是那种"平时不出事、出事就是 HardFault"的隐患。
3. DMA 收发链路拆解:从 IDLE 中断切帧到 DE 引脚翻转
物理层通了,接下来是这套方案的核心竞争力所在。串口收发的传统写法是"一个字节一次中断",115200 下每秒上万次进中断,CPU 什么都干不了。DMA 加 IDLE 的组合把中断次数从"每字节一次"降到"每帧一次",代价是要处理两个边界:帧什么时候算结束,以及发送什么时候算真的发完。
3.1 接收:DMA 循环搬运 + IDLE 中断判帧尾
原理不复杂。DMA 通道挂在 USART 的 RX 请求上,串口每收到一个字节就自动触发一次搬运,数据从 DR 寄存器直接进内存,CPU 全程不参与。真正的问题是"我怎么知道一帧结束了"——Modbus RTU 用 3.5 个字符时间作为帧间隔,而 STM32 的 USART 恰好有个 IDLE 标志:总线连续空闲一个字符时间就会置位。
一个字符时间远小于 3.5 个字符,所以 IDLE 会提前触发,但没关系——Modbus 主站发完一帧后会停下等回复,中间的空闲远超 3.5 字符,IDLE 一旦触发,这一帧就基本齐了。真正要防的是干扰导致的假 IDLE,所以我在中断里再补一层时间戳校验。
/* bsp_uart.c —— IDLE 中断里把"这一帧有多长"算出来 */ void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); /* 读 SR 再读 DR,顺序不能反 */ /* 当前 DMA 还剩多少没搬,减一下就是本帧长度 */ uint16_t remain = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); uint16_t len = RX_DMA_BUF_SIZE - remain; if (len > 0 && len <= MB_MAX_FRAME) { /* 拷贝到环形缓冲,避免下一帧 DMA 直接覆盖正在解析的数据 */ RingBuf_Push(&g_rx_rb, g_rx_dma_buf, len); g_frame_tick = BSP_GetUs(); /* 记下到帧时刻,供 3.5T 校验 */ g_frame_flag = 1; /* 通知主循环有整帧待处理 */ } /* 重新挂接收:地址复位到缓冲区头部,长度给满 */ HAL_UARTEx_ReceiveToIdle_DMA(&huart1, g_rx_dma_buf, RX_DMA_BUF_SIZE); __HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT); } HAL_UART_IRQHandler(&huart1); }__HAL_DMA_GET_COUNTER返回的是剩余传输量,用缓冲区总长减掉它就是本帧字节数,这比另外维护一个计数器可靠得多。注意__HAL_UART_CLEAR_IDLEFLAG内部是"读 SR 再读 DR"的固定序列,HAL 已经封装好了,自己手写的时候顺序反了标志清不掉,中断会反复进。RingBuf_Push那一步是必要的:如果直接在 DMA 缓冲区上解析,下一帧数据到来时会把你正在读的内存改掉,这种 bug 只在总线繁忙时偶发,非常难查。
至于 3.5 字符的严格判定,我会在主循环侧再做一次:拿到g_frame_flag后比较BSP_GetUs() - g_frame_tick是否大于3.5 * 11 * 1000000 / baud微秒。这个值在 9600 波特率下约 4010μs,115200 下约 334μs。实际工程里可以直接用 IDLE 触发就算一帧,3.5T 主要是在高波特率、总线噪声大的场合才需要严格卡。
3.2 发送:DMA 发完不算完,要等 TC 标志
发送侧的坑比接收侧阴险得多。HAL_UART_Transmit_DMA返回的那一刻,数据只是被塞进了 DMA 通道,一个字节都还没上总线;而 DMA 的传输完成中断,是在最后一个字节从内存搬到 USART 的 DR 寄存器时触发的——此时移位寄存器里还有数据没发完。如果在这个时刻把 DE 拉低,最后一个字节的停止位就会被削掉。
正确做法是等 USART 的 TC(Transmission Complete)标志,它才代表移位寄存器真的空了。HAL 的HAL_UART_TxCpltCallback恰好是在 TC 中断里回调的(内部走UART_EndTransmit_IT),所以在这个回调里翻 DE 是安全的;而如果你自己挂 DMA 的传输完成中断去翻,就会踩坑。
/* bsp_rs485.c —— 发送一帧:先立 DE,再启 DMA,最后在 TC 回调里落下 */ int BSP_RS485_Send(const uint8_t *buf, uint16_t len) { if (g_tx_busy) return -1; /* 上一帧没发完,直接拒绝 */ g_tx_busy = 1; HAL_GPIO_WritePin(RS485_DE_PORT, RS485_DE_PIN, GPIO_PIN_SET); /* 切发送态 */ /* 这里加几个空指令的短延时,等收发器真正进入驱动状态 */ for (volatile int i = 0; i < 20; i++) { __NOP(); } if (HAL_UART_Transmit_DMA(&huart1, (uint8_t *)buf, len) != HAL_OK) { HAL_GPIO_WritePin(RS485_DE_PORT, RS485_DE_PIN, GPIO_PIN_RESET); g_tx_busy = 0; return -2; } return 0; } /* 这个回调由 USART 的 TC 中断触发,不是 DMA 完成中断 */ void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { HAL_GPIO_WritePin(RS485_DE_PORT, RS485_DE_PIN, GPIO_PIN_RESET); g_tx_busy = 0; } }g_tx_busy这个标志看着土,但必须要有。Modbus 是半双工,发送期间收到的任何东西都是自己的回显,如果不等发完就去解析,从机会把自己的请求当成新请求再回一遍,总线直接打架。那个__NOP()空循环是给收发器留的切换时间,具体转几条看器件手册的使能延时,一般几百纳秒就够,20 次循环在 72MHz 下约 0.3μs,够用。
3.3 环形缓冲:中断和业务的解耦层
如果直接在中断里解析协议,你会遇到两个问题:一是中断执行时间过长,高优先级任务被拖住;二是解析到一半又来一帧,数据被覆盖。环形缓冲解决的就是这个。
/* ringbuf.c —— 单生产者单消费者,不加锁也能保证安全 */ typedef struct { uint8_t buf[RB_SIZE]; volatile uint16_t head; /* 只由中断写 */ volatile uint16_t tail; /* 只由主循环写 */ } RingBuf_t; uint16_t RingBuf_Push(RingBuf_t *rb, const uint8_t *data, uint16_t len) { uint16_t written = 0; while (len--) { uint16_t next = (rb->head + 1) % RB_SIZE; if (next == rb->tail) break; /* 满了就丢,不做阻塞 */ rb->buf[rb->head] = *data++; rb->head = next; written++; } return written; /* 返回实际写入量,上层判断是否溢出 */ }head和tail都用volatile修饰,防止编译器把它们优化进寄存器。单生产者单消费者的模型下,只要保证head只在中端写、tail只在主循环写,就不需要关中断。rb->buf[rb->head] = *data++;这行先写数据再移动 head,顺序不能反,否则主循环可能读到还没写进去的脏数据——这种 bug 在 -O2 优化下才暴露,属于典型的编译器玄学。
缓冲大小按最坏情况算:主循环一次要处理的最长任务耗时 × 最大帧率 × 帧长。Modbus 在主站轮询模式下帧率不高,256 字节的环形缓冲基本够,做从机被动响应可以给到 512。
4. Modbus RTU 协议层:CRC16、功能码分发与寄存器映射表
数据搬进环形缓冲只是原材料,接下来要把它变成"读寄存器 0x0001 返回 0x00FF"这种有意义的操作。Modbus RTU 的帧格式非常朴素:地址 + 功能码 + 数据 + CRC16,没有起始符也没有长度字段,全靠时间间隔切帧——这也是为什么第 3 章的 IDLE 切帧如此重要。
4.1 CRC16:查表比逐位算快一个数量级
CRC16/MODBUS 的多项式是 0xA001(反射后的 0x8005),初值 0xFFFF,低字节在前。逐位算一帧 256 字节要几千个时钟周期,在 115200 下勉强能跟上,但没必要。查表法把 256 项表放 Flash 里,每字节一次查表一次异或,速度提升非常明显。
/* crc16.c —— 标准 CRC16/MODBUS,多项式 0xA001,初值 0xFFFF */ static const uint16_t crc_table[256] = { 0x0000, 0xC0C1, 0xC181, 0x0140, /* ... 中间省略 ... */ 0x8201, 0x42C0 }; uint16_t CRC16_Modbus(const uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; /* 初值固定 */ while (len--) { crc = (crc >> 8) ^ crc_table[(crc ^ *data++) & 0xFF]; } return crc; /* 返回值低字节先发 */ } /* 校验一帧:末两字节是 CRC,低字节在前 */ int MB_CheckFrame(const uint8_t *frame, uint16_t len) { if (len < 4) return -1; uint16_t calc = CRC16_Modbus(frame, len - 2); uint16_t recv = frame[len - 2] | (frame[len - 1] << 8); return (calc == recv) ? 0 : -1; }crc >> 8是右移不是左移,这点跟 CRC16/CCITT 不一样,抄错表就很正常。返回值组装时低字节在前,跟 Modbus RTU 的字节序一致,发送时直接按内存顺序塞进缓冲区即可。表本身可以用脚本生成,也可以从标准实现里搬,但一定要用已知报文验一遍——比如01 03 00 00 00 01的 CRC 是84 0A,跑不对就是表或者初值错了。
4.2 从机状态机:地址过滤、功能码分发、异常码
从机收到一帧后的处理流程是固定的五步,任何一步失败都要么静默丢弃、要么回异常帧。Modbus 有个不太符合直觉的规定:地址不匹配时要静默丢弃,不能回任何东西,否则总线上一堆从机会同时抢着回应。
/* mb_rtu_slave.c —— 一帧请求的完整处理链路 */ void MB_Slave_Process(const uint8_t *frame, uint16_t len) { uint8_t addr = frame[0]; uint8_t func = frame[1]; uint16_t resp_len; /* 1. 广播地址 0 只处理不回复;地址不匹配直接静默丢弃 */ if (addr != g_slave_addr && addr != 0x00) return; /* 2. CRC 错就丢,不回异常帧——回了只会让总线更乱 */ if (MB_CheckFrame(frame, len) != 0) return; switch (func) { case 0x03: /* 读保持寄存器 */ resp_len = MB_ReadHoldingRegs(frame, len); break; case 0x06: /* 写单个寄存器 */ resp_len = MB_WriteSingleReg(frame, len); break; case 0x10: /* 写多个寄存器 */ resp_len = MB_WriteMultiRegs(frame, len); break; default: /* 不支持的功能码回 0x80 | func */ g_tx_buf[0] = g_slave_addr; g_tx_buf[1] = func | 0x80; g_tx_buf[2] = 0x01; /* 非法功能码 */ resp_len = 5; break; } if (resp_len > 0 && addr != 0x00) { /* 广播不回复 */ uint16_t crc = CRC16_Modbus(g_tx_buf, resp_len - 2); g_tx_buf[resp_len - 2] = crc & 0xFF; g_tx_buf[resp_len - 1] = crc >> 8; BSP_RS485_Send(g_tx_buf, resp_len); } }addr != 0 && addr != g_slave_addr这个判断顺序有讲究,先判广播再判本机地址,写反了广播就收不到。异常码方面,0x01 是非法功能码、0x02 是非法数据地址、0x03 是非法数据值、0x04 是从机设备故障,回异常帧时功能码要或上 0x80,这是协议强制要求,主站靠这个位判断是不是异常响应。CRC 错的时候我选择静默丢弃而不是回异常,因为 CRC 错说明传输已经出问题了,这时候回帧大概率也是错的,只会加剧总线冲突。
4.3 寄存器映射表:业务和协议的唯一边界
寄存器表是整个工程里最该设计好的东西。我的做法是用一张结构体数组当"协议地址到内存变量"的映射,业务代码只改内存变量,协议层只读这张表,两边互不感知。
| 协议地址 | 类型 | 读写 | 映射变量 | 说明 |
|---|---|---|---|---|
| 0x0000 | 保持寄存器 | R/W | g_dev_cfg.addr | 从机地址,掉电保存 |
| 0x0001 | 保持寄存器 | R/W | g_dev_cfg.baud_idx | 波特率档位 |
| 0x0002 | 输入寄存器 | R | g_sensor.temp_x10 | 温度 ×10,单位 0.1℃ |
| 0x0003 | 输入寄存器 | R | g_sensor.humi_x10 | 湿度 ×10 |
| 0x0010 | 线圈 | R/W | g_do_state[0] | 继电器 1 输出 |
| 0x0011 | 线圈 | R/W | g_do_state[1] | 继电器 2 输出 |
/* mb_regs.c —— 一张表管住所有寄存器,业务改值、协议读值 */ typedef struct { uint16_t addr; /* Modbus 协议地址 */ uint16_t *ptr; /* 指向真实内存 */ uint8_t rw; /* 0=只读 1=可写 */ } MB_RegItem_t; static MB_RegItem_t g_reg_map[] = { { 0x0000, &g_dev_cfg.addr, 1 }, { 0x0001, &g_dev_cfg.baud_idx, 1 }, { 0x0002, &g_sensor.temp_x10, 0 }, { 0x0003, &g_sensor.humi_x10, 0 }, }; uint16_t MB_ReadHoldingRegs(const uint8_t *frame, uint16_t len) { uint16_t start = (frame[2] << 8) | frame[3]; /* 起始地址,大端 */ uint16_t count = (frame[4] << 8) | frame[5]; /* 寄存器数量 */ if (count == 0 || count > 125) return MB_Exception(0x03); g_tx_buf[0] = g_slave_addr; g_tx_buf[1] = 0x03; g_tx_buf[2] = count * 2; /* 字节数 */ for (uint16_t i = 0; i < count; i++) { uint16_t v = MB_GetReg(start + i); /* 查表取值,越界返回 0 */ g_tx_buf[3 + i * 2] = v >> 8; /* 大端:高字节先发 */ g_tx_buf[3 + i * 2 + 1] = v & 0xFF; } return 3 + count * 2 + 2; /* 头 + 数据 + CRC */ }两个参数边界要记住:读保持寄存器一次最多 125 个(0x7D),写多个寄存器一次最多 123 个(0x7B),超过就是非法数据值。字节序是大端,高字节在前,跟 CRC 的小端正好相反,这一对反着的字节序是新手最容易翻车的地方。g_v这种全局缓冲区不够优雅,但它省掉了动态分配,在没有任何堆的裸机环境里是最稳的选择。
5. 排查与避坑:五条总线上的血泪记录
这套方案在实验室跑通和在现场跑稳,中间隔着好几条命。下面五条都是实际撞过的,按"现象 → 原因 → 解决"写,对上了直接抄。
5.1 上电第一帧永远 CRC 错
现象:设备上电后主机发的第一条请求,从机必回异常或干脆不回,之后一切正常。
原因:DMA 接收通道和串口使能的先后顺序反了。串口已经能收字节,DMA 却还没配好,头几个字节被搬到了错误的地址;也可能相反,DMA 早于串口使能,通道一直处于 waiting 状态,第一帧的部分字节直接丢了。
解决:初始化顺序严格按"GPIO → DMA → USART → 开中断",并且在启动接收之前先调一次__HAL_UART_CLEAR_IDLEFLAG和__HAL_UART_CLEAR_OREFLAG,把上电残留的标志清干净。另外把 DE 引脚在初始化最开头就拉低到接收态,别等串口配完再拉。
5.2 高波特率下丢首字节或尾字节
现象:9600 波特率下一切正常,切到 115200 后随机丢帧,逻辑分析仪上看从机确实回了,但主机收不到。
原因:DE 引脚翻转时机不对。发送侧用 DMA 传输完成中断去拉低 DE,此时移位寄存器还有最后 1~2 个字节没发完,停止位被削,对端判帧失败;接收侧则是 DE 拉低太慢,自己的回显还没结束就被当成新帧收进来。
解决:发送侧一律在HAL_UART_TxCpltCallback(TC 中断)里拉低 DE,绝不用 DMA 完成中断。接收侧在拉低 DE 之后加几十个__NOP()的空转,给收发器的使能切换留出时间。
5.3 从机收到自己的回显,无限自激
现象:总线上只挂一个从机和一个调试助手,从机却疯狂回帧,示波器上波形连成一片。
原因:DE 控制时序对了,但接收端没做"发送期间屏蔽接收"。半双工总线上,自己发出去的每一帧都会被自己的接收端原样收回来,如果这帧正好是发给本机的,从机会把它当成新请求再处理一次。
解决:用一个g_tx_busy标志,发送期间收到的数据直接丢弃,或者在发送前调用__HAL_UART_DISABLE_IT(&huart1, UART_IT_RXNE)、发完再打开。更彻底的做法是在RingBuf_Push之前判断g_tx_busy,为真就直接清空 DMA 缓冲区。
5.4 总线繁忙时数据偶发错位
现象:单条请求测试百分之百正确,一旦主机连续轮询多个从机,就出现"读 0x0002 返回的却是 0x0003 的值"这种错位。
原因:DMA 配置成了循环模式,缓冲区首尾相接。IDLE 中断里直接拿 DMA 缓冲区去解析,解析过程中下一帧已经到了,DMA 从缓冲区头部开始覆盖,正在读的数据被改掉一半。
解决:IDLE 中断里先把数据memcpy到环形缓冲,再重新挂接收,让 DMA 缓冲区只做"过路站"。或者把 DMA 配成普通模式,每帧处理完手动重挂,代价是多一次HAL_UARTEx_ReceiveToIdle_DMA调用。我倾向第一种,中断里做事少,逻辑清楚。
5.5 帧被切碎,一帧分成两次处理
现象:明明是一帧完整的请求,从机却只处理了前半段,返回异常码 0x03。
原因:3.5 字符超时判定用 SysTick 做基准,而 SysTick 的 1ms 分辨率在 115200 下根本不够——3.5 个字符只有 334μs,1ms 的计时粒度会让判定完全失真。还有一种情况是把 IDLE 中断里的时间戳记成了"处理时刻"而不是"到帧时刻"。
解决:用一个 1MHz 自由运行的定时器(或者 DWT 的 CYCCNT 寄存器)做微秒级计时,时间戳在 IDLE 中断最开头就取好。3.5 字符时间按35 * 1000000 / (baud * 10)微秒算(每字符 11 位,这里取 10 位近似),115200 下约 304μs,留 20% 余量。另外高波特率下允许放宽到 1.75 字符,这是协议本身允许的弹性。
6. 二次开发改造:把单从机实验板改成多主站轮询网关
单从机跑通只是起点,真正落地时八成会变成"一个 MCU 同时当主站和从站"的网关:向下用 485 轮询几台设备,向上通过另一个串口或以太网把数据送出去。改造的核心是把被动响应的MB_Slave_Process和一个主动轮询状态机接到同一个bsp_rs485上,靠g_tx_busy互斥。
轮询状态机我一般写成四个状态,用一张表驱动,改设备数量只改表长:
/* mb_rtu_master.c —— 极简轮询状态机,一次只发一条请求 */ typedef enum { MB_IDLE, MB_SEND, MB_WAIT, MB_TIMEOUT } mb_state_t; static const uint8_t poll_list[][2] = { /* {从机地址, 功能码} */ {0x01, 0x03}, {0x02, 0x03}, {0x03, 0x04}, {0x04, 0x04}, }; void MB_Master_Poll(void) { static uint8_t idx = 0; static uint32_t t0; switch (g_mb_state) { case MB_IDLE: MB_BuildReadReq(poll_list[idx][0], 0x0000, 2); /* 组请求帧 */ BSP_RS485_Send(g_tx_buf, g_tx_len); t0 = BSP_GetMs(); g_mb_state = MB_WAIT; break; case MB_WAIT: if (g_frame_flag) { /* 收到响应 */ g_mb_state = MB_IDLE; idx = (idx + 1) % ARRAY_SIZE(poll_list); } else if (BSP_GetMs() - t0 > 200) { /* 200ms 无响应算超时 */ g_mb_state = MB_TIMEOUT; } break; case MB_TIMEOUT: /* 重试两次后跳过该设备 */ if (++g_retry >= 2) { g_retry = 0; idx = (idx + 1) % ARRAY_SIZE(poll_list); } g_mb_state = MB_IDLE; break; } }这张改造表里最关键的两个值:轮询超时 200ms 是经验值,波特率 9600 下一帧往返大概 50ms,留四倍余量够用;重试次数 2 次,再多会拖慢整个轮询周期,现场设备掉线时按"跳过"处理比死等更有价值。协议扩展方面,如果要传浮点或长整型,别改标准功能码,用 0x03 读多个寄存器然后按 IEEE754 拼接就行,主站侧约定好字节序,比自定义功能码省事得多。
验证环节我有个固定套路:先用 Python 写个最小主站脚本跑通链路(pyserial加手算 CRC 就够,二十行),再上逻辑分析仪抓 DE 引脚和 A/B 差分波形,最后把它们接到真实设备上跑满 24 小时。脚本这步不能省,MCU 排查 MCU 是黑匣子对黑匣子。
从那以后我每次做 485 项目,都会在开工第一天把"DE 引脚波形 + TC 中断时刻"这两个信号挂到逻辑分析仪上,先确认时序再写协议,后悔药没处买。希望帮到你。
本文还有配套的精品资源,点击获取