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 の宿題としてはちょうどいい感じでかなり楽しかった。
以上。
