Build Your Own RTOS 系列文章

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

1 让出CPU

在我们前几期所搭建出来的简易系统中,虽然已经完成了任务调度和时间片轮转,可以并发执行任务,但是当一个任务不需要运作,想要让出CPU给别的任务时,我们好像没有一个办法让其完成。

我们这一期就来解决这个问题:当任务不需要CPU时,它会主动进入“睡眠”状态,不再参与调度,直到时间到达。配合空闲任务,我们的系统就初步具备了资源管理的功能。这才像一个真正的RTOS。

我们先来构思一下具体的实现路径,就以大家都熟悉的硬件阻塞延时(比如ST的HAL库提供的HAL_Delay())为例。HAL有一个时基变量uwTick,用于存储当前“系统时间”,实现方法是在定期触发的SysTick_Handler()中断中,让uwTick++HAL_Delay()的完整实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
__weak void HAL_Delay(uint32_t Delay)
{
uint32_t tickstart = HAL_GetTick(); /* HAL_GetTick()返回uwTick */
uint32_t wait = Delay;

/* 加上一个uwTickFreq以确保最低延时 */
if (wait < HAL_MAX_DELAY)
{
wait += (uint32_t)(uwTickFreq);
}

while ((HAL_GetTick() - tickstart) < wait)
{
}
}

可以看到,HAL_Delay()的原理就是,如果规定的时间没到我就卡在这死循环,直到规定时间结束。这就是我们常说的阻塞延时的阻塞所在。

类似的,我们的延时也使用这个思路进行。

1.1 延时时间

首先我们要为每一个任务分配一个变量,用于存储延时的时间。这个变量当然是放在TCB里了。

1
2
3
4
5
6
typedef struct Task_Control_Block
{
volatile uint32_t *stackPtr; ///< 任务对应的栈指针
struct Task_Control_Block *Next; ///< 指向下一个任务的指针
volatile uint32_t DelayTicks; ///< 延时的时间(单位ms)
} OS_TCB;

不要忘记初始化的时候,将TCB中的DelayTicks设置为0。修改OS_TaskCreate函数,添加一行tcb->DelayTicks = 0;

1.2 中断函数

接着我们要改造OS_Tick_Handler(),遍历一遍任务列表,如果一个任务的延时时间>0,就将其减1。这里涉及到一点算法的知识:如何遍历链表?

2 OS_Delay()

好了,我们基于以上的内容开始编写我们的延时函数void OS_Delay(uint32_t ticks)。它的主要逻辑就是,将当前任务的延时变量设置成ticks,然后触发PendSV,让出CPU。

1
2
3
4
5
6
void OS_Delay(uint32_t ticks)
{
CurrentTCB->DelayTicks = ticks;

OS_Trigger_PendSV();
}

就这么简单结束了吗?当然不是。我们知道,除了OS_Delay()这个函数外,SysTick_Handler()中断也会更新任务延时的值,如果就这么写的话它们俩说不定会在程序里打架,导致系统错乱了。所以我们要在前后添加临界区。注意,为了解耦,我们还是要把开关中断的函数在os_cpu.c中封装起来。

3 调度器

接下来我们还要修改调度器。我们之前的调度器是只看任务链表,不看延时;我们就要修改调度的逻辑,让它考虑到正在延时中的任务,将它们排除在外。我们的调度器逻辑实际上是写在OS_Tick_Handler()里的,所以我们又要修改它。

首先我们先在os_core.c里封装一个私有函数FindNextTask(),用于寻找下一个处于就绪态的任务。

1
2
3
4
5
6
7
8
9
OS_TCB* FindNextTask(void)
{
OS_TCB* TempTCB = CurrentTCB->Next;
do{
TempTCB = TempTCB->Next;
}while(TempTCB->DelayTicks > 0);

return TempTCB;
}

然后我们修改OS_Tick_Handler()

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
void OS_Tick_Handler(void)
{
// 1. 安全检查
if (CurrentTCB == NULL) return;

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

// 3. 遍历任务列表,将每个延时值(若>0)-1
OS_TCB* ptr = CurrentTCB; // 从当前任务开始

do{
if(ptr->DelayTicks > 0) ptr->DelayTicks--;
ptr = ptr->Next; // 将当前标记指针指向下一个任务
}while(ptr != CurrentTCB);

// 4. 核心调度逻辑
NextTCB = FindNextTask();

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

问题又来了:如果所有任务都在睡觉,会发生什么?答案是会卡在寻找下一个可用任务的循环中出不来。所以,我们在启用调度器时,需要先创建一个空闲任务,内容可以是一个死循环,或者是执行__WFI()(一个ARM架构下的宏,代表进入Wait For Interrupt的低功耗模式),这样即使所有任务都在休眠,系统也能正常运作。我们在os_core.c里创建这个任务函数,这里就用死循环。

1
2
3
4
void IdleTask(void)
{
for(;;);
}

然后我们在OS_StartScheduler()函数的最开头添加这个任务。由于任务链表中必有一个任务,所以原来的防御编程可以省略了。

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
void OS_StartScheduler(void)
{

// 0. 创建空闲任务 确保系统中至少有一个始终处于就绪态的任务
OS_TaskCreate(&IdleTaskTCB, IdleTask, IdleTaskStack, IDLE_STACK_SIZE);

// 1. 此时 CurrentTCB 肯定不为 NULL 了,原来的防御性检查可以删除
// if (CurrentTCB == NULL) return;

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

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

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

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

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

4 “启动”

好了,我们可以开始实验。这次实验推荐使用两个GPIO,这样能直观的看到延时的效果。

我们的任务就设置的简单一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void Task1(void)
{
for(;;){
HAL_GPIO_TogglePin(LED_RED_GPIO_Port, LED_RED_Pin);
OS_Delay(500);
}
}

void Task2(void)
{
for(;;){
HAL_GPIO_TogglePin(LED_BLUE_GPIO_Port, LED_BLUE_Pin);
OS_Delay(500);
}
}

插板子,编译、下载。看看这个效果。。。好像有点不对劲?

这就是RTOS开发中典型的“假切换”问题。实际上,问题出在我们的OS_Delay()代码中。

当任务调用OS_Delay()时,虽然任务修改了自己的延时变量,也触发了PendSV,但是它没有指定下一个任务是谁(很有可能,Next还是自己),直到下一次的SysTick中断里才会重新寻找下一个就绪任务。所以就导致“相位”在漂移。

我们只需要修改OS_Delay()

1
2
3
4
5
6
7
8
9
10
void OS_Delay(uint32_t ticks)
{
OS_Disable_IRQ();
CurrentTCB->DelayTicks = ticks;
OS_Enable_IRQ();

NextTCB = FindNextTask();

OS_Trigger_PendSV();
}

在交出CPU的时候就指定下一个任务,这样调度就正常了。

5 总结

本期我们完成了任务阻塞延时,现在我们的系统已经基本可以运行多个独立的任务了。接下来我们就要讨论如何让多个任务“通信”。下期见!