64ビットモード向けBIOSブートローダーの作成
(thasso.xyz)- 512バイトのブートセクタから始めて、x86_64 CPUを16ビットreal modeから64ビットlong modeへ移行させる最小ブートローダーの流れを段階的に構成する
- BIOS割り込みでまず出力を確認し、その後
nasm、ld、objcopy、QEMUを組み合わせて、ブートイメージが実際に実行されるかを検証する - ブートセクタの容量制限のため、stage 1/stage 2に分割し、protected modeへ入る前にBIOS
int 0x13で次のコードをディスクから読み込む - 32ビットprotected mode以降はBIOSルーチンが使えないため、GDT、flat segmentation、VGAバッファへの直接出力といったハードウェア初期化が必要になる
- 64ビットlong modeへの移行には、ページテーブル、PAE、
EFER.LME、cr0.PG、64ビット向けGDTをすべて正しく設定する必要があり、その後はfreestandingなCコードをカーネルのように呼び出せる
BIOSから始まる16ビット環境
- x86 CPUはリセット後、real modeにあり、基本オペランドサイズは16ビットである
- real modeはsegmentationによって20ビットのアドレス空間を作り、最大1MBのメモリを扱える
- BIOSが最初に実行するコードは、ディスクのブートセクタに入っている
- BIOSは先頭セクタが
0xaa55マジックナンバーで終わるディスクを探す - そのセクタをメモリアドレス
0x7c00に読み込む
- BIOSは先頭セクタが
- BIOSが渡してくれる領域は512バイトしかないため、このコードは残りのブートローダーを読み込むbootstrapの役割に集中する
- BIOSルーチンはreal modeにとどまっている間だけ利用できる
準備物とビルド環境
- 試すには Intel 64 and IA-32 Architectures Software Developer’s Manual、assembler、QEMUが必要
- 例で使うassemblerは nasm で、x86 assemblyとなasm文法を理解している前提で進む
- x86_64 CPUがなくても、QEMUでx86 CPUをエミュレーションして実行できる
- 32ビットモードまでの基礎資料として Writing a Simple Operating System — from Scratch を参照している
ブートセクタを作って実行を確認する
- 最初のブートセクタはBIOSルーチンで
"Hello, world!"を出力した後、hltとループで停止する - 文字列出力にはBIOS video services呼び出しの
int 0x10とah = 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基準で配置**されるよう設定するMEMORYのboot_sectororiginは0x7c00、lengthは512.bootsignセクションは0x7c00 + 510位置に0x55、0xaaを追加する
- ブートセクタ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値に意味がなくなるため、
ds、ss、es、fs、gsを新しいdata segment selectorに設定する - すべての設定後にinterruptを再び有効化するには追加作業が必要になる
BIOSなしで画面に出力する
- protected modeではBIOSルーチンをもう呼び出せない
- 文字列出力はVGA bufferへ直接書き込む方式に切り替わる
print_string32は0xb8000アドレスに文字と色属性バイトを書き込む- 色の値は
0xf - 各文字セルは2バイトを使う
- 色の値は
- この出力関数は非常に単純で、メッセージは常に画面左上に表示される
long mode移行のためのページテーブル
- Intel文書のIA-32e modeはAMD64 manualのlong modeに相当する
- long modeへ切り替えるには、CPUがprotected modeにあり、pagingも有効化されていなければならない
- pagingの概念については Introduction to Paging と OSTEP を参照している
- 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を設定する
- pageサイズは
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を有効化する0xc0000080MSRを読み、EFER.LME、つまりbit 8を設定するcr0のbit 31であるPGflagを設定して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 buffer0xb8000をクリアし、"Hello from C"を出力する- assembly側の
start_long_modeは64ビット向け文字列出力の後、extern _start_kernelを宣言して_start_kernelを呼び出す - linker scriptはメモリ領域を3つに分ける
boot_sector:0x7c00, length512stage2:0x7e00, length512kernel:0x8000, length0x10000
.text、.data、.rodata、.bssセクションはkernel領域に配置されるMakefileはassemblyとCの両方をビルドするよう変更される- C compilerは
gcc - 主なCFLAGSは
-std=c99 -ffreestanding -m64 -mno-red-zone -fno-builtin -nostdinc -Wall -Wextra
- C compilerは
- 全体のサンプルコードは ダウンロードリンク で提供されている
1件のコメント
Hacker News の意見
プロテクトモードを経由しなくても、はるかに少ないコードで直接ロングモードに入れる: https://wiki.osdev.org/Entering_Long_Mode_Directly
この方法で作った小さな64ビットカーネル用ブートローダーがあり、ディスクからカーネルを読み込み、VESAモードを設定するコードまで含めてもブートセクタに十分収まっていた。第2段階ローダーも不要だった
実際の現代的なハードディスクを使うなら、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はフラットバイナリ出力をサポートしているが、それを使うのはあまりに「ハックっぽい」と見たのだろう
以前、自分の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の目標や設計がそういうものだったのかもしれない
Itanium、つまりItanicも同じだった
後方互換性こそが、ARM、MIPS、RISC-Vなどではなくx86を選ぶ核心的な理由。残念ながら、IntelとAMDの一部の人たちはこれをよく分かっていないようだ
素晴らしいプロジェクト。ここでUEFI支持者たちが、なぜわざわざ新しいブートローダ方式を作ったのか不思議に思うなら、人々がこういう作業をする理由を見落としているのだと思う
筆者が最後に書いた「ここまでついてきたなら素晴らしい」という言葉どおり、本当に素晴らしい
UEFIが登場してからどれくらいたったのかと思う。ロングモードと一緒にBIOSも廃止してくれればよかったのに、惜しい
廃止予定というのは削除済みという意味ではなく、削除を目標として、もはや更新や開発をしないという意味
この起動手順がEFI/UEFIでも動作するのか気になる。動作するなら、UEFIの監督側がリアルモード、プロテクトモード、ロングモードへの切り替えをエミュレートするのか、それとも実際のハードウェア上で行うのかも気になる
趣味OS向けUEFIブートローダを作る過程をここに書いておいた: https://0xc0ffee.netlify.app/osdev/05-bootloader-p1.html
これがARMではもっと単純なのか気になる