浅谈特权模式与模式切换

恐龙书上的 User Mode 与 Kernel Mode

在恐龙书中有提到,作业系统一般会在 User Mode 与 Kernel Mode 之间切换,Kernel Mode 具有更高的系统控制权且掌管了多数的硬体资源。
而 User Mode 通常用於执行 User Application,如果 User Application 呼叫了 System call,系统便会切换到 Kernel Mode 进行处理,并在处理完成後退回 User Mode。

恐龙本没有教的事:特权指令

前面提到: Kernel Mode 具有更高的系统控制权且掌管了多数的硬体资源,到底是如何达成的呢?
这与 ISA 的特权模式有很大的关联:

上图为 RISC-V Spec 定义的特权模式,其权限由高到低为: Machine Mode, Supervisor Mode 以及 User Mode
Machine Mode 必须执行绝对可性的程序码以保护系统安全。
每个 Mode 都有属於自己的中断与异常暂存器与中断向量表,使其可以各自维护中断的纪录与应对方式。

专案观摩

由於 mini-riscv-os 仅运行在 Machine Mode 上,且尚没有增加对 system call 的支援,所以我们选用 xv6-riscv 的原始码来学习作业系统的模式切换。

如何支援 System Call

会让作业系统在 User/Kernel Mode 频繁切换的主因就是系统呼叫,所以我们必须先搞清楚系统呼叫对於硬体来说是什麽。
xv6-riscv 使用软件中断做为系统呼叫的信号,所以要进入 Kernel Mode 之前,作业系统会使用 ecall 指令来产生软件中断,进入中断处理後,再让 software_interruptHandler 处理系统呼叫的判断即可。

xv6 使用的特权模式

搞清楚系统呼叫如何产生以後,我们来看一下 xv6-riscv 的 User/Kernel Mode 分别对应哪个特权模式:

  • User Mode: User Mode
  • Kernel Mode: Supervisor Mode

由此可知,当系统呼叫产生软件中断後,系统程序需要从 User Mode 跳进 Supervisor Mode 处理系统呼叫,待结束时再回到 User Mode

回到正题

搞清楚来龙去脉以後,让我们直接阅读原始码学习吧!
首先看到 kernel/riscv.h 定义的状态暂存器:

// Supervisor Status Register, sstatus

#define SSTATUS_SPP (1L << 8)  // Previous mode, 1=Supervisor, 0=User
#define SSTATUS_SPIE (1L << 5) // Supervisor Previous Interrupt Enable
#define SSTATUS_UPIE (1L << 4) // User Previous Interrupt Enable
#define SSTATUS_SIE (1L << 1)  // Supervisor Interrupt Enable
#define SSTATUS_UIE (1L << 0)  // User Interrupt Enable

接着观察 kernel/trap.c 当中的程序码:

void
usertrap(void)
{
  int which_dev = 0;

  if((r_sstatus() & SSTATUS_SPP) != 0)
    panic("usertrap: not from user mode");

  // send interrupts and exceptions to kerneltrap(),
  // since we're now in the kernel.
  w_stvec((uint64)kernelvec);

  struct proc *p = myproc();
  
  // save user program counter.
  p->trapframe->epc = r_sepc();
  
  if(r_scause() == 8){
    // system call

    if(p->killed)
      exit(-1);

    // sepc points to the ecall instruction,
    // but we want to return to the next instruction.
    p->trapframe->epc += 4;

    // an interrupt will change sstatus &c registers,
    // so don't enable until done with those registers.
    intr_on();

    syscall();
  } else if((which_dev = devintr()) != 0){
    // ok
  } else {
    printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
    printf("            sepc=%p stval=%p\n", r_sepc(), r_stval());
    p->killed = 1;
  }

  if(p->killed)
    exit(-1);

  // give up the CPU if this is a timer interrupt.
  if(which_dev == 2)
    yield();

  usertrapret();
}

当系统在 User Mode 的情况下发生中断时,会进行一些安全性检查,这边比较值得一提的动作有:

1. w_stvec((uint64)kernelvec);: 将 S-Mode 下的中断向量表设定为 kernelvec,这也代表系统已经进入 Kernel Mode。
至於系统是如何从 User Mode 跳到 Supervisor Mode,可以参考以下资讯。

在 RISC-V 中,所有中断与异常的处理预设都会由 Machine Mode 处理,为了节省效能开销,我们可以透过设定:

  • mideleg (Machine Interrupt Delegation)
  • medeleg (Machine Exception Delegation)

将中断与异常委托给其他模式处理。
这一段操作我们也可以在 xv6 的原始码中看到:

// in start.c
void start(){
  // ...
  // delegate all interrupts and exceptions to supervisor mode.
  w_medeleg(0xffff);
  w_mideleg(0xffff);
  w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE);
  // ...
}

上面的程序码将原本该由 Machine mode 处理的中断与异常委托给 Supervisor Mode,这也是为何当 User Mode 底下产生中断与异常时,处理器能切换至 Supervisor Mode 的原因。

注意!
中断与异常的委托仅能委托等同或是更高权限的模式处理,像是 Machine Mode 下产生的中断是无法委托 Supervisor/User Mode 处理的。

2. if(r_scause() == 8): 在这个条件成立的情况下,系统会储存修改 epc 暂存器的资料,它记录了产生中断时当下 Program counter 的位址,当系统处理完中断时会将 epc 的资料放回 PC,所以为了避免重复执行 ecall 不断的进入中断,我们会将 ecall 跳过,意即 p->trapframe->epc += 4;
3. usertrapret();: 这边呼叫了 usertrapret() 让系统跳回 User Mode 执行。

观摩完 usertrap(),我们可以跳到它所呼叫到的 kernelvec 挖宝:


# kernel/kernelvic.S

.globl kernelvec
.align 4
kernelvec:
        // make room to save registers.
        addi sp, sp, -256

        // save the registers.
        sd ra, 0(sp)
        sd sp, 8(sp)
        sd gp, 16(sp)
        sd tp, 24(sp)
        sd t0, 32(sp)
        sd t1, 40(sp)
        sd t2, 48(sp)
        sd s0, 56(sp)
        sd s1, 64(sp)
        sd a0, 72(sp)
        sd a1, 80(sp)
        sd a2, 88(sp)
        sd a3, 96(sp)
        sd a4, 104(sp)
        sd a5, 112(sp)
        sd a6, 120(sp)
        sd a7, 128(sp)
        sd s2, 136(sp)
        sd s3, 144(sp)
        sd s4, 152(sp)
        sd s5, 160(sp)
        sd s6, 168(sp)
        sd s7, 176(sp)
        sd s8, 184(sp)
        sd s9, 192(sp)
        sd s10, 200(sp)
        sd s11, 208(sp)
        sd t3, 216(sp)
        sd t4, 224(sp)
        sd t5, 232(sp)
        sd t6, 240(sp)

	// call the C trap handler in trap.c
        call kerneltrap

        // restore registers.
        ld ra, 0(sp)
        ld sp, 8(sp)
        ld gp, 16(sp)
        // not this, in case we moved CPUs: ld tp, 24(sp)
        ld t0, 32(sp)
        ld t1, 40(sp)
        ld t2, 48(sp)
        ld s0, 56(sp)
        ld s1, 64(sp)
        ld a0, 72(sp)
        ld a1, 80(sp)
        ld a2, 88(sp)
        ld a3, 96(sp)
        ld a4, 104(sp)
        ld a5, 112(sp)
        ld a6, 120(sp)
        ld a7, 128(sp)
        ld s2, 136(sp)
        ld s3, 144(sp)
        ld s4, 152(sp)
        ld s5, 160(sp)
        ld s6, 168(sp)
        ld s7, 176(sp)
        ld s8, 184(sp)
        ld s9, 192(sp)
        ld s10, 200(sp)
        ld s11, 208(sp)
        ld t3, 216(sp)
        ld t4, 224(sp)
        ld t5, 232(sp)
        ld t6, 240(sp)

        addi sp, sp, 256

        // return to whatever we were doing in the kernel.
        sret

kernelvec 做的事很简单,就是将处理器的暂存器位置先存起来,呼叫 kerneltrap() 做完中断处理後再跳回来还原暂存器状态。

/* kernel/trap.c */
void 
kerneltrap()
{
  int which_dev = 0;
  uint64 sepc = r_sepc();
  uint64 sstatus = r_sstatus();
  uint64 scause = r_scause();
  
  if((sstatus & SSTATUS_SPP) == 0)
    panic("kerneltrap: not from supervisor mode");
  if(intr_get() != 0)
    panic("kerneltrap: interrupts enabled");

  if((which_dev = devintr()) == 0){
    printf("scause %p\n", scause);
    printf("sepc=%p stval=%p\n", r_sepc(), r_stval());
    panic("kerneltrap");
  }

  // give up the CPU if this is a timer interrupt.
  if(which_dev == 2 && myproc() != 0 && myproc()->state == RUNNING)
    yield();

  // the yield() may have caused some traps to occur,
  // so restore trap registers for use by kernelvec.S's sepc instruction.
  w_sepc(sepc);
  w_sstatus(sstatus);
}

kerneltrap() 所做的事情就跟 mini-riscv-osInterrupt_handler() 差不多,负责处理外部中断与时间中断。

回到 usertrapret(),这个函式主要就是设定中断向量表与 Interrupt Enable 以及 User Page Table:

void
usertrapret(void)
{
  struct proc *p = myproc();

  // we're about to switch the destination of traps from
  // kerneltrap() to usertrap(), so turn off interrupts until
  // we're back in user space, where usertrap() is correct.
  intr_off();

  // send syscalls, interrupts, and exceptions to trampoline.S
  w_stvec(TRAMPOLINE + (uservec - trampoline));

  // set up trapframe values that uservec will need when
  // the process next re-enters the kernel.
  p->trapframe->kernel_satp = r_satp();         // kernel page table
  p->trapframe->kernel_sp = p->kstack + PGSIZE; // process's kernel stack
  p->trapframe->kernel_trap = (uint64)usertrap;
  p->trapframe->kernel_hartid = r_tp();         // hartid for cpuid()

  // set up the registers that trampoline.S's sret will use
  // to get to user space.
  
  // set S Previous Privilege mode to User.
  unsigned long x = r_sstatus();
  x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode
  x |= SSTATUS_SPIE; // enable interrupts in user mode
  w_sstatus(x);

  // set S Exception Program Counter to the saved user pc.
  w_sepc(p->trapframe->epc);

  // tell trampoline.S the user page table to switch to.
  uint64 satp = MAKE_SATP(p->pagetable);

  // jump to trampoline.S at the top of memory, which 
  // switches to the user page table, restores user registers,
  // and switches to user mode with sret.
  uint64 fn = TRAMPOLINE + (userret - trampoline);
  ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp);
}

作业系统之所以能够让处理器在这个步骤切换回 User mode 下工作,跟这一段程序码有很大的关联性:

// set S Previous Privilege mode to User.
  unsigned long x = r_sstatus();
  x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode
  x |= SSTATUS_SPIE; // enable interrupts in user mode
  w_sstatus(x);

它将 sstatus 中的 SPP 写为 0,如此一来,等到处理器跳至 trampoline.S 执行 sret 结束整个中断流程时,就会依据 SPP 的值决定要跳回哪一个模式。

总结

本篇文章带读者粗略的学习 xv6 的中断处理与系统呼叫的机制,如果对中断处理那边感到困惑的话,建议先去阅读 RISC-V 的 Spec 或是教学文章。

因为笔者自己也只有帮 mini-riscv-os 写写 Patch,并不是 RISC-V 的熟手,所以可能无法提供很 Hardcore 的技术文章。
不过,我想这对於想要实作系统呼叫或是 User/Kernel Mode Switching 的朋友们应该是有帮助的。

Reference


<<:  DAY24-EXCEL统计分析:共变数介绍

>>:  [Day24] HTB Devel

[13th][Day12] struct

Day8 go 的变数有着各式各样的型态: int float string pointer ......

人脸辨识-day25 Overfitting、Underfitting

在处理完资料集後,将资料放入模型训练时,会将资料集分为训练集、验证集和测试集,训练集是模型会对训练集...

[浅谈]-NoSQL资料库怎麽选?

前言 NoSQL是目前在云端服务很常使用的一种型态的资料库,仅具备BASE原则。然而在市场上有许多种...

[Vue.js + Axios] ToDoList (下)

续前一篇的例子: 分段进行: Vue 基本结构及使用到的属性: var app = new Vue(...

[Day9] Android : Kotlin笔记:JetPack - Fragment KTX

Fragment KTX 首先要在app的build.gradle加入: dependencies ...