简介:面向STM32嵌入式开发者的MODBUS工业通信参考工程,基于FREEMODBUS库实现双串口多从机通信,并引入DMA与FIFO优化串口数据吞吐。压缩包内含1220个文件,以C源代码与头文件为主,包含608个.c、290个.h,另有启动文件、标准库文件、链接脚本、CubeMX/IAR/Keil工程配置文件及编译产物,整体约9.2MB。已有1009人学习。工程围绕Modbus RTU主从站模式展开,展示了双串口独立收发配置、通过DMA降低中断负载、利用FIFO缓冲确保高波特率下数据不丢失,并提供多从机地址映射与轮询处理的示例逻辑。文件类型覆盖源码、链接脚本、工程备份与说明文档,既可直接编译烧录,也便于对照学习协议栈和STM32外设集成方式。目录结构清晰,适合快速搭建工业网关、数据采集节点或移植FREEMODBUS的中高级开发者参考。 双串口、MODBUS、DMA、FIFO,这几个词放在一起,懂的都懂——这就是典型的工业通信设备底层框架。我最近正好在整理一套可以复用的工程代码,压缩包名字就叫“双串口MODBUS+DMA+FIFO.7z”,里面跑通了一主一从双串口同时工作的完整逻辑,也解决了串口收发过程中CPU被频繁中断占用的老大难问题。
这套方案做出来之后,我把它应用在一款数据采集终端上:串口1作为MODBUS从机,响应上位机或触摸屏的查询请求;串口2作为MODBUS主机,轮询下面挂着的温湿度传感器、电能表等设备。两个串口同时跑,数据量一上来,最开始用普通中断收发的方式直接翻车,CPU几乎全耗在频繁进中断、搬运数据上了。后来改成DMA + FIFO + 空闲中断这套组合拳,整机CPU占用率从接近70%降到了15%以内,稳定跑了大半年没出过问题。如果你也在做类似的双串口MODBUS设备,或者正准备从传统中断收发迁移到DMA方案,这篇文章里的思路、代码框架、踩坑记录应该都能帮到你。
1. 整体方案设计:为什么非要用DMA+FIFO
1.1 双串口MODBUS设备的典型工作场景
在动手写代码之前,先得搞清楚设备到底要干什么。我这边做的设备是一台“协议转换+数据采集”一体机:一方面要实时响应上位机的MODBUS查询,另一方面还要主动去读取下级设备的数据。这两个任务分别跑在两个串口上,所以就叫“双串口MODBUS”。
这种架构在工业现场特别常见:PLC做主站、单片机做从站;或者单片机做采集网关,一边和触摸屏通信,一边和传感器通信。关键点在于,两个串口的数据流是独立且并发的——上位机可能在任意时刻发出查询指令,而采集轮询也不能因为上位机通信而中断。如果采用传统的按字节中断收发,每来一个字节就要进一次中断,两个串口同时跑的时候,中断频率高得吓人,而且还要在中断里处理协议逻辑,稍不留神就丢数据。
所以我在设计初始就定了调:接收用DMA + 空闲中断,发送也用DMA,协议解析和帧处理全部放在主循环里,中断里只做必要的标志位置位。
1.2 DMA和FIFO分别承担什么职责
很多初学者会把DMA和FIFO搞混,其实这两个东西干的完全不是同一件事。
DMA(Direct Memory Access,直接存储器访问)解决的是“数据搬运不占用CPU”的问题。传统方式下一个字节一个字节地从串口数据寄存器搬到内存里,每搬一次CPU都要参与;而DMA相当于在硬件上开了一条“货运专线”,串口收到数据后,由DMA控制器直接写入我们指定的内存缓冲区,CPU完全不需要参与搬运过程。只有当一条完整的数据帧接收完成后(通过空闲中断判断),CPU才出来处理这一整包数据。
FIFO(First In First Out,先入先出队列)解决的是“数据生产速度和消费速度不匹配”的问题。DMA虽然把数据搬到了缓冲区,但上层协议解析的速度可能跟不上数据的到达速度。我用FIFO做一个中间蓄水池:DMA收进来的数据全部进入FIFO,主循环里的协议解析模块按自己的节奏从FIFO里取数据。哪怕某一帧解析慢了,下一帧数据也不会丢失,而是乖乖在FIFO里排队。
打个比方,DMA是传送带,FIFO是仓库,CPU是工人。传送带不停地把货物(串口数据)送进仓库,工人有空了再从仓库里拿货处理。
1.3 整体框架选型的取舍
硬件平台我用的是STM32F103系列,标准外设库开发。之所以没选HAL库,个人习惯是一方面,更重要的是标准库的DMA配置逻辑直白,中断回调路径短,出问题容易排查。如果你用HAL库也没关系,思路完全一致,只是API不同。
资源分配上,两个串口都配置了独立的DMA通道,互不干扰。串口1的DMA接收缓冲区大小为256字节,串口2的DMA接收缓冲区大小为512字节——这是因为从机串口的查询命令通常比较短,而主机串口收到的传感器数据帧可能稍长一些。FIFO则是两个串口各配一个环形缓冲区,大小1KB,够用且不浪费内存。
这套方案选型的核心逻辑总结起来就一句话:尽量让CPU少干活,让硬件多干活,把宝贵的算力留给协议解析和应用逻辑。
2. 串口与DMA初始化细节
2.1 串口参数配置的几个易错点
MODBUS RTU的串口参数通常固定为波特率9600、8数据位、无校验、1停止位。但在代码里配置的时候,有几个细节很容易出错,我这里逐个说清楚。
首先是USART_IT_IDLE(空闲中断)的使能时机。空闲中断的检测机制是:接收线路上出现一个字节时间的空闲状态时触发。注意,这个中断不是每个字节都触发,而是一整段数据接收完毕、总线上安静下来之后才触发。所以必须等DMA接收使能之后,再打开空闲中断,并且设置DMA为循环模式,否则FIFO数据会溢出覆盖。
USART_ITConfig(USART1, USART_IT_RXNE, DISABLE); // 关掉按字节接收中断,改用DMA USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); // 使能串口接收的DMA请求 USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); // 使能空闲中断,判断一帧接收完毕初始化顺序这里我踩过一次坑。如果先开空闲中断再使能DMA,在系统刚上电的那一瞬间,总线上的抖动噪声很可能触发一次假空闲中断,导致程序误以为收到了一帧数据。我的做法是把空闲中断的使能放在DMA和串口全部初始化完成之后,并且在中断处理里清标志时做一次数据长度判断,长度为0直接忽略。
2.2 DMA通道选择与FIFO缓冲区的内存管理
STM32F103的DMA1有7个通道,每个通道可以服务多个外设请求,但同一时刻只能分配一个。串口1的接收和发送分别需要单独一个通道,串口2也一样,总共占用4个DMA通道。我实际分配是这样的:
| 外设 | DMA通道 | 方向 | 缓冲区 |
|---|---|---|---|
| USART1_RX | DMA1_Channel5 | 外设到内存 | 256字节 |
| USART1_TX | DMA1_Channel4 | 内存到外设 | 根据帧长动态 |
| USART2_RX | DMA1_Channel6 | 外设到内存 | 512字节 |
| USART2_TX | DMA1_Channel7 | 内存到外设 | 根据帧长动态 |
两个接收DMA都设置为循环模式(Circular Mode),这样DMA会自动回绕写入缓冲区,配合空闲中断就能实现“不定长数据帧”的接收。缓冲区数组定义时要注意内存对齐,尤其是开启了D-Cache的芯片(比如F103没有D-Cache,但M7核的芯片就必须注意),否则DMA和CPU读写同一块内存时会出现数据不一致的诡异问题。
FIFO缓冲区我是自己写的一个环形队列结构:
typedef struct { uint8_t buffer[FIFO_SIZE]; volatile uint16_t head; // 写入索引 volatile uint16_t tail; // 读取索引 uint16_t count; // 当前数据量 } RingFIFO;之所以加上volatile,是因为head和tail会被中断和主循环同时访问。如果缺少这个关键字,编译器优化后可能在主循环里一直读到旧值,导致数据明明已经写入FIFO却读不出来。这个问题排查起来极其隐蔽,我花了大半天才定位到。
2.3 发送DMA的完成判断
串口发送用DMA,很多人会忽略一个关键问题:DMA把数据从内存搬到串口发送数据寄存器,并不代表数据已经从串口线路上发送完毕。如果紧接着切换RS485的方向引脚(收发切换),必须在“发送完成中断”(USART_IT_TC)里关断发送使能、切换方向,而不是在DMA传输完成中断里做。
void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_TC) != RESET) { USART_ClearITPendingBit(USART1, USART_IT_TC); RS485_DE_1_OFF(); // 切换为接收模式 DMA_Cmd(DMA1_Channel4, DISABLE); // 关闭发送DMA } }如果不这样做,在高速波特率下RS485总线会出现最后一两个字节被硬生生砍断的奇怪现象。主机端收到的响应帧CRC校验永远错误,波形上看又觉得数据长度对,实际上就是发送方向切换早了那么几十微秒。
3. 接收不定长数据帧的完整方案
3.1 空闲中断+DMA怎样配合判断帧结束
MODBUS RTU协议本身没有固定的帧尾标记,帧与帧之间靠“静默时间”来区分。标准做法是:接收完最后一个字节后,总线上超过3.5个字符时间没有新数据,就认为这一帧接收完毕。实现方式有好几种,常见的有定时器超时判断和空闲中断判断。
我选择空闲中断,原因很简单——STM32内置了硬件空闲检测,不需要额外占用定时器资源。空闲中断配合DMA循环接收,核心思路是这样的:
- DMA在循环模式下不断把串口数据搬运到缓冲区;
- 当串口总线空闲时,硬件触发IDLE中断;
- 在IDLE中断里,用“DMA当前计数寄存器”反推出本次接收的数据长度;
- 读取DMA的当前数据计数(
DMA_GetCurrDataCounter()),用缓冲区总长度减去当前计数,得到本次收到的数据字节数。
if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { USART_ClearITPendingBit(USART1, USART_IT_IDLE); uint16_t remain = DMA_GetCurrDataCounter(DMA1_Channel5); uint16_t len = RX_BUF_SIZE - remain; if (len > 0 && len <= MAX_FRAME_LEN) { RingFIFO_Write(&uart1_rx_fifo, uart1_rx_buf, len); frame_flag = 1; // 通知主循环有新的数据帧 } }注意读取数据计数器之前,必须先清除IDLE挂起位。这个顺序不能反,反了会导致下一次IDLE中断无法触发,帧接收直接卡死。
3.2 环形FIFO的读写实现与防覆盖保护
环形FIFO的精髓在于读写索引的循环管理。写入时,数据放入buffer[head],head递增,越界后回绕到0;读取时,从buffer[tail]取出数据,tail递增,同样越界回绕。判断FIFO是满还是空,最保险的方法是维护一个count变量,而不是只比较head和tail是否相等。
void RingFIFO_Write(RingFIFO *fifo, uint8_t *data, uint16_t len) { for (uint16_t i = 0; i < len; i++) { if (fifo->count >= FIFO_SIZE) { // FIFO已满,本轮数据丢弃并记录溢出次数 fifo->overflow_cnt++; break; } fifo->buffer[fifo->head] = data[i]; fifo->head = (fifo->head + 1) % FIFO_SIZE; fifo->count++; } }FIFO溢出是最让人头疼的问题之一。当协议解析慢了,DMA还在往缓冲区写数据,FIFO装不下,后续数据就只能丢弃。我的处理策略是:宁可丢弃最新数据,也绝不用旧数据去覆盖新数据。因为MODBUS协议里,一帧数据出错会导致CRC校验失败,整帧丢弃;但如果缓冲区被覆盖错位,可能导致两帧数据拼接成一帧“假数据”,这种错误比丢帧更难以排查。
另外,overflow_cnt这个变量一开始就要加上,不然后期定位问题完全靠猜。我在代码里还加了一个FIFO水位监控,当FIFO中的数据量长时间超过一半时,说明CPU处理不过来,这时候需要优化协议解析代码,而不是盲目加大缓冲区。
3.3 发送链路的FIFO排队机制
接收用了FIFO,发送我也建议做一个FIFO。很多人的发送逻辑是:需要发数据时,直接调用DMA发送函数发送。这在单串口单任务下没问题,但双串口并发时就容易乱。
比如串口2作为MODBUS主机,轮询周期是100ms,需要连续向3个设备发送查询指令;此时串口1正好收到上位机的写寄存器请求,需要立即回复响应帧。如果两个任务同时操作同一个DMA发送通道,就会出现DMA正在发送A帧,程序却又把B帧装载到了DMA的存储地址——B帧的前几个字节会紧接着A帧发送出去,接收方解析必然出错。
我的解决方案是发送FIFO:所有需要发送的数据帧,先进入发送FIFO排队;主循环每轮检查发送FIFO,如果当前无发送任务(DMA空闲),则从FIFO取出帧头发送。DMA发送完成后再取下一帧。
void UART_SendFrame(USART_TypeDef *USARTx, uint8_t *data, uint16_t len) { // 拷贝数据到发送缓冲区,写入对应串口的发送FIFO RingFIFO_Write(&tx_fifo, data, len); // 如果当前对应串口没有正在发送,则启动发送 if (!tx_busy_flag) { Poll_TX_FIFO(USARTx); } }这里有个小经验:DMA发送的数据源地址必须是一个稳定的内存数组,不能用局部变量。局部变量在函数退出后内存就被释放了,DMA控制器读到一半数据地址被复用,发送出来的就是乱码。所以我每个串口都维护了一个全局的发送缓冲区数组。
4. MODBUS协议处理核心代码
4.1 帧解析状态机设计
从FIFO里读出来的原始数据,还不能直接认为是完整的一帧。MODBUS RTU帧格式为:地址(1字节)+ 功能码(1字节)+ 数据区(N字节)+ CRC16(2字节)。因为MODBUS是半双工协议,理论上一个时间片内只可能收到一个完整帧,但为了代码健壮性,我还是用了一个简单的状态机来校验帧完整性,而不是直接按固定长度解析。
typedef enum { FRAME_ADDR, // 等待接收地址字节 FRAME_FUNC, // 接收功能码 FRAME_DATA, // 接收数据区 FRAME_CRC_HI, // 接收CRC高位 FRAME_CRC_LO, // 接收CRC低位 FRAME_DONE // 一帧完成 } FrameParseState;状态机的好处是,它对“坏帧”天然有容错能力。如果帧长度不对,状态机在哪个分支卡住都能通过超时机制复位,不会影响后续正常帧的接收。实际应用里,我还在状态机外层加了一个帧超时保护:如果5个字符时间内没有新数据到达,状态机强制回到FRAME_ADDR状态,等待下一帧。
4.2 CRC16计算与功能码处理
MODBUS RTU使用的CRC16算法是固定的多项式0xA001,初始值为0xFFFF。这个算法网上实现一大堆,但性能差异很大。我用的查表法比逐位计算快得多:
uint16_t Modbus_CRC16(uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { crc ^= buf[i]; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001; else crc >>= 1; } } return crc; }功能码的处理上,我重点实现了三类常用功能码:03(读保持寄存器)、06(写单个寄存器)、16(写多个寄存器)。这是工业现场最常用的三个,基本覆盖了95%以上的应用场景。每个功能码处理完都要返回正确的响应帧,功能码错误要返回异常码01(非法功能码),地址越界返回异常码02(非法数据地址),数值超范围返回异常码03(非法数据值)。
一个容易忽视的细节是:响应帧的CRC校验字节,低字节在前,高字节在后。很多人写MODBUS程序栽在这个字节序上,上位机怎么都报CRC错误。
4.3 4字节浮点数据与IEEE 754转换
工业现场采集到的很多数据是浮点数,比如温湿度传感器的温度值、电能表的电压值。MODBUS协议本身没有定义浮点数的传输格式,行业里默认使用IEEE 754标准,并将4字节按大端方式排列在寄存器中(每个寄存器2字节,共2个寄存器)。
数据解析时,最常见的坑是“寄存器字节序不对”。同样一组4字节数据在不同设备里可能表现为ABCD、CDAB、BADC、DCBA四种排列方式。我这套代码里做了统一的字节序转换层:
float Modbus_GetFloat(uint16_t reg1, uint16_t reg2) { uint32_t temp = ((uint32_t)reg1 << 16) | reg2; float result; memcpy(&result, &temp, 4); return result; }用memcpy而不是直接指针强转,是为了避免STM32上未对齐访问导致的HardFault。编译器在某些优化等级下,对未对齐的32位读取会生成多条LDR指令,但如果地址不是4的倍数,就有可能在Cortex-M3上直接进硬件错误。
5. 调试工具应用与疑难杂症排查
5.1 modbus poll在联调中的正确打开方式
代码写完了,总得上实际工具去验证。MODBUS调试工具我用得最多的是modbus poll(从机调试用modbus slave)。这两个工具本质上一个是模拟主机、一个是模拟从机。
拿我调试串口1从机的环境举例:PC串口通过USB转RS485接到设备,modbus poll配置好串口参数后,设置功能码为03,起始地址0,寄存器数量10。轮询一开,如果设备协议没问题,500ms内就能看到寄存器值实时更新。
使用中有个很实用的技巧:modbus poll的“Error Count”如果一直累加但“Read/Write”计数不涨,说明报文根本没到设备;如果Error Count在设备端发送响应后仍然累加,大概率是CRC错误,优先检查字节序和波特率匹配。
另外,两个工具都支持报文日志功能。联调时不光要看轮询值是否正确,更要把报文打开,逐字节核对请求帧和响应帧。我遇到过设备端所有数据都对,但就是时不时掉线,最后抓日志发现是主机轮询间隔太短,设备还没来得及切换RS485方向就收到了下一帧,导致帧冲突。把轮询间隔从50ms调到200ms后问题彻底消失。
5.2 经典问题:主机从机单独测试正常,连在一起就不正常
论坛上一个高频帖子就是“485主机和从机分别测试都正常,主机连接从机就不正常”。这个问题我复现过,原因是多方面的,但最常见的就是上述的RS485方向切换时序问题。主机发送完请求帧后,需要等一个“帧间隔”再切换为接收模式;从机收到请求帧后,也要等3.5个字符时间再发送响应帧。如果这个时间配得太短,从机在总线上还能检测到主机发送的回波信号,就认为总线忙,直接放弃了响应。
解决思路是统一用一个状态机管理RS485方向:
- 主机平时处于接收状态;
- 需要发送请求帧时,切换到发送模式,数据发完后不要立刻切回接收,而是等待发送完成中断,再延时至少1个字节时间后切换;
- 从机收到完整请求后,延迟3.5个字符时间再应答。
5.3 DMA偶发数据错位的排查方法
DMA+RX方案用得久了,你可能会遇到一种现象:设备偶尔返回的数据里,开头的几个字节错位了。比如从机地址是0x01,收到的却是0x03。这个问题的根源在于DMA循环模式下,“帧边界”和“缓冲区起始位置”不对齐。
我可以举个例子说明:DMA缓冲区是256字节,第一帧数据收在缓冲区位置0~10,程序把数据取出来处理;第二帧数据则收在位置11~20。但如果程序在处理第一帧数据时耗时太长,第二帧数据到达时DMA已经写到了缓冲区中部,而IDLE中断触发时,程序去读“当前计数寄存器”反推长度,可能会把第二帧的一部分和第三帧的一部分拼在一起,导致错位。
我的解决办法是:在IDLE中断里,读取数据计数器之前,先短暂关闭DMA,把数据长度确认好、写入FIFO之后,再重新开启DMA。关闭DMA的这段时间(几微秒)不足以产生数据丢失,但能避免计数寄存器读一半变成不确定值。
DMA_Cmd(DMA1_Channel5, DISABLE); uint16_t remain = DMA_GetCurrDataCounter(DMA1_Channel5); uint16_t len = RX_BUF_SIZE - remain; if (len > 0 && len <= MAX_FRAME_LEN) { RingFIFO_Write(&uart1_rx_fifo, uart1_rx_buf, len); } DMA_SetCurrDataCounter(DMA1_Channel5, RX_BUF_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE);重置计数器的目的是让下一次帧接收从头开始,保证帧边界永远对齐缓冲区起始位置。这个方法实测下来,错位问题彻底消失。
6. 工程扩展与应用思考
6.1 从双串口扩展到多路串口
这个方案的通用性很强。三个串口、四个串口,只要芯片DMA通道够用,框架可以直接平铺扩展。我后来在另一个项目里把串口扩展到3个,增加了一路RS232用于本地调试输出,核心代码零改动,只需要添加串口句柄和对应的DMA中断处理函数。
但有一个瓶颈要注意:随着串口数量增加,IDLE中断的并发处理逻辑会变复杂。每个串口的IDLE中断里都做数据读取和FIFO写入操作,如果都放在同一个中断优先级,可能相互阻塞。我的做法是把所有串口的IDLE中断打断优先级,只做“DMA数据搬运到FIFO”这一件事,而协议解析全部放在主循环,从而避免中断嵌套过深。
6.2 从MODBUS RTU向MODBUS TCP演进
如果你的设备需要接入以太网,MODBUS RTU转MODBUS TCP是一个很自然的方向。物理层从串口换成网口,但在应用层处理上,帧格式有区别:RTU有CRC16校验,TCP模式去掉了CRC,增加了长度为6字节的MBAP报文头。
设计这套双串口DMA+FIFO框架时,我就刻意把“数据收发”和“协议处理”分离开来。RTU模式下,收到的原始字节经过FIFO交给帧解析状态机;TCP模式下,网口数据包经过TCP/IP协议栈交给同一套帧解析逻辑。两者共用同一套功能码处理函数,只是帧封装和解帧部分不一样。这样改造起来工作量很小,应用层代码几乎不用动。
我建议你在搭建底层驱动时也保持这个思路,把串口操作抽象成统一的接口函数,后期无论是换协议、加通道还是换硬件平台,都能少走很多弯路。
6.3 实际工程中性能与可靠性的平衡建议
最后聊聊性能调优。DMA+FIFO这套方案上线后,我在优化CPU占用率时发现,最大的性能消耗点其实不在串口收发,而在协议解析里的CRC计算和帧解析。CRC查表法比位运算法快大约8倍,但需要额外的512字节表空间——这个成本在拥有几十KB内存的MCU上完全值得。
FIFO缓冲区的大小则需要结合具体业务来定。接收FIFO太小容易溢出丢帧,太大浪费内存。我统计了所有从站设备的最大响应帧长度是50字节,而主循环的协议解析周期最坏情况下约10ms,波特率9600下10ms约能收到10个字节,突发情况下可能积压60~70字节。所以接收FIFO设置成1KB是足够的,留足了余量又不觉得浪费。
我实际调完这套工程后,最深的感受是:串口通信这种“老技术”不代表简单,DMA、FIFO、空闲中断这些工具用得好不好,直接决定了系统在数据高压下的稳定表现。特别是双串口并发这种场景,如果没有DMA把CPU解放出来,后续加再多的协议解析逻辑都是空中楼阁。这套工程我在两个产品上直接复制使用,中间踩过的坑都写在上面了,希望帮你把开发周期从以周为单位压缩到以天为单位。
本文还有配套的精品资源,点击获取