☰
Modbus RTU 帧边界、CRC 与 RS485 DE/DMA 时序排查
2026/10/26 9:00:16 网站建设 项目流程

简介:面向嵌入式开发学习者与工业通信初学者的STM32F103ZET6实践工程,围绕RS485总线、DMA传输与Modbus协议展开,适合需要实现主从设备寄存器读写、提升串口收发效率并验证通信稳定性的开发者参考。压缩包共171个文件,约3.32MB,含c/h源码、Keil工程、hex/axf可执行文件及map/lst编译映射,便于直接打开工程、对照编译结果、检查引脚与串口参数。已有8318人学习。内容覆盖RS485方向控制、串口DMA自动收发、0x03读寄存器与0x06写单寄存器报文解析,以及从机响应组包、多寄存器轮询、超时重试、错误处理和通信中断恢复等设计。可作为工业自动化、物联网设备主从通信与课程设计的完整工程参考,帮助读者理解DMA如何降低CPU占用、验证请求响应流程,并积累现场调试、问题定位和原型搭建经验。

1. 最后两个字节总是随机出错:Modbus 通信里被忽略的三条时间线

在 STM32 上用 DMA 跑 RS485 上的 Modbus,很多人第一版都会遇到一个很特殊的现象:帧长度对得上,地址对、功能码对、数据域全对,唯独末尾那两个 CRC 字节随机出错。把波特率从 9600 改成 115200 会好一些,换个中断优先级又好一些,于是开始怀疑是干扰、是地线、是芯片。实际上,DMA 搬运完成的时间、USART 移位寄存器把最后一位送出引脚的时间、以及 RS485 收发器从发送态切回接收态的时间,是三条彼此独立的时间线。Modbus RTU 是一套严格按静默间隔切帧的协议,三条线只要错开一个字符宽度,帧边界就被挪走,CRC 自然对不上。下面从协议层往上,一层层拆这条时间线是怎么被设计出来的。

2. 帧边界是靠算出来的:Modbus RTU 的静默间隔与 CRC 校验闭环

Modbus RTU 没有起始符、没有长度字段、没有转义字符,一帧的开头和结尾完全靠「线上安静了多久」来判断。这个判断标准在规范里被定义成字符时间的 1.5 倍和 3.5 倍,看起来是个抽象概念,实际上是个能直接拿计算器算出来的物理量。搞不清楚这一点,后面所有的 DMA 配置和中断时序都会缺少依据。

2.1 3.5 个字符时间是一个能算出来的物理量

一个字符时间取决于串口帧格式。最常用的 8N1 配置下,一个字符包含 1 个起始位、8 个数据位、1 个停止位,共 10 个位时间。波特率 9600 时,字符时间 = 10 / 9600 ≈ 1.0417 ms,那么 t1.5 ≈ 1.5625 ms,t3.5 ≈ 3.646 ms。波特率 19200 时字符时间约 0.52 ms,t3.5 约 1.82 ms。规范对 19200 以上的波特率给了简化建议,直接取固定值 1.75 ms,避免高速下算出来的间隔小到定时器分辨不出来。

这条规则在实现上对应两种完全不同的做法。第一种是「字节超时法」:每收到一个字节就把定时器计数器清零重装,定时器溢出就判定帧结束。它严格贴合 t3.5 的定义,对任何波特率都成立。第二种是「IDLE 中断法」:依赖 USART 外设自带的空闲线检测,它在接收引脚连续空闲一个字符时间后就置位 IDLE 标志。IDLE 比 t3.5 更早触发,严格说并不符合规范,但因为帧内相邻字节之间几乎不可能出现一个完整字符的空闲,实际工程里能工作,省掉了一个定时器。

定时器方案的计数值可以直接算出来,不用试凑:

/* 按 8N1、每字符 10 bit 计算 t3.5 对应的定时器计数值 */ static uint32_t t35_ticks(uint32_t baud, uint32_t tim_clk_hz) { /* 字符时间 = 10 / baud 秒;t3.5 = 3.5 个字符 = 35 / baud 秒 */ uint64_t num = (uint64_t)35 * tim_clk_hz; uint32_t ticks = (uint32_t)(num / baud); /* 高速波特率下可能算出 0,至少留 1 个 tick 保证定时器能触发 */ return ticks < 1u ? 1u : ticks; }

baud是实际波特率,tim_clk_hz是定时器计数时钟频率(等于 APB 定时器时钟除以预分频)。假设定时器时钟 72 MHz、不分频,9600 波特率下算出来约 262500 个 tick,显然超过 16 位定时器的 65535 上限,所以预分频必须一起算进去。我一般习惯把预分频也做成参数,让实际计数值落在 1000 到 50000 这个区间,既保证分辨率,又不会溢出。这套计算的价值在于:换波特率时改两个常量就行,不用再靠示波器反复调整。

2.2 从地址域到 CRC 尾:一帧数据的分层校验

Modbus RTU 的帧结构极其朴素,所有字段首尾相接,没有分隔符:

字段长度说明
从站地址1 字节0 表示广播,1~247 为单播地址
功能码1 字节0x03 读保持寄存器,0x06 写单个寄存器,0x10 写多个寄存器
数据域N 字节内容由功能码决定,长度也从功能码推导
CRC162 字节低字节在前,高字节在后

以最常见的 0x03 读保持寄存器为例,请求帧固定 8 字节:地址、功能码、起始地址高字节、起始地址低字节、寄存器数量高字节、寄存器数量低字节、CRC 低字节、CRC 高字节。从站正常响应是「地址 + 功能码 + 字节数 + 数据 + CRC」,异常响应是「地址 + (功能码 | 0x80) + 异常码 + CRC」,固定 5 字节。所以最小合法帧长是 4 字节(地址 + 功能码 + 2 字节 CRC),解析前先卡这个下限,能挡掉大量由总线噪声造成的碎片帧。

CRC 的计算是这套协议里唯一需要循环的部分,多项式是反射后的 0x8005,也就是 0xA001,初值 0xFFFF:

/* Modbus RTU 的 CRC16:初值 0xFFFF,反射多项式 0xA001,输入输出均不取反 */ uint16_t modbus_crc16(const uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { crc ^= buf[i]; /* 字节异或进低 8 位 */ for (uint8_t b = 0; b < 8; b++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; /* 低位为 1 时右移并异或多项式 */ } else { crc >>= 1; /* 低位为 0 时纯右移 */ } } } return crc; } /* 校验用法:帧末尾两个字节分别是 CRC 的低字节和高字节 */ int modbus_frame_check(const uint8_t *frame, uint16_t len) { if (len < 4) return -1; /* 太短,连最小帧都不够 */ uint16_t calc = modbus_crc16(frame, len - 2); uint16_t recv = (uint16_t)frame[len - 2] | ((uint16_t)frame[len - 1] << 8); return (calc == recv) ? 0 : -1; }

逐位计算法在 115200 波特率下每次中断只处理一帧,开销完全可以接受;如果从站需要处理密集的轮询,可以换成 256 项查表法,把内层循环消掉,代价是 512 字节的常量表占用 Flash。这两种实现的取舍在资源紧张的芯片上值得算一笔账:一个 32 位 Cortex-M0 跑逐位法,8 字节帧大约几百个周期,查表法不到一百个周期,区别在毫秒级轮询周期里根本看不出来。

2.3 为什么 CRC 要低字节先发,而不是高字节

这个细节单独拎出来讲,因为它是最典型的「算对了但发错了」的事故来源。CRC 寄存器在计算过程中是右移的,最终结果在逻辑上属于一个 16 位小端值,Modbus 规范明确要求先发低字节。而 USART 本身也是低位先出,两件事叠加起来,就形成了「CRC 的低字节排在帧的前面」这个看起来别扭的布局。

调试时如果发现校验值差了一个字节的位置,或者高低温字节互换后能对上,基本就是这里错了。我习惯在发送函数里直接把这个顺序写死,绝不留给调用方决定:

/* 把 CRC 追加到帧尾:规范要求低字节在前 */ uint16_t modbus_append_crc(uint8_t *frame, uint16_t len) { uint16_t crc = modbus_crc16(frame, len); frame[len] = (uint8_t)(crc & 0xFF); /* 低字节先发 */ frame[len + 1] = (uint8_t)(crc >> 8); /* 高字节后发 */ return len + 2; }

顺带说一句,有些芯片带硬件 CRC 外设,配置成多项式 0x8005、初值 0xFFFF、输入输出反射之后就能得到一致的结果,能省下不少 CPU。但硬件 CRC 的位序配置极易搞错,配错了还不如软件算得快——因为你会花两天时间去查为什么只有一个字节对不上。常见的做法是先软件算通,再用硬件替换并逐字节比对,两者结果一致才切换。

3. RS485 半双工的方向控制:DE 引脚切换早一个字符就全盘皆输

Modbus 是一主多从的协议,物理层跑在 RS485 差分总线上,所有节点共享同一对线。这意味着任何时刻只能有一个节点在驱动总线,谁在说话谁就得把收发器切到发送态,说完立刻切回来。这个「切」的动作,是整个链路里唯一一个由软件时序决定的物理行为,也是绝大多数通信不稳定问题的根源。

3.1 一个 GPIO 同时控制 DE 和 RE 的电路约定

常见的 RS485 收发器有两根控制引脚:DE(Driver Enable,高电平有效,使能差分驱动)和 RE(Receiver Enable,低电平有效,使能接收器)。最省引脚的接法是把它们并在一起,由同一个 GPIO 控制。此时 GPIO 输出高电平,驱动器开、接收器关,节点处于发送态;输出低电平,驱动器关、接收器开,节点处于接收态。这样做的副作用是发送时听不到自己在说什么,做不了冲突检测,但对标准的 Modbus 主从轮询来说完全够用。

上电瞬间的电平是这里最容易出问题的地方。GPIO 在复位后默认是浮空输入,此时若外部没有下拉电阻,DE 引脚的电平不确定,收发器可能一上电就进入发送态,把整条总线拉到某个固定电平上。总线上只要有一个节点这么干,所有其他节点的通信都会失效。解决办法有两个,一是硬件上给 DE 加一个 10 kΩ 下拉电阻,二是软件在初始化串口之前先把 GPIO 配成输出低电平。我一般两个都做,硬件兜底,软件保证。

还有一个容易忽略的细节:如果 DE 和 RE 是分开控制的,发送时让接收器保持使能,就能收到自己发出的每一个字节。这在某些需要回读校验的设计里有用,但会带来一个新问题——本地接收中断会被自己的发送数据触发,DMA 接收缓冲里混进回环数据。除非确实需要,否则不推荐这么接。

3.2 切换基准只能是 USART 的 TC,不能是 DMA 的传输完成

这是整篇文章里最值得记住的一句话。DMA 的传输完成标志(TC)只表示「数据已经从内存搬进了 USART 的数据寄存器」,而此时 USART 的移位寄存器里可能还压着上一个字节,最后搬进去的那个字节更是纹丝未动。此刻如果把 DE 拉低,收发器立刻停止驱动,总线上最后那一到两个字节就变成了悬空的高阻态,从站收到的帧在末尾被截断,CRC 自然对不上。

正确的切换时机是 USART 的 TC 标志(Transmission Complete),它要到「最后一个字符的停止位完整移出引脚」之后才置位。以 HAL 库为例,HAL_UART_Transmit_DMA启动后,DMA 传输完成中断会先把 TC 中断打开然后退出,等 TC 中断到来时才调用HAL_UART_TxCpltCallback。所以在这个回调里拉低 DE 是安全的:

/* 发送一帧从站响应:先抢总线,再交给 DMA 搬运 */ void mb_slave_reply(const uint8_t *frame, uint16_t len) { RS485_DE_SET(1); /* 切到发送态,占用总线 */ HAL_UART_Transmit_DMA(&huart2, (uint8_t *)frame, len); } /* HAL 的 TxCplt 回调由 USART 的 TC 中断触发, 此时停止位已完整移出引脚,可以安全释放总线 */ void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART2) { RS485_DE_SET(0); /* 切回接收态,释放总线 */ } }

而下面这种写法,是无数人踩过的坑:

/* 错误示范:在 DMA 通道的传输完成中断里直接拉低 DE */ void DMA1_Channel6_IRQHandler(void) { if (DMA1->ISR & DMA_ISR_TCIF6) { DMA1->IFCR = DMA_IFCR_CTCIF6; RS485_DE_SET(0); /* 最后一个字节还在移位寄存器里,必然被截断 */ } }

现象是:波特率越高越容易出错,9600 时偶尔错,115200 时几乎必错。原因很简单——波特率越高,一个字符时间越短,留给「DMA 搬完」到「移位寄存器发完」之间的窗口越小,软件哪怕只晚几微秒,也可能已经切早了。这条经验在裸机寄存器写法里同样成立:一定要在 USART 的 TC 中断里操作 DE,不要在 DMA 中断里操作。

3.3 总线空闲电平为什么要靠偏置电阻兜底

RS485 是差分总线,A、B 两线电压差大于 +200 mV 判为逻辑 1,小于 -200 mV 判为逻辑 0,中间是无效区。当总线上所有节点都不驱动时,如果没有任何东西给这对线定一个确定电平,它们就会飘到无效区,接收器输出端可能出现随机翻转。对 USART 来说,这等于凭空出现一个下降沿,被误判成起始位,然后收进一个 0x00 或者 0xFF 的垃圾字节。

解决办法是加偏置电阻:A 线上拉到电源、B 线下拉到地,阻值通常取 560 Ω 到 1 kΩ,保证空闲时 A 比 B 高出一个明确的正电压,接收器稳定输出逻辑 1,也就是 USART 的空闲电平。这条偏置只需在总线的一端加,不要每个节点都加,否则并联后阻值变小、功耗变大。

终端电阻是另一件事。长距离传输时,电缆的特性阻抗通常在 120 Ω 左右,总线两端各加一个 120 Ω 电阻做阻抗匹配,抑制信号反射。短距离、低波特率的桌面上测试,不加终端电阻往往也能通信,这就导致很多人以为它可有可无,直到把线拉到几十米外才发现波形完全变形。偏置和终端是两套独立的电阻,功能不同,不能互相替代。

4. DMA 在这套链路里到底搬了什么:从外设请求到内存的一段无人值守

前面两章讲的是协议和物理层,这一章讲搬运。DMA 本身并不神秘,它就是一个能替 CPU 读写外设寄存器的搬运工,真正需要想清楚的是:它替谁干活、什么时候停、停下来之后谁来接管。

4.1 没有 DMA 时,115200 波特率意味着每秒上万次中断

假设一个从站设备挂在 115200 波特率的总线上,主站以 10 ms 周期轮询,每帧请求 8 字节、响应 20 字节左右。纯中断方式下,每收到一个字节就进一次中断,一个轮询周期就是 28 次中断,折算下来每秒近 3000 次。如果总线上一主多从、轮询更密集,或者帧更长(比如一次读几十个寄存器),中断频率很容易冲到每秒上万次。

这些中断本身不算重,但它带来的问题在于时序抖动:如果系统里还有别的中断(定时器、ADC、看门狗喂狗),字节中断的响应会被推迟。一旦推迟超过一个字符时间,接收移位寄存器里的数据就会被下一个字节覆盖,触发溢出错误 ORE,整帧作废。Modbus 的帧没有重传机制,只能靠主站超时重发,表现就是「偶尔丢一帧,过一会儿又好了」。

DMA 的价值不是「快」,而是「确定性」。它由外设的 RXNE 事件直接触发,从数据寄存器搬到内存,全程不经过 CPU 流水线,不受中断优先级影响,只要 DMA 通道配置正确,一个字节都不会漏。CPU 只需要在整帧收完之后被叫醒一次。

4.2 IDLE 中断 + DMA 定长缓冲:一次中断处理一整帧

这套组合的核心思路是:DMA 负责把字节一个个搬进缓冲区,IDLE 中断负责告诉你「搬完了,这一帧到此为止」。DMA 的传输长度设成缓冲区的物理上限(比如 256 字节),而不是某一帧的预期长度,因为 Modbus 的帧长是变化的,事先不知道。

#define MB_RX_BUF_SIZE 256 #define MB_MIN_FRAME_LEN 4 static uint8_t mb_rx_buf[MB_RX_BUF_SIZE]; /* 武装一次接收:DMA 长度填缓冲区上限,IDLE 中断负责判帧尾 */ static void rs485_rx_arm(void) { __HAL_UART_CLEAR_IDLEFLAG(&huart2); /* 清掉上一帧残留的 IDLE */ __HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE); /* 打开空闲线检测中断 */ HAL_DMA_Start_IT(&hdma_usart2_rx, (uint32_t)&USART2->DR, /* 源地址:串口数据寄存器 */ (uint32_t)mb_rx_buf, /* 目的地址:接收缓冲区首地址 */ MB_RX_BUF_SIZE); /* 传输长度:缓冲区上限 */ SET_BIT(USART2->CR3, USART_CR3_DMAR); /* 使能串口的 DMA 接收请求 */ }

MB_RX_BUF_SIZE取多大是有讲究的。取太小,长帧会被截断;取太大,单次 DMA 传输的内存占用增加,但更重要的是溢出风险——如果 IDLE 事件因为某种原因没被处理,DMA 会继续往后写,写满之后行为取决于 DMA 模式。普通模式下写满即停,数据不再更新;循环模式下会绕回缓冲区开头,把前一帧的数据覆盖掉。长度设为最大可能帧长的两倍是个稳妥的选择。

IDLE 中断的处理逻辑很短,短到几乎不占用中断时间:

void USART2_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart2); /* 读 SR 再读 DR 才能清除 */ CLEAR_BIT(USART2->CR3, USART_CR3_DMAR); /* 先停掉 DMA 请求,防止继续写 */ uint16_t remain = __HAL_DMA_GET_COUNTER(&hdma_usart2_rx); uint16_t recv_len = MB_RX_BUF_SIZE - remain; /* 剩余计数反推实际长度 */ if (recv_len >= MB_MIN_FRAME_LEN) { mb_frame_ready(mb_rx_buf, recv_len); /* 丢进协议栈,别在这里做重活 */ } rs485_rx_arm(); /* 立刻重新武装,等下一帧 */ } }

两个参数值得说明。__HAL_DMA_GET_COUNTER返回的是 DMA 通道里还剩多少字节没搬,用缓冲区总长减掉它,就是已经收到的字节数。mb_frame_ready只负责把帧头指针和长度放进队列,真正的 CRC 校验、功能码分发、寄存器读写都放到主循环里做,中断服务程序里不做任何耗时操作。这一点看起来是常识,但真到了带 RTOS 的工程里,很多人会在中断里直接调信号量并让任务立刻抢占,结果解析还没结束,下一帧已经开始了——因为 IDLE 之后的rs485_rx_arm已经重新开好了 DMA。

4.3 环形缓冲和半传输中断什么时候才值得用

DMA 的循环模式配合「半传输中断 + 传输完成中断」,可以做一个软件环形缓冲区,实现「边收边处理」。这种结构在串口协议栈里很常见,但对 Modbus 来说往往是过度设计。原因在于 Modbus 是严格的请求—响应式协议,一帧收完到下一帧开始之间必然有一段总线空闲,这段时间足够处理完上一帧。用循环模式反而引入了新的复杂度:你需要维护读指针和写指针,需要处理回绕,还要在 IDLE 中断里正确算出这一段的起止位置。

真正需要环形缓冲的场景有两个。一是主站侧,主站要连续轮询多个从站,帧与帧之间几乎没有间隙,处理不及时就会丢;二是总线上有非标准设备会主动上报数据,帧到达时间不可预测。这两种情况下,单缓冲确实不够用。

半传输中断(HT)在定长传输场景里很有价值——比如接收一个固定 256 字节的数据块,收到 128 字节时就处理前半段,收满时处理后半段,吞吐量翻倍。但 Modbus 的帧长不固定,半传输时刻大概率落在一帧中间,把它当成「帧收完了」的信号只会制造错误。如果确实要用HAL_UARTEx_ReceiveToIdle_DMA,记得把 HT 中断关掉,否则回调会在半传输和空闲两种事件里都触发,帧长度参数的含义完全不同:

/* 使用 ReceiveToIdle 接口时,关闭半传输中断,只保留空闲事件 */ __HAL_DMA_DISABLE_IT(&hdma_usart2_rx, DMA_IT_HT);

另外,HAL_UARTEx_RxEventCallback里必须重新调用一次HAL_UARTEx_ReceiveToIdle_DMA,因为 HAL 的这个接口在 IDLE 事件后会自动停止 DMA,不会自己续上。忘了这一步的典型现象是:第一帧收得很好,第二帧开始永远是空的。

5. 常见问题排查:五条让通信变得玄学的坑

下面这几条都是从实际调试记录里筛出来的,共同点是现象看起来毫无规律、原因却都很具体。按「现象 → 原因 → 解决」的顺序列,遇到问题可以照着对号入座。

5.1 偶发 CRC 错误,帧长却是对的

现象:通信能跑,几十分钟里蹦出一两次校验失败,重发就好,示波器上看波形也很干净。

原因:多半是接收侧的长度计算和内存可见性问题。__HAL_DMA_GET_COUNTER拿到的是 DMA 内部计数器的瞬时值,如果读取时机在 DMA 还在搬运最后几个字节的瞬间,算出来的长度会比实际短。另一种情况是缓冲区被上一次的数据污染——上一帧收完没有清空缓冲区,新帧只覆盖了前一部分,后面残留的旧字节被当成新数据一起送进 CRC 计算。

解决:在 IDLE 中断里先关闭串口的 DMA 请求位(CLEAR_BIT(USART2->CR3, USART_CR3_DMAR)),再读计数器,读完立刻重新武装。缓冲区在每次rs485_rx_arm之前清零,或者在解析时严格按长度截断,不读缓冲区长度之外的任何字节。CRC 校验失败时直接丢弃整帧并计数,不要尝试「部分恢复」。

5.2 从站回复了但主站收不到最后一个字节

现象:主站报超时,但从站的发送函数返回成功,逻辑分析仪上能看到从站确实在总线上有动作,只是最后一个字节的电平异常,像是被中途掐断。

原因:DE 切换时机错了。DMA 传输完成中断里就把 DE 拉低,此时最后一个字节还在 USART 移位寄存器里。更隐蔽的一种情况是用了HAL_UART_Transmit(阻塞式)之后立刻拉低 DE,看起来没问题,但如果编译器优化或者中断打断了这个函数,同样可能切早。

解决:所有 DE 拉低的动作集中到一个地方,只由 USART 的 TC 中断触发。裸机写法就在USART2_IRQHandler里判USART_SR_TC,HAL 写法就放在HAL_UART_TxCpltCallback里。绝对不要在 DMA 中断、定时器中断或者主循环里凭经验加延时去拉低 DE,那种「试出来的延时」换一块板子就失效。

5.3 上电后第一次通信必失败,之后一切正常

现象:设备冷启动后第一帧永远收不到或者校验不过,重发一次就正常了,之后一直稳定。

原因:上电时序问题。GPIO 复位后是浮空输入,DE 引脚在软件初始化之前电平不确定,收发器可能短暂进入发送态把总线拉死。另一种可能是收发器的电源建立时间比 MCU 慢,MCU 已经开始发送了,收发器还没准备好,第一个字节被吃掉。

解决:在main的最开头、任何外设初始化之前,先把 DE 对应的 GPIO 配成推挽输出并写低电平,保证一上电就是接收态。如果确认是收发器上电慢,在初始化完成后加一个几十毫秒的启动延时再开放通信,这个延时不参与任何实时逻辑,不影响后续时序。

5.4 总线上多挂一台设备,整个网络就瘫了

现象:单机测试一切正常,挂上第二台、第三台设备之后,通信成功率断崖式下跌,有时候整个总线一条数据都过不去。

原因:某台设备的 DE 引脚被长期拉高。常见于程序跑飞、看门狗复位后 GPIO 状态未恢复、或者调试器暂停在发送函数中间。只要有一个节点持续驱动总线,其他节点的差分信号就全被压制,表现为全网络失效。另一个可能是地址冲突,两台设备配了同一个从站地址,同时响应同一个请求,波形叠加后谁都解析不了。

解决:硬件上给 DE 加下拉电阻是最后一道保险。软件上加看门狗,并且在初始化流程里显式把 DE 置低。地址冲突要靠管理手段,出厂前逐台写入唯一地址,现场调试时用广播或者逐个断电的方式定位冲突设备。如果设备支持,可以加一个「发送超时保护」:DE 拉高之后启动一个几十毫秒的定时器,超时未收到 TC 中断就强制拉低,防止程序异常导致总线被永久占用。

5.5 同一份代码换块板子就跑不通

现象:在开发板上跑得好好的固件,烧到另一块板子上,波特率稍高就开始丢包,降低波特率又能跑。

原因:两块板子的 RS485 外围电路不一致。终端电阻有的装有的没装,偏置电阻阻值不同,甚至收发器型号都不同,导致上升沿和下降沿的斜率不一样。波特率越高,位时间越短,波形边沿占的比例越大,容错窗口越小。还有一种情况是两块板子的地线处理不同,共模电压超出收发器的输入范围,长距离时信号直接失真。

解决:把终端电阻和偏置电阻做成可选的焊接位,现场按总线长度决定装不装。调试不同板子时,先用示波器看差分波形,确认空闲电平、上升沿时间和过冲幅度都在合理范围,再去怀疑软件。地线不要想当然地认为是共的,隔离型收发器在跨设备场景里几乎是必需品,这一点在实验室里永远体会不到。

6. 设计取舍:IDLE 与定时器、单缓冲与双缓冲,工业现场为什么最后收敛到这套组合

回到最开始的三个方案选择上。帧边界判定到底用 IDLE 中断还是 t3.5 定时器,这是个典型的合规性与复杂度之间的取舍。IDLE 省一个定时器、代码更短、触发更快,代价是严格来说不满足规范的 3.5 字符间隔;定时器方案完全合规、对任意波特率一致,但要处理定时器中断与 DMA 中断的竞态,代码量和出问题的面都会变大。我见过两种做法都能在工业现场跑很多年,选哪个更多取决于团队维护能力,而不是技术优劣。

方案资源占用规范符合度主要风险适用场景
IDLE 中断 + DMA省一个定时器近似(1 字符)帧内异常间隔被拆帧帧短、总线干净、从站少
t3.5 定时器 + DMA占一个定时器严格符合定时器与 DMA 竞态长帧、多从站、波特率多样
纯中断逐字节收不占 DMA 通道严格符合高波特率下丢字节低波特率、低负载

缓冲策略的取舍同理。单缓冲配 IDLE,逻辑最直白,出问题好查;环形缓冲配循环 DMA,吞吐量高,但读写下标一旦算错,表现出来的是随机丢帧,排查成本陡增。判断标准不是「哪个更高级」,而是「总线上两帧之间的间隔够不够处理完上一帧」。够,就用单缓冲;不够,再考虑环形。

验证方式我习惯用两层。第一层是静态验证:把波特率、缓冲区长度、t3.5 计数值全部做成宏,在编译期断言帧长上限小于缓冲区的一半,让配置错误在编译时就暴露。第二层是压力验证:写一个循环发送的测试程序,按最短帧、最长帧、异常帧三种长度交替发,连续跑几个小时统计 CRC 错误率。这个测试能跑通,再上线。

从那以后我每次开新的 485 项目,都会强制先把 DE 时序单独用示波器过一遍——只发一个字节,看 TC 拉低 DE 的时刻和总线上停止位的结束时刻是否对齐。这个动作花不了五分钟,却能挡掉后面两周的返工。这套组合本身没有多高明,真正值钱的是把每个时间边界的因果关系抠清楚,然后在代码里留一处明确的注释,告诉下一个接手的人为什么不能改。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询