Rustで作るx86_64 自作OS入門シリーズ
Part1【環境構築】 | Part2【no_std】 | Part3【Hello World】| Part4【VGA】 | Part5【割り込み】 | Part6【ページング】 | Part7【ヒープ】 | Part8【マルチタスク】| Part9【ファイルシステム】 | Part10【ELFローダー】 | Part11【シェル】 | Part12【完結】
はじめに
Part7でヒープが動いて、VecやStringが使えるようになりました。
今回はマルチタスクを実装します。複数のタスクを切り替えながら実行する、OSの醍醐味ですね。
正直に言います。コンテキストスイッチで1週間溶けました。レジスタの退避・復元を間違えると即死するので、デバッグがものすごく大変...
目次
マルチタスクとは
1つのCPUで複数のタスクを「同時に」動かす仕組み。実際には高速に切り替えてるだけ。
時間 →
CPU: [タスクA][タスクB][タスクA][タスクC][タスクB]...
人間には同時に動いてるように見える。これがタイムシェアリング。
協調的 vs プリエンプティブ
| 方式 | 説明 | メリット | デメリット |
|---|---|---|---|
| 協調的 | タスクが自発的にCPUを譲る | 実装が簡単 | タスクが譲らないと止まる |
| プリエンプティブ | タイマー割り込みで強制切り替え | 公平 | 実装が複雑 |
今回は協調的マルチタスクをRustのasync/awaitで実装します。プリエンプティブは...またいつか。
async/awaitを使う理由
Rustのasync/awaitは協調的マルチタスクの完璧な抽象化です。
async fn task_a() {
loop {
println!("A");
yield_now().await; // ここでCPUを譲る
}
}
async fn task_b() {
loop {
println!("B");
yield_now().await;
}
}
コンパイラが状態機械に変換してくれるので、手動でコンテキストを保存する必要がない!
Executorを実装する
async関数を実行するにはExecutorが必要です。
// src/task/mod.rs
use alloc::boxed::Box;
use core::{
future::Future,
pin::Pin,
task::{Context, Poll},
};
pub struct Task {
future: Pin<Box<dyn Future<Output = ()>>>,
}
impl Task {
pub fn new(future: impl Future<Output = ()> + 'static) -> Task {
Task {
future: Box::pin(future),
}
}
fn poll(&mut self, context: &mut Context) -> Poll<()> {
self.future.as_mut().poll(context)
}
}
シンプルなExecutor
// src/task/simple_executor.rs
use super::Task;
use alloc::collections::VecDeque;
use core::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
pub struct SimpleExecutor {
task_queue: VecDeque<Task>,
}
impl SimpleExecutor {
pub fn new() -> SimpleExecutor {
SimpleExecutor {
task_queue: VecDeque::new(),
}
}
pub fn spawn(&mut self, task: Task) {
self.task_queue.push_back(task);
}
pub fn run(&mut self) {
while let Some(mut task) = self.task_queue.pop_front() {
let waker = dummy_waker();
let mut context = Context::from_waker(&waker);
match task.poll(&mut context) {
Poll::Ready(()) => {} // タスク完了
Poll::Pending => self.task_queue.push_back(task),
}
}
}
}
ダミーWaker
Wakerは「タスクを起こす」ための仕組み。今回は何もしないダミー実装:
fn dummy_raw_waker() -> RawWaker {
fn no_op(_: *const ()) {}
fn clone(_: *const ()) -> RawWaker {
dummy_raw_waker()
}
let vtable = &RawWakerVTable::new(clone, no_op, no_op, no_op);
RawWaker::new(0 as *const (), vtable)
}
fn dummy_waker() -> Waker {
unsafe { Waker::from_raw(dummy_raw_waker()) }
}
キーボード入力を非同期化
割り込みで受け取ったキー入力を、非同期で処理できるようにします。
// src/task/keyboard.rs
use conquer_once::spin::OnceCell;
use crossbeam_queue::ArrayQueue;
use core::{pin::Pin, task::{Context, Poll}};
use futures_util::{stream::Stream, task::AtomicWaker, StreamExt};
use pc_keyboard::{layouts, DecodedKey, HandleControl, Keyboard, ScancodeSet1};
static SCANCODE_QUEUE: OnceCell<ArrayQueue<u8>> = OnceCell::uninit();
static WAKER: AtomicWaker = AtomicWaker::new();
pub struct ScancodeStream {
_private: (),
}
impl ScancodeStream {
pub fn new() -> Self {
SCANCODE_QUEUE.try_init_once(|| ArrayQueue::new(100))
.expect("ScancodeStream::new should only be called once");
ScancodeStream { _private: () }
}
}
impl Stream for ScancodeStream {
type Item = u8;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Option<u8>> {
let queue = SCANCODE_QUEUE.try_get().expect("not initialized");
if let Some(scancode) = queue.pop() {
return Poll::Ready(Some(scancode));
}
WAKER.register(&cx.waker());
match queue.pop() {
Some(scancode) => {
WAKER.take();
Poll::Ready(Some(scancode))
}
None => Poll::Pending,
}
}
}
割り込みハンドラを修正
// src/interrupts.rs
pub(crate) fn add_scancode(scancode: u8) {
if let Some(queue) = SCANCODE_QUEUE.try_get() {
if queue.push(scancode).is_err() {
println!("WARNING: scancode queue full; dropping keyboard input");
} else {
WAKER.wake(); // タスクを起こす!
}
}
}
extern "x86-interrupt" fn keyboard_interrupt_handler(_stack_frame: InterruptStackFrame) {
use x86_64::instructions::port::Port;
let mut port = Port::new(0x60);
let scancode: u8 = unsafe { port.read() };
crate::task::keyboard::add_scancode(scancode); // キューに追加
unsafe {
PICS.lock().notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
}
}
非同期キーボードタスク
pub async fn print_keypresses() {
let mut scancodes = ScancodeStream::new();
let mut keyboard = Keyboard::new(
ScancodeSet1::new(),
layouts::Us104Key,
HandleControl::Ignore,
);
while let Some(scancode) = scancodes.next().await {
if let Ok(Some(key_event)) = keyboard.add_byte(scancode) {
if let Some(key) = keyboard.process_keyevent(key_event) {
match key {
DecodedKey::Unicode(character) => print!("{}", character),
DecodedKey::RawKey(key) => print!("{:?}", key),
}
}
}
}
}
while let Some(scancode) = scancodes.next().awaitがキモ。キー入力があるまでawaitで待機します。
追加の依存クレート
# Cargo.toml
[dependencies]
conquer-once = { version = "0.4", default-features = false }
crossbeam-queue = { version = "0.3", default-features = false, features = ["alloc"] }
futures-util = { version = "0.3", default-features = false, features = ["alloc"] }
main.rsの更新
mod task;
fn kernel_main(boot_info: &'static BootInfo) -> ! {
// ... 前回までの初期化 ...
println!("Starting async executor...");
let mut executor = task::simple_executor::SimpleExecutor::new();
executor.spawn(Task::new(example_task()));
executor.spawn(Task::new(task::keyboard::print_keypresses()));
executor.run();
println!("Tasks finished!");
loop { x86_64::instructions::hlt(); }
}
async fn example_task() {
let number = async_number().await;
println!("async number: {}", number);
}
async fn async_number() -> u32 {
42
}
動作確認
cargo bootimage
Compiling conquer-once v0.4.0
Compiling crossbeam-queue v0.3.12
Compiling futures-util v0.3.31
Compiling my-os v0.1.0 (C:\Users\Aqua\Documents\Qiita\my-os)
Finished `dev` profile [optimized + debuginfo] target(s) in 12.43s
Created bootimage for `my-os` at `target\x86_64-my_os\debug\bootimage-my-os.bin`
qemu-system-x86_64 -drive format=raw,file=target\x86_64-my_os\debug\bootimage-my-os.bin
Starting async executor...
async number: 42
キーボードを押すと、非同期で文字が表示される!
改良版Executor
SimpleExecutorはビジーループで効率が悪い。改良版:
// src/task/executor.rs
use super::{Task, TaskId};
use alloc::{collections::BTreeMap, sync::Arc, task::Wake};
use core::task::{Context, Poll, Waker};
use crossbeam_queue::ArrayQueue;
pub struct Executor {
tasks: BTreeMap<TaskId, Task>,
task_queue: Arc<ArrayQueue<TaskId>>,
waker_cache: BTreeMap<TaskId, Waker>,
}
impl Executor {
pub fn new() -> Self {
Executor {
tasks: BTreeMap::new(),
task_queue: Arc::new(ArrayQueue::new(100)),
waker_cache: BTreeMap::new(),
}
}
pub fn spawn(&mut self, task: Task) {
let task_id = task.id;
if self.tasks.insert(task.id, task).is_some() {
panic!("task with same ID already in tasks");
}
self.task_queue.push(task_id).expect("queue full");
}
pub fn run(&mut self) -> ! {
loop {
self.run_ready_tasks();
self.sleep_if_idle();
}
}
fn sleep_if_idle(&self) {
use x86_64::instructions::interrupts;
interrupts::disable();
if self.task_queue.is_empty() {
interrupts::enable_and_hlt();
} else {
interrupts::enable();
}
}
fn run_ready_tasks(&mut self) {
while let Some(task_id) = self.task_queue.pop() {
let task = match self.tasks.get_mut(&task_id) {
Some(task) => task,
None => continue,
};
let waker = self.waker_cache
.entry(task_id)
.or_insert_with(|| TaskWaker::new(task_id, self.task_queue.clone()));
let mut context = Context::from_waker(waker);
match task.poll(&mut context) {
Poll::Ready(()) => {
self.tasks.remove(&task_id);
self.waker_cache.remove(&task_id);
}
Poll::Pending => {}
}
}
}
}
sleep_if_idleで、やることがないときはhltで省電力モードに入ります。
コンテキストスイッチの闘い(おまけ)
最初は手動でコンテキストスイッチを実装しようとしました...
#[repr(C)]
struct TaskContext {
rax: u64, rbx: u64, rcx: u64, rdx: u64,
rsi: u64, rdi: u64, rbp: u64, rsp: u64,
r8: u64, r9: u64, r10: u64, r11: u64,
r12: u64, r13: u64, r14: u64, r15: u64,
rip: u64, rflags: u64,
}
これをアセンブリで退避・復元するコードを書いて...
1日目: レジスタの順番間違えた → 即死
2日目: rspを間違えた → スタック破壊
3日目: rflags忘れた → 謎の挙動
4日目: 割り込み中に切り替え → 二重障害
5日目: やっと動いた!
6日目: 別のタスクで壊れた
7日目: async/awaitに逃げた ← いまここ
async/await最高です。コンパイラが全部やってくれる。
まとめ
Part8では以下を達成しました:
- 協調的マルチタスクの概念
- Executorの実装
- async/awaitの活用
- 非同期キーボード入力
- 省電力対応(hlt)
手動コンテキストスイッチで1週間溶かしたけど、最終的にasync/awaitに落ち着きました。Rustの非同期ランタイムを自作できたのは良い経験。
次回(Part9)はファイルシステムを実装して、データの永続化に挑戦します!
この記事が役に立ったら、いいね・ストックしてもらえると嬉しいです!