Writing an OS in 1,000 Lines を Rust でやってみた
1000行でOSをつくってみる
Writing an OS in 1,000 Lines という1000行前後でOSをつくれるコンテンツがあります。
このコンテンツ自体はC言語を前提としているのですが、Rustでもシステムプログラミングが可能なので、今回はRustで挑戦してみました。
Rust で Writing an OS in 1,000 Lines をやってみるときのポイントをまとめておきます。
RISC-V
このコンテンツでは RISC-V をターゲットアーキテクチャとしていて、RISC-V のOSを開発します。
RISC-V はオープンソースのRISC (Reduced Instruction Set Computer) のISA (命令セットアーキテクチャ) です。
詳しい説明は、RISC-V で検索すれば数多くヒットします。
この RISC-V 関連で、参考にした主な情報源は以下のとおりです。
- RISC-V 公式の仕様書
- RISC-V の命令一覧
- FPGA開発日記 (RISC-V関連の記事が多い)
各章のポイント
Writing an OS in 1,000 Lines は、18章で構成されており、それぞれの章で環境構築をしたり、プロセス管理の実装をしたり、ファイルシステムの実装をしたり、とステップバイステップで進んでいきます。
Rustで挑戦するにあたり、各章でのポイントとなった点をまとめていきます。
(章構成については、本記事執筆時点のものです)
2. 開発環境
- 記事通りに、brew や apt で必要なものをインストールします
- Rust でやるので、Rust もインストール (https://doc.rust-lang.org/cargo/getting-started/installation.html)
curl https://sh.rustup.rs -sSf | sh
-
cargo init
して、プロジェクトを初期化しておきます - cargo ターゲットに RISC-V を追加 (https://rust-lang.github.io/rustup/cross-compilation.html)
- Rust で RISC-V 向けのコンパイルができるようにする必要があります
- 全ターゲットの一覧を確認する
rustup target list | grep riscv
- 今回は、
riscv32i-unknown-none-elf
をターゲットするので、これを追加しますrustup target add riscv32i-unknown-none-elf
- デフォルトターゲットを変更
- 今回は
riscv32i-unknown-none-elf
のみをターゲットとするので、これをデフォルトに設定しておきます
(設定してないと、毎回オプションで指定する必要があります) - cargo の設定ファイルを作って、そこに設定を書きます (https://doc.rust-lang.org/cargo/reference/config.html)
-
.cargo/config.toml
ファイル[build] target = "riscv32i-unknown-none-elf"
-
- 今回は
- メインファイル変更
-
Cargo.toml
に次の設定を足すことで、src/main.rs
ではなくてsrc/kernel.rs
をエントリにできます[[bin]] name = "kernel" path = "src/kernel.rs"
-
- nightly を使うように設定する
-
naked_functions
等の unstable な機能を使うためには、nightly を利用する必要があります - 設定はかんたんで、
rust-toolchain
というファイルにnightly
と書くだけですecho "nightly" > rust-toolchain
-
5. ブート
- 先にコードを載せますが、
kernel.rs
は以下のようにしました#![no_std] #![no_main] #![feature(naked_functions)] use core::{arch::asm, panic::PanicInfo, ptr}; extern "C" { static mut __bss: u32; static __bss_end: u32; static __stack_top: u32; } #[no_mangle] fn kernel_main() { unsafe { let bss = ptr::addr_of_mut!(__bss); let bss_end = ptr::addr_of!(__bss_end); ptr::write_bytes(bss, 0, bss_end as usize - bss as usize); } loop {} } #[link_section = ".text.boot"] #[naked] #[no_mangle] extern "C" fn boot() { unsafe { asm!( "la sp, {stack_top}", "j kernel_main", stack_top = sym __stack_top, options(noreturn) ); } } #[panic_handler] fn panic(_info: &PanicInfo) -> ! { loop {} }
-
#![no_std]
- no_stdは、Rust標準ライブラリ(std)を使わずにプログラムを作成するための属性です。組み込みシステムやOS開発など、低レベルな環境での利用に適しています。 stdクレートの代わりにcoreクレートを使用したり、自分で実装したりします。
-
#![no_main]
- no_mainは、Rustの標準的なエントリーポイントであるmain関数を使わずにプログラムを起動するための属性です。独自の初期化コードを実装する際に使用されます。
-
#![features(hoge)]
- Rustのexperimentalな機能を利用できるようになります。今回だと、
#[naked]
を使えるように#![feature(naked_functions)]
を入れています。 (https://doc.rust-lang.org/cargo/reference/unstable.html)
- Rustのexperimentalな機能を利用できるようになります。今回だと、
- リンカで指定しているシンボルやアセンブリ等で指定しているシンボルは、この記述でRustプログラム内でも利用できるようにします。
extern "C" { static mut __bss: u32; static __bss_end: u32; static __stack_top: u32; }
-
#[no_mangle]
- Rustのマングリング (mangling) とは、関数や変数名を一意に識別するために、コンパイラが名前を複雑な文字列に変換することです。これにより、異なるモジュールやクレート内での名前の衝突を防ぎます。
- このマングリング処理を行わないようにするための設定が
#[no_mangle]
です。 - 今回の場合だと、アセンブラから
j kernel_main
として、利用されていて、kernel_main
というシンボル名をそのまま使いたいのでこのオプションをつけています。
- C言語の
memset
はRustのptr::write_bytes
,memcpy
はptr::copy
が使えます。 -
#[link_section = ".text.boot"]
-
#[link_section]
は、特定のシンボルを特定のセクションに配置するために使用されます。#[link_section = ".text.boot"]
は、そのシンボルが.text.boot
セクションに配置されます。
-
-
#[naked]
- コンパイラは関数にプロローグやエピローグと呼ばれるコードを自動生成するそうです。これらのコードを自動生成しないための設定が
#[naked]
です。
- コンパイラは関数にプロローグやエピローグと呼ばれるコードを自動生成するそうです。これらのコードを自動生成しないための設定が
-
unsafe
とasm!
- メモリ安全を保証できないコードは
unsafe
をつける必要があります。 - Rustでインラインアセンブリを記述するには
asm!
を使います。 -
asm!
はunsafe
で囲う必要があります。 - https://doc.rust-jp.rs/book-ja/ch19-01-unsafe-rust.html
- メモリ安全を保証できないコードは
- panic_handler
-
- カーネルパニック の内容ですが、
#![no_std]
を使う場合、パニックハンドラを定義してないとエラーになってしまうので、先に用意しています。
- カーネルパニック の内容ですが、
- とりあえず、最小限のハンドラとして、ただただループするだけのハンドラとしています。
-
-
-
run.sh
は以下のようにしました- Cコンパイラによるビルドの部分を
cargo build
に変更します - ビルドされたファイルは
target/
ディレクトリ配下に各ターゲットごとに配置されます
#!/bin/bash set -xue QEMU=qemu-system-riscv32 KERNEL=target/riscv32i-unknown-none-elf/debug/kernel cargo build $QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \ -kernel $KERNEL
- Cコンパイラによるビルドの部分を
- さらに、
build.rs
というファイルを作って、ビルドの設定をします- 今回だとリンカの設定をしています
fn main() { println!("cargo:rerun-if-changed=src/kernel.ld"); println!("cargo::rustc-link-arg=-Tsrc/kernel.ld"); println!("cargo::rustc-link-arg=-Map=kernel.map"); }
6. Hello World!
Hello World 表示
-
ecall
のアセンブラ部分だけasm!( "ecall", inout("a0") arg0 => error, inout("a1") arg1 => value, in("a2") arg2, in("a3") arg3, in("a4") arg4, in("a5") arg5, in("a6") fid, in("a7") eid );
- Rustの変数とレジスタの紐付けは、
inout("a0") arg0 => error
やin("a2") arg2
で行います。
- Rustの変数とレジスタの紐付けは、
printf
- カーネルとユーザーランドの両方から使うファイルとして、
common.h
が登場してきます。今回は、commonクレートを作ることにしました。cargo init --lib common
- ルートのクレートをカーネルのクレートとします。カーネルクレートからcommonを使う設定を書きます。
[dependencies] common = { path = "./common" }
- ルートのクレートをカーネルのクレートとします。カーネルクレートからcommonを使う設定を書きます。
- printf の実装
- Rust の core には
core::fmt
というのがあり、これがフォーマット関連の処理が実装されています。今回はこれを利用して、print!
とprintln!
の2つを実装しました。 - commonから
putchar()
を使うのは、extern "C"
経由で使っているので、 カーネルのputchar()
には#[no_mangle]
をつけています。use core::fmt::Write; extern "C" { fn putchar(ch: u8); } pub struct Console; impl Write for Console { fn write_str(&mut self, s: &str) -> core::fmt::Result { for c in s.as_bytes() { unsafe { putchar(*c) } } core::fmt::Result::Ok(()) } } pub fn _print(args: core::fmt::Arguments) { let mut console = Console; console.write_fmt(args).unwrap(); } #[macro_export] macro_rules! print { ($($arg:tt)*) => ($crate::_print(format_args!($($arg)*))); } #[macro_export] macro_rules! println { () => ($crate::print!("\n")); ($($arg:tt)*) => { $crate::print!("{}\n", format_args!($($arg)*)); }; }
- Rust の core には
7. C標準ライブラリ
- メモリ操作系は
core::ptr
が使えます - 文字列操作系は自分で実装しました
-
is_aligned
とalign_up
はこんな感じで実装していますpub const fn align_up(value: usize, align: usize) -> usize { let r = value % align; if r == 0 { value } else { value + (align - r) } } pub const fn is_aligned(value: usize, align: usize) -> bool { value % align == 0 }
8. カーネルパニック
-
- ブート で実装済なので、
println
を使って、メッセージを表示するようにだけ変更しました
- ブート で実装済なので、
9. 例外処理
- ここまで出てきた
asm!
やno_mangle
を使って、実装していくだけです -
write_csr
とread_csr
はマクロを用意しておくと便利です
10. メモリ割り当て
-
next_paddr
について-
__free_ram
のアドレスを初期値として、static mut な変数を定義しますstatic mut NEXT_PADDR: *mut u8 = unsafe { ptr::addr_of_mut!(__free_ram) };
-
11. プロセス
- 基本的には、実装していくだけです。
static
が増えるので、自分はProcessManager
という構造体をつくって、こいつがプロセス一覧を持ち、プロセスの作成や切り替えを管理するようにしました。 -
switch_context()
には、#[naked]
と#[no_mangle]
をつけます - ポインタ操作は
core::ptr
を活用します-
add
やoffset
メソッドでは、何のポインタであるかをベースに自動で計算してくれます。例えば、今回の例だとu32
型 (4bytes) のポインタなので、offset(-10)
だと-10 * 4bytes
で-40
されます。
let stack = ptr::addr_of_mut!(proc.stack) as *mut u32; let sp = stack.add(proc.stack.len()); *sp.offset(-1) = 0; // s11 *sp.offset(-2) = 0; // s10 *sp.offset(-3) = 0; // s9 *sp.offset(-4) = 0; // s8 *sp.offset(-5) = 0; // s7 *sp.offset(-6) = 0; // s6 *sp.offset(-7) = 0; // s5 *sp.offset(-8) = 0; // s4 *sp.offset(-9) = 0; // s3 *sp.offset(-10) = 0; // s2 *sp.offset(-11) = 0; // s1 *sp.offset(-12) = 0; // s0 *sp.offset(-13) = 0; // ra proc.sp = sp.offset(-13) as VAddr;
-
12. ページテーブル
-
map_page
は次のように実装しました。ここでもcore::ptr
を使っています。pub fn map_page(table1: *mut u32, vaddr: VAddr, paddr: PAddr, flags: u32) { if !is_aligned(vaddr as usize, PAGE_SIZE) { panic!("unaligned vaddr {vaddr}"); } if !is_aligned(paddr as usize, PAGE_SIZE) { panic!("unaligned paddr {paddr}"); } let table1 = table1 as *mut u32; let vpn1 = ((vaddr >> 22) & 0x3ff) as isize; unsafe { if (*table1.offset(vpn1) & PAGE_V) == 0 { let pt_paddr = alloc_pages(1); *table1.offset(vpn1) = ((pt_paddr / PAGE_SIZE as u32) << 10) | PAGE_V; } let vpn0 = ((vaddr >> 12) & 0x3ff) as isize; let table0 = ((*table1.offset(vpn1) >> 10) * PAGE_SIZE as u32) as *mut u32; *(table0.offset(vpn0)) = ((paddr / PAGE_SIZE as u32) << 10) | flags | PAGE_V; } }
13. アプリケーション
- ユーザーランド用にクレートを作成しました
cargo init user
- エントリになるファイル名と出力ファイルの名前を変更しています。
user/Cargo.toml
ファイルに以下を追記しています。また、src/main.rs
をsrc/user.rs
にリネームしました。src/user.rs
にも#![no_std]
と#![no_main]
をつけます。[[bin]] name = "user" path = "src/user.rs"
- user側も
build.rs
でリンカの設定をします。fn main() { println!("cargo:rerun-if-changed=src/user.ld"); println!("cargo:rustc-link-arg=-Tsrc/user.ld"); }
-
run.sh
を変更して、ユーザー側のビルドと実行ファイルの変換を行います。USER=user/target/riscv32i-unknown-none-elf/debug/user (cd user && cargo build) llvm-objcopy --set-section-flags .bss=alloc,contents -O binary $USER shell.bin llvm-objcopy -Ibinary -Oelf32-littleriscv shell.bin shell.bin.o
- カーネル側の
build.rs
を変更して、リンク時にユーザー側のshell.bin.o
をリンク対象にしますfn main() { println!("cargo:rerun-if-changed=src/kernel.ld"); println!("cargo::rustc-link-arg=-Tsrc/kernel.ld"); println!("cargo::rustc-link-arg=-Map=kernel.map"); println!("cargo:rerun-if-changed=./shell.bin.o"); println!("cargo::rustc-link-arg=./shell.bin.o"); }
14. ユーザーモード
- ユーザーアプリケーションのシンボルをRustプログラム内で使えるようにします
extern "C" { static _binary_shell_bin_start: u32; static _binary_shell_bin_size: u32; }
-
user_entry()
において、USER_BASE
やSSTATUS_SPIE
といった定数をasm!
の入力に与える必要があります。asm!
の入力に定数を使うために、#![feature(asm_const)]
をsrc/kernel.rs
に追記しています。 -
shell.rs
- カーネルが利用するメモリ領域に書き込みしようとして、パニックを発生させます。
#[no_mangle] fn main() { unsafe { let addr = 0x80200000 as *mut u32; *addr = 0x1234; } loop {} }
- ↓なパニックが発生します。発生箇所が
0x80200000
、 cause がF
(Store Page Fault) です。また、sepcが示す場所の命令をllvm-objdump
等でみてみると、shell.rs
の代入部分のsw
命令が見つかります。unexpected trap scause=f, stval=80200000, sepc=1000038
15. システムコール
-
println
使うとパニックを起こすので、直接putchar
で文字列を表示するようにしました。(原因がわかっておらず、未解決のままです。) - そのまま実装を進めていると謎のpanicが発生するようになってしまいました。
memset
の1命令目で発生してるっぽいことまではわかったのですが、原因わからず解決できず。理由はわかってないですが、ユーザー側のターゲットを release (cargo build --release
) にしたら、panic 発生しなくなったので、とりあえずこれで進めることにしました。
16. ディスクの読み書き
-
struct VirtioVirtq
には、パディングを入れる必要があります。https://github.com/Catminusminus/senos/blob/6d612fbf17a30bcb32061004841f14fb20dc6afa/senos/src/main.rs#L310-L326 を参考に、データ型の大きさをもとにパディングサイズを計算し、[u8; size]
のフィールドを定義することでパディングとしました。Rustでもっとスマートにかける方法を探し中です...。const SIZE_OF_U16: usize = core::mem::size_of::<u16>(); const SIZE_OF_U32: usize = core::mem::size_of::<u32>(); const SIZE_OF_U64: usize = core::mem::size_of::<u64>(); const SIZE: usize = (SIZE_OF_U64 + SIZE_OF_U16 + SIZE_OF_U32 + SIZE_OF_U16) * VIRTQ_ENTRY_NUM + (SIZE_OF_U16 + SIZE_OF_U16 + SIZE_OF_U16 * VIRTQ_ENTRY_NUM as usize); #[repr(C, packed)] struct VirtioVirtq { descs: [VirtqDesc; VIRTQ_ENTRY_NUM], avail: VirtqAvail, pad: [u8; (PAGE_SIZE - (SIZE % PAGE_SIZE)) / mem::size_of::<u8>()], used: VirtqUsed, queue_index: u32, used_index: *mut u16, last_used_index: u16, }
17. ファイルシステム
- tarのヘッダ用構造体
- virtioのときと同じように、パディングを入れてます
-
data
はサイズがわからないので、0にしておいて、先頭のアドレスだけわかるようにしてます#[repr(C, packed)] struct TarHeader { (省略) pad: [u8; 12], data: [u8; 0], }
- ファイルに書き込む内容は
\0
を末尾につけています。\0
が文字列の終端を表現しているためです。
まとめ
Writing an OS in 1,000 Lines を、Rustで一通りやってみました。
OSそれ自体の知識に加えて、RustやCargo周りに関しても非常に多くの学びがありました。
最終的には↓な感じで、文字列の入出力やファイルの読み書きができるようになりました。
OpenSBI v1.2
____ _____ ____ _____
/ __ \ / ____| _ \_ _|
| | | |_ __ ___ _ __ | (___ | |_) || |
| | | | '_ \ / _ \ '_ \ \___ \| _ < | |
| |__| | |_) | __/ | | |____) | |_) || |_
\____/| .__/ \___|_| |_|_____/|____/_____|
| |
|_|
(省略)
virtio-blk: capacity is 10240 bytes
file: ./hello.txt, size=14
file: ./lorem.txt, size=598
> hello
Hello world from shell!
> readfile
Lorem ipsum dolor sit amet, consectetur adipiscing elit. In ut magna consequat, cursus velit aliquam, scelerisque
odio. Ut lorem
> writefile
wrote 2560 bytes to disk
> readfile
Hello from virtio
> hello
Hello world from shell!
> exit
process 1 exited
switched to idle process
QEMU: Terminated
今回のリポジトリ
参考
上で書いたRISCV関連以外には、以下を参考にしました。
(他にもいろいろ見てたんですが、記録してないので覚えてる範囲で。)
- 先行事例
- Writing an OS in Rust
- Embedded Rust Techniques
Discussion