本章源码已放在 GithubPreemptive
项目的Chapter3-ContextSwitch
章节。
这篇文章将介绍 Cortex-M3
指令集的一些基础知识,并会在 Cortex-M3
架构上实现上下文切换。其实这个实现非常简单,去除注释仅仅不到一百行代码,但已经涵盖了很多 CPU 核心的重要概念。
这篇文章将会涉及部分 ARM 指令集汇编,但这里不假设读者有任何 ARM 汇编知识,只需要掌握汇编的基础格式即可,比如学过 CSAPP 或玩过 TIS-100 系列游戏(此处强烈推荐冬促剁手 (≖◡≖) )
上下文切换
上下文切换是一个操作系统提供的功能,它可以随时让 CPU
执行流在多个进程间切换,而不会影响到进程内部的逻辑。
很多同学对 x86 架构的操作系统的上下文切换已经有所认识,就不对上下文切换这个概念作太多介绍了,直接开始讨论如何实现上下文切换。如果不了解也没有关系,下面这篇文章可以先让你对上下文切换有直观的理解。
七淅:一文让你明白CPU上下文切换zhuanlan.zhihu.comCPU 寄存器
上下文切换其实就是对核心寄存器进行合适的保护/恢复现场操作。要实现上下文切换,首先要了解各个核心寄存器的作用。Cortex-M3
典型有 16 个寄存器:
这些寄存器有几种不同的的作用:
R0-R3
为函数传参通用寄存器,用来在函数调用时传入参数或者返回函数返回值。R4-R11
为通用寄存器,编译器会使用它们来存储函数执行过程中的中间变量。与R0-R3
不同的是,当函数调用子函数的时候,编译器会将R4-R11
保存在栈上,这样就可以在子函数返回的时候重新恢复这些寄存器的值。(准确来说是子函数来负责保护和恢复现场。当子函数不需要使用R4-R11
的时候,它无需作任何现场保护就可以保证调用前后R4-R11
不发生变化,用到哪个存哪个即可)R12
也是通用寄存器,它还有个特殊的名字IP
寄存器(Intra Procedure call scratch Register)。R0-R4
由调用者负责保护,R4-R11
由被调用者保护。而R12
是个孤儿 : ),任何函数调用前后都不保证R12
不发生变化,它有一些特殊的用法,比如变长入参,其它情况下,只要保证使用期间不跨过函数调用点,那么它和之前其他通用寄存器是等价使用的。R13
-SP
栈指针寄存器(Stack Pointer Register)。它负责记录当前栈顶的指针,这个在下面会讲到。R14
-LR
链接寄存器(Linker Register)。它负责记录父函数的调用点程序地址,函数返回时将会使用这个地址进行跳转。Call Stack
记录就是一系列在栈上被保护的LR
寄存器值组成的。R15
-PC
程序计数器寄存器(Programe Counter)。储存下一条程序指令的地址,如果修改它,就能改变程序的执行流。顺序执行的情况下PC
存储当前指令地址 + 4 (32 位指令长度为 4)R16
-xPSR
程序状态寄存器。它由几个小寄存器组成,比如中断屏蔽寄存器PRIMASK
,错误屏蔽寄存器FAULTMASK
以及控制寄存器CONTROL
。
不同寄存器有不同的读写方法:
- 通用寄存器使用
MOV
指令读写 - 特殊寄存器
R13
,R14
和R16
无法使用MOV
指令读写,需要使用专门的MRS
(读取) 和MSR
(写入) 指令
MOV R3, R0 ; R3=L0
MRS R0, LR ; R0=LR
MSR LR, R0 ; LR=R0
栈指针
栈空间是一片连续分配的内存空间,用于存放函数调用栈中的临时变量,它的内存地址由高到低分配。它的分配方式非常简单,只需一个栈顶指针记录最后分配的地址,push
时栈顶指针地址向下减少,pop
时栈顶指针地址向上增加。一般在内存中堆和栈分别位于内存空间的两端:
我们通过看一段编译器生成的汇编代码简单理解一下栈指针寄存器的作用。这里不需要完全理解汇编的内容,只需要关注代码对于 SP
寄存器的操作。
我们看看下面这段 Rust
例子:
#[entry]
fn main() -> ! {
let input = 15;
let result = foo(&input);
loop {}
}
fn foo(input: &u8) -> u8 {
let result = *input + 2;
result
}
关闭所有编译器优化选项以及溢出检查,我们得到最基础的汇编实现:
00000416 <main>:
#[entry]
416: sub sp, #8 ; SP -= 8 // SP 指针下降,分配 8 字节栈空间
418: movs r0, #15 ; R0 = 15 // R0 载入立即值 15
let input = 15;
41a: strb.w r0, [sp, #6] ; *(SP + 6) = R0 // 向 input 写入一字节 R0 的值,即 input = 15。input 位于栈顶向上第 6 字节
41e: add.w r0, sp, #6 ; R0 = SP + 6 // 计算 &input 引用地址。存入 R0 作为 foo() 函数入参。
let result = foo(&input);
422: bl 400 <_ZN5hello3foo17hf2bcefbaa6a71063E> ; PC = &foo; LR = 426 // 跳转函数到 foo() 并记录函数返回点
426: strb.w r0, [sp, #7] ; *(SP + 7) = R0 // 保存函数返回值(一字节)到 result。result 位于栈顶向上第 7 字节
42a: b.n 42c <main+0x16>
loop {}
42c: b.n 42e <main+0x18>
42e: b.n 42e <main+0x18> ; PC = 42e // 死循环
00000400 <_ZN5hello3foo17hf2bcefbaa6a71063E>:
fn foo(input: &u8) -> u8 {
400: sub sp, #8 ; SP -= 8 // SP 指针下降,分配 8 字节栈空间
402: str r0, [sp, #0] ; *(SP + 0) = R0 // 提取入参 R0 到局部变量 input
let result = *input + 2;
404: ldr r0, [sp, #0] ; R0 = *(SP + 0) // R0 载入栈中的 input。
406: ldrb r0, [r0, #0] ; R0 = *R0 // 解引用 R0 地址值对应内存中的数据(一字节),并保存到 R0
408: adds r0, #2 ; R0 += 2
40a: strb.w r0, [sp, #7] ; *(SP + 7) = R0 // 向 result 写入一字节 R0 的值
result
40e: ldrb.w r0, [sp, #7] ; R0 = *(SP + 7) // R0 载入 result。R0 将作为函数返回值。
412: add sp, #8 ; SP += 8 // SP 指针上升,释放 8 字节栈空间
414: bx lr ; PC = LR // 函数返回
add
a b: 计算 a + b,结果存储在 asub
a b: 计算 a - b,结果存储在 amovs
a b: 寄存器赋值 a = b,同时设置条件 flagstr
a b: 内存赋值一字(32位) b = astrb
a b: 内存赋值一字节 b = aldr
a b: 内存读取一字(32位) a = bldrb
a b: 内存读取一字节 a = bbl
fn: 调用函数 fnbx lr
: 函数返回,等价于mov lr pc
b.n
: 无条件跳转,即 goto
建议多花一两分钟理解上面这段汇编,直观感受栈变量和栈指针是如何分配和使用的。
双栈指针寄存器
Cortex-M3
事实上有两个栈指针寄存器,分别为:
- 主栈指针寄存器
MSP
(Main Stack Pointer),用于OS
内核进程。 - 进程指针寄存器
PSP
(Process Stack Pointer),用于应用进程。
它们可以分别存储不同的值,且可以通过寄存器名 MSP
和 PSP
直接读写。寄存器名 SP
指代其中哪一个取决于当前 CONTROL
寄存器的 bit[1]
。
CONTROL[1]=0
选择主堆栈指针CONTROL[1]=1
选择进程堆栈指针
通过切换 CONTROL
寄存器的值我们可以轻松地在内核与应用间切换调用栈。通常我们不会直接读写 CONTROL
寄存器,而是使用不同的中断返回地址来切换调用栈。(这是因为如果开启了特权保护,用户进程无法使用 MRS
以及 MSR
指令,进而无法读写 CONTROL
,只能通过中断提权)
中断返回地址
CPU
在进入中断处理函数时会给 LR
赋予初始值,并在返回时赋予 PC
。中断处理函数的返回地址只能取以下三个特殊值之一,其他值会导致 Hard Fault
:
- 0xFFFFFFF9 : 返回到线程模式 (使用
MSP
) - 用于切换到内核进程 - 0xFFFFFFFD : 返回到线程模式 (使用
PSP
) - 用于切换到应用进程 - 0xFFFFFFF1 : 返回到异常处理模式 (使用
MSP
) - 用于切换到上一层嵌套中断处理函数
这里线程模式 (thread mode
) 即正常执行流,异常处理模式 (handler mode
) 为中断处理函数执行流。是核心操作模式 (operation mode
) 的两种状态,这里可以先不理会,有兴趣可以去查找有关 Cortex-M3
操作模式和特权模式的资料。
如果当前执行流正在使用 MSP
,中断发生前 LR
初始值会被自动赋予 0xFFFFFFF9
; 反之如果当前执行流正在使用 PSP
,中断发生前 LR
初始值会被自动赋予 0xFFFFFFFD
。这样在一般情况下中断处理前后 MSP
与 PSP
使用模式不会发生改变。
当然,我们也可以通过手动改变这一返回地址来切换栈,但是首先,我们需要一个中断。
SVC (Supervisor Call) 中断
这是 Cortex-M3
提供的一个特殊中断,它不像外部中断,它可以由 SVC
指令直接触发。它可以有一个参数比如 svc 0x01
,这样在触发 SVC
中断的同时会把 0x01
赋值 R0
,常用于 syscall
传递系统调用号。而且 SVC
中断拥有最低优先级,也就是说,它不会嵌套于其他中断。
我们实现一个简单的 SVC
中断,它会在被调用时来回切换 MSP
和 PSP
模式,这样就实现了最基础的上下文切换:
/// Toggle context between kernel and task
///
/// SVC interrupt can only be fired by instruction `svc`.
///
/// SVC handler is an interrupt handler, which means it will
/// be executed in handler mode, and because of that, it could
/// choose the execution context when it returns by loading special
/// EXC_RETURN value into pc register.
///
/// EXC_RETURN varients:
/// - 0xfffffff9 : return to msp (thread mode) - switch to kernel
/// - 0xfffffffd : return to psp (thread mode) - switch to task
/// - 0xfffffff1 : return to msp (handler mode) - return to another interrupt handler
///
/// `msp` means the Main Stack Pointer and
/// `psp` means the Process Stack Pointer.
#[no_mangle]
#[naked]
pub unsafe extern "C" fn svc_handler() {
asm!("
cmp lr, #0xfffffff9
bne to_kernel
movw lr, #0xfffd
movt lr, #0xffff
bx lr
to_kernel:
movw lr, #0xfff9
movt lr, #0xffff
bx lr"
:::: "volatile" );
}
cmp
a b: 对比 a,b,相等则设置条件 flagbne
: 若条件 flag 为否则跳转movw
: 赋值低 16 位movt
: 赋值高 16 位
在进入 svc_handler()
后,我们首先检查 LR
寄存器,如果不等于 #0xfffffff9
就意味着现在是 PSP
模式,这样的话我们跳到 to_kernel
给 LR
赋值 #0xfffffff9
并返回,进入 MSP
模式;否则就是处于 MSP
模式,赋值 #0xfffffffd
,进入PSP
模式。
现场保护
好了,现在终于可以开始实现上下文切换了。但别急,我们还要给用户进程创建一个栈空间:
const TASK_STACK_SIZE: usize = 100;
static mut TASK_STACK: [usize; TASK_STACK_SIZE] = [0; TASK_STACK_SIZE];
// gek the highest address of the buffer
let stack_pointer = unsafe { TASK_STACK.last_mut().unwrap() as *mut usize };
在切换栈指针来切换上下文之前,我们还需要提前准备好栈空间初始值。这是因为 CPU
在调用中断处理函数时,会自动将 R0
, R1
, R2
, R3
, LR
, PC
以及 xPSR
入栈,以及在中断返回时,从 SP
指向的栈中恢复这些寄存器。所以我们要提前在栈空间中给这些寄存器值赋予初始值。
这里我们实现一个发散的用户函数,因此不需要处理用户进程函数返回:
/// Set initial register of the context of task
///
/// The processor will automaticllay load the top 8 words(u32)
/// from the stakc frame of task into register when switching to context.
pub unsafe fn push_function_call(user_stack: *mut usize, callback: fn() -> !) -> *mut usize {
let stack_bottom = user_stack.offset(-8);
write_volatile(stack_bottom.offset(7), 0x01000000); // xPSR
write_volatile(stack_bottom.offset(6), callback as usize | 1); // PC
write_volatile(stack_bottom.offset(5), 0 | 0x1); // LR
write_volatile(stack_bottom.offset(3), 0); // R3
write_volatile(stack_bottom.offset(2), 0); // R2
write_volatile(stack_bottom.offset(1), 0); // R1
write_volatile(stack_bottom.offset(0), 0); // R0
stack_bottom
}
PS:ARM
规范规定程序地址最后一位必须为 1。
这里还要注意用户进程的 R4-R11
并没有被保护,因此要手动保护它们。我们先定义一个结构用来保存 R4-R11
以及栈指针:
pub struct Process {
stack_ptr: *mut usize,
// R4 - R11
states: [usize; 8],
}
impl Process {
/// Initialize stack frame of task
pub unsafe fn new(stack_ptr: *mut usize, callback: fn() -> !) -> Self {
Self {
stack_ptr: push_function_call(stack_ptr, callback),
states: [0; 8],
}
}
/// Switch context from kernel to task
pub fn switch_to_task(&mut self) {
unsafe { self.stack_ptr = switch_to_task(self.stack_ptr, &mut self.states) }
}
}
实现上下文切换
下面我们实现一个由内核进程进入用户进程的上下文切换入口:
/// Setup task context and switch to it
///
/// This function is doing these few steps:
/// 1. Saves registers {r4-r12, lr} into msp (by complier ABI).
/// 2. Load task stack address into psp.
/// 3. Restore the register states of task from `process_regs` into {r4-r11}.
/// 4. Invoke SVC execption in order to jump into svc_handler,
/// therefore we switched to task context.
/// 5. Saves registers states {r4-r11} into `process_regs`
/// when switched back to kernel (by systick_handler or svc_handler),
/// 6. Restore new psp into `user_stack`.
/// 7. Restore kernel registers states {r4-r12, lr->pc} from msp (by complier ABI).
///
/// The first step and last step is performed by function call ABI convention,
/// so we have to ensure this function is never inlined.
#[inline(never)]
#[no_mangle]
pub unsafe extern "C" fn switch_to_task(
mut user_stack: *mut usize,
process_regs: &mut [usize; 8],
) -> *mut usize {
asm!("
/* Load bottom of stack into Process Stack Pointer */
msr psp, $1
/* Load non-hardware-stacked registers from Process stack */
/* Ensure that $2 is stored in a callee saved register */
ldmia $2, {r4-r11}
/* SWITCH */
svc 0xff /* It doesn't matter which SVC number we use here */
/* Push non-hardware-stacked registers into Process struct's */
/* regs field */
stmia $2, {r4-r11}
mrs $0, PSP /* PSP into r0 */
"
: "={r0}"(user_stack)
: "{r0}"(user_stack), "{r1}"(process_regs as *mut _ as *mut _)
: "r4","r5","r6","r7","r8","r9","r10","r11" : "volatile" );
user_stack
}
ldmia
a {b}: 读取内存,对 b 中寄存器循环执行 a = b,且每次循环后 a += 4stmia
a {b}: 写入内存,对 b 中寄存器循环执行 b = a,且每次循环后 a -= 4
这里逻辑比较复杂,我们分别来说:
首先,由于我们在 asm!()
属性里声明了汇编代码段需要保护 R4-R11
,因此编译器在函数开头结尾会分别对 R4-R11
入栈和出栈,所以在汇编段里无需手动保护内核进程的 R4-R11
。
: "r4","r5","r6","r7","r8","r9","r10","r11" : "volatile" );
其次,我们通过 asm!()
属性要求将入参 user_stack
和 process_regs
存储到 R0
以及 R1
,在汇编段中用 $1
, $2
指代;另外还声明了汇编段会赋值 user_stack
,用 $0
指代。
: "={r0}"(user_stack)
: "{r0}"(user_stack), "{r1}"(process_regs as *mut _ as *mut _)
然后,使用 MSR
指令将用户进程栈顶指针写入 PSP
寄存器。
/* Load bottom of stack into Process Stack Pointer */
msr psp, $1
接着我们需要从 process_regs
中手动载入用户进程的 R4-R11
,这时 R4-R11
中存储的是已经保护了的内核进程的 R4-R11
值,因此可以直接覆盖。在这之后就算作好了切换准备。
/* Load non-hardware-stacked registers from Process stack */
/* Ensure that $2 is stored in a callee saved register */
ldmia $2, {r4-r11}
这时我们就可以触发 SVC
中断进入 svc_handler()
。svc_handler()
中断开始时 CPU
会自动将当前 R0-R4
, LR
, PC
以及 xPSR
压入当前 SP
寄存器 (也就是 MSP
)记录的栈空间。
svc_handler()
中我们切换到 PSP
模式,中断返回时会从 PSP
栈上弹出我们用 push_function_call()
准备好的 R0-R4
, LR
, PC
以及 xPSR
。
从这里开始,CPU
的执行流就进入到了用户进程中,直到用户进程再次触发 SVC
中断执行上述相反的现场保护/恢复并切换回 MSP
模式。
svc 0xff
接下来我们只需和之前相反操作,保护用户进程的 R4-R11
以及最新的 PSP
:
/* Push non-hardware-stacked registers into Process struct's */
/* regs field */
stmia $2, {r4-r11}
/* Load bottom of stack into Process Stack Pointer */
mrs $0, PSP /* PSP into r0 */
函数返回前,编译器会自动恢复内核进程的 R4-R11
。这时执行流又回到了内核进程。
实现了从内核进程进入用户进程的入口,我们再实现一个由用户进程交回内核控制权的 syscall()
入口,它只会简单触发 SVC
中断:
#[no_mangle]
pub extern "C" fn syscall() {
unsafe {
asm!("svc 0xff" :::: "volatile");
}
}
调度
我们已经能够进行最简单的上下文切换了,下面为它实现一个极简的非抢占式调度内核。下面内核进程循环会把时间片交给用户进程 task()
,直到 task()
主动调用 syscall()
交出时间片:
#[no_mangle]
#[inline(never)]
fn main() -> ! {
let mut dp = stm32f103xx::Peripherals::take().unwrap();
// initialize task stack
let stack_pointer = unsafe { TASK_STACK.last_mut().unwrap() as *mut usize };
let mut process_task = unsafe { Process::new(stack_pointer, task) };
// initialize resourses
rcc::rcc_clock_init(&mut dp.RCC, &mut dp.FLASH);
usart::usart_init(&mut dp.RCC, &mut dp.GPIOA, &mut dp.USART2);
led::led_init(&mut dp.RCC, &mut dp.GPIOB);
// light up
led::set(true);
writeln!(USART, "Kernel started!").unwrap();
// main dispatch loop
loop {
// switch to task
process_task.switch_to_task();
// switched back now
writeln!(USART, "Entering kernel").unwrap();
}
}
#[no_mangle]
fn task() -> ! {
let mut n = 0;
loop {
writeln!(USART, "Entering task n=({})", n).unwrap();
n += 1;
writeln!(USART, "Working").unwrap();
delay();
writeln!(USART, "Work is done").unwrap();
// switch back to kernel
syscall();
}
}
pub fn delay() {
for _ in 0..20000000 {
cortex_m::asm::nop();
}
}
串口输出如下:
Kernel started!
Entering task n=(0)
Working
Work is done
Entering kernel
Entering task n=(1)
Working
Work is done
Entering kernel
...
总结
上下文切换、进程调度总是系统内核中听起来令人生畏的部分,但是事实上它的实现原理是非常简单以及符合直觉的,我们欠缺的只是对 CPU
核心的了解。我写作这篇文章是希望我们不再把 CPU
核心以及系统底层当作黑箱,能够真正理解手头的单片机是怎样运作的。
下一篇将会介绍如何引入定时器实现时间分片的抢占式调度内核。