☰
STM32CubeMX 配置 ThreadX 与 UART 线程收发避坑指南
2026/10/26 18:51:10 网站建设 项目流程

简介:面向嵌入式开发者的ThreadX串口通信工程包,专注STM32平台在ThreadX实时操作系统下用DMA完成UART收发,适合已有裸机串口基础、准备迁移到RTOS并想弄清队列与信号量协同调度的中级开发者。包内约2000个文件,以905个C源文件和345个头文件为主体,另有启动汇编、分散加载与链接脚本、工程配置、静态库及Cortex-M系列数学库等配套文件,压缩包约15.36MB,用STM32CubeMX和Keil打开即可复现实验。工程示范了中断与DMA模式下的数据搬运、用消息队列传递接收数据、以信号量同步收发流程,可作为产品级串口框架的参考模板。目前已有275人学习下载,便于对照源码理解内核对象的使用边界与常见调试思路。

1. 从一份 UART_ThreadX.7z 说起:为什么 CubeMX 里的 ThreadX 一装就翻车

解压一份带 RTOS 的 STM32CubeMX 工程,双击 .ioc 打开,最常见的结果是弹窗提示找不到中间件;就算打开了,点 Generate Code 之后编译器也能甩出几十行undefined symbol _tx_initialize_low_level。这类失败跟业务代码没什么关系,问题基本都出在三个地方:X-CUBE-AZRTOS 扩展包没装全、Toolchain 选的和 ports 汇编对不上、SysTick 被 ThreadX 拿走之后 HAL 的时基没人接。ThreadX 在 STM32CubeMX 里不是拷几个 .c 进来就能跑的东西,它是一整套扩展包加内核汇编端口再加时基重定向。这份 UART_ThreadX 工程留下的就是一份已经把这些坑填平的参照:串口收发跑在线程里、打印不阻塞主循环、两套时基各归各位。适合手上已经有 Cortex-M 板子、想用 ThreadX 做串口队列收发的人,从零照着搭一遍最省事。

2. 版本对齐:STM32CubeMX 6.x、X-CUBE-AZRTOS 与 ThreadX 内核的三方搭配

装 ThreadX 这事,第一件要做的不是点安装,而是把三个东西的版本关系捋清楚:CubeMX 本身、对应芯片系列的固件包、以及 X-CUBE-AZRTOS 扩展包。这三者任意一个超前或落后,症状都不一样,而且都不会直接告诉你"版本不匹配",只会用编译错误或者灰掉的选项让你自己猜。

2.1 三个组件各自的职责与不匹配时的症状

CubeMX 负责生成工程骨架和中间件配置,固件包提供 HAL 和 CMSIS 那套头文件,X-CUBE-AZRTOS 则把 ThreadX 内核源码、端口汇编、以及 CubeMX 的配置界面一起带进来。我一般会先对照下表把本地环境摸一遍,再决定装哪一版。

组件作用在哪确认不匹配时的典型症状
STM32CubeMX 6.x生成工程、配置中间件Help → About.ioc 打不开,或中间件分类整块灰掉
STM32Cube FW_xx 固件包HAL 驱动、CMSIS、启动文件Manage embedded software packages编译报 HAL 宏重复定义、外设句柄类型对不上
X-CUBE-AZRTOS-xxThreadX 内核 + ports + 配置界面同上,厂商分类下Middleware 里找不到 ThreadX,或链接报汇编符号缺失
Toolchain/IDE决定用哪一份 ports 汇编Project Manager → Toolchain/IDE.s 文件报语法错,_tx_initialize_low_level 找不到

版本策略上我一向保守:不追最新,选一个本地包管理器里能同时看到、并且和你的固件包同期的组合,然后整份工程锁死。中途升级扩展包是血泪经验——CubeMX 允许同一个扩展包多版本共存,升级之后旧工程默认指向的版本号可能悄悄变了,接口没变但端口文件换了,编译能过、跑起来节拍不对,这种问题最难查。

一份工程到手,先解压再说。压缩包里的路径结构本身就是要检查的第一项:

# 先看清单,确认 Middlewares 和 Drivers 都在,不是只打了个 Core 目录 7z l UART_ThreadX.7z | head -40 # 解压到纯英文、无空格的目录,CubeMX 对中文路径和长路径的兼容一直不稳 7z x UART_ThreadX.7z -o./uart_threadx

第一条命令只是列清单,不做任何写入,用它判断这份工程是不是完整工程——正常的 CubeMX 工程至少要有.ioc、Core/、Drivers/、Middlewares/四块,缺任何一块都说明打包时漏了,别急着往下走。第二条才是真正解压,输出目录特意避开桌面和中文路径,原因是 CubeMX 生成代码时会把路径拼进 Makefile 和工程文件里,带空格或中文的路径在链接阶段容易出莫名其妙的路径截断。

2.2 扩展包的两种装法:在线安装与本地导入

在线安装是首选。打开 Help → Manage Embedded Software Packages,展开厂商分类,找到对应芯片系列的 X-CUBE-AZRTOS 包,点 Install,等进度条走完。装完建议重启一次 CubeMX,因为中间件列表是启动时扫描的,不重启经常看不到新装的 ThreadX。

离线导入用在没网或者版本被下架的情况。同一个界面里有个 From Local 的入口,选中扩展包 zip 即可。这里有个必须注意的点:

注意:本地导入的 zip 不要事先手动解压再指过去,CubeMX 要的是原始压缩包。已经解压过的目录它认不出来,会一直提示包格式错误。

装完还没完,回到工程里检查扩展包是否真的生效。一个可靠的判断方法是看.ioc文件里有没有对应的中间件条目:

# 打开 .ioc,搜中间件名,搜得到才说明这份工程和当前装的包对得上 grep -i "threadx\|azrtos" uart_threadx/*.ioc | head -20

grep匹配的是.ioc这种纯文本配置文件,里面记录了 MxCube 版本、器件型号、以及每个中间件的使能状态。如果什么都没搜到,说明这份工程生成时压根没勾 ThreadX,或者勾了但用的是另一套包名,那就别在代码里找了,问题在配置层。

2.3 解压后先看四个地方,判断能不能直接生成

我把每次拿到陌生工程的开场检查固定成四步,按顺序看,任何一步不对就停在那里先解决:

  • .ioc头部的 MxCube.Version 和 ProjectManager.DeviceId,确认版本和芯片型号跟你本地装的对得上。
  • Middlewares/Third_Party/ThreadX/目录是否存在,里面应该有common/和ports/。
  • Middlewares/Third_Party/ThreadX/ports/cortex_m4/下有没有你当前工具链对应的子目录。
  • Core/Src/tx_initialize_low_level.S(GCC)或.s(MDK/IAR)是否存在,这个文件是内核启动的汇编端口。

第三步最容易被忽略。ThreadX 的上下文切换、节拍中断入口全写在这份汇编里,不同工具链的语法完全不一样。GCC 用.S大写后缀走预处理器,MDK 用 armasm 语法,两份文件不通用。工程里只有一份、而你用的又是另一种工具链,链接阶段必然报符号缺失。补救办法很简单:在 CubeMX 里把 Toolchain/IDE 切到匹配的那个,重新 Generate Code,端口文件会按需重新生成。

3. 内核配置:在 STM32CubeMX 里把 ThreadX 的时基和线程跑起来

扩展包装好、工程能打开,接下来才是真正动配置。这一章的三件事按顺序做:先抢时基、再使能内核、最后建线程。顺序颠倒的话,生成出来的代码能编译但跑不起来,而且现象很迷惑。

3.1 时基重定向:SysTick 交给 ThreadX,HAL 换一个基本定时器

这是整个安装过程中最关键的一步,也是最多人跳过的一步。ThreadX 的 Cortex-M 端口把 SysTick 当作系统节拍源,PendSV 做上下文切换。HAL 库的HAL_Delay和HAL_GetTick同样依赖 SysTick 中断里累加的uwTick。两者抢同一个中断,后果分两种:轻则HAL_Delay短延时看着正常、超过几百毫秒就永远不出来;重则 ThreadX 节拍不稳,tx_thread_sleep睡 100 tick 实际睡了两倍。

操作路径是 System Core → SYS → Timebase Source,从 SysTick 改成 TIM6(任意一个没被占用的基本定时器都行)。改完 CubeMX 会自动生成一个独立的时基文件:

/* stm32xxxx_hal_timebase_tim.c,CubeMX 在时基切到 TIM 后自动生成 * 关键点:中断里只做 uwTick 累加,HAL_Delay/HAL_GetTick 由此恢复可用 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM6) { HAL_IncTick(); /* 1ms 一次的 HAL 节拍,与 ThreadX 的 SysTick 完全隔离 */ } }

这段回调的触发周期由 TIM6 的预分频和重装载值决定,CubeMX 会自动按 1ms 算好,不需要手改。真正要盯的是 TIM6 的中断优先级——我一般直接给到最低(数值最大那档),原因是它只做一件事:累加一个计数。优先级给高了,反而可能在 ThreadX 做上下文切换的关键窗口里插进来,虽然多数时候不会立刻出事,但出的时候一定查不出来。生成代码后记得确认MX_TIM6_Init()在main()里的调用位置在MX_ThreadX_Init()之前,顺序反了的话,ThreadX 起来的时候HAL_GetTick还返回 0。

3.2 中间件里勾选 ThreadX 并调内核参数

Middleware and Software Packs → Azure RTOS ThreadX → 勾选使能,下面会展开一页参数。需要动的没几个,但每个都有讲究:

参数常见取值说明
TX_TIMER_TICKS_PER_SECOND1000每秒节拍数,与上面 1ms 的 HAL 时基对齐最省心
TX_MAX_PRIORITIES32优先级数量,0 最高,数值越大优先级越低
Memory Pool Size按线程数给线程控制块、队列、信号量都从这里分配
TX_ENABLE_STACK_CHECKING开启线程栈被踩穿时能回调报错,而不是直接 HardFault
TX_DISABLE_PREEMPTION关闭除非确定要协作式调度,否则别开

TX_TIMER_TICKS_PER_SECOND和 HAL 那边保持一致不是为了好看。两者都按 1ms 走,你才能直接拿tx_time_get()和HAL_GetTick()对比来验证——后面验证章节会用上这一招。内存池大小是最容易给小的,一份工程里如果建了五六个线程、再加两个队列,池子给到几 KB 才算稳,给不够的表现是tx_thread_create返回TX_POOL_ERROR,而这个返回值很多人根本没接。

设置完点 Generate Code,CubeMX 会生成Core/Src/app_threadx.c和Core/Inc/tx_user.h。前者是留给你填线程的空壳,里面只有一个空的tx_application_define,后者是内核裁剪开关。这两个文件生成之后就不要再用 CubeMX 覆盖了——重新 Generate 会按配置重写,手写的东西会丢,我一般在第一次生成后立刻把它们纳入版本管理。

3.3 建线程:栈大小和优先级的单位别照抄 FreeRTOS

从 FreeRTOS 迁过来的人在这一步几乎必翻车,因为tx_thread_create的栈参数单位是字节,而xTaskCreate的栈深度单位是字。同样写 128,一个给 128 字节、一个给 512 字节。

/* Core/Src/app_threadx.c —— CubeMX 生成后是个空壳,线程要自己往里加 */ #include "tx_api.h" #include "app_threadx.h" TX_THREAD uart_thread; TX_QUEUE uart_rx_queue; /* 栈必须静态分配或全局,别写在函数里:函数返回后栈就被回收了 */ static ULONG uart_thread_stack[1024 / sizeof(ULONG)]; VOID tx_application_define(VOID *first_unused_memory) { /* 队列的存储区由调用方提供,大小 = 消息个数 × 单条消息字节数 */ static ULONG queue_buf[16 * sizeof(uint8_t) / sizeof(ULONG)]; tx_queue_create(&uart_rx_queue, "uart_rx", 1, queue_buf, sizeof(queue_buf)); /* 1024 是字节数,不是字数;优先级 10,数字越小越优先 */ tx_thread_create(&uart_thread, "uart_thread", uart_thread_entry, 0, uart_thread_stack, sizeof(uart_thread_stack), 10, 10, TX_AUTO_START); }

逐个参数说清楚:第一个是线程控制块指针,全局变量,由内核填。第二个是名字,调试器里能看到,别省。第三个是入口函数,签名固定void f(ULONG)。第四个是传给入口的参数。第五、六个是栈起始地址和字节数。第七、八个是优先级和抢占阈值,两者给相同值表示标准抢占式调度。最后一个是启动方式,TX_AUTO_START会在内核启动后自动进入就绪态。

队列那条tx_queue_create的第三个参数是单条消息的字节数(这里 1 字节,适合串口这种逐字节流),第四个是存储区,第五个是存储区总字节数。存储区必须是ULONG对齐的静态区,用uint8_t数组在某些编译器的严格对齐检查下会出问题,所以我习惯统一声明成ULONG数组再按字节算长度。

4. 串口落地:USART1 参数、printf 重定向与线程收发闭环

内核跑起来只是有了骨架,UART 才是这套工程真正被用起来的地方。这一章要解决三件事:串口硬件参数配对、打印输出不拖慢线程、以及中断收数据到线程处理这条链路打通。

4.1 USART 参数、引脚与中断的分配原则

Connectivity → USART1 → Mode 选 Asynchronous,剩下的按下面这张表填:

配置项取值为什么这么给
Baud Rate115200通用速率,调试输出够用且不易因时钟误差丢包
Word Length8 Bits标准 8N1,绝大多数串口工具默认
ParityNone同上,开启会增加一字节开销
Stop Bits1同上
Over Sampling16采样点数多,抗时钟误差能力强于 8
USART1 global interruptEnabled接收走中断,否则线程要轮询浪费 CPU

引脚按板子来,CubeMX 会自动分配 PA9/PA10 或对应的复用脚。一个经常被忽略的点是 USART1 的时钟源——部分系列的串口挂在 APB2 上,而 APB2 的分频跟主频不一定一致,时钟树配错、波特率算出来就会偏,现象是能发出数据但对方全是乱码。稳妥做法是配完时钟后,在 CubeMX 的时钟树界面确认一下 USART1 的输入时钟值,再手算一遍分频:USARTDIV = fCK / (16 × Baud)。

中断优先级这块给个经验值:串口中断设到中等(5 左右),不要设成 0。原因不是 ThreadX 的 API 不能在 ISR 里调,而是优先级最高意味着它可能在内核做上下文切换的窗口里插进来,此时栈指针状态未稳定,ISR 里再调内核服务就有概率翻车。这个坑不常触发,但触发一次就够查半天。

4.2 printf 重定向到串口:三种做法与各自的代价

让printf打到串口,是这套工程最实用的一个功能,也是栈溢出的重灾区。工具链不同,重定向的入口函数也不同:

/* 做法一:Keil/MDK 工具链,重定向 fputc,一个字符一个字符阻塞发 */ int fputc(int ch, FILE *f) { /* 超时给足,HAL_UART_Transmit 超时返回后字符就丢了,还查不出来 */ HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }
/* 做法二:GCC 工具链(CubeIDE / Makefile),重定向 _write,支持整块输出 */ int _write(int fd, char *ptr, int len) { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; }

两种做法的差别不只是函数名。fputc是逐字节写,每次调用都进一次 HAL,一秒钟打几百行日志就直接把线程堵死;_write是整块写,printf攒够一个缓冲区才调一次,效率高得多。但整块写有个隐患:缓冲区在栈上,长度随格式化字符串变化,栈给小了会直接踩穿。

第三种做法是 DMA 加重定向,把HAL_UART_Transmit_DMA塞进_write里,线程立即返回。代价是要自己处理"上一帧还没发完又来一帧"的冲突,我一般配一个TX_SEMAPHORE,发送完成回调里tx_semaphore_put,下次发送前先tx_semaphore_get等上一帧结束。串口日志量大的项目值得这么干,量小的项目用阻塞版更省心。

提示:开了 printf 的线程,栈至少给 1KB。浮点格式化、长字符串拼接实际占用的栈比想象中大得多,给 512 字节然后打印一句带小数点的日志,十次里有三次会挂。

4.3 中断收、线程发的队列闭环

串口收数据最忌讳在中断里做协议解析。正确的分工是:中断只负责把字节搬进队列,解析和回显全放在线程里做。这样即使解析逻辑很重,也不会阻塞其他中断。

/* 接收中断回调:只做搬运,不做解析,中断里绝不能用等待型 API */ void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { static uint8_t rx_byte; if (huart->Instance == USART1) { /* TX_NO_WAIT:队列满就丢弃并返回错误,中断上下文不能等 */ tx_queue_send(&uart_rx_queue, &rx_byte, TX_NO_WAIT); HAL_UART_Receive_IT(&huart1, &rx_byte, 1); /* 重新挂接收,别忘了这句 */ } }
/* 线程侧:阻塞等队列,收到一个字节就回显一个,逻辑再重也不影响中断 */ static void uart_thread_entry(ULONG arg) { uint8_t b; for (;;) { if (tx_queue_receive(&uart_rx_queue, &b, TX_WAIT_FOREVER) == TX_SUCCESS) { HAL_UART_Transmit(&huart1, &b, 1, 100); } } }

回调里的rx_byte必须是静态的或者全局的,写成局部变量的话,HAL_UART_Receive_IT拿到的地址在函数返回后就失效了,DMA 或中断写进去的是野指针。tx_queue_send用TX_NO_WAIT是硬性要求,中断上下文里用TX_WAIT_FOREVER会让整个系统挂在这里等一个永远不会来的唤醒。

线程侧用TX_WAIT_FOREVER是合理的,因为这个线程本来就是被设计成"有数据才干活"的消费者,没数据时挂起不占 CPU。tx_queue_receive的第三个参数是等待 tick 数,TX_WAIT_FOREVER意味着无限等,队列一来数据立刻唤醒,唤醒延迟通常在几个微秒量级。

5. 排查清单:ThreadX 加 UART 组合最常见的五个翻车现场

装完之后出问题,八成集中在这五条上。我把它们按出现频率排了序,每条都按现象、原因、解决三段写,对着看比翻文档快。

5.1 编译期:符号缺失和头文件找不到

现象一:链接报undefined symbol _tx_initialize_low_level,或者 .s 文件一串语法错误。

原因是工程里的 ports 汇编和当前工具链对不上。X-CUBE-AZRTOS 扩展包内置了好几套端口,CubeMX 只会把匹配当前 Toolchain 设置的那一套拷进工程。如果你换了 IDE 但没在 Project Manager 里改 Toolchain/IDE,工程里留着的还是旧工具链的汇编,链接器自然找不到入口。解决办法是回 CubeMX 把 Toolchain/IDE 改成实际使用的那个,重新 Generate Code。改完去Middlewares/Third_Party/ThreadX/ports/cortex_m4/下面确认对应工具链目录存在,别只信生成日志。

现象二:编译报tx_api.h: No such file or directory。

扩展包装了,但工程的 include 路径没带上。CubeMX 生成时会把中间件路径写进工程的包含目录,没写进去通常是因为.ioc里 ThreadX 的使能状态和实际装的包版本不匹配。检查方式是在 CubeMX 里重新勾选一次 ThreadX,看有没有弹出"版本不兼容"的提示;没有提示就重新 Generate,有提示就说明得换扩展包版本。手工往 Makefile 或工程属性里加路径也能编过,但下次 Generate 会被覆盖,不建议这么绕。

5.2 运行期:卡死、乱码和只发一帧

现象三:程序烧进去之后卡在HAL_Delay里不出来,调试器暂停发现一直在这个函数里循环。

这是最经典的一条:SysTick 被 ThreadX 接管了,HAL 时基还指着 SysTick。表现是HAL_GetTick()返回值不再增长,所有基于它的超时判断全部失效。解决就是 3.1 节那一步,System Core → SYS → Timebase Source 改成 TIM6,重新生成。改完记得在main()里确认 TIM6 的初始化在 ThreadX 初始化之前。

现象四:串口能收到数据,但全是乱码,或者只有开头几个字符正常。

两个方向查。先查时钟:外部晶振实际频率和 CubeMX 里填的不一致,波特率算出来就偏。用逻辑分析仪量一下实际波特率最直接,没有仪器就用示波器看单个位的宽度,115200 下一位约 8.68 微秒。再查重定向:HAL_UART_Transmit返回了但最后一个字节还没从移位寄存器发完,程序就进了低功耗或者切了时钟,末尾字符就被截断。稳妥做法是发送后等 TC 标志:

/* 等发送完成标志,避免最后一个字节还在移位寄存器里就被后续操作打断 */ HAL_UART_Transmit(&huart1, (uint8_t *)buf, len, 100); while (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TC) == RESET) { }

现象五:DMA 发送只发出第一帧,后面再调就没反应了。

大概率是发送缓冲区写成了函数内的局部数组。HAL_UART_Transmit_DMA是异步的,函数返回时数据还没发完,如果缓冲区在栈上,函数一返回这块内存就被下一层调用复用了,DMA 搬出去的是一堆垃圾。改成static或者全局数组即可。另一个可能是连续调用太快,上一帧的 DMA 传输完成标志没清,HAL 直接返回HAL_BUSY。用信号量串起来最稳:发送前tx_semaphore_get,HAL_UART_TxCpltCallback里tx_semaphore_put。

6. 跑通验证:三个动作确认 ThreadX 节拍与 UART 收发都在正常工作

代码编过、烧进去、串口有输出,这只是"看起来能跑"。真正要确认的是两套时基都在走、线程确实在调度、串口链路在高负载下不丢数据。我固定用三个动作验证,缺一个都不算装完。

6.1 第一个动作:对比两个 tick,确认时基没打架

在任意一个线程里加一行打印,把两个时间源摆在一起看:

/* 放在监控线程里,每 5 秒打一行;两个值必须同步增长 */ tx_thread_sleep(5000); /* 5000 tick = 5 秒,前提是 TX_TIMER_TICKS_PER_SECOND=1000 */ printf("hal_tick=%lu tx_tick=%lu\r\n", (unsigned long)HAL_GetTick(), (unsigned long)tx_time_get());

判读方法很直接:hal_tick不动,说明 TIM6 时基没生效,回去查 SYS 配置和HAL_TIM_PeriodElapsedCallback;tx_tick不动,说明 ThreadX 节拍没起来,查tx_initialize_low_level里的 SysTick 配置。两个都在涨但差值越来越大,说明有一路被频繁打断丢 tick,通常是某个中断优先级给得太高。

6.2 第二个动作:栈水位与溢出回调

线程栈给多少合适,靠猜没用。开启TX_ENABLE_STACK_CHECKING之后注册一个错误回调,栈被踩穿时能停在现场而不是直接进 HardFault:

/* 在 tx_application_define 里注册;栈溢出时会被调用,此时线程名就是嫌疑人 */ VOID stack_error_handler(TX_THREAD *thread_ptr) { printf("[stack] overflow in %s\r\n", thread_ptr->tx_thread_name); for (;;) { } /* 停在这里,挂调试器看调用栈比事后推理快得多 */ } /* 注册语句放在线程创建之前 */ tx_thread_stack_error_notify(stack_error_handler);

这个回调的价值在于把"随机 HardFault"变成"带线程名的确定性报错"。跑了几天才挂一次的问题,靠这个能一次定位。

6.3 第三个动作:连续灌数据十分钟

最后一步是压测。用串口工具以 10ms 间隔连续发数据,跑十分钟,结束后检查三件事:

检查项期望现象不通过时优先看
回显是否完整发多少收多少,字节序一致队列深度是否够,tx_queue_send返回值是否被忽略
线程是否还在调度监控线程的 tick 打印间隔稳定是否有线程死循环占满 CPU
内存池是否耗尽tx_thread_create之外无TX_POOL_ERROR内存池大小、队列存储区是否重复申请

灌数据这个动作看着粗暴,但它能一次性暴露队列深度不够、线程优先级配反导致的饥饿、以及内存池给小了这三类问题。前两类在正常调试时几乎看不出来,因为人手敲键盘的速度根本压不满队列。

从那以后我每次拿到一份带 RTOS 的 CubeMX 工程,都会先把 Timebase Source 和 Toolchain/IDE 这两个地方翻一遍,再点 Generate,最后强制走完这三个验证动作。这套流程救过我很多次——配置本身不难,难的是错的时候不报错。希望这些能帮到你,少在时基和工具链上耗时间。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询