资讯动态

FreeRTOS+DMA+消息队列:打造高效MODBUS双向通信串口架构

发布时间:2026/10/14 1:06:51 来源:尧图企业网站定制
简介这是一份基于STM32与FreeRTOS的MODBUS双向通信例程包旨在演示通过消息队列串联多任务采集与上位机请求应答的完整方案。工程共划分6个任务指示灯闪烁、两路DI采集、SD2405时钟读取、串口中断收帧及MODBUS主处理合理规划MSG消息数组承载DI状态与年月日时分秒星期等数据适合正在学习FreeRTOS任务通信、DMA串口收发或工业协议移植的开发者参考。压缩包共839个文件以c源文件、h头文件、html文档及png图片为主附带工程配置文件、链接脚本和hex固件整体仅3.1MB便于快速下载与对照阅读。已有2405人学习。通过阅读源码可清晰理解消息队列在设计中的核心作用掌握串口DMA中断、MODBUS报文解析及多任务协作的典型写法可直接迁移到类似采集与控制项目中。1. 串口工程的实时性困境FreeRTOS、DMA 与消息队列如何协同解决 MODBUS 双向通信某个工业控制项目里一个看似只会“收命令、回响应”的 MODBUS 从站设备代码却越写越乱串口中断里做协议解析CPU 被频繁打断裸机主循环轮询又总是错过帧间隔DMA 缓冲区的数据还没被处理就被下一帧覆盖。这份“基础例程 FreeRTOS DMA USART1 消息队列 MODBUS 双向通信”源码包恰好把接收、解析、响应三段链路完整打通让串口通信在 RTOS 环境下变成一条不互相阻塞的数据流水线。它适合正在学 FreeRTOS 的开发者也适合想把串口中断架构改成“中断投递 任务解析”的从业者。下面按我拆这个工程时的顺序把任务划分、DMA 配置、MODBUS 组帧和几个血泪坑逐一展开。2. FreeRTOS 任务划分与消息队列设计中断里只投递、任务里做解析在有 RTOS 之前串口收数据基本两种写法中断里边收边解析或者主循环轮询标志位。前者在数据量大时中断服务函数耗时过长后者在帧间隔短的场景下根本忙不过来。引入 FreeRTOS 以后最合理的做法是把数据流拆成三段中断服务函数只负责把 DMA 收到的数据搬进队列解析任务从队列拿报文做 CRC 校验和功能码分发发送任务负责把响应帧交到 DMA 发送通道。这样每一段都是独立调度的不会再出现“解析到一半被另一个中断打断”的情况。2.1 为什么要在中断和任务之间加消息队列消息队列在这里扮演的是缓冲加解耦的角色。中断上下文只适合做时间敏感的操作比如把 DMA 缓冲区里的数据整体搬走、清标志、重启接收而 MODBUS 解析涉及 CRC 计算、寄存器映射、异常响应这些操作耗时不确定放进任务上下文才能让调度器有机会均衡其他任务。另外一个关键点是FreeRTOS 的队列是线程安全的。中断和任务之间用裸数组共享缓冲区需要自己加临界区保护漏一次就出玄学问题队列自带互斥机制xQueueSendFromISR 和 xQueueReceive 配对使用基本不用考虑数据竞争。拆这套源码的时候我最看重的就是这一点数据流是单向的——接收 DMA 进接收队列解析任务处理后进发送队列再由发送 DMA 发出全程没有跨任务共享的全局接收缓冲区。2.2 消息结构的定义与队列参数选择队列里传的不能是裸指针因为 DMA 缓冲区会被下一轮接收覆盖。常见做法是定义一个消息结构体包含数据数组和长度字段队列按值拷贝传递。// 消息结构体固定容量按值拷贝进队列 typedef struct { uint8_t data[256]; // MODBUS RTU 最大报文长度地址功能码数据CRC uint16_t len; // 实际接收长度 } ModbusMsg_t; // 创建两个队列接收队列 8 个元素发送队列 4 个元素 QueueHandle_t xRecvQueue; QueueHandle_t xSendQueue; void App_QueueInit(void) { xRecvQueue xQueueCreate(8, sizeof(ModbusMsg_t)); xSendQueue xQueueCreate(4, sizeof(ModbusMsg_t)); }队列长度不是越大越好。每个元素大小是 256 2 258 字节接收队列 8 个元素就要占 2064 字节对 Cortex-M 内核的 MCU 来说已经是一块不小的内存。我一般按最大报文长度估算再留 2 到 3 个余量避免极端情况下队列满导致中断里的投递失败。发送队列设 4 个因为 MODBUS 响应帧通常比请求短4 个足够缓冲主站连续轮询的压力。参数取值选择理由接收队列长度8覆盖主站突发帧数且内存开销可控发送队列长度4从站响应频率低4 个元素足够消息体大小258 字节MODBUS RTU 最大帧 256 字节 长度字段2.3 队列创建与投递的参考代码中断里投递必须用带 FromISR 后缀的 API而且必须检查返回值——队列满时宁可丢帧也不能死等。以下代码模拟 DMA 接收完成后的投递逻辑。// DMA 接收中断入口空闲中断触发时调用 void App_OnUartIdle(void) { BaseType_t xHigherPriorityTaskWoken pdFALSE; ModbusMsg_t msg; // 计算当前 DMA 已接收长度拷贝到消息结构体 msg.len APP_RX_BUFSIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); memcpy(msg.data, rx_buffer, msg.len); // 投递到接收队列失败则丢弃本次报文 if (xQueueSendFromISR(xRecvQueue, msg, xHigherPriorityTaskWoken) ! pdPASS) { // 队列满丢弃等待主站重试 } // 重启 DMA 接收 App_DMA_Restart(); // 通知调度器若解析任务被唤醒则立即切换 portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }逻辑说明空闲中断触发时DMA 已停止接收缓冲区里是从上次重启到现在的完整数据。通过 DMA 剩余计数器换算实际长度再整包拷贝进消息体。这里用的是值拷贝所以即使重启 DMA 后缓冲区被覆盖队列里的数据依然安全。参数说明APP_RX_BUFSIZE 是 DMA 缓冲区长度我配的是 512覆盖 MODBUS RTU 256 字节上限的同时留出余量__HAL_DMA_GET_COUNTER返回剩余未传输字节数用总长减它就是已接收长度。这里有个容易忽略的细节队列满时直接丢弃旧报文不要覆盖。MODBUS 请求-响应模型里丢一帧最多让主站超时重试乱序响应才是更严重的故障。2.4 解析任务与发送任务的结构任务划分直接影响系统响应时间。我把解析任务优先级设高发送任务次之这样主站发来的请求能被尽快消费响应帧也能及时启动 DMA 发送。// 解析任务从接收队列取数据做 CRC 校验和功能码分发 void vModbusParseTask(void *pvParameters) { ModbusMsg_t msg; for (;;) { if (xQueueReceive(xRecvQueue, msg, portMAX_DELAY) pdPASS) { if (App_CheckCRC(msg) 0) { App_HandleRequest(msg); // 解析并构造响应 xQueueSend(xSendQueue, msg, 10); } } } } // 发送任务把响应帧从队列搬到 DMA 发送缓冲区 void vModbusSendTask(void *pvParameters) { ModbusMsg_t msg; for (;;) { if (xQueueReceive(xSendQueue, msg, portMAX_DELAY) pdPASS) { App_UART_SendDMA(msg.data, msg.len); // DMA 发送 } } }这个结构的缺点是响应路径要经过两次队列拷贝但 MODBUS 数据量不大CPU 拷贝 258 字节的开销远小于调试共享缓冲区 Bug 的时间成本。xQueueSend(xSendQueue, msg, 10)里的第三个参数是阻塞时间如果发送队列满解析任务最多等 10 个 tick不会永久卡死。3. USART1 DMA 收发通道配置从内存搬运到空闲中断用 DMA 的目的很明确串口数据到达时CPU 不用逐字节进中断处理DMA 控制器直接把数据搬到内存。对 MODBUS RTU 这种以帧为单位的协议最理想的方式是 DMA 连续接收配合串口空闲中断判断一帧结束。3.1 DMA 收发通道的初始化先看初始化代码这里以 HAL 库为例核心参数与寄存器操作和其他库是相通的。// USART1 收发 DMA 配置 static void App_USART1_DMA_Init(void) { DMA_HandleTypeDef hdma_tx; // 发送通道内存到外设普通模式 hdma_tx.Instance DMA1_Channel4; hdma_tx.Init.Direction DMA_MEMORY_TO_PERIPH; hdma_tx.Init.PeriphInc DMA_PINC_DISABLE; hdma_tx.Init.MemInc DMA_MINC_ENABLE; hdma_tx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_tx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_tx.Init.Mode DMA_NORMAL; hdma_tx.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_tx); __HAL_LINKDMA(huart1, hdmatx, hdma_tx); // 接收通道外设到内存循环模式 hdma_rx.Instance DMA1_Channel5; hdma_rx.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_rx.Init.PeriphInc DMA_PINC_DISABLE; hdma_rx.Init.MemInc DMA_MINC_ENABLE; hdma_rx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_rx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_rx.Init.Mode DMA_CIRCULAR; hdma_rx.Init.Priority DMA_PRIORITY_LOW; HAL_DMA_Init(hdma_rx); __HAL_LINKDMA(huart1, hdmarx, hdma_rx); }逻辑说明发送通道用普通模式每次发完一帧 DMA 自动停止接收通道用循环模式DMA 持续接收并自动回绕配合空闲中断判断帧边界这是串口不定长接收最常见的架构。参数说明MemInc 必须使能否则每次搬运地址不变PeriphInc 必须禁止串口数据寄存器地址固定。对齐方式统一字节避免缓冲区地址对齐问题。接收通道优先级可以设低因为串口数据量小即便等 DMA 仲裁也不会丢数据。3.2 空闲中断与 DMA 半满中断的配合MODBUS RTU 帧长度变化很大最短 8 字节最长 256 字节。如果只用 DMA 传输完成中断缓冲区必须等 512 字节填满才会触发帧边界早就错过了。所以必须用串口空闲中断总线上出现一个字节间隔没有新数据空闲中断触发此时 DMA 计数器值就是这一帧的长度。// 开启串口空闲中断关闭 DMA 传输完成中断 static void App_USART1_NVIC_Init(void) { HAL_NVIC_SetPriority(USART1_IRQn, 5, 0); HAL_NVIC_EnableIRQ(USART1_IRQn); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); __HAL_DMA_DISABLE_IT(hdma_usart1_rx, DMA_IT_TC); }这里有一个常见选择差异很多人会同时开启 DMA 半传输中断把大缓冲区分成前后两段每段满 256 字节就搬走一次防止数据覆盖。但这个工程里 MODBUS 报文长度基本都小于 256且空闲中断已经切好了帧半满中断可以关掉让代码简单很多。知道这个边界是后面避坑的关键。3.3 接收完成后的数据转移处理空闲中断触发后DMA 可能停在缓冲区任意位置。正确处理顺序是先停 DMA取计数器算长度搬运数据再清标志重新配置传输长度。void App_DMA_Restart(void) { // 停止 DMA取回计数 __HAL_DMA_DISABLE(hdma_usart1_rx); HAL_UART_DMAStop(huart1); // 清标志 __HAL_DMA_CLEAR_FLAG(hdma_usart1_rx, DMA_FLAG_TC | DMA_FLAG_HT | DMA_FLAG_FE); // 重新设置传输长度 HAL_DMA_Start_IT(hdma_usart1_rx, (uint32_t)USART1-DR, (uint32_t)rx_buffer, APP_RX_BUFSIZE); // 使能串口 DMA 接收 HAL_UART_Receive_DMA(huart1, rx_buffer, APP_RX_BUFSIZE); }注意 HAL_UART_DMAStop 和 HAL_DMA_Disable 的顺序先停 DMA 再停串口否则可能漏掉最后一个字节。我踩过的坑是反过来写帧尾经常丢一两个字节后来每次移植到这里都强制先停 DMA。提示DMA 与串口的停止顺序是“先停 DMA、再停串口”和使能顺序相反。4. MODBUS 双向通信实现报文解析、CRC 校验与响应组帧MODBUS RTU 从站的标准模型是请求-响应主站发命令从站回数据。这份源码里的“双向通信”还多了一层从站自身也能作为发起方主动上报状态和报警。这也是它和普通从站例程最大的区别。4.1 接收任务中的报文解析流程MODBUS RTU 报文格式是地址码 功能码 数据 CRC16。解析第一步是校验地址和 CRC第二步按功能码分发。uint8_t App_CheckCRC(ModbusMsg_t *msg) { uint16_t crc 0xFFFF; for (uint16_t i 0; i msg-len - 2; i) { crc ^ msg-data[i]; for (uint8_t j 0; j 8; j) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } // MODBUS RTU 低字节在前高字节在后 uint16_t recv_crc msg-data[msg-len - 1] 8 | msg-data[msg-len - 2]; return (crc recv_crc) ? 0 : 1; }逻辑说明这是 CRC16-MODBUS 算法多项式 0x8005 的反射形式是 0xA001初值 0xFFFF。遍历报文时跳过最后两个 CRC 字节。参数说明接收的 CRC 按低字节在前、高字节在后组装正好对应 MODBUS RTU 的字节序。如果校验失败直接丢弃不进入功能码分发。4.2 功能码分发与响应构造常见的功能码有 0x03 读保持寄存器、0x06 写单个寄存器、0x10 写多个寄存器。解析任务拿到完整报文后按功能码分发到不同处理函数。void App_HandleRequest(ModbusMsg_t *msg) { uint8_t addr msg-data[0]; uint8_t func msg-data[1]; // 不是发给本机地址的报文直接丢弃 if (addr ! APP_MODBUS_ADDR) return; switch (func) { case 0x03: // 读保持寄存器 App_HandleReadReg(msg); break; case 0x06: // 写单个寄存器 App_HandleWriteSingleReg(msg); break; case 0x10: // 写多个寄存器 App_HandleWriteMultiReg(msg); break; default: App_SendError(msg, 0x01); // 不支持的功能码 break; } }广播地址 0 要单独处理。如果主站使用广播地址写寄存器所有从站都要执行但不回响应。这个工程里地址过滤写在分发函数最前面广播处理需要单独加分支否则广播报文会被当成非本机地址直接丢弃。4.3 响应帧组帧与发送队列投递以读保持寄存器为例响应帧是 地址 功能码 字节数 寄存器数据 CRC。先把数据填入消息结构再计算 CRC 填充尾部最后投递到发送队列。static void App_HandleReadReg(ModbusMsg_t *msg) { uint16_t start_reg msg-data[2] 8 | msg-data[3]; // 起始寄存器地址 uint16_t reg_cnt msg-data[4] 8 | msg-data[5]; // 寄存器数量 ModbusMsg_t resp; // 寄存器数量超过 125 直接返回异常 if (reg_cnt 125) { App_SendError(msg, 0x03); // 非法数据值 return; } resp.len 3 reg_cnt * 2; // 地址 功能码 字节数 寄存器数据 resp.data[0] APP_MODBUS_ADDR; resp.data[1] 0x03; resp.data[2] reg_cnt * 2; // 数据字节数 for (uint16_t i 0; i reg_cnt; i) { resp.data[3 i * 2] reg_table[start_reg i] 8; resp.data[4 i * 2] reg_table[start_reg i] 0xFF; } App_AppendCRC(resp); // 计算并追加 CRC xQueueSend(xSendQueue, resp, 10); }这里最需要注意的是寄存器数量上限。MODBUS 协议规定单次读寄存器不能超过 125 个否则响应帧会撑破 256 字节的报文上限。地址越界也要检查起始寄存器加数量超过寄存器表总长度时应返回异常码 0x02。这个检查是很多从站例程容易漏掉的。4.4 主动上报的双向链路与普通从站不同双向通信意味着从站也是发起方。比如温度越限主动上报用一个独立任务周期性检查状态满足条件就组帧投递。void vTempMonitorTask(void *pvParameters) { for (;;) { vTaskDelay(pdMS_TO_TICKS(1000)); // 每秒检查一次 if (temp_high) { ModbusMsg_t alarm; alarm.len 8; alarm.data[0] APP_MODBUS_ADDR; alarm.data[1] 0x41; // 自定义功能码异常上报 alarm.data[2] 0x01; // 报警类型 alarm.data[3] high_temp 8; alarm.data[4] high_temp 0xFF; App_AppendCRC(alarm); xQueueSend(xSendQueue, alarm, 10); } } }这里有一个隐藏的设计陷阱主动上报帧必须避开主站请求的响应窗口否则总线上两个设备同时发数据会碰撞。我一般会在上报任务里加一个退避逻辑收到过主站请求后的 50ms 内不主动上报优先保证请求-响应模型的确定性。5. 避坑与排查DMA 丢帧、队列溢出与波特率误差的 5 个真实记录这套架构从搭起来到稳定跑踩过的坑不少。下面 5 条是最典型的问题按“现象 - 原因 - 解决”的顺序写清楚也许能帮你少走弯路。5.1 现象一DMA 接收偶发半个包解析永远 CRC 错误现象跑一段时间后串口调试助手显示收到了不完整帧长度只有正常帧的一半CRC 校验永远失败。原因空闲中断触发后DMA 计数器被重新装载但此时 UART 的数据寄存器里还残留了一个字节。这个字节在下一轮 DMA 启动后被搬到缓冲区开头导致整帧错位一个字节。解决在清空闲标志之后、重启 DMA 之前先读一次数据寄存器把残留字节读掉if (status USART_SR_IDLE) { USART1-SR; // 先读状态寄存器 (void)USART1-DR; // 再读数据寄存器清除残留字节 // 之后才是取长度、投队列、重启 DMA }5.2 现象二xQueueSendFromISR 返回 errQUEUE_FULL主站轮询频繁超时现象主站以 50ms 周期持续轮询从站偶尔无响应日志里能看到队列投递失败计数在增加。原因接收队列长度设为 8但主站连续发送多帧时某个瞬间空闲中断连续触发两次队列被填满后续帧被全部丢弃。解决两条路。一是把接收队列加长到 16更彻底的是把解析任务优先级提到比大多数任务都高让队列消费速度跟上。另外CRC 校验失败的帧不要进队列直接在中断里丢弃减少无效占用。5.3 现象三MODBUS 响应超时逻辑分析仪却看到响应帧已发出现象主站报超时但从站逻辑分析仪上明明抓到了响应帧波形。原因响应帧发出时主站的请求-响应超时计时已经结束主站先一步判定超时。根源是任务调度不及时——解析任务在队列里拿到报文后又做了别的延时操作导致发送任务启动 DMA 晚了。解决请求处理路径上不要放任何 vTaskDelay、printf 等耗时操作。拿到报文立即校验、组帧、投递把发送队列的阻塞超时从 10 tick 改到 0让发送任务能立刻被唤醒。5.4 现象四波特率配置相同但串口数据乱码现象从站收到的请求全部是乱码主站调试助手却能正常显示自己发送的内容。原因晶振频率与波特率分频系数不匹配。USART 的波特率计算用的是 APB2 外设时钟不是主频。某 MCU 主频 72MHz但 APB2 分频后只有 36MHz如果按 72MHz 计算分频系数波特率误差会超过 3%正好突破 UART 容错极限。解决打开时钟树确认 APB2 实际频率重新计算。用 HAL 库的话直接检查 huart1.Init.BaudRate 和时钟源是否匹配上电后用示波器测量 TX 引脚的位宽和理论波特率对照。5.5 现象五接收任务处理延迟DMA 缓冲区被覆盖后才拿到数据现象收到的报文 CRC 能对上但打印出来的内容有明显重复片段像是同一段数据被读了两次。原因接收任务优先级低于某个周期性任务周期性任务每次运行占用时间过长接收任务获得 CPU 时 DMA 循环缓冲区已经被第二轮数据覆盖。解决要么把接收任务优先级提到最高要么在 DMA 半满中断里先把前半段数据搬走。我最终选择了提高优先级因为 MODBUS 数据量小解析任务不会长时间占 CPU优先级高一点反而更安全。6. 进阶验证用串口助手模拟主机确认 FreeRTOS 下的 MODBUS 双向通信时序拿到代码后怎么快速验证这套双向通信真的可用我的习惯是分四步走全部通过才算移植成功。第一步串口助手配好 9600、8 数据位、无校验、1 停止位发送标准读寄存器命令 01 03 00 00 00 01 84 0A预期返回 01 03 02 XX XX CRC。这一步确认最关键的接收-解析-发送链路。第二步把串口助手的定时发送设为 20ms连续发几十条命令观察接收区是否有丢帧、错位、重复。这一步最考验 DMA 重启时序和队列消费速度能暴露 5.1 和 5.2 里说的残留字节和队列溢出问题。第三步验证主动上报。把温度模拟值调到报警阈值以上观察串口助手是否在两次轮询之间收到从站主动发来的 0x41 功能码帧。这一步要重点看时序主动上报帧必须避开请求-响应窗口如果总线上出现两条数据交叠说明退避逻辑没生效。第四步用逻辑分析仪抓 USART1 TX 引脚。注意抓 TX 而不是 RX才能看到从站的响应节奏。正常情况响应帧应该在请求帧结束后 10ms 以内返回如果超过 20ms就去检查解析任务优先级、发送队列阻塞时间和 DMA 启动延迟。移植这套架构时最容易忽略的是时序验证。调试助手里看起来正常的通信逻辑分析仪上一拉波形问题全出来了。从那以后我每次做带 MODBUS 的嵌入式工程上电第一件事就是开逻辑分析仪把 TX/RX 时序图拉出来看一遍再谈其他。希望这些验证方法能帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑