-
Notifications
You must be signed in to change notification settings - Fork 20
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
Comments
https://zhuanlan.zhihu.com/p/89852850 这篇文章将会涉及部分 ARM 指令集汇编,但这里不假设读者有任何 ARM 汇编知识,只需要掌握汇编的基础格式即可,比如学过 CSAPP 或玩过 TIS-100 系列游戏(此处强烈推荐冬促剁手 (≖◡≖) ) 上下文切换上下文切换是一个操作系统提供的功能,它可以随时让 CPU 执行流在多个进程间切换,而不会影响到进程内部的逻辑。 很多同学对 x86 架构的操作系统的上下文切换已经有所认识,就不对上下文切换这个概念作太多介绍了,直接开始讨论如何实现上下文切换。如果不了解也没有关系,下面这篇文章可以先让你对上下文切换有直观的理解。 七淅:一文让你明白CPU上下文切换 CPU 寄存器上下文切换其实就是对核心寄存器进行合适的保护/恢复现场操作。要实现上下文切换,首先要了解各个核心寄存器的作用。Cortex-M3 典型有 16 个寄存器: 这些寄存器有几种不同的的作用: R0-R3 为函数传参通用寄存器,用来在函数调用时传入参数或者返回函数返回值。 通用寄存器使用 MOV 指令读写
栈指针栈空间是一片连续分配的内存空间,用于存放函数调用栈中的临时变量,它的内存地址由高到低分配。它的分配方式非常简单,只需一个栈顶指针记录最后分配的地址,push 时栈顶指针地址向下减少,pop 时栈顶指针地址向上增加。一般在内存中堆和栈分别位于内存空间的两端: 我们通过看一段编译器生成的汇编代码简单理解一下栈指针寄存器的作用。这里不需要完全理解汇编的内容,只需要关注代码对于 SP 寄存器的操作。 我们看看下面这段 Rust 例子:
关闭所有编译器优化选项以及溢出检查,我们得到最基础的汇编实现:
建议多花一两分钟理解上面这段汇编,直观感受栈变量和栈指针是如何分配和使用的。 双栈指针寄存器Cortex-M3 事实上有两个栈指针寄存器,分别为:
中断返回地址CPU 在进入中断处理函数时会给 LR 赋予初始值,并在返回时赋予 PC。中断处理函数的返回地址只能取以下三个特殊值之一,其他值会导致 Hard Fault: 0xFFFFFFF9 : 返回到线程模式 (使用 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 现场保护好了,现在终于可以开始实现上下文切换了。但别急,我们还要给用户进程创建一个栈空间:
在切换栈指针来切换上下文之前,我们还需要提前准备好栈空间初始值。这是因为 CPU 在调用中断处理函数时,会自动将 R0, R1, R2, R3, LR, PC 以及 xPSR 入栈,以及在中断返回时,从 SP 指向的栈中恢复这些寄存器。所以我们要提前在栈空间中给这些寄存器值赋予初始值。 这里我们实现一个发散的用户函数,因此不需要处理用户进程函数返回:
PS:ARM规范规定程序地址最后一位必须为 1。 这里还要注意用户进程的 R4-R11 并没有被保护,因此要手动保护它们。我们先定义一个结构用来保存 R4-R11 以及栈指针:
实现上下文切换下面我们实现一个由内核进程进入用户进程的上下文切换入口:
ldmia a {b}: 读取内存,对 b 中寄存器循环执行 a = b,且每次循环后 a += 4 首先,由于我们在 asm!() 属性里声明了汇编代码段需要保护 R4-R11,因此编译器在函数开头结尾会分别对 R4-R11 入栈和出栈,所以在汇编段里无需手动保护内核进程的 R4-R11。
其次,我们通过 asm!() 属性要求将入参 user_stack 和 process_regs 存储到 R0 以及 R1,在汇编段中用 $1, $2 指代;另外还声明了汇编段会赋值 user_stack,用 $0 指代。
然后,使用 MSR 指令将用户进程栈顶指针写入 PSP 寄存器。
接着我们需要从 process_regs 中手动载入用户进程的 R4-R11,这时 R4-R11 中存储的是已经保护了的内核进程的 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 模式。
接下来我们只需和之前相反操作,保护用户进程的 R4-R11 以及最新的 PSP:
函数返回前,编译器会自动恢复内核进程的 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();
}
} 串口输出如下:
总结上下文切换、进程调度总是系统内核中听起来令人生畏的部分,但是事实上它的实现原理是非常简单以及符合直觉的,我们欠缺的只是对 CPU 核心的了解。我写作这篇文章是希望我们不再把 CPU 核心以及系统底层当作黑箱,能够真正理解手头的单片机是怎样运作的。 |
问答如何实现将中断函数插入寄存器?
https://blog.csdn.net/guoyiyan1987/article/details/80240218
对于本文而言,我们重点关注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了 |
https://zhuanlan.zhihu.com/p/63326820
https://github.com/cisen/preemptive
这次带来新的系列,手把手教大家从零开始设计和搭建一个最简单的嵌入式实时抢占式内核,看到这个标题先别先入为主认为学习它很难或者认为没有它实际用处,实际上,我们要设计的这个内核非常精简易懂,既囊括了几章讲过的知识,可以作为一次概括性的复习,同时可以深入学习 Cotex-M3 的内核操作状态和核心寄存器的知识点。
为什么要学习可抢占式内核
不管在我们们自学时或是读书时上《操作系统》课程时,内核总是重点中的重点,在日常与 Linux 打交道的时候,我们也经常会在 Kernel 态和 Userlang 间来回切换,而到了实时嵌入式领域,我们可能会用到 ucos 或 freertos,但是我们不一定清楚这些操作系统到底做了什么事情,以及如何在我们之前使用的裸机环境之上提供内核与用户空间分离,权限控制以及多线程切换。学习内核最基本原理可以让我们在使用这些系统时更清楚每时每刻到底在发生什么事情,知道性能调优该如何下手。
什么是可抢占式内核
抢占式内核对应的是非抢占式内核,他们都负责管理全局硬件及内存资源,调度多个进程运行。他们的区别在于内核是否有权利强行夺回用户进程的执行权,这也是抢占一词的定义。
当内核是不可抢占的时候,用户进程有义务在一定执行时间后返回以让内核调度其他进程,如果某个进程执行时间过长就会阻塞整个系统,这时如果有任何外部中断到来都会进入 pending 状态,结果会带来极大的响应延迟。
可抢占式内核上的进程完全不需要关心内核及其他进程是如何运作的,它甚至可以陷入死循环,内核也有能力将足够的时间分片分给其他进程。

图中的 task4 是 Linux 中一个高优先级的进程,它可以在时间分片外抢占其他进程,在嵌入式系统中扮演这个角色的一般是驱动程序。
为什么内核可以抢占正在执行的进程
这个问题的关键是中断(interrupt),不管是嵌入式架构还是 x86 桌面架构在这一点上的原理都是一样的,CPU(或 MCU) 都会提供一系列硬件中断,这些中断的优先级总是高于一切进程(包括 Kernel 进程),那么内核便可以通过中断,比如设定定时器定时引发中断,然后在中断处理时将执行权交给 Kernel 进程便完成了抢占的过程。也有的抢占是来自于硬件中断,比如网卡硬件接收到网络数据包,这时硬件触发数据包到达中断,内核就会将执行权交给网卡驱动(运行在 Kernel)。
最终实现的效果 (画大饼时间)
例子里有两个进程,分别负责计算斐波那契额数列以及质数判定然后打印结果到串口,他们各自的工作进程都是死循环,但是内核会让他们平分计算资源。
串口输出:
Limitations
我们实现的内核将只可以运行在 STM32F103 单片机上,因为实现它的作用主要是为了学习,为了方便不会加入 HAL 来泛化适用范围,但是需要泛化的部分都是集中在时钟初始化和串口初始化上,内核部分在 Cortex-M3 甚至 Cortex-M4 上都是通用的。
另外,这个内核为了实现简单,暂时没有使用 MPU 内存保护单元和特权模式,这些是一个实用的系统必须具备的特性,用来保护 Kernel 内存不会被 Userland 进程污染。而且这个内核没有使用高级的线程调度算法,只是定时切换时间分片,因为这部分内容算法比较复杂,却和嵌入式内核的原理关系不大,有兴趣的读者可以阅读拓展内容。
The text was updated successfully, but these errors were encountered: