1. 项目缘起为什么要在STM32上折腾RTOS和RT-Spark如果你玩过一阵子STM32从点灯、串口打印到驱动各种外设大概都会经历一个阶段裸机编程玩得挺溜但项目稍微复杂点代码就开始变得一团乱麻。中断里套着中断全局变量满天飞一个延时函数就能让整个系统“卡死”。这时候你大概率会听说一个词RTOS实时操作系统。RTOS听起来高大上但很多新手的第一反应是我的STM32资源那么紧张跑得动操作系统吗会不会让程序变慢学习成本是不是很高这些问题我当初也纠结过。直到我接手了一个实际项目需要同时处理按键扫描、屏幕刷新、传感器数据采集和网络通信用裸机状态机写得我头皮发麻才下定决心把RTOS用起来。而“RT-Spark”这个名字可能对很多人来说有点陌生。它不是像FreeRTOS、RT-Thread那样广为人知的成熟系统。实际上它更像是一个教学性质或轻量级的RTOS内核实现或者是某个社区项目、课程实验的名称。它的价值不在于替代主流RTOS而在于提供了一个足够简单、透明的“麻雀”让我们能亲手解剖理解RTOS里“线程”或者说“任务”这个最核心的概念到底是怎么运转起来的。这比直接面对FreeRTOS那庞大的源码要友好得多。所以这个“STM32 RTOS Lab: Threads with RT-Spark”项目本质上是一个动手实验。目标不是做一个产品级的应用而是通过在一个具体的STM32开发板上移植或实现一个名为RT-Spark的简易内核并创建多个线程来彻底搞懂在单片机上多个“看似同时运行”的程序线程是如何被调度管理的上下文切换是怎么发生的线程间又如何安全地通信搞明白了这些你再去看FreeRTOS或RT-Thread就会有一种“哦原来如此”的通透感而不是对着API手册机械地调用。2. RT-Spark内核初探一个简易RTOS的骨架在开始写代码之前我们得先搞清楚RT-Spark或者任何类似的教学RTOS大概长什么样。它通常包含以下几个最核心的模块我们可以自己动手实现或者基于已有的简易内核进行学习。2.1 核心数据结构线程控制块TCB线程控制块是操作系统的“户口本”每个线程都有一个保存了它的所有关键信息。在RT-Spark中一个简化版的TCB可能如下所示typedef struct tcb { void *sp; // 栈指针用于上下文切换时保存/恢复现场 uint32_t priority; // 线程优先级 uint32_t delay; // 延时计数器用于实现vTaskDelay struct tcb *next; // 指向就绪链表中下一个TCB的指针 // 还可以扩展线程名、栈起始地址、栈大小、状态就绪、阻塞、挂起等 } tcb_t;这个sp栈指针是灵魂。当CPU要切换到另一个线程时它需要把当前线程的寄存器值全部保存到它自己的栈里然后把栈指针SP寄存器的值存到TCB的sp成员中。接着从下一个线程的TCB里取出它的sp值加载到CPU的SP寄存器再从这个新栈里恢复所有寄存器的值。这样CPU就仿佛从未离开过一样开始执行新线程的代码。这个过程完全由汇编语言编写是RTOS的“魔法”所在。2.2 就绪链表与调度器有了TCB操作系统怎么知道接下来该运行谁呢这就需要一个“就绪链表”。所有状态为“就绪”Ready的线程按其优先级顺序挂在这个链表上。RT-Spark可能采用最简单的优先级调度算法永远从就绪链表的头部取出最高优先级的线程来运行。调度器Scheduler的核心函数schedule()其伪代码逻辑异常简单从就绪链表头取出下一个要运行的线程TCB。调用上下文切换函数如PendSV_Handler将当前线程的现场保存到其栈中并恢复下一个线程的现场。这个调度动作由系统节拍定时器SysTick中断周期性触发。例如设置SysTick每1ms中断一次在中断服务程序中更新所有线程的延时计数器然后判断是否需要触发调度器。2.3 临界区与开关中断在RTOS中多个线程会共享资源比如全局变量、外设。当一个线程正在修改一个全局变量时如果被更高优先级的线程打断并且也修改了这个变量数据就可能出错。保护共享资源的关键区域就叫“临界区”。在像Cortex-M这样的单片机上进入临界区最直接有效的方法就是关闭全局中断退出时再打开。RT-Spark需要提供一对宏#define ENTER_CRITICAL() __disable_irq() #define EXIT_CRITICAL() __enable_irq()在操作就绪链表、修改TCB状态等核心数据结构时必须使用这对宏包裹起来。这是编写RTOS内核应用代码的第一个也是最重要的纪律。3. 实验环境搭建与RT-Spark移植理论说得再多不如动手做一遍。我们以最常见的STM32F103C8T6蓝色药丸板和Keil MDK环境为例。3.1 硬件与软件准备开发板STM32F103C8T6核心板资源足够72MHz主频20K RAM64K Flash。IDE/编译器Keil MDK-ARM。也可以选择VS Code ARM GCC OpenOCD但对于初次接触Keil的集成度更高排查问题更直观。调试器ST-Link V2。这是必备的因为我们需要单步调试观察线程切换的每一个细节。基础工程从STM32标准库或HAL库的示例中创建一个最简单的工程包含SysTick中断、USART打印用于调试信息和几个GPIO用于点灯直观观察线程运行。3.2 RT-Spark源码结构假设我们拿到或自己编写的RT-Spark内核源码文件夹结构如下RT-Spark/ ├── inc/ │ ├── rt_spark.h // 内核头文件包含API声明、TCB定义等 │ └── port.h // 与CPU架构相关的移植层头文件 ├── src/ │ ├── rt_spark.c // 内核实现调度器、线程管理、延时等 │ ├── list.c // 就绪链表实现 │ └── port.c // 移植层实现上下文切换汇编、SysTick配置 └── demo/ // 示例应用代码移植的核心工作集中在port.c和port.h。你需要根据Cortex-M3STM32F103的内核的架构手册编写上下文切换的汇编代码并配置好SysTick定时器。3.3 关键移植步骤详解SysTick配置在port.c的初始化函数里设置SysTick的重装载值使其产生1ms的周期性中断。并启用SysTick中断。// 系统时钟72MHz 1ms中断一次 SysTick_Config(SystemCoreClock / 1000);编写PendSV中断服务程序PendSV可挂起的系统调用是Cortex-M专门为RTOS上下文切换设计的中断。它的优先级被设为最低。当调度器决定要切换线程时它不会立刻切换而是触发一个PendSV异常。等所有更高优先级的中断都处理完后才执行PendSV进行实际的切换。这保证了上下文切换不会打断关键的中断处理。; port.c 中的汇编部分 PendSV_Handler: CPSID I ; 禁止中断保护切换过程 MRS R0, PSP ; 如果当前线程使用PSP进程栈指针则将其值存入R0 CBZ R0, PendSV_Handler_nosave ; 如果PSP为0说明是第一个线程无需保存 ; 保存当前线程的上下文R4-R11, LR到其栈中 STMDB R0!, {R4-R11, LR} ; 将当前线程的栈指针保存到它的TCB-sp中这里需要一些C-汇编交互 LDR R1, current_tcb LDR R1, [R1] STR R0, [R1] ; 更新TCB中的sp PendSV_Handler_nosave: ; 调用C函数获取下一个要运行的线程TCB其sp字段已包含新线程的栈顶 LDR R0, get_next_ready_thread BLX R0 ; R0现在保存了next_tcb的地址 LDR R1, current_tcb STR R0, [R1] ; 更新current_tcb LDR R0, [R0] ; 从next_tcb中加载栈指针到R0 ; 从新线程的栈中恢复上下文R4-R11, LR LDMIA R0!, {R4-R11, LR} MSR PSP, R0 ; 将新的栈指针赋给PSP CPSIE I ; 开启中断 BX LR ; 返回此时LR是特殊的EXC_RETURN值CPU会自动使用PSP并恢复其余寄存器这段汇编是理解RTOS的“圣杯”。它清晰地展示了保存和恢复“现场”的过程。被保存的R4-R11是C语言函数调用约定中需要被调用者保存的寄存器而R0-R3, R12, LR, PC, xPSR会在进入中断时由硬件自动压栈。初始化第一个线程在main()函数中硬件初始化完成后我们需要手动构造第一个线程的栈。这个过程叫做“线程栈初始化”。你需要模拟一个中断栈帧将线程的入口函数地址PC、状态寄存器xPSR等正确压入为该线程分配的栈空间顶端然后将这个栈顶指针填入第一个线程TCB的sp字段。最后将current_tcb指向它并将PSP进程栈指针设置为这个栈顶然后执行一个特殊的返回指令通常通过汇编函数触发让CPU“跳转”到这个线程开始执行。注意在Keil中调试此类多线程程序Watch窗口可能无法自动识别不同线程的局部变量因为栈指针SP一直在变。你需要手动将SP实际上是PSP的值输入到Memory窗口或者直接查看为每个线程分配的静态数组栈空间的内容来调试。4. 创建与观察你的第一个多线程应用内核跑起来后我们就可以创建线程了。假设我们要创建两个线程一个让LED闪烁Thread_LED一个通过串口打印计数Thread_Print。4.1 线程函数与创建// 线程1LED闪烁 void thread_led_entry(void *arg) { (void)arg; while(1) { GPIO_WriteBit(GPIOB, GPIO_Pin_12, Bit_SET); // LED亮 rt_spark_delay(500); // 延时500个tick假设1 tick1ms GPIO_WriteBit(GPIOB, GPIO_Pin_12, Bit_RESET); // LED灭 rt_spark_delay(500); } } // 线程2串口打印 void thread_print_entry(void *arg) { (void)arg; uint32_t count 0; while(1) { printf(Thread Print Count: %lu\r\n, count); rt_spark_delay(1000); // 每秒打印一次 } } // 线程栈定义静态分配 #define THREAD_STACK_SIZE 128 static uint32_t thread_led_stack[THREAD_STACK_SIZE]; static uint32_t thread_print_stack[THREAD_STACK_SIZE]; // TCB定义 static tcb_t tcb_led; static tcb_t tcb_print; int main(void) { // 硬件初始化时钟、GPIO、串口... SystemInit(); LED_GPIO_Config(); USART_Config(); // 初始化RT-Spark内核 rt_spark_init(); // 创建线程 rt_spark_thread_create(tcb_led, LED, thread_led_entry, NULL, thread_led_stack, sizeof(thread_led_stack), 2); // 优先级2 rt_spark_thread_create(tcb_print, Print, thread_print_entry, NULL, thread_print_stack, sizeof(thread_print_stack), 1); // 优先级1数字小优先级高 // 启动调度器永不返回 rt_spark_scheduler_start(); while(1); // 不会执行到这里 }4.2 观察与调试线程如何“并发”运行烧录程序后你会看到LED以1Hz的频率闪烁同时串口每隔1秒打印一个递增的数字。从现象上看两个任务在“同时”运行。但内核里是串行的。通过调试器在SysTick_Handler和PendSV_Handler里设置断点你可以像看电影慢放一样观察整个过程SysTick中断每1ms发生一次。在中断里内核会遍历所有线程将那些调用了rt_spark_delay的线程的delay计数器减1。如果某个线程的delay从1变为0说明它延时结束需要从“阻塞态”移回“就绪态”。调度决策在SysTick中断服务程序的最后它会检查就绪链表。如果发现就绪链表中最高优先级的线程不是当前正在运行的线程例如一个更高优先级的线程延时结束了它就会触发PendSV异常。PendSV上下文切换如前所述保存当前线程现场恢复下一个线程现场。整个过程在几十个时钟周期内完成对于人类感知来说就是一瞬间。你可以尝试修改两个线程的优先级。将打印线程的优先级设为2LED线程设为1。你会发现串口打印依然每秒一次但LED闪烁可能会变得不规律甚至“卡住”。这是因为打印线程优先级更高它每次延时结束后都会立刻抢占CPU如果它的执行时间很长比如打印大量数据低优先级的LED线程就得不到运行机会。这就是优先级调度带来的问题也引出了线程间通信和同步的必要性。5. 从RT-Spark到工业级RTOS还缺什么通过RT-Spark我们亲手实现了线程、调度和延时。但这离一个可用的RTOS还差得很远。理解这些缺失的部分正是我们学习的目的。5.1 线程间通信与同步机制这是RTOS的精华所在。RT-Spark可能没有实现但我们必须知道它们是什么信号量Semaphore用于资源计数或任务同步。比如一个硬件SPI总线一次只能被一个线程使用可以用一个二值信号量互斥锁来保护。消息队列Queue线程间传递数据的“管道”。生产者线程将数据放入队列消费者线程从队列取出。这是解耦模块的利器。事件标志组Event Group一个线程可以等待多个事件中的任意一个或全部发生。非常灵活。互斥锁Mutex带有优先级继承机制的二值信号量专门用于解决优先级反转问题。实操心得在裸机编程中我们习惯用全局变量加标志位来通信。在RTOS中请务必使用内核提供的通信原语。直接操作全局变量而不加保护在多线程环境下是灾难的根源这种Bug极难复现和定位。5.2 内存管理与定时器服务动态内存分配malloc/free在资源紧张且碎片化严重的嵌入式系统中是危险的。成熟的RTOS如FreeRTOS会提供多种内存管理方案如heap_1只分配不释放、heap_4合并空闲块防碎片等。在RT-Spark这类学习项目中我们通常使用静态内存分配就像上面定义栈数组一样安全可控。软件定时器除了线程自带的delay系统还需要一种可以触发回调函数的定时器服务。这通常由一个叫做“定时器服务线程”的特殊线程来管理。5.3 优先级反转与死锁这是多线程编程的经典难题。优先级反转高优先级线程等待一个被低优先级线程占有的资源而低优先级线程又被中优先级线程抢占导致高优先级线程实际被中优先级线程阻塞。互斥锁的优先级继承功能就是为了解决这个问题。死锁两个线程互相等待对方持有的资源导致双双卡死。避免死锁需要遵循一些编程规范比如按固定顺序获取多个锁。在RT-Spark的实验中我们可以故意设计一个场景创建三个优先级不同的线程共享一个资源比如一个全局变量用开关中断保护观察调度情况就能直观理解优先级反转。理解了问题才能更好地使用FreeRTOS等系统提供的解决方案。6. 项目总结与进阶思考这个“RT-Spark Lab”做下来虽然代码量可能不大但信息量是巨大的。它强制你从CPU和内存的视角去思考程序是如何运行的。当你再看到FreeRTOS的xTaskCreate、vTaskDelay、xSemaphoreTake这些API时你脑子里浮现的不再是黑盒而是栈指针在跳动、链表在调整、中断在开关的具体场景。我个人在从裸机转向RTOS的过程中最大的思维转变有两点从“顺序执行”到“并发设计”不再画一个从上到下的超级循环流程图而是画一个由多个独立线程和它们之间通信管道组成的框图。每个线程都是一个独立的、循环执行的函数。资源观念的变化CPU时间变成了由调度器分配的资源外设、变量、内存都是需要被保护或有序共享的资源。编程时首先要考虑“这个资源会被谁访问会不会冲突怎么保护”最后给想继续深入的朋友几点建议不要停留在RT-Spark用它理解原理后立刻切换到FreeRTOS或RT-Thread进行实际项目开发。它们经过千锤百炼功能完整社区支持好。读源码理解了基本原理后尝试去读FreeRTOS的list.c、tasks.c和port.c针对你用的芯片你会看到很多精妙的设计和细节处理比如listGET_OWNER_OF_NEXT_ENTRY宏实现的轮转调度。使用调试工具很多IDE如STM32CubeIDE和插件如SystemView、Tracealyzer可以图形化显示线程的运行状态、切换序列、信号量使用情况这对分析和优化系统性能至关重要。动手实现一次简单的RTOS内核是嵌入式开发路上一次重要的“开窍”体验。它拆解了魔法让你获得了对系统更深层次的控制力和理解力。当你下次遇到复杂的多任务需求时你会自信地知道该请出哪位“RTOS朋友”来帮忙了。