简介:面向嵌入式开发者与工业通信学习者的 STM32F103ZET6+RS485+DMA+Modbus 工程资料,重点解决主从设备间高效、标准化数据交互问题,可用于课程设计、毕业项目及工业采集原型验证。压缩包共171个文件,约3.32MB,包含38个.h头文件、37个.c源文件,以及Keil MDK工程配置、启动与链接脚本、HEX/AXF/MAP等构建产物和若干备份文件,便于直接编译、对照工程结构与排查配置。内容围绕RS485收发方向切换、串口参数设置、DMA自动收发、Modbus 0x03读寄存器与0x06写单寄存器展开,并涉及报文解析、响应校验、超时与通信恢复思路。已有8318人学习,适合希望理解STM32串口DMA与Modbus主从实现的读者参考。
1. 从一块跑不通的 RS485 板子说起
某次在车间蹲了两天,只为一件事:一块 STM32 从站板单机测试全绿,接到现场主站轮询后,每隔十几分钟就丢一帧。手边那个 USB 转 RS485 模块加串口助手抓到的报文干干净净,怎么都复现不了。后来拆开看,是两个问题叠在一起——DMA 接收在循环模式下写指针没做回绕处理,方向控制脚又在 TXE 而不是 TC 上拉低,最后一个字节的高位被削掉了。分开看都不致命,凑一起就变成玄学。
这份资源处理的就是 STM32 + RS485 + DMA + Modbus RTU 这四个东西的组合问题:STM32 侧用 DMA 把收发从 CPU 上卸下来,RS485 负责差分长距离传输,Modbus RTU 定义帧格式与寄存器语义。它面向的是已经能点灯、能跑串口回环,但一上多机轮询就出问题的嵌入式开发者,也适合要把自研设备接进既有 PLC 或组态系统的工程师。下面按物理层、协议层、驱动层、排查、自测的顺序往下推,每一步都落到能直接编译进去的代码。
2. 物理层先立住:方向切换、终端匹配与共地
软件写得再漂亮,物理层不成立就是白搭。这一章不讲收发器内部结构,只讲三件在现场会直接决定通信成败的事:方向脚什么时候翻、总线两端挂什么、地线怎么走。
2.1 DE/RE 引脚不是普通 GPIO:切换时序决定会不会吃掉最后一个字节
RS485 收发器(常见的 MAX3485、SP3485、THVD1450 这类)有两根控制脚:DE(Driver Enable,高有效)和 /RE(Receiver Enable,低有效)。绝大多数板子把这两根短接在一起,用一个 GPIO 控制:拉高进发送态,拉低进接收态。这样做没问题,问题出在"什么时候拉低"。
STM32 的 USART 有两个容易混淆的标志位:
| 标志位 | 含义 | 能不能用来关 DE |
|---|---|---|
| TXE | 发送数据寄存器空,数据已搬到移位寄存器 | 不能,此时最后一个字节还在线上 |
| TC | 发送完成,移位寄存器也空了,停止位已发完 | 可以,这才是安全点 |
如果用 TXE 中断去拉低 DE,最后一个字节会被从中间截断。9600 波特率下一个字节约 1.04 ms,截断一半的表现是:主站收到的帧 CRC 校验失败,或者干脆把最后一字节当成新帧的起始。更隐蔽的情况是偶发——因为截断位置取决于中断响应延迟,负载轻的时候刚好赶在发完,负载一重就翻车。
用 HAL 库的话有一个现成的便利:HAL_UART_Transmit_DMA()在 DMA 传输完成中断里并不会立刻回调,它会先把 TC 中断打开,等 TC 置位后才调用HAL_UART_TxCpltCallback()。也就是说,在这个回调里拉低 DE 天然是安全的。但如果自己写 DMA 中断服务函数,就必须手动等 TC。
提示:部分 STM32 系列(如带 RS485 硬件支持的型号)提供了 USART 的 DE 信号输出和 DEAT/DEDT 寄存器,方向切换由硬件按时序自动完成,能彻底消掉软件延迟带来的不确定性。选型阶段值得优先考虑,能省掉一整类问题。
还有一个细节:从发送态切回接收态后,收发器需要几个比特的恢复时间,某些器件手册给的是 100 ns 级别,可以忽略;但如果你在同一个 USART 上做本地回显,刚拉低 DE 时 RX 线上可能还残留自己刚发出去的数据,这时要把接收 DMA 的缓冲区指针重置掉,否则下一帧会把回显拼在前面。
2.2 终端电阻、偏置电阻和共地:现场 80% 的丢包出在这
这三样东西经常被当成"可选项",但只要线长超过十几米、波特率高过 38400,它们就会变成必选项。
终端电阻:RS485 是差分总线,特性阻抗通常按 120 Ω 设计,总线两个物理末端各挂一个 120 Ω 电阻,中间节点不挂。只挂一端,反射还在;挂三个以上,总线负载过重,驱动能力下降,幅值掉到接收门限以下。血泪经验是:中间节点也焊了 120 Ω 的板子,短距离测试完全正常,一拉到 100 米就成片丢包。
偏置电阻:总线空闲时所有驱动器都关闭,A/B 之间的差分电压接近 0,接收器输出处于不确定状态,可能一直输出低电平,被 CPU 当成一帧全 0 的起始位。解决办法是在总线的某一端加一对偏置电阻(常见 560 Ω 上拉 A、560 Ω 下拉 B,具体阻值按收发器手册和节点数算),让空闲态差分电压大于 200 mV。很多收发器自带 fail-safe 偏置,看手册确认,别重复加。
共地:RS485 是差分,但它有共模电压范围(常见 -7 V 到 +12 V)。两地设备如果各自用独立的开关电源,地电位差可能超过这个范围,表现是"白天正常晚上丢包"或者"一开电机就断"。正确做法是沿总线敷一根地线(不要用屏蔽层代替),把各节点地连起来;长距离或者强干扰场合,在节点间加隔离型收发器,把地环路断掉。
线材:必须用双绞线,A/B 在同一对绞线上。用两根平行线走 485,抗共模干扰能力基本为零。
2.3 上电前的三分钟自检:用示波器和逻辑分析仪确认链路
在写协议代码之前,先用最笨的办法验证物理层,能省掉后面大量"到底是硬件还是软件"的扯皮。
步骤一,断开主站,让从站周期性发送固定报文(比如 0x01 0x03 0x00 0x00 0x00 0x0A + CRC)。用示波器差分探头或者两通道相减看 A-B 波形,确认:
- 空闲时差分电压在 ±200 mV 之外(偏置生效)
- 有数据时幅值不低于 1.5 V(5 V 收发器)或 1.0 V(3.3 V 收发器)
- 波形上升沿没有明显振铃,过冲不超过电源轨
步骤二,用逻辑分析仪同时抓 DE 脚和 RO(接收输出)脚,确认 DE 拉低发生在最后一个停止位之后,而不是数据中间。这一条能直接抓出 2.1 节说的问题。
步骤三,把波特率提到实际使用值,连续跑十分钟,统计误码。如果此时就已经有误码,不用往上查协议栈了,先解决物理层。
3. Modbus RTU 帧与寄存器映射:先把协议定死,再谈代码
物理层通了,接下来是协议语义。Modbus 本身不复杂,但实现时最容易出问题的不是 CRC,而是寄存器地址映射和异常响应——这两块设计错了,上位机联调时你会收到一堆莫名其妙的超时。
3.1 帧长、功能码与 CRC16:RTU 的硬边界
Modbus RTU 一帧的结构是固定的:从站地址(1 字节)+ 功能码(1 字节)+ 数据(N 字节)+ CRC(2 字节,低字节在前)。整个 ADU 不超过 256 字节,这是硬上限,接收缓冲区按 256 开就够,但建议多留一点余量做溢出检测。
功能码只需要实现最常用的八个,其余返回异常码 01:
| 功能码 | 名称 | 请求数据 | 响应数据 |
|---|---|---|---|
| 0x01 | 读线圈 | 起始地址 + 数量 | 字节数 + 位图 |
| 0x02 | 读离散输入 | 起始地址 + 数量 | 字节数 + 位图 |
| 0x03 | 读保持寄存器 | 起始地址 + 数量 | 字节数 + 寄存器值 |
| 0x04 | 读输入寄存器 | 起始地址 + 数量 | 字节数 + 寄存器值 |
| 0x05 | 写单个线圈 | 地址 + 0xFF00/0x0000 | 原样回显 |
| 0x06 | 写单个寄存器 | 地址 + 值 | 原样回显 |
| 0x0F | 写多个线圈 | 地址 + 数量 + 字节数 + 位图 | 地址 + 数量 |
| 0x10 | 写多个寄存器 | 地址 + 数量 + 字节数 + 值 | 地址 + 数量 |
CRC16 用的是 Modbus 专用变种:多项式 0xA001(0x8005 反转),初值 0xFFFF,输入输出都不反转,结果低字节先发。这段代码几乎每个项目都要抄一遍,但抄错的人不少——注意是右移不是左移,异或的是 0xA001 不是 0x8005。
/* Modbus RTU 的 CRC16:初值 0xFFFF,反射多项式 0xA001,结果低字节先发 */ uint16_t modbus_crc16(const uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; while (len--) { crc ^= *buf++; /* 低 8 位先异或 */ for (uint8_t i = 0; i < 8; i++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; /* 右移一位后异或多项式 */ } else { crc >>= 1; } } } return crc; /* 发送时先发 crc & 0xFF */ }参数说明:buf指向从站地址开始、到数据域结束的缓冲区,len不包含 CRC 本身的两个字节。校验时把收到的整帧(含 CRC)传进去,正确结果是 0——因为 CRC 的自校验性质,直接判modbus_crc16(frame, total_len) == 0比拆开比对更省事,也少一处出错点。
另外两个容易被忽略的边界:一是寄存器数量,0x03 功能码一次最多读 125 个寄存器(250 字节数据,加上 5 字节头尾刚好不超 256),超过要返回异常码 03;二是字节数域的合法性,写多个寄存器时请求里的字节数必须等于数量 × 2,不相等就返回异常码 03,别硬着头皮按请求里的字节数去读缓冲区,那是越界访问的入口。
3.2 寄存器表设计:保持寄存器、输入寄存器和线圈怎么分
Modbus 的四个数据区在协议上是独立的地址空间,都从 0 开始编址:
- 线圈(Coils,0x 区):可读可写,1 位,用功能码 01/05/0F 访问。放继电器输出、使能位、启停命令。
- 离散输入(Discrete Inputs,1x 区):只读,1 位,用 02 访问。放限位开关、急停状态、外部 DI。
- 输入寄存器(Input Registers,3x 区):只读,16 位,用 04 访问。放 ADC 采样值、温度、实时电流这类测量量。
- 保持寄存器(Holding Registers,4x 区):可读可写,16 位,用 03/06/10 访问。放参数设定值、标定系数、运行模式。
设计时有两条实操建议。第一条,只读量一律放 3x 区,可写量一律放 4x 区,别把只读的测量值塞进 4x 区,否则上位机可以随手写进去,而且你还得在写处理里加一层拒绝逻辑。第二条,地址连续、按功能分组,温度相关的一组从 0x0000 开始,参数相关的一组从 0x0100 开始,留出空隙以后加字段不用改上位机地址表。
代码上不要写一大串if (addr == 0) ... else if (addr == 1) ...,用表驱动,一次遍历就能处理任意数量的寄存器:
typedef struct { uint16_t addr; /* 寄存器起始地址(协议地址,从 0 开始) */ uint16_t *data; /* 指向真实变量,读写都落在这块内存上 */ uint16_t count; /* 连续寄存器个数 */ uint8_t rw; /* 0 = 只读(输入寄存器),1 = 读写(保持寄存器) */ } reg_block_t; /* 把散落的变量按块登记,新增字段只需加一行 */ static uint16_t g_temp_raw, g_hum_raw, g_run_mode, g_setpoint; static const reg_block_t g_input_regs[] = { { 0x0000, &g_temp_raw, 1, 0 }, { 0x0001, &g_hum_raw, 1, 0 }, }; static const reg_block_t g_hold_regs[] = { { 0x0100, &g_run_mode, 1, 1 }, { 0x0101, &g_setpoint, 1, 1 }, };参数说明:addr用协议地址(0 基),不要混用 PLC 侧习惯的 40001 这种显示地址,两者差 1,联调时最容易在这里对不上。data直接指向全局变量,省掉一次拷贝,但要注意多字节数据(float、int32)的字节序——Modbus 寄存器是 16 位大端,而 STM32 是小端,一个 float 拆成两个寄存器时需要显式转换,并且和上位机约定好高低字顺序。
3.3 异常响应的四种码:别让主站等到超时
从站收到一帧之后,只有两种合法回复:正常响应或者异常响应。异常响应的格式是功能码 | 0x80,后面跟一个字节的异常码:
| 异常码 | 含义 | 典型触发场景 |
|---|---|---|
| 0x01 | 非法功能 | 收到未实现的功能码 |
| 0x02 | 非法数据地址 | 请求地址超出登记范围 |
| 0x03 | 非法数据值 | 数量超限、字节数不匹配 |
| 0x04 | 从站设备故障 | 内部执行失败,如 EEPROM 写不进去 |
很多实现图省事,遇到不认识的请求直接不回。这在单机测试里看不出问题,上了现场就是主站侧等 1 秒超时,然后重试,重试再超时,整个轮询周期被拖垮,表现成"某个从站挂了导致整条总线变慢"。正确做法是:只要地址匹配、CRC 正确,无论请求多离谱都要回一帧异常响应,让主站立刻知道结果。
还有一个坑是广播地址 0。地址 0 的报文所有从站都要执行,但不能回复。如果代码里没做这个判断,总线上会出现多个从站同时回应的碰撞,波形一片混乱,查起来非常费劲。
4. STM32 + DMA 收发落地:IDLE 中断配环形缓冲
协议定完了,重心转到驱动。DMA 的价值在这里才真正体现:一个 115200 波特率、30 个从站的轮询系统,每帧之间间隔可能只有几毫秒,CPU 如果在每个字节中断里搬运数据,主循环基本没时间干活。
4.1 初始化:USART、DMA 通道与 NVIC 的配置顺序
顺序错了会有一些很难查的现象。推荐的顺序是:先开 GPIO 和 USART 时钟、配置引脚复用,再配 USART 参数(波特率、8 数据位、无校验或偶校验、1 停止位),接着配 DMA 通道(发送用 Normal 模式、内存自增、外设不自增、字节对齐;接收用 Circular 模式),最后配 NVIC 并使能 IDLE 中断。
几个具体参数:
- 波特率:按现场总线约定,9600 / 19200 / 38400 / 115200 都常见。注意误差,STM32 的 USART 波特率由 PCLK 分频得到,算下来误差超过 2% 就可能出现偶发误码,用 CubeMX 时它会直接标红提示。
- 校验位:Modbus 规范要求 11 位字符,即 8 数据位 + 1 校验位 + 1 停止位。实际项目里 8N1(无校验)用得也很多,两边一致就行。但如果用 8E1,注意 STM32 的 9 位数据寄存器里校验位占最高位,读出来的数据要
& 0x7F或者干脆配置成 8 位数据 + 硬件校验,别手动拼。 - DMA 优先级:接收通道优先级建议设为 High,发送设为 Medium,避免接收溢出。
- 接收缓冲区:开 256 字节,与 Modbus ADU 上限对齐,循环模式。
提示:接收 DMA 用 Circular 模式时,不要再用
HAL_UART_Receive_DMA()重新启动,那样会把缓冲区指针重置,正在接收的数据就丢了。Circulation 模式启动一次即可,靠写指针回绕。
4.2 发送路径:DMA 搬运 + TC 中断关方向
发送的完整流程是:拼帧 → 拉高 DE → 启动 DMA → 等 TC → 拉低 DE。
#define MODBUS_TX_MAX 256 static uint8_t tx_buf[MODBUS_TX_MAX]; /* DMA 源缓冲,发送期间不能改写 */ void rs485_send(const uint8_t *frame, uint16_t len) { if (len == 0 || len > MODBUS_TX_MAX) return; memcpy(tx_buf, frame, len); /* 拷贝,避免调用者的栈缓冲被回收 */ RS485_DE_HIGH(); /* 先占住总线,再启动搬运 */ HAL_UART_Transmit_DMA(&huart2, tx_buf, len); } /* HAL 在 DMA 完成中断里会先打开 TC 中断,等 TC 置位后才回调这个函数 */ void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART2) { RS485_DE_LOW(); /* 此刻停止位已发完,可以安全释放总线 */ g_tx_busy = 0; } }逻辑说明:tx_buf必须是静态或全局的,因为HAL_UART_Transmit_DMA()是异步的,函数返回时数据还没发完;如果传的是栈上的临时数组,函数一返回这块内存就被覆盖了,现象是发出的报文前几个字节对、后面全是垃圾。RS485_DE_HIGH()要在启动 DMA 之前调用,否则前几个字节可能在接收态下发出,差分总线上根本没有驱动。
参数说明:len是完整 ADU 长度(含 CRC),最大 256。如果项目里有多个 USART 共用这个函数,把huart做成参数传进来,回调里靠huart->Instance区分。
发送还有一个必须处理的情况:上一帧还没发完又来一帧。g_tx_busy标志就是干这个的,新请求进来先判忙,忙则丢弃或者排队。不判的话,第二次HAL_UART_Transmit_DMA()会返回HAL_BUSY,帧直接丢了,而调用方还以为发出去了。
4.3 接收路径:DMA 循环模式 + IDLE 中断算帧长
接收比发送复杂,因为要判断"一帧什么时候结束"。默认的 HAL 做法是定长接收,但 Modbus 是变长的,所以要用 IDLE 中断:总线空闲一个字符时间后,USART 的 IDLE 标志置位,此时 DMA 已经把所有字节搬进缓冲区了。
#define RX_BUF_SIZE 256 static uint8_t rx_buf[RX_BUF_SIZE]; static uint16_t rx_last_pos = 0; /* 上一次处理的写指针位置 */ void rs485_rx_start(void) { rx_last_pos = 0; __HAL_UART_CLEAR_IDLEFLAG(&huart2); __HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE); HAL_UART_Receive_DMA(&huart2, rx_buf, RX_BUF_SIZE); /* DMA 配成 Circular */ } /* 放在 USART2_IRQHandler 里,注意先清 IDLE 再调 HAL 的通用处理 */ void rs485_uart_irq_handler(void) { if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart2); /* 读 SR 再读 DR 完成清除 */ uint16_t pos = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart2_rx); uint16_t len; if (pos >= rx_last_pos) { len = pos - rx_last_pos; /* 没有回绕 */ } else { len = RX_BUF_SIZE - rx_last_pos + pos; /* 回绕,分两段 */ } if (len > 0 && len <= MODBUS_ADU_MAX) { modbus_frame_input(&rx_buf[rx_last_pos], len); /* 交给协议层校验 */ } rx_last_pos = pos; } HAL_UART_IRQHandler(&huart2); /* 让 HAL 处理错误标志 */ }逻辑说明:__HAL_DMA_GET_COUNTER()返回的是剩余待传输数量,用缓冲区总长减掉它才是 DMA 当前写到的位置。循环模式下写指针会在缓冲区里绕圈,所以必须保存上一次的位置rx_last_pos,用两次位置差算帧长,回绕时拆成两段。如果缓冲区刚好绕过去,len就是两段之和,但内存上它是分散在缓冲区的头尾两处——上面的简化写法直接用&rx_buf[rx_last_pos]传指针在回绕情况下是错的,正确做法是先把这一段拷贝到临时缓冲区再交给协议层。这是最容易埋雷的地方,单板测不出,现场跑久了缓冲区绕一圈就爆。
参数说明:MODBUS_ADU_MAX定义为 256。len超过它说明是噪声或者截断帧,直接丢弃,不要试图解析。另外HAL_UART_IRQHandler()里会检查 ORE(溢出)、NE(噪声)、FE(帧错误)标志,如果这几个标志频繁置位,基本可以断定是物理层问题,回去看第 2 章。
4.4 3.5 字符判帧:定时器比裸 IDLE 稳在哪
IDLE 中断的触发条件只有 1 个字符时间。如果主站因为任务调度抖动,在一帧中间插了个 2 个字符的间隙,IDLE 就会误判帧结束,把半帧交给协议层,CRC 必然错。
Modbus 规范的判帧条件是 3.5 个字符时间的静默。做法有两种:
第一种是纯软件,用一个 1 kHz 的定时器(或者 systick 计数器),每收到一个字节就重置计数,计数超过 3.5T 对应的毫秒数就判帧结束。3.5 字符时间按 11 位/字符算:9600 波特率约 4.01 ms,19200 约 2.0 ms,115200 约 0.33 ms。规范里补充了一条:波特率高于 19200 时,固定用 1750 µs。
第二种是硬件定时器,字节到达时把计数器清零,满了产生更新中断。这种精度高,不占 CPU,缺点是多占一个定时器资源。
/* TIM3 配成 1 MHz 计数、单脉冲模式,计数值 = 3.5T 微秒数 */ static uint32_t t35_us; static void rs485_update_t35(uint32_t baud) { /* 11 位/字符(起始位 + 8 数据位 + 校验位 + 停止位) */ t35_us = (baud > 19200U) ? 1750U : (uint32_t)(3.5f * 11.0f * 1e6f / baud); } void rs485_rx_byte_hook(void) /* 每个字节到达时调用(可用 DMA 半传中断) */ { __HAL_TIM_SET_COUNTER(&htim3, 0); __HAL_TIM_ENABLE(&htim3); } void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { HAL_TIM_Base_Stop(&htim3); modbus_frame_input(...); /* 确认静默 3.5T,收帧结束 */ } }逻辑说明:IDLE 中断仍然保留,作用是在物理层空闲时快速唤醒处理;3.5T 定时器负责最终确认,两者配合能同时兼顾延迟和准确性。参数说明:t35_us按实际波特率算,注意3.5f * 11.0f这个 11 位是含校验位的情况,如果配的是 8N1 就是 10 位,算出来的值略有差别,实际调试时留 20% 余量更稳妥。
5. 避坑与排查:五条血泪记录
这一章按"现象 → 原因 → 解决"记五条。每一条都是能让人在工位上耗掉半天的类型。
5.1 前几个字节正常,最后一个字节 CRC 总错
现象:主站收帧,前面数据都对,就是 CRC 校验不过,用逻辑分析仪看波形,最后一个字节只有一半宽度。
原因:DE 在 TXE 中断里拉低了。TXE 只表示数据寄存器空,移位寄存器里还有一个字节在发。
解决:改到 TC 中断(或用HAL_UART_TxCpltCallback())里释放 DE。如果自己写中断服务函数,等USART_SR_TC置位再拉低,并清掉 TC 标志。改良方案是换用带硬件 DE 的 USART,彻底消掉软件时序的不确定性。
5.2 单板测试正常,多从站并联后随机丢帧
现象:一台从站挂上去跑一天没事,挂三台以上,每天总有一两台偶尔不响应,换哪台不响是随机的。
原因:总线末端没有终端电阻,或者中间节点也焊了 120 Ω,导致总线负载过重、信号反射。短距离、低速时反射的影响被掩盖,节点一多就暴露。
解决:确认只有物理两端各一个 120 Ω,其余节点去掉;用万用表断电量 A-B 之间的直流电阻,正常应该在 60 Ω 左右(两个 120 Ω 并联)。同时检查偏置电阻是否只加了一处。
5.3 上电后第一次通信必失败,之后一切正常
现象:设备断电重启,主站发第一条请求从站不回,重发一次就正常了,之后稳定。
原因:初始化顺序问题。HAL_UART_Receive_DMA()在 RS485 收发器还没稳定、或者 DE 脚电平还没确定的时候就被调用,第一帧被漏掉。或者 DMA 通道使能晚于 USART 使能,第一个字节丢失。
解决:在初始化末尾加 10 到 50 ms 延时,等收发器和外部隔离电源稳定;确保 DMA 通道先于 USART 使能,或者在使能接收前先手工清一次 RX 数据寄存器。第一次通信失败对某些主站来说是致命的,因为它的重试次数可能只有一次。
5.4 高波特率下偶发收到自己发出的数据
现象:从站日志里出现地址和数据完全等于自己刚发出去的帧,CRC 还是对的。
原因:DE 和 /RE 短接,发送时接收器是关闭的;但拉低 DE 的瞬间,如果总线上还有其他节点的延迟响应,或者本地收发器恢复时间长,残留电平会被 RX 脚采到。另一种情况是全双工模式下的本地回环没关。
解决:拉低 DE 后立刻把接收 DMA 的写指针重置回起始位置,把这段残留数据覆盖掉;或者在协议层过滤掉与刚发送帧完全一致的输入。根治办法是用带自动方向控制的收发器,切换时序由器件内部保证。
5.5 主站读到的数据永远慢一拍
现象:改了设定值,主站下一次读还是旧值,再读一次才更新。
原因:寄存器映射用了双缓冲但没做一致性处理——低 16 位和高 16 位分别在两次不同的主循环周期里更新,主站恰好在中间读,拿到的是半新半旧的组合。或者写操作先进了队列,主循环还没来得及应用到实际变量。
解决:多寄存器量的更新做成临界区,先关中断,一次性把两个字都写进去,再开中断;读取时同样,一次性把整组寄存器拷到快照缓冲区,再从快照里往外发。对于 float 这类跨两个寄存器的量,尤其要注意。
6. 进阶:给从站加一套可自测机制
协议跑通之后,真正决定项目能不能交付的,是有没有一套不依赖主站的自测手段。我的习惯是在从站固件里留三个调试入口,平时不占资源,出问题时能立刻定位。
第一是寄存器快照。在主循环里维护一份寄存器区的完整拷贝,只在收到特定诊断请求(可以借用用户自定义功能码,比如 0x41)时才整体输出。现场遇到"主站显示不对"的时候,先看快照,能立刻分辨是数据源错了还是响应拼装错了,省掉一轮猜测。
第二是通信统计。用一个结构体记下收到的帧数、CRC 错帧数、异常响应数、溢出错误数、最后一次成功通信的时间戳,映射到一组只读寄存器上。
typedef struct { uint32_t rx_frames; /* 收到的完整帧 */ uint32_t crc_errors; /* CRC 校验失败 */ uint32_t exc_responses; /* 发出的异常响应 */ uint32_t uart_errors; /* ORE/NE/FE 累计 */ uint32_t last_rx_tick; /* 最后一次成功收帧的 tick */ } comm_stat_t; static comm_stat_t g_stat;这组计数器是定位现场问题的黑匣子。crc_errors一直涨而uart_errors不涨,多半是判帧时机不对;两个都在涨,回去查物理层;都正常但last_rx_tick不再更新,那是主站侧的问题或者从站地址配错了。没有这组数据,现场只能靠猜。
第三是脚本回归。用 Python 加 pyserial 写一个小压测工具,模拟主站连续轮询,统计丢帧率和响应时间分布。脚本不长,但每次改完驱动都跑一遍,能挡住大部分回归问题:
import serial, time, random def crc16(data: bytes) -> bytes: """Modbus RTU CRC16:初值 0xFFFF,反射多项式 0xA001,低字节先发""" crc = 0xFFFF for b in data: crc ^= b for _ in range(8): crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1 return bytes([crc & 0xFF, crc >> 8]) def build_read(slave: int, addr: int, count: int) -> bytes: body = bytes([slave, 0x03, addr >> 8, addr & 0xFF, count >> 8, count & 0xFF]) return body + crc16(body) port = serial.Serial("COM5", 115200, timeout=0.3) # 换成实际串口 total, lost, worst = 0, 0, 0.0 for _ in range(2000): req = build_read(1, 0x0100, 2) t0 = time.perf_counter() port.reset_input_buffer() # 丢掉上一轮的残留 port.write(req) resp = port.read(9) # 地址+功能码+字节数+4数据+2CRC dt = (time.perf_counter() - t0) * 1000 total += 1 if len(resp) != 9 or crc16(resp) != b"\x00\x00": lost += 1 else: worst = max(worst, dt) print(f"total={total} lost={lost} loss={lost/total:.2%} worst={worst:.1f}ms")脚本里的reset_input_buffer()不能省,否则上一轮的超时响应会污染下一轮的读取,统计出来的丢帧率虚高。worst这个最大值比平均值有用得多——平均值 2 ms、最大值 800 ms 的系统,在现场就是会偶发超时。跑够 2000 次,丢帧率应该为 0,最大值应该稳定在响应时间的两倍以内。
提示:压测时把波特率、节点数、线长都按现场条件配置。在桌面上用 30 cm 短线跑出来的 0 丢帧,说明不了任何问题。
最后说个教训。早些年我习惯把驱动写完、单板能通就打包发出去,觉得现场问题现场再说。后来一次多从站项目,为了一个"偶发丢帧"来回折腾了三轮,最后发现是循环缓冲回绕时指针没处理好——这个问题在任何单板测试里都不会出现,只有缓冲区绕满一圈才触发。从那以后我每次改完接收路径,都强制先跑一遍绕圈测试:把缓冲区故意改小到 16 字节,连续发超过缓冲区长度的帧,确认回绕分支被走到且数据正确,再改回 256 去联调。这个动作只花两分钟,但能挡掉一整类只有现场才暴露的问题。希望帮到你。
本文还有配套的精品资源,点击获取