STM32 ThreadX串口驱动:DMA空闲中断与环形缓冲分层 简介面向嵌入式开发者的ThreadX串口通信实践工程包基于STM32CubeMX生成围绕UART的DMA收发展开并引入队列与信号量完成中断与任务间的数据传递和同步适合已掌握STM32基础、希望进一步理解实时操作系统任务通信机制的学习者参考。压缩包共2000个文件约15.36MB其中C源文件905个、头文件345个另有汇编启动文件、链接脚本、静态库、工程配置及编译输出等较完整地保留了从外设初始化、驱动实现到构建产物的工程结构。已有275人学习读者可据此梳理ThreadX下串口DMA的配置流程、队列与信号量的典型用法并对照工程文件组织方式排查中断优先级、缓冲管理与任务同步中的常见问题从而对串口DMA收发、队列投递与信号量同步等关键环节形成直观认识快速搭建可复用的串口通信框架。1. 中断里只留三行代码UART_ThreadX 这份工程在解决什么裸机项目里最常见的写法是在 USART 中断回调里直接拼协议、判帧头、顺手打印一句日志。波特率一上到 115200、帧长一过 32 字节丢包就变成一种难以解释的现象今天测试正常明天现场就掉线。这份 UART_ThreadX 压缩包走的是另一条路STM32CubeMX 负责把时钟、GPIO、USART、DMA 初始化生成好ThreadX 在内核上挂两条线程一条只管收、一条只管发中断里只做三件事——搬字节、投信号量、退出。它适合两类人被裸机大循环里的串口协议折腾过、想看看 RTOS 下该怎么分层的人以及手上已有 ThreadX 工程、但串口驱动是抄来的、不确定对不对的人。工程本身不大跑通一次半小时但几个参数的取舍值得单独拎出来讲。2. 工程骨架拆解CubeMX 生成的初始化跟 ThreadX 内核怎么接上拿到压缩包先别急着编译先看目录。很多人直接双击 .ioc 重新生成一遍结果自己加的两百行代码全被覆盖这是最常见的一次意外状况。先把「哪些文件是 CubeMX 的、哪些是自己的」分清楚后面所有改动才有依据。2.1 目录结构Core、AZRTOS、Middlewares 三块各管什么打开工程根目录能看到这么几个平行的文件夹职责划分其实很清楚路径内容改动频率Core/Src/main.cHAL_Init、时钟配置、外设初始化、最后调MX_ThreadX_Init低Core/Src/app_threadx.ctx_application_define创建线程、信号量、互斥量高Core/Src/uart_port.c环形缓冲、DMA 重新挂载、收发 API高Core/Src/stm32xxxx_it.c中断向量最终调到 HAL 的弱回调低Core/Inc/tx_user.hThreadX 编译期配置tick 频率、错误检查开关中AZRTOS/AppST 的封装层池大小宏一般在这里中Middlewares/ST/threadx内核源码 端口层汇编基本不动Drivers/HAL 库与 CMSIS不改UART_ThreadX.iocCubeMX 工程文件中Middlewares/ST/threadx下面还会分成common/和ports/两块common是跟架构无关的内核源码ports下按内核分目录Cortex-M4 和 Cortex-M7 各有一份tx_initialize_low_level.S。这份汇编是整个移植里最不该碰、也最容易被误改的文件它干的事情只有两件把 SysTick 和 PendSV 的优先级设到最低0xFF以及把链接脚本里堆顶的地址记录到_tx_initialize_unused_memory。提示_tx_initialize_unused_memory这个符号决定了内核能用的内存起点。如果你在链接脚本里改了 RAM 分区却不改这个符号表现出来就是内核启动正常、创建第二个线程就返回TX_NO_MEMORY非常不易定位。真正需要你维护的是app_threadx.c和uart_port.c这两个文件。我的习惯是app_threadx.c只放内核对象的创建和线程入口函数体的骨架具体业务逻辑全部放到独立模块里app_threadx.c里只留一行调用。这样 CubeMX 重新生成时就算app_threadx.c被覆盖重新补十几个创建函数就行业务代码一行不丢。2.2 启动链从 Reset_Handler 到 tx_kernel_enter 的七步main.c在带 ThreadX 的工程里反而是最短的一个文件因为它没什么可写的——走到最后一行就交权了int main(void) { HAL_Init(); /* 1. NVIC 优先级分组、HAL 时基源 */ SystemClock_Config(); /* 2. 主频、PLL、外设时钟使能 */ MX_GPIO_Init(); /* 3. 调试口、状态灯 */ MX_DMA_Init(); /* 4. DMA 控制器时钟与 NVIC */ MX_USART1_UART_Init(); /* 5. 串口参数此时中断还没开 */ MX_ThreadX_Init(); /* 6. 进内核这个函数不返回 */ while (1) { } /* 7. 永远走不到这里 */ }逐行说一下这七步里容易忽略的地方。第 1 步的HAL_Init()会调HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4)也就是 4 位全给抢占优先级、没有子优先级——这个设置必须和 ThreadX 端口层的假设一致中途改成分组 2 会让tx_thread_preemption_change的行为变得不可预期。第 5 步的MX_USART1_UART_Init()只配了波特率、字长、停止位并不打开 NVIC 中断真正打开是在HAL_UART_MspInit()里调HAL_NVIC_EnableIRQ(USART1_IRQn)这一步的时机很关键后面避坑那章会专门讲。第 6 步进到tx_kernel_enter()内部依次是_tx_initialize_low_level设置 SysTick/PendSV 优先级并把堆顶地址记下来_tx_initialize_high_level初始化定时器、线程、队列等子系统然后回调你的tx_application_define(first_unused_memory)最后启动调度器从就绪链表里挑最高优先级的线程开始跑。/* app_threadx.c —— 这段跑在调度器启动之前中断仍然是关的 */ TX_THREAD g_rx_thread; TX_THREAD g_tx_thread; TX_SEMAPHORE g_uart_rx_sem; TX_MUTEX g_uart_tx_mutex; /* 栈用静态数组比从 byte pool 里切更可控 */ static UCHAR s_rx_stack[1024] __attribute__((aligned(8))); static UCHAR s_tx_stack[1024] __attribute__((aligned(8))); VOID tx_application_define(VOID *first_unused_memory) { /* 用不到动态分配的话first_unused_memory 可以不接 */ (void)first_unused_memory; tx_semaphore_create(g_uart_rx_sem, uart rx, 0); tx_mutex_create(g_uart_tx_mutex, uart tx, TX_INHERIT); tx_thread_create(g_rx_thread, uart rx, uart_rx_thread_entry, 0, s_rx_stack, sizeof(s_rx_stack), 8, 8, TX_NO_TIME_SLICE, TX_AUTO_START); tx_thread_create(g_tx_thread, uart tx, uart_tx_thread_entry, 0, s_tx_stack, sizeof(s_tx_stack), 10, 10, TX_NO_TIME_SLICE, TX_AUTO_START); }tx_thread_create的那几个数字要解释一下第 6、7 个参数是优先级和抢占阈值ThreadX 里数值越小优先级越高这里是 8 和 10收线程优先级高于发线程因为收方向丢一个字节就是整帧重传发方向晚几毫秒没人看得出来。第 5 个参数是栈大小单位是字节不是字1024就是 1KB这个数字给小了是硬伤串口打印一句printf就可能吃掉 600 字节。最后两个参数TX_NO_TIME_SLICE和TX_AUTO_START前者表示同优先级之间不做时间片轮转后者表示创建完立刻进就绪队列。如果你希望两个同优先级的线程公平轮流跑把TX_NO_TIME_SLICE换成TX_1_TICK之类的时间片值。2.3 CubeMX 里必须动的三个参数生成的默认配置能编译能跑但跑不长。下面这三处是我每建一个 ThreadX 工程都要回去确认的参数位置默认值建议值原因Timebase SourceSYS → Timebase SourceSysTickTIM6 或 TIM7ThreadX 独占 SysTick 做内核 tickUSART1 全局中断优先级NVIC → USART1 global interrupt05 ~ 6数值 0 会抢在内核 tick 之前TX_TIMER_TICKS_PER_SECONDtx_user.h1001000100 时tx_thread_sleep(1)等于 10ms粒度太粗第一项是最容易被忽略的。CubeMX 默认让 HAL 用 SysTick 做HAL_GetTick()的时基而 ThreadX 的 Cortex-M 端口层也要把 SysTick 设成自己的 tick 源两个东西抢同一个定时器。表现是HAL_Delay和tx_thread_sleep互相打架时序全乱。在 CubeMX 的 SYS 页面把 Timebase Source 切到 TIM6HAL 就会用 TIM6 的更新中断去喂uwTick两边各用各的。第二项的原理是SysTick 和 PendSV 在端口层被设成最低优先级 0xFF如果你的串口中断优先级设成 0它就能打断内核任何一段临界区。虽然 ThreadX 在 Cortex-M 上默认用CPSID i关全局中断来做临界区保护理论上不会被真正的临界区打断但优先级 0 的串口中断会拖长中断响应嵌套链高波特率下表现为偶发的字节错位。设成 5~6 是个折中比 tick 高保证串口能及时取走数据又不会压过所有系统中断。第三项直接决定tx_thread_sleep的精度。tx_thread_sleep(1)在 100Hz 配置下是 10ms在 1000Hz 下是 1ms。串口超时判断一般按毫秒算用 100Hz 会让所有超时都变成一个数量级的粗粒度调起来很别扭。3. 串口驱动核心中断搬字节信号量交数据互斥量守通道分层原则一句话说完中断里不做任何耗时超过几微秒的事线程里不做任何不让出 CPU 的等待。这份工程的uart_port.c就是围绕这两条搭起来的。3.1 中断回调里的三行代码为什么不能就地解析协议先看接收侧。用 DMA 空闲中断是这份工程的选择比单字节中断省太多 CPU/* uart_port.c —— 接收侧全局变量 */ #define UART_RING_SIZE 1024u /* 必须是 2 的幂用掩码代替取模 */ #define UART_DMA_CHUNK 64u static uint8_t s_ring[UART_RING_SIZE]; static volatile uint16_t s_head; /* 只有中断写 */ static volatile uint16_t s_tail; /* 只有线程写 */ static volatile uint32_t s_drop_cnt; /* 溢出计数调试时看这个 */ static uint8_t s_dma_buf[UART_DMA_CHUNK]; /* HAL 的空闲中断回调运行在中断上下文 */ void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance ! USART1) { return; } for (uint16_t i 0; i Size; i) { uint16_t next (uint16_t)((s_head 1u) (UART_RING_SIZE - 1u)); if (next s_tail) { /* 满了丢新字节保住老数据 */ s_drop_cnt; continue; } s_ring[s_head] s_dma_buf[i]; s_head next; } tx_semaphore_put(g_uart_rx_sem); /* 不能带超时中断里不许阻塞 */ }逻辑上分三段。第一段是一次最多 64 字节的 DMA 搬运Size参数由 HAL 根据 DMA 剩余计数算出来是本次实际收到的字节数不是缓冲区长度。第二段是写环形缓冲用(head 1) (SIZE - 1)求下一个位置UART_RING_SIZE取 2 的幂就是为了省掉一次除法——Cortex-M4 上一条UDIV是 2~12 个周期在中断里能省则省。第三段只投一个信号量唤醒接收线程。判断满的方式是「下一个 head 撞上 tail」牺牲一个字节的空间换取无锁的读写分离。s_drop_cnt这个变量不起眼但很有用。串口不通的时候先看它是 0 还是猛涨一直是 0 说明数据根本没进来问题在物理层或 DMA 配置猛涨说明线程侧消化太慢得调大缓冲或者提高线程优先级。这就是现场排查时最实用的指标。参数上有一点要提醒HAL_UARTEx_RxEventCallback是 HAL 在HAL_UART_IRQHandler里根据 DMA 状态调的回调它同时承载「半传输」和「空闲」两种事件。半传输事件默认是开的意味着传满 32 字节就会进来一次和空闲中断混在一起。如果不需要这么高频可以在HAL_UART_MspInit里__HAL_DMA_DISABLE_IT(huart-hdmarx, DMA_IT_HT);把半传输关掉只留空闲触发中断次数能少一半。3.2 接收线程信号量 环形缓冲把字节流拼成帧线程侧的逻辑反而简单因为所有的脏活都在中断里做完了/* 接收线程入口 */ static VOID uart_rx_thread_entry(ULONG arg) { uint8_t chunk[64]; for (;;) { /* 1. 一直等到中断投信号量期间不占 CPU */ if (tx_semaphore_get(g_uart_rx_sem, TX_WAIT_FOREVER) ! TX_SUCCESS) { continue; /* 理论上不会走到出错直接重来 */ } /* 2. 一次最多搬 64 字节出来剩下的下一轮再说 */ uint16_t n 0; while (n sizeof(chunk) s_tail ! s_head) { chunk[n] s_ring[s_tail]; s_tail (uint16_t)((s_tail 1u) (UART_RING_SIZE - 1u)); } if (n 0) { continue; } /* 3. 解析放在这里慢一点也不会丢后面的字节 */ proto_feed(chunk, n); } }tx_semaphore_get的超时参数用TX_WAIT_FOREVER是因为这条线程就是专门为串口服务的没数据时挂起零开销不需要超时兜底。如果你还想让这条线程周期性做点别的事比如心跳把超时改成 100 tick超时返回TX_NO_INSTANCE时走另一条分支就行。第 2 步的while有一个细节只搬 64 字节如果环形缓冲里积压了 500 字节剩下的要等下一轮信号量。这样写的好处是单次处理时间可控不会因为积压太多一次卡死几十毫秒代价是解析函数要能处理「半帧」不能假设一次proto_feed就拿到完整一帧。如果协议本身要求整帧进整帧出把chunk开大到UART_RING_SIZE一次搬完再解析。proto_feed是留给二次开发的接口默认实现里可以先只做一件事——找帧头帧尾、算校验、把完整帧投到命令队列。把「收字节」和「解析帧」分开是这套骨架最有价值的约定。3.3 发送侧互斥量守住 huart1DMA 发完再释放多个线程往同一个串口写数据在这个工程里是常态一条线程回命令一条线程打日志偶尔还有一条线程做 OTA 心跳。不加保护的话两个HAL_UART_Transmit交叉执行输出就是两段字节互相插花看起来像乱码实际是竞态。TX_SEMAPHORE g_uart_tx_done_sem; /* 发送完成回调由 DMA 传输完成中断触发 */ void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { tx_semaphore_put(g_uart_tx_done_sem); } } int uart_send(const uint8_t *buf, uint16_t len, ULONG timeout) { /* 1. 抢通道抢不到说明别人正在发 */ if (tx_mutex_get(g_uart_tx_mutex, timeout) ! TX_SUCCESS) { return -1; } /* 2. 挂 DMA注意 buf 在发完之前不能被释放或改写 */ if (HAL_UART_Transmit_DMA(huart1, (uint8_t *)buf, len) ! HAL_OK) { tx_mutex_put(g_uart_tx_mutex); return -2; } /* 3. 等发完超时说明链路有问题别无限期挂住 */ UINT rc tx_semaphore_get(g_uart_tx_done_sem, timeout); tx_mutex_put(g_uart_tx_mutex); return (rc TX_SUCCESS) ? 0 : -3; }tx_mutex_create时传的TX_INHERIT是关键参数它开启优先级继承。假设低优先级的日志线程拿到了互斥量正在等 DMA此时高优先级的命令线程来抢如果没开继承命令线程会被中间优先级的线程无限期挡住这就是典型的优先级反转。开了继承之后内核会临时把持锁线程的优先级提到和等待者一样高让它尽快跑完释放。timeout参数我一般传TX_WAIT_FOREVER给必须发出去的数据命令响应传 100 tick 给可丢弃的数据日志。区分这一点很重要日志发不出去就丢掉命令响应丢了会导致上位机超时重传。第 2 步的注释是个真坑。HAL_UART_Transmit_DMA只是把缓冲区地址交给 DMA 控制器就返回了如果buf是栈上的临时数组函数返回后这块栈被覆盖DMA 还在往外搬旧数据发出去就是一串垃圾。要么buf用静态区要么像上面这样等发送完成再返回。3.4 把「发什么」和「怎么发」解耦命令队列上面那个uart_send是同步的调用者要等到发完才能继续。命令响应用它没问题但如果日志量一大每条日志都占着一个互斥量等 DMA吞吐就上不去了。加一层队列就解决了TX_QUEUE g_uart_tx_queue; /* 消息大小 4 字节存的是 buffer 索引 */ /* 日志线程只负责投递不关心什么时候真正发出去 */ int log_post(const char *fmt, ...) { uint8_t *slot bufpool_alloc(); /* 从静态缓冲池里拿一块 */ if (slot NULL) { return -1; /* 池子空了直接丢不阻塞 */ } int n vsnprintf((char *)slot, LOG_SLOT_SIZE, fmt, va_list_stuff); if (tx_queue_send(g_uart_tx_queue, slot, TX_NO_WAIT) ! TX_SUCCESS) { bufpool_free(slot); return -1; } (void)n; return 0; }tx_queue_send的最后一个参数在日志场景一定用TX_NO_WAIT。队列满了就丢这是设计上的取舍——日志不该成为阻塞业务的原因。如果改成TX_WAIT_FOREVER日志量一大所有调log_post的线程全部卡在队列上表现出来就是「系统莫名其妙变卡」查半天才发现是打印拖的。发送线程那边就是死循环tx_queue_receiveuart_sendbufpool_free。队列深度按最坏情况估一般给 8~16 个槽位每个槽位 128 字节总共不到 2KB比给日志线程开大栈划算得多。4. 编译、烧录与验证怎么确认线程真的在跑代码写完只是开始能不能证明它在按你想象的方式跑才是关键。这一章讲的是怎么用最短路径确认调度、缓冲、中断三件事都正常。4.1 编译选项与内存布局先确认池子够不够工程用arm-none-eabi-gcc CMake 或者 CubeIDE 都能编。不管哪种编译完先看.map文件里三个段的占用arm-none-eabi-size build/UART_ThreadX.elf # text data bss dec hex filename # 48216 1124 18392 67732 10894 build/UART_ThreadX.elfbss段是重点。所有静态线程栈、环形缓冲、日志槽位都落在这里18392字节意味着 RAM 已经被吃掉 18KB。如果你的芯片只有 64KB RAM加上 HAL 自己的缓冲和堆余量就不多了。这时候要么裁缓冲要么换更大 RAM 的型号别硬撑。如果用了 byte pool 做动态分配tx_byte_pool_create的第三个参数池大小必须小于实际可用 RAM 减去 bss 之后的余量。宏定义在app_threadx.h里CubeMX 重新生成可能会把它改回默认值我一般在tx_user.h里覆盖一层避免被改回去。判断依据很简单池子开太大tx_byte_pool_create会返回TX_SIZE_ERROR开太小跑到第二个线程就TX_NO_MEMORY。两个错误码方向相反看返回值就能定位。4.2 打三个断点看调度器是不是真的在切烧录进去先别急着开串口助手。打三个断点一个一个确认第一个断点打在tx_application_define的第一行。能停下来说明_tx_initialize_low_level和内存初始化都过了。如果停不下来十有八九是链接脚本里_tx_initialize_unused_memory符号没定义或者tx_initialize_low_level.S没被加进编译列表——检查 CMakeLists 或者 CubeIDE 的源文件排除状态。第二个断点打在uart_rx_thread_entry的for(;;)里面。全速跑能停下来说明调度器起来了、线程被选中了。如果一直不停看tx_thread_create的返回值或者用调试器的 Watch 窗口看g_rx_thread.tx_thread_state值是TX_READY还是TX_SUSPENDED。第三个断点打在HAL_UARTEx_RxEventCallback里。串口助手发一串数据能停下来说明 DMA 和空闲中断都通了。这一步停不下来最常见的原因是 NVIC 中断没使能或者 DMA 通道配错了。三个断点都能停下来基本可以确定链路是通的。这套方法看着笨但比盯着串口助手猜快得多。4.3 黑盒验证GPIO 翻转和串口回环断点会打断实时性验证吞吐的时候得换黑盒手段。两个办法最实用。第一个是 GPIO 翻转。在接收线程进入和退出时各翻转一次空闲引脚/* 用示波器或逻辑分析仪看这个引脚占空比反映 CPU 占用 */ #define PROF_ENTER() (GPIOC-BSRR GPIO_BSRR_BS_6) #define PROF_EXIT() (GPIOC-BSRR GPIO_BSRR_BR_6)如果占空比一直在 80% 以上说明接收线程处理太慢得看s_drop_cnt是不是在涨如果占空比很低但数据还是丢问题就在中断侧而不是线程侧。这一招在排查「到底是收不进来还是处理不过来」的时候特别好用。第二个是串口回环。把 TX 和 RX 短接发什么应该收到什么。回环测试能一次性验证波特率、字长、停止位、DMA 配置这四件事比逐项检查快。回环不通但断点能停多半是波特率算错了——检查SystemClock_Config里给 USART1 的时钟源频率HAL 计算 BRR 时用的就是这个值主频改了但 CubeMX 没重新生成就会出现「配置里写 115200、实际跑 9600」的情况。5. 避坑与排查五条踩过的记录下面这五条都是这套骨架在不同项目里真实复现过的问题按「现象 → 原因 → 解决」写遇到类似的直接对号入座。5.1 现象串口只收到第一个字节再发就没反应原因HAL_UART_Receive_IT是一次性的。回调进一次之后接收状态机就回到HAL_UART_STATE_READY但 NVIC 还开着下一个字节进来只会置位错误标志不会进回调。很多人以为 HAL 会自己续上实际上是自己在回调里重新挂。解决在HAL_UARTEx_RxEventCallback或HAL_UART_RxCpltCallback的末尾重新调用一次HAL_UARTEx_ReceiveToIdle_DMA(huart1, buf, len)并且重新关掉半传输中断。更稳妥的做法是把「重新挂载」封成一个uart_rx_arm()函数初始化时调一次回调末尾调一次只有一处逻辑。5.2 现象线程里调了 HAL_Delay 之后整个系统像被冻住原因HAL_Delay是忙等本质是while (uwTick - start ms)全程占着 CPU 不释放。ThreadX 是抢占式的但如果这条线程优先级最高它忙等的时候低优先级的线程一次都跑不到。另一个隐藏原因是 HAL 时基还挂在 SysTick 上和内核 tick 抢同一个定时器。解决线程上下文里一律用tx_thread_sleep(ms * TX_TIMER_TICKS_PER_SECOND / 1000)把 CPU 让出去。同时在 CubeMX 的 SYS 页面把 Timebase Source 改成 TIM6。改完之后用 4.3 那个 GPIO 翻转法复测占空比应该明显下降。5.3 现象加了 printf 之后输出乱码或者直接停在 HardFault_Handler原因三个独立问题叠在一起。一是 newlib 的_write没重定向printf的输出被丢到默认的_write里有的工具链实现会触发半主机异常。二是printf内部不是线程安全的两条线程同时进来会踩同一块静态缓冲。三是printf的栈开销大格式化浮点时能吃掉 600 字节以上1KB 的线程栈直接溢出。解决重定向_write到HAL_UART_Transmit外面套一层互斥量或者干脆放弃printf写一个基于 3.4 那个队列的log_post。栈至少给到 2KB如果一定要格式化浮点给 3KB。这属于典型的「为了省事结果更费事」。5.4 现象跑十几分钟到几小时随机死机复位之后又能跑原因栈溢出或者动态分配失败没判返回值。tx_byte_allocate返回TX_NO_MEMORY时指针还是 NULL后面直接往里写就是踩内存。另一种是中断里调了带超时参数的服务比如tx_semaphore_get(sem, 100)在中断上下文里超时参数必须是无符号的零传非零会返回TX_WAIT_ERROR并可能破坏内核状态。解决在tx_user.h里打开 TX_ENABLE_STACK本文还有配套的精品资源点击获取