ThreadX串口通信实战:从信号量到DMA空闲中断的丢数据排查指南
简介本资源面向STM32嵌入式开发者与实时操作系统学习者聚焦ThreadX下UART串口DMA通信的完整工程实现解决裸机串口收发效率低、任务间同步困难等问题。工程基于STM32CubeMX生成结合ThreadX的队列与信号量机制演示DMA收发与任务调度的协同方式适合具备一定RTOS基础、希望深入掌握串口驱动与线程通信的中级开发者参考。压缩包共约2000个文件以905个C源文件、345个头文件为核心辅以汇编、链接脚本、库文件及工程配置等整体约15.36MB涵盖完整编译产物与工程结构。目前已有274人学习下载。读者可从中获取可直接编译运行的ThreadX串口DMA工程模板理解队列传递数据、信号量同步收发的设计思路并借助完整目录结构快速定位驱动、内核与配置模块为移植与二次开发提供参考。1. 串口调不通的那晚我翻出了这份 UART_ThreadX 工程嵌入式开发里最让人头大的时刻往往不是算法跑不动而是串口收发就是没反应。你对着示波器看波形TX 线上有数据RX 线上却一片死寂或者中断进了但数据就是丢。这类问题在裸机时代还能靠调试器单步硬啃一旦上了 RTOS线程调度、中断优先级、信号量超时全搅在一起排查难度直接翻倍。这份 UART_ThreadX.7z 就是冲着这个场景来的——它把 ThreadX 实时操作系统和 STM32CubeMX 生成的 HAL 库串口驱动揉在一起给出一套能直接跑通的收发框架。适合谁正在从裸机往 RTOS 迁移的 STM32 开发者或者已经用上 ThreadX 但串口通信还在用轮询硬扛的团队。它不教你 ThreadX 内核原理而是把「串口中断 信号量 线程」这条链路完整摆出来让你看到数据从寄存器到应用缓冲区的每一步。2. 拆开压缩包ThreadX 与 HAL 的串口分工逻辑2.1 为什么不是裸机中断 环形缓冲裸机串口收发最常用的方案是中断里填环形缓冲区主循环去取。这套逻辑简单但一旦系统里多了几个任务主循环的响应时间就不确定了。比如你在主循环里做 Flash 擦写擦除期间串口数据来了中断能进但主循环取不走缓冲区满了就丢数据。ThreadX 的价值在于把「取数据」这件事变成一个独立线程由内核调度器保证它按优先级拿到 CPU 时间。串口中断只负责把数据塞进 ThreadX 的消息队列或释放信号量剩下的解析、打包、转发全在串口线程里做。这样即使其他线程在跑长任务串口线程的优先级只要设得合理数据就不会因为主循环卡顿而丢失。这份工程里UART 中断服务函数只做两件事读寄存器、释放信号量。真正的数据搬运在uart_thread_entry里。这种分工让中断执行时间压到最短也避免了在中断里调用可能阻塞的 HAL 函数。2.2 工程目录里每个文件在干什么解压后你会看到典型的 STM32CubeMX ThreadX 工程结构但有几个文件是这份资源的核心文件/目录作用需要关注的点Core/Src/app_threadx.cThreadX 线程创建入口串口线程的优先级和栈大小在这里定Core/Src/usart.cCubeMX 生成的 UART 初始化波特率、中断使能、DMA 配置Core/Src/stm32f4xx_it.c中断服务函数USART 中断里调用了哪个 ThreadX APICore/Inc/app_threadx.h线程声明和全局定义信号量/队列句柄的 extern 声明ThreadX/ThreadX 内核源码移植层tx_initialize_low_level.s别乱动我一般会先打开app_threadx.c看线程优先级分配。串口接收线程的优先级通常设得比处理线程高但比系统时钟节拍线程低。如果串口线程优先级设成最高系统 tick 都可能被它挡住导致延时函数不准。2.3 信号量还是消息队列选型看数据量工程里用的是二值信号量通知「有数据来了」然后线程去读 HAL 的接收缓冲区。这种方式适合不定长、低频的数据包。如果你要收的是固定长度、高频的传感器数据流更合适的是消息队列——中断里把每个字节或每帧数据直接投递到队列线程从队列取。区别在于信号量只告诉你有事发生数据还在 HAL 的缓冲区里线程取的时候可能被下一次中断覆盖消息队列把数据拷贝到内核对象里安全但多一次内存拷贝。常见做法是波特率 115200 以下、数据包间隔大于 10ms用信号量足够波特率上到 921600 或者连续流直接上 DMA 消息队列别省那点内存。3. 从 CubeMX 配置到 ThreadX 线程串口收发链路搭建3.1 CubeMX 里必须勾对的三个地方打开工程里的.ioc文件或者你自己新建一个UART 配置要确认这几项第一NVIC 里 USART 全局中断必须使能抢占优先级设成比configMAX_SYSCALL_INTERRUPT_PRIORITY对应的值高——ThreadX 里对应的是TX_PORT_IRQ_PRIORITY相关宏具体看移植层。如果串口中断优先级低于内核可屏蔽阈值中断里调 ThreadX API 会直接触发断言。第二如果用了 DMADMA 通道中断也要使能并且和 UART 中断优先级拉开层次。我一般把 DMA 完成中断设得比 UART 空闲中断低一级避免 DMA 完成时打断空闲中断的处理。第三时钟树里 UART 时钟源要确认。STM32F4 系列 UART 挂在 APB1 或 APB2 上CubeMX 会自动算波特率分频但如果你手动改了时钟树没重新生成代码波特率寄存器可能还是旧值现象就是能收但全是乱码。/* usart.c 里 CubeMX 生成的初始化片段重点看这三行 */ huart1.Instance USART1; huart1.Init.BaudRate 115200; huart1.Init.WordLength UART_WORDLENGTH_8B; huart1.Init.StopBits UART_STOPBITS_1; huart1.Init.Parity UART_PARITY_NONE; huart1.Init.Mode UART_MODE_TX_RX; huart1.Init.HwFlowCtl UART_HWCONTROL_NONE; huart1.Init.OverSampling UART_OVERSAMPLING_16; /* 下面这行决定中断是否在 HAL 层被使能 */ if (HAL_UART_Init(huart1) ! HAL_OK) { Error_Handler(); }OverSampling设成 16 是标准做法除非你的时钟源除不出标准波特率才考虑改 8 倍过采样。HwFlowCtl一般关掉除非你板子上真有 RTS/CTS 引脚并且对端支持。3.2 中断服务函数里只做两件事stm32f4xx_it.c里 CubeMX 会生成USART1_IRQHandler默认调HAL_UART_IRQHandler。你要加的是接收完成后的通知逻辑。常见做法是在 HAL 的回调函数HAL_UART_RxCpltCallback里释放信号量而不是直接在 IRQHandler 里写。/* 在 usart.c 或 app_threadx.c 里定义信号量 */ TX_SEMAPHORE uart_rx_semaphore; /* 中断回调HAL 库在接收完成后调用 */ void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { /* 释放信号量通知串口线程数据已就绪 */ tx_semaphore_put(uart_rx_semaphore); /* 重新挂载接收准备下一帧 */ HAL_UART_Receive_IT(huart1, rx_byte, 1); } }这里有个细节HAL_UART_Receive_IT每次只收一个字节收完进回调回调里再挂一次。这种方式中断频率高115200 波特率下每 87us 进一次中断CPU 占用可观。更高效的是用HAL_UARTEx_ReceiveToIdle_IT或者 DMA 接收不定长但那份工程为了逻辑清晰用的是单字节中断。如果你要移植到高波特率场景这一步必须改。3.3 串口线程的优先级和栈大小怎么定app_threadx.c里创建线程时tx_thread_create的参数决定了一切#define UART_THREAD_PRIO 10 #define UART_THREAD_STACK 1024 TX_THREAD uart_thread; UCHAR uart_thread_stack[UART_THREAD_STACK]; UINT app_threadx_init(VOID) { /* 创建信号量初始计数 0表示还没有数据 */ tx_semaphore_create(uart_rx_semaphore, UART RX Sem, 0); /* 创建串口处理线程 */ tx_thread_create(uart_thread, UART Thread, uart_thread_entry, 0, uart_thread_stack, UART_THREAD_STACK, UART_THREAD_PRIO, UART_THREAD_PRIO, TX_NO_TIME_SLICE, TX_AUTO_START); return TX_SUCCESS; }优先级数字越小优先级越高。ThreadX 默认 0 是最高但通常留给系统时钟或关键中断处理线程。串口线程设 10 左右比应用层逻辑线程高比系统线程低。栈大小 1024 字节对于只做数据搬运和简单解析的线程够用但如果你在串口线程里调sprintf或者浮点运算栈要加到 2048 以上。我踩过一次坑栈溢出后线程直接跑飞现象是串口收几包数据后系统死机调试器看栈指针已经越界。3.4 线程入口里的接收循环写法void uart_thread_entry(ULONG thread_input) { UINT status; uint8_t local_buf[64]; uint16_t idx 0; while (1) { /* 等待信号量超时设成 TX_WAIT_FOREVER 表示一直等 */ status tx_semaphore_get(uart_rx_semaphore, TX_WAIT_FOREVER); if (status TX_SUCCESS) { /* 从 HAL 接收缓冲区取数据这里假设 rx_byte 是全局变量 */ local_buf[idx] rx_byte; /* 简单帧头帧尾判断实际项目按协议改 */ if (idx sizeof(local_buf)) { idx 0; /* 防溢出实际应该做环形缓冲 */ } /* 示例收到换行符就回显 */ if (rx_byte \n) { HAL_UART_Transmit(huart1, local_buf, idx, 100); idx 0; } } } }tx_semaphore_get的第二个参数是等待时间TX_WAIT_FOREVER表示线程挂起直到有信号量。如果你希望线程周期性做别的事可以设成具体 tick 数超时后走 else 分支。注意HAL_UART_Transmit是阻塞发送在串口线程里调用会占着 CPU 等发送完成。更好的做法是用HAL_UART_Transmit_IT或者 DMA 发送发送完成再释放另一个信号量。这份工程为了简洁用了阻塞发送实际产品里要改。4. 避坑排查串口在 ThreadX 下丢数据、死机的五个原因4.1 现象中断进不去信号量永远不释放原因通常是 NVIC 优先级分组和 ThreadX 移植层不匹配。STM32 的 NVIC 优先级分组设成 4 位抢占优先级但 ThreadX 的tx_initialize_low_level.s里可能按默认分组配置了PENDSV和SYSTICK优先级。如果串口中断的抢占优先级数值大于内核可屏蔽阈值中断里调tx_semaphore_put会触发tx_thread_system_return断言。解决在tx_user.h或移植层找到TX_PORT_IRQ_PRIORITY定义确保串口中断优先级数值小于这个值。STM32 里数值越小优先级越高所以串口中断优先级要设成比阈值更小的数。4.2 现象收几包数据后系统死机调试器显示硬件错误栈溢出。串口线程栈设了 512 字节但线程里调了printf或者局部数组开了 256 字节加上 ThreadX 上下文保存的开销直接爆栈。ThreadX 有栈检查机制但需要开启TX_ENABLE_STACK_CHECKING并在创建线程时传TX_STACK_CHECK标志。解决把栈加到 1024 或 2048用tx_thread_stack_error_notify注册回调栈出错时能抓到是哪个线程。4.3 现象数据偶尔丢一两个字节单字节中断方式下如果串口线程优先级不够高或者中断被更高优先级中断打断太久HAL 的接收寄存器可能被覆盖。特别是系统里有 DMA 传输或者 Flash 操作时总线仲裁会让 CPU 取指变慢。解决改用 DMA 接收 空闲中断或者把串口线程优先级提到比 Flash 操作线程高。另一个办法是在中断里直接把数据存进一个全局环形缓冲线程从环形缓冲取不依赖 HAL 的接收完成回调。4.4 现象发送正常接收完全没反应检查HAL_UART_Receive_IT有没有在初始化后调用。CubeMX 生成的代码只初始化 UART不会自动挂接收。你必须在main里 ThreadX 启动前或者串口线程第一次运行时调用一次HAL_UART_Receive_IT(huart1, rx_byte, 1)。漏了这一步中断永远不会触发。另一个可能是 GPIO 配置。UART 的 RX 引脚在 CubeMX 里要设成Alternate Function模式并且上下拉根据你的硬件定。如果对端是推挽输出STM32 这边设成浮空输入通常没问题如果对端是开漏这边要上拉。4.5 现象ThreadX 跑起来后HAL_Delay 卡死HAL_Delay依赖SysTick中断但 ThreadX 启动后会接管SysTick作为系统节拍。如果你在 ThreadX 启动后还调HAL_Delay它等的 tick 可能永远等不到因为SysTick的中断优先级被 ThreadX 改了或者HAL_IncTick不再被调用。解决ThreadX 下用tx_thread_sleep替代HAL_Delay。tx_thread_sleep(10)表示休眠 10 个系统节拍如果节拍是 1ms就是 10ms。不要在中断里调tx_thread_sleep中断里只能用tx_thread_sleep的非阻塞版本或者直接操作硬件定时器。5. 进阶用 DMA 空闲中断把串口线程的 CPU 占用压到 1% 以下单字节中断在 115200 波特率下每 87 微秒进一次中断CPU 有相当一部分时间花在保存/恢复上下文上。如果你把波特率提到 921600单字节中断基本不可行。我后来把这份工程的接收部分改成了 DMA 空闲中断串口线程的 CPU 占用从 8% 降到了 0.3%。思路是这样DMA 循环模式接收串口空闲中断触发时计算 DMA 当前写指针和上次读指针的差值就知道这一帧收了多少字节。然后释放信号量线程去 DMA 缓冲区取数据。/* 定义 DMA 接收缓冲区大小按最大帧长 * 2 来定 */ #define UART_DMA_BUF_SIZE 256 uint8_t uart_dma_buf[UART_DMA_BUF_SIZE]; volatile uint16_t uart_dma_last_pos 0; /* 空闲中断回调HAL 库在检测到总线空闲后调用 */ void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { /* Size 是本次接收的总长度HAL 已经算好了 */ uart_dma_last_pos Size; tx_semaphore_put(uart_rx_semaphore); /* 重新启动 DMA 接收准备下一帧 */ HAL_UARTEx_ReceiveToIdle_DMA(huart1, uart_dma_buf, UART_DMA_BUF_SIZE); } }HAL_UARTEx_ReceiveToIdle_DMA是 STM32 HAL 库提供的组合 API同时使能 DMA 和空闲中断。回调里的Size参数是本次接收的字节数不用自己算指针差值。注意 DMA 要配成循环模式还是普通模式普通模式下每次接收完要重新启动循环模式下 DMA 自己回绕但空闲中断里拿到的Size是累计值需要自己维护读指针。我一般用普通模式 每次重启逻辑简单不容易出现指针回绕的玄学问题。重启 DMA 的开销在微秒级对 921600 波特率来说可以忽略。线程那边改成从uart_dma_buf里按uart_dma_last_pos取数据void uart_thread_entry(ULONG thread_input) { UINT status; uint16_t len; while (1) { status tx_semaphore_get(uart_rx_semaphore, TX_WAIT_FOREVER); if (status TX_SUCCESS) { len uart_dma_last_pos; if (len 0 len UART_DMA_BUF_SIZE) { /* 处理 uart_dma_buf 里的 len 个字节 */ process_uart_frame(uart_dma_buf, len); } } } }这里有个竞争点线程读uart_dma_buf的时候DMA 可能正在往同一个缓冲区写下一帧。解决办法是双缓冲——DMA 写 buf0 的时候线程处理 buf1空闲中断里切换。或者用__disable_irq()短暂关中断拷贝数据但关中断时间要控制在几微秒内。从那以后我每次移植 ThreadX 串口驱动都强制走一遍「DMA 空闲中断 双缓冲」的模板再也没遇到过丢数据。希望帮到你。本文还有配套的精品资源点击获取