iis 装完framework4 7 无法切换_从零开始构建实时抢占式内核 (二):最干货的上下文切换介绍...

48699a862a877925c653a0bad49911f6.png
本章源码已放在 Github Preemptive 项目的 Chapter3-ContextSwitch 章节。

这篇文章将介绍 Cortex-M3 指令集的一些基础知识,并会在 Cortex-M3 架构上实现上下文切换。其实这个实现非常简单,去除注释仅仅不到一百行代码,但已经涵盖了很多 CPU 核心的重要概念。

这篇文章将会涉及部分 ARM 指令集汇编,但这里不假设读者有任何 ARM 汇编知识,只需要掌握汇编的基础格式即可,比如学过 CSAPP 或玩过 TIS-100 系列游戏(此处强烈推荐冬促剁手 (≖◡≖) )

上下文切换

上下文切换是一个操作系统提供的功能,它可以随时让 CPU 执行流在多个进程间切换,而不会影响到进程内部的逻辑。

很多同学对 x86 架构的操作系统的上下文切换已经有所认识,就不对上下文切换这个概念作太多介绍了,直接开始讨论如何实现上下文切换。如果不了解也没有关系,下面这篇文章可以先让你对上下文切换有直观的理解。

七淅:一文让你明白CPU上下文切换​zhuanlan.zhihu.com
b8506914659d54c01643086d091d8aad.png

CPU 寄存器

上下文切换其实就是对核心寄存器进行合适的保护/恢复现场操作。要实现上下文切换,首先要了解各个核心寄存器的作用。Cortex-M3 典型有 16 个寄存器:

f27d3674bb7a060f53a38ce16a0df2f7.png

这些寄存器有几种不同的的作用:

  • 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, R14R16 无法使用 MOV 指令读写,需要使用专门的 MRS(读取) 和 MSR(写入) 指令
MOV R3, R0        ; R3=L0
MRS R0, LR        ; R0=LR
MSR LR, R0        ; LR=R0

栈指针

栈空间是一片连续分配的内存空间,用于存放函数调用栈中的临时变量,它的内存地址由高到低分配。它的分配方式非常简单,只需一个栈顶指针记录最后分配的地址,push 时栈顶指针地址向下减少,pop 时栈顶指针地址向上增加。一般在内存中堆和栈分别位于内存空间的两端:

2851b55e57d2ffc4c305d797ed1a5887.png

我们通过看一段编译器生成的汇编代码简单理解一下栈指针寄存器的作用。这里不需要完全理解汇编的内容,只需要关注代码对于 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,结果存储在 a sub a b: 计算 a - b,结果存储在 a movs a b: 寄存器赋值 a = b,同时设置条件 flag str a b: 内存赋值一字(32位) b = a strb a b: 内存赋值一字节 b = a ldr a b: 内存读取一字(32位) a = b ldrb a b: 内存读取一字节 a = b bl fn: 调用函数 fn bx lr: 函数返回,等价于 mov lr pc b.n: 无条件跳转,即 goto

建议多花一两分钟理解上面这段汇编,直观感受栈变量和栈指针是如何分配和使用的。

双栈指针寄存器

Cortex-M3 事实上有两个栈指针寄存器,分别为:

  • 主栈指针寄存器 MSP (Main Stack Pointer),用于 OS 内核进程。
  • 进程指针寄存器 PSP (Process Stack Pointer),用于应用进程。

它们可以分别存储不同的值,且可以通过寄存器名 MSPPSP 直接读写。寄存器名 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。这样在一般情况下中断处理前后 MSPPSP 使用模式不会发生改变。

当然,我们也可以通过手动改变这一返回地址来切换栈,但是首先,我们需要一个中断。

SVC (Supervisor Call) 中断

这是 Cortex-M3 提供的一个特殊中断,它不像外部中断,它可以由 SVC 指令直接触发。它可以有一个参数比如 svc 0x01,这样在触发 SVC 中断的同时会把 0x01 赋值 R0,常用于 syscall 传递系统调用号。而且 SVC 中断拥有最低优先级,也就是说,它不会嵌套于其他中断。

我们实现一个简单的 SVC 中断,它会在被调用时来回切换 MSPPSP 模式,这样就实现了最基础的上下文切换:

/// 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,相等则设置条件 flag bne: 若条件 flag 为否则跳转 movw: 赋值低 16 位 movt: 赋值高 16 位

在进入 svc_handler() 后,我们首先检查 LR 寄存器,如果等于 #0xfffffff9 就意味着现在是 PSP 模式,这样的话我们跳到 to_kernelLR 赋值 #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 += 4 stmia 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_stackprocess_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 核心以及系统底层当作黑箱,能够真正理解手头的单片机是怎样运作的。

下一篇将会介绍如何引入定时器实现时间分片的抢占式调度内核。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值