RISC-Vとx86のsystem callの内部実装の違い(xv6を例に)

はじめに

xv6-publicxv6-riscvUNIX v6を下敷きに、それぞれx86 versionとRISC-V versionに移植された教育用のOSである。

前回、RISC-Vのprivilege modeの遷移について書いたが、本記事では前回よりも少し具体的に、system callがx86 versionとRISC-V versionでどの様に実装されているのかを比較したい。

Reference

概要

項目\archtecture x86 risc-v
syscall handlerの登録 IDT stvec
mode遷移 [ring-3]=>[ring-0] U-mode=>S-mode
syscall instruction int $0x80 ecall
interruptのswitch EFLAGS.IFはenableのまま sstatus.SIEは自動でdisableに
stack 後述 特定のレジスタは個別のCSRに退避される
modeの帰還 iret sret

syscall handler

xv6(x86)の場合

x86 versionの場合、IDT(Interrupt Descriptor Table) のentryに以下のように定義することでsystem callが有効になる:

// main -> tvinit @ trap.c
// istrap=1, segment selector=SEG_KCODE<<3
// offset=vectors[T_SYSCALL](global variable; see vectors.pl), DPL=DPL_USER
SETGATE(idt[T_SYSCALL], 1, SEG_KCODE<<3, vectors[T_SYSCALL], DPL_USER);
// mmu.h
struct gatedesc {
  // lower bits
  uint off_15_0 : 16;   // low 16 bits of offset in segment
  uint cs : 16;         // code segment selector
  // upper bits
  uint args : 5;        // # args, 0 for interrupt/trap gates
  uint rsv1 : 3;        // reserved(should be zero I guess)
  uint type : 4;        // type(STS_{IG32,TG32})
  uint s : 1;           // must be 0 (system)
  uint dpl : 2;         // descriptor(meaning new) privilege level
  uint p : 1;           // Present
  uint off_31_16 : 16;  // high bits of offset in segment
};

#define STS_IG32    0xE     // 32-bit Interrupt Gate
#define STS_TG32    0xF     // 32-bit Trap Gate

#define SETGATE(gate, istrap, sel, off, d)                \
{                                                         \
  (gate).off_15_0 = (uint)(off) & 0xffff;                \
  (gate).cs = (sel);                                      \
  (gate).args = 0;                                        \
  (gate).rsv1 = 0;                                        \
  (gate).type = (istrap) ? STS_TG32 : STS_IG32;           \
  (gate).s = 0;                                           \
  (gate).dpl = (d);                                       \
  (gate).p = 1;                                           \
  (gate).off_31_16 = (uint)(off) >> 16;                  \
}
# vectors.pl
print "\n# vector table\n";
print ".data\n";
print ".globl vectors\n";
print "vectors:\n";
for(my $i = 0; $i < 256; $i++){
    print "  .long vector$i\n";
}

f:id:knknkn11626:20190927215422j:plain
Reference[5] Figure6-2より

DPL(IDT[14:13]; DPL_USER=3)、Segment Selector(GDTの先頭アドレスからのoffset; SEG_KCODE<<3でDPL=0)は権限チェックのために用いられる。詳しくはReference[4]の"Exception発生直後のhardware(x86)の対応"を参照のこと。

権限チェックに問題がなかった場合、system callが呼ばれるが、vectors.plに定義されているvector128(vectors[T_SYSCALL]にちょうど対応している)がhandlerとしてまず呼び出される。vector128は同じvector.plで以下のように定義されている:

for(my $i = 0; $i < 256; $i++){
    print ".globl vector$i\n";
    print "vector$i:\n";
    if(!($i == 8 || ($i >= 10 && $i <= 14) || $i == 17)){
        # push error code
        print "  pushl \$0\n";
    }

    # each entry point is different because the x86 doesn't provide the trap number to the interrupt handler
    print "  pushl \$$i\n";
    print "  jmp alltraps\n";
}

vector128の部分だけ抽出すると、以下のようになる:

.globl vector0
vector128:
     pushl $0
     pushl $128
     jmp alltraps

最後にpushl $128(128=0x80)としているのは、x86では、どの番号のintにてexceptionが発生したのかが分からないので1、stackにintの番号をpushしている。

Note) IDTR(IDT Register)にIDTのbase address(physical address)を格納する必要がある:

// main -> idtinit @ trap.c
void
idtinit(void)
{
  // IDTR are loaded with a linear base address
  lidt(idt, sizeof(idt));
}

xv6-riscvの場合

xv6-riscvの場合、stvecというCSRにexception/interrupt handlerを設定するだけでよい:

f:id:knknkn11626:20190927223450j:plain
Reference[6]のch.3より

f:id:knknkn11626:20190927223737j:plain
Reference[6]のch.3より

xv6-riscvではmode=0とすることで、IDTというvector tableを作成する必要がなくなった。この場合、単一のentry pointを置けばよい。ただし、mode=1とすることでIDTのようにvector tableの先頭アドレスでの管理も可能である。

どこでstvecを設定しているかは少し難しいが、

となる。(x86IDTとは違って、状況に応じてstvecの中身を変更する)

Note) x86では0x80をstackにsaveする必要があったが、RISC-Vでは、scause CSRというregisterによって何が原因でexception/interruptが呼び出されたのかがわかる様になっている:

f:id:knknkn11626:20190927224717j:plain
Reference[6]

f:id:knknkn11626:20190927224805j:plain
Reference[6]より

ecall実行後のS-modeの遷移にてscauseの値によって個々の関数にdispatchする事ができる(ecall後のtrap handlerでは確かにinterrupt:0, code:8に対応するコードがsyscall()となっている)

Note) x86では、結局は全てのinterrupt handlerがalltraps関数に集約されるので、メモリ上無駄である(がIDTというhardware機能のため、このようにせざるを得ない)。対して、RISC-Vでは単一のentrypointがtrap handlerになっており、メモリ上のムダが省けるという利点があると思う。

system callが生じたときのmode遷移

xv6(x86)の場合

x86では、system call(int 命令)が生じると、IDT(interrupt Descriptor Table)のentry 0x80番の中身を見て、権限チェックの後、ring-0(Kernel mode)に移る。詳しくは、Reference[4]の"Exception発生直後のhardware(x86)の対応"を確認のこと。

xv6-riscvの場合

RISC-Vでは、3種類のモード(privilege順にM-mode(machine mode) > S-mode(Supervisor-mode(kernel modeのこと)) > U-mode(User mode)が存在する。詳細はReference [3] medeleg/midelegの節を見てほしいが、RISV-Vではデフォルトで全てのexception/interruptがU-mode -> M-modeに遷移する様になっているが、boot時に以下の設定をすることで、M-modeでなく、S-modeにdelegate(移譲する)形でtrapを発動させることができる:

// start() @ start.c
  // delegate all exceptions(0-15) to S-mode
  w_medeleg(0xffff); // asm volatile("csrw medeleg, %0" : : "r" (x));
  // delegate all interrupt(0-15) to S-mode
  w_mideleg(0xffff); // asm volatile("csrw mideleg, %0" : : "r" (x));

Reference[3]の"medeleg/mideleg"に触れたように、medeleg(Machine-mode delegate) CSRのbit8をONにすることで、ecallがU-modeからS-modeにモード遷移するようになる。

syscall instruction

xv6(x86)の場合

usys.Sに以下のように定義されている:

#define SYSCALL(name) \
  .globl name; \
  name: \
    movl $SYS_ ## name, %eax; \
    int $T_SYSCALL; \
    ret

SYSCALL(fork)
// SYSCALL(...)

T_SYSCALL=0x80だから、x86の場合はint $0x80にてsystem callを発行できる。interrupt handlerの節で触れるように、IDTIDT(Interrupt Descriptor Table; 配列)のentryに所定の設定をすれば良く、その場所がたまたま0x80番としていしただけなので、0x80である必然性はない。

xv6-riscvの場合

user/usys.plに以下のように定義されている:

sub entry {
    my $name = shift;
    print ".global $name\n";
    print "${name}:\n";
    print " li a7, SYS_${name}\n";
    print " ecall\n";
    print " ret\n";
}
    
entry("fork");
# entry("xxx")

これは、Makefileにてusys.Sに出力される:

# Makefile
$U/usys.S : $U/usys.pl
    perl $U/usys.pl > $U/usys.S

RISC-Vでは、ecall instructionを用いることで、system callを発行する。

interruptのswitch

xv6(x86)の場合

Reference[5]のch.6.8.1とch.6.12.1.2より、

When an interrupt is handled through an interrupt gate, the IF flag is automatically cleared, which disables maskable hardware interrupts. (If an interrupt is handled through a trap gate, the IF flag is not cleared.)

--

The only difference between an interrupt gate and a trap gate is the way the processor handles the IF flag in the EFLAGS register. When accessing an exception- or interrupt-handling procedure through an interrupt gate, the processor clears the IF flag to prevent other interrupts from interfering with the current interrupt handler. A subsequent IRET instruction restores the IF flag to its value in the saved contents of the EFLAGS register on the stack. Accessing a handler procedure through a trap gate does not affect the IF flag.

と書いてあり、int $0x80の場合はtrap gateなので、IF=1のままである。

xv6-riscvの場合

Reference[3]のU-mode => S-modeの部分にも詳しく書いたが、ecallが生じたとき、自動的にsstatus.SIE(Software Interrupt Enable)がOFFになる。これがOFFになることで、S-mode => S-mode(horizontal traps)へのinterruptによるmode遷移がdisableになる[^vertial_traps]。

このtrap時のhardwareの振る舞いに関しては、Reference[6]のch.3.1.8に

When a trap is delegated to a less-privileged mode x, the x cause register is written with the trap cause; the x epc register is written with the virtual address of the instruction that took the trap; the x tval register is written with an exception-specific datum; the xPP field of mstatus is written with the active privilege mode at the time of the trap; the xPIE field of mstatus is written with the value of the x IE field at the time of the trap; and the x IE field of mstatus is cleared. The mcause and mepc registers and the MPP and MPIE fields of mstatus are not written.

とあるので、これを参考にした2

ただし、system callの場合は、software側で一時的に

としている。

stack

xv6(x86)の場合

だいたいのregisterがstackに退避される。

int $0x80が起こるとhardware側で、以下のようにstackにint $0x80直前のregisterの中身が保存される:

f:id:knknkn11626:20190928123435j:plain
Figure6.4

Note) Error codeはint $0x80の場合はstackに積まれない。

stackの位置自体はcontext switch(swtch関数)の直前のswitchuvm(p)関数にて以下のようにts.ss0(0はring-0の0)とts.esp0を次のように指定することで実現できる:

// switchuvm(p: struct proc)
  mycpu()->gdt[SEG_TSS] = SEG16(STS_T32A, &mycpu()->ts, sizeof(mycpu()->ts)-1, 0);
  mycpu()->gdt[SEG_TSS].s = 0; // 0 = system
  mycpu()->ts.ss0 = SEG_KDATA << 3;
  mycpu()->ts.esp0 = (uint)p->kstack + KSTACKSIZE;
  // reload TR

その後、vector128で

vector128:
     pushl $0 // error code
     pushl $128 // trapno(0x80)
     jmp alltraps

とスタックを積んで、alltraps("syscall handler"の節を参照)にて、

// alltraps @ trapasm.S
  pushl %ds
  pushl %es
  pushl %fs
  pushl %gs
  pushal

で更に、d/s/f/g segment registerとGeneral-Purpose Registersをpushすることで、struct trapframeを形成する。

この後、trap関数に飛んで、interrupt handlerの大元の処理を行う:

// alltraps @ trapasm.S
  # Set up data segments.
  movw $(SEG_KDATA<<3), %ax
  movw %ax, %ds
  movw %ax, %es

  # Call trap(tf), where tf=%esp
  pushl %esp
  call trap // void trap(struct trapframe *tf)

Note) trapframe構造体は以下のように定義されている:

struct trapframe {
  // registers as pushed by pusha
  uint edi;
  uint esi;
  uint ebp;
  uint oesp;      // useless & ignored
  uint ebx;
  uint edx;
  uint ecx;
  uint eax;

  // push by alltraps
  ushort gs;
  ushort padding1;
  ushort fs;
  ushort padding2;
  ushort es;
  ushort padding3;
  ushort ds;
  ushort padding4;
  // vector128
  uint trapno;

  // below here defined by x86 hardware
  uint err;
  uint eip;
  ushort cs;
  ushort padding5;
  uint eflags;

  // below here only when crossing rings, such as from user to kernel
  uint esp;
  ushort ss;
  ushort padding6;
};

一番下からuint eip;メンバまでがx86によって自動的にstackにpushされ、それから上は、vector128, alltrapsによってstackにpushしたメンバである。

xv6-riscvの場合

xv6-riscvの場合、U-modeでのregisterがCSRに(hardwaretの意味で)自動的に退避されるものと、sscratchによってスタック上に管理されるものの2種類に分かれる。x86と比較すると次のようになる:

x86 RISC-V(遷移先がS-mode) remarks
%ss(stack) - RISC-Vではsegmentationはない
%esp(stack) x RISC-Vの場合、手動でsscratchにて管理する
%eflags.IF(stack) sstatus.SPIE Machine/Supervisor Privious Interrupt Enable
%cs(stack) sstatus.SPP Machine/Supervisor Privious Privilege
%eip(stack) sepc Machine/Supervisor Exception Program Counter
error code(stack) scause Machine/Supervisor Cause Register
trapno(stack) scauseにて確認する
ds, es, fs, gs(stack) - RISC-Vではsegmentationはない
integer and floating-point registers(General-Purpose Registers)(stack) stackに管理し、先頭アドレスをsscratchにsave/restoreする

図の通り、x86で自動的にhardwareが積まれるものは、RISC-Vでは大体CSR(register)に退避される。具体的には上表の一番上から7つ。

最後のinteger and floating-point registers

TRAPFRAMEは https://github.com/mit-pdos/xv6-riscv/blob/036b5edf12fccc06a11287ad58565f253ab2eef6/kernel/trap.c#L127 の第一引数(=a0)が元になってる

ret

xv6(x86)の場合

x86の場合はiretにてkernel modeからuser modeに戻る3

.globl trapret
trapret:
  popal
  popl %gs
  popl %fs
  popl %es
  popl %ds
  addl $0x8, %esp  # trapno and errcode
  iret

個々でやっているのは、stackの節にて構成されたtrapframe構造体を引き上げる。

iretの直前は

f:id:knknkn11626:20190928140155j:plain
Figure6.4

のようになっている(一番下が現在の%esp)ので、iretにてmode遷移とともにこれらのレジスタを復元する。

xv6-riscvの場合

RISC-Vの場合はsretを用いてS-mode(Supervisor mode; kernel mode) => U-mode(User mode)に戻る。

以前の記事U-mode -> S-modeの節でも触れたように、

  1. sret実行
  2. sstatus.SIE <- sstatus.SPIE(=1)
  3. U-modeに遷移する4
  4. sstatus.SPIE <~ 1 [always]
  5. sstatus.SPP <~ 00(U-mode) [always]
  6. pc(program counter) <~ sepc CSR5

にて無事U-modeに戻ることができる。

感想

  • 比較した感想としては、x86ではuser mode->kernel modeに昇格する際に権限のチェックを行うので、それがかなりややこしい(今回はチェックは省略したが)。対してRISC-Vでは、U-modeで生じたtrapは、sie registerの各ビットをONにしている限りS-modeに遷移するので、権限チェックの煩雑さがなくなり嬉しい。

  • 登場するresiger自体もx86に比べてRISC-Vでは減っている(例えば、x86でのsegmentationに関係するregisterやGDT, LDTはRISC-Vでは登場しない概念)ので、少しシンプルな設計になっている。

  • trapの仕組み自体は両者で一見かなり違うように見えるが、hardwareがtrap直後に自動でやる処理はx86でもRISC-Vでも存在しているし、退避させるreigsterの方法もstackかCSRかという違いだけでやりたいことは同じだと思うので、ただ単に細部の差異だけのように見える。


  1. ソースコードコメントに"x86 doesn’t provide the trap number to the interrupt handler"と書いてある。

  2. mstatusとsstatusの同名のbitは同一視できる。これはReference[3]の"各モードのCSRのbitの解釈について"という節で詳しく書いたので参考にされたい。

  3. ただし、本来のiretの意味合いはmodeの遷移とは厳密に異なる。これに関しては、http://jamesmolloy.co.uk/tutorial_html/10.-User%20Mode.html が参考になると思う。

  4. trap handler(ecall)発生直後、sstatus.SPP(S-mode Privious Privilege) <~ U-mode(00)に設定されてるため。

  5. trap handler(ecall)発生直後、sepcにはtrap発生直前のprogram counterが入る。ecallならば、ecall instructionの直前のaddress.