QEMUもどきをRustで作る
皆様はじめまして。今夜は星が見えますか?流星彗と申します。ながほしすいと読みます。
さて、今回はQEMUと呼ばれる軽量かつ万能なオープンソースエミュレータのパチモンRustバージョンを作っていきます。
以下のリポジトリにて開発を進めていきます。
QEMUとは
QEMUはFabrice Bellard氏を中心に開発されている、フリーでオープンソースなエミュレータです。
QEMUは多種多様なプロセッサ環境のエミュレーションが可能で、Arm、MIPS、PowerPC、RISC-V、s390x、SPARC、x86などのアーキテクチャをサポートしています。フルシステムエミュレーションの他にLinuxのユーザランドも独立してエミュレーションすることができます。
CPUの他にもPS/2、SATA、USB、サウンドカード、フロッピーディスク等のデバイスをエミュレートできます。
また、ホスト上のGDBと接続、仮想マシンの監視などICEのような使い方や埋め込みVNC、SPICEサーバによりリモートマシンの制御も可能です。
一方で仮想化支援機能が少なく、VMwareやVirtualBoxよりは低速です。
公式サイト
開発方針のようなもの
いきなりQEMUのような機能をもたせることはできないので、まずはTD4をエミュレートできるようにします。
TD4は名著「CPUの創りかた」で作成する4bit CPUです。ちなみにTD4は「とりあえず動作する4bit CPU」の略だそうです。
TD4の次はこちらも名著「30日でできる!OS自作入門」で作る「はりぼてOS」が動作するようにします。はりぼてOSはその名前からはかけ離れた高機能なOSで、マルチウィンドウ、ファイルシステム、インベーダーゲームなど一通りの機能は揃っています。
その次はいよいよ第三形態として、またまた名著「ゼロからのOS自作入門」で作る「MikanOS」が動作するようにします。MikanOSははりぼてOSの上位互換に近い自作OSで64bit動作、UEFIブート、USB3.0などのより近代的な機能を備えています。
最後に、いよいよ終わりのない戦いが始まります。ネットワーク、SATA、GPUなどのドライバ関連の機能、有名どころのプロセッサ環境のエミュレート、などなどだんだんと実機に近づけていきます。
どこまで続けられるか自信はありませんが、応援お願いします。
ファイル読み込み
実行時に引数で渡されたパスのファイルを読み込み、println!
でストリームに流す機能を実装しました。
BufReader
でバッファを取り、行ごとにベクタへ足していき、最後にfor in
ループで要素ごと(一行ごと)に表示します。
コード全文は以下になります。
use std::fs::File;
use std::io::{BufRead, BufReader};
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
panic!("ERROR: Invalid args.");
}
let file = BufReader::new(File::open(args.get(1).unwrap()).expect("ERROR: File not found."));
let operations = file
.lines()
.map(|line| line.unwrap())
.collect::<Vec<String>>();
for i in &operations {
println!("{}", i);
}
}
IO
入出力関連ということでIO、いわゆるInput、Outputを実装しました。
実装といっても至ってシンプルで、Port
という名前の構造体を用意し、受け取った値を突っ込んで返すだけです。たぶん。
コードは以下
pub struct Port {
input: u8,
output: u8,
}
impl Port {
pub fn new(input: u8, output: u8) -> Self {
Self { input, output }
}
pub fn input(&self) -> u8 {
self.input
}
pub fn output(&self) -> u8 {
self.output
}
pub fn set_output(&mut self, im: u8) {
self.output = im;
}
}
書き忘れていましたが、TD4エミュレータの作成は下記のサイトを参考に、ほぼそのままの形で実装させていただいています。
ROM
続いて実装が単純なものその2として、ROMの実装を行いました。
バイナリの並んだベクタ内u8 as usize
位置にある4bitのバイナリデータを返すEom::read
関数、バイナリ長を返すRom::size
関数があります。
コードは以下
pub struct Rom {
pub memory_array: Vec<u8>,
}
impl Rom {
pub fn new(memory_array: Vec<u8>) -> Self {
Self { memory_array }
}
pub fn read(&self, program_counter: u8) -> u8 {
self.memory_array[program_counter as usize]
}
pub fn size(&self) -> u8 {
self.memory_array.len() as u8
}
}
Opcode & Register
ある意味最も重要とも言える、Opcode
とRegister
を実装しました。
こちらもやはりそこまで複雑ではなく、enum
やstruct
でまとめて、値を代入したり、返したりする関数を用意するだけです。
Opcode
に関しては、enum
の中に命令名と対応するバイナリを並べているだけで、関数などは一切ありません。
use num_derive::FromPrimitive;
#[derive(Debug, PartialEq, FromPrimitive)]
pub enum Opcode {
AddA = 0b0000,
AddB = 0b0101,
MovA = 0b0011,
MovB = 0b0111,
MovA2B = 0b0001,
MovB2A = 0b0100,
Jmp = 0b1111,
Jnc = 0b1110,
InA = 0b0010,
InB = 0b0110,
OutB = 0b1001,
OutIm = 0b1011,
}
pub struct Register {
register_a: u8,
register_b: u8,
carry_flag: u8,
program_counter: u8,
}
impl Default for Register {
fn default() -> Self {
Self {
register_a: u8::default(),
register_b: u8::default(),
carry_flag: u8::default(),
program_counter: u8::default(),
}
}
}
impl Register {
pub fn new() -> Self {
Self::default()
}
pub fn program_counter(&self) -> u8 {
self.program_counter
}
pub fn set_program_counter(&mut self, new_value: u8) {
self.program_counter = new_value;
}
pub fn increment_program_counter(&mut self) {
self.program_counter += 1;
//以下略
Emulator
いよいよエミュレータ本体を作成します。
Register
、Port
、Rom
の入った構造体を作り、implでくっつけていきます。
バイナリをデコードするfn decode
、デコードされた命令をもとにエミュレータの命令を呼ぶfn exec
、そして各命令本体があります。
コードは以下(一部抜粋)
pub struct CpuEmulator {
register: Register,
port: Port,
rom: Rom,
}
impl CpuEmulator {
pub fn with(register: Register, port: Port, rom: Rom) -> Self {
assert!(
rom.size() <= 16,
"Maximum memory size is 16. This program can't work."
);
Self {
register,
port,
rom,
}
}
//~~~
fn decode(&self, data: u8) -> Result<(Opcode, u8), EmulatorError> {
let opelation = data >> 4;
let immediate = data & 0x0f;
if let Some(opcode) = FromPrimitive::from_u8(opelation) {
match opcode {
Opcode::AddA
| Opcode::AddB
| Opcode::MovA
| Opcode::MovB
| Opcode::MovA2B
| Opcode::MovB2A
| Opcode::Jmp
| Opcode::Jnc
| Opcode::OutIm => Ok((opcode, immediate)),
Opcode::InA | Opcode::InB | Opcode::OutB => Ok((opcode, 0)),
}
} else {
Err(EmulatorError::new("No match for opcode."))
}
}
pub fn exec(&mut self) -> Result<(), EmulatorError> {
loop {
let data = self.fetch();
let (opcode, immediate) = self.decode(data)?;
match opcode {
Opcode::MovA => self.mov_a(immediate),
Opcode::MovB => self.mov_b(immediate),
Opcode::AddA => self.add_a(immediate),
Opcode::AddB => self.add_b(immediate),
Opcode::MovA2B => self.mov_a2b(),
Opcode::MovB2A => self.mov_b2a(),
Opcode::Jmp => self.jmp(immediate),
Opcode::Jnc => self.jnc(immediate),
Opcode::InA => self.in_a(),
Opcode::InB => self.in_b(),
Opcode::OutB => self.out_b(),
Opcode::OutIm => self.out_immedilate(immediate),
};
if opcode != Opcode::Jmp && opcode != Opcode::Jnc {
self.register.increment_program_counter();
}
if self.does_halt() {
return Ok(());
}
}
}
fn mov_a(&mut self, immediate: u8) {
self.register.set_register_a(immediate);
self.register.set_carry_flag(0);
}
//~~~
あとはバイナリに対応すれば完成・・・なのですが、私の技術ではバイナリを4bitでsplitする事ができなかったので、参考サイトに載っているコンパイラを実装しました。
多分バイナリを二進数が記述されたテキストデータとして強引に読めば行けそうな気がするので、時をみて試してみます。
さて、コンパイラとは言いますが、仕組み自体は非常にシンプルで、例えばmov A 0001
といった簡易的なアセンブラをwhitespaceで分割してやると命令
->レジスタ
->値
といった順で解釈できるようになります。
その後generate_binary_code
などの関数が、各命令のバイナリとイミディエイトデータを結合し、u8
のベクタに詰めていく、といった流れで動作します。
//~~~
let operation = operation.unwrap();
if operation == "mov" {
self.position += 1;
let lhs = self
.source
.get(self.position)
.ok_or_else(|| EmulatorError::new("Failed to parse mov left hand"))?;
self.position += 1;
let rhs = self
.source
.get(self.position)
.ok_or_else(|| EmulatorError::new("Failed to parse mov right hand"))?;
let token = if lhs == "B" && rhs == "A" {
Token::MovBA
} else if lhs == "A" && rhs == "B" {
Token::MovAB
} else {
Token::Mov(
Register::from(lhs.to_string()),
self.from_binary_to_decimal(rhs)?,
)
};
result.push(token);
}
//~~~
fn from_binary_to_decimal(&self, text: impl Into<String>) -> Result<u8, EmulatorError> {
let ret = text.into();
let binary_to_decimal = u8::from_str_radix(&ret, 2);
binary_to_decimal
.map_err(|_| EmulatorError::new(&format!("Failed to parse string: {}", ret)))
}
//~~~
//~~~
for token in tokens {
let program = match token {
Token::Mov(Register::A, immediate) => self.generate_binary_code(0b0011, immediate),
Token::Mov(Register::B, immediate) => self.generate_binary_code(0b0111, immediate),
Token::MovAB => self.generate_binary_code_with_zero_padding(0b0001),
Token::MovBA => self.generate_binary_code_with_zero_padding(0b0100),
Token::Add(Register::A, immediate) => self.generate_binary_code(0b0000, immediate),
Token::Add(Register::B, immediate) => self.generate_binary_code(0b0101, immediate),
Token::Jmp(immediate) => self.generate_binary_code(0b1111, immediate),
Token::Jnc(immediate) => self.generate_binary_code(0b1110, immediate),
Token::In(Register::A) => self.generate_binary_code_with_zero_padding(0b0010),
Token::In(Register::B) => self.generate_binary_code_with_zero_padding(0b0110),
Token::OutB => self.generate_binary_code_with_zero_padding(0b1001),
Token::OutIm(immediate) => self.generate_binary_code(0b1011, immediate),
};
result.push(program);
}
Ok(result)
}
fn generate_binary_code(&self, operation: u8, immediate: u8) -> u8 {
let shift_operation = operation << 4;
let shift_data = immediate & 0x0f;
shift_operation | shift_data
}
//~~~
これでひとまずTD4エミュレータは実装できました。
簡易アセンブラを書いたファイルを引数に渡して実行すると、中間報告とともに実行結果をしゅつりょくしてくれます。
~~~
Finished dev [unoptimized + debuginfo] target(s) in 50.54s
Running `target\debug\remu.exe examples/test/test.txt`
["mov A 0001", "add A 0001", "mov B A", "out B"]
["mov", "A", "0001"]
["add", "A", "0001"]
["mov", "B", "A"]
["out", "B"]
[Mov(A, 1), Add(A, 1), MovBA, OutB]
[49, 1, 64, 144]
Port B Output: 2
ちゃんと1+1
の計算結果である2
が出力されています。
これにて第一フェーズTD4エミュレータの実装はおしまいです。
次回からははりぼてOSエミュレータ編となります。
終りが見えない笑
大変お世話になった参考サイト
リポジトリ本スクラップにて開発中のrust製エミュレータ、remu
x86編 -Clapを導入-
数日ぶりです。流星 彗です。今回からはx86編ということで32bitCPUをエミュレートし、「はりぼてOS」を動作させるところまでやっていきます。(長くなりそうな予感)
コマンドラインパーサの導入
いきなり非必須(Optional)な仕組みとして、コマンドラインパーサを導入しました。
コマンドラインパーサとは、--arg hoge
のようなソフトウェアを実行する際に渡される引数を構文解析するものです。Rustの場合コマンドラインパーサを用いずとも引数による処理分岐はできますが、処理が複雑になり、可読性の低下や思わぬ誤作動を招くリスクがあるため、本プロジェクトではサードパーティのコマンドラインパーサを使うことにしました。
今回は、Clap というクレートを使います。
なんでも、#[derive()]
で宣言的にオプションの定義を記述でき、下手に冗長的にならず効率的に開発が進められるようです。
$ cargo add clap
とすることで、Cargo.tomlファイルの[dependencies]
に追記され、プロジェクトから利用できるようになります。
なくてもなんとかなると思いますが、コードの先頭にextern crate clap;
と書いておくとよいかと思います。
オプションの定義
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
としたstruct
でオプションを定義します。
#[command(version, about, long_about = None)]
はそれぞれ、バージョン情報、簡単な説明、長文の説明(今回はなし)をマクロで省略することを示していると思われます(おそらく)。
今回は、エミュレートするアーキテクチャを指定するtarget
、ブートするイメージのパスを指定するimage
を定義することにしました。
また、#[derive(ValueEnum)]
とenum
で指定されるべきターゲットをあらかじめ定義しておきます。まあ、現時点ではTD4とx86のみのサポートであるためまだあんまり役に立ちませんが笑
実装
上記docs.rsに書かれているチュートリアルをかなり参考にしています。
#[macro_use]
extern crate clap;
use clap::Parser;
use remu::td4;
use std::fs::File;
use std::io::{BufRead, BufReader};
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Cli {
#[arg(short, long)]
target: Target,
#[arg(short, long)]
image: String,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
enum Target {
Td4,
}
fn main() {
let cli = Cli::parse();
//println!("{:?}", cli.name);
let file = BufReader::new(File::open(cli.image).expect("ERROR: File not found."));
let operations = file
.lines()
.map(|line| line.unwrap())
.collect::<Vec<String>>();
println!("{:?}", operations);
match cli.target {
Target::Td4 => {
println!("here");
td4::td4::td4(operations)
}
}
}
x86編 -Clapの必要最低限のチュートリアル-
How to set up
$ cargo add clap --features derive
または
[dependencies]
+ clap = { version = "4.5.4", features = ["derive"] }
でcargoプロジェクトにclapが追加されます。
How to coding
コマンド定義
#[derive(Parser)]
されたstruct
にて定義(宣言)を行います。
フィールドに#[arg(short, long)]
をつけることで -o --outputというように省略された形(short)と正式なもの(long)両方に対応することができます。
remuでの定義例を以下に示します。
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Cli {
#[arg(short, long)]
target: Target,
#[arg(short, long)]
image: String,
}
ここで、target: Target
となっているフィールドがあるかと思います。
これは、次の章で解説するenum
を用いた値の制限で定義されたTarget列挙型です。
また、#[command(version, about, long_about = None)]
というのは、()で指定されたオプションに対応する値をCargo.tomlファイルからマクロ的に読み込み、定義を省略できるものです。
私の環境では、versionのみ正常に動作しました。
書いておいたほうがいいおまじないのようなものと思ってくれれば結構です。
enumを用いた値の制限
先ほどちらっとお話した部分の解説になります。
#[derive(ValueEnum)]
をつけたenum
はその列挙型が注釈されたオプションが、ユーザに指定されるべき値を列挙することができます。
当たり前ですがユーザが列挙されていない値を指定しようとするとエラーを吐きます。
これにより、意図せぬ値によって、プログラムが誤動作することを防げます。また、あらかじめ選択肢が与えられることで、ユーザにとって使いやすくすることができます。
remuでの実装例を以下に示します。
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
enum Target {
Td4,
X86,
}
fn main() {
//~~~
match cli.target {
Target::Td4 => {
td4::td4::td4(operations)
}
Target::X86 => {
println!("86yade!");//test
}
}
//~~~
}
実行例
cargo build
した実行ファイルやcargo run
コマンドで普通に実行できます。
$ remu.exe --target td4 --image examples/test/test.txt
["mov", "A", "0001"]
["add", "A", "0001"]
["mov", "B", "A"]
["out", "B"]
[Mov(A, 1), Add(A, 1), MovBA, OutB]
[49, 1, 64, 144]
Port B Output: 2
$ cargo run -- --target td4 --image examples/test/test.txt
["mov", "A", "0001"]
["add", "A", "0001"]
["mov", "B", "A"]
["out", "B"]
[Mov(A, 1), Add(A, 1), MovBA, OutB]
[49, 1, 64, 144]
Port B Output: 2