『ゼロからのOS自作入門』履修メモ

Written on 2022-03-03

『ゼロからのOS自作入門』(通称「Mikan本」)を購入して履修した。 本書はUEFI+x86_64で動作するOS(MikanOS)をC++を使って自作するための教科書・チュートリアルである。 昔、『30日でできる!OS自作入門』という「はりぼてOS」を作る本があったが、環境が古くなったので、本書の著者が新しい環境に適応して新規に書き下ろしたもの。

OSの自作というものそれ自体も、とても興味深いことだ。ただ、純粋にOSっぽいところ(タスク管理やメモリ管理)だけではない。OSの作成を通じて「現代の環境」に適応した低レイヤーな技術セットを習得できる、というところも本書のスコープ。また第1章ではバイナリエディタでOSを書き、第30章ではマルチウィンドウシステム上で画像ビューアや日本語テキストビューワが動く。小さなものからシステムを育て上げて大きなモノを作り上げる「インクリメンタルな開発」の体験ストーリーにもなっている。

本書が想定している「現代の環境」というのは、UEFIブート、USB HIDデバイスドライバ、マルチタスク、GUIとウィンドウシステム、qemuによる実行、Linux上での開発、新し目のC++の機能、newlibとのリンク、x86_64の機能(ABI、仮装メモリ、ACPIなど)。こういった更新が早い情報は、ネットの掲示板やブログ記事などが主な情報源であったが、まとまって整理された書籍という形はとても嬉しい。

ファイルシステムはオンメモリのFAT。また、aarch64やM1 macには対応外だ。

履修、活用方法

2020-2021年の冬シーズンに、コロナ+ミネソタの寒さで篭っているときの自習用にトライし始めた。実際は冬でもスキーや自転車など外で遊ぶことも多く、また、暖かくなったらさらに忙しくなり、そのシーズンでは完走できず、2021-2022冬シーズンでようやく読了できた。

本書の構成としては、30章からなっており、1章が休日の1日分にちょうどいいぐらいの分量になっている。順調にこなせば「30日でできる」だろう。

当初は「写経」に挑戦しようとしたが時間的・リソース的に追いつかず、「履修」スタイルで行った。 提供されているリポジトリを取得すると、本の構成に沿ってgitのタグが付けられている。 書籍中のソースコードと本文中の解説を読んで、git履歴から辿れる変更点をたどり、qemuを使って実行結果を確認するというサイクル。自分での開発行為・拡張などの発展的な作業は盛り込むことが出来なかった。

世間では、Rustで書きなおしたり、RaspberryPi(aarch64)で動かしたりしている人もいる。そういったものは、2周目への課題としたい。

以下では、本の章立てに沿ってのOS開発の立ち上げ・育て方という側面、OSを書くために必要なx86-64 CPU特有の機能やペリフェラル、ツールなどの低レイヤー知識という側面、低レイヤーも抽象化も対応可能なモダンC++という側面から概観をまとめる。

day1: バイナリエディタでUEFIのブートファイルを作成する

開発環境はUbuntu。ansibleを使ってセットアップする。前提としているのがUbuntu 20.04だが、私のデスクトップ環境がUbuntu16.04からアップデートしていなかったので、若干苦労した。

バイナリエディタでUEFIのブートファイルを作成し、USBメモリに書き込む。実機をUSBから起動すれば、作成したブートファイルが起動する。UEFIの起動パス、起動ドライブの変更、バイナリの構造などが学べる。

UbuntuではなくWSLでの開発方法、実機ではなくqemuを使った実行方法も触れられている。

章の最後ではCで作ったUEFIアプリのコードをコンパイルしてEFIからqemu上で実行するところまで。

day2: EDK IIと EFUアプリ、EFI API

EDKを使ったEFIアプリの作り方、EFI APIを使ってメモリマップを取得する方法。

day3: qemu と 画面表示

qemuを本格的に使っていく。qemuはたんなるエミュレータだけでなくCPUモニタ機能もある。後の章ではあまり明示的にモニタ機能を使っていないが、執筆途中でデバッグに利用していただろう。

カーネルの卵もこの章で生まれる。EFIアプリであるブートローダがELF形式のカーネルをロードして実行する形。ブートローダがカーネルを読み込み、メモリマップから得られた特定のメモリアドレスにカーネルをロードしてエントリーポイントを呼び出す。

さらに、UEFIにあるGOPを取得してフレームバッファのアドレスを取得。そこに書き込むことでピクセル描画を実現。

day4: Makefile

make と Makefileを導入してビルドを自動化していく。

フレームバッファを導入して描画を抽象化していく。さらにそれをC++のクラス化して抽象度を高めていく。

day5: 文字列表示

英字フォントが導入され、ピクセル描画APIを通して文字出力が可能となる。ぐっと開発がしやすくなる。 コンソールクラスも導入され、表示文字列がスクロールアップしていく。

day6: マウス

筆者が提供するUSBドライバを利用してマウス機能が実装される。

day7: 割り込みハンドラ

ポーリングではなくxHCI割り込みハンドラを利用してマウスが動くようになる。割り込みを処理するために x86 CPU のIDT、PCIバスのMSI割り込みについても学ばなければならない。また、割り込み禁止の方法(__asm__("cli"), __asm__("sti"))も学ぶ。

day8: メモリ管理

UEFIからメモリマップの情報をもらい、スタック領域(カーネルスタック)を設定する。また、セグメンテーション(GDT)やページング(PM4テーブル)もここで登場する。メモリ管理やスタック管理がはいってくると、かなりOSっぽい。

day9,10: 描画の改善

本書のOSではnewlibを使っているので、sbrk()を定義すればmalloc()が使え、newも使えるようになる。

画面描画の重ねあわせ処理で描画が高速化する。また、描画領域もWindowクラス、WindowWriterクラスと抽象化度が上がっていく。効率化のために、細かい座標計算で必要最低限の領域のみ書き換えるようにする。計算が面倒だ。

day11: タイマとACPI

day12: キー入力

day13,14: マルチタスク

いかにもOSといった機能。タスク切り替えは、つまり、コンテキスト切り替えである。つまり、命令の実行に必要なレジスタ類をタスク構造体のコンテキスト領域に保存して、CPUに見せるレジスタを新しいタスク構造体のコンテキストからロードして、ごっそりと入れ替えるアセンブラルーチンがその心臓部である。

day15: ターミナル

MikanOSでのターミナルは、Linuxでのxtermのような単なるアプリではなく、OSに直接生えているシェルのようなものだ。シェルとして入力コマンドを解釈したり、内部コマンドを持っていたり、タスクの起動を行う。Unixでいうバックグラウンド実行は、MikanOSではターミナル無し実行となる。

day16: 組み込みコマンド

day17: ファイルシステム

MikanOSでは、起動ディスクはFATで作成され、EFIが定める所定のパスにローダが、ルートディレクトリにカーネルが保存される。ローダはFATを理解できるので、OSにファイルシステムが未作成でもルートディレクトリのカーネルを起動できるというわけだ。 MikanOSのファイルシステムは、この起動ディスクをそのまま(節約のため先頭部分のみ)メモリに読み込んで、FATのチェーンを辿ってファイル構造とファイルの内容を取り出すことができる。

day18:アプリケーション

ファイルシステムが読み込めるようになったので、アプリを実行バイナリとして作成し、ファイルシステム上に格納して、ターミナル(シェル)から起動できるようになる。アプリの実行バイナリはELFとして作成される。ABIに互換性があるので、ホストのコンパイラでそのままアプリのビルドができるというのは興味深い(システムコールやライブラリが異なるのでSegmentation Faultになる)。

day19: 仮想アドレスと4階層ページング

OSのカーネル、ライブラリ、複数のアプリをメモリ上に配置するために、x86_64 CPUが持つ仮想アドレス機構を使う。歴史的経緯が複雑だが x86_64 ではメモリアクセス機構は整理され、CR3がポイントするPML4という領域から始まる4階層のページング機構によって、仮想アドレスが物理アドレスに変換される。この仮想アドレス機構は将来的にメモリマップドファイルやデマンドページングにつながる。

day20: システムコール

OSがアプリに対して提供する機能をシステムコールとしてパターン化してどんどん提供できるようにしていく。 アプリがOSの機能を呼び出すには、単なる関数呼び出しではなく、低い特権のアプリから高い特権のOSの機能を呼び出せるための準備をしっかりと構築しておく必要がある。

day21,22,23: アプリから描画

システムコールを追加して、アプリからウィンドウ、テキスト、ビットマップを描画できるようにしていく。たんにシステムコールを次々と実装していくのではなく、それの動作テスト用のミニアプリも作っていく。簡単で、機能がわかって、見た目も楽しい。こういう所がよい。

day24: アプリレベルでのマルチタスク

複数のアプリを同時に実行できるようにする。それぞれのアプリは仮想メモリ機能を使って固有のアドレス空間を持つようになっている。その仮想メモリ(ページングテーブル)をアプリごとに切り替えるようにすることだ。MikanOSでは、ターミナル=シェルなので、ターミナルを複数起動することでシェルが複数起動し、それぞれのシェルからアプリを起動することができるおゆになる。

day25,26: ファイルの読み書き

MikanOSにFAT読み書きのシステムコールを追加する。しかし、MikanOSはディスクドライバを保たないため、操作はOS起動時にメモリマップされたディスクイメージに対して行われ、OSを再起動すると初期化されてしまう。

day27: デマンドページング

ページング機構と例外ハンドラを組み合わせてデマンドページングを実装する。世の中のOSでは一般的だが、これを最初に考えた人は本当にすごいと思う。

day28, 29: リダイレクトとパイプ

ファイルディスクリプタの仕組みを拡張してリダイレクトとパイプを実装する。ファイル読み書きをきちんと作っておいたお陰で、じつに素直にリダイレクトとパイプが実装出来ている。

day30: テキストビューア、画像ビューア

stbimageというヘッダファイル形式のライブラリを使って画像ビューアを作成する。

QEMU

今回は、ホストは x86_64のUbuntu、ゲストはx86_64で動作するMikanOS。これをQemu上で(day1で実機上でも動かしているが)動作させながら開発を進める。

一般的にQemuはCPUごとエミュレートするので、ホストとターゲットのCPUが異なっても動作する。たとえば、M1 mac上でもクロスコンパイラを使えば x86_64のバイナリをビルドすることは出来るし、qemu-system-x86_64を使えば、それをx86_64ターゲットのQemu上で動作させることもできるだろう。OVMFというフリーのUEFIイメージを使えばUEFIモードで起動する。もしかしたら、クロスコンパイラではなく、M1 mac上にx86_64のQemuを走らせて、その上にUbuntuをインストールして開発する、というのもあり得るかもしれない。

クロスコンパイラとエミューレータの技術の進歩により、どのようなアーキテクチャでも似たようなことができるようになってきたことは素晴らしい。

  • qemuを使ったEFIアプリの実行: day1
  • qemuのCPUモニタ機能: day3

x86_64のCPUの機能

4階層ページング機構: day19

x86_64は階層ページング機構をサポートしている。仮想アドレスを物理アドレスに変換するのに、リニアなテーブルを保持するとコストが掛かり過ぎるからだ。

CR3レジスタが、PML4という512要素のテーブルがあり、それぞれがPDPという512要素のテーブルを参照している。PDPの要素はPDテーブル(512要素)を参照しており、PDのテーブルはPTテーブル(512要素)を参照している。64ビットの仮想アドレスのうち[63:48]は全て1、[47:39]の9ビットがPML4のインデックスに、[38:30]がPDPのインデックスに、[29:21]がPDの要素に[20:12]がPTの要素に対応する。PTの要素は物理ページを指しており、[11:0]の12ビットは、ページ(4KiB)内のオフセットだ。先頭の[63:48]は[47]と同じビットで埋められていなければならない(全て0または全て1)のでページング機構では無視できる。

MikanOSではLinearAddress4Levelというuint64_tと同じサイズのビットフィールドからなるunionを定義し、さらにそこにメンバー関数を生やしてスムーズな記述ができるように工夫している。

union LinearAddress4Level {
  uint64_t value;

  struct {
    uint64_t offset : 12;
    uint64_t page : 9;
    uint64_t dir : 9;
    uint64_t pdp : 9;
    uint64_t pml4 : 9;
    uint64_t : 16;
  } __attribute__((packed)) parts;

  int Part(int page_map_level) const {
    switch (page_map_level) {
    case 0: return parts.offset;
    case 1: return parts.page;
    case 2: return parts.dir;
    case 3: return parts.pdp;
    case 4: return parts.pml4;
    default: return 0;
    }
  }

  void SetPart(int page_map_level, int value) {
    switch (page_map_level) {
    case 0: parts.offset = value; break;
    case 1: parts.page = value; break;
    case 2: parts.dir = value; break;
    case 3: parts.pdp = value; break;
    case 4: parts.pml4 = value; break;
    }
  }
};

特権モード: day20

  • DPL、CPL、TSSの使い方など。GDTとの関係

X86_64 ABI

MikanOSではLinuxなどで使われているSystem V AMD64 ABIを使う。

  • 引数は、RDI, RSI, RDX, RCX, R8, R9を使う。つまり64ビット×6個まではレジスタ渡しが可能。MikanOSでも、システムコールは最大6個の引数を取ることができる。:day20
  • 返り値は、RAX, RDXを使う。64ビットまでの場合はRAX。RDXも使えば128ビット。そこに収まるように返り値を設計すれば都合が良い。
  • Callee Save レジスタは、RSP, RBP, RBX, R12, R13, R14, R15。これらは呼ばれた関数の側でスタックなどに保存しておかなければならない。
  • Red Zone。末尾の関数(leaf function)は128バイト以下であればRSPを調整せずに使えるメモリ領域がある。: day3

モダンなC++の機能

私がC++を学んでいたのは25年前。その当時から比べると、C++も随分とモダンな言語になった。組込み分野ではいまだにCが主流だが、このような、抽象度が高いが見えないコストが少ない機能が活用されているのを見るとC++も魅力的に見える。個人的にはそれよりRustでいいと思うが、業界的にはどっちの方向(C++のモダンな機能を使いこなす、または、最初からRust、それともCに固執)に行くだろうか。

  • C。十分に(十二分に)枯れている。見えないコストがかからない。抽象化度が低い。標準の便利機能(データ構造など)が無いので都度手書き。
  • C++。新しい機能がたくさん。抽象化度が高い。しかし見えないコストがかからないように工夫されている。便利ライブラリが標準化。Rustよりはユーザが多い。
  • Rust。抽象化度が高い。ゼロコスト。ライブラリや周辺ツールも含めて標準化。no-std、FFIなど、最初から低レイヤーサポートが想定されている。型を活用したIDE・コンパイラサポートが賢い。C++と比べたら、過去のノウハウが整理されて標準化されているので迷いがないことが良い。一方、ライブラリの蓄積はC++が勝る。

配置new: day4

メモリを動的確保せずに静的確保した領域を与え、オブジェクトの初期化のみ行う。

cast: day6

  • C++にはstatic_cast, dynamic_cast, const_cast, reinterprt_castがある。
  • static_castは通常のcast
  • dynamic_castは、基本型のポインタを継承型のポインタに変更するなど。基底型のポインタが実際に継承型のオブジェクトを指していれば成功する。
  • const_castは、constvolatileを除去する。
  • reinterprt_castは、たとえばchar*int*に変換するなど。

ラムダ式: day9

  • 無名関数で環境をキャプチャできるものをラムダ式と呼ぶ。LispやRubyではおなじみの機能だが、コールバック関数などが綺麗に書ける。

範囲for: day9

  • Rubyの foreachみたいに、添字を使わずに、コレクションの全要素をひとつづつ取り出してきて処理ができる。

スマートポインタ: day9

constexpr: day10

  • C++の式のように見えるがコンパイル時に畳み込めるもの。効率的ではあるが、注意しなければ黒魔術みたいになってしまいがち。

std::array<T,N>での配列定義

  • メモリ効率、アクセス効率は通常の固定サイズ配列と同等だが、いろいろな便利メソッドが生えているのがいいのだろう。
  • 通常の配列にもユーティリティ関数はあり、便利さがいまいちわからない。

auto型

  • 型推論で適切な型が付く。
  • 本書では文脈型(実装が同じでも意味が違えば積極的に別の型を使う)を多用しており、autoの出番が多い。

テンプレートを使ったResultのようなもの: day6

  • これは良いプラクティス。エラーになる可能性がある戻り値をstruct WithERROR {T: value; Error error;}という構造体で返す。
  • RustのほうがResultと似たような使い方。
  • Errorは、エラー情報のEnum、ファイル名、行番号を含むリッチなものだ。

構造化束縛: day19

  • 上のWithError<T>のようなものは、返り値を受け取る時に、構造体のメンバーごとに代入して受け取ることができる。

ユーザ定義リテラル: day8

  • _KiBなどのサフィックスをoperator""_KiBというように定義できる。
  • 目的に応じた型定義といい、筆者は読みやすさにかなりのコダワリがあるように思える。

桁区切りと2進数リテラル: day20

  • 定数を見やすくするための桁区切りは “’“。
  • 2進数リテラルはプレフィックス “0b…“。
  • 両方とも他の言語では一般的な機能だがC++14から導入された。

命名規則

はやりの(Googoeとかでも使われれているだろう)命名規則。

  • 定数は kConstant (kで始まる)
  • メンバ変数は member_ (末尾にアンダースコア)
  • 2文字インデント

書かれていなくて困ったこと

LLVMのバージョン

本書のリポジトリから環境セットアップスクリプトを呼ぶと、llvm-7の環境を前提としてインストール&シンボリックリンクの作成が行われる。しかし、私の環境では提供されるバイナリがllvm-3.8とllvm-8だけだったので、スクリプトを修正して手作業を含めたセットアップが必要になった。

QEMUからマウスをリリースする

  • 左CTRL+左ALT
    • LEFT from QEMU と覚える。

osbook_day06c

  • QEMU環境でマウスドライバの初期化に失敗する。⇒kernel/usb/xhci/xhci.cで次のようにすると通る。
namespace usb::xhci {
  //...
  Error Controller::Initialize() {
    //...
    op_->USBCMD.Write(usbcmd);
    // while (!op_->USBSTS.Read().bits.host_controller_halted);     // ここの無限ループが帰ってこないので、とりあえずコメントアウト