Build Your Own RTOS 系列文章

  1. 理解M3架构
  2. 硬件自动压栈
  3. 初始化栈
  4. PendSV
  5. 当前阅读:时间片轮转
  6. 延时
  7. 信号量

1 RTOS的心跳

如果说PendSV是RTOS的心脏,那SysTick就是RTOS的心跳。在前几期中,我们已经搞定了上下文切换,但是任务的切换必须要放在任务函数里,由任务函数手动触发PendSV中断。也就是说,现在的任务是顺序执行的(做完你的做你的,做完你的做你的)。

既然我们想要一个并发执行的系统,我们就不能让任务自己决定什么时候切换,要把这个“发令枪”交给一个定期高频触发的中断,也就是我们设置的SysTick。虽然实际上我们知道,CPU并没有真正的同时做多件事情的能力,但是由于我们的SysTick间隔很短,看起来就好像CPU在同时处理多个任务一样。这就是时间片轮转(Time-Slice Round-Robin)。

时间片就是我们设定的SysTick中断的间隔时间,通常是1ms;轮转就是大家排好队一个一个来,就这么简单。

为了让我们的代码结构更清晰,更符合工程规范,从这一期开始,我们会严格遵守软件分层解耦的设计思想:

  • os_core.c:负责内核的纯业务逻辑(调度算法、任务管理),尽量不包含硬件相关的寄存器操作。
  • os_cpu.c:负责底层的硬件抽象(寄存器配置、触发中断),给内核提供接口。
  • stm32f1xx_it.c:负责接收硬件中断,并将其转发给内核处理。

2 SysTick 硬件初始化

SysTick定时器是一个系统级的24位倒计时基础定时器。如果你有过STM32HAL库开发经验,你一定或多或少的听过或用过这个定时器。

我们都知道定时器的基本原理:每个定时器的时钟周期,定时器的值(在这里是)自减1,直到其减至0后触发一次中断,然后其回到重装载值,重复以上过程。

如果你查阅CMSIS的驱动中的core_cm3.h,会发现它已经定义好了一个初始化函数SysTick_Config(),我们直接拿来用。我们在os_cpu.c中编写一个硬件初始化函数OS_Init_Timer()

注意:虽然大多数情况下PendSVSysTick的中断优先级都设为最低不会有什么问题,但是我们这里还是把SysTick设成比PendSV高一级(14,PendSV设为15)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* in os_cpu.c */
/* 记得在 os_cpu.h 中 #include "stm32f1xx.h" 以获取 SysTick_IRQn 定义 */

void OS_Init_Timer(uint32_t ms)
{
// 系统时钟 72MHz
uint32_t ticks = 72000000 * ms / 1000;

if(SysTick_Config(ticks)){
while(1); /* 配置失败了,死循环 */
}

/* 这里我们只设置SysTick的优先级,PendSV的在别的地方(OS_StartScheduler里)设置 */
NVIC_SetPriority(SysTick_IRQn, 14);
}

此外,我们还需要在os_cpu.c中提供一个“触发任务切换”的接口,供内核调用。这样内核就不需要知道具体是操作哪个寄存器了。

1
2
3
4
5
6
/* in os_cpu.c */
void OS_Trigger_PendSV(void)
{
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk; // 实际上就是我们上期写的OS_Yield函数,只是用宏来写
}

3 任务链表与创建逻辑

接下来我们开始处理任务调度逻辑。首先我们需要一个任务列表来给任务排队,最好的方式就是用链表实现。我们直接给TCB中添加一个成员struct Task_Control_Block *Next,用于指向下一个任务的TCB。

1
2
3
4
5
6
7
/* in os_core.h */
typedef struct Task_Control_Block
{
volatile uint32_t *stackPtr; ///< 任务对应的栈指针
struct Task_Control_Block *Next; ///< 指向下一个任务的指针
} OS_TCB;

接着我们来写一个创建任务的函数OS_TaskCreate()。之前我们写的OS_StackInit()只是初始化了任务栈,现在我们要把任务串成一个单向循环链表

逻辑如下:

  • 如果当前CurrentTask == NULL,说明还没有任务,我们就把新任务TCB的Next指向自己,顺便把CurrentTCB也指向它。
  • 否则呢,我们就把新任务TCB的Next指向CurrentTCB的下一个任务,再把CurrentTCBNext指向这个新任务的TCB。

这样一来,新任务总是被插入到CurrentTCB的后面。我们在os_core.c中完成这个函数的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* in os_core.c */

/* 全局变量定义 */
OS_TCB* CurrentTCB = NULL;
OS_TCB* NextTCB = NULL;
uint32_t g_SystemTickCount = 0; // 系统时基

void OS_TaskCreate(OS_TCB* tcb, void* task_function, uint32_t* stack_init_address, uint32_t stack_depth)
{
// 初始化栈
tcb->stackPtr = OS_StackInit(task_function, stack_init_address, stack_depth);

// 链表插入逻辑
if(CurrentTCB == NULL){
tcb->Next = tcb;
CurrentTCB = tcb;
}
else{
tcb->Next = CurrentTCB->Next;
CurrentTCB->Next = tcb;
}
}

4 核心调度器

OK,我们终于可以开始写调度器的核心逻辑了。

按照解耦的思路,我们在os_core.c中定义一个OS_Tick_Handler()函数。这个函数会被硬件中断调用,它是OS处理时间片的大脑。

它的逻辑非常清晰:

  1. 更新系统时间(g_SystemTickCount++)。
  2. 寻找下一个任务(时间片轮转的核心就是:下一个任务 = 当前任务的Next)。
  3. 如果需要切换,就调用OS_Trigger_PendSV
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* in os_core.c */
void OS_Tick_Handler(void)
{
// 1. 安全检查
if (CurrentTCB == NULL) return;

// 2. 更新系统时间
g_SystemTickCount++;

// 3. 核心调度逻辑 (Round Robin)
// 以后如果你想改优先级调度,只需要改这里
NextTCB = CurrentTCB->Next;

// 4. 请求上下文切换
// 这里不再直接写寄存器,而是调用移植层 os_cpu.c 的接口
if (NextTCB != CurrentTCB) {
OS_Trigger_PendSV();
}
}

现在,内核逻辑写好了,我们需要把它挂载到真正的硬件中断上。请打开stm32f1xx_it.c,找到SysTick_Handler(),修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
/* in stm32f1xx_it.c */
/* 记得引用头文件 #include "os_core.h" */

void SysTick_Handler(void)
{
// 1. 维持 HAL 库基准 (这是 BSP 的职责,为了兼容HAL_Delay)
HAL_IncTick();

// 2. 通知 OS:一个 Tick 过去了 (这是 OS 的职责)
OS_Tick_Handler();
}

5 启动!

万事俱备,只欠东风。我们需要一个函数来启动整个调度系统。为了防止栈错误,我们用C语言重新写一个开启调度器的函数,把原来汇编里的OS_Start删掉。

这里有一个非常巧妙的Trick
在第一次触发上下文切换时,我们只有NextTCB(第一个要跑的任务),但没有“上一个任务”需要保存。如果PendSV傻乎乎地去保存CurrentTCB的上下文,而此时CurrentTCB指向的栈可能是乱的,系统就崩了。

所以,我们在启动时,故意将CurrentTCB设为NULL。还记得我们在PendSV_Handler汇编代码里写的那句判断吗?CMP R1, #0 —— 如果CurrentTCB是0,就跳过保存上下文的步骤,直接执行恢复上下文!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* in os_core.c */
void OS_StartScheduler(void)
{
// 1. 此时 CurrentTCB 在 CreateTask 时已经被设置为链表头了(比如 TaskA)
// 但我们不能直接让 PendSV 看到它,否则会尝试保存 TaskA 的栈(但 TaskA 还没跑过,栈是初始化的)

if (CurrentTCB == NULL) return; // 防御性代码

// 2. 关键步骤:设置 NextTCB 为第一个要运行的任务
NextTCB = CurrentTCB;

// 3. 关键步骤:欺骗 PendSV
// 将 CurrentTCB 暂时设为 NULL。
// 这样 PendSV 里的 "CMP R1, #0" 就会成立,从而跳过 STMDB (保存上下文),
// 直接执行 RestoreContext (恢复 NextTCB 的上下文)。
CurrentTCB = NULL;

// 4. 初始化 SysTick (开启时间片,开始 1ms 中断)
// 注意:OS_Tick_Handler 里有一句 if(CurrentTCB != NULL),
// 所以在 PendSV 执行完第一次切换并恢复 CurrentTCB 之前,
// 即使 SysTick 提前触发了,也不会乱调度。
OS_Init_Timer(1);

// 5. 触发 PendSV,开始第一次切换!
OS_Trigger_PendSV();

// 6. 应该永远不会执行到这里
while(1);
}

至此,我们的时间片轮转调度系统就搭建完成了!

和上一期一样,我们同样把两个任务设置成一个变量++,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
void Task1(void)
{
for(;;){
count1++;
}
}

void Task2(void)
{
for(;;){
count2++;
}
}

打开调试器,观察Watch1窗口。这次你会发现,和上次不同,两个变量相差好像挺大的。

这里任务1代表的count1转换成十进制是8897345,任务2代表的count2转换成十进制是8885449。相差了大约12000。

实际上,这恰恰证明了我们成功的完成了时间片轮转。在某1ms内,任务1疯狂的循环,count1疯狂的++;在下一ms内,又轮到任务2的count2疯狂的++。我们截图的这一瞬间,就是任务1正在运行(即将结束)的一个时间。

如果你不放心,也可以把任务改成翻转一个GPIO或类似的操作,然后加一个软件for循环延时来可视化。

6 总结

至此,我们已经完成了RTOS的核心功能:多任务并发!下一期,我们将探讨如何将任务停下来,让出CPU给别的任务。