RISC-V ハイパーバイザーを作ろう (2) ~ 仮想CPU

執筆者 : 高橋 浩和

※ 「RISC-V OSを作ろう」連載記事一覧はこちら
※ 「RISC-V OS、ハイパーバイザー」のコードはgithubにて公開しています。


はじめに

今回からハイパーバイザーSageVisorの実装を覗いていきます。前回の記事でも述べましたが、ハイパーバイザー実装は基本的にOS実装と同じです。OS実装の知識を前提に、ハイパーバイザーとして動かすための仕組みを見ていきます。

今回の記事の題目は、仮想CPUです。 OS上でアプリケーションを実行するコンテキストとしてタスクがあるように、ハイパーバイザー上でゲストOSを実行するコンテキストとして仮想CPUがあります。 仮想CPUのコンテキストとは、その仮想CPUを動作させる時に必要な「レジスタ値、スタック、更にレジスタ値に現れないCPU内部状態」となります。 仮想CPUのコンテキストはタスクのコンテキストよりも大きく、OSが操作する制御レジスタ(特権レジスタ)も含まれます。またRISC-Vの場合、レジスタの値として参照できない実行モードや仮想化モード(後述する)もコンテキストの一部となります。

RISC-Vハイパーバイザー拡張

RISC-Vは、ハイパーバイザーを実現するための支援機能「ハイパーバイザー拡張(Hypervisor Extension)」を提供しています。スーパーバイザーモード(Sモード)で動作するOSを、そのままハイパーバイザー上でも動作させることができます。このハイパーバイザー拡張は、ゲストOS実行時にハイパーバイザー介入を極力行わずに済むよう考慮されています。

仮想化モード

実行モード(Mモード/Sモード/Uモード)と直行する状態として、仮想化モード(Virtualization mode)が導入されました。実行モードと仮想化モードは同時に持つことができます。仮想化モードかつSモードである状態をVSモード(Virtualized Supervisorモード)と呼び、従来のSモードをHSモード(Hypervisor-Extended Supervisorモード)とも呼びます。同様にUモードも仮想化モードの状態により、VUモード、HUモードと区別します。

動作しているOSからはこの仮想化モードの状態は見えませんが*1、この仮想化モードという状態は仮想CPUのコンテキストの一部です。

VS制御レジスタ

RISC-Vには、Sモードで動作するOSが管理する制御レジスタとしてSの文字から始まる制御レジスタ群(S制御レジスタ)があります。SSTATUS、SCAUSE、SEPC、SIE、SIPなどのレジスタです。

ハイパーバイザー拡張では、ゲストOS用の制御レジスタとして、新たにVS制御レジスタ群(VSSTATUS、VSCAUSE、VSEPC、VSIE、VSIPなど)が導入されました。VSモードのOSからのS制御レジスタ操作は、自動的にこれらVS制御レジスタ操作に置き換えられます。VS制御レジスタは、ゲストOSに対してはS制御レジスタと同じく機能します。

RISC-VではVS制御レジスタを導入することにより、ベアメタル環境用に作られたOSをハイパーバイザー上のゲストOSとして動作させた場合でも、ゲストOSの制御レジスタ操作にハイパーバイザーが介入する必要が無くなりました。なかなか悪くないアイデアです。 一方、これまでのメジャーなCPUアーキテクチャでは、ゲストOSが行なう制御レジスタアクセスをハイパーバイザーが捕捉してエミュレートする必要がありました。

これらVS制御レジスタ群はゲストOSの管理下にあり、仮想CPUのコンテキストとなります。

ハイパーバイザー制御レジスタ

ゲストOS(仮想CPU)の状態を制御するためのハイパーバイザー制御レジスタ群があります。これらはゲストOSからは参照できません。またこのレジスタの操作が、ハイパーバイザー(またはホストOS)の状態に影響することもありません。そのうち重要なものを見てみます。

制御レジスタ 説明
HSTATUS 仮想CPU状態の操作
HVIP VSレベル割込みの発生要求
HIP 保留中のVSレベル割込み状態
HIE VSレベル割込みの禁止/許可
HGATP ゲストOSの物理メモリ空間制御

HSTATUSレジスタは仮想CPUの状態を表します。HSTATUSレジスタには、例外発生前の仮想CPUの仮想モードの状態を表すフィールドSPV/SPVPがあります。VSモード/VUモードで例外が発生しHSモード(ハイパーバイザー)に切り替わると、HSTATUSのSPV/SPVPフィールドが共に1になります。ハイパーバイザーはHSTATUSを参照することにより、ゲストOS(仮想モード)中で発生した例外であることを確認できます。

HSTATUSレジスタ

SPVとSPVPの違いは、続けて行なうハイパーバイザー処理の中で再度例外が発生した時の動きとなります。ハイパーバイザー中で例外が発生するとSPVはクリアされますが、SPVPの値は維持されます。SPVは例外発生元のコンテキストが仮想化モードであるか否かを表す一方、SPVPは仮想化モードで発生した例外処理の延長で動作していることを表わします。SPVPは、ハイパーバイザーが例外発生の可能性のある操作(ゲストOSのメモリアクセスなど)を行なうことに備えて用意されています。

HVIP/HIP/HIEは、ゲストOSへの割込み(VSレベル割り込み)を制御するために使用します。これらについては、次回の記事にて説明する予定です。

HGATPは、ゲストOSの物理メモリ空間制御に利用します。これについても別の記事で説明する予定です。

これらレジスタはゲストOS管理のために使われているため、仮想CPUのコンテキストとなります。

ステータスレジスタまとめ

アプリケーションがecall命令を実行した時の動作と、ゲストOSがecall命令を実行した時の動作は良く似ています。

Uモード動作中のアプリケーションがecall命令を発行するとEnvironment Call例外が発生し、コンテキストがSモードで動作しているOSに切り替わります。その時、ecallを実行したコンテキストの状態(実行モード、割り込み禁止・許可状態)がSSTATUSレジスタに保存されます。

同様に、VSモード動作中のゲストOSがecall命令を発行するとEnvironment Call例外が発生し、コンテキストがHSモードで動作しているハイパーバイザーに切り替わります。その時、ecallを実行したコンテキストの状態(実行モード、割り込み禁止・許可状態、仮想化モード)がSSTATUSレジスタとHSTATUSレジスタに保存されます。

ecall発行元に復帰する時はsret命令を実行します。SSTATUSレジスタとHSTATUSレジスタの値から、ecall発行元のコンテキストを復元します。

ハイパーバイザーは、VSSTATUSレジスタに加えSSTATUSレジスタ・HSTATUSレジスタの値も仮想CPUのコンテキストとして扱う必要があります。

まとめると、ハイパーバイザーが操作するステータスレジスタは3種類あります。これらはすべてゲストOS(仮想CPU)のコンテキストとなります。

ステータスレジスタ 制御元 制御対象 補足説明
SSTATUS ハイパーバイザー(ホストOS) ハイパーバイザー、仮想CPU(ゲストOS)の状態 仮想CPU(ゲストOS)の実行モードを記録
VSSTATUS ゲストOS 仮想CPU(ゲストOS)の状態 ゲストOSからはSSTATUSとして参照される
HSTATUS ハイパーバイザー 仮想CPU(ゲストOS)の状態 仮想CPU(ゲストOS)の仮想化モードを記録

仮想CPUの実装

コンテキスト管理用に、仮想マシンを制御するデータ構造VmControlと仮想CPUを制御するデータ構造VcpuControlを用意します。VmControl 1つに対して、複数のVcpuControlを割り当てます。

仮想CPU制御

仮想CPU毎に1つのVcpuControlを用意します。VcpuControlは、Sophia OSのTaskControlに相当するものです。仮想CPUが属する仮想マシンのID(vmidメンバ)、仮想CPU間割込み要求を示すフラグ(ipi_reqeustメンバ)があるくらいで、あとはTaskControlとほぼ同じです。

仮想マシン制御

仮想マシン(VM)毎に一つのVmControlを用意します。VmControlの主な目的は仮想CPUのグルーピングと仮想マシン空間の管理です。

VmControlは仮想マシンが利用する物理メモリ域とI/O領域の情報を保持します。ゲスト物理アドレスからホスト物理アドレス(本当の物理アドレス)に変換するページテーブルを指すvm_hgatpメンバも用意します。

仮想マシン内で動作するゲストOSを起動(boot)する仮想CPUの情報も管理します(boot_vcpuメンバ)。仮想マシン起動は、boot用の仮想CPU 1つを起動するのみで、後はその仮想CPUに仮想マシンの初期化を任せます。

仮想CPUのスケジューリング

カレントタスクを指すCurrentTask変数の代りにカレント仮想CPUを指す変数CurrentVcpuを用意します。仮想CPUのスケジューリングは、仮想CPUにアイドル状態が無いことを除き、Sophia OSのタスクスケジューリングとまったく同じです。仮想CPUのアイドル状態はまだ存在しませんが、いずれ実装することを考えています。

仮想CPUのコンテキスト切替え

仮想CPUのコンテキスト切り換えに併せて、ゲストOSへの仮想割込み発生を要求しています(1)(2)。仮想割り込みは、その仮想割り込みを管理するゲストOS配下にある仮想CPUが動作している時でなければ実行できないため、仮想CPUが実行されるまで保留しています。詳細については別記事にて説明します。

ゲスト物理メモリ空間の切り替えを行います(3)。ゲストOS毎に固有の物理メモリ空間が割り当てられており、仮想CPUを動かす前に物理メモリ空間の切り替えを行なう必要があります。詳細については、別の記事で触れることにします。

仮想CPUコンテキストの切り替えの本体は(4)です。nextが指す仮想CPUから、currentが指す仮想CPUへの切り替えを行ないます。

void VcpuSwitch(struct VcpuControl *current, struct VcpuControl *next)
{   
    context* p = (context *)next->sp;
    
    if ((long)(next->next_time - _get_time()) <= 0) {
(1)    p->hvip |= IP_VSTI;
        deactivateTimer(next);
    }
    if (VcpuControl[CurrentVcpu].ipi_request) {
        VcpuControl[CurrentVcpu].ipi_request = FALSE;
        MemBarrier();
(2)    p->hvip |= IP_VSSI;
    }
    /* switch guest's physical space */
(3) SetHGATP(VmControl[next->vmid].vm_hgatp);
(4) switch_context(&next->sp, &current->sp);
}

コンテキスト切り換えの本体はアセンブリコードです。Sophia OSのタスクコンテキスト切り換えのコードをベースにしていますが、仮想CPUの切り換えではVS制御レジスタ群とハイパーバイザー制御レジスタ群の切り換えも行います。ここではS制御レジスタの切り替えを行なっていませんが、Sophia OSと同じく割り込み・例外のエントリにて行なっています。

    .globl switch_context
    .globl load_context
    .type switch_context,@function
    .balign 4
switch_context:
    addi  sp, sp, -8*23
    sd    s0, 0*8(sp)
    sd    s1, 1*8(sp)
    sd    s2, 2*8(sp)
    sd    s3, 3*8(sp)
    sd    s4, 4*8(sp)
    sd    s5, 5*8(sp)
    sd    s6, 6*8(sp)
    sd    s7, 7*8(sp)
    sd    s8, 8*8(sp)
    sd    s9, 9*8(sp)
    sd    s10, 10*8(sp)
    sd    s11, 11*8(sp)
    sd    ra, 12*8(sp)
    
    csrr  t0, vsstatus
    sd    t0, 13*8(sp)
    csrr  t0, vsepc
    sd    t0, 14*8(sp)
    csrr  t0, vscause
    sd    t0, 15*8(sp)
    csrr  t0, vstval
    sd    t0, 16*8(sp)
    csrr  t0, vsie
    sd    t0, 17*8(sp)
    csrr  t0, vstvec
    sd    t0, 18*8(sp)
    csrr  t0, vsscratch
    sd    t0, 19*8(sp)
    csrr  t0, vsatp
    sd    t0, 20*8(sp)
    csrr  t0, hvip
    sd    t0, 21*8(sp)
    csrr  t0, hstatus
    sd    t0, 22*8(sp)

    sd    sp, (a1)

load_context:
    ld    sp, (a0)
    ld    s0, 0*8(sp)
    ld    s1, 1*8(sp)
    ld    s2, 2*8(sp)
    ld    s3, 3*8(sp)
    ld    s4, 4*8(sp)
    ld    s5, 5*8(sp)
    ld    s6, 6*8(sp)
    ld    s7, 7*8(sp)
    ld    s8, 8*8(sp)
    ld    s9, 9*8(sp)
    ld    s10, 10*8(sp)
    ld    s11, 11*8(sp)
    ld    ra, 12*8(sp)

    ld    t0, 13*8(sp)
    csrw  vsstatus, t0
    ld    t0, 14*8(sp)
    csrw  vsepc, t0
    ld    t0, 15*8(sp)
    csrw  vscause, t0
    ld    t0, 16*8(sp)
    csrw  vstval, t0
    ld    t0, 17*8(sp)
    csrw  vsie, t0
    ld    t0, 18*8(sp)
    csrw  vstvec, t0
    ld    t0, 19*8(sp)
    csrw  vsscratch, t0
    ld    t0, 20*8(sp)
    csrw  vsatp, t0
    ld    t0, 21*8(sp)
    csrw  hvip, t0
    ld    t0, 22*8(sp)
    csrw  hstatus, t0

    addi  sp, sp, 8*23
    ret
    .size switch_context,.-switch_context

仮想マシンの起動

Sophia OSのタスク起動と基本は同じです。

仮想マシン初期化

InitVcpu関数にて、各VcpuControl構造体を初期化します。各仮想CPUが下記状態で立ち上がるように、スタック上にコンテキストを生成します。VS制御レジスタ群の初期化はゲストOSに任せます。

レジスタ 説明
RA 仮想CPU共通の起動関数VcpuEntryを登録。
HSTATUS SPVビットとSPVPビットを立てる。sret実行時に仮想化モードへ遷移する。

InitVm関数にて、仮想マシンのboot用仮想CPUのVcpuControlのentryメンバに、仮想マシンのリセットエントリを登録します。仮想マシンのリセットエントリは0x80200000番地固定です。

VmControl構造体の情報に従いゲスト物理アドレスから物理アドレスへの変換用ページテーブルを生成し、VmControl構造体のvm_hgatpメンバに登録します。詳細は別の記事で説明します。

仮想CPU起動

VmControl構造体のboot_cpuメンバに対応する仮想CPUを起動します(1)。今回の実装では2つの仮想マシンが起動します。後にアイドル仮想CPUを実装しますが、その時にはSophia OSと同じ起動方法になる予定です。

仮想CPUが属する仮想マシンの空間に切り換えた後(2)、boot用の仮想CPUのコンテキストを読込みます(3)。

void main(CoreIdType coreid)
{
          :
          :
    if (ThisCore == BootCore) {
(1)     CurrentVcpu = VmControl[VM0].boot_vcpu;
    } else {
(1)     CurrentVcpu = VmControl[VM1].boot_vcpu;
    }
    
    SpinLock(&lock_sched);
    VcpuControl[CurrentVcpu].state = STARTED;
    VcpuControl[CurrentVcpu].coreid = ThisCore;
(2) SetHGATP(VmControl[VcpuControl[CurrentVcpu].vmid].vm_hgatp);
(3) load_context(&VcpuControl[CurrentVcpu].sp);
}

起動した仮想CPUは、VcpuEntryから動作を開始します。

static void VcpuEntry(void)
{
    SpinUnlock(&lock_sched);
    VcpuStart(VcpuControl[CurrentVcpu].entry, VcpuControl[CurrentVcpu].param);
}

VcpuStart関数で、SSTATUSレジスタのSPPビット(STATUS_SPP_SMODE)を立ててsretを実行することにより、Sモードへ遷移します。この時、仮想マシン初期化にて、HSTATUSのSPVフィールドが立つようにしてあるため、同時に仮想化モードへも遷移します。

VcpuControl[CurrentVcpu].entryに登録された関数が、VSモードで呼び出されることになります。

    .equ   STATUS_SPP_SMODE, (1U<<8)        /* supervisor mode */

    .globl VcpuStart
    .type VcpuStart,@function
    .balign 4
VcpuStart:  /* turns the mode into VS-mode */
    csrw  sepc, a0
    li    a0, STATUS_SPP_SMODE
    csrw  sstatus, a0
    mv    a0, a1
    sret
    .size VcpuStart,.-VcpuStart

あとは、ゲストOSの仕事です。 仮想マシンを構成する他の仮想CPUは、ここで起動したboot用の仮想CPUがゲストOSの初期化処理を行なう中で起動されます。

最後に

今回の作成したハイパーバイザーSageVisorは、仮想CPUのアイドル状態を実装していません。実行する仕事が無い仮想CPUは、即座に他の仮想CPUに実行権を明け渡すべきですし、実行すべき仮想CPUが無いときには、物理CPUを低消費電力モードに遷移させた方が良いでしょう。

この機能は、後のブログ記事で命令エミュレーションを紹介した後に実装することを考えています*2。しばらくお待ちください。

*1:OSがベアメタル上で動作しているか、ハイパーバイザー上で動作しているかは判別できません

*2:ゲストOSのwfi命令実行をハイパーバイザーが捕捉し、エミュレートする必要があるため