1 ポイント 投稿者 GN⁺ 2024-07-16 | 1件のコメント | WhatsAppで共有
  • 512バイトのブートセクタから始めて、x86_64 CPUを16ビットreal modeから64ビットlong modeへ移行させる最小ブートローダーの流れを段階的に構成する
  • BIOS割り込みでまず出力を確認し、その後nasmldobjcopyQEMUを組み合わせて、ブートイメージが実際に実行されるかを検証する
  • ブートセクタの容量制限のため、stage 1/stage 2に分割し、protected modeへ入る前にBIOS int 0x13で次のコードをディスクから読み込む
  • 32ビットprotected mode以降はBIOSルーチンが使えないため、GDT、flat segmentation、VGAバッファへの直接出力といったハードウェア初期化が必要になる
  • 64ビットlong modeへの移行には、ページテーブル、PAE、EFER.LMEcr0.PG、64ビット向けGDTをすべて正しく設定する必要があり、その後はfreestandingなCコードをカーネルのように呼び出せる

BIOSから始まる16ビット環境

  • x86 CPUはリセット後、real modeにあり、基本オペランドサイズは16ビットである
  • real modeはsegmentationによって20ビットのアドレス空間を作り、最大1MBのメモリを扱える
  • BIOSが最初に実行するコードは、ディスクのブートセクタに入っている
    • BIOSは先頭セクタが0xaa55マジックナンバーで終わるディスクを探す
    • そのセクタをメモリアドレス0x7c00に読み込む
  • BIOSが渡してくれる領域は512バイトしかないため、このコードは残りのブートローダーを読み込むbootstrapの役割に集中する
  • BIOSルーチンはreal modeにとどまっている間だけ利用できる

準備物とビルド環境

ブートセクタを作って実行を確認する

  • 最初のブートセクタはBIOSルーチンで"Hello, world!"を出力した後、hltとループで停止する
  • 文字列出力にはBIOS video services呼び出しのint 0x10ah = 0x0eを使う
  • Makefileはnasmでobjectを作成し、linker scriptでリンクした後、objcopy -O binaryでrawブートイメージを生成する
  • make bootはQEMUでイメージを実行する
    • qemu-system-x86_64 -no-reboot -drive file=$<,format=raw,index=0,media=disk
  • linker scriptはブートセクタが**0x7c00基準で配置**されるよう設定する
    • MEMORYboot_sector originは0x7c00、lengthは512
    • .bootsignセクションは0x7c00 + 510位置に0x550xaaを追加する
  • ブートセクタassembly内でoffsetやmagic numberを直接処理することもできるが、ここではlinker scriptがその役割を担う

stage 1がstage 2をディスクから読み込む

  • stage 1はBIOSがロードするブートセクタコードで、目的はstage 2をメモリへ読み込むこと
  • stage 2には16ビットreal modeから32ビットprotected modeへ移行するコードが含まれる
  • protected modeに入った後はBIOSルーチンを使えないため、ディスクセクタの読み込みは移行前に完了させる必要がある
  • ディスクアクセスにはBIOS disk servicesのint 0x13を使う
    • ah = 0x42はBIOS extended read機能
    • dl = 0x80はドライブ番号
    • disk address packetには読み込むセクタ数、対象アドレス、開始セクタが入る
  • 例ではREAD_SECTORS_NUM equ 64として64セクタを読む
    • ブートセクタはsector 0なので、stage 2はsector 1から読む
    • 対象アドレスはBOOT_LOAD_ADDR + SECTOR_SIZE、つまり0x7c00 + 512
  • コードには、要求した数より少ないセクタしか読めなかった場合を許容する暫定処理が残っている
  • stage 2は最初にreal mode用print_stringをコピーして"Hello from stage 2"を出力し、stage 1からのジャンプが正しく続いているかを確認する

32ビットprotected modeへ切り替える

  • protected modeへ入るには、まずGlobal Descriptor Table(GDT) を定義しなければならない
  • protected modeでは基本的にsegmentationがメモリ保護に使われる
  • 64ビットlong modeではpagingが必要になるが、その前段階であるprotected modeへの移行ではまずsegmentation設定が必要
  • 例のGDTはIntel manualのflat modelに従う
    • code segmentとdata segmentを置く
    • 2つのsegmentは線形アドレス空間全体にマップされる
    • long modeへ進むための中間段階なので、最も単純なモデルを使う
  • GDTはメモリ上に置かれる連続した構造体である
    • 先頭には無効な変換を捕捉するためのnull descriptorがある
    • その後にcode segment descriptorとdata segment descriptorが続く
  • 切り替えは次の順序で行う
    • cliでinterruptを無効化する
    • lgdt [gdt32_pseudo_descriptor]でGDTRにGDTのアドレスと長さをロードする
    • cr0.PE、つまりcr0のbit 0を設定してprotected modeを有効化する
    • far jumpでinstruction pipelineを空にし、csを新しいcode segmentへ更新する
  • protected mode移行後は既存のsegment値に意味がなくなるため、dsssesfsgsを新しいdata segment selectorに設定する
  • すべての設定後にinterruptを再び有効化するには追加作業が必要になる

BIOSなしで画面に出力する

  • protected modeではBIOSルーチンをもう呼び出せない
  • 文字列出力はVGA bufferへ直接書き込む方式に切り替わる
  • print_string320xb8000アドレスに文字と色属性バイトを書き込む
    • 色の値は0xf
    • 各文字セルは2バイトを使う
  • この出力関数は非常に単純で、メッセージは常に画面左上に表示される

long mode移行のためのページテーブル

  • Intel文書のIA-32e modeはAMD64 manualのlong modeに相当する
  • long modeへ切り替えるには、CPUがprotected modeにあり、pagingも有効化されていなければならない
  • pagingの概念については Introduction to PagingOSTEP を参照している
  • PAEが有効なlong modeでは4段階ページテーブルを使う
  • build_page_tableは指定アドレスに4段階ページテーブルを生成する
    • pageサイズは0x1000
    • 各page tableサイズは0x1000
    • entry数は512
    • まず4つのtableを0で初期化し、すべてのentryをnot present状態にする
    • PML4 → PDP → PD → page tableの先頭entry同士を接続する
    • 最下位のpage table layerで512個のentryを設定する

64ビット向けGDTとlong mode移行手順

  • pagingがvirtual address spaceと権限管理を担う一方で、long modeでもGDTは必要
  • 64ビット向けGDTもflat modelに従い、protected mode用GDTとほぼ同じ
  • 違いはlong mode関連bitの設定にある
    • code segmentの64-bit code segment flagを設定する
    • このflagが設定される場合、default operation size bitは0でなければならない
  • long modeへの移行は次の流れで進む
    • 0x1000アドレスに4段階page tableを生成する
    • cr3にPML4 tableアドレスを入れる
    • cr4のbit 5を設定してPAEを有効化する
    • 0xc0000080 MSRを読み、EFER.LME、つまりbit 8を設定する
    • cr0のbit 31であるPG flagを設定してpagingを有効化する
    • 64ビット向けGDTをlgdtでロードする
    • 64ビットcode segmentへfar jumpして64ビットモードに入る
  • pagingを有効化した直後はIA-32e compatibility modeの状態で、64ビットsegment flagが設定されたGDTへジャンプすることで64ビットモードへ移行する
  • 成功確認メッセージはVGA bufferを通じて画面左上に表示される

freestanding Cコードを呼び出す

  • 64ビットlong modeまで到達すれば、freestandingなCコードを呼び出せる
  • kernel.cはVGA buffer 0xb8000をクリアし、"Hello from C"を出力する
  • assembly側のstart_long_modeは64ビット向け文字列出力の後、extern _start_kernelを宣言して_start_kernelを呼び出す
  • linker scriptはメモリ領域を3つに分ける
    • boot_sector: 0x7c00, length 512
    • stage2: 0x7e00, length 512
    • kernel: 0x8000, length 0x10000
  • .text.data.rodata.bssセクションはkernel領域に配置される
  • MakefileはassemblyとCの両方をビルドするよう変更される
    • C compilerはgcc
    • 主なCFLAGSは-std=c99 -ffreestanding -m64 -mno-red-zone -fno-builtin -nostdinc -Wall -Wextra
  • 全体のサンプルコードは ダウンロードリンク で提供されている

1件のコメント

 
GN⁺ 2024-07-16
Hacker News の意見
  • プロテクトモードを経由しなくても、はるかに少ないコードで直接ロングモードに入れる: https://wiki.osdev.org/Entering_Long_Mode_Directly
    この方法で作った小さな64ビットカーネル用ブートローダーがあり、ディスクからカーネルを読み込み、VESAモードを設定するコードまで含めてもブートセクタに十分収まっていた。第2段階ローダーも不要だった

    • それをどうやって512バイト以内に全部入れるのか気になる。カーネルをディスク上のどこにでも通常ファイルのように置ける実際のファイルシステムはなさそうだし、ファイルの断片化を扱うだけでも512バイトを軽く超えそう
    • 単に https://limine-bootloader.org/ を使えばずっと単純になる。リアルモードに触る必要がなく、SMPでも同様で、カーネルをhigher-halfマッピングで自動ロードし、aarch64とriscv64でも動作する
    • その通りだが、パーティションテーブルまで入れて現代的なAHCIコントローラとSATAをサポートしようとすると、ブートローダーの領域がさらに減るので最適化が必要になる。この場合、ローダーに510バイト全部を使うことはできず、ずっと少なくなるし、有効なパーティションエントリまで埋めるならテーブル内のバイトも使えないのでさらに厄介になる
      実際の現代的なハードディスクを使うなら、MBRよりGPTを見るほうがよい。パーティションテーブルの制限を超えずに2TBを超える大きなディスクも扱える。UEFIはこうした問題をなくし、特に苦労せずきちんとしたディスクレイアウトを使えるようにしてくれる
      64ビットモードに入るのにプロテクトモードは必要ない。ただしBIOSは使わないほうがよい。汚く、作業を余計に面倒にするだけだ
      UEFIをEDK2やGnuEFIで使うやり方のほうがよく、どちらも実装はかなり簡単で楽だ。UEFIの初期概念に慣れるには少しかかるが、GitHubのサンプルプロジェクトを見れば構造は簡単に把握できる。EDKは.decや.infファイルなどが少し微妙で、GnuEFIは機能を探すためにヘッダーファイルを読む必要があるが、仕様が不明瞭なBIOSインターフェースよりははるかにましだ。実際のハードウェアでは、int 0x10やint 0x15などが正しく存在すると仮定することすらできない
      UEFIシステムでは安定した最小限の基盤を前提にでき、ハードウェアやプラットフォーム機能もまともに列挙できる。またUEFIがプラットフォームをすでにかなり設定しているので、OSローダーコンポーネントで多くを初期化する必要はなく、OS、ドライバ、カーネル設計に合った構成要素をそのままロードすればよい。メモリマップを取得し、EFIファイルシステムにアクセスして必要なものを読み込めばよい
    • こういうことが可能だとは知らなかった。単にロングモードへ行くのが目的なら、そもそもなぜプロテクトモードを経由する必要があるのか気になる
  • 80286には16ビットレジスタのMachine Status Word(MSW)があり、80386はこれを32ビットレジスタCR0に拡張した。その後64ビットロングモードではEFER MSRを追加し、CR0を64ビットに拡張したが、今日でもCR0では11ビットしか使われておらず、EFERも有効なビットは8個だけだ
    Intel/AMDが既存レジスタの余ったビットをそのまま使わず、なぜ二度も新しい選択をしたのか気になる: https://wiki.osdev.org/CPU_Registers_x86-64#CR0

    • おそらく下位互換性をより堅牢にするためである可能性が高い。ソフトウェアが予約ビットの値を仮定したり、そこに値を書き込んだりする可能性があるためだ。こうしたハードウェアレジスタのビット割り当てはかなり恣意的で、上位ビットを使うことに特にコストもない
    • 一言で答えるなら、おそらく官僚主義だと思う。大きな組織は全般に、とりわけ良い判断を下すのがあまり得意ではなく、筋の通らない選択も多く生まれる
      CR1とCR5〜CR7がいまだに予約状態なのにCR8ができた理由も似ているように見える
  • この記事で最も不必要に複雑に見えるのはMakefileとリンカスクリプトだ。NASMはフラットバイナリ出力をサポートしているが、それを使うのはあまりに「ハックっぽい」と見たのだろう

    • 個人的には、フラットなNASMよりもリンカスクリプトのほうがずっと読みやすく、推論しやすいと思う。特にソースファイルが複数ある場合はそうだ
    • 完全にその通り。後になるとMakefileとリンカスクリプトは重要な頭痛の種になるが、フラットバイナリを作るなら、ただフラットバイナリを作ればよい。わざわざ膨らませる必要はない
      以前、自分のOSにはこれをからかうためにmake.shというファイルがあった。今では「ファイル形式」のような格好いいものを使っているので、-fbinと--oformat=binaryははかない思い出になった。データ用Cファイルとコード用Cファイルを分けてバイナリにダンプし、そこから怪物を組み立てようと長いこと試したが、リンクとロードが難しすぎた。ELFやPEを使うほうがよく、実際それらの形式はそういう仕事をしてくれるようだ
  • かっこよく、よい練習のように見えるが、有用かは分からない。実行中に設定を確認したり変更したりできる、Fisher-PriceのおもちゃのようなUXでもあるのか気になる
    起動とは、ミニミーモード、シングルユーザーモード、リカバリーモードから飛び立つ状態へ移る過程だ
    Xenix/DOS時代からMicrosoft製品と一緒にUnixを使ってきて、およそ40年くらいになると思う。その間にどれほど進歩したのか疑問だ
    Linuxもスウェーデン版、つまり最初のリリースから使っていて、GNU 0.1も試した
    XenixをUnixと呼んだことは謝る。Xenixは発売直後から衰退するまで、すでに過去のものになりたがっていた二番煎じのごちゃごちゃした製品だった
    Microsoftは製品をリリースする会社ではなく、顧客の上に猫のトイレをひっくり返す会社のようだ。最近の例はCopilotと22H2だ
    F1カー、鉛筆、ポケット電卓がどう進化したかを見ると、私たちは使える理想形にどれだけ近いのか気になる
    なぜブートローダーが静的カーネルモードではないのかも疑問だ。昔はそうだったし、最近誰かがまたそうすべきだと提案していて、私も同意した

  • https://wiki.osdev.org/A20_Line

  • CPUを正しいモードに切り替えるために必要な手順が、どれも不要に見えて驚き。ほとんどが後方互換性のために必要な手続きのように見える
    Intelは最初から正しいモードで起動するフラグや命令を用意できなかったのか、あるいは後方互換性を取り除けなかったのか気になる
    ARM64にも似た問題が一部あったと記憶している。そもそも64ビットとして設計され、後方互換性が不要で、デフォルトで望ましい状態に入るCPUがあるのか気になる。Itaniumの目標や設計がそういうものだったのかもしれない

    • Intelが提案したX86Sはその目的のもの

      X86S is a legacy-reduced-OS ISA that removes outdated execution modes and operating system ISA.
      The presence of the X86S ISA is enumerated by a single, main CPUID feature LEGACY_REDUCED_ISA in CPUID 7.1.ECX[2] which implies all the ISA removals described in this document. A new, 64-bit “start-up” interprocessor interrupt (SIPI) has a separate CPUID feature flag.
      [0] https://cdrdv2.intel.com/v1/dl/getContent/776648 [PDF注意]

    • Intelは80376でそれを試みたが、うまくいかなかった: https://en.wikipedia.org/wiki/Intel_80376
      Itanium、つまりItanicも同じだった
      後方互換性こそが、ARM、MIPS、RISC-Vなどではなくx86を選ぶ核心的な理由。残念ながら、IntelとAMDの一部の人たちはこれをよく分かっていないようだ
    • UEFIがすでにある。パーティションのフォルダにWindows風のバイナリを置けば、64ビットモードのホスティング環境で実行される。もちろん、こうしたことを代わりに処理してくれるブートローダも無数にある
    • arm64で何が問題なのか分からない
  • 素晴らしいプロジェクト。ここでUEFI支持者たちが、なぜわざわざ新しいブートローダ方式を作ったのか不思議に思うなら、人々がこういう作業をする理由を見落としているのだと思う
    筆者が最後に書いた「ここまでついてきたなら素晴らしい」という言葉どおり、本当に素晴らしい

  • UEFIが登場してからどれくらいたったのかと思う。ロングモードと一緒にBIOSも廃止してくれればよかったのに、惜しい

    • BIOSはすでに廃止予定。新しいマザーボードでその機能は基本的にUEFIを通じてエミュレーションされており、拡張もされていない
      廃止予定というのは削除済みという意味ではなく、削除を目標として、もはや更新や開発をしないという意味
  • この起動手順がEFI/UEFIでも動作するのか気になる。動作するなら、UEFIの監督側がリアルモード、プロテクトモード、ロングモードへの切り替えをエミュレートするのか、それとも実際のハードウェア上で行うのかも気になる

    • 違う。UEFIファームウェアは、レガシーBIOS環境、つまり実アドレスモードとはまったく異なる環境をUEFIブートローダに提供する。現代のシステムのUEFIファームウェアは直接64ビットロングモードに入り、フラットメモリモデルのGDTとアイデンティティマッピングのページングも設定する
      趣味OS向けUEFIブートローダを作る過程をここに書いておいた: https://0xc0ffee.netlify.app/osdev/05-bootloader-p1.html
  • これがARMではもっと単純なのか気になる

    • ボードメーカーごとに好き勝手にやる部分があるという意味では単純。ボードメーカーにとっては単純だが、それ以外の全員にとってはひどく複雑になる
    • その通り。ブートローダは依然として複雑だが、必要なレガシー設定はより少ない。ただしBIOSではなくUEFIをターゲットにすれば、x86でもずっと単純になる
    • 確信はないし期待もしていない。今RISC-Vを深く掘っているが、そこには希望がありそうだ