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 RAM,64K 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-M3(STM32F103的内核)的架构手册,编写上下文切换的汇编代码,并配置好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 线程函数与创建
// 线程1:LED闪烁 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 tick=1ms) 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上下文切换:如前所述,保存当前线程现场,恢复下一个线程现场。整个过程在几十个时钟周期内完成,对于人类感知来说就是一瞬间。
你可以尝试修改两个线程的优先级。将打印线程的优先级设为2,LED线程设为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朋友”来帮忙了。