Closed21

Rustで自作OS - ページング、メモリ管理、ハードウェア割り込み、APIC

yubrotyubrot

これまで

https://zenn.dev/yubrot/scraps/9735639c0c982d

リポジトリ

https://github.com/yubrot/ors

8章 メモリ管理

placement newがRustにはないので、カーネル自身が動的なアロケーションについてカーネルの制御を利用できるかどうかはわからないが、代替手段になるかもしれないメモリ管理を先に進めてみる。

OS起動直後のメインメモリの未使用領域についての情報は、ブートサービス停止時に返されるメモリマップの情報がまさにそれである。これを kernel_main に渡す必要がある。 FrameBuffer と同じようにmainの引数に加えて渡す。

UEFIのメモリタイプ的に自由に使用可能でも、そのうち一部の領域にはUEFIの実行の流れから既に使用されており (使用可能領域上にUEFIによって準備されたデータが存在する)、これをカーネルの制御下に移動しておかないと、データが上書きされたときにCPUが誤動作する原因となる。そのようなデータはx64では以下の3つ:

  • スタック領域: UEFIが準備したスタック領域が使用されている
  • Global Descriptor Table: x86 セグメンテーションの設定を集めたもの。x64ではセグメンテーションの設定はもはや無効だが最低限の設定は必要
  • ページテーブル: 19章のページングで解説・使用される

スタック領域

kernel_main を呼び出した後UEFIのmain側に制御は戻らないので、単に静的領域を準備して rsp レジスタに設定すればよい。万一に制御が戻らないように kernel_main2 (元の kernel_main をリネームしたもの) から呼び出しが返ってきたらループしておく。今回はスタック領域もアセンブリのbssセクションに書いてみた。

extern kernel_main2

section .bss align=16
kernel_main_stack:
  resb 1024 * 1024

section .text
global kernel_main
kernel_main:
  mov rsp, kernel_main_stack + 1024 * 1024
  call kernel_main2
.fin:
  hlt
  jmp .fin

Rustでは、 build.rs でシンプルにコンパイルしてリンク対象に含めるのが良いだろう。

build.rs
fn main() {
    let out_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap());
    println!("cargo:rustc-link-search={}", out_dir.display());

    // asm.s -> asm.o -> libasm.a
    let out_asm = {
        let mut path = out_dir.clone();
        path.push("asm.o");
        path
    };
    Command::new("nasm")
        .args(&["-f", "elf64", "-o", out_asm.to_str().unwrap()])
        .arg("asm.s")
        .status()
        .unwrap();
    Command::new("ar")
        .args(&["crus", "libasm.a", "asm.o"])
        .current_dir(&out_dir)
        .status()
        .unwrap();
    println!("cargo:rustc-link-lib=static=asm");
}

GDT

GDTの設定は本書通り行う。ビットフィールドにはmodular-bitfieldが良さそうだったので使った。こんな感じに書ける:

#[bitfield(bits = 64)]
#[derive(Debug, Clone, Copy)]
pub struct SegmentDescriptor {
    limit_low: B16,
    base_low: B16,
    base_middle: B8,
    ty: B4,
    s: B1,
    dpl: B2,
    present: B1,
    limit_high: B4,
    available: B1,
    long_mode: B1,
    default_operation_size: B1,
    granularity: B1,
    base_high: B8,
}

セグメントレジスタ

GDTもそうだがこの辺はx86からの歴史的な事情が多い。セグメントレジスタは使用されず、ページングが用いられるが、一部値は使われる。

  • DS, ESレジスタは使用されない。
  • FS, GSレジスタはTIB (Thread Information Block)やTLS (Thread Local Storage)を指すのに使われたりする。本書では使われない。

    (以上とは異なり、インテルの先導により策定されたアーキテクチャではないが)x64では、Microsoft Windows の x64 版は、GSセグメントレジスタがスレッド局所記憶へのポインタを指すようになっている。LinuxカーネルではGSがCPU単位のデータを指している。

  • SSレジスタも全く使用されないが、syscall命令との互換性を考えて適当なデータセグメントを設定しておく? 本書で後述される。
  • CSレジスタ: limitとbaseは無視されるが、CSの指すディスクリプタの設定内容に基づいてアクセス権限の検査が行われる。

CSレジスタは mov で設定できない。 far jumpによる設定方法もあるが、本書ではfar return命令 (retf) を用いている。OSDev WikiのGDT Tutorialも参考。セグメントレジスタの更新によってGDTが読み込まれ、カーネルが用意したGDTによって動作するようになる。

yubrotyubrot

ページング

ページングはリニアアドレス (linear address) を物理アドレス (physical address) に変換する。ソフトウェアの指定するアドレスはリニアアドレスであり、ページテーブルを通して物理アドレスに変換してからメモリの読み書きが発生する[1]

ページングは19章で詳しく扱うが、ここではアイデンティティマッピングを設定する。文字通り、リニアアドレスと物理アドレスをそのまま対応させるマッピングとなる。この辺はRustでもひたすらやるという感じ。

脚注
  1. 正確には論理アドレスがセグメンテーションによってリニアアドレスとなるがx64ではこの変換は恒等である ↩︎

yubrotyubrot

メモリ管理

メモリ管理は、ここまでお膳立てが済んだので粛々と行うという感じ。物理アドレス領域をページフレーム単位(例では4KiB)で区切り、アロケート済みかどうかのフラグをビットマップフラグの配列で持つ。 efi_main から与えられたメモリマップ情報をもとに、利用可能でない領域を事前にアロケート済みとマークする。
本書でのアロケート領域の探索の戦略は非常に単純で、先頭から目的のページフレーム数空いている領域を見つけてアロケートするというもの (first fit) になっている。

yubrotyubrot

5章 文字を書いてみる

Rustの場合、フォントファイルは include_bytes! でコンパイル時に埋め込むことができる。

static ASCII_FONT: &[u8; 4096] = include_bytes!("ascii.bin");

sprintf 相当は core::fmt::Writeの実装を与えれば利用できる。5章後半の Console 型がこのトレイトを実装するようにした。

printklogクレートの実装を与える形で。placement newが無いので PixelWriter 相当の実装をグローバルに持つのが少々厄介...

unsafe fn prepare_buffer(fb: FrameBuffer) -> &'static dyn Buffer {
    // 本来は
    // mem::size_of::<RgbFrameBuffer>().max(mem::size_of::<BgrFrameBuffer>())
    // としたい
    static_assertions::assert_eq_size!(RgbFrameBuffer, BgrFrameBuffer);
    const PAYLOAD_SIZE: usize = mem::size_of::<RgbFrameBuffer>();
    static mut PAYLOAD: [u8; PAYLOAD_SIZE] = [0; PAYLOAD_SIZE];
    match fb.format {
        PixelFormat::Rgb => {
            let p = &mut PAYLOAD[0] as *mut u8 as *mut RgbFrameBuffer;
            ptr::write(p, RgbFrameBuffer(fb));
            &mut *p
        }
        PixelFormat::Bgr => {
            let p = &mut PAYLOAD[0] as *mut u8 as *mut BgrFrameBuffer;
            ptr::write(p, BgrFrameBuffer(fb));
            &mut *p
        }
    }
}

これで得られる &'static dyn Buffer をグローバルな static mut に保持してしまう。グローバル変数に頼っているところは、いずれ (マルチコア対応とかが入るあたり?) は適切な形に置き換わるかと思う。

pub fn initialize() {
    log::set_logger(&KernelLogger).unwrap();
    log::set_max_level(log::LevelFilter::Info);
}

struct KernelLogger;

impl log::Log for KernelLogger {
    fn enabled(&self, metadata: &log::Metadata) -> bool {
        true
    }

    fn log(&self, record: &log::Record) {
        writeln!(
            unsafe { global::CONSOLE.on(global::BUFFER, 0, 0, Color::WHITE, Color::BLACK) },
            "{}: {}",
            record.level(),
            record.args()
        )
        .unwrap();
    }

    fn flush(&self) {}
}

この段階で、最低限 [panic_handler] も情報を出力できるようになる。logクレートの error! を用いているが、コンソール出力を使えば何でも良いだろう。

#[panic_handler]
fn panic(info: &core::panic::PanicInfo) -> ! {
    error!("{}", info);

    loop {
        hlt!()
    }
}
yubrotyubrot

6章 マウス入力とPCI

PCIデバイスの検索

push して長さを保持しているが、ヒープアロケーションを伴わないデータ構造にheaplessクレートを採用した。他にRust特有っぽい話はない。
ただ、I/O空間の読み書きのような簡単な命令であればインラインアセンブリが使える。

pub fn io_in(addr: u16) -> u32 {
    let ret: u32;
    unsafe { asm!("in eax, dx", out("eax") ret, in("dx") addr) };
    ret
}

pub fn io_out(addr: u16, data: u32) {
    unsafe { asm!("out dx, eax", in("dx") addr, in("eax") data) };
}
yubrotyubrot

USBホストドライバ

非常にしんどい。PCIコンフィグレーション空間からxHCIのMemory-mapped I/Oのベースアドレスは取得できたが、ここからの処理が本書では著者謹製のUSBホストドライバを使用している。この実装は数千行あり、移植するのはかなり大変 (多少試みてみたが) で、かつC++なのでリンクしてC APIを通して使用するのも面倒だ。

移植が困難な理由に、USBのホストドライバ実装に比較的興味がないということもあり、PS/2のハンドリングが簡単であればそちらでお茶を濁そうかなと考えている。ただ、これを言ってしまうとウィンドウなどのグラフィカルな処理にも興味が無いので、本書の流れをトレースすることから大きく外れてきそうだなあと思われる。

とりあえず、一旦Writing an OS in Rustではどうやっているかなあと見てみることにする。Legacy BIOSで起動している! UEFIを検討しているissueを見ると(チュートリアル観点での)Legacy BIOSとUEFI BIOSの手法の差異が見られて少し面白い。

  • Writing an OS in RustのほうではVGAテキストモードを使用しているが、これもUEFIではサポートされていないらしい。対してUEFIでは本書で出てきたGOP (Graphics Output Protocol) があり、これはLinuxにおいてはGPUドライバが存在しない環境で使われているとのこと。
  • Legacy PICはUEFIではサポートされないが、APICはacpiクレートなどの助けによって使うのが非常に困難ということはない。
  • グラフィックス自体興味無いのでシリアルポートを使うというのも択に入るよなあ...
yubrotyubrot

Writing an OS in Rustを参考にいくつか改善を施す。

  • cargo run でQEMUが立ち上がるようにする。 .cargo/config.toml にtargetで以下のような記述を加えることが出来るのを知った:
    .cargo/config.toml
    [target.'cfg(target_os = "none")']
    runner = ['../qemu/make_and_run.sh', '../target/x86_64-unknown-uefi/debug/ors-loader.efi']
    
  • x86_64クレートを用いてインラインアセンブリの記述やアセンブリによる実装を減らした。
    • 後の割り込み対応では、合わせてGDT周りもx86_64クレートの提供する高レベルなAPIを使用する形にした。
  • シリアルポートにログを出力するように。uart_16550クレートのおかげでかなり簡単。
  • cargo test でテストが実行できるように。色々と知らない属性やfeatureフラグを言われたままに加えていくと cargo test が普通にQEMU上で動くようになる。

この段階でWriting an OS in RustのBare Bonesの章が終わった相当になる。このままInterruptsの章を進めてからOS自作入門の6章以降を拾っていく形にしようかなと考えている。あるいはWriting an OS in RustのMemory Managementの章まで進めてしまっても良いかもしれない。

yubrotyubrot

CPU例外

Exceptions - OSDev Wiki

CPUによるエラー。割り込みの一種 (としてx86系では実装されている)。CPUは、ページフォルト、アクセス違反、無効な命令コード等に遭遇すると、割り込みとして例外処理に入る。
x86_64において割り込みは、事前に登録しておいた割り込みハンドラが呼び出されることで処理される。割り込みハンドラの登録はIDT (Interrupt Descriptor Table) にディスクリプタエントリ群を構築して lidt 命令でロードすることで行う (構図はセグメンテーションの lgdt と似ている)。

このあたり色々とCPUの面倒をみる作業が多いのだが、x86_64クレートとRustの feature(abi_x86_interrupt) のサポートはこの大部分を抽象化してくれている。

  • IDTの構築はお馴染み仕様によって定められたデータ構造の構築が必要だが、x86_64クレートはこれをうまく型付けして抽象化した形で提供している。具体的には、staticな InterruptDescriptorTablenew() して、各エントリにハンドラ関数を set_handler_fn() して IDT.load() するだけ。
  • 例外は特殊な呼出規約を必要とするが、これらは全て "x86-interrupt"呼出し規約とx86_64クレートによって満たされるようになっている。
    • 割り込みは(call, retのような)呼出の境界に関係なく発生するため、通常の呼出規約において保持されていればいいレジスタ群と異なり全てのレジスタが保持される必要がある。割り込みハンドラとなる関数は使用するレジスタ全てについて事前にpushするなどして保持しておく。
    • 割り込みハンドラへの制御の移動においては、スタックポインタやCPUの特定のフラグが書き換わり得るので、それに備えて割り込みは(call が単にリターンアドレスをpushするのと比較して)追加の情報 (SS, RSP, RFLAGS, CS, RIP, エラー情報 (割り込みの種類によっては)) をpushする。そのため割り込みハンドラから制御を戻すときは (単に ret ではなく) iret を用いる必要がある。
    • エントリが割り込みゲートの場合は、RFLAGSをpushした後に割り込みフラグをクリアする (マスク可能割り込みのネストを防ぐ)
    • エラー情報が積まれる割り込みの場合は、 iret 呼び出し前にそのエラー情報をpopしておく必要がある。

割り込みディスクリプタテーブルは、構造としては256エントリのベクトルで、0から31番目まではCPUによって予約された割り込みに、それ以降はユーザ定義の割り込みに使用される。

yubrotyubrot

ダブルフォルト

ダブルフォルト (Double fault)の処理。x86_64では、何らかの割り込みが発生したが、割り込みハンドラが設定されておらず、それによって一般保護例外 (General protection fault)が発生した場合など、特定の割り込みが割り込み処理中に発生した場合はダブルフォルト例外となる。ダブルフォルトの処理に失敗するとトリプルフォルトが発生し、これは通常ハンドルできず、システムリセットを起こしてしまう。

ダブルフォルトは他の割り込み同様に割り込みハンドラによって処理することができる一方、ダブルフォルトが起きうる原因にはカーネルのスタックオーバーフローのような場合も考えられるため、専用のスタック領域を設けて設定する必要がある。そのようにしなければ、カーネルのスタックオーバーフロー時には、割り込み時のCPUによる例外スタックフレームのpushによって即座にページフォルトしてしまう。

x86_64では色々歴史的なものがあって割り込みスタックテーブル(IST, Interrupt Stack Table)をTSS(Task State Segment)という構造上に設定する形になっている。TSSはGDTから読み込まれる。...このあたりは歴史的にこうなってるという解釈しかしていないので細かいところ自信がない。

TSSは名前の通りタスクの状態を保持するための構造で、つまりコンテキストスイッチにハードウェアのサポートがあるわけだが、現在の主要なOSではコンテキストスイッチはソフトウェアレベルで行っているらしく、例えばLinuxではTSSはCPU毎に1つだけ用意して全てのコンテキストで共有されている。

segmentation.rs
static mut GDT: x64::GlobalDescriptorTable = x64::GlobalDescriptorTable::new();
static mut TSS: x64::TaskStateSegment = x64::TaskStateSegment::new();

pub const DOUBLE_FAULT_IST_INDEX: u16 = 0;

pub unsafe fn initialize() {
    TSS.interrupt_stack_table[DOUBLE_FAULT_IST_INDEX as usize] = {
        const STACK_SIZE: usize = 4096 * 5;
        static mut STACK: [u8; STACK_SIZE] = [0; STACK_SIZE];
        let stack_start = x64::VirtAddr::from_ptr(&STACK[0]);
        let stack_end = stack_start + STACK_SIZE;
        stack_end
    };
    let code_selector = GDT.add_entry(x64::Descriptor::kernel_code_segment());
    let data_selector = GDT.add_entry(x64::Descriptor::kernel_data_segment());
    let tss_selector = GDT.add_entry(x64::Descriptor::tss_segment(&TSS));
    GDT.load();
    x64::CS::set_reg(code_selector);
    x64::SS::set_reg(data_selector);
    x64::load_tss(tss_selector);
}

x86_64クレートがGDT, TSS, セグメントレジスタ群に良い抽象を与えてくれているが、これらの操作自体「そうなってる」といった感じになってしまう。ともかく、このようにするとインデックス DOUBLE_FAULT_IST_INDEX がダブルフォルト用に静的に準備した割り込みスタック領域を指すようになるので、IDT側にこれを設定するとスタックが切り替わるようになる。内部的には double_fault はベクトルの8番目。

interrupts.rs
static mut IDT: x64::InterruptDescriptorTable = x64::InterruptDescriptorTable::new();

pub unsafe fn initialize() {
    IDT.breakpoint.set_handler_fn(breakpoint_handler);
    IDT.double_fault
        .set_handler_fn(double_fault_handler)
        .set_stack_index(DOUBLE_FAULT_IST_INDEX);
    IDT.load();
}

実際に試してみる。カーネル上でスタックオーバーフローによるページフォルトを起こそうとすると、ゼロからのOS自作入門の本の流れではWriting an OS in Rustと異なり既にページテーブルのIdentity Mappingを設定しているため、ガードページが設定されておらず、スタックが伸び続けてダブルフォルトの割り込みハンドラの本体まで破壊してしまうのか、なんにせよ結果としてシステムリセットしてしまいうまくいかなかった。代わりに適当なアドレスのロードを試みることでダブルフォルトを確認できた。

info!("{}", unsafe { *(0xfffffffffffu64 as *const u8) });

筆者の環境では、空の double_fault_handler で試した場合、 RIP=0x10a4b9, RSP=0x530018 を指して停止した。objdumpした ors-kernel.elf のシンボルテーブルを見ると、 RSP=0x530018 は正しくダブルフォルト用に用意したスタック領域中の場所を指していることがわかった。 RSPをdumpしてみると、

(qemu) x /9xg 0x530018
0000000000530018: 0x00000fffffffffff 0x00000fffffffffff
0000000000530028: 0x00000fffffffffff 0x0000000000000000
0000000000530038: 0x0000000000108230 0x0000000000000008
0000000000530048: 0x0000000000000002 0x0000000000672d20
0000000000530058: 0x0000000000000010

先頭8x4バイトは、 double_fault_handler が退避(push)したレジスタの値3つ (objdumpしたdouble_fault_handlerのアセンブリから確認できる)、割り込みハンドラに渡されたエラーコードだと思われる。そこから例外スタックフレーム RIP=0x108230, CS=0x8, RFLAGS=..., RSP=0x672d20, SS=0x10 が確認できる。

yubrotyubrot

Memory Management (blog_os)

ハードウェア割り込みには諸々の処理にアロケーションを伴いそうなので、Writing an OS in Rustのメモリ管理の章を進める。

Introduction to Paging

ページングの解説。Mikan本の流れで既にアイデンティティマッピングを構成しているが、これがどのような設定 (ページテーブル) によってどのように扱われるかが解説されている。

  • x64ではデフォルトで4階層のページテーブルを用いる
  • 各ページのサイズは4KiB (= 2^12)
  • 各ページテーブルは512エントリ (= 2^9) (各エントリは8バイトなのでページテーブル全体が1ページに収まる)
  • 仮想アドレス (仮想メモリ空間上の領域を指すリニアアドレス) は 48bit (= 12 + 9*4, 256TiB) で表現され、64bit数値としては下位48bitがアドレスの値、上位16bitは47bit目のコピー (sign extendedな形) である必要がある
  • 仮想アドレスの上位bitから順に、Level 4 index (9bit), Level 3 index (9bit), Level 2 index (9bit), Level 1 index (9bit), Offset (12bit) として解釈され、この順にページテーブルを辿る
  • 物理アドレスは52bit長で、ページテーブルは各エントリの12-51bitにページフレームの物理アドレスの12-51bit目を持つ
    • ページフレームの物理アドレスは常にpage alignedなので、0-11bitは0であり、これらビットは (63bit目と合わせて) ユーザーモードからのアクセス性やwritableなどのフラグとして用いられている
  • ページテーブルの変換は当然高コストなので、CPUは内部的にキャッシュを持つ。これをTLB(Translation Lookaside Buffer) という
    • 通常のCPUキャッシュと異なりTLBは完全に透明ではなく、ページテーブルの更新時はTLBの明示的な無効化が必要になる
    • これを行う命令がinvlpg(invalidate page)で、指定されたページの変換をTLBから取り除く。CR3レジスタの更新でも良い
yubrotyubrot

Paging Implementation

ページテーブルをどう構成するかの案色々。

再帰ページテーブルが少し面白くて、ページテーブルの適当なインデックスから自分自身を指すようにページテーブルを構成することで、ページテーブル自身へのマッピングが出来る。

Writing an OS in Rustで用いているbootloaderクレート自身がページテーブルの構成をいくつかサポートしていて、またx86_64クレートもMapperトレイトといくつかのページテーブル構成用のMapper実装をサポートしており、それを利用して

  • ページテーブルをソフトウェアレベルで辿っての仮想アドレスから物理アドレスへの変換
    • Mapperトレイトは Page<S> -> PhysFrame<S> を、Translateトレイトは VirtAddr -> PhysAddr を提供している
    • Page<S>S: PageSize のページの仮想アドレス (VirtAddr)
    • PhysFrame<S>S: PageSize のページフレームの物理アドレス (PhysAddr)
  • map_to: ページテーブルへの新しいマッピングの作成 (引数の Page<S> から引数の PhysFrame<S> へのマップを作成する)
    • 中間のページテーブルのためのページフレームの確保のため &mut impl FrameAllocator<Size4KiB> を引数に取る
    • 成功時の結果型 MapperFlush<S> の値からTLBをflushできる

FrameAllocatorトレイトはメソッド fn allocate_frame(&mut self) -> Option<PhysFrame<S>> の実装を要求する。このシグネチャはMikanOS側のメモリ管理の章で実装したメソッド pub fn allocate(&mut self, num_frames: usize) -> Result<FrameId, AllocateError> と似ていて、そのまま実装できそうだ。
次は、MikanOS本にもページングの章があるので一度そちらを確認してみる。

yubrotyubrot

19章 ページング (MikanOS)

アドレス変換とx64での階層ページングの解説。これはWriting an OS in Rustで解説されているものと同様。MikanOSでのページングの改良の動機は以下の通り:

  • MikanOSではPIE (位置独立実行可能コード) は扱わない
  • MikanOSではカーネルを低位アドレス側 (0x0000000000000000..0x00007fffffffffff) で、アプリケーションを高位アドレス側 (0xffff800000000000..0xffffffffffffffff) で動かす
  • 8章で設定したアイデンティティマッピングでは先頭64GiBの仮想アドレス空間のみマップしていたので、高位アドレス側で動作するようにリンクしたアプリケーション(ELF)をロードしようとするとページフォルトしてしまう

MikanOSではアプリケーションのロード時にあわせてページマップの確保とページの割り当てを行うようだ (アプリケーションの実行については今後の実装になる)。階層構造のページテーブルの変更は再帰的な関数によって行われる。x86_64クレートのmapped_page_table.rsにも似たような実装があるが、MikanOSでは前述のとおりカーネルを低位アドレスで動かしているのを含めて物理メモリがアイデンティティマッピングされている状態なので、ページフレームの物理アドレスをそのまま仮想アドレスとみなして、ポインタにキャストして使用することができる。

ざっと読んだところ、Writing an OS in Rustと衝突することは無さそうなので、このままWriting an OS in RustのMemory Managementを読み進めることにする。x86_64クレートが高レベルの抽象も提供しているが、MikanOS側に寄せていくつかは手動で実装していく。

yubrotyubrot

Heap Allocation, Allocator Designs (blog_os)

既にMikanOSに沿って実装してある memory_manager::BitmapMemoryManager を、仮想メモリと物理メモリを意識して phys_memory::BitmapFrameManager にリネームしておく。

Rustの global_allocator に適合するアロケータを実装することで、カーネル上でもallocクレートによって提供される動的なアロケーションを伴うコレクションを利用できるようになる。

MikanOSではアイデンティティマッピングを行うので、カーネルのアドレス空間の仮想アドレスと物理アドレスの変換は容易に行える (恒等変換)。しかし、メモリ確保要求のたびフレーム単位で毎回アロケートし、それを返すようにすると、わずか8バイトのアロケーションにも4KiBのフレームが割り当てられて無駄なので、Writing an OS in Rustの実装に倣って確保したフレームを分割して返す実装を行った。アロケータの実装では、確保したフレーム上の未使用領域自体をアロケートのための情報をストアする領域として使用できるのが少し面白いところ。

yubrotyubrot

Hardware Interrupts

今回、レガシーな8259 PICは使用したくなかった[1]ので、xv6の実装を参考にAPICのサポートを試みる。一方、上述のようにUSBのサポートは大変なので、レガシーながらPS/2 キーボードからの割り込み要求を処理することを考える。

APIC関連の制御のためのシステムの情報を取得する

APIC関連のレジスタはメモリマップドI/Oでアクセスできるが、このアドレスは完全な固定値というわけではなく、マルチコア等のマシン依存の情報などを含めてまず情報を取得する必要がありそうだった。

xv6はLegacy BIOSで起動するためMP Floating Pointer Structure? というものを特定のアドレスから探して読んでいるが、今回はUEFIで起動するため、ACPIという標準で定義されているRSDP (Root System Description Pointer)からシステムの構成情報を読む (参考1)。

まずRSDPを得る。RSDPはUEFIのConfiguration Tableから得ることができる:

fn get_rsdp(st: &SystemTable<Boot>) -> u64 {
    st.config_table()
        .iter()
        .find(|config| config.guid == uefi::table::cfg::ACPI_GUID)
        .map(|config| config.address as u64)
        .expect("Could not find RSDP")
}

これをカーネルに渡して、カーネル側でそれをパースする。パースにはacpiクレートが利用できる。このスクラップの上の方で

ハードウェア割り込みには諸々の処理にアロケーションを伴いそうなので

と書いたのはこのクレートのためだが、自前でパースすればアロケーションレスにするにも特に不都合は無さそうだった。

#[derive(Clone, Debug)]
pub struct KernelAcpiHandler;

impl AcpiHandler for KernelAcpiHandler {
    unsafe fn map_physical_region<T>(&self, addr: usize, size: usize) -> PhysicalMapping<Self, T> {
        let ptr = as_virt_addr(x64::PhysAddr::new(addr as u64))
            .unwrap()
            .as_mut_ptr();
        PhysicalMapping::new(addr, NonNull::new(ptr).unwrap(), size, size, self.clone())
    }

    fn unmap_physical_region<T>(_region: &PhysicalMapping<Self, T>) {}
}

...

let info = AcpiTables::from_rsdp(KernelAcpiHandler, rsdp)
    .unwrap()
    .platform_info()
    .unwrap();

AcpiTablesAcpiHandler を要求する。これは仮想アドレスと物理アドレスのマッピングのためのトレイトだが、MikanOSではアイデンティティマッピングを採用しているので、物理アドレスはマップ処理を伴わずそのまま仮想アドレスとして解釈できる。あとは info.interrupt_model から local_apic_address などを取得できる。

脚注
  1. 特にマルチコア対応を考えるとAPICを使いたい ↩︎

yubrotyubrot

Local APIC, IOAPICを有効にし、8259 PICを無効にする

MikanOSでも今後の章でAPICは出てくるようなので、細部の仕様は追わず、とりあえずxv6の実装を粛々と移植していく。

重要な点は、PS/2 キーボードのようなデバイスからの割り込み要求(IRQ)を割り込みコントローラ(今回はAPIC)が認識して、想定した割り込みハンドラによる割り込みで処理されるように設定することになる。PS/2 キーボードの場合 [1]

  1. (キー入力が発生する)
  2. IRQ 1がIOAPICに送られる (Wikipedia等に記載されている通り、このIRQ番号は予約されている)
  3. IOAPIC上のRedirection Tableの設定から、割り込みの転送先を選び、CPUのLocal APICに割り込みが転送される
  4. Local APICは優先度等の設定に基づいて、そのCPUで (Redirection Tableで指定されていた) 割り込みベクタの割り込みハンドラを呼び出す
  5. (割り込みハンドラはEnd Of Interruptを通知する)
    • この通知はLocal APICのためのものであることに注意したい。CPUの割り込みフラグは関係しない。

といった流れになる。実装上は以下のような形となった。

const EXTERNAL_IRQ_OFFSET: u32 = 32; // 割り込みベクトルの最初の32個はCPU例外等で予約されている
const IRQ_KBD: u32 = 1; // PS/2 キーボードのIRQ
const IRQ_COM1: u32 = 4; // シリアルポート1のIRQ

unsafe fn initialize_apic(rsdp: usize) {
    ...
    let cpu0 = (info.processor_info.boot_processor.local_apic_id as u64) << (24 + 32);

    // IOAPICのRedirection Tableに、
    // - 割り込み要求 IRQ_KBD をcpu0へ割り込み EXTERNAL_IRQ_OFFSET + IRQ_KBD として
    // - 割り込み要求 IRQ_COM1 をcpu0へ割り込み EXTERNAL_IRQ_OFFSET + IRQ_COM1 として
    // 転送するように設定する
    ioapic.set_redirection_table_at(IRQ_KBD, (EXTERNAL_IRQ_OFFSET + IRQ_KBD) as u64 | cpu0);
    ioapic.set_redirection_table_at(IRQ_COM1, (EXTERNAL_IRQ_OFFSET + IRQ_COM1) as u64 | cpu0);
}

...

unsafe fn initialize_idt() {
    IDT.double_fault
        .set_handler_fn(double_fault_handler)
        .set_stack_index(DOUBLE_FAULT_IST_INDEX);
    ...
    // - 割り込み EXTERNAL_IRQ_OFFSET + IRQ_KBD のハンドラとして kbd_handler を
    // - 割り込み EXTERNAL_IRQ_OFFSET + IRQ_COM1 のハンドラとして com1_handler を
    // 割り込みディスクリプタテーブルに登録
    IDT[(EXTERNAL_IRQ_OFFSET + IRQ_KBD) as usize].set_handler_fn(kbd_handler);
    IDT[(EXTERNAL_IRQ_OFFSET + IRQ_COM1) as usize].set_handler_fn(com1_handler);
    IDT.load();
}

USBの場合の割り込みについては、MikanOS本を読み進めつつ後ほど書きたい。

脚注
  1. 細部の表現 (「受け取る」や「送られる」など) が適切でないかもしれない ↩︎

yubrotyubrot

7章 割り込みとFIFO

x86_64での割り込みについて。割り込みディスクリプタテーブルの初期化はWriting an OS in Rustの流れで行っていた。

MSI割り込み

前述の疑問の答えがすぐに出てきた。USB (xHCI) では割り込みの発生方法として、PCI規格が定めるMSIを採用している。MSIによる割り込みではIOAPICを介さず、直接Local APICに割り込みが送られるようだ。

yubrotyubrot

割り込みが入ってくるので、適当に spin::Mutex 使ってる部分の排他制御について考え直す。

今回特に気を付けなければならないのは割り込みハンドラの実装だろう。例えば割り込みハンドラがロギングをしていて、ロギングの実装がシリアルポートへの書き込みのためのロックを取得しようとしているとする。このとき、割り込み前の処理がちょうどシリアルポートをロックしていた場合、割り込みハンドラ側のロックの取得は失敗し、かつ割り込みから返る手段も無いためデッドロックとなってしまう。

Linuxカーネルではこのために spin_lock_irqsave spin_lock_irqrestore が用意されている。スピンロックの獲得の前にそのCPUでの割り込み要求の受付を停止することで、スピンロックのクリティカルセクションがそのCPUで速やかに実行されるようにする。他、read-write lockを使ったより効率的な方法などが示唆されている。

(追記) xv6はかなり保守的になっている。spinlockは常にpushcli/popcliを呼び出していて、まあつまりスピンロックを獲得する時は常に割り込みを止めている。

ともかく、割り込みハンドラが触れるデータ群の排他制御には一層の注意を要する。

yubrotyubrot

PCで利用できるタイマーが思ったより色々あることを知る: Timers (レガシーなものも色々)
Local APICもタイマー機能を持っていて、定期的にCPU内で割り込みを発生させたりもできる。MikanOS本ではベンチマークのために、xv6では割り込みのために使用されている。とりあえず空の割り込みハンドラを設けて割り込みが発生するようにだけしておく。

yubrotyubrot

グラフィック周りは好きにやろうと思いつつも、どうしようかなあと考えていたところで端末のエスケープコードを思い出す。少し調べてみた。
(歴史的な順序は把握してないが)かつて使われていた端末のVT100の仕様がデファクトスタンダードとなっており、ANSI X3.64といった標準仕様や現在の端末エミュレータの実装もこれのサブセットあるいはスーパーセットとなっているようだ。
ただvimのようなアプリケーションは直接エスケープコードを吐くことは少なく、terminfo/termcapによって端末のエスケープコードを得て使っているようだ。 infocmp で現在の端末の対応表が見られる。 tput ユーティリティによってこれを試すこともできる。
例えば tput setaf 1 && echo "hello" で文字色を赤にした出力を試せる。 tput setaf 1 は一般的なLinuxのターミナル環境なら ^[[31m と出力している。

(追記) 15-16章相当の実装時に、ANSIエスケープシーケンスの解釈を含めてこのあたりの実装を改善した:

https://zenn.dev/link/comments/430b70afc2144e

yubrotyubrot

9章 重ね合わせ処理, 10章 ウィンドウ

フレームバッファの実装を参考に描画を高速化した。あまり本書の流れに則ってない。

yubrotyubrot

11章 タイマとACPI, 12章 キー入力

キー入力はWriting an OS in Rustの流れでサポート済みなので、タイマ処理を改善する。
Local APIC Timerの周波数はハードウェア依存でまちまちなので、別のハードウェアタイマーを使ってこの周波数を計測し、およそ10ms間隔で割り込みが発生するように調整する。

The ACPI Power Management Timer is a very simple timer which runs at 3.579545 MHz and generates a SCI when the counter has overflown. It is extremely limited (you cannot set custom rates, for example). It is recommended to use other timers, like the HPET or the APIC Timer.

とあるように、このタイマーは機能に乏しいが、周波数が決まっているので、これを用いてLocal APIC Timerの周波数を計測できる。
名前の通りACPI標準に含まれるタイマーで、上でも利用したacpiクレートによって RSDP -> RSDT or XSDT -> FADT を読む流れを自前実装しなくて済む。

unsafe fn initialize_apic(rsdp: usize) {
    let info = AcpiTables::from_rsdp(KernelAcpiHandler, rsdp)
        .unwrap()
        .platform_info()
        .unwrap();
    ...
    let pm_timer = info.pm_timer.expect("Could not find ACPI PM Timer");
    assert_eq!(pm_timer.base.address_space, AddressSpace::SystemIo); // TODO: MMIO Support
    assert_eq!(pm_timer.base.bit_width, 32);
    let pm_timer_port = x64::Port::<u32>::new(pm_timer.base.address as u16);
    // pm_timer_port.read() によってタイマーの値を得る
}
このスクラップは2021/09/23にクローズされました