Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

从零开始构建实时抢占式内核【转】 #860

Open
cisen opened this issue Mar 29, 2020 · 2 comments
Open

从零开始构建实时抢占式内核【转】 #860

cisen opened this issue Mar 29, 2020 · 2 comments

Comments

@cisen
Copy link
Owner

cisen commented Mar 29, 2020

https://zhuanlan.zhihu.com/p/63326820
https://github.com/cisen/preemptive

这次带来新的系列,手把手教大家从零开始设计和搭建一个最简单的嵌入式实时抢占式内核,看到这个标题先别先入为主认为学习它很难或者认为没有它实际用处,实际上,我们要设计的这个内核非常精简易懂,既囊括了几章讲过的知识,可以作为一次概括性的复习,同时可以深入学习 Cotex-M3 的内核操作状态和核心寄存器的知识点。

为什么要学习可抢占式内核

不管在我们们自学时或是读书时上《操作系统》课程时,内核总是重点中的重点,在日常与 Linux 打交道的时候,我们也经常会在 Kernel 态和 Userlang 间来回切换,而到了实时嵌入式领域,我们可能会用到 ucos 或 freertos,但是我们不一定清楚这些操作系统到底做了什么事情,以及如何在我们之前使用的裸机环境之上提供内核与用户空间分离,权限控制以及多线程切换。学习内核最基本原理可以让我们在使用这些系统时更清楚每时每刻到底在发生什么事情,知道性能调优该如何下手。

v2-0cbf4b7b1e609d6d0ac63f6727d0039a_720w

什么是可抢占式内核

抢占式内核对应的是非抢占式内核,他们都负责管理全局硬件及内存资源,调度多个进程运行。他们的区别在于内核是否有权利强行夺回用户进程的执行权,这也是抢占一词的定义。

当内核是不可抢占的时候,用户进程有义务在一定执行时间后返回以让内核调度其他进程,如果某个进程执行时间过长就会阻塞整个系统,这时如果有任何外部中断到来都会进入 pending 状态,结果会带来极大的响应延迟。

可抢占式内核上的进程完全不需要关心内核及其他进程是如何运作的,它甚至可以陷入死循环,内核也有能力将足够的时间分片分给其他进程。
v2-8bea98c7b09fe77cd79dc18c5040e2bb_720w

图中的 task4 是 Linux 中一个高优先级的进程,它可以在时间分片外抢占其他进程,在嵌入式系统中扮演这个角色的一般是驱动程序。

为什么内核可以抢占正在执行的进程

这个问题的关键是中断(interrupt),不管是嵌入式架构还是 x86 桌面架构在这一点上的原理都是一样的,CPU(或 MCU) 都会提供一系列硬件中断,这些中断的优先级总是高于一切进程(包括 Kernel 进程),那么内核便可以通过中断,比如设定定时器定时引发中断,然后在中断处理时将执行权交给 Kernel 进程便完成了抢占的过程。也有的抢占是来自于硬件中断,比如网卡硬件接收到网络数据包,这时硬件触发数据包到达中断,内核就会将执行权交给网卡驱动(运行在 Kernel)。

最终实现的效果 (画大饼时间)

例子里有两个进程,分别负责计算斐波那契额数列以及质数判定然后打印结果到串口,他们各自的工作进程都是死循环,但是内核会让他们平分计算资源。

const TASK_NUM: usize = 2;
// 内存空间不足硬件产生中断
const TASK_STACK_SIZE: usize = 100;
static mut TASK_STACKS: [[usize; TASK_STACK_SIZE]; TASK_NUM] = [[0; TASK_STACK_SIZE]; TASK_NUM];

#[no_mangle]
fn main() -> ! {
    // Initialization omitted

    // initialize task stack
    let mut process_task1 =
        unsafe { Process::new(TASK_STACKS[0].last_mut().unwrap() as *mut usize, task1) };

    let mut process_task2 =
        unsafe { Process::new(TASK_STACKS[1].last_mut().unwrap() as *mut usize, task2) };

    writeln!(USART, "Kernel started!");

    // main dispatcher loop
    loop {
        writeln!(USART, "\nExecuting task1!");
        process_task1.switch_to_task();
        writeln!(USART, "\nExecuting task2!");
        process_task2.switch_to_task();
    }
}

fn task1() -> ! {
    for n in 0.. {
        writeln!(USART, "fib({})={}", n, fib(n));
    }
}

fn task2() -> ! {
    for n in 0.. {
        writeln!(USART, "is_prime({})={}", n, is_prime(n));
    }
}

串口输出:

Kernel started!

Executing task1!
task1: fib(0)=1
task1: fib(1)=1
task1: fib(2)=2

Executing task2!
task2: is_prime(1)=true
task2: is_prime(2)=true
task2: is_prime(3)=true

Executing task1!
task1: fib(3)=3
task1: fib(4)=5
task1: fib(5)=8

Executing task2!
task2: is_prime(4)=false
task2: is_prime(5)=true
task2: is_prime(6)=false
...

Limitations

我们实现的内核将只可以运行在 STM32F103 单片机上,因为实现它的作用主要是为了学习,为了方便不会加入 HAL 来泛化适用范围,但是需要泛化的部分都是集中在时钟初始化和串口初始化上,内核部分在 Cortex-M3 甚至 Cortex-M4 上都是通用的。

另外,这个内核为了实现简单,暂时没有使用 MPU 内存保护单元和特权模式,这些是一个实用的系统必须具备的特性,用来保护 Kernel 内存不会被 Userland 进程污染。而且这个内核没有使用高级的线程调度算法,只是定时切换时间分片,因为这部分内容算法比较复杂,却和嵌入式内核的原理关系不大,有兴趣的读者可以阅读拓展内容。

@cisen
Copy link
Owner Author

cisen commented Mar 29, 2020

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

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

上下文切换

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

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

七淅:一文让你明白CPU上下文切换

zhuanlan.zhihu.com
图标

CPU 寄存器

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

v2-8ea0459f0886d828a7bb08bc2e59deb6_720w

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

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 时栈顶指针地址向上增加。一般在内存中堆和栈分别位于内存空间的两端:
v2-d4576b432cf67e33913e9d6c86cc0a1f_720w

我们通过看一段编译器生成的汇编代码简单理解一下栈指针寄存器的作用。这里不需要完全理解汇编的内容,只需要关注代码对于 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]
 // SP 指针下降,分配 8 字节栈空间
 416:    sub    sp, #8              ; SP -= 8
// R0 载入立即值 15        
 418:    movs   r0, #15             ; R0 = 15         
    let input = 15;
// 向 input 写入一字节 R0 的值,即 input = 15。input 位于栈顶向上第 6 字节
 41a:    strb.w r0, [sp, #6]        ; *(SP + 6) = R0  
// 计算 &input 引用地址。存入 R0 作为 foo() 函数入参。
 41e:    add.w  r0, sp, #6          ; R0 = SP + 6     
    let result = foo(&input);
 // 跳转函数到 foo() 并记录函数返回点
 422:    bl     400 <_ZN5hello3foo17hf2bcefbaa6a71063E>  ; PC = &foo; LR = 426 
// 保存函数返回值(一字节)到 result。result 位于栈顶向上第 7 字节
 426:    strb.w r0, [sp, #7]        ; *(SP + 7) = R0  
 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 {
 // SP 指针下降,分配 8 字节栈空间
 400:    sub    sp, #8              ; SP -= 8        
// 提取入参 R0 到局部变量 input
 402:    str    r0, [sp, #0]        ; *(SP + 0) = R0  
    let result = *input + 2;
// R0 载入栈中的 input。
 404:    ldr    r0, [sp, #0]        ; R0 = *(SP + 0)  
// 解引用 R0 地址值对应内存中的数据(一字节),并保存到 R0
 406:    ldrb   r0, [r0, #0]        ; R0 = *R0        
 408:    adds   r0, #2              ; R0 += 2
  // 向 result 写入一字节 R0 的值
 40a:    strb.w    r0, [sp, #7]     ; *(SP + 7) = R0
    result
 // R0 载入 result。R0 将作为函数返回值。
 40e:    ldrb.w    r0, [sp, #7]     ; R0 = *(SP + 7) 
 // SP 指针上升,释放 8 字节栈空间
 412:    add    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),用于应用进程。
    它们可以分别存储不同的值,且可以通过寄存器名 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,相等则设置条件 flag
bne: 若条件 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 += 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_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 核心以及系统底层当作黑箱,能够真正理解手头的单片机是怎样运作的。

@cisen
Copy link
Owner Author

cisen commented Apr 24, 2020

问答

如何实现将中断函数插入寄存器?

  • 通过ISR_VECTOR将中断函数地址插入FLASH
  • 机器启动的时候调用reset_handlerISR_VECTOR的数据读取到RAM的初始位置(固定偏移)
  • 出现中断的时候会自动读取该端内存指向的函数并执行,见:

https://blog.csdn.net/guoyiyan1987/article/details/80240218
对于ARM处理器而言,当发生异常的时候,处理器会暂停当前指令的执行,保存现场,转而去执行对应的异常向量处的指令,当处理完该异常的时候,恢复现场,回到原来的那点去继续执行程序。系统所有的异常向量(共计8个)组成了异常向量表。向量表(vector table)的代码如下:

.section .vectors, "ax", %progbits 
__vectors_start: 
    W(b)    vector_rst 
    W(b)    vector_und 
    W(ldr)    pc, __vectors_start + 0x1000 
    W(b)    vector_pabt 
    W(b)    vector_dabt 
    W(b)    vector_addrexcptn 
    W(b)    vector_irq ---------------------------IRQ Vector 
    W(b)    vector_fiq

对于本文而言,我们重点关注vector_irq这个exception vector。异常向量表可能被安放在两个位置上:

(1)异常向量表位于0x0的地址。这种设置叫做Normal vectors或者Low vectors。

(2)异常向量表位于0xffff0000的地址。这种设置叫做high vectors

具体是low vectors还是high vectors是由ARM的一个叫做的SCTLR寄存器的第13个bit (vector bit)控制的。对于启用MMU的ARM Linux而言,系统使用了high vectors。为什么不用low vector呢?对于linux而言,0~3G的空间是用户空间,如果使用low vector,那么异常向量表在0地址,那么则是用户空间的位置,因此linux选用high vector。当然,使用Low vector也可以,这样Low vector所在的空间则属于kernel space了(也就是说,3G~4G的空间加上Low vector所占的空间属于kernel space),不过这时候要注意一点,因为所有的进程共享kernel space,而用户空间的程序经常会发生空指针访问,这时候,内存保护机制应该可以捕获这种错误(大部分的MMU都可以做到,例如:禁止userspace访问kernel space的地址空间),防止vector table被访问到。对于内核中由于程序错误导致的空指针访问,内存保护机制也需要控制vector table被修改,因此vector table所在的空间被设置成read only的。在使用了MMU之后,具体异常向量表放在那个物理地址已经不重要了,重要的是把它映射到0xffff0000的虚拟地址就OK了

@cisen cisen added the Tock label Jun 23, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant