ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

双串口MODBUS+DMA+FIFO:STM32从站RS-485数据不丢的实现方案

双串口MODBUS+DMA+FIFO:STM32从站RS-485数据不丢的实现方案 简介双串口MODBUSDMAFIFO完整工程面向需要实现MODBUS多从机通信与串口高效收发的STM32开发者。工程基于FREEMODBUS库在双串口场景下引入DMA与FIFO缓冲机制降低CPU负载、保证数据有序性可应用于工业自动化设备、物联网节点等对实时性要求较高的场景。资源包共收录1220个文件压缩后仅9.2MB以C/H源码和工程配置文件为主除约900个源码文件外还包含STM32CubeMX的.ioc/.mxproject配置、IAR/Keil工程文件、.a/.lib静态库、.hex/.axf构建产物以及md/txt说明文档目录层次清晰便于对照学习和移植。目前已有1013人学习浏览。通过该工程读者可以直接获取双串口DMAFIFO收发框架、FREEMODBUS从机配置方法、多从机交互逻辑和完整可编译工程适合作为Modbus协议栈二次开发或串口性能优化的参考样例。1. 双串口MODBUSDMAFIFO到底解决什么问题双串口MODBUSDMAFIFO这套资源解决的不是“怎么把Modbus代码复制两份”的问题而是“两条RS-485总线同时压到同一台从站上数据怎么不丢”的问题。现场很常见1号总线的主站下发参数2号总线的主站轮询运行数据两边从站地址不同、波特率还不一样。如果靠串口中断一字节一字节收主程序一进临界区或者Flash一擦写立刻丢字节。我拆完这套资源后的感受是两路串口各配一个DMA接收通道用空闲中断IDLE判定Modbus帧结束再把DMA缓冲区里的字节倒进统一的FIFO队列交给FreeModbus按帧解析中断次数能降一个数量级。适合手里有STM32从站工程、想把中断收数换成DMA收数的人直接拆改。2. 模块怎么搭双串口、DMA、FIFO三者的分工与顺序2.1 为什么全中断接收在双串口下容易翻车Modbus RTU 的帧边界很脆弱协议规定两帧之间至少 3.5 个字符时间的静默帧内字符必须连续。一帧最长 256 字节按 115200bps 8N1 计算一个字节位时间约 86.8µs3.5 个字符约 304µs。也就是说整个串口在等字节的过程中CPU 被迫跟着字节节奏走完全没有弹性。最常见的单字节中断收数代码长这样void UARTx_IRQHandler(void) { uint8_t ch; if (__HAL_UART_GET_FLAG(huartx, UART_FLAG_RXNE) ! RESET) { ch (uint8_t)(huartx.Instance-RDR 0xFF); ring_fifo_push(rx_fifo_x, ch); /* 每来一个字节就压一次队列 */ __HAL_UART_CLEAR_FLAG(huartx, UART_FLAG_RXNE); } }这段代码看着没问题实际在双串口时会暴露出两类开销一次中断现场保护压栈出栈几十个周期两次中断之间还有不可裁剪的调度窗口。更麻烦的是两个串口共享同一中断优先级时一旦 1 号总线连续发帧2 号总线的字节会在嵌套向量表里排队等 1 号把一帧收完2 号硬件接收移位寄存器已经被新字节覆盖——ISR 没来得及读数据就没了。工业现场还有更隐蔽的情况操作系统临界区、Flash 擦写、调试打印都会关中断。实测关中断 2ms 后9600 波特率下会丢 2 到 3 个字节。DMA 的价值在于“中断延迟期间字节被外设自动搬到内存”FIFO 的价值在于“搬过来的字节还要按顺序喂给 Modbus 解析不能一次全推给协议栈”。这也是这套资源把 DMA 和 FIFO 绑在一起的原因。2.2 推荐接收链路DMA 搬字节IDLE 定帧尾FIFO 排队整套链路拆开看是五段外设字节进入 RX DMADMA 连续写入收发缓冲区串口产生 IDLE 空闲中断时暂停 DMA软件把缓冲区中已接收的字节拷贝进 FIFO应用层再从 FIFO 拉数据交给 FreeModbus 分包解析。收数方式CPU 占用抗中断延迟帧边界检测单字节中断最高差RXNE 没及时读就丢额外软件定时器定时器轮询 RDR中一般字节被覆盖风险超时近似DMA 空闲中断 FIFO低强DMA 自动保存IDLE 中断天然定位关于 IDLE 中断要说明一点串口在一个字节时间内没有收到新的起始位就会拉高 IDLE 标志。Modbus 帧内字节不允许出现这种间隙所以 IDLE 经常被当作“帧结束”的标志。对 115200 而言一个字节空隙约 87µs小于协议要求的 3.5 字符时间只要主站字节间不间断IDLE 不会被误触发。唯一例外是波特率很低或主站软件发送不连贯这类情况后面避坑章节单独处理。2.3 双串口并发为什么拷贝入 FIFO 不用加锁两个串口各自使用独立的 DMA 通道、独立的接收缓冲区和独立的 FIFO内存地址完全不重叠。即便两条总线同时来帧写的是不同区域裸机环境下不需要互斥锁。这套资源的文件结构也是这样设计的两个 FIFO 独立应用层有一个统一的轮询入口。#define UART1_RX_FIFO_SIZE 512 #define UART2_RX_FIFO_SIZE 512 static uint8_t uart1_rx_dma_buf[UART1_RX_FIFO_SIZE]; static uint8_t uart2_rx_dma_buf[UART2_RX_FIFO_SIZE]; static uint8_t uart1_fifo_mem[UART1_RX_FIFO_SIZE]; static uint8_t uart2_fifo_mem[UART2_RX_FIFO_SIZE]; static ring_fifo_t fifo_uart1 { .buf uart1_fifo_mem, .size UART1_RX_FIFO_SIZE }; static ring_fifo_t fifo_uart2 { .buf uart2_fifo_mem, .size UART2_RX_FIFO_SIZE };这里有三个 512 字节的数组分别是 DMA 原始缓冲区和 FIFO 存储区。为什么两个大小一致因为 DMA 一轮最多搬运的数据量不会超过 FIFO 容量如果 FIFO 比 DMA 缓冲区小IDLE 中断里拷贝时会丢数据。如果只跑一个从站256 字节也够双从站且主站频繁读多寄存器时512 更稳。3. FreeModbus移植与双串口初始化能抄的代码和参数3.1 移植 FreeModbus 时改哪几个 port 函数FreeModbus 的串口移植集中在 portserial.c 里核心就是下面几个函数函数作用这套资源的改法xMBPortSerialInit初始化串口与 DMA启动 HAL_UART_Receive_DMA 接收xMBPortSerialGetByte从 FIFO 取一个字节ring_fifo_pop(rx_fifo[port])xMBPortSerialPutByte发送单字节拉高 DE写 DR 或走 DCA 发送xMBPortSerialEnableIrq使能收发中断使能 IDLE 中断发送完成后关 DEvMBPortSerialClose关闭端口停 DMA关中断移植的坑通常在 xMBPortSerialPutByte。Modbus 是半双工协议发送前要把 RS-485 方向引脚拉高发完再拉低。如果只发一个字节就立刻拉低最后一个字节的停止位还在线上对端收到的帧就短了一截。3.2 双串口的 DMA 初始化与参数选择初始化代码可以直接抄重点看 DMA 通道选择和传输模式void BSP_UART_DMA_Init(void) { /* USART1 接收 DMA默认 NORMAL 模式由 IDLE 触发停止 */ hdma_uart1_rx.Instance DMA1_Channel5; /* 按实际型号改 */ hdma_uart1_rx.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_uart1_rx.Init.PeriphInc DMA_PINC_DISABLE; hdma_uart1_rx.Init.MemInc DMA_MINC_ENABLE; hdma_uart1_rx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_uart1_rx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_uart1_rx.Init.Mode DMA_NORMAL; __HAL_LINKDMA(huart1, hdmarx, hdma_uart1_rx); /* USART2 接收 DMA通道换掉其他参数一致 */ hdma_uart2_rx.Instance DMA1_Channel6; hdma_uart2_rx.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_uart2_rx.Init.PeriphInc DMA_PINC_DISABLE; hdma_uart2_rx.Init.MemInc DMA_MINC_ENABLE; hdma_uart2_rx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_uart2_rx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_uart2_rx.Init.Mode DMA_NORMAL; __HAL_LINKDMA(huart2, hdmarx, hdma_uart2_rx); HAL_UART_Receive_DMA(huart1, uart1_rx_dma_buf, UART1_RX_FIFO_SIZE); HAL_UART_Receive_DMA(huart2, uart2_rx_dma_buf, UART2_RX_FIFO_SIZE); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); __HAL_UART_ENABLE_IT(huart2, UART_IT_IDLE); }两个关键参数要说明。第一方向必须是 DMA_PERIPH_TO_MEMORY外设地址不自增内存地址自增第二模式用 DMA_NORMAL 而不是 DMA_CIRCULAR。这套资源的思路是“收满一帧就停”等 IDLE 中断把数据处理完再重新调用 HAL_UART_Receive_DMA 启动下一轮。如果用 CIRCULAR 模式缓冲区会被自己的写入指针反复覆盖IDLE 中断里取数据时可能已经丢了一截。另外注意 HAL 库的 HAL_UART_Receive_DMA 会把 huart 的 RxState 置为 BUSY如果 IDLE 中断里先停 DMA、拷贝数据、再重新启动必须确保上一次调用已经返回 HAL_OK否则会卡在 HAL_BUSY 上。3.3 双从站地址路由与轮询逻辑FreeModbus 原生只抽象一个串口双串口时我习惯做一层独立的帧分发不强行套两个协议栈实例。思路是先各自从 FIFO 提取完整帧校验 CRC 后比对从站地址命中哪一路就走哪一路的应答逻辑void MODBUS_Poll(void) { uint16_t len1 0, len2 0; len1 modbus_frame_extract(fifo_uart1, rx_frame[0]); len2 modbus_frame_extract(fifo_uart2, rx_frame[1]); if (len1 0 rx_frame[0].addr slave_addr[0]) { modbus_dispatch(rx_frame[0]); } if (len2 0 rx_frame[1].addr slave_addr[1]) { modbus_dispatch(rx_frame[1]); } }modbus_frame_extract 的逻辑是从 FIFO 里逐字节弹出边弹边算 CRC16直到帧长度超过 4 字节且 CRC 校验通过才算拿到一帧完整数据。这样做的优势在于两条串口互不阻塞1 号总线上一帧 100 字节的读操作不会延误 2 号总线的写操作。地址路由有个容易忽略的点如果两条总线上主站恰好用相同从站地址就要在 modbus_dispatch 里带总线编号参数应答时找对应的发送串口和 DE 引脚而不是用全局变量。3.4 RS-485 方向切换和发送完成判断发送方向的切换我一般放在 FreeModbus 的 xMBPortSerialEnableIrq 回调里void vMBPortSerialEnable(BOOL xRxEnable, BOOL xTxEnable) { if (xTxEnable) { RS485_DE_WRITE(1); /* 拉高 DE进入发送模式 */ __HAL_UART_ENABLE_IT(huart_send, UART_IT_TC); } else { /* 不能在这里立刻拉低 DE要等发送完成中断 */ } }TC 中断意味着 TDR 寄存器已空但移位寄存器里的最后一个字节可能还在线上。如果 TC 一到就关 DE总线上的帧尾会被截掉。常见做法是在 TC 中断里做几十到一百微秒的延时再关void UART_TC_Callback(void) { volatile uint32_t delay; for (delay 0; delay 120; delay); /* 粗略延时按主频微调 */ RS485_DE_WRITE(0); }这个延时不追求精确只要大于一个字符的位时间即可。比如 9600 波特率下一个字符约 1.04ms115200 下约 87µs延时 120 个循环在常见主频下约几十微秒到一百微秒勉强覆盖高速场景。严谨做法是按系统时钟算周期但资源包里的实现通常就是这样粗调现场够用。4. FIFO缓冲与帧边界判断把 DMA 搬运的字节按帧喂给协议栈4.1 环形 FIFO 实现与“容量必须是 2 的幂”环形 FIFO 的代码很短但容量有个硬性约束必须写成 2 的幂。原因很简单取余操作可以换成位与省掉除法指令typedef struct { uint8_t *buf; volatile uint16_t head; /* 写指针 */ volatile uint16_t tail; /* 读指针 */ uint16_t size; /* 必须是 2 的幂 */ volatile uint16_t len; /* 当前数据长度 */ } ring_fifo_t; static inline uint16_t ring_fifo_count(const ring_fifo_t *f) { return f-len; } static inline uint8_t ring_fifo_push(ring_fifo_t *f, uint8_t b) { if (f-len f-size) { return 0; /* 满丢弃新字节 */ } f-buf[f-head] b; f-head (f-head 1) (f-size - 1); f-len; return 1; } int ring_fifo_pop(ring_fifo_t *f, uint8_t *b) { if (f-len 0) { return 0; } *b f-buf[f-tail]; f-tail (f-tail 1) (f-size - 1); f-len--; return 1; }代码里有两个细节。第一head 和 tail 都用了 volatile因为写指针在 IDLE 中断里被修改读指针在应用层任务里被修改第二len 字段用来判断空和满比直接用 head tail 判断更可靠避免空和满两种状态混淆。我把 512 字节的 FIFO 用在双串口上原因是 Modbus 最长帧 256 字节DMA 一轮最多搬 256 字节FIFO 至少要是 DMA 缓冲区的两倍才能容忍应用层解析稍慢时的积压。4.2 IDLE 中断里的 DMA 停收与数据搬运IDLE 中断是这套资源的主心骨。进来之后先停 DMA再取计数器最后把数据搬进 FIFOvoid UART_Idle_IRQHandler(UART_HandleTypeDef *huart, ring_fifo_t *fifo, uint8_t *dma_buf, uint16_t dma_size) { uint16_t dma_index; if (__HAL_UART_GET_FLAG(huart, UART_FLAG_IDLE) RESET) { return; } __HAL_UART_CLEAR_FLAG(huart, UART_FLAG_IDLE); /* 关键先停 DMA再读计数器否则数据还在写入 */ __HAL_DMA_DISABLE(huart-hdmarx); /* 当前 DMA 已搬了多少字节 */ dma_index dma_size - (uint16_t)__HAL_DMA_GET_COUNTER(huart-hdmarx); for (uint16_t i 0; i dma_index; i) { ring_fifo_push(fifo, dma_buf[i]); } /* 清空缓冲区重新启动下一轮接收 */ memset(dma_buf, 0, dma_size); HAL_UART_Receive_DMA(huart, dma_buf, dma_size); }一定不要把 __HAL_DMA_DISABLE 放到读取 CNDTR 之后。DMA 在 IDLE 中断入口处可能还在响应最后一次 RXNE 请求如果先读计数器再停 DMA读出的值可能偏大或偏小导致搬运错位。先停再读虽然多花几个周期但数据是稳定的。还有一个细节HAL_UART_Receive_DMA 在 DMA 停掉之后调用需要重新设置传输长度。如果上一步的 DMA 已经搬完 512 字节且没有触发 IDLE这里的 HAL_UART_Receive_DMA 会因为 huart-RxState 仍为 BUSY 而返回错误。所以建议在调用前把 huart-RxState 强制复位或者直接复用底层 API 重新配置。4.3 帧边界3.5 字符静默靠 IDLE 还是靠定时器IDLE 中断在大多数情况下够用但遇到“主机字节间有微小停顿”时会误判。比如 PC 串口工具做主站发送缓冲区刷新不及时帧内两个字节之间出现超过一个字符时间的空闲IDLE 就会提前触发。低速 9600 波特率时3.5 个字符约 3.6ms远大于一个字符时间IDLE 反而不容易误判高速 115200 时一个字符约 87µs如果主站软件在字节间停顿超过 87µsIDLE 就会把一帧拆成两帧。稳妥做法是加一个定时器辅助判定。每个字节进入 FIFO 时记录时间应用层定期检查如果当前时间与最后字节时间差超过 3.5 字符时间就认为帧结束把 FIFO 里剩余数据当作一帧处理#define MB_35C_TICK(baud) ((uint32_t)(35000000UL / (uint32_t)(baud))) static uint32_t last_rx_tick[2]; void Modbus_Frame_Check_Timer(void) { uint32_t now HAL_GetTick(); if ((now - last_rx_tick[0]) MB_35C_TICK(9600) ring_fifo_count(fifo_uart1) 0) { modbus_frame_dispatch(fifo_uart1); } if ((now - last_rx_tick[1]) MB_35C_TICK(9600) ring_fifo_count(fifo_uart2) 0) { modbus_frame_dispatch(fifo_uart2); } }这套资源里保留了两种边界判定默认走 IDLE注释里留有定时器分支。我自己的习惯是波特率大于 57600 时用 IDLE低于 19200 时用定时器。因为高速时字节间隔短IDLE 触发更灵敏低速时 3.5 字符时间足够长定时器判定更符合协议定义。4.4 FIFO 容量选多大、溢出怎么办不同波特率下推荐容量参考波特率推荐 FIFO 大小理由9600128帧间隔长应用层来得及处理38400256一帧最长 256留一帧余量115200512双串口同时到达时防积压9216001024DMA 连续搬运量更大溢出时我倾向保留旧数据、丢弃新字节。原因是 Modbus 从站本身不负责重传主站超时后会重发完整帧丢掉新字节最多导致当前帧解析失败保留旧数据能保证之前已经对齐的帧不受影响。每个 FIFO 里维护一个溢出计数方便调试时观察是否经常超出容量。5. 双串口避坑记录五个我亲眼见过的坑5.1 IDLE 在长帧中间触发把一帧拆成两帧现象主站一次读 20 个保持寄存器从站却解析出两帧一帧 CRC 错误另一帧长度不对。原因主站是 PC 串口调试工具发送缓冲刷新不及时帧内字节之间出现了超过一个字符时间的空闲。UART 的 IDLE 标志在“线空闲满一个字符时间”时就会置位协议栈看到 IDLE 就认为帧结束。解决不要只依赖 IDLE。把第四章的定时器辅助判定打开或者把主站侧换成带连续发送模式的设备。排查时先用示波器抓总线波形看字节之间是否有超过一个字符宽度的空闲段。5.2 DMA 计数器还没读就被下一次接收改写现象IDLE 中断里读 CNDTR 得到 10实际 DMA 缓冲区里只有 6 个字节是有效的帧头被吞掉。原因IDLE 中断响应有延迟进入 ISR 时 DMA 尚未完全停住UART 的 RXNE 又触发了一次 DMA 请求CNDTR 继续递减。解决ISR 第一行必须先 __HAL_DMA_DISABLE再读 __HAL_DMA_GET_COUNTER。同时把该 DMA 通道的半传输中断关掉避免 HT 中断和 IDLE 中断同时操作 CNDTR造成竞态。5.3 复位后第一个字节被当成帧头整条总线判错现象设备上电后主站第一次读数据返回的帧总是错一个字节之后恢复正常。原因DMA 缓冲区里残留上电默认值 0xFF复位后 DMA 在应用层初始化之前就开始搬运了缓冲区里混进了无效字节。协议栈把这些 0xFF 当成帧头解析自然出错。解决初始化 DMA 之前先 memset 接收缓冲区为 0x00再把 FIFO 清空最后启动 HAL_UART_Receive_DMA。另外确认复位后 UART 的 RXNE 不会在 DE 拉低之前触发数据读取。5.4 两个串口的 DE 控制互相干扰两条 RS-485 总线上打架现象1 号总线读数据时2 号总线上的电平抖动从站响应帧出现移位和损坏。原因两个串口共用了同一个方向控制引脚或者驱动板上的收发器方向控制信号是反逻辑的。代码里把 DE 拉高时两条总线的 DE 同时被拉高两个收发器同时驱动总线。解决确认两路 RS-485 使用独立的 DE 引脚如果硬件已经共用只能把两路收发器的方向控制接到不同的 GPIO并在代码里分别控制。调试时用万用表量两个 DE 引脚的电平能立刻发现问题。5.5 发送 DE 关早了响应帧最后一个字节被截断现象从站自检认为已发送完毕主站收到的响应帧长度少一字节CRC 校验不过。原因TC 中断触发条件是 TDR 已空但不代表移位寄存器已空。此时最后一位可能还在线上立刻拉低 DE 会把停止位截掉。解决TC 中断里做短延时再拉低 DE或者等 TXE 和 TC 两个标志都成立再加半个字符时间的延时。按波特率 115200 计算延迟 50µs 就足够覆盖最后一位输出。6. 进阶把 FIFO 统计状态放进 Modbus 寄存器当验收指标6.1 用保持寄存器开放内部状态双串口从站调试最头疼的是“丢帧了但不知道怎么丢的”。我习惯在项目里加四个保持寄存器把 FIFO 溢出次数、CRC 错误次数、DMA 恢复次数、IDLE 触发次数映射到寄存器地址uint16_t stat_regs[4]; #define REG_FIFO_OVERRUN_CNT 0 /* FIFO 溢出次数 */ #define REG_RX_CRC_ERR_CNT 1 /* CRC 校验失败次数 */ #define REG_DMA_RECOVER_CNT 2 /* DMA 重新启动次数 */ #define REG_IDLE_CNT 3 /* IDLE 中断次数 */ uint16_t read_stat_reg(uint8_t reg_idx) { return stat_regs[reg_idx]; }把这组寄存器挂到 FreeModbus 的保持寄存器回调里主站通过标准 03H 功能码就能读到。验收时先让主站持续轮询一小时如果 FIFO 溢出计数不断增长说明应用层消费速度跟不上如果 IDLE 触发次数远大于实际帧数量说明字节间空隙过大应该改定时器边界判定。6.2 双串口互测的自检技巧没有两条独立总线时可以用一根杜邦线把 UART1 的 TX 和 UART2 的 RX 短接反过来也一样。程序里定时从 UART1 发一条标准读保持寄存器请求再由 UART2 的 FIFO 接收并回包验证两路链路是否正常void Modbus_SelfTest_Tick(void) { uint8_t req[8] {0x01, 0x03, 0x00, 0x00, 0x00, 0x02, 0xC4, 0x0B}; UART1_SendBytes(req, 8); /* 发到 UART1 总线 */ HAL_Delay(20); if (ring_fifo_count(fifo_uart2) 0) { stat_regs[REG_RX_CRC_ERR_CNT]; } }这里 CRC 值是固定的所以测试帧不需要额外计算。如果 fifo_uart2 没有收到任何数据说明 DMA 初始化或者 DE 方向控制有问题直接定位到对应代码。从那以后我每次做双串口从站调试都强制把四个统计寄存器加进去跑一晚上轮询再看计数帧边界和缓冲容量的问题基本都是这么暴露的。这套资源里的 DMA 停收顺序和 FIFO 容量参数也是我当时踩过这些坑之后改出来的希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表