简介:基于STM32H743开发板的RTX5与FreeRTOS模板例程源码,面向嵌入式开发者,提供带CMSIS-RTOS V2封装层的双内核参考实现。通过统一的CMSIS-RTOS V2接口,可快速切换或对比RTX5、FreeRTOS两种内核,适合正在评估RTOS选型、学习封装层原理或进行项目移植的中高级工程师。压缩包共980个文件,以466个h头文件、372个c源文件为主,配套IAR链接配置icf、Keil工程uvprojx、启动文件s、静态库a/lib及少量脚本、HTML说明等,整体约6.63MB,内容覆盖多工具链工程模板与常见外设相关库文件,便于直接导入或裁剪。已有77人学习下载,作为STM32H743平台的多任务模板、CMSIS-RTOS V2兼容层验证以及两种RTOS对比移植的起点非常合适。此外还包括PDM滤波库等外设组件,可结合音频采集等实际场景理解RTOS接口在真实项目中的集成方式。
1. 基于stm32h743的RTX5/FreeRTOS模板:一份代码在两个内核间切换到底图什么
stm32h743开发板到手,第一件事不是点灯,是跑RTOS。这套基于stm32h743单片机开发板的模板例程源码同时带了RTX5和FreeRTOS,并且两者都用CMSIS-RTOS V2封装层,也就是说你写的线程、队列、事件标志、软件定时器全走同一套cmsis_os2.h接口,切换内核时应用层代码一行不用改。它的价值不是“能编译”,而是让你在H743上快速建立一条对照基线:这颗Cortex-M7核配哪个RTOS更贴合你的通信、控制和界面负载。适合正从裸机转到RTOS的入坑选手,也适合在FreeRTOS和RTX5之间做方案选型、想保留迁移空间的团队。
很多人把RTOS当成一个调度循环,实际用起来才发现栈、时基、中断优先级、内存位置没有一处省心。这套例程的好处是先把工程骨架、RTE组件、启动文件和典型API调用都摆好,让你先跑起来,再在跑起来的基础上对照两个内核的真实差异。
2. CMSIS-RTOS V2封装层拆开看:osThreadNew 到任务调度之间发生了什么
2.1 为什么两个内核能共用一套os开头的API
CMSIS-RTOS V2是ARM CMSIS标准里定义的一组RTOS接口,头文件是cmsis_os2.h。这组接口不大,核心就是线程、信号量、互斥锁、消息队列、事件标志、软件定时器这几个对象。RTX5是Keil MDK自带的RTOS内核,它原生就把CMSIS-RTOS V2当成自己的接口层,所以你在RTX5组件里直接看到的就是osThreadNew这些实现,没有中间翻译。
FreeRTOS这边情况不同。FreeRTOS本身没有os前缀的API,需要一层适配代码把osThreadNew翻译成xTaskCreate,把osMessageQueuePut翻译成xQueueSend。这套适配源码在Keil RTE集成的FreeRTOS组件里自带,也可以从FreeRTOS官方仓库单独拉。模板例程的工程结构通常就是:应用源码#include "cmsis_os2.h",底层实现由某个宏决定链接到RTX5还是FreeRTOS的适配层。
这个封装的真正价值在于:你的应用代码不#include "FreeRTOS.h"也不#include "rtx_os.h",只认cmsis_os2.h。将来换内核,不需要逐文件改API调用,只需要换组件、确认宏开关、检查内存配置。对产品要长期维护的团队来说,这一层抽象能省掉很多重复劳动。
2.2 优先级和时间片:RTX5和FreeRTOS在这块各玩各的
CMSIS-RTOS V2定义了一组优先级宏:osPriorityIdle等于0,osPriorityLow等于8,osPriorityBelowNormal等于16,osPriorityNormal等于24,osPriorityAboveNormal等于32,osPriorityHigh等于40,osPriorityRealtime等于48。RTX5基本就是照这套数值直接用,几乎没有额外映射。
FreeRTOS在适配层里会把CMSIS优先级数值翻译成自己的优先级数值,具体映射逻辑在cmsis_os2.c的osPriorityToFreeRTOSPriority函数里。这里容易踩一个认知误区:你把任务优先级调高,不代表它能打断别的任务。任务优先级只影响就绪队列里的排队顺序,真正能打断线程的只有中断,包括调度器自己用的SysTick和PendSV。所以不要拿任务优先级和NVIC中断优先级混着调,这是两个维度。
时间片行为也不一样。FreeRTOS对同优先级任务默认启用时间片轮转,多个任务平分CPU;RTX5默认也有时间片,但具体时长和轮转策略由RTX_Config.h里的配置决定。两个内核在“看起来一样”的优先级下,实际调度时序会差几十个tick。这正是模板例程存在的意义:同样的代码在两个内核上跑一圈,逻辑分析仪抓一下波形,差异立刻现形。
2.3 SysTick、PendSV、SVC三个异常把切换这件事干完
Cortex-M内核给RTOS切换上下文提供了三个固定的异常入口。SysTick提供周期时基,RTX5和FreeRTOS都默认把时基源挂在SysTick上,只是节拍频率的配置位置不同:RTX5在RTX_Config.h的RTX_TICK_RATE,FreeRTOS在FreeRTOSConfig.h的configTICK_RATE_HZ,默认都是1000Hz,也就是1ms一个tick。
PendSV被设计成最低优先级异常,用来做上下文切换。当一个任务被更高优先级中断打断时,调度器不会立刻切换,而是挂起PendSV,等当前中断处理完再切换。这样能避免在中断上下文里做复杂的寄存器保存。SVC则负责系统启动时从线程模式拉起第一个任务。
三个异常的优先级配置有硬性要求:PendSV和SysTick必须保持最低优先级,否则嵌套中断会把调度顺序搞乱。模板例程里这些已经配好,但如果你在stm32h743xx_it.c里自己也写了空的SysTick_Handler或PendSV_Handler,就会和RTOS的handler产生二义性,这是后面避坑章节要重点讲的。
3. 把模板例程烧进stm32h743:RTE配置、切换宏与内存选址
3.1 用Keil RTE勾选RTOS组件,别手抄移植文件
拿到模板源码,第一步不是急着改代码,而是先确认Keil MDK的Runtime Environment组件有没有勾对。打开工程后点“Manage Run-Time Environment”,在CMSIS分组下面会看到RTOS2相关的条目。选Keil RTX5和FreeRTOS组件时,二者只能勾一个,同时勾两个链接阶段必定冲突。
我一般会先清空RTE目录重新生成一次,让Keil重新拷贝源文件,因为从别的工程复制过来的FreeRTOS源码版本经常和当前KEIL版本不匹配,运行期栈溢出这种玄学问题会莫名其妙地出现。RTE生成后,工程里会出现RTE/CMSIS目录,里面放着RTOS2的头文件和实现源文件,同时自动生成一个RTE_Components.h,这里面是模板切换内核的关键。
如果你习惯用STM32CubeMX,也能生成带CMSIS-RTOS V2的FreeRTOS工程,但CubeMX对RTX5的支持非常有限。想对照两个内核,建议走Keil RTE这条路线,两边都能覆盖。
3.2 从FreeRTOS切到RTX5:一个头文件宏加一个组件勾选
RTE_Components.h是Keil自动维护的头文件,模板里切换内核的开关通常就在这里。常见的写法类似这样:
// RTE_Components.h 关键片段 #define CMSIS_device_header "stm32h743xx.h" #define RTE_CMSIS_RTOS2 // 二选一: #define RTE_CMSIS_RTOS2_FreeRTOS // #define RTE_CMSIS_RTOS2_RTX5这个宏告诉cmsis_os2.h该包含哪一家的实现。但注意:宏改了还不够,工程的组件列表里必须真正包含对应的源文件。FreeRTOS组件提供cmsis_os2.c和FreeRTOSConfig.h,RTX5组件提供rtx_lib.c和RTX_Config.h,两套源文件不能同时在编译列表里。
最稳妥的切换方式是回到Manage Run-Time Environment界面,把FreeRTOS组件取消勾选,勾上Keil RTX5,让MDK自动改宏并更新文件。手动改宏只适合理清代码逻辑,不适合作为日常切换手段。切换之后建议Clean一下工程再全量编译,避免旧的预编译产物干扰。
3.3 任务栈放哪个RAM:DTCM、AXI SRAM、普通SRAM的选择
stm32h743的内存分布和F103那种单一SRAM完全不一样,这是H743上跑RTOS最容易出问题的一层。
| 内存区域 | 地址范围(大致) | 大小 | DMA可达性 | 建议用途 |
|---|---|---|---|---|
| ITCM | 0x00000000附近 | 64KB | DMA不可达 | 放时序敏感代码 |
| DTCM | 0x20000000附近 | 128KB | DMA不可达 | 任务栈、RTOS内核数据 |
| AXI SRAM | 0x24000000附近 | 512KB | DMA可达 | 大数组、DMA缓冲、显存 |
| SRAM1/2/3 | 0x30000000附近 | 288KB | DMA可达 | 通用堆 |
| SRAM4 | 0x38000000附近 | 64KB | DMA可达 | 低功耗保留数据 |
DTCM由CPU直接访问,延迟最低,任务栈放这里跑得最快,但DMA控制器完全摸不到这块区域。如果你把一个DMA缓冲定义在DTCM,外设的中断会一直等数据,表现出来就是任务“卡死”。AXI SRAM是大块通用内存,DMA可达,适合放大数组和外设缓冲。
模板例程的启动文件默认把栈放在IRAM1,也就是DTCM区域,这对RTOS任务是好事。但如果你要加以太网、摄像头DCMI、或者串口DMA,缓冲数组必须明确放到AXI SRAM。我一般会修改分散加载文件,把RTOS的堆搬到AXI SRAM,任务栈仍然留在DTCM,这样两边都能兼顾。
还有一层必须注意:H743的D-Cache默认开启,Cache和DMA之间的数据一致性问题会在你加入外设后立刻暴露。后面避坑章节会专门讲。
3.4 printf重定向:RTOS里第一处不起眼的翻车点
裸机时代printf随便用,进了RTOS就不一样。多个任务同时调printf,标准库内部用全局状态,任务A打印到一半被任务B抢占,输出就串了。常见的解决是在重定向的fputc外面加互斥锁:
#include "cmsis_os2.h" #include <stdio.h> static osMutexId_t printf_mtx; int fputc(int ch, FILE *fp) { (void)fp; if (printf_mtx == NULL) { printf_mtx = osMutexNew(NULL); } osMutexAcquire(printf_mtx, osWaitForever); ITM_SendChar(ch); // 用SWO输出,或者换成串口发送 osMutexRelease(printf_mtx); return ch; }参数说明:osMutexNew(NULL)表示用默认属性创建互斥锁,默认不带递归和优先级继承;osWaitForever表示拿不到锁就一直等。代价是低优先级任务可能被高优先级任务的打印阻塞,一旦某个任务死锁,所有打印任务都会卡住。
另一个坑是MicroLIB。Keil模板工程经常开启MicroLIB来省代码空间,但MicroLIB的malloc和printf锁行为比较弱,在RTOS多任务环境下偶尔表现不稳定。如果模板默认开了MicroLIB,跑起来有诡异问题,先关掉试试。
4. 模板例程里最常用的四类API:参数与行为差异全说明
4.1 osThreadNew加osDelay:周期任务的正确打开方式
周期性任务是最常见的模板代码。创建线程时,osThreadAttr_t结构体决定了任务的名字、栈大小、优先级:
#include "cmsis_os2.h" #include "main.h" static const osThreadAttr_t led_attr = { .name = "led_blink", .stack_size = 1024, // 单位是字节,Cortex-M7建议不低于512,开FPU至少1024 .priority = osPriorityNormal, // 数值是24 .attr_bits = 0, }; static void led_task(void *arg) { (void)arg; for (;;) { HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_0); osDelay(500); // 单位是tick,默认1ms一个tick,即500ms } } void app_rtos_init(void) { osThreadNew(led_task, NULL, &led_attr); }逻辑说明:osThreadNew把栈地址和任务函数注册到内核,函数最后会返回一个osThreadId_t,可以用来挂起、恢复、设置优先级。这里最容易被忽略的是stack_size的单位。CMSIS-RTOS V2标准规定它是以字节为单位,但在FreeRTOS适配层内部会除以4转换成word数再传给xTaskCreate。如果你之前用惯了FreeRTOS原生API里“栈大小必须给word数”的习惯,切到CMSIS-RTOS V2后容易给小四倍。
osDelay只能在线程上下文调用,osKernelStart()之前调用会返回错误。500这个值是tick数,不是毫秒数,如果某个模板把时基调成了2ms一个tick,那500就是1秒。判断tick周期先看RTX_Config.h或FreeRTOSConfig.h里的配置,不要凭感觉。
4.2 osMessageQueuePut和Get:生产者消费者的阻塞与超时
消息队列是任务间传数据的主力。模板例程里最常见的是传感器采集任务往队列里丢数据,处理任务从队列取:
static osMessageQueueId_t sensor_queue; static const osMessageQueueAttr_t q_attr = { .name = "sensor_q", }; void producer_task(void *arg) { (void)arg; uint32_t value = 0; for (;;) { value = read_adc(); osStatus_t st = osMessageQueuePut(sensor_queue, &value, 0, 100); if (st != osOK) { // 100个tick内没放进去,说明消费者没及时取,统计丢包,别静默 } osDelay(10); } } void consumer_task(void *arg) { (void)arg; uint32_t received; for (;;) { osStatus_t st = osMessageQueueGet(sensor_queue, &received, NULL, osWaitForever); if (st == osOK) { process_value(received); } } } void app_rtos_init(void) { sensor_queue = osMessageQueueNew(8, sizeof(uint32_t), &q_attr); osThreadNew(producer_task, NULL, &producer_attr); osThreadNew(consumer_task, NULL, &consumer_attr); }参数说明:osMessageQueueNew(8, sizeof(uint32_t), &q_attr)的第一个参数是队列深度,按元素个数算,不是字节数;第二个是单个元素大小,传实际数据结构的sizeof。osMessageQueuePut的第四个参数100是超时tick数,传100表示放不进去就等100个tick,超时返回osErrorTimeout;传osWaitForever表示死等。第三个参数是消息优先级,CMSIS-RTOS V2接口里保留了这个字段,但RTX5和FreeRTOS适配层里通常直接忽略,传0即可。
两个内核都支持在中断服务函数里调用osMessageQueuePut,适配层会自动判断上下文并走FromISR版本。但中断里绝对不能传osWaitForever,中断没有阻塞的概念,等下去就是在等死。
4.3 osEventFlagsSet和Wait:事件标志的自动清除会让新手懵一下
事件标志适合做多任务同步,比如一个任务等几个条件都满足后再干活:
#define EVT_READY (1UL << 0) #define EVT_DONE (1UL << 1) static osEventFlagsId_t event_flags; void worker_task(void *arg) { (void)arg; uint32_t flags = osEventFlagsWait(event_flags, EVT_READY, osFlagsWaitAny, osWaitForever); if (flags & EVT_READY) { do_work(); osEventFlagsSet(event_flags, EVT_DONE); } } void main_task(void *arg) { (void)arg; osEventFlagsSet(event_flags, EVT_READY); osEventFlagsWait(event_flags, EVT_DONE, osFlagsWaitAny | osFlagsNoClear, 1000); }逻辑说明:osEventFlagsWait第三个参数决定等待行为和标志行为。osFlagsWaitAny表示等待的标志位中任意一个置位就返回;osFlagsNoClear表示返回时不自动清除已置位的标志。默认不带NoClear时,满足条件的标志会被自动清零。这个自动清除是新手最容易踩的点:两个任务同时等同一个事件标志,一个任务先等到并清掉了标志,另一个任务就永远等不到。
解决方式是明确要不要清标志。如果标志的消费方只有一个任务,让内核自动清除没问题;如果有多个任务要以“广播”方式接收事件,就必须在处理逻辑里自己管理标志状态,或者给每个接收任务分配不同的位。
4.4 osTimerNew软件定时器:回调里别干重活,这不是中断
软件定时器在模板例程里常用来做周期采样或超时判断:
static osTimerId_t capture_timer_id; static void timer_cb(void *arg) { // 这个回调运行在RTOS定时器线程上下文,不是中断 uint32_t *capture = (uint32_t *)arg; *capture = read_counter(); } void app_rtos_init(void) { static uint32_t counter_value = 0; static const osTimerAttr_t timer_attr = { .name = "capture_timer", }; capture_timer_id = osTimerNew(timer_cb, osTimerPeriodic, &counter_value, &timer_attr); osTimerStart(capture_timer_id, 100); }参数说明:osTimerNew第二个参数是定时器类型,osTimerPeriodic表示周期性执行,osTimerOneShot表示只执行一次。第四个参数是定时器属性。osTimerStart的第二个参数100是启动延迟加周期的tick数,单位同样是tick,不是毫秒。
最关键的一点:回调不是在中断上下文跑的。RTX5和FreeRTOS适配层都让定时器回调运行在RTOS的定时器线程上下文中,这个线程有自己的栈,通常是主栈之外单独分配的。你把大数组、复杂计算、甚至osDelay塞进回调里,轻则定时器线程栈溢出,重则阻塞后续所有定时器回调。回调里也只做“记录事件然后通知任务”这种事,真正的处理放到普通任务里。
5. 移植避坑:H743加RTOS最容易翻车的五个细节
5.1 现象:main里一切正常,osKernelStart后立刻HardFault
代码在裸机环境能跑,创建任务也正常,但osKernelStart()刚启动调度就跳进HardFault_Handler。
原因有两个常见方向。一个是stm32h743xx_it.c里保留了空的SysTick_Handler和PendSV_Handler,和RTOS源码里的同名Handler冲突。Keil在链接时可能把裸机版本链接进去了,导致调度器的tick没跑起来,或者PendSV切换直接跳到了空函数。另一个是SysTick同时被HAL库和RTOS占用,HAL_Init()默认把SysTick配置成了HAL的时基源,RTOS启动时又去改SysTick配置,两个模块抢一个外设。
解决方法是先检查中断服务函数文件,把空的PendSV_Handler、SysTick_Handler注释掉,让RTOS自己的实现生效。然后把HAL的时基源从SysTick换到其他定时器,比如TIM6。这样HAL_Delay和RTOS的tick才不打架。改完还死机的话,单步跟进osKernelStart,看是否进入了SVC启动流程,进入后第一个上下文切换发生在哪一行。
5.2 现象:串口中断频繁丢数据,示波器看RX引脚却有波形
硬件上信号正常,但RTOS里的串口接收任务经常收不到完整帧,或者收到的字节顺序错乱。
原因和中断优先级分组有关。CMSIS在HAL库初始化时默认把优先级分组设为NVIC_PRIORITYGROUP_4,也就是4位抢占优先级,没有子优先级。FreeRTOS要求凡是能在中断里调用FromISR类API的中断,其优先级数值必须大于等于某个阈值,这个阈值由configMAX_SYSCALL_INTERRUPT_PRIORITY控制。STM32的NVIC是数值越小优先级越高,如果你把串口中断优先级设置为0,它比SysTick还高,RTOS的tick根本得不到执行,调度自然乱。
解决方法是把外设中断优先级往低了调,比如设置为5到7之间的数值。RTX5虽然没有FreeRTOS这么严格的阈值宏,但同样建议把外设中断优先级调得比SysTick高一点,同时保持分组一致。串口中断里如果用osMessageQueuePut往队列里丢数据,千万不要传超时参数osWaitForever,中断里不能等。
5.3 现象:任务栈溢出,但代码看着没问题
任务跑几分钟后“随机”HardFault,Keil的Call Stack窗口里返回地址完全乱掉。
原因通常是任务栈太小。Cortex-M7带了FPU,启用浮点单元后,上下文切换需要保存FPU寄存器组。中断嵌套越深,额外消耗的栈越多。再加上ABI要求栈指针8字节对齐,很多看起来够用的栈在FPU介入后就不够了。模板例程里512字节的栈在不开FPU时还能撑住,一开FPU就爆。
解决方法是把任务栈统一提高到1024字节起步,涉及浮点运算或大局部变量的任务直接给2048。开发期打开栈溢出检测:FreeRTOS在FreeRTOSConfig.h里设置configCHECK_FOR_STACK_OVERFLOW 2,并实现vApplicationStackOverflowHook打印出错任务名;RTX5则靠替换osRtxErrorNotify回调来接收osRtxErrorStackOverflow错误码。检测机制有开销,发版前可以关掉,但开发期必须开着,这是定位随机HardFault最快的路径。
5.4 现象:RTX5运行几天后卡死在osRtxErrorNotify回调
RTX5模板的默认错误处理函数往往实现成一个死循环for(;;),一旦内核检测到错误就停在那里,看起来像系统挂死。
原因可能是内存分配越界。RTX5报了osRtxErrorISRQueueOverflow说明中断里发的事件太多,内核的事件队列满了;osRtxErrorStackOverflow说明某个任务栈踩过了边界;osRtxErrorMemoryAllocation说明初始化时内存池不够用。
解决方法是把osRtxErrorNotify重写成一个能输出错误码的实现,先把错误码用串口打出来,再决定是复位还是继续运行:
uint32_t osRtxErrorNotify(uint32_t code, void *object) { printf("RTX5 error code=%lu\r\n", (unsigned long)code); NVIC_SystemReset(); return 0; }生产环境里我一般记录日志后复位,开发环境则先停下来分析错误码。FreeRTOS对应的钩子是vApplicationStackOverflowHook,同样把任务名打印出来再定位。
5.5 现象:DMA外设和RTOS任务互相“卡死”
加了摄像头、以太网或者串口DMA之后,RTOS的低优先级任务突然长时间不执行,外设中断也没有继续触发。
原因大概率是D-Cache和DMA的缓存一致性问题。H743默认开D-Cache,CPU写数据后数据还在Cache里,DMA去内存读的是旧数据;反过来DMA写内存后,CPU读的也可能是Cache里的旧内容。更糟的是缓冲数组被定义在了DTCM,DMA根本访问不到,外设永远等不到数据。
解决方法是先把DMA缓冲放到AXI SRAM,再在每次收发前后调用Cache维护函数:
SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, tx_len); SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, rx_len);发送前Clean是把Cache里的数据刷到内存,接收后Invalidate是把Cache里对应地址的内容作废,强制下次从内存重新加载。顺序不能反。对持续吞吐的场景,可以给这段DMA缓冲的内存区域用MPU配置成Non-cacheable属性,省去手动维护的麻烦。
6. 模板到手后先别写业务代码:用三件事验证两个内核在本板上都靠谱
第一件是测任务栈高水位。FreeRTOS可以用uxTaskGetStackHighWaterMark()拿到每个任务的历史最低剩余栈,模板里每个任务创建后把句柄存下来,跑一段压力再打印一遍。RTX5侧我是直接用Keil调试器的RTX任务窗口看栈余量,或者用EventRecorder记录线程状态。两个内核跑同样的任务集合,栈消耗会差不少,这组数据直接决定你最终选哪个内核、任务栈给多大。
第二件是验证时基一致性。建一个1秒翻转一次的任务,用逻辑分析仪抓LED引脚波形,对比RTX5和FreeRTOS下的周期抖动。H743跑480MHz时裸机定时器几乎无抖动,RTOS下抖动主要来自临界区关中断和tick处理时长。如果你们的应用里有PID控制或编码器采样,这个抖动数字就是选型关键。模板的两个内核都跑一遍,记录最大抖动,比看一百篇评测都有用。
第三件是做一次48小时压力测试。开两个生产者任务往消息队列里塞数据,一个消费者任务取出来校验CRC,中间穿插事件标志同步和软件定时器喂狗。看门狗别放在低优先级任务里喂,放在最高优先级任务里喂才有意义。两个内核各跑一遍,用串口把错误计数打出来。RTX5挂掉会停在osRtxErrorNotify,FreeRTOS会触发栈溢出钩子,两边都能给出明确的错误信号。
这套模板最大的价值不是给你一份能编译的代码,而是给你一条对照基线。我自己的习惯是拿到任何RTOS例程先花半天做这三件事,跑通之后再往上加LVGL、LWIP这些中间件,否则后面出了问题分不清是内核的锅还是应用的锅。希望这些经验能帮你在stm32h743上少走几趟弯路。
本文还有配套的精品资源,点击获取