RISCV下的CPU虚拟化

摘要:围绕ArchCpu结构,介绍RISCV架构下的CPU虚拟化工作。

涉及的两个数据结构

hvisor支持多种架构,每个架构的CPU虚拟化需要做的工作不同,但在一个系统中又应该提供统一的接口,故我们将CPU拆分成 PerCpuArchCpu 两个数据结构。

PerCpu

这是一个通用的CPU的描述,在 PerCpu 的文档中已给出介绍。

ArchCpu

ArchCpu 是针对具体架构(本文中介绍RISCV架构)的CPU结构。由这个结构承担CPU具体的行为。

在ARM架构下,也有对应的 ArchCpu ,与本节介绍的 ArchCpu 具体结构略有不同,但他们具有相同的接口(也就是都具有初始化等行为)。

包含的字段如下:

pub struct ArchCpu {
    pub x: [usize; 32], //x0~x31
    pub hstatus: usize,
    pub sstatus: usize,
    pub sepc: usize,
    pub stack_top: usize,
    pub cpuid: usize,
    // pub first_cpu: usize,
    pub power_on: bool,
    pub init: bool,
    pub sstc: bool,
}

各个字段的解释如下:

  • x :通用寄存器的值
  • hstatus :存储Hypervisor状态寄存器的值
  • sstatus :存储Supervisor状态寄存器的值,管理S模式的状态信息,如中断使能标志等
  • sepc :异常处理结束的返回地址
  • stack_top :对应的cpu栈的栈顶
  • power_on :该cpu是否被开启
  • init :该cpu是否已初始化
  • sstc :是否配置了定时器中断

相关方法

这一部分讲解涉及的方法。

ArchCpu::init

这个方法主要是对cpu进行初始化工作,设置初次进入vm的时候的上下文,以及一些CSR的初始化。

pub fn init(&mut self, entry: usize, cpu_id: usize, dtb: usize) {
        write_csr!(CSR_SSCRATCH, self as *const _ as usize); //arch cpu pointer
        self.sepc = entry;
        self.hstatus = 1 << 7 | 2 << 32; //HSTATUS_SPV | HSTATUS_VSXL_64
        self.sstatus = 1 << 8 | 1 << 63 | 3 << 13 | 3 << 15; //SPP
        self.stack_top = self.stack_top() as usize;
        self.x[10] = cpu_id; //cpu id
        self.x[11] = dtb; //dtb addr

        set_csr!(CSR_HIDELEG, 1 << 2 | 1 << 6 | 1 << 10); //HIDELEG_VSSI | HIDELEG_VSTI | HIDELEG_VSEI
        set_csr!(CSR_HEDELEG, 1 << 8 | 1 << 12 | 1 << 13 | 1 << 15); //HEDELEG_ECU | HEDELEG_IPF | HEDELEG_LPF | HEDELEG_SPF
        set_csr!(CSR_HCOUNTEREN, 1 << 1); //HCOUNTEREN_TM
                                          //In VU-mode, a counter is not readable unless the applicable bits are set in both hcounteren and scounteren.
        set_csr!(CSR_SCOUNTEREN, 1 << 1);
        write_csr!(CSR_HTIMEDELTA, 0);
        set_csr!(CSR_HENVCFG, 1 << 63);
        //write_csr!(CSR_VSSTATUS, 1 << 63 | 3 << 13 | 3 << 15); //SSTATUS_SD | SSTATUS_FS_DIRTY | SSTATUS_XS_DIRTY

        // enable all interupts
        set_csr!(CSR_SIE, 1 << 9 | 1 << 5 | 1 << 1); //SEIE STIE SSIE
                                                     // write_csr!(CSR_HIE, 1 << 12 | 1 << 10 | 1 << 6 | 1 << 2); //SGEIE VSEIE VSTIE VSSIE
        write_csr!(CSR_HIE, 0);
        write_csr!(CSR_VSTVEC, 0);
        write_csr!(CSR_VSSCRATCH, 0);
        write_csr!(CSR_VSEPC, 0);
        write_csr!(CSR_VSCAUSE, 0);
        write_csr!(CSR_VSTVAL, 0);
        write_csr!(CSR_HVIP, 0);
        write_csr!(CSR_VSATP, 0);
    }

write_csr!(CSR_SSCRATCH, self as *const _ as usize) 延续了上一个方法的内容,将 ArchCpu 的地址写入 sscratch 。将返回地址设置为入口,将 hstatusSPV 字段设置为1,代表返回vm的时候vm是运行在VS态下的(或理解为异常发生前的vm是在VS态运行的);VSXL 字段设置了VS模式下寄存器的长度。sstatusSPP 等字段给出 Trap 发生之前 CPU 处在哪个特权级等信息。SPPSPV 字段,结合起来使用,能够确定在HS模式下执行 sret 指令应该返回哪个特权级,返回的地址由 spec 设置。

HIDELEGCSR_HEDELEG 这两个寄存器的设置是将某些中断委托给VS态处理。HCOUNTERENSCOUNTEREN 用于限制vm能够访问的性能计数器,此处是使能了 TM 字段,代表允许访问 time 寄存器。HTIMEDELTA 用于调整vm读取 time 寄存器的值,在VS或者VU模式下读取 time 将返回 HTIMEDELTAtime 的和。SIE 的作用的中断使能,我们开启了所有中断。

在代码中注意 write_csr!set_csr! 的区别,write_csr! 采用的是直接写入,也就是覆盖的方法,而 set_csr! 则是采用“or”的做法,设置某些位。

ArchCpu::idle

通过执行wfi指令,将非主cpu设置为低功耗的idle状态。

设置一个特殊的内存页,包含了使得CPU进入低功耗等待状态的指令,从而在系统中未分配任何任务给某些CPU时,可以将它们置于低功耗等待状态,直到发生中断。

pub fn idle(&mut self) -> ! {
        extern "C" {
            fn vcpu_arch_entry() -> !;
        }
        assert!(this_cpu_id() == self.cpuid);
        self.init(0, this_cpu_data().id, this_cpu_data().opaque);
        // reset current cpu -> pc = 0x0 (wfi)
        PARKING_MEMORY_SET.call_once(|| {
            let parking_code: [u8; 4] = [0x73, 0x00, 0x50, 0x10]; // 1: wfi; b 1b
            unsafe {
                PARKING_INST_PAGE[..4].copy_from_slice(&parking_code);
            }

            let mut gpm = MemorySet::<Stage2PageTable>::new();
            gpm.insert(MemoryRegion::new_with_offset_mapper(
                0 as GuestPhysAddr,
                unsafe { &PARKING_INST_PAGE as *const _ as HostPhysAddr - PHYS_VIRT_OFFSET },
                PAGE_SIZE,
                MemFlags::READ | MemFlags::WRITE | MemFlags::EXECUTE,
            ))
            .unwrap();
            gpm
        });
        unsafe {
            PARKING_MEMORY_SET.get().unwrap().activate();
            vcpu_arch_entry();
        }
}

将该cpu的入口地址设置为0,而0地址将会被映射到 parking pageparking page 中设置了一些wfi指令的编码。wfi指令使得CPU进入等待状态,直到有中断发生。

然后进入 vcpu_arch_entryvcpu_arch_entry 指向一段汇编代码,功能是根据 sscratch 找到 ArchCpu 进行上下文的恢复,然后执行 sret ,返回到 spec 的地址处执行,即执行刚刚设置的wfi指令(而不是内核代码),进入低功耗模式。

虽然在这里也进行了一些初始化的工作,但是并没有将cpu的初始化标志 init 设置为 true ,所以后续真正要唤醒并运行该cpu时,会重新进行初始化(在run方法中体现)。

ArchCpu::run

该方法主要内容是进行了一些初始化,设置了正确的cpu执行入口,以及修改cpu已初始化的标志。

pub fn run(&mut self) -> ! {
        extern "C" {
            fn vcpu_arch_entry() -> !;
        }

        assert!(this_cpu_id() == self.cpuid);
        //change power_on
        this_cpu_data().activate_gpm();
        if !self.init {
            self.init(
                this_cpu_data().cpu_on_entry,
                this_cpu_data().id,
                this_cpu_data().opaque, //dtb_ipa
            );
            self.init = true;
        }

        self.power_on = true;
        info!("CPU{} run@{:#x}", self.cpuid, self.sepc);
        info!("CPU{:#x?}", self);
        unsafe {
            vcpu_arch_entry();
        }
    }

可以看到这次的初始化,正确设置了cpu的入口地址,该入口地址指向内核的代码。然后进入 vcpu_arch_entry 执行,恢复上下文,再返回到内核的代码执行

vcpu_arch_entry / VM_ENTRY

这是一段汇编代码,描述的是从hvisor进入vm的时候需要处理的工作。首先就是通过 sscratch 寄存器得到原本的 ArchCpu 中的上下文信息,再将 hstatussstatussepc 设置成之前我们保存的值,保证返回到vm的时候是VS态、并且从正确位置开始执行。最后恢复通用寄存器的值,并使用 sret 返回vm。

.macro VM_ENTRY

    csrr   x31, sscratch

    ld   x1, 32*8(x31)
    csrw   hstatus, x1
    ld   x1, 33*8(x31)
    csrw   sstatus, x1
    ld   x1, 34*8(x31)
    csrw   sepc, x1

    ld   x0, 0*8(x31)
    ...
    ld   x31, 31*8(x31)

    sret
    j   .
.endm

VM_EXIT

从vm退出进入hvisor时,也需要保存vm退出时候的相关状态。

首先还是通过 sscratch 寄存器得到 ArchCpu 的地址,但在这里我们会交换 sscratchx31 的信息,而不是直接覆盖 x31 。然后将除 x31 外的通用寄存器的值进行保存,那么 x31 的信息现在在 sscratch 中,所以我们先把 x31 的值保存到 sp ,再交换 x31sscratch ,将 x31 的信息通过 sp 存到 ArchCpu 的对应位置。

再将 hstatussstatussepc 进行保存,当我们在hvisor处理完工作,将要返回vm的时候,需要用到VM_ENTRY的代码,将这三个寄存器的值进行恢复到vm进入hvisor之前的状态,所以在这里我们应该进行保存。

ld sp, 35*8(sp)ArchCpu 所保存的内核栈的栈顶放到 sp 中进行使用,便于我们在hvisor中去使用内核栈。

csrr a0, sscratch 这一句,将 sscratch 的值放到 a0 寄存器中,当我们保存完上下文,跳转到异常处理函数的时候,参数将会通过 a0 传递,那么在异常处理的时候就能够访问保存起来的上下文,比如退出码等等。

.macro VM_EXIT

    csrrw   x31, sscratch, x31

    sd   x0, 0*8(x31)
    ...
    sd   x30, 30*8(x31)
  
    mv      sp, x31
    csrrw   x31, sscratch, x31
    sd   x31, 31*8(sp)

    csrr    t0, hstatus
    sd   t0, 32*8(sp)
    csrr    t0, sstatus
    sd   t0, 33*8(sp)
    csrr    t0, sepc
    sd   t0, 34*8(sp)

    ld   sp, 35*8(sp)
    csrr      a0, sscratch

.endm