Build Your Own RTOS Part5:时间片轮转
Build Your Own RTOS 系列文章:
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位倒计时基础定时器。如果你有过STM32的HAL库开发经验,你一定或多或少的听过或用过这个定时器。
我们都知道定时器的基本原理:每个定时器的时钟周期,定时器的值(在这里是)自减1,直到其减至0后触发一次中断,然后其回到重装载值,重复以上过程。
如果你查阅CMSIS的驱动中的core_cm3.h,会发现它已经定义好了一个初始化函数SysTick_Config(),我们直接拿来用。我们在os_cpu.c中编写一个硬件初始化函数OS_Init_Timer()。
注意:虽然大多数情况下PendSV和SysTick的中断优先级都设为最低不会有什么问题,但是我们这里还是把SysTick设成比PendSV高一级(14,PendSV设为15)。
1 | /* in os_cpu.c */ |
此外,我们还需要在os_cpu.c中提供一个“触发任务切换”的接口,供内核调用。这样内核就不需要知道具体是操作哪个寄存器了。
1 | /* in os_cpu.c */ |
3 任务链表与创建逻辑
接下来我们开始处理任务调度逻辑。首先我们需要一个任务列表来给任务排队,最好的方式就是用链表实现。我们直接给TCB中添加一个成员struct Task_Control_Block *Next,用于指向下一个任务的TCB。
1 | /* in os_core.h */ |
接着我们来写一个创建任务的函数OS_TaskCreate()。之前我们写的OS_StackInit()只是初始化了任务栈,现在我们要把任务串成一个单向循环链表。
逻辑如下:
- 如果当前
CurrentTask == NULL,说明还没有任务,我们就把新任务TCB的Next指向自己,顺便把CurrentTCB也指向它。 - 否则呢,我们就把新任务TCB的
Next指向CurrentTCB的下一个任务,再把CurrentTCB的Next指向这个新任务的TCB。
这样一来,新任务总是被插入到CurrentTCB的后面。我们在os_core.c中完成这个函数的实现。
1 | /* in os_core.c */ |
4 核心调度器
OK,我们终于可以开始写调度器的核心逻辑了。
按照解耦的思路,我们在os_core.c中定义一个OS_Tick_Handler()函数。这个函数会被硬件中断调用,它是OS处理时间片的大脑。
它的逻辑非常清晰:
- 更新系统时间(
g_SystemTickCount++)。 - 寻找下一个任务(时间片轮转的核心就是:下一个任务 = 当前任务的Next)。
- 如果需要切换,就调用
OS_Trigger_PendSV。
1 | /* in os_core.c */ |
现在,内核逻辑写好了,我们需要把它挂载到真正的硬件中断上。请打开stm32f1xx_it.c,找到SysTick_Handler(),修改如下:
1 | /* in stm32f1xx_it.c */ |
5 启动!
万事俱备,只欠东风。我们需要一个函数来启动整个调度系统。为了防止栈错误,我们用C语言重新写一个开启调度器的函数,把原来汇编里的OS_Start删掉。
这里有一个非常巧妙的Trick:
在第一次触发上下文切换时,我们只有NextTCB(第一个要跑的任务),但没有“上一个任务”需要保存。如果PendSV傻乎乎地去保存CurrentTCB的上下文,而此时CurrentTCB指向的栈可能是乱的,系统就崩了。
所以,我们在启动时,故意将CurrentTCB设为NULL。还记得我们在PendSV_Handler汇编代码里写的那句判断吗?CMP R1, #0 —— 如果CurrentTCB是0,就跳过保存上下文的步骤,直接执行恢复上下文!
1 | /* in os_core.c */ |
至此,我们的时间片轮转调度系统就搭建完成了!
和上一期一样,我们同样把两个任务设置成一个变量++,如下:
1 | void Task1(void) |
打开调试器,观察Watch1窗口。这次你会发现,和上次不同,两个变量相差好像挺大的。

这里任务1代表的count1转换成十进制是8897345,任务2代表的count2转换成十进制是8885449。相差了大约12000。
实际上,这恰恰证明了我们成功的完成了时间片轮转。在某1ms内,任务1疯狂的循环,count1疯狂的++;在下一ms内,又轮到任务2的count2疯狂的++。我们截图的这一瞬间,就是任务1正在运行(即将结束)的一个时间。
如果你不放心,也可以把任务改成翻转一个GPIO或类似的操作,然后加一个软件for循环延时来可视化。
6 总结
至此,我们已经完成了RTOS的核心功能:多任务并发!下一期,我们将探讨如何将任务停下来,让出CPU给别的任务。
.jpg)