octox は Rust で一からすべて(ビルドシステムも含め)実装された Unix ライクなオペレーティングシステムです。 xv6-riscv に触発された学習用 OS として実装を始めたものです(元々は Linux のような OS の勉強として始めました)。
この投稿では、octox の実装を通して経験したことから自身の Unix-like OS の実装において Rust の機能がどのように活用されているかについて触れてみたいと思います。
本記事は英語で公開していた以下の記事の日本語訳版です。
Writing a Unix-like OS in Rust
上記の記事を投稿後、いくつかのニュースサイトでも特集され記事になっていました:
どなたかが deepwiki への取り込みもしてくれていましたので、併せて参照ください:
概要
octoxの特徴には以下が含まれます:
- カーネル、ユーザーランド、ビルドシステムがすべてRustで実装されています。
- 可能な限り、Rust言語の標準機能を使用しています。
- カーネルはライブラリクレートとしても使用可能なようになっています。
- ユーザーランドにはRustの
std
に似た使いやすさを持つライブラリが含まれており、シェルを含むUnix系のコマンドはこのライブラリを使用して実装されています。 - ファイルシステムはジャーナリング機能を備ています。
- マルチコアをサポートし、複数のプロセスが複数のコア上で同時に実行できるプリエンプティブカーネルを採用しています。
どのようなOSなのかは確認していただいた方が早いと思いますので、ぜひhttps://github.com/o8vm/octoxから試してみてください。
必要なのはRustとqemuだけです。これはビルドシステム全体がRustで記述されていることの大きなメリットの一つだと思います。私が確認した限りでは、Windows(mingw64またはWSL)、Linux、macOSで全く同じ手順でOSをビルドしてテストすることができます。Rustの標準機能以外の機能は使用されておらず、環境固有の調整も必要ありません。
Rustのビルドシステムの優秀さを体験いただけましたでしょうか。ビルドプロセス以外にもRustをOS開発に使用する利点は多くあります。
なぜRustなのか?
Rust言語の利点はすでに様々な場所で議論されているため、ここで繰り返す必要はないかもしれません。主に、Rustは以下の理由からOS実装にも最適です:
- 型安全な型システムにより、未定義の動作が発生しないことが保証されます。
- メモリ安全性により、解放後や初期化前のメモリ領域へのアクセスなどのメモリ操作を防ぎます。
- 型安全性とメモリ安全性による制約のおかげで、並行プログラムの記述も容易です。
- ただし、低レベルな機能を実装するために、
unsafe
キーワードを使用することで型安全でないプログラミングも可能です。 - モダンな言語機能が利用可能です
- クロスコンパイルとカスタムビルドが容易でそれもRustで記述できます。
さらに、Rust言語の恩恵を最小限の労力で十分に享受するために、octoxは以下の点に注意して実装されています:
- Rustの標準ビルドツールである
cargo
の利用を最大化すること。 - Rustの標準型の使用を最大化すること。
no_std
の使用によりstd
で利用できない型については、同等の機能を持つ代替手段を実装します。 -
unsafe
ブロックの使用を最小限に抑えること。
実装
以下に具体的な例を示します。
設定とビルド
octoxのカーネルはsrc/kernel
にあり、ユーザーランドはsrc/user
に存在します。ファイルシステムを作成するプログラムmkfs
はsrc/mkfs
にあります。これらの中で、カーネルは直接ライブラリとして利用でき、ユーザーランドの構築と mkfs
の構築の両方に使用されます。ビルドプロセスは cargo
のbuild.rs
ビルドスクリプトを通じて行われます。build.rs
は通常のRustプログラムで、ビルドのカスタマイズを可能にし、ビルド中にターゲットシステムを変更したり、ビルドプロセス中にファイルシステムを作成してOSの起動を準備したりすることができます。
build.rs:
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
let (uprogs_src_path, uprogs) = build_uprogs(&out_dir);
let mkfs_path = build_mkfs(&out_dir);
let fs_img = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("target").join("fs.img"); let mut mkfs_cmd = Command::new(&mkfs_path);
mkfs_cmd.status().expect("mkfs fs.img failed");
user クレート内には src/user/build.rs
もあり、これは上記の build_uprogs()
関数内で実行されます。libkernel
クレートを使用して、ユーザーランド側のシステムコールラッパーライブラリが自動的に生成され、src/user/usys.rs
に配置されます。各ユーザーランドプログラムは、自動生成された src/user/usys.rs
を利用してシステムコールを発行します。
src/user/build.rs:
fn main() {
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
let mut usys_rs = File::create(out_dir.join("usys.rs")).expect("cloudn't create src/user/usys.rs");
usys_rs.write_all("// Created by build.rs\n\n".as_bytes()).expect("src/user/usys.rs: write error");
for syscall_id in SysCalls::into_enum_iter().skip(1) {
usys_rs.write_all(syscall_id.gen_usys().as_bytes()).expect("usys write error");
}
カーネルライブラリの中核はシステムコールの実装です。enum SysCalls
には、システムコールテーブルTABLE
とターゲットに応じてコード変更を可能にするcfg
属性があります。システムコールの実態も定義されています。
src/kernel/syscall.rs:
#[derive(Copy, Clone, Debug)]
#[repr(usize)]
pub enum SysCalls {
Fork = 1,
Exit = 2,
Wait = 3,
...
}
impl SysCalls {
pub const TABLE: [(Fn, &'static str); variant_count::Self>()] = [
(Fn::N(Self::invalid), ""),
(Fn::I(Self::fork), "()"),
(Fn::N(Self::exit), "(xstatus: i32)"),
(Fn::I(Self::wait), "(xstatus: &mut i32)"),
...
];
}
impl SysCalls {
pub fn exit() -> ! {
#[cfg(not(all(target_os = "none", feature = "kernel")))]
unimplemented!();
#[cfg(all(target_os = "none", feature = "kernel"))]
{
exit(argraw(0) as i32)
}
}
....
}
#[cfg(not(target_os = "none"))]
impl SysCalls {
pub fn into_enum_iter() -> std::vec::IntoIterSysCalls> {
(0..core::mem::variant_count::SysCalls>())
.map(SysCalls::from_usize)
.collect::VecSysCalls>>()
.into_iter()
}
pub fn signature(self) -> String {
let syscall = Self::TABLE[self as usize];
format!(
"fn {}{} -> {}",
self.fn_name(),
syscall.1,
self.return_type()
)
}
このようにしておくことで将来octoxをライブラリOSや unikernel に簡単に変換できるようになることを狙っています。
実行
.cargo/config.toml
で qemu
をランナーとして設定すると、cargo run
を実行した際に qemu
上で OS を起動できます。
.cargo/config.toml:
[target.riscv64gc-unknown-none-elf]
runner = """ qemu-system-riscv64 -machine virt -bios none -m 524M -smp 4 -nographic -serial mon:stdio -global virtio-mmio.force-legacy=false -drive file=target/fs.img,if=none,format=raw,id=x0 -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0 -kernel """
インラインアセンブリ
Rustにはインラインアセンブリがあり、関数にはlink_section
およびrepr(align)
属性を付けることができるため、アセンブリ用のファイルを用意したり、ビルドプロセスを複雑にしたり、ファイルを切り替えたりする必要はありません。また、関数内で関数を記述できるため、トランポリンコードは次のように *.rs
ファイル内で簡単に記述できます(こちらは記事執筆時点の nightly の機能に依存していました。最新の Rust では仕様が変更になり変更の必要性があります。安定化した naked 属性に移行を検討中です。):
src/kernel/trampoline.rs
#[link_section = "trampsec"]
#[no_mangle]
#[repr(align(16))]
pub unsafe extern "C" fn trampoline() -> ! {
unreachable!();
#[link_section = "trampsec"]
#[naked]
#[no_mangle]
#[repr(align(16))]
pub unsafe extern "C" fn uservec() -> ! {
asm!(
"csrw sscratch, a0",
"li a0, {tf}",
"sd ra, 40(a0)",
...
"csrw satp, t1",
"sfence.vma zero, zero",
"jr t0",
tf = const TRAPFRAME,
options(noreturn)
);
}
#[link_section = "trampsec"]
#[naked]
#[no_mangle]
#[repr(align(16))]
pub unsafe extern "C" fn userret(pagetable: usize) -> ! {
...
asm!(
....
"sret",
tf = const TRAPFRAME,
options(noreturn),
);
}
}
型とトレイトによるアドレス空間の分割
oxtoxでは、物理アドレス、カーネルアドレス空間、ユーザーアドレス空間がタイプによって明確に分離されています。そのため、それらを混同して誤った操作をすることはありません。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[repr(transparent)]
pub struct PAddr(usize);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[repr(transparent)]
pub struct KVAddr(pub usize);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
#[repr(transparent)]
pub struct UVAddr(usize);
一方、共通機能はRustのトレイトと呼ばれる機能によって実装されています。
例えば、VAddr
トレイトが実装されていれば、ページテーブルエントリを検索するためのwalk()
メソッドは、カーネル/ユーザーアドレス空間に関係なく共通して使用できます。最新の言語機能の使用により、見通しがよくなっていると思います。
pub trait VAddr: Addr + Debug {
const PXMASK: usize = 0x1FF;
fn px(&self, level: usize) -> usize;
const MAXVA: usize = 1 (9 + 9 + 9 + 12 - 1);
}
impl_vaddr!(KVAddr);
impl_vaddr!(UVAddr);
#[derive(Debug, Clone)]
pub struct PageTableV: VAddr> {
ptr: *mut RawPageTable,
_marker: PhantomDataV>,
}
implV: VAddr> PageTableV> {
pub fn walk(&mut self, va: V, alloc: bool) -> Option&mut PageTableEntry> {
let mut pagetable = self.ptr;
if va.into_usize() >= V::MAXVA {
panic!("walk");
}
...
}
}
アトミック型
Rustには、usize
に対するAtomicUsize
やbool
に対するAtomicBool
など、いくつかのデータ型のアトミックバージョンがあります。これにより、OSをマルチコア対応にすることが容易になります。さらに、アトミック型を使用することで、no_std
に存在しない並行型を簡単に構築できます。例えば、ロックに関する型などです。
ロック
OSをマルチコアサポートで実装する際、最初の重要なコンポーネントの一つはスピンロックです。しかし、no_std
環境では、スピンロック用の組み込み型はありません。octoxでは、ロック取得時に割り込みを無効にするカスタムスピンロックがMutex
型として導入されています。これはアトミック型を使用して簡単に実装できます。使用方法はstd
のMutex
型と同じです(カーネル内でスピンロック取得時に割り込みを無効にすることは、パフォーマンスの低下を避け、デッドロックの発生を防ぐことを目的としています)。
以下は使用例です。Rustのロック機構では、スコープが終了すると自動的にロックが解放されるため、ロックの解放を忘れることはありません。
let m: Mutexusize> = Mutex::new(5);
{
let mut val = m.lock();
*val = 6;
}
割り込み無効化機能は、IntrLock
型を導入して割り込みの有効化/無効化を管理し、それをRustのリソース管理に委譲することで安全に実装されています。
具体的には、スピンロックはロックを取得する際にIntrLock
値を保持し、この型の値が存在する間、そのプロセッサの割り込みを無効化します。逆に、プロセッサ上のすべてのIntrLock
型がドロップされると、そのプロセッサの割り込みが有効になるような実装になっています。Rustでは、値のライフタイムが終了すると、値を破棄するためにdrop()
(Drop
トレイト)が呼び出されます。ロックの解放を忘れる危険がないのと同様に、Rustの機能により割り込みを無効のままにしておく危険性も排除されます。
src/kernel/spinlock.rs:
pub struct MutexT> {
locked: AtomicBool,
data: UnsafeCellT>,
}
pub struct MutexGuard'a, T> {
mutex: &'a MutexT>,
_intr_lock: IntrLock,
}
implT> MutexT> {
pub fn lock(&self) -> MutexGuard'_, T> {
let _intr_lock = Cpus::lock_mycpu();
while self.lcoked.swap(true, Ordering::Acquire) {
core::hint::spin_loop();
}
MutexGuard {
lock: self,
intr_lock,
}
}
}
impl Cpus {
pub fn lock_mycpu(name: &str) -> IntrLock {
...
}
}
impl Drop for IntrLock {
fn drop(&mut self) {
}
}
octoxはOnceLock
型も実装しており、変数が一度だけ書き込まれることを可能にします。また、変数が最初にアクセスされたときに初期化を実行するLazyLock
型もあります。使用方法はRust標準ライブラリのhttps://doc.rust-lang.org/std/sync/struct.OnceLock.htmlおよびhttps://doc.rust-lang.org/std/sync/struct.LazyLock.htmlとそれぞれ同じです。
これにより、static
変数の初期化がより安全でわかりやすくなります。
src/kernel/vm.rs:
pub static mut KVM: OnceLockKvm> = OnceLock::new();
pub unsafe fn kinit() {
unsafe {
KVM.set(Kvm::new().unwrap()).unwrap();
KVM.get_mut().unwrap().make();
}
}
src/kernel/proc.rs:
pub static PROCS: LazyLockProcs> = LazyLock::new(|| Procs::new());
pub fn init() {
for (i, proc) in PROCS.pool.iter().enumerate() {
proc.data_mut().kstack = kstack(i);
}
}
他の、より高度なロックの型は、Arc
とMutex
、そしてプロセス機能を使用して実装されています。例としてはSleepLock
タイプがあります。詳細については、src/kernel/sleeplock.rs
を参照してください。
Rustの参照カウントの使用
参照カウントは、値の所有権を共有する所有者の数をカウントし、カウントがゼロになると値を破棄します。OSの実装には参照カウントを必要とするリソースがいくつかありますが、自分で参照カウントを行うとバグの原因となります。RustにはRc
とArc
という型があり、内部で参照カウントを使用して複数の所有者を実現できます。これにより、カウントミスを防ぐことができます。
例えば、バッファキャッシュを考えてみましょう。
バッファキャッシュは、IOの高速化とアクセスの同期化のためにディスクアクセスの前に配置されるディスクブロックのコピーです。
未使用のバッファキャッシュは再利用されるため、特定のバッファが使用されている場所を追跡する必要があります。ここで参照カウントが活躍します。複数の並行実行されるコードがバッファキャッシュに同時にアクセスする可能性があるため、並行実行されるコードでの使用をサポートするArc
を使用できます。
octoxでは、バッファキャッシュは次のように実装されています。データの実態をArc
でラップすることで参照カウンティングを行います:
src/kernel/bio.rs:
pub struct Buf {
data: Arc&'static SleepLockData>>,
}
メモリ内で参照カウントが必要なリソースもあります。例えば、メモリ内のInodeがそうです。
Inodeはディスク上(DInode
)またはメモリ内(MInode
)に存在することができ、MInode
には DInode
の内容がコピーされ、カーネルで使用するための情報が追加され存在しています。MInode
を参照するコードが存在する限り、メモリ内のInodeはメモリに割り当てられている必要があります。反対の場合は、メモリから破棄されなければなりません。これがまさにArc
の出番です。
octoxのInode
定義は次のとおりです。
src/kernel/fs.rs:
#[derive(Default, Clone, Debug)]
pub struct Inode {
ip: OptionArcMInode>>,
}
Arc
の参照カウントは、ファイルがそのInodeを開くか参照するたびに増加します。ファイルが使用されなくなると、参照カウントは減少します。参照カウントが減少し、Inode
がもう必要ないことが分かった場合、自動的にメモリから破棄されます。追加のコードは必要ありません。
また、Arc
を使用して、開いているファイルを管理することもできます。ファイルは複数のプロセスまたは単一のプロセスによって複数回開かれることがあり、それらがいくつあるかを記憶する必要があります。これは、Arc
がここでも使用できることを意味し、参照カウントのエラーを防ぎ、不要になったリソースを適切に破棄するのに役立ちます。
#[derive(Default, Clone, Debug)]
pub struct File {
f: OptionArcVFile>>,
readable: bool,
writable: bool,
cloexec: bool,
}
ちなみに、File
はInode
に追加されたオフセットとファイルデータのためのFNod
、デバイスファイルのためのDNod
、パイプのためのPipe
をenum VFile
として抽象化したもので、readable
、writable
、cloexec
などの属性が付与されています。octoxはファイル状態管理テーブルを使用せず、代わりに各オープンファイルごとに状態を管理します。これもRustの型システムのおかげで実装が容易でした。
#[derive(Debug)]
pub enum VFile {
Device(DNod),
Inode(FNod),
Pipe(Pipe),
None,
}
#[derive(Debug)]
pub struct FNod {
off: UnsafeCellu32>,
ip: Inode,
}
#[derive(Debug)]
pub struct DNod {
driver: &'static dyn Device,
ip: Inode,
}
#[derive(Debug)]
pub struct Pipe {
rx: OptionReceiveru8>>,
tx: OptionSyncSenderu8>>,
}
MultiProducer-MultiConsumer (MPMC) channel
octoxはカーネル内にCondvar
を持っており、プロセスの sleep()
と wakeup()
を使用して実装されています。このCondvar
はRust標準ライブラリhttps://doc.rust-lang.org/std/sync/struct.Condvar.htmlに見られるようなnotify_all()
やwait()
などのメソッドの使用を可能にします。このメカニズムにより、チャネルを簡単に実装でき、結果としてパイプも容易に実装することができます。
octoxはマルチプロデューサー-マルチコンシューマー(MPMC)チャネルを持ち、複数の送信者と受信者をサポートすることができます。このチャネルはRustの標準ライブラリのmpsc
モジュールを念頭に置いて設計されており、同等の使いやすさを提供しています。
pub fn sync_channelT: Debug>(max: isize, name: &'static str) -> (SyncSenderT>, ReceiverT>) { ... }
pub struct SyncSenderT: Debug> {
sem: ArcSemaphore>,
buf: ArcMutexLinkedListT>>>,
cond: ArcCondvar>,
scnt: ArcAtomicUsize>,
rcnt: ArcAtomicUsize>,
}
pub struct ReceiverT: Debug> {
sem: ArcSemaphore>,
buf: ArcMutexLinkedListT>>>,
cond: ArcCondvar>,
scnt: ArcAtomicUsize>,
rcnt: ArcAtomicUsize>,
}
以下は mpmc を使用したパイプの実装です: src/kernel/pipe.rs:
struct Pipe {
rx: OptionReceiveru8>>,
tx: OptionSyncSenderu8>>,
}
impl Pipe {
const PIPESIZE: isize = 512;
pub fn new(rx: OptionReceiveru8>>, tx: OptionSyncSenderu8>>) -> Self {
Self { rx, tx }
}
pub fn get_mode(&self) -> OMode { ... }
pub fn alloc() -> Result(File, File)> {
let (tx, rx) = sync_channel::u8>(Self::PIPESIZE, "pipe");
let p0 = Self::new(Some(rx), None);
let p1 = Self::new(None, Some(tx));
let f0 = FTABLE.alloc(p0.get_mode(), FType::Pipe(p0))?;
let f1 = FTABLE.alloc(p1.get_mode(), FType::Pipe(p1))?;
Ok((f0, f1))
}
pub fn write(&self, mut src: VirtAddr, n: usize) -> Resultusize> {
let tx = self.tx.as_ref().ok_or(BrokenPipe)?;
let mut i = 0;
while i n {
let mut ch: u8 = 0;
either_copyin(&mut ch, src)?;
let Ok(_) = tx.send(ch) else {
break;
};
src += 1;
i += 1;
}
Ok(i)
}
pub fn read(&self, mut dst: VirtAddr, n: usize) -> Resultusize> {
let rx = self.rx.as_ref().ok_or(BrokenPipe)?;
let mut i = 0;
while i n {
let Ok(ch) = rx.recv() else {
break;
};
either_copyout(dst, &ch)?;
dst += 1;
i += 1;
}
Ok(i)
}
}
上記のパイプの実装のように、Rustの言語機能をOSの機能へと活用することで、他のOS機能の開発を大幅に効率化することができます
No libc, Rust-like userland library available
octoxでは、libcは存在しません。ユーザーライブラリも完全にRustで書かれており、Rustのstd
のようなライブラリがシステムコールのラッパーとして機能しています。
ユーザーランドは簡単に言ってしまえば、カーネルにシステムコールを発行するだけです。ですが、これは単純に見えますが、特に便利ではありません。octox では使いやすさを向上させるために、ユーザーライブラリをRustの標準ライブラリに似せて実装しています。例えば、DirEntry
のような便利な機能へのアクセスがあると、ユーザーランドでの開発がより効率的になります(https://doc.rust-lang.org/std/fs/struct.DirEntry.html参照)。Rustの標準ライブラリに合わせることには他の利点もあります。その一つは互換性の問題の吸収です。Rustは様々なプラットフォームと互換性があり、システムコールやlibcを通じてではなく、Rust標準ライブラリのレベルで互換性を処理する方が効率的な場合があると考えました。目標は、あるOSで使用されているRustコードを私のOSで動作するように再コンパイルできることです。
octox ls example:
src/user/ls.rs
fn ls(path: &str) -> sys::Result()> {
let path = Path::new(path);
match fs::read_dir(path) {
Err(_) => {
let attr = File::open(path)?.metadata()?;
...
}
Ok(entries) => {
for entry in entries {
let entry = entry.unwrap();
let attr = entry.metadata()?;
println!(
"{:14} {:6} {:3} {}",
entry.file_name(),
format!("{:?}", attr.file_type()),
attr.inum(),
attr.len()
);
}
}
}
Ok(())
}
Rust の型をシステムコール引数として使用します。
octoxは将来Rustに導入されるcrabi
ABI https://github.com/rust-lang/rust/pull/105586 を期待し、Rustの型を直接システムコール引数として使用します。
従来、RustのABIは安定していなかったため、FFI、システムコールなどはC ABIを一度通過する必要がありました(例:extern "C"
)。しかし、C ABIを通過すると問題が発生する可能性があります。例えば、安全なString
型を使用したいのに、それを安全でないC文字列に一度変換しないことにはカーネルとユーザーランドの間で通信できません。また、余分なコピーが発生する可能性があるため、手間がかかります。すべてがRustで実装され、システムコールの引数にも Rust の型を使用することできれば魅力的です。
例えば、以下はexec
システムコールを自動生成するための定義です。
文字列スライスのスライスを引数として使用すれば、引数の数を指定する必要がなく、ユーザーランドとカーネル間で文字列変換を行う必要もありません。
impl SysCalls {
pub const TABLE: [(Fn, &'static str); variant_count::Self>()] = [
(Fn::I(Self::exec), "(filename: &str, argv: &[&str])"),
...,
];
}
exec()
でカーネル側からユーザーランドに渡される引数を、Rustと同じ型にすることもできます。ユーザープログラムに渡される引数は、スタック上のRust文字列スライスのスライスと考えることができます。カーネルから常に正しいデータが渡されると仮定すると、ユーザープログラム側での引数処理(https://doc.rust-lang.org/std/env/fn.args.htmlのような)は簡単に実装できます。文字列変換は必要ありません。
main()
を呼び出すlang_start()
、つまりRustのランタイム起動は、次のように定義できます:
#[lang = "start"]
fn lang_startT: Termination + 'static>(
main: fn() -> T,
_: isize,
args: *const *const u8,
_: u8,
) -> isize {
unsafe {
ARGS = (args as *const &[&str]).as_ref().copied();
}
let xstatus = main().report() as i32;
sys::exit(xstatus)
}
env モジュールは次のように定義されています:
pub static mut ARGS: Option&[&str]> = None;
pub fn args() -> Args {
Args {
iter: if let Some(args) = unsafe { ARGS } {
args.iter()
} else {
[].iter()
},
}
}
lang_start
引数は Rust によって定義されているため、現時点の私の OS では不要なものもありますが、ここでより柔軟性が与えられれば、ランタイムスタートアップのシグネチャは簡略化できるでしょう。例えば、lang_start(main: fn() -> T, args: Option)
の方がシンプルになります。
実装でつまづいたところ
ここで Rust とオペレーティングシステムの実装における困難やバグについて触れようと思っていましたが、驚くべきことに、よく話題にされるRust特有の難しさといったものは全く感じたことはありませんでした。私はCよりもRustでOSを書く方がはるかに簡単だと感じました。
なお、私は計算が苦手なので最も多く遭遇したバグの一つは配列インデックスの計算ミスでしたが、これはどの言語でも起こり得ることでしょう。しかし、計算ミスによって引き起こされるバグは C よりは発生しにくいのではないかと思われます。
他に難しかったところをあえて上げるなら、unsafe
とArc
の取り扱いがあります。かなり基本的なミスでしたが、デバッグには時間がかかりました。以下に、CPUを管理する構造体と配列の概要を示しています。問題は、これらの構造体からプロセス情報を抽出するためのコードで発生しました。
src/kernel/proc.rs:
pub static CPUS: Cpus = Cpus::new();
pub struct Cpus([UnsafeCellCpu>; NCPU]);
pub struct Cpu {
pub proc: OptionArcProc>>,
pub context: Context,
...
}
impl Cpus {
pub unsafe fn cpu_id() -> usize {
let id;
asm!("mv {0}, tp", out(reg) id);
id
}
pub unsafe fn mycpu(&self) -> *mut Cpu {
let id = Self::cpu_id();
self.0[id].get()
}
}
OSがプロセス情報を取得する必要がある場合、現在実行中のCPUコアのIDを使用してCPUS
配列から対応するCpu
構造体を取得し、そこからProc
を抽出します。問題はこの手順にありました。より理解を深めるために、問題のあるコードと修正されたバージョンを比較しましょう。
問題のあるコード:
pub fn myproc(&self) -> Option&ArcProc>> {
let _intr_lock = CPUS.lock_mycpu("withoutspin");
unsafe {
let c = self.mycpu();
(*c).proc.as_ref()
}
}
修正済みコード:
pub fn myproc() -> OptionArcProc>> {
let _intr_lock = Self::lock_mycpu("withoutspin");
let c;
unsafe {
c = &*CPUS.mycpu();
}
c.proc.clone()
}
問題のあるコードは、unsafe
ブロック内でCpu
への生ポインタを介してArc
への参照を返しています。具体的には、Arc
が格納されているアドレスを返しています。つまり、この関数はCPUS
配列内のターゲットプロセス情報(Arc
)が存在する場所のアドレスを返します。このアプローチは、プロセスが頻繁に異なるコア間を移動するため、重大な問題を引き起こします。その結果、そのアドレスを使用する時点では、操作対象のプロセスがもはやその場所に存在しない可能性があります。この不安定性が、mycpu()
操作がunsafe
ブロック内にある理由です。割り込みが無効になっていない限り、Cpu
の生ポインタは現在実行中のCPUコアを指していない可能性があります。
Arc
自体がプロセス情報へのポインタであることを考えると、関数は修正されたコードで示されているように、Arc
を直接返すべきです。この方法により、プロセスが元のコアから移動した場合でも、正しいプロセス情報にアクセスできることが保証されます。
比較的単純なバグでしたが、その識別と解決は即座には行われませんでした。OS のタスクは複数のコアにまたがって様々に実行されるため、実行ごとに異なる可能性があったためです。不具合の現れ方がランダムなのでした。例えば、ある実行ではコア1で例外が発生し、別の実行ではコア2で発生する可能性があります。しかし、この問題はCで書かれていた場合よりも迅速に特定できました。これらのエラーをunsafe
ブロックに関連付けることができるため、最初の調査をそこに向けることができ、エラーの潜在的な原因を絞り込むのに役立ちました。
In Conclusion
ここまでの例から、Rustがオペレーティングシステム開発に使えると思っていただけたら幸いです。
Rust の堅牢な型システムは開発を加速するだけでなく、問題の発生を最小限に抑え、問題が発生した場合も容易に特定できます。octoxは教育用OSとなることを目指しています、OS開発について学びたい人にとって有用なリソースとなれば幸いです。興味があれば、ぜひ開発に挑戦してみてください。カーネルが難しいと感じる場合には、ユーザーランドプログラミングから始めることも考えられますし、カーネルに新しいシステムコールを追加することから始めていただくこともできます。GitHubのhttps://github.com/o8vm/octoxにあるoctoxのREADMEに簡単な例があるので、それを参考にするとよいでしょう。
すでに、ユーザーランドプログラムをいくつか開発してくれる人や lisp を実装してくれる人がいました。他にも貢献いただける人がいたら嬉しいです。自作 Unix-like OS を一緒にはじめませんか?
今後の目標としては、複数のアーキテクチャを効果的にサポートする抽象化を導入する予定です。それが達成されたら、次のステップはoctoxを実際のハードウェアやクラウド環境のいずれかの上で実行することになります。カーネルランドにて wasm を実行することも検討していますし、実験的にスケジューラーと async/await ランタイムの統合についても検討しています。
また、将来的には、octoxのユーザーランドは Rustの標準ライブラリ(std)へと統合する野望も密かにあります。
Views: 2