🔡

Writing an OS in 1,000 Lines を Rust でやってみた

2024/06/09に公開

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 関連で、参考にした主な情報源は以下のとおりです。

各章のポイント

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プログラム内でも利用できるようにします。
      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, memcpyptr::copy が使えます。
    • #[link_section = ".text.boot"]
      • #[link_section] は、特定のシンボルを特定のセクションに配置するために使用されます。#[link_section = ".text.boot"] は、そのシンボルが .text.boot セクションに配置されます。
    • #[naked]
      • コンパイラは関数にプロローグやエピローグと呼ばれるコードを自動生成するそうです。これらのコードを自動生成しないための設定が #[naked] です。
    • unsafeasm!
      • メモリ安全を保証できないコードは unsafe をつける必要があります。
      • Rustでインラインアセンブリを記述するには asm! を使います。
      • asm!unsafe で囲う必要があります。
      • https://doc.rust-jp.rs/book-ja/ch19-01-unsafe-rust.html
    • panic_handler
        1. カーネルパニック の内容ですが、#![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
    
  • さらに、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 => errorin("a2") arg2 で行います。

printf

  • カーネルとユーザーランドの両方から使うファイルとして、common.h が登場してきます。今回は、commonクレートを作ることにしました。
    cargo init --lib common
    
    • ルートのクレートをカーネルのクレートとします。カーネルクレートからcommonを使う設定を書きます。
      [dependencies]
      common = { path = "./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)*));
          };
      }
      

7. C標準ライブラリ

  • メモリ操作系は core::ptr が使えます
  • 文字列操作系は自分で実装しました
  • is_alignedalign_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. カーネルパニック

    1. ブート で実装済なので、println を使って、メッセージを表示するようにだけ変更しました

9. 例外処理

  • ここまで出てきた asm!no_mangle を使って、実装していくだけです
  • write_csrread_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 を活用します
    • addoffset メソッドでは、何のポインタであるかをベースに自動で計算してくれます。例えば、今回の例だと 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.rssrc/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_BASESSTATUS_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

今回のリポジトリ
https://github.com/utouto97/os1000-rs

参考

上で書いたRISCV関連以外には、以下を参考にしました。
(他にもいろいろ見てたんですが、記録してないので覚えてる範囲で。)

Discussion