Build Your Own RTOS 系列文章

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

1 PendSV

在前几期中,我们已经理解了寄存器,学会了如何模仿硬件压栈来完成上下文切换。本期我们就要在PendSV_Handler()中实现它。

1.1 什么是PendSV?

PendSV(Pendable Service Call,可挂起的系统调用)是CM3内核专门设计的一种异常。就如字面所述,它是可以“挂起”(等待)的。

如果没有PendSV,我们可能要在别的中断(例如SysTick中)完成上下文切换。试想一下,我们正在处理紧急的硬件中断(或其他高优先级的任务),这时SysTick过来,不分青红皂白的切换上下文,直接让CPU去执行别的普通任务,原任务就被搁置了,这对我们的系统是致命的。

但是有了PendSV就不一样了。我们通常把PendSV的优先级设置成全系统最低(优先级号越大,优先级越低)。当SysTick触发任务切换的指令时,系统会先检查有没有比PendSV优先级更高的中断,如果有就先去执行优先级高的,直到最后只剩下PendSV,此时才执行任务切换。

1.2 为什么PendSV_Handler()只能用汇编写?

因为编译器太智能了,它会在你看不到的地方,在你手写的C语言函数代码前后添加一堆乱七八糟的东西(PrologueEpilogue),虽然对于普通的代码而言无关紧要,但是对PendSV_Handler()这种高要求的操作,会出现大麻烦。所以,涉及到这种底层寄存器的操作,我们只能使用纯汇编来写。幸运的是,之后基本就不用了。

1.3 实现逻辑

我们现在就正式开始准备实现PendSV_Handler()。由于我们还没有写调度,所以今天我们先写一个简单版,等到后面我们再把内容添加进来。

为了切换任务,我们需要两个指针来分别指向当前的任务和下一个要运行的任务。我们用CurrentTCBNextTCB来表示。

首先我们要明确:PendSV_Handler()要做些什么。假设我们已经有了两个任务,任务1的运行时间到了,接下来准备执行任务2。那么我们怎么处理现在寄存器里的数据呢?

  1. 先把现在正在用的数据存起来。第一步:获取当前PSP的值,存在R0里。由于R0-R3被硬件自动压栈了,所以存在R0里是安全的。然后,再把硬件没存的寄存器手动存到R0此刻指向的地址,也就是和硬件自动存储的数据放在一起。最后把CurrentTCB的值(也就是当前执行任务的TCB的地址)读出来放进R1,再把此刻R0的值存到R1指向的地址。
  2. 接下来就是恢复现场,把任务2的数据拿回来。首先把NextTCB的值写入CurrentTCB,现在的CurrentTCB已经是新的任务了。然后获取CurrentTCBsp,从这个地址中弹出数据到R4-R11,最后更新PSP。至此,任务切换结束。

好了,逻辑基本讲清楚了,我们就开始写代码。把PendSV_Handler()函数的代码写在os_cpu_a.s中。我们需要在函数的开头使用CPSID I关中断、结尾BX LR前使用CPSIE I开中断来制造临界区。这在实际应用中是不需要甚至应该避免的,但是为了今天的调试和学习,我们先这么做,防止别的中断打断我们的逻辑。

2 “启动”

接下来我们就开始做实验,亲眼看看我们的代码是不是成功了。首先因为我们没写调度器,所以我们需要一个用于触发PendSV的函数OS_Yield()。它通过向中断控制及状态寄存器ICSR(地址:0xE000_ED04)的Bit[28]写入1来挂起PendSV中断。

1
2
3
4
5
6
7
8
9
void OS_Yield(void) 
{
// 设置第 28 位 (PENDSVSET),触发 PendSV 异常
(*(volatile uint32_t *)0xE000ED04) = (1 << 28);

// 指令同步
__asm("DSB");
__asm("ISB");
}

然后我们要写一个开始函数OS_Start()。当我们还没有开始任务调度的时候,CurrentTCB绝对是NULL,即0;而NextTCB指向下一个TCB。所以我们OS_Start()的任务实际上就是PendSV_Handler()的下半部分,只切换不存储。所以我们直接复用PendSV_Handler(),不再写一遍了。注意:这里很容易错,如果一个不小心寄存器存反了就进HardFault了。所以请仔细核查自己的代码,实在不行就直接复制吧。

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

PendSV_Handler PROC ; PROC代表函数的开头
CPSID I ; 关中断
MRS R0, PSP
ISB ; 指令同步隔离,确保程序生效

LDR R2, =CurrentTCB ; 现在R2里存的是CurrentTCB的地址
LDR R1, [R2] ; 把R2(CurrentTCB)地址中所存的值(就是TCB的首地址,也就是sp变量的地址)存到R1里

CMP R1, #0 ; 比较R1和0
BEQ RestoreContext ; 如果相等 (Z标志位为1),直接跳转到恢复上下文部分

STMDB R0!, {R4-R11}
STR R0, [R1] ; 把现在的R0(也就是PSP最终指向的地址)存到R1指向的地址(也就是存进sp变量)

RestoreContext
LDR R2, =NextTCB ; 现在R2里存的是NextTCB的地址
LDR R3, =CurrentTCB ; 现在R3里存的是CurrentTCB的地址
LDR R1, [R2] ; 把R2(NextTCB)地址中所存的值存到R1里
STR R1, [R3] ; 把R1(NextTCB的sp变量)存到CurrentTCB的地址所对应的内存中,现在CurrentTCB已经是新的任务了
LDR R0, [R1] ; 从R1(NextTCB的sp变量)所对应的内存中读取NextTCB(实际上就是CurrentTCB)的sp变量到R0
LDMIA R0!, {R4-R11}
MSR PSP, R0
ORR LR, LR, #0x04 ; 将LR的第2位置1,返回时使用PSP
CPSIE I
BX LR
ENDP

OS_Start PROC

; 1. 设置PendSV的优先级为最低(0xFF)
; 这一步我们在C语言中做

; 2. 触发PendSV,这里实际上就是我们的OS_Yield函数
LDR R0, =0xE000ED04 ; ICSR寄存器地址
LDR R1, =0x10000000 ; 第28位是PENDSVSET
STR R1, [R0] ; 写1触发PendSV

; 3. 开中断 (防止之前被关了)
CPSIE I

; 4. 死循环 (理论上永远不会执行到这里,因为PendSV会立即抢占并切换走)
B .

ENDP

main.c里这么写(不要忘记在main函数之前定义任务的TCB和任务栈数组,还有在os_core.c里创建CurrentTCBNextTCB两个OS_TCB*指针):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

// 1. 设置 PendSV 优先级为最低 (0xFF)
NVIC_SetPriority(PendSV_IRQn, 0xFF); // 这里中断优先级实际上只接收4位,即只有最低4位有效 这么写也没问题

// 2. 关键初始化
Task1TCB.stackPtr = OS_StackInit(Task1, Task1stack, 128);
Task2TCB.stackPtr = OS_StackInit(Task2, Task2stack, 128);
CurrentTCB = 0; // 必须设为NULL,告诉汇编这是第一次
NextTCB = &Task1TCB; // 告诉 OS 第一个任务是谁

// 3. 启动!
OS_Start();

while(1) // 理论上永远不会运行到这里了
...

这里的Task1Task2函数就这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
uint32_t count1 = 0;
uint32_t count2 = 0;
void Task1(void)
{
for(;;){
count1++;
NextTCB = &Task2TCB; // 没有调度器的权宜之计
OS_Yield();
}
}

void Task2(void)
{
for(;;){
count2++;
NextTCB = &Task1TCB;
OS_Yield();
}
}

最后,我们在stm32f1xx_it.c中找到PendSV_Handler(),把它注释掉,让程序使用我们自己写的PendSV_Handler()

接下来就是插板子调试了。我们可以使用第三期类似的方法,打开Watch Windows,输入count1count2,如果程序运行正常,它们的值应该飞速增大且最多相差1。

3 总结

我们已经啃下了路线上最难啃的骨头,也许没有之一! 后面的代码大多是逻辑层面的C语言编程,主要考察的是设计架构和算法编写能力,会轻松很多。