RustでRISC-Vエミュレータを書いてNOMMU Linuxをブラウザで動かした
2023-05-23
以前からRISC-V
エミュレータを書いてみようと思っていたのだが、書いては飽きてを繰り返して全然進められずにいた。そんな中、以下のRepository
で、rv32ima,Zifencei,Zicsr
、あとはCLINT
を実装すればLinux
が動くと知り、飽きずに進められそうな気がしてきたので今度こそ、と実装してみることにした。
目次
成果物
Repository
は以下。本記事では実装の概要の記載もあるが、簡略化していたり抜粋だったりするので適宜参照いただきたい。基本的にはcore
というcrate
が実装の中枢となっている。app
はcore
にcli
の皮を被せただけだ。
また以下にPlayground
も用意した。
ブラウザでアクセスすることで以下のようにLinux
が起動するところを確認できる。
参考図書
実装にあたり以下を読んだ。
RISC-V原典
は以前に読んでいたはずだが、記憶にあまり残っておらず、とりあえず引っ張りだしてきてリファレンスとして利用した。
またDevice Tree
について全く知識がなかったので積んであった私はどのようにしてLinuxカーネルを学んだか Device Tree編
をこの機会に読んだ。
実装について
備忘のためにも実装についてポイントとなりそうな箇所を簡素化しつつ書いておく。
前提としては冒頭で書いた最低限を実装するため32bit
(rv32ima,Zifencei,Zicsr
)でMMU
なしとする。
また、起動するイメージはcnlohr/mini-rv32imaで生成されるものを利用する。
CPU
CPUを実装する
というと難しく感じるかもしれないが、基本的には以下の繰り返しになると思う。あとはこれに割り込みが加わるくらいだ。
- 1.
プログラムカウンタ
の指すアドレスから命令
を読む - 2.
命令
の種別やソースとなるレジスタ
などを判別する - 3.
命令
にもとづき演算
を行う - 必要に応じて、
メモリ
やレジスタ
にデータを書き戻し、プログラムカウンタ
をインクリメントし、1 にもどる
- 必要に応じて、
Linux
を動かすにはいくつかのフラグやレジスタが必要となるが、処理の流れを理解するには最低限のCPU
として、以下のようなものを考えればよく、本記事内では簡素化のため、これをもとに話を進めていく。
x
というのは汎用レジスタで今回は32bit
のレジスタを32
個用意する。pc
はプログラムカウンタ
だ。
pub struct Cpu<B> {
/// CPU bus
bus: B,
/// Registers
x: [u32; 32],
/// Program counter
pc: u32,
}
またBus
の詳細に関してはCPU
の責務外となるのでインジェクトできるようにしておき、以下のようなtrait
が最終的にimpl
されていることを期待するものとする。
pub trait BusReader {
fn read8(&self, addr: u32) -> Result<u8, BusException>;
fn read16(&self, addr: u32) -> Result<u16, BusException>;
fn read32(&self, addr: u32) -> Result<u32, BusException>;
}
その上で以下のようなstep
を用意し、上記1~4
までを実行させるようにした。実際のコードはこちら。
impl<B: BusReader> Cpu<B> {
pub fn step(&mut self) {
// 1~4までを実施する
}
}
これをloop
で実行させればCPU
の基本動作としては OK だ。idle
時の操作なども含まれているが、実際のコードも以下のようになっている。
loop {
match core.step() {
CpuState::Active if core.bus().power_off() => return,
CpuState::Active if core.bus().reboot() => break 'reboot,
CpuState::Idle => {
sleep(core::time::Duration::from_micros(100));
core.add_cycles(1);
}
_ => {}
}
}
1. プログラムカウンタ
の指すアドレスから命令
を読む
前述のような準備ができていれば、単純にself.bus
経由で32bit
のread
を行えば良い。
let Ok(ir) = self.bus.read32(self.pc) else { ... }
自分は以下のようにBus
の実体にRAM
を持たせBusReader
を介してread
させるようにした。
pub struct Bus {
pub ram: Vec<u8>,
}
こうしておくことでCPU
はメモリマップを意識する必要がなくなり、透過的にread/write
を行えば良くなるという利点がある。 またインジェクトするBus
を変更することでCPU
を変更することなく、どんなメモリマップにも対応できる。
多くのRISC-V
ボードでRAM
の先頭アドレスは0x8000_0000
となっているようでデフォルトのBus
でも0x8000_0000
をRAM
の先頭としている。(すなわちここでは、RAM_START
は0x8000_0000
となる)。このあたりは後述するDevice Tree
とも関係する。
なのでlet addr = addr.wrapping_sub(RAM_START);
することでVec
のオフセットと合わせている。こうすることで0x8000_0000
へのアクセスはself.ram[0]
へのアクセスということになる。
impl BusReader for Bus {
fn read32(&self, addr: u32) -> Result<u32, BusException> {
let addr = addr.wrapping_sub(RAM_START);
Ok(u32::from_le_bytes([
self.ram[addr as usize],
self.ram[addr as usize + 1],
self.ram[addr as usize + 2],
self.ram[addr as usize + 3],
]))
}
}
2. 命令
の種別やソースとなるレジスタ
などを判別する
ここでは例として1
の以下のコードにおいてプログラムカウンタ
の指し先から0x0017_0713
が読めてきたものとして説明をする。すなわちここではir
が0x0017_0713
となる。
let Ok(ir) = self.bus.read32(self.pc) else { ... }
そして0x0017_0713
は具体的にどんな命令なんだ。という話を先にすると以下の命令となる。
addi a4, a4, 1
これはa4
すなわちx14
レジスタに即値1
を足してx14
に書き戻す命令なのだが、どのように0x0017_0713
をdecode
していけばそれがわかるのかを見ていくことにする。
まずRISC-V
の命令形式は以下のようにいくつかの基本的な命令形式が定義されている。
31 25 24 20 19 15 14 12 11 7 6 0
|-----------|-------|--------|--------|-----------|--------|
| funct7 | rs2 | rs1 | funct3 | rd | opcode | R形式
|-----------|-------|--------|--------|-----------|--------|
31 20 19 15 14 12 11 7 6 0
|-------------------|--------|--------|-----------|--------|
| imm[11:0] | rs1 | funct3 | rd | opcode | I形式
|-------------------|--------|--------|-----------|--------|
31 25 24 20 19 15 14 12 11 7 6 0
|------------|------|--------|--------|-----------|--------|
| imm[11:5] | rs2 | rs1 | funct3 |imm[4:0] | opcode | S形式
|------------|------|--------|--------|-----------|--------|
31 25 24 20 19 15 14 12 11 7 6 0
|------------|------|--------|--------|-----------|--------|
|imm[12|10:5]| rs2 | rs1 | funct3 |imm[4:1|11]| opcode | B形式
|------------|------|--------|--------|-----------|--------|
31 12 11 7 6 0
|-------------------------------------|-----------|--------|
| imm[31:12] | rd | opcode | U形式
|-------------------------------------|-----------|--------|
31 20 19 15 14 12 11 7 6 0
|---------------------|------|--------|-----------|--------|
| imm[20|10:1|11|19:12] | rd | opcode | J形式
|---------------------|------|--------|-----------|--------|
これを一瞥してわかるように下位 7bit が共通してopcode
となっている。
つまり、まずはopcode
の値を調べれば命令形式を絞りこめるようになっている。
今回の例(0x0017_0713
)でいえば下位7bit
は0b0010011
でありマニュアルやRISC-V原典 図2.3
を見てみるとI形式
のADDI
,SLTI
,SLTIU
,XORI
,ORI
,ANDI
,SLLI
,SRLI
,SRAI
のいずれかであることがわかる。
I形式
であることがわかれば後はfunct3
を見ることで更に絞りこむことができるようになっている。
0x0017_0713
をI形式
に当てはめてみると以下のことがわかる。
種別 | 値 |
---|---|
imm[11:0] | 0x01 |
rs1 | 0x0E |
funct3 | 0x00 |
rd | 0x0E |
funct3
が0x00
の場合はADDI
であることがわかるので、これによりaddi a4, a4, 1
,すなわちx14
レジスタに即値1
を足してx14
に書き戻す命令であることが確認できた。後はこれをコードに落とし込めば良い。
まずは以下のようにopcode
で絞りこみ。
fn step(&mut self) {
// ...
match ir & 0x7f {
0b0010011=> self.op(ir),
_ => {
todo!("Other instructions")
}
}
}
次に以下のようにfunct3
で絞り込めば良い。
fn op(&mut self, ir: u32) {
// funct3を判別する
match (ir >> 12) & 7 {
0b000 => // Please impl ADDI,
_ => {
todo!("Other instructions")
}
}
}
3.命令
にもとづき演算
を行う
2
の段階でどのレジスタに何を行えばいいかは判定ができた。
後は実際に演算すればいいだけだ。
fn op(&mut self, ir: u32) {
// irからI形式にもとづきrdを取り出す
let rd = helpers::rd(ir);
// irからI形式にもとづき即値を取り出す
let imm = ir >> 20;
let imm = imm | if (imm & 0x800) != 0 { 0xfffff000 } else { 0 };
// irからI形式にもとづきrs1を取り出す
let rs1 = self.x[helpers::rs1(ir)];
let v = match (ir >> 12) & 7 {
// funct3を判別し、対応する演算を行う。以下は加算。
0b000 => rs1.wrapping_add(imm),
_ => {
todo!()
}
};
}
今回は加算なので演算といっても実質rs1.wrapping_add(imm)
するだけだ。
命令の大部分はこのようなシンプルなもの(乗算や除算、シフトなど)なので、後はこつこつマッピングしていくだけになる。
4. 必要に応じて、メモリ
やレジスタ
にデータを書き戻し、プログラムカウンタ
をインクリメントし、1 にもどる
あとは結果を下記戻し、pc
をインクリメントして1
に戻れば良い。
今回の例ではx14
に結果を書き戻せばよい。
fn op(&mut self, ir: u32) {
let rd = helpers::rd(ir);
let imm = ir >> 20;
let imm = imm | if (imm & 0x800) != 0 { 0xfffff000 } else { 0 };
let rs1 = self.x[helpers::rs1(ir)];
let v = match (ir >> 12) & 7 {
0b000 => rs1.wrapping_add(imm),
_ => todo!(),
};
// rdに書き戻す。この例ではx14に書き戻す
self.write_back(rd, v)
}
以上で基本的なCpu
の流れは完了だ。
前述したように同じ要領で命令を拡充してけばよい。
たとえば他の減算
などを埋めると以下のようになる。
fn op(&mut self, ir: u32) {
let rd = helpers::rd(ir);
let imm = ir >> 20;
let imm = imm | if (imm & 0x800) != 0 { 0xfffff000 } else { 0 };
let rs1 = self.x[helpers::rs1(ir)];
let reg = (ir & 0b100000) != 0;
let rs2 = if reg { self.x[imm as usize & 0x1f] } else { imm };
let v = match (ir >> 12) & 7 {
0b000 if reg && (ir & 0x4000_0000) != 0 => rs1.wrapping_sub(rs2),
0b000 => rs1.wrapping_add(rs2),
0b001 => rs1 << (rs2 & 0x1f),
0b010 => ((rs1 as i32) < (rs2 as i32)) as u32,
0b011 => (rs1 < rs2) as u32,
0b100 => rs1 ^ rs2,
0b101 if (ir & 0x40000000) != 0 => ((rs1 as i32) >> (rs2 & 0x1f)) as u32,
0b101 => rs1 >> (rs2 & 0x1f),
0b110 => rs1 | rs2,
0b111 => rs1 & rs2,
_ => {
self.record_exception(Exception::IllegalInstruction, ir);
0
}
};
self.write_back(rd, v)
}
多く命令は上記のようにとてもシンプルなのもので特に面白みもないのだが、今回の実装対象としてはAtomic
命令を含んでおり、これは面白かった。具体的にはLL/SC命令
を実装する必要があり、このあたりの解像度が上がったのは収穫だった。
Timer/CLINT
OS
を動かすに当たりほぼ必須となるのが、Timer
ペリフェラルとその割り込みだ。 これはCore-Local Interruptor (CLINT)
で管理されており今回は以下のような構造を用意した。
CLINT
側
timer: T
は実際のどれくらいの時間が経過したかを管理するものだが、動作環境がブラウザか否かで実装を分けたったため抽象化している。実際の実装はこちら。
pub struct Clint<T> {
/// Machine-mode software interrupts are generated by writing to the memory-mapped control register msip
/// The msip register is a 32-bit wide WARL register where the upper 31 bits are tied to 0.
/// The least significant bit can be used to drive the MSIP bit of the mip CSR of a RISC-V hart.
/// Other bits in the msip register are hardwired to zero. On reset, the msip register is cleared to zero.
pub msip: u32,
/// This is a read-write register and holds a 64-bit value.
/// A timer interrupt is pending whenever mtime is greater than or equal to the value in the mtimecmp register.
/// The timer interrupt is used to drive the MTIP bit of the mip CSR of a RISC-V core.
pub mtimecmp: u64,
/// mtime is a 64-bit read-write register that keeps track of the number of cycles counted from an Arbitrary
/// point in time. It is a free-running counter which is incremented every tick_count number of cycles
pub mtime: u64,
/// timer driver.
timer: T,
}
仕組みはとてもシンプルだ。mtime
はハードウェアにより一定周期でインクリメントされる64bit
レジスタとなっており、mtimecmp
に設定した値より大きくなれば割り込みが発生する。というものだ。
割り込みが発生するとcpu
側のmip
レジスタの対象bit
をセットすることでcpu
へ割り込みを通知する。そのため、今回はcpu
のサイクルごとに以下を実行するものとした。
mip
のbit7
はMTIP(bit 7):Machine timer interrupt pending
なので割り込みを発生させる場合は*mip |= 0x80
としている。
impl<T: TimerDriver> Clint<T> {
pub fn step(&mut self, mip: &mut u32) {
// 経過時間を加算する
// 実際の実装は以下
// https://github.com/bokuweb/r2/blob/main/devices/src/timer.rs#L14
self.mtime += self.timer.as_micros();
// 割り込み発生条件を満たしていたらCPUのMTIP(bit 7):Machine timer interrupt pendingを立てる
if self.mtimecmp != 0 && self.mtime >= self.mtimecmp {
*mip |= 0x80;
} else {
*mip &= !(0x80);
}
}
}
CPU
側
CLINT
側に対してCPU
側はmip
を監視し割り込みの有無を確認すれば良い。
そのため今回は以下のようにself.bus.step
経由でCLINT
のstep
を実行し、その後、以下の条件を判定することでTimer
割り込みが発生しているかを確認している。
(self.mip & 0x80 != 0)
にてMachineTimerInterrupt
がpending
になっているか確認する(self.mie & 0x80 != 0)
にてMachineTimerInterrupt
が有効になっているか確認する(self.mstatus & 0x8 != 0)
にてMachineInterrupt
が有効になっている確認する
pub fn step(&mut self) {
// Drive bus state
self.bus.step(&mut self.mip);
// bit3 in mstatus is MIE: Machine Interrupt Enable
if (self.mip & 0x80 != 0) && (self.mie & 0x80 != 0) && (self.mstatus & 0x8 != 0) {
self.exception = Interrupt::MachineTimerInterrupt.into();
// ここで割り込み処理をおこなう
self.process_exception();
return CpuState::Active;
}
}
これらの条件を満たしている場合は以下のように割り込み処理に遷移する。
説明のために簡略化しているが、基本的にはmcause
などのレジスタに割り込みや例外の情報をセットして、mtvec
にジャンプする。というのが大筋だ。
mtvec
の先ではおそらくOS
によって設定されたであろう割り込みハンドラがうまくやってくれるはずだ。
fn process_exception(&mut self) {
// 割り込みや例外の要因はmcauseに保存する
self.mcause = self.exception;
self.mtval = 0;
// mepcには割り込み前のpcを保存しておく
self.mepc = self.pc;
let prev: u32 = self.previous_mode.into();
// mstatusにprevious_modeを保存しておく
self.mstatus = (((self.mstatus) & 0x08) << 4) | (prev << 11);
// vectorへジャンプする
self.pc = self.mtvec;
self.previous_mode = PrivilegeMode::Machine;
self.exception = 0;
}
ちなみにplayground
からcat /proc/interrupts
することで一応割り込みが発生していることも確認できる。
~ # cat /proc/interrupts
CPU0
7: 1789 RISC-V INTC 7 Edge clint-timer
~ # cat /proc/interrupts
CPU0
7: 1964 RISC-V INTC 7 Edge clint-timer
~ # cat /proc/interrupts
CPU0
7: 2098 RISC-V INTC 7 Edge clint-timer
~ #
ここまででCPU
が直接CLINT
を保持していないことに疑問をもった方もいるかもしれない。というのもCLINT
はMemoryMapped
なレジスタのようでCPU
から見るとBus
を介して存在することになる。
Bus
側
そのため今回は以下のようにBus
側にCLINT
の実体をもたせ、アクセスできるようにした。
pub struct Bus<T, S> {
pub ram: Vec<u8>,
pub clint: Clint<T>,
// ...
}
この例では0x1100bff8
がmtime
のマッピングアドレスになっている。
つまりCpu
は0x1100bff8
に32bit
リードすればmtime
の下位32bit
が読めてくる。といった具合だ。実際のコードはこちら。
impl BusReader for Bus {
fn read32(&self, addr: u32) -> Result<u32, BusException> {
match addr {
0x1100bffc => Ok(self.clint.read(addr & 0xffff)),
0x1100bff8 => Ok(self.clint.read(addr & 0xffff)),
_ => { ... }
}
}
}
CLINT
はMemoryMapped
なレジスタと前述したが、その規則を見ていく。
たとえばmtime
であれば基本的には下位アドレスは0xbff8
に配置されるようだ。(ただ、それが規定されているというわけでもなさそう?)
riscv-software-src/riscv-isa-sim
そして上位アドレスのほうはハードウェアごとにDevice Tree
で設定できるようになっているようだ。今回はDevice Tree
が以下のようになっているため、上位アドレスは0x1100_0000
になる。前述の下位アドレスを考慮すると0x1100_bff8
がmtime
のマッピング先になる。
clint@11000000 {
interrupts-extended = <0x02 0x03 0x02 0x07>;
reg = <0x00 0x11000000 0x00 0x10000>;
compatible = "sifive,clint0\0riscv,clint0";
};
Timer
についての概要は以上だ。簡単にまとめると以下ようになる。
- 経過時間を
mtime
に加算していく mtime
とmtimecmp
を比較しmtime
のほうが大きければmip
をセットする- 割り込みが有効になっており
mip
がセットされていればmtvec
へジャンプする
Device Tree
このプロジェクトをやるまでDevice Tree
についてはキーワードしか聞いたことがなく、今回はじめて触れたのだけど、基本的にはボード固有のハードウェア情報を注入する仕組み
だと解釈した。
先のCLINT
にしてもボードによっては0x1100_0000
であったり0x2000_0000
であったりするのだが、この情報をkernel
などに持たすことなく調整できるようになると認識している。
これによりハードウェアのRevision
が上がりメモリマップが変更になったりUART
のチップが変更になった場合もソフトウェアの変更を最小限にできる。はず。
今回の設定は以下のようなテキストで表現できる。これをdts
というらしい。これをdtc
というコンパイラを使ってdtb
というバイナリ表現に変換し、Kernel
にわたす。という手順になるようだ。
あまり詳細まで追ってはいないがspec
は以下で確認できそうだ。
/dts-v1/;
/ {
#address-cells = <0x02>;
#size-cells = <0x02>;
compatible = "riscv-minimal-nommu";
model = "riscv-minimal-nommu,qemu";
chosen {
bootargs = "earlycon=uart8250,mmio,0x10000000,1000000 console=hvc0";
};
memory@80000000 {
device_type = "memory";
reg = <0x00 0x80000000 0x00 0x3ffc000>;
};
cpus {
#address-cells = <0x01>;
#size-cells = <0x00>;
timebase-frequency = <0xf4240>;
cpu@0 {
phandle = <0x01>;
device_type = "cpu";
reg = <0x00>;
status = "okay";
compatible = "riscv";
riscv,isa = "rv32ima";
mmu-type = "riscv,none";
interrupt-controller {
#interrupt-cells = <0x01>;
interrupt-controller;
compatible = "riscv,cpu-intc";
phandle = <0x02>;
};
};
cpu-map {
cluster0 {
core0 {
cpu = <0x01>;
};
};
};
};
soc {
#address-cells = <0x02>;
#size-cells = <0x02>;
compatible = "simple-bus";
ranges;
uart@10000000 {
clock-frequency = <0x1000000>;
reg = <0x00 0x10000000 0x00 0x100>;
compatible = "ns16850";
};
poweroff {
value = <0x5555>;
offset = <0x00>;
regmap = <0x04>;
compatible = "syscon-poweroff";
};
reboot {
value = <0x7777>;
offset = <0x00>;
regmap = <0x04>;
compatible = "syscon-reboot";
};
syscon@11100000 {
phandle = <0x04>;
reg = <0x00 0x11100000 0x00 0x1000>;
compatible = "syscon";
};
clint@11000000 {
interrupts-extended = <0x02 0x03 0x02 0x07>;
reg = <0x00 0x11000000 0x00 0x10000>;
compatible = "sifive,clint0\0riscv,clint0";
};
};
};
先のCLINT
もそうだが、CPU
の実装説明の際に記載したRAM
の先頭アドレスが0x8000_0000
である。という情報もここにある。
細かいルールは分からなくとも、なんとなく書いてあることはわかると思う。
Device Tree
の渡し方
dtc
コマンドでdtb
にしたあと、それをどのようにkernel
に渡すのかという話があるが、RISC-V
の場合はa1
すなわち第 2 引数にdtb
の先頭番地をいれてLinux
を起動すればいいように見える。
本来このあたりはbootloader
の仕事になると思うが、emulator
ではその仕事も肩代わりする。
具体的に書くと以下のようにdtb
を読んだとram
に埋め込み、そのアドレス
をa1
にセットし、起動している。
let mut f = File::open(dtb)?;
let len = f.metadata()?.len();
let ptr = ram_size as u64 - len;
// dtbをRAMの最後尾にlaodする
f.read_exact(&mut ram[(ptr as usize)..(ptr + len) as usize])?;
実際のコードはこちら。
core.a0(0x00) // hart id
.a1(ptr) // ref to dtb
.pc(pc);
おそらく、以下が該当の箇所のように見える。
これで、Kernel
はdtb
を読み、RAM
やCLINT
の情報を把握し適切な設定をしてくれるようだ。
ちなみにKernel
のimage
は以下のようにRAM
の先頭に埋めており、pc
をRAM
の先頭にしておけば起動するようだ。
let mut ram = vec![0u8; ram_size];
let mut f = File::open(args.image_file_path)?;
let len = f.metadata()?.len();
// RAMの先頭にimageをloadする
f.read_exact(&mut ram[..len as usize])?;
UART
ここまでの内容を肉つけしていけばKernel
は起動すると思うのだが、このままではそれを観測するすべがなくUART
も自ずと実装することになる。
UART
とはなにか、という話はここでは省略する。
UART
についてもボード固有のものになるので同じく以下のようにDevice Tree
に定義されている。
soc {
uart@10000000 {
clock-frequency = <0x1000000>;
reg = <0x00 0x10000000 0x00 0x100>;
compatible = "ns16850";
};
}
送信処理
つまり0x1000_0000
から0x100
だけMemoryMapping
されているので今回はBus
側で以下のようにrouting
してやった。実際のコードはこちら。
fn write32(&mut self, addr: u32, v: u32) -> Result<(), BusException> {
match addr {
// ...
0x10000000..=0x100000ff => {
let addr = addr & 0xffff;
self.serial.write(addr, v);
}
// ...
};
Ok(())
}
native
な実装ではself.serial.write(addr, v)
の実体は以下のようにした。
これにより、Cpu
が0x1000_0000
へ書いたデータがそのまま標準出力へ吐かれるようになる。
impl device_interfaces::SerialInterface for Uart {
fn write(&self, addr: u32, v: u32) {
if addr == 0x000 {
let c = char::from_u32(v).expect("...");
print!("{}", c);
std::io::stdout().flush().expect("...");
}
}
}
おそらく0x00
をread
した場合は受信バッファ
に到達したデータを読むことになり、0x00
をwrite
した場合は送信バッファ
にデータを書き込むことになるのだろう。標準的なUART
のレジスタ構成だと思う。
受信処理
送信に対して受信は以下のようになる。
0x0005
はおそらくLineStatusRegister
で最下位bit
が1
であれば受信データ
が存在することを示しているはず。また、0x60
は送信可能な状態を示すbit
が立っているのだと思う。
ここでは実装の詳細は省くが、受信データ
が存在する場合はread_key
で入力値を返してやればよい。
impl device_interfaces::SerialInterface for Uart {
fn read(&self, addr: u32) -> u8 {
match addr {
// データがあれば最下位bitを1とする
0x0005 => 0x60 | if is_keydown() { 1 } else { 0 },
0x0000 if is_keydown() => read_key() as u8,
_ => 0,
}
}
}
ここまででnative
な実装の概要は完了となる。うまく行けば標準入出力でOS
とやりとりできるはずだ。
次にWasm
のケースも一応記載しておく。
Wasm
このパートではブラウザで動かすにはどうしたらいいか。について記載する。WASI
は特に何もしなくとも動くので詳細は記載しない。
基本的には以下の手順でブラウザで動かすことができた。
- 1.
xterm.js
でterminal
を構築する - 2.
native
実装でstd::time
やprintln!
を使用していた箇所を差し替える - 3.
xterm
とWasm
をつなぎこむ
1.xterm.js
でterminal
を構築する
これはxterm.js
を使用すればすんなり用意できた。
基本的には以下のようなhtml
を用意すればよい。Wasm
自体はmain thread
をブロックしないようにWorker
で動かすものとする。
<!DOCTYPE html>
<html>
<head>
...
<link rel="stylesheet" href="vendor/css/xterm.css" />
<script type="text/javascript" src="vendor/lib/xterm.js"></script>
<script type="text/javascript" src="vendor/lib/xterm-addon-fit.js"></script>
</head>
<body>
<div id="terminal"></div>
<script>
const fitAddon = new FitAddon.FitAddon();
const term = new Terminal({ fontSize: 12 });
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();
const worker = new Worker("worker.js");
function run() {
if (term._initialized) return;
term._initialized = true;
term.onKey((e) => {
worker.postMessage(e.key);
});
}
run();
worker.onmessage = (e) => {
term.write(String.fromCodePoint(e.data));
};
</script>
</body>
</html>
2.native
実装でstd::time
やprintln!
を使用していた箇所を差し替える
具体的には以下の関数をブラウザからimport
し、wasm
側で利用する。
extern "C" {
fn elapsed_us() -> usize;
fn tx(s: u32);
fn rx() -> u32;
fn keydown() -> bool;
}
これにより、まずTimer
は以下のように置き換えができる。
struct Elapsed;
impl device_interfaces::TimerDriver for Elapsed {
fn as_micros(&self) -> u64 {
unsafe { elapsed_us() as u64 }
}
}
次にUART
以下のように置き換える。
struct Term;
impl device_interfaces::SerialInterface for Term {
fn read(&self, addr: u32) -> u8 {
match addr {
0x0000 if unsafe { keydown() } => unsafe { rx() as u8 },
0x0005 => 0x60 | if unsafe { keydown() } { 1 } else { 0 },
_ => 0,
}
}
fn write(&self, addr: u32, v: u32) {
if addr == 0x000 {
// txを介してmain threadにmessageを投げる
unsafe {
tx(v);
}
}
}
}
あとはこれをBus
にインジェクトすればよい。今回はkernel image
,dtb
ともに埋め込んでしまい、以下のようにした。あとはこれをブラウザから呼べば良い。
#[no_mangle]
pub extern "C" fn start() {
let ram_size = 640 * 1024 * 1024;
let mut ram = vec![0u8; ram_size];
// 今回はWasmに埋めてしまう
let bin = include_bytes!("../../fixtures/linux.bin");
let dtb = include_bytes!("../../fixtures/default.dtb");
ram[0..bin.len()].copy_from_slice(bin);
let dtb_ref = ram_size - dtb.len();
ram[dtb_ref..dtb_ref + dtb.len()].copy_from_slice(dtb);
let clint = core::clint::Clint::new(Elapsed);
let term = Term;
let bus = core::bus::Bus::new(ram, clint, term);
let sleep = |_u: std::time::Duration| {};
core::start(bus, RAM_START, dtb_ref as u32 + RAM_START, &sleep);
}
3.xterm
とWasm
をつなぎこむ
1
において、すでに記載したが、main thread
側からは以下のようにworker
にpostMessage
でkey
を投げ、onmessage
で受け取ったdata
をterm.write
でxterm
に表示することとした。
const worker = new Worker("worker.js");
function run() {
if (term._initialized) return;
term._initialized = true;
term.onKey((e) => {
worker.postMessage(e.key);
});
}
run();
worker.onmessage = (e) => {
term.write(String.fromCodePoint(e.data));
};
対してworker
側はまずは以下のようにした。ただ、これには問題点があり、動かなかった。これは後述する。
elapsed_us
についてはperformance API
を使用し、経過時間を計算しているだけだ。
tx
についてもWasm
からkey
データが渡されるのでそれをpostMessage
でmain thread
に渡しているだけだ。
rx
についてはself.addEventListener("message", ...)
でmain thread
からのkey
をうけとり、keybuf
にpush
しておき、keydown
やrx
が呼ばれたときにこのbuf
を読めばいいと考えた。が、これが誤りだった。
last = performance.now();
const elapsed_us = () => {
const elapsed = (performance.now() - last) * 1000;
last = performance.now();
return elapsed;
};
const tx = (s) => {
postMessage(s);
};
const keybuf = [];
const keydown = () => {
if (!!keybuf.length) return true;
return !!keybuf.length;
};
const rx = () => {
const d = keybuf.shift();
return d.charCodeAt(0);
};
self.addEventListener("message", (event) => {
keybuf.push(event.data);
});
この方式だと2
のWasm
におけるstart
を呼び、emulator
を起動してしまうとWorker
をブロックしてしまいタスクキュー
が掃けないことによりmain thread
からのmessage
がいつまでたっても届かない。という問題点があることがわかった。
対策としては以下の方法を検討した。
Wasm
側でloop
に入るのではなくWasm
側からはCPUを1cycleだけ回す関数
(仮にstep
とする)を露出し、JS
側でloop
を回しながらstep
を呼びつつ定期にsetTimeout
などを使いタスクキュー
をチェックする
SharedArrayBuffer
を使い、postMessage
を使用せずkey
入力などは直接Memory
を読み書きするようにする
Asynsify
を使用し、keydown
時にタスクキュー
をチェックする
1
については確かに動くが、無駄にsetTimeout
などが呼ばれることによりperformance
の劣化が激しかった。また、wasm
側のコンテキストをmem::forget
などを使用し、保持するのが面倒だった。
2
については今回のplayground
のhosting
先はgithub pages
のみに使用と考えており、その際COOP
/COEP
の設定ができないという問題がでてくるので諦めた。performance
が一番優れているのはこれではないかと思う。
よって、今回はstandalone
なAsynsify
を使用し、keydown
が呼ばれた際にタスクキュー
を吐かせるように変更した。
具体的にはworker
のkeydown
のコードは以下のようになる。
const delay = () => new Promise((r) => setTimeout(() => r()));
const keybuf = [];
// Asynsifyによりasync関数をimportすることができる
const keydown = async () => {
if (!!keybuf.length) return true;
// このdelayによりpostMessageを受け取る
await delay();
return !!keybuf.length;
};
また、instantiateStreaming
はAsyncify
が公開されているものを使用することになる。JS
側の変更はこれくらいだ。 あとはwasm-opt
を使用して対象の関数(keydown
)をasync
化する必要がある。具体的には以下だ。
$ cargo build --target wasm32-unknown-unknown --release
$ wasm-opt --asyncify --pass-arg=asyncify-imports@env.keydown ../target/wasm32-unknown-unknown/release/wasm.wasm -o out.wasm
これでWasm
からkeydown
が呼ばれるたびにmessage
がチェックされ、無事動くようになった。
まとめ
今回の内容であれば記述量は 1000 行くらいなので、CPU
とその周辺を学ぶ教材として非常に優れていると感じた。CPU
のみではなく、割り込み機構やTimer
/UART
がどういうものかも学ぶことができるし、Atomic
な命令についても学べる点もよい。
今後は64bit
とMMU
を対応しXV6
を動かすことを目標としたい。Chisel
で書き直し、物理CPU
上でLinux
を動かすのも楽しそうだ。また、繰り返しにはなるが、教材として非常に優れていると感じたので、もうすこし詳細を記載した文書を書いてみるのもいいかと思った。本記事では概要しか書けてていないので。
GW の宿題としてはちょうどいい感じでかなり楽しかった。
以上。