尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

FreeRTOS下UART DMA接收发送的完整设计与避坑指南

FreeRTOS下UART DMA接收发送的完整设计与避坑指南 简介这份工程以FreeRTOS实时操作系统为框架在STM32F302CB上实现UART通过DMA进行的循环接收与数据解析面向需要高效串行通信的嵌入式开发者特别适合解决连续大数据流下易丢帧、CPU介入频繁的问题。压缩包共659个文件大小13.81MB以346个C源文件和113个头文件为主体同时包含IAR工程配置.ewp/.ewd、链接脚本.icf与调试辅助文件构成一套可直接打开的完整工程源码中还可见STM32 HAL驱动与CMSIS库文件便于对照标准库理解寄存器与DMA的配合。内容涵盖DMA通道配置、串口空闲中断、循环缓冲区切换以及FreeRTOS任务调度解析等关键环节既可逐行研读底层实现也能提取核心代码移植到同类Cortex-M4项目中。该资源已有395人学习适合具备一定FreeRTOS基础、希望进阶DMA收发机制的开发者参考其设计思路也可直接应用于工业自动化或物联网设备的串口数据采集场景。1. 先理清这套组合的本质矛盾数据到底在谁手里前两天有人问我FreeRTOS DMA UART 三个词凑一起为啥总是出现平时好好的一跑百分之百出问题这种玄学现象。我第一反应就是你大概率没理清楚数据在这套组合里到底过了几道手。裸机下跑 UART DMA 其实不难理解外设收到字节DMA 自动搬到内存搬完触发中断你在中断里取数据。但一旦引入 FreeRTOS整个数据流就变成了三方交接——DMA 引擎负责搬运、中断服务函数负责喊人、任务负责干活。这三者谁跟谁之间节奏没对齐数据就会丢、会乱、会踩内存。举一个最常见的场景你配置了一个 DMA 接收缓冲区设成循环模式串口源源不断收数据。DMA 在后台不停写内存你的任务在另一个优先级上处理数据。这时候问题来了——DMA 写到某个位置任务也正在读某个位置如果不做同步两边极有可能操作到同一个地址。轻则丢一帧数据重则把环形缓冲区的头尾指针搞乱整个通信链路直接崩掉。所以你在移植 FreeRTOS 之后第一件要做的事不是写代码而是把数据流动图画出来UART 外设 → DMA → 内存缓冲区 → 中断通知 → FreeRTOS 队列/信号量 → 任务处理。任何一步没有明确边界后面全是坑。另外说一句很多人一上来就盯着 DMA 的大缓冲区搬数据但其实在 FreeRTOS 环境下更常见的问题反而是数据到了但任务没被及时唤醒或者任务醒了但数据已经被覆盖。这两个问题一个靠中断通知机制解决一个靠环形缓冲区设计解决下文逐个说。这套组合最典型的应用场景是物联网网关的串口透传、Modbus 从站、GPS/4G 模块数据处理、以及一切需要一边高速收串口数据、一边还要跑协议栈的嵌入式设备。如果你只是简单收个传感器数据每秒钟几个字节那用不用 DMA 其实差别不大直接用中断逐字节收反而更简单。只有当波特率达到 921600、数据是连续帧、CPU 还要跑别的任务时DMA 的价值才真正体现出来。我后面所有代码示例都以 STM32 HAL 库 FreeRTOS 为例但思路是通用的换成其他厂商的 MCU 和 SDK 一样能对上。2. 接收方向的核心设计空闲中断、循环 DMA 与环形缓冲区的配合2.1 不定长数据怎么判断一帧结束串口通信里最麻烦的不是数据本身而是你永远不知道一帧数据到底有多长。Modbus RTU 靠 3.5 个字符时间间隔判断帧结束很多自定义协议靠帧头帧尾而最常见的做法就是利用 UART 的空闲中断。STM32 的 HAL 库把这套逻辑封装成了HAL_UARTEx_ReceiveToIdle_DMA它做的事情是启动 DMA 循环接收同时使能线空闲检测。当串口线上没有新数据进来超过一个字节的时间硬件会认为这一帧结束了触发事件回调。这样你就能收到不定长的一整帧而不需要提前知道长度。实际配置时要注意两个点第一这个 API 本质上是在收到空闲事件和DMA 传输完成两条路径上都会回调HAL_UARTEx_RxEventCallback回调参数里的Size表示自上次回调以来新收到的字节数。这个计数的基准不是缓冲区地址而是 DMA 的传输计数所以你在回调里直接拿Size去喂数据绝对没错。第二循环模式下 DMA 永远不会自然停止它会把缓冲区从头写到尾再绕回来。所以如果数据量小、帧之间都有空闲间隔回调主要在 idle 路径触发缓冲区 512 字节往往只用到前几十字节。但如果你运气不好数据连续灌满缓冲区也没出现空闲这时候就必须靠 DMA 的半传输完成中断HT和传输完成中断TC来处理数据否则后半段数据会覆盖还没被读走的前半段。2.2 环形缓冲区是解决覆盖问题的唯一答案有了 DMA 缓冲区之后紧接着要解决的是任务读取和DMA 写入并发的问题。我最推荐的做法是在 DMA 缓冲区之上再封装一层环形缓冲区ISR 里面只做一件事——更新写指针和通知任务任务的 while 循环里持续读指针并处理数据。这里的关键是环形缓冲区的头尾指针必须是volatile的而且只能在中断里写 head、在任务里写 tail反过来不行。只有这样两个执行上下文才不会因为同时写一个变量而互相干扰。一个精简的环形缓冲区结构体长这样typedef struct { uint8_t *buf; /* 缓冲区基地址通常直接指向DMA缓冲区 */ uint32_t size; /* 缓冲区大小建议2的幂方便做位与取模 */ volatile uint32_t head; /* 写索引仅在ISR或DMA回调中更新 */ volatile uint32_t tail; /* 读索引仅在任务中更新 */ } uart_ring_t;缓冲区大小设为 2 的幂次方取模的时候直接用index (size - 1)比% size快得多。在 STM32F4 这种主频上无所谓在高频大数据量的时候能省一点是一点。2.3 中断回调里只通知不处理下面这段是 STM32 HAL 库典型的接收事件回调处理。它的作用只有一个把新来数据这件事通知给跟 DMA 缓冲区匹配的接收任务真正的解析在任务里做。void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { BaseType_t xHigherPriorityTaskWoken pdFALSE; UART_RxEvent_t evt; evt.huart huart; evt.size Size; /* 把事件塞进队列不在中断里做业务逻辑 */ xQueueSendFromISR(xRxEvtQueue, evt, xHigherPriorityTaskWoken); /* 如果接收任务的优先级高于当前被打断的任务则立即切换 */ portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }强调一个 FreeRTOS 环境下的铁律在中断回调里只能用FromISR结尾的 API比如xQueueSendFromISR、xSemaphoreGiveFromISR、vTaskNotifyGiveFromISR。很多人从裸机开发切过来习惯在中断里直接调用xQueueSend表面上编译能过但运行起来就会随机死机原因很简单非 FromISR 版本的函数会尝试切换任务上下文而当前正处于中断上下文里这叫在错误的位置启动了调度器。接收任务侧大概长这样void uart_rx_task(void *argument) { UART_RxEvent_t evt; uart_ring_t *ring get_ring_for_uart1(); for (;;) { /* 阻塞等待接收事件CPU让出来给其他任务用 */ if (xQueueReceive(xRxEvtQueue, evt, portMAX_DELAY) pdPASS) { uint32_t head_new ring-head evt.size; /* 更新写索引之前要检查会不会把未读数据覆盖掉 */ if (!ring_is_full(ring, head_new)) { ring-head head_new; } process_frame(ring); } } }注意process_frame里要做的是先把原始字节从环形缓冲区拷贝到任务自己的静态缓冲区再去做协议解析。因为 DMA 缓冲区的所有权实际上属于外设和中断上下文任务只是一个借用者拿到数据后要尽快复制走否则下一次 DMA 写入会直接覆盖。2.4 半传输中断到底要不要开我看过很多人的 CubeMX 配置默认把 DMA 的半传输中断勾上然后从来不处理HAL_UART_RxCpltCallback只在 RxEvent 回调里干活。这其实埋了个雷如果一帧数据刚好等于 DMA 缓冲区的一半你会在半传输中断里回调一次、又在传输完成中断里回调一次而你在回调里判断帧结束的条件是出现了空闲事件这会导致一帧数据被拆成两次通知。我的经验是看应用场景决定。如果是 Modbus 这类一问一答、帧短且有明确空闲间隔的协议关掉半传输中断只用空闲事件就够。如果是 4G 模块上报这种可能长时间连续输出的场景打开半传输中断在半传输和传输完成时都把数据搬进环形缓冲区防止 DMA 缓冲区后半段还没被处理就被新一轮数据覆盖。开与不开核心原则是DMA 缓冲区任何时刻都不能让新写入的数据覆盖掉还没被任务读走的数据。3. 发送方向别把 DMA 发送当成丢出去就完事3.1 DMA 发送的忙状态是隐藏杀手接收讲完发送方向同样有一堆人踩坑。最典型的问题就是任务里连续两次调用HAL_UART_Transmit_DMA第二次直接返回HAL_BUSY然后你的代码如果没有处理返回值就以为发送成功了实际上数据根本没发出去。原因在于DMA 发送是异步的。第一次调用把数据交给 DMA 后函数立即返回DMA 还在后台逐字节往外搬此时再调用一次HAL 库会发现发送状态还是HAL_UART_STATE_BUSY_TX直接拒绝。裸机下你可以在主循环里轮询发送完成标志但 FreeRTOS 里任务之间是抢 CPU 的两个任务如果同时要用同一个串口发送状态控制就必须要交给信号量。3.2 用互斥锁 完成信号量组合管理发送我现在惯用的方案是两个 FreeRTOS 同步对象配合一个互斥量保护串口发送权一个二值信号量表示DMA 已经发完。任务要发数据先拿互斥量再调 DMA 发送然后等完成信号量等到了之后释放互斥量。这样既避免了两个任务同时发起发送导致 HAL_BUSY又保证了发送不会提前返回。static SemaphoreHandle_t s_txMutex; static SemaphoreHandle_t s_txDoneSem; static uint8_t s_txBuf[256]; void uart_tx_init(void) { s_txMutex xSemaphoreCreateMutex(); s_txDoneSem xSemaphoreCreateBinary(); } /* DMA发送完成中断回调 */ void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { BaseType_t xHigherPriorityTaskWoken pdFALSE; xSemaphoreGiveFromISR(s_txDoneSem, xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } int uart_tx_send(const uint8_t *data, uint32_t len) { if (len sizeof(s_txBuf)) { return -1; } if (xSemaphoreTake(s_txMutex, pdMS_TO_TICKS(100)) ! pdPASS) { return -1; /* 拿不到发送权说明别的任务正在发可以按策略丢弃或者排队 */ } memcpy(s_txBuf, data, len); if (HAL_UART_Transmit_DMA(huart1, s_txBuf, len) ! HAL_OK) { xSemaphoreGive(s_txMutex); return -1; } /* 等待DMA把数据真正发完超时时间按波特率估算 */ if (xSemaphoreTake(s_txDoneSem, pdMS_TO_TICKS(100)) ! pdPASS) { /* 这里要小心如果经常超时说明DMA中断没触发需要去查NVIC配置 */ xSemaphoreGive(s_txMutex); return -1; } xSemaphoreGive(s_txMutex); return 0; }有个细节要提醒s_txDoneSem每次发送前最好确认它是空的。因为你可能上一次发送超时了信号量里还残留一个标志下一次发送刚调用完 DMA 就被这个残留信号量唤醒实际上数据根本没发完。我习惯在发送前调用xSemaphoreTake(s_txDoneSem, 0)清一下这是个便宜又安全的保险。3.3 短数据别用 DMA很多人有个误区为了显得高级不管发多少字节都走 DMA。但 DMA 启动有自己的开销——配置源地址、目标地址、长度、使能通道这几条寄存器操作在低波特率下反而比轮询发送慢得多。我的实践经验是发送长度小于 16 字节时直接轮询HAL_UART_Transmit超过 16 字节再用 DMA。这个阈值因 MCU 主频而异但方向是一致的——DMA 不是免费的它解决的是长数据搬运占用 CPU的问题而不是异步发送很酷的问题。4. FreeRTOS 下的中断优先级配置最容易出隐蔽 Bug 的地方4.1 configMAX_SYSCALL_INTERRUPT_PRIORITY 是怎么回事很多人在 CubeMX 里把 UART 和 DMA 的中断优先级配成 0然后 FreeRTOS 就随机死机。这不是玄学是 FreeRTOS 的硬性要求任何调用了FromISR系列 API 的中断它的优先级数值必须大于等于configMAX_SYSCALL_INTERRUPT_PRIORITY。注意这里说的是大于等于因为 STM32 的 NVIC 优先级是数值越小、优先级越高。如果你的 FreeRTOS 配置里configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY设的是 5那么 UART、DMA 相关中断的抢占优先级数值就必须是 5、6、7不能是 0、1、2、3、4。我见过有人在 CubeMX 里把所有中断都设为优先级 0结果是任务调度器的临界区频繁被打断xQueueSendFromISR内部读到了被破坏的队列状态系统跑几分钟就 HardFault。建议的配置方案是中断抢占优先级说明SysTick15最低FreeRTOS 的时基必须能被任何中断打断PendSV15最低)任务切换触发源不启用作抢占UART 接收 DMA5实时性要求高但要允许被更高优先级的中断打断UART 发送 DMA6比接收低一点发送晚几十微秒无伤大雅其他外设中断7默认档位这里有个反常识的地方很多人觉得接收中断优先级越高越好恨不得设成 0。但在 FreeRTOS 环境下中断优先级高过系统调用阈值后就不能再调用任何FromISR函数了你只能在中断里置一个标志位等任务去查询。这样反而更麻烦。所以我的建议是不超过configMAX_SYSCALL_INTERRUPT_PRIORITY的前提下能多高就多高。4.2 回调函数里不能做的几件事除了不能用非 FromISR 的 API 之外还有几个高频错误我整理一下在回调里调用HAL_UART_Receive_DMA重新启动接收。这是裸机时代的习惯在 HAL 库的RxEvent回调里其实不需要因为循环 DMA 一直都在跑但如果你确实用了非循环模式重启接收的操作要放到任务里做别在中断里做。在回调里做耗时的数据解析、打印、甚至memcpy大块数据。串口波特率 115200 时一个字节约 87 微秒低优先级任务可能等得起但在中断里占用的每一微秒都是在抢其他实时任务的时间。在回调里直接操作环形缓冲区的读指针。读指针只有任务能改这句话我前面说过但值得再强调一遍。4.3 优先级反转在串口发送任务里的表现FreeRTOS 的互斥量自带优先级继承机制这是它比二值信号量更适合做资源锁的原因之一。在串口发送场景里如果高优先级任务正在等互斥量而低优先级任务拿到了互斥量还没发完互斥量会把低优先级任务的优先级临时提升到高优先级任务的级别防止中间优先级任务插队导致高优先级任务长时间拿不到锁。所以你用互斥量管发送权而不是用普通二值信号量是有理论依据的不只是习惯问题。5. 堆栈溢出、内存对齐和 Cortex-M7 缓存三个随机崩溃的元凶5.1 堆栈溢出检查要早开FreeRTOS 的configCHECK_FOR_STACK_OVERFLOW设成 1 只能检测任务栈是否被写爆设成 2 会更严格一点它会在任务切换时检查栈指针是否越过栈底边界。开了之后一旦溢出会进入vApplicationStackOverflowHook你在这个函数里打断点就能第一时间定位到是哪个任务栈不够。但我必须说一句实话这个检测是概率性的不是百分百能抓到。更可靠的方法是在任务创建之前根据自己的处理逻辑估算栈大小。我一般按这个公式估任务局部变量 函数调用链上最大的那个函数栈帧 FreeRTOS 内部调用开销 冗余 30%。串口接收任务因为要解析协议、拷贝数据栈给 512 字节通常不够建议 1024 起步。DMA 缓冲区这种大数组绝对不要定义在任务栈里要么全局、要么静态、要么单独分配内存放在栈里就是在制造 HardFault。5.2 DMA 缓冲区的对齐问题有人用uint8_t buf[512]直接丢给 DMA在 F1/F4 上跑没啥问题换到部分 M0 或者 DMA 要求字对齐的芯片上就出诡异现象。DMA 外设对缓冲区的对齐要求取决于具体芯片的 DMA 配置常见的是 4 字节对齐Cortex-M7 配合数据缓存时通常要求 32 字节对齐对应 cache line 大小。最省心的写法是用 GCC 或 ARMCC 的对齐属性static uint8_t dma_rx_buf[512] __attribute__((aligned(32)));在 STM32H7 这类带 D-Cache 的芯片上DMA 缓冲区放在普通 SRAM 且开启了 D-Cache 的情况下CPU 读到的数据可能是缓存里的旧数据DMA 写进内存的新数据根本没进缓存。这时候有两个方向一是把 DMA 缓冲区放到非缓存区域通过 MPU 给某段 SRAM 配置成 Device 或 Non-cacheable二是在接收事件到来后主动做一次SCB_InvalidateDCache_by_Addr发送前做一次SCB_CleanDCache_by_Addr。如果项目刚开始我强烈建议直接用 MPU 划分一块非缓存的 SRAM 给 DMA 用一劳永逸比每次收发都手动维护缓存一致性要可靠得多。缓存一致性维护的代码一旦漏写一次就是偶发性数据错误排查成本极高。5.3 FreeRTOS 堆内存和 DMA 大缓冲的关系FreeRTOS 默认的heap_4支持内存合并碎片率不高但大块连续内存依然存在分配失败的可能。如果你把 DMA 缓冲区用pvPortMalloc动态分配跟任务栈、队列内存混在一起长时间运行时万一被碎片拆散pvPortMalloc返回 NULL你的代码还没判空直接把 NULL 地址丢给 DMA这就不是数据错误了是直接崩。我的建议是DMA 缓冲区、环形缓冲区这些跟外设强绑定的内存一律静态分配、固定地址。FreeRTOS 的动态堆只用来分配任务控制块、栈、队列这类运行期对象。6. 数据零丢失的验证方法别靠眼睛测把上面的架构搭完你以为就完了还远得很。我见过太多人代码写得头头是道一上压力测试就原形毕露。这里给一套我自己一直在用的验证流程。6.1 把串口助手换成发序列号测试接收的时候用上位机周期性发送 0x00 到 0xFF 的递增序列每帧带帧号。下位机收到后校验序列是否连续、帧号是否有跳变。任何丢字节的行为都会表现为序列不连续比用 0xAA 0x55 这种固定字节测可靠得多因为固定字节丢一个你也看不出来。6.2 用 DMA 的计数寄存器判断缓冲落后量STM32 的 DMA 外设有NDTR寄存器记录剩余传输数量。在空闲中断里读一下它你就能算出当前 DMA 写到了缓冲区哪个位置再跟任务的读指针比对直接得出缓冲区剩余可用空间。如果剩余空间持续逼近 0说明任务处理速度跟不上接收速度这时候你要么加大缓冲区要么优化任务处理逻辑而不是继续加打印。6.3 必须检查 UART 的溢出错误DMA 搬运过程中如果 DMA 还没把数据搬走而串口接收寄存器又来了新数据UART 会产生溢出错误ORE。这个错误标志会锁住接收你不清掉它后面所有数据全部丢弃。由于ORE在中断里触发我建议在 UART 错误中断回调里做三件事读一下SR/ISR清错误标志、统计错误次数、把错误计数用一条日志上报给上位机。如果压测时错误次数持续增长你就知道问题出在 DMA 搬移速度跟不上串口接收速度而不是去改协议解析代码。6.4 我自己最后一条经验串口调试的时候千万别长时间开着断点在中断回调里看变量。DMA 接收是全天候在跑的你断在中断里DMA 继续写缓冲区等你在 Keil 的 Watch 窗口里看完变量点继续运行数据早就绕了几圈了该覆盖的覆盖了你看到的现象全是假象。要调试中断里的问题正确做法是在中断里置个标志量主循环里通过逻辑分析仪或者串口打印把这个标志量带出来。最后分享一个我从老工程师那里学来的小习惯在工程的调试日志里给每一条接收记录都打上一个单调递增的计数器。等到排查问题时只要看到计数器的跳变就知道丢包发生的大概时刻再结合那个时刻的任务调度情况问题范围立刻就缩小了。这个思路比反复打印缓冲区内容高效得多。本文还有配套的精品资源点击获取
返回列表