简介:本资源面向STM32嵌入式开发者与ThreadX实时操作系统学习者,聚焦串口DMA通信这一常见工程场景,解决在ThreadX下如何用队列与信号量协调串口收发、避免阻塞与数据丢失的问题。压缩包共约2000个文件,以905个C源文件、345个头文件为核心,配合225个D依赖文件、225个O目标文件、214个CRF交叉引用文件及146个ICF链接脚本,另有S汇编、A静态库、LIB库文件与TXT说明等,完整保留工程编译与调试痕迹,整体约15.36MB。资源基于STM32CubeMX生成,含IOC配置、UVProjx工程、HEX与AXF固件及MAP映射文件,可直接复现DMA+队列+信号量的串口通信框架。已有274人学习下载,适合希望掌握ThreadX任务间通信与串口高效收发的初中级工程师参考。
1. 串口收发还能这么玩:一份 ThreadX + STM32CubeMX 的 UART 工程拆解
很多人第一次在 STM32 上跑 RTOS,都是从点灯和串口打印开始的。裸机轮询串口谁都会写,但一旦把 ThreadX 拉进来,UART 的中断、信号量、消息队列怎么配合,就成了不少人的第一道坎。这份UART_ThreadX.7z就是冲着这个场景来的——它把 STM32CubeMX 生成的 HAL 底层和 ThreadX 的线程调度缝在一起,做成了一个能直接编译、能跑、能改的串口收发工程。适合两类人:一类是刚接触 ThreadX、想找个最小可运行例子摸清 API 的;另一类是已经在用 CubeMX 做项目,想把裸机串口升级成 RTOS 任务模型的。下面我按自己拆包的顺序,把这份工程从目录结构到核心代码捋一遍。
2. 工程结构与技术选型:CubeMX 生成骨架,ThreadX 接管调度
2.1 目录布局与文件职责
拿到压缩包解压后,目录基本是 CubeMX 的标准产物加上 ThreadX 的源码目录。我先把关键路径列一下,方便你对照自己的工程:
UART_ThreadX/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── app_threadx.h // ThreadX 应用层声明 │ │ └── stm32xxxx_hal_conf.h │ └── Src/ │ ├── main.c // 时钟、外设初始化入口 │ ├── app_threadx.c // 线程创建与业务逻辑 │ ├── stm32xxxx_it.c // 中断服务函数 │ └── stm32xxxx_hal_msp.c // 外设底层初始化 ├── Middlewares/ │ └── ST/ │ └── threadx/ // ThreadX 内核源码 ├── Drivers/ │ ├── STM32xxxx_HAL_Driver/ │ └── CMSIS/ └── UART_ThreadX.ioc // CubeMX 工程文件这个布局的价值在于:Core/Src/app_threadx.c是你唯一需要大改的文件,其余都是 CubeMX 自动生成或内核源码。我一般会把业务线程全部塞进app_threadx.c,这样重新生成代码时不会被覆盖。Middlewares/ST/threadx里是内核本体,除非你要裁剪功能,否则不用动。
2.2 为什么选 ThreadX 而不是裸机
裸机串口收发的典型写法是:中断里收一个字节存进缓冲区,主循环里轮询标志位再处理。数据量小的时候没问题,但一旦要同时处理多条指令、还要做超时重传,主循环就会变成一堆if-else嵌套。ThreadX 在这里解决的是「解耦」——中断只负责把数据丢进消息队列,解析线程阻塞等队列,两者互不干扰。
这份工程的技术选型是:STM32CubeMX 负责时钟树、GPIO、UART 外设和 NVIC 的初始化,ThreadX 负责线程调度和线程间通信。CubeMX 里勾选 ThreadX 中间件后,它会自动生成tx_application_define的调用入口,你只需要在这个入口里创建线程和队列。常见做法是把 UART 接收中断的优先级设得比TX_MAX_PRIORITIES对应的系统节拍中断低,否则中断里调用 ThreadX API 会触发断言。
2.3 CubeMX 里的关键配置项
打开.ioc文件,有几个配置直接决定工程能不能跑起来:
| 配置项 | 位置 | 建议值 | 说明 |
|---|---|---|---|
| ThreadX 时基 | Middleware → ThreadX | SysTick 或 TIM | 用 SysTick 时注意别和 HAL 的HAL_Delay冲突 |
| UART 全局中断 | NVIC Settings | 使能 | 接收必须开中断 |
| UART 中断优先级 | NVIC | 5 或更低 | 必须低于 ThreadX 内核中断优先级 |
| 堆栈大小 | ThreadX → Memory | 按线程数调整 | 默认值偏小,多线程要加 |
我踩过一次坑:CubeMX 默认把 UART 中断优先级设成 0,结果一进中断调用tx_queue_send就死机。后来把优先级改成 5,问题消失。这个优先级关系是 ThreadX 移植里最容易翻车的地方。
3. 核心代码拆解:从 UART 中断到线程收发的完整链路
3.1 线程创建与消息队列初始化
app_threadx.c里的tx_application_define是整个应用的入口,CubeMX 生成的main.c会在内核启动后调用它。下面是我从工程里摘出来的核心初始化代码:
/* app_threadx.c */ #include "tx_api.h" #include "app_threadx.h" TX_THREAD uart_rx_thread; TX_THREAD uart_tx_thread; TX_QUEUE uart_rx_queue; /* 接收线程栈,单位是 ULONG,4 字节 */ ULONG uart_rx_thread_stack[512]; ULONG uart_tx_thread_stack[512]; /* 队列存储区,能存 16 个消息,每个消息 1 字节 */ ULONG uart_rx_queue_mem[16]; VOID tx_application_define(VOID *first_unused_memory) { /* 创建接收队列:消息大小 1 字节,容量 16 */ tx_queue_create(&uart_rx_queue, "UART RX Queue", TX_1_ULONG, uart_rx_queue_mem, sizeof(uart_rx_queue_mem)); /* 创建接收线程,优先级 10 */ tx_thread_create(&uart_rx_thread, "UART RX Thread", uart_rx_thread_entry, 0, uart_rx_thread_stack, sizeof(uart_rx_thread_stack), 10, 10, TX_NO_TIME_SLICE, TX_AUTO_START); /* 创建发送线程,优先级 11,比接收低 */ tx_thread_create(&uart_tx_thread, "UART TX Thread", uart_tx_thread_entry, 0, uart_tx_thread_stack, sizeof(uart_tx_thread_stack), 11, 11, TX_NO_TIME_SLICE, TX_AUTO_START); }这段代码做了三件事:建队列、建接收线程、建发送线程。参数说明几个关键点:TX_1_ULONG表示每个消息占一个 ULONG,虽然我们只传一个字节,但 ThreadX 队列最小单位就是 ULONG;优先级数字越小越高,接收线程设 10、发送设 11,保证收数据比发数据优先响应;TX_AUTO_START让线程创建后立即就绪,不用手动调tx_thread_resume。
栈大小 512 个 ULONG 就是 2KB,对于只做串口收发的线程够用了。如果你在接收线程里还要做协议解析、调sprintf,建议加到 1024,否则栈溢出会直接跑飞,而且 ThreadX 默认不检测栈溢出,查起来很痛苦。
3.2 UART 中断回调:把字节丢进队列
HAL 库的接收中断回调是HAL_UART_RxCpltCallback,工程里在这个回调里把收到的字节发给队列。代码大致是这样:
/* stm32xxxx_it.c 或 main.c 中 */ uint8_t rx_byte; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { /* 把收到的字节发送到队列,超时设为 0 表示不等待 */ tx_queue_send(&uart_rx_queue, &rx_byte, TX_NO_WAIT); /* 重新开启接收中断,准备收下一个字节 */ HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } }这里有两个细节值得说。第一,tx_queue_send的第三个参数用TX_NO_WAIT,因为中断里不能阻塞,队列满了就直接丢弃——如果你不能接受丢数据,得把队列容量加大或者用环形缓冲区。第二,每次回调结束必须重新调HAL_UART_Receive_IT,否则只能收到一个字节就停了,这是 HAL 库中断接收的固定套路,新手经常忘。
中断优先级方面,USART1_IRQn的抢占优先级必须低于TX_TIMER_PROCESS_INTERN对应的优先级,否则tx_queue_send内部的临界区操作会出问题。CubeMX 里 ThreadX 默认用 SysTick,优先级是 0,所以 UART 中断设成 5 以上就安全。
3.3 接收线程:阻塞等队列,处理完整帧
接收线程的主体是一个死循环,阻塞在tx_queue_receive上,收到字节后拼帧:
/* app_threadx.c */ void uart_rx_thread_entry(ULONG thread_input) { ULONG received; uint8_t frame[64]; uint8_t idx = 0; while (1) { /* 阻塞等待队列消息,TX_WAIT_FOREVER 表示一直等 */ if (tx_queue_receive(&uart_rx_queue, &received, TX_WAIT_FOREVER) == TX_SUCCESS) { uint8_t ch = (uint8_t)received; /* 以换行符作为帧结束标志 */ if (ch == '\n' || idx >= sizeof(frame) - 1) { frame[idx] = '\0'; /* 这里可以调用解析函数,或转发给其他线程 */ process_frame(frame, idx); idx = 0; } else { frame[idx++] = ch; } } } }tx_queue_receive的第二个参数是输出缓冲区,ThreadX 会把队列里的 ULONG 拷进去。第三个参数TX_WAIT_FOREVER让线程在没有数据时挂起,不占 CPU,这正是 RTOS 相比裸机轮询的优势。拼帧逻辑用换行符做分隔,实际项目里你可以换成帧头帧尾或者长度字段。
process_frame是留给二次开发的钩子,工程里可能是个空函数或者简单回显。我一般会在这里做命令匹配,匹配到之后通过另一个队列发给发送线程,而不是直接在接收线程里调HAL_UART_Transmit,因为发送可能阻塞,会拖慢接收节奏。
3.4 发送线程与互斥保护
发送线程从另一个队列取数据,调用 HAL 发送:
void uart_tx_thread_entry(ULONG thread_input) { ULONG msg; while (1) { if (tx_queue_receive(&uart_tx_queue, &msg, TX_WAIT_FOREVER) == TX_SUCCESS) { uint8_t *data = (uint8_t *)msg; /* 阻塞式发送,超时 100ms */ HAL_UART_Transmit(&huart1, data, strlen((char *)data), 100); } } }如果多个线程都要用串口发数据,就得加互斥量。ThreadX 里用tx_mutex_create和tx_mutex_get/put,在HAL_UART_Transmit前后包一层。不加的话,两个线程同时调 HAL 发送,输出会交错成乱码,这种问题在调试时特别玄学,因为单线程测试永远正常。
4. 编译、烧录与运行验证:从零把工程跑起来
4.1 工具链准备与编译
这份工程大概率是 STM32CubeIDE 或者 Keil MDK 的工程。用 CubeIDE 的话,直接File → Open Projects from File System选解压目录,它会识别.project文件。用 Keil 的话打开.uvprojx。编译前确认两点:一是 ThreadX 的源码路径已经加到包含目录里,CubeMX 生成时一般会自动配好;二是tx_user.h里的TX_MAX_PRIORITIES至少是 32,默认值通常够用。
编译命令如果用命令行:
# CubeIDE 的 headless 编译,路径按实际改 cd UART_ThreadX/Debug make -j4 all编译报错最常见的是tx_api.h找不到,说明 Middlewares 路径没加进 include。CubeMX 重新生成代码时偶尔会丢配置,手动在项目属性里补一下Middlewares/ST/threadx/common/inc和Middlewares/ST/threadx/ports/cortex_m4/gnu/inc就行。
4.2 烧录与串口验证
烧录用 ST-Link 或者 J-Link,CubeIDE 里直接点 Run。验证步骤:
- 用 USB 转串口模块接开发板的 USART1,TX 接 RX、RX 接 TX,共地。
- 打开串口助手,波特率设成工程里配置的值(常见 115200,8N1)。
- 复位开发板,应该能看到启动信息或者回显。
- 发送一行以换行结尾的文本,观察是否原样返回或者有解析结果。
如果没有任何输出,先查时钟配置——CubeMX 里外部晶振频率和实际板子不一致是最常见的原因,导致波特率偏差太大,串口助手收到乱码或者收不到。用示波器量一下 TX 引脚,如果有波形但内容是乱的,基本就是时钟问题。
4.3 用调试器看线程状态
ThreadX 有个很实用的调试功能:在 IDE 的 watch 窗口里加_tx_thread_current_ptr,能看到当前运行的线程。更完整的方式是调tx_thread_info_get打印所有线程状态。我在排查线程卡死时,会临时加一段代码遍历线程控制块,看哪个线程处于TX_SUSPENDED或者栈指针异常。
如果工程带了tx_execution_profile相关的宏,还能统计每个线程的 CPU 占用。不过这份最小工程可能没开,需要自己在tx_user.h里定义TX_EXECUTION_PROFILE_ENABLE。
5. 避坑与常见问题:那些让我熬夜的串口 RTOS 坑
5.1 中断里调用阻塞 API 导致死机
现象:串口一收到数据就死机,调试器停在tx_queue_send内部或者 HardFault。原因:中断优先级配置错误,UART 中断优先级高于 ThreadX 内核中断,导致在中断里调用 ThreadX API 时无法正确进入临界区。解决:在 CubeMX 的 NVIC 配置里,把 UART 中断抢占优先级设成比 ThreadX 系统节拍中断低。ThreadX 用 SysTick 时优先级是 0,UART 设 5 或更高。改完重新生成代码。
5.2 队列消息大小理解错导致数据错乱
现象:发送一个字节,接收线程收到的值不对,或者多个字节粘在一起。原因:tx_queue_create的消息大小参数用了TX_1_ULONG,但发送时传的是字节指针,接收时按 ULONG 读,大小端和类型转换出问题。解决:统一用 ULONG 变量中转。发送时ULONG temp = ch; tx_queue_send(&queue, &temp, ...),接收时tx_queue_receive(&queue, &received, ...)再强转回 uint8_t。别直接传 uint8_t 指针。
5.3 栈溢出导致随机跑飞
现象:程序运行一段时间后莫名重启,或者某个线程再也不执行。原因:线程栈给太小,接收线程里调用了sprintf、strtok这类吃栈的函数。解决:把栈数组从 512 加到 1024 甚至 2048。ThreadX 默认不检测栈溢出,可以开TX_ENABLE_STACK_CHECKING,在tx_user.h里定义,内核会在切换线程时检查栈魔数。
5.4 HAL_UART_Receive_IT 忘记重开
现象:串口只能收到第一个字节,之后再也收不到。原因:HAL_UART_RxCpltCallback里没有重新调用HAL_UART_Receive_IT,HAL 库的中断接收是一次性的。解决:在回调末尾加上重新开启的调用。或者改用HAL_UART_Receive_IT的 DMA 版本,配合空闲中断做不定长接收,但那就复杂一些了。
5.5 CubeMX 重新生成代码覆盖业务逻辑
现象:在 CubeMX 里改了个引脚配置,重新生成后自己写的线程代码没了。原因:业务代码写在了 CubeMX 管理的main.c的USER CODE区间之外。解决:所有自定义代码放在/* USER CODE BEGIN */和/* USER CODE END */之间,或者单独建app_threadx.c这种不被 CubeMX 管理的文件。我习惯后者,干净。
6. 二次开发改造:把最小工程变成能用的串口协议栈
这份工程跑通之后,离实际项目还有距离。我一般会做三件事:加环形缓冲区、加协议解析层、加超时重传。
先说环形缓冲。ThreadX 队列容量固定,突发大量数据时容易丢。改造方式是在中断里把字节写进一个无锁环形缓冲区,接收线程从环形缓冲区批量取数据。环形缓冲区的读写指针用volatile修饰,中断里只写、线程里只读,单生产者单消费者模型不需要加锁。代码骨架:
typedef struct { uint8_t buf[256]; volatile uint16_t head; volatile uint16_t tail; } ring_buf_t; /* 中断里调用 */ void ring_buf_put(ring_buf_t *rb, uint8_t data) { uint16_t next = (rb->head + 1) % sizeof(rb->buf); if (next != rb->tail) { /* 队列未满 */ rb->buf[rb->head] = data; rb->head = next; } }接收线程改成从环形缓冲区读,读到完整帧再处理。这样中断执行时间极短,不会因为队列满而丢数据。
第二件事是协议解析层。把process_frame拆成独立的protocol.c,用状态机解析帧头、长度、载荷、校验。状态机的好处是不依赖固定分隔符,能处理二进制协议。我通常定义一个parse_state枚举,在接收线程里逐字节喂给状态机,解析出完整帧后通过回调通知业务层。
第三件事是超时重传。发送线程发完一帧后启动一个软件定时器(ThreadX 的tx_timer_create),如果在超时时间内没收到 ACK,就重发。ACK 的匹配用序列号,接收线程解析到 ACK 帧后通过事件标志组tx_event_flags_set通知发送线程。这套机制在工业串口通信里很常见,能显著提高可靠性。
验证改造是否成功,我有个笨办法但很有效:写一个 Python 脚本,用 pyserial 以随机间隔、随机长度往串口灌数据,同时校验返回内容。跑一晚上不丢帧、不乱序,基本就稳了。脚本里加个统计,记录发送数和接收数,对不上就打印差异。
从那以后我每次拿到新的 RTOS 串口工程,都强制先跑一遍「中断优先级检查 + 栈大小翻倍 + 环形缓冲区压测」这三步,再开始写业务。希望这份拆解能帮你少走点弯路。
本文还有配套的精品资源,点击获取