
どこまで続くかわからないけど、熱意が冷めないうちに…
Writing an OS in Rust を一通り写経したので、そこから先を実装する個人的なプロジェクトを始めていきたいと思います。
動機は完全なる趣味・興味です。Writing an OS in Rust は Async/Await の章までしか書かれていないので、そこから先、できれば xv6 と同等以上のレベルにまでは仕上げていきたいと思っています。
命名
せっかくなので自作 OS に名前をということで、「FerriOS」(フェリオス)と命名しました。
Rust の蟹である Ferris に寄せています。また、Rust が「錆」という意味なので、それに関連して「鉄」を表す Ferrous に近い発音を選びました。
リポジトリ
リポジトリはこちら。適宜更新していきます。
今後のロードマップ
今のところ、下記の手順で進めていきたいと思っています。
- カーネルスレッド実装(今回)
- コンテキスト実装
- スレッドテーブル実装
- コンテキストスイッチ実装
- スケジューラ実装
- ユーザスレッド実装
- ユーザモード対
- ユーザスタック実装
- システムコール
- マルチコア対応
- …
カーネルスレッドの実装
今回の本題です。
Writing an OS in Rust の12章「Async/Await」では協調的マルチタスクを実装しました。これは各タスクが必要な処理を終えたら自発的に CPU を手放す(yield)マルチタスク処理で、複数のスレッド間で CPU を共有している場合、次のタスクに CPU が移譲されます。しかし、協調的という言葉にも現れている通り、これは各タスクが適度に CPU を手放すことを前提としたアプローチであり、中には無制限に CPU を使いたがるタスクもあるかもしれません。また、ハードウェアとのやり取りや他の外的要因によって想定以上に実行時間が長くなってしまったり、バグで無限ループやブロッキングに陥ってしまった場合、CPU はそのタスクによって独占され続けるので、他のタスクは永遠に処理を続けることができなくなります。
そこで今回実装したいのは非協調的マルチタスクです。各タスクに一定時間ずつ CPU を割り当てていき、時間になったら強制的に CPU を取り上げて別のタスクに CPU を割り当てるアプローチです。これを実装するには、下記の2つが必要になります。
- コンテキスト
- スケジューラ
コンテキストは、プログラムが中断された時点の各タスクの情報(汎用レジスタ、プログラムカウンタ、スタックポインタなど)を次回再開するときのために保存しておくための領域です。
スケジューラは、一定時間おきに各タスクに順々に処理を割り当てていくためのアルゴリズムです。
タイマ割り込みを利用する
タイマ割り込みを利用すると、CPU に一定時間おきに割り込みを発生させてもらうことができます。スケジューラはこのタイマ割り込みを利用して、一定時間おきにタスクに順に CPU を割り当てていきます。タイマ割り込みが発生したときの処理を実装しているプログラムを「割り込みハンドラ」と呼びます。
Writing an OS in Rust では、既にタイマ割り込みハンドラ自体は実装されています。
/// タイマ割り込みハンドラ
extern "x86-interrupt" fn timer_interrupt_handler(_stack_frame: InterruptStackFrame) {
print!(".");
// End of interrupt (割り込み終了)
unsafe {
PICS.lock().notify_end_of_interrupt(InterruptIndex::Timer.as_u8());
}
}
現時点では単に “.” をタイマ割り込みが発生するたびに表示させているだけです。結果として、起動させたときに ↓ のように一定時間おきに延々と “.” が表示され続けます。

このタイマ割り込みハンドラに、後ほど実装するスケジューラを追加していきます。
コンテキストの実装
では、タスクの状態を保存しておくコンテキストを実装していきます。
まずは src/ の下に thread/mod.rs と thread/context.rs を作成します。
thread/mod.rs:
pub mod context;
また、lib.rs から thread モジュールを参照できるようにしておきます。
lib.rs:
pub mod thread;
では、コンテキストを実装していきましょう。
thread/context.rs:
/// コンテキスト構造体
/// コンテキストスイッチ時の保存/復元に利用
#[derive(Debug, Clone, Copy)]
#[repr(C)]
pub struct Context {
pub r15: u64,
pub r14: u64,
pub r13: u64,
pub r12: u64,
pub rbx: u64,
pub rbp: u64,
pub rsp: u64,
pub rip: u64,
}
impl Context {
pub fn new() -> Self {
Context {
r15: 0,
r14: 0,
r13: 0,
r12: 0,
rbx: 0,
rbp: 0,
rsp: 0,
rip: 0,
}
}
}
x86_64 で用いる汎用レジスタ r12, r13, r14, r15、rbx、rbp と、スタックポインタである rsp、そして return address を保持する rip を保存しておくための変数を用意しました。64 bit レジスタなので当然、u64 です。rax、rcx、rdx、rsi、rdi、r8、r9、r10、r11 は呼び出し元が使用するレジスタなので退避対象外としています。
スレッド構造体の実装
thread/mod.rs にスレッド構造体と関連する構造体を実装していきます。
まずはスレッドの状態を示す enum から:
thread/mod.rs:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThreadState {
Unused,
Embryo,
Sleeping,
Runnable,
Running,
Zombie,
}
次に、スレッド構造体(PCB)を実装していきます。
use context::Context;
/// Thread Control Block
#[derive(Debug, Clone, Copy)]
pub struct Thread {
pub tid: usize, // Thread ID
pub state: ThreadState, // スレッドの状態
pub context: Context, // スレッドのコンテキスト
pub kstack: u64, // このスレッド用のカーネルスタック
}
impl Thread {
pub fn new() -> Self {
Thread {
tid: 0,
state: ThreadState::Unused,
context: Context::new(),
kstack: 0,
}
}
}
とりあえず最低限カーネルスレッドを実行するのに必要な PID、ThreadState、コンテキスト、カーネルスタックだけ用意しておきました。
スレッドテーブルの実装
現在存在するスレッドを管理するためのスレッドテーブルを作成しておきます。グローバル変数として扱う必要があるので、初期化処理が1度だけ行われるよう lazy_static を使いましょう。
thread/mod.rs:
pub const NPROC: usize = 64;
use spin::Mutex;
use lazy_static::lazy_static;
lazy_static! {
pub static ref PROCESS_TABLE: Mutex<[Thread; NPROC]> = {
Mutex::new([Thread::new(); NPROC])
};
}
スレッドの最大数は xv6 と同じ 64 としておきます。lazy_static を利用することで、最初のアクセス時にサイズ64のスレッドテーブルとして Thread 型のグローバル配列が初期化されます。
コンテキストスイッチの実装
CPU が処理するタスクを切り替えるためのコンテキストスイッチを実装します。
thread/context.rs:
use core::arch::global_asm;
unsafe extern "C" {
pub fn switch_context(old: *mut Context, new: *const Context);
}
// コンテキストスイッチ
global_asm!(
r#"
.globl switch_context
switch_context:
# 現在のコンテキストを保存
mov [rdi + 0], r15
mov [rdi + 8], r14
mov [rdi + 16], r13
mov [rdi + 24], r12
mov [rdi + 32], rbx
mov [rdi + 40], rbp
# RSP を保存
lea rax, [rsp + 8]
mov [rdi + 48], rax
# RIP を保存
mov rax, [rsp]
mov [rdi + 56], rax
# RFLAGS を保存
pushfq
pop rax
mov [rdi + 64], rax
# 新しいコンテキストを復元
mov r15, [rsi + 0]
mov r14, [rsi + 8]
mov r13, [rsi + 16]
mov r12, [rsi + 24]
mov rbx, [rsi + 32]
mov rbp, [rsi + 40]
mov rsp, [rsi + 48]
# RFLAGS を復元
mov rax, [rsi + 64]
push rax
popfq
# 新しいスレッドへ jump
push qword ptr [rsi + 56]
ret
"#
);
中身はアセンブリの処理です。old コンテキストから new コンテキストへの CPU の各レジスタを切り替えていきます。
引数の順序的に、old の先頭アドレスは rdi レジスタに、new の先頭アドレスは rsi レジスタに記録されます。ですので、このレジスタの値からアドレスを計算していき、まずは各レジスタの値を old の構造体上に記録し、その後 new 構造体の値を各レジスタに復元していきます。
最後に、new 構造体に記録されている rip レジスタが指すプログラムカウンタに ret 命令でジャンプします。
CPU 固有の構造体の実装
後のスケジューラ実装に向けて、CPU ごとに固有の変数などを保持する Cpu 構造体を実装しておきます。
現状、ここには cpuid と、CPU 固有のスケジューラ用のコンテキスト構造体、そして CPU が現在実行しているスレッド ID を保持する current_tid を用意しておきます。
cpu.rs:
use crate::thread::context;
pub struct Cpu {
pub id: usize, // CPU ID
pub scheduler: context::Context, // スケジューラ用コンテキスト
pub current_tid: Option<usize>, // 現在実行中のスレッド ID
}
impl Cpu {
pub fn new(cpu_id: usize) -> Self {
Cpu {
id: cpu_id,
scheduler: context::Context::new(),
current_tid: None,
}
}
}
現在は CPU はシングルコアなので考慮する必要はありませんが、将来的に FerriOS をマルチコアに対応させるとき、この Cpu 構造体はコアごとに別々に保持するよう修正する必要があります。
スケジューラの実装
シンプルなラウンドロビン方式のスケジューラを実装します。スケジューラの実装は xv6 を参考にしました。
thread/scheduler.rs:
use super::{ ThreadState, PROCESS_TABLE, NPROC };
use super::context::{ Context, switch_context };
use crate::cpu;
use lazy_static::lazy_static;
static mut CURRENT_PID: usize = 0;
pub static mut SCHEDULER_STARTED: bool = false;
lazy_static! {
static ref CPU: spin::Mutex<cpu::Cpu> = spin::Mutex::new(cpu::Cpu::new(0));
}
/// スケジューラ
pub fn scheduler() -> ! {
unsafe {
if SCHEDULER_STARTED {
panic!("Scheduler already started");
}
SCHEDULER_STARTED = true;
}
loop {
let mut table = PROCESS_TABLE.lock();
let mut cpu = CPU.lock();
// 次に実行するスレッドの決定
let next_tid = {
find_next_runnable_thread(&table, cpu.current_tid)
};
match next_tid {
None => {
x86_64::instructions::interrupts::enable_and_hlt();
drop(cpu);
drop(table);
continue;
}
Some(next_tid) => {
let (old_context, new_context) = {
// スレッド状態を更新
table[next_tid].state = ThreadState::Running;
if let Some(current_tid) = cpu.current_tid {
if table[current_tid].state == ThreadState::Running {
table[current_tid].state = ThreadState::Runnable;
}
}
// CPU で実行中のスレッド ID を更新
cpu.current_tid = Some(next_tid);
let old_context = &mut cpu.scheduler as *mut Context;
let new_context = &table[next_tid].context as *const Context;
drop(cpu);
drop(table);
(old_context, new_context)
};
unsafe {
x86_64::instructions::interrupts::enable();
//crate::println!("switch");
switch_context(old_context, new_context);
}
}
}
}
}
fn find_next_runnable_thread(table: &[Thread; NPROC], current_tid: Option<usize>) -> Option<usize> {
let current_tid = current_tid.unwrap_or(0);
for i in 1..NPROC+1 {
let tid = (current_tid + i) % NPROC;
if table[tid].state == ThreadState::Runnable {
return Some(tid);
}
}
None
}
ラウンドロビン方式のスケジューラは単に、スレッドテーブルを走査していって実行可能 (Runnable) なスレッドを見つけ、それを順番に実行していく方式です。find_next_runnable_thread() で現在 CPU が実行しているスレッドの PID を取得し(cpu.current_tid)、PID + 1 から順に次に実行するスレッドを見つけていきます。Runnable なスレッドが見つかったらそれを次の実行スレッドとして選びます。その後、次に実行するスレッドの状態を Running に変更し、 cpu.current_tid を更新して、switch_context() でコンテキストスイッチします。
ここでコンテキストスイッチする元となるコンテキストは、前回実行していたスレッドではなくスケジューラです。cpu.scheduler にスケジューラのコンテキストを保存しておき、次のコンテキストスイッチが起きたときに再びこのスケジューラに処理が戻ってくるようにしています。
スケジューラ→スレッドに切り替えるコンテキストスイッチ処理を実装したということは、逆にスレッド→スケジューラに戻ってくるためのコンテキストスイッチ処理の実装も必要です。そこで下記の yield_from_context() を thread/scheduler.rs に実装します。
pub fn yield_from_context() {
x86_64::instructions::interrupts::disable();
let mut table = PROCESS_TABLE.lock();
let cpu = CPU.lock();
let current_tid = cpu.current_tid;
if current_tid.is_none() {
x86_64::instructions::interrupts::enable();
return;
}
let current_tid = current_tid.unwrap();
if table[current_tid].state != ThreadState::Running {
panic!("CPU has current_tid but the thread is not Running");
}
let (old_context, new_context) = {
// Runnable に変更
table[current_tid].state = ThreadState::Runnable;
// スケジューラへコンテキストスイッチ
let old_context = &mut table[current_tid].context as *mut Context;
let new_context = &cpu.scheduler as *const Context;
drop(cpu);
drop(table);
(old_context, new_context)
};
unsafe {
x86_64::instructions::interrupts::enable();
switch_context(old_context, new_context);
}
}
この yield_from_context() はタイマ割り込みハンドラから一定時間おきに呼び出され、実行中のスレッドから CPU 固有のスケジューラに処理を戻します。また同時に、実行していたスレッドを Runnable 状態に戻します。ここで switch_context() が実行されると、先程の scheduler() 内の switch_context() の直後に CPU の処理が戻ってくるわけです。
最後に、thread/mod.rs で thread から scheduler モジュールを参照できるようにしておきます。
thread/mod.rs:
pub mod scheduler;
タイマ割り込みハンドラからスケジューラを呼び出す
スケジューラの実装まで完了しましたので、タイマ割り込みハンドラから yield_from_context() を呼び出すように実装します。これにより、各スレッドは一定時間おきにスケジューラに遷移し、スケジューラを経て別のスレッドに CPU を譲るようになります。
interrupts.rs:
use crate::thread::scheduler;
/// タイマ割り込みハンドラ
extern "x86-interrupt" fn timer_interrupt_handler(_stack_frame: InterruptStackFrame) {
unsafe {
PICS.lock().notify_end_of_interrupt(InterruptIndex::Timer.as_u8());
}
unsafe {
if crate::thread::scheduler::SCHEDULER_STARTED {
scheduler::yield_from_context();
PICS.lock().notify_end_of_interrupt(InterruptIndex::Timer.as_u8());
}
}
}
notify_end_of_interrupt() は割り込みハンドラに割り込み完了 (EOI: End Of Interrupt) を伝えるための処理です。PIC は EOI を受け取るまで、タイマ割り込みハンドラと同等以下の優先度の割り込みを中断します。ここでコンテキストを切り替えると、そのコンテキストが再び実行されるまで、後の EOI 送信が実行されません。そこで、コンテキストスイッチ前にも一度 EOI を呼び出しています。
main 関数からもスケジューラを呼び出す
初回のスケジューラ呼び出しは kernel_main() から呼び出します。以降、このスケジューラが延々とループし続けます。
main.rs:
/// エントリポイント
fn kernel_main(boot_info: &'static BootInfo) -> ! {
...
thread::scheduler::scheduler();
}
カーネルスレッドを作成してみる
現状はまだユーザモードを実装していませんので、ひとまずカーネルで動作するスレッドを生成して、動くかどうかを試してみましょう。
まずはカーネルスレッド作成用のメソッドを用意しておきます。
thread/mod.rs:
static STACK_SIZE: usize = 4096 * 4;
/// カーネルスレッド作成
pub fn create_kernel_thread(entry: fn() -> !) {
// スレッド ID を確保
let tid = next_tid().expect("Thread table is full");
// スタックを作成
let stack = unsafe {
let layout = alloc::alloc::Layout::from_size_align(STACK_SIZE, 16).unwrap();
alloc::alloc::alloc(layout)
};
let stack_top = stack as u64 + STACK_SIZE as u64;
let mut table = PROCESS_TABLE.lock();
table[tid].tid = tid;
table[tid].state = ThreadState::Runnable;
table[tid].kstack = stack_top;
// コンテキストを初期化する
table[tid].context.rsp = stack_top;
table[tid].context.rip = entry as u64;
table[tid].context.rflags = 0x200; // IF (Interrupt Flag) を有効化
}
/// スレッド ID 決定
pub fn next_tid() -> Option<usize> {
let table = PROCESS_TABLE.lock();
for i in 0..NPROC-1 {
if table[i].state == ThreadState::Unused {
return Some(i);
}
}
None
}
ここでは 4KB 分のスタックを用意しておき、その先頭アドレスを新しく生成するスレッドのコンテキストの rsp レジスタにセットしておきます。スレッドの状態は Runnable としておきます。また、割り込みを有効化するために rflags には 0x200 (IF ビットを立てる)を設定しておきます。
スレッド ID は空いているものを next_tid() で確保します。もし空きが無い場合、Thread Table は満杯ですので、panic で終了します。
これでいよいよ準備完了です。main.rs に、カーネルスレッドで実行するプログラムを書いていきましょう。
main.rs:
// カーネルスレッド
fn kernel_thread_0() -> ! {
let mut count = 0;
loop {
// 割り込みが有効か確認
println!("Thread 0 running: {}", count);
count = count + 1;
for _ in 0..1000000 {
unsafe { core::arch::asm!("nop"); }
}
}
}
fn kernel_thread_1() -> ! {
let mut count = 0;
loop {
// 割り込みが有効か確認
println!("Thread 1 running: {}", count);
count = count + 1;
for _ in 0..1000000 {
unsafe { core::arch::asm!("nop"); }
}
}
}
動作確認のため2つの関数を用意しました。どちらも適当に表示して1ずつカウントアップしていくだけの簡単な処理です。一応、表示頻度を抑えるために nop を毎ループで100万回実行させています(これも早く sleep を実装したい!)。
そしてこれらを実行するスレッドを kernel_main() 内で生成します。
main.rs:
/// エントリポイント
fn kernel_main(boot_info: &'static BootInfo) -> ! {
...
// カーネルスレッド作成
thread::create_kernel_thread(kernel_thread_0);
thread::create_kernel_thread(kernel_thread_1);
thread::scheduler::scheduler();
}
さて、動かしてみましょう。

うまく動きました!スレッド 0 とスレッド 1 が(ほぼ)交互に表示されています。若干タイミングのズレで 0 が2連続になったり 1 が2連続になったりしていますが、スケジューラによって交互に実行されているはずです。
また、それぞれで別々に数値がカウントアップされていることから、各スレッド用のスタックもうまく機能していることが分かります。
キーボード割り込み
Writing an OS in Rust の「Async/Await」の章では、キーボード割り込みを Task(協調的マルチタスク)として実装していました。ただ、スケジューラが延々と実行されるようになってしまった今、Task の Executor を無限ループで回すのは困難です。
とりあえず、キーボード割り込みのためのタスクもカーネルスレッドとして実行しておきましょう。
src/main.rs
/// エントリポイント
fn kernel_main(boot_info: &'static BootInfo) -> ! {
...
// カーネルスレッド作成
thread::create_kernel_thread(kernel_thread_0);
thread::create_kernel_thread(kernel_thread_1);
thread::create_kernel_thread(keyboard_thread);
thread::scheduler::scheduler();
}
// キーボード割り込み用スレッド
fn keyboard_thread() -> ! {
let mut executor = Executor::new();
executor.spawn(Task::new(keyboard::print_keypresses()));
executor.run();
}
これでカーネル起動後に何らかのキーを押すと、それに応じて押されたキーが表示されるはずです。

ただ、非常に遅い…。もっと良い実装方法はないか模索していきたいと思います。
スケジューラをトレイトとして実装する
現在はラウンドロビンスケジューラしか実装していませんが、将来的には他のスケジューラも実装するかもしれません。必須ではありませんが、拡張しやすくするためにトレイトとしての実装に変更したいと思います。
まずは thread/scheduler/mod.rs を作成し、トレイトを定義しておきます。
use super::{ Thread, ThreadState, PROCESS_TABLE, NPROC };
use super::context;
use crate::cpu;
use lazy_static::lazy_static;
use conquer_once::spin::OnceCell;
use alloc::boxed::Box;
pub mod round_robin;
lazy_static! {
static ref CPU: spin::Mutex<cpu::Cpu> = spin::Mutex::new(cpu::Cpu::new(0));
}
pub static SCHEDULER: OnceCell<Box<dyn Scheduler + Send + Sync>> = OnceCell::uninit();
pub static mut SCHEDULER_STARTED: bool = false;
pub fn init(scheduler: Box<dyn Scheduler + Send + Sync>) {
SCHEDULER.init_once(|| scheduler);
}
pub trait Scheduler: Send + Sync {
fn scheduler(&self) -> !;
fn on_yield(&self);
}
fn get_scheduler() -> &'static dyn Scheduler {
SCHEDULER.get()
.expect("Scheduler not initialized")
.as_ref()
}
pub fn scheduler() -> ! {
get_scheduler().scheduler();
}
pub fn yield_from_context() {
get_scheduler().on_yield();
}
今回は、スケジューラは scheduler::init() で初期化します。このとき、どのスケジューラを使用するかは引数で渡します。使用するスケジューラは SCHEDULER 変数に保持しておき、いずれのスレッドからでも参照できるよう OnceCell<Box<..>> として宣言しています。OnceCell を使う制約としてScheduler トレイトは &mut self なメソッドを定義できなくなりますが、メンバ変数に RwLock や Mutex を使うことで内部可変性を持たせることができます。
トレイト Scheduler には Send と Sync を継承させています。Send はスケジューラを別の CPU コアでも実行できるようにするための所有権移動の安全性、Sync は複数の割り込みハンドラから同時にアクセスできるようにするための共有参照の安全性を保証するために使用しており、いずれも将来的なマルチコア対応のためのスレッド安全性に向けたものです。
schduler() はスケジューラ自体の実装、on_yield() はスケジューラにコンテキストを戻すときの処理を実装します。
続いてラウンドロビンスケジューラの本体実装。こちらは実装自体は変更していません。
thread/scheduler/round_robin.rs:
use super::{ Thread, ThreadState, PROCESS_TABLE, NPROC, CPU, SCHEDULER_STARTED };
use super::context::{ Context, switch_context };
pub struct RoundRobin;
impl super::Scheduler for RoundRobin {
/// スケジューラ
fn scheduler(&self) -> ! {
... // 以前の実装と同じ
}
/// スレッドからスケジューラに戻る
fn on_yield(&self) {
... // 以前の yield_from_context() の実装と同じ
}
}
fn find_next_runnable_thread(table: &[Thread; NPROC], current_tid: Option<usize>) -> Option<usize> {
// 以前の実装と同じ
}
find_next_runnable_thread() のみトレイトの実装から外れるので、private メソッドとして定義しました。
これで準備は整いました。スケジューラおよび yield を呼び出す側も修正していきます。
main.rs:
use ferrios::thread::scheduler;
/// エントリポイント
fn kernel_main(boot_info: &'static BootInfo) -> ! {
...
println!("Starting the scheduler..");
thread::scheduler::scheduler();
}
interrupts.rs:
/// タイマ割り込みハンドラ
extern "x86-interrupt" fn timer_interrupt_handler(_stack_frame: InterruptStackFrame) {
...
unsafe {
if crate::thread::scheduler::SCHEDULER_STARTED {
scheduler::yield_from_context();
...
}
}
}
これにて完了。無事、スケジューラが実行できていることも確認できました。
コンソールの改善(おまけ)
本題からは逸れますが、今後開発しやすくするために、コンソールの改善もここで行っておきます。
Writing an OS in Rust で実装したコンソールの出力先は VGA とシリアル出力の2つがあります。QEMU をグラフィックモードで起動した場合は VGA が表示されますが、--nogprahic で起動した場合はコンソール出力が表示されます。
現状、kernel_main() や各スレッドが表示するのに利用している print!()、println!() は VGA 出力です。QEMU を no-graphic モードで起動すると表示できません。
ということで、どちらからでも常時表示できるように、VGA とシリアル出力の両方に対応したコンソールを実装しました。このコンソールを利用すると、コンソール出力に未対応の環境では VGA のみ、コンソール対応環境の場合は VGA とコンソールの両方を同時に出力するようになります。
まずは console ディレクトリを作成し、そこに既に実装した vga_buffer.rs と serial.rs を移動します。さらに、mod.rs も作成しておきます。
src/console/
├── mod.rs
├── serial.rs
└── vga_buffer.rs
まずは mod.rs に Console 構造体を実装していきます。
console/mod.rs:
use core::fmt;
use spin::Mutex;
use lazy_static::lazy_static;
pub mod serial;
pub mod vga_buffer;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConsoleMode {
Serial,
Vga,
Both,
}
/// コンソール構造体
#[derive(Debug)]
pub struct Console {
mode: ConsoleMode,
}
impl Console {
const fn new() -> Self {
Console {
mode: ConsoleMode::Both,
}
}
pub fn set(&mut self, mode: ConsoleMode) {
self.mode = mode;
}
pub fn get(&self) -> ConsoleMode {
self.mode
}
fn is_serial_avaiable() -> bool {
use x86_64::instructions::port::Port;
unsafe {
// Line Status Register
let mut port = Port::<u8>::new(0x3FD);
let status = port.read();
// bit-5 と bit-6 が立っていればシリアルポートが存在する
(status & 0x60) != 0
}
}
pub fn update_mode(&mut self) {
self.mode = if Self::is_serial_avaiable() {
ConsoleMode::Both
}
else {
ConsoleMode::Vga
};
}
}
この Console 構造体は、コンソールのモード ConsoleMode を保持します。is_serial_avaiable() で現在の環境がシリアル出力に対応しているか確認し、update_mode() で現在の環境に適したモードを選択します。is_serial_avaiable() では Line Status Register を確認し、そのレジスタの5ビット目および6ビット目が立っているかどうかでシリアル出力の対応の有無を確認しています。
さらに、グローバル変数として現在の環境に適したコンソールモードを保持する CONSOLE 変数を用意しておきます。
console/mod.rs:
lazy_static! {
pub static ref CONSOLE: Mutex<Console> = Mutex::new(Console::new());
}
pub fn init() {
let mut console = CONSOLE.lock();
console.update_mode();
}
pub fn _print(args: fmt::Arguments) {
use x86_64::instructions::interrupts;
// ロック中の割り込みを防止
interrupts::without_interrupts(|| {
let console = CONSOLE.lock();
match console.mode {
ConsoleMode::Serial => {
serial::_print(args);
},
ConsoleMode::Vga => {
vga_buffer::_print(args);
},
ConsoleMode::Both => {
serial::_print(args);
vga_buffer::_print(args);
}
}
});
}
#[macro_export]
macro_rules! print {
($($arg:tt)*) => {
$crate::console::_print(format_args!($($arg)*))
};
}
#[macro_export]
macro_rules! println {
() => ($crate::console::_print("\n"));
($fmt:expr) => ($crate::print!(concat!($fmt, "\n")));
($fmt:expr, $($arg:tt)*) => ($crate::print!(concat!($fmt, "\n"), $($arg)*));
}
例によって lazy_static です。kernel_main() から呼び出される console::init() で update_mode() を呼び出して初期化します。また、この CONSOLE の状態に応じて、シリアルか VGA かあるいは両方の _print() を呼び出す _print() メソッドと、それを呼び出すためのマクロ print!()、println!() も用意しました。
シリアル側、VGA 側の print!()、println!() もマクロとして呼び出せるよう、それぞれ改名しておきます。
console/serial.rs:
/// シリアルポートに文字列を書き込む
#[macro_export]
macro_rules! serial_print {
($($arg:tt)*) => {
$crate::console::serial::_print(format_args!($($arg)*))
};
}
/// シリアルポートに文字列を書き込み、改行する
#[macro_export]
macro_rules! serial_println {
() => ($crate::serial_print!("\n"));
($fmt:expr) => ($crate::serial_print!(concat!($fmt, "\n")));
($fmt:expr, $($arg:tt)*) => ($crate::serial_print!(concat!($fmt, "\n"), $($arg)*));
}
console/vga_buffer.rs:
/// println!, print! マクロの実装
#[macro_export]
macro_rules! vga_print {
($($arg:tt)*) => ($crate::console::vga_buffer::_print(format_args!($($arg)*)));
}
#[macro_export]
macro_rules! vga_println {
() => ($crate::print!("\n"));
($($arg:tt)*) => ($crate::print!("{}\n", format_args!($($arg)*)));
}
タブ文字への対応
コンソール側は問題ないのですが、VGA 側でタブ文字(\t)がうまく表示できていなかったので、空白4文字に置き換えるよう修正しました。
console/vga_buffer.rs:
impl Writer {
pub fn write_byte(&mut self, byte: u8) {
match byte {
b'\n' => self.new_line(),
b'\t' => { // ここ
for _ in 0..4 {
self.write_byte(b' ');
}
}
byte => {
...
}
}
}
...
pub fn write_string(&mut self, s: &str) {
for byte in s.bytes() {
match byte {
// 出力可能な ASCII byte または改行コード
0x20..=0x7e | b'\n' | b'\t' => self.write_byte(byte), // ここも
// 出力不可な文字 -> 特定の文字に置き換え
_ => self.write_byte(0xfe),
}
}
}
}
動作確認
動作確認にあたり、kernel_main() を少し修正。現在のコンソールモードを表示するようにしました。また、見やすいようにタブ文字で各セクション(仮想メモリの動作確認とか)を表示するよう変更しています。
main.rs:
/// エントリポイント
fn kernel_main(boot_info: &'static BootInfo) -> ! {
...
println!("Welcome to FerriOS!");
println!("");
print!("Initializing..");
ferrios::init();
console::init();
println!("done.");
let console_mode = console::CONSOLE.lock().get();
println!("console-mode: {:?}", console_mode);
...
println!("Checking Virtual Memory..");
let phys_mem_offset = VirtAddr::new(boot_info.physical_memory_offset);
...
for &address in &addresses {
let virt = VirtAddr::new(address);
let phys = mapper.translate_addr(virt);
println!("\t{:?} -> {:?}", virt, phys);
}
println!("done.");
...
}
まずは QEMU no-graphic モードで実行した場合から。
Welcome to FerriOS!
Initializing..done.
console-mode: Both
Checking Virtual Memory..
VirtAddr(0xb8000) -> Some(PhysAddr(0xb8000))
VirtAddr(0x201008) -> Some(PhysAddr(0x401008))
VirtAddr(0x10000201a10) -> Some(PhysAddr(0x27fa10))
VirtAddr(0x18000000000) -> Some(PhysAddr(0x0))
done.
Initializing heap memory..
heap_value at 0x444444440000
vec at Pointer { addr: 0x444444440800, metadata: 500 }
current reference count is 2
reference count is 1 now
done.
Starting kernel threads..done.
Starting the scheduler..
Thread 1 running: 0
Thread 0 running: 0
Thread 0 running: 1
うまくいきました。QEMU no-graphic モードで実行した場合は Both モード、つまり VGA とシリアルの両方に出力されています。VGA には対応していないはずですが、現状、コンソール対応の場合は無条件に Both モードにしています(どうやって VGA 対応の有無を検知するかは迷い中…)。
続いて QEMU の graphic モードの場合。

こちらも Both モードになっていました。結局 graphic モードでも no-graphic モードでも Both モードになるという結果に。とりあえずどちらのモードでも表示できるようになりましたが、両方出力することによるオーバーヘッドは気になります。今後改善していきたい点です。
おわりに
自作 OS の第一歩として、まずはコンテキストとスケジューラを実装し、カーネルスレッドを動かせるようにしました。ついでにコンソールも VGA とシリアルポートの双方に出力できるようにしました。
次回はユーザモードに対応し、ユーザスレッドを作成できるようにしていきたいと思っています。