https://Takym.GITHUB.IO/

自分で作成したリポジトリの情報を集約しています。また、日本人向けの情報技術資料の収集を目指しています。

たかがブートローダー。されどブートローダー。最後まで読めばブートローダーの意外な奥深さを知る事ができます。この記事では、x86 系の Legacy BIOS 上で動作するブートローダーを開発します。ブートローダーは、コンピュータに電源が入った時に、BIOS によって呼び出され、オペレーティング・システム(OS、Operating System)を起動する責務を負います。

ブートローダーの開発【x86 系の Legacy BIOS】

Copyright (C) 2026 Takym.

たかがブートローダー。されどブートローダー。最後まで読めばブートローダーの意外な奥深さを知る事ができます。

目次

概要

この記事では、x86 系の Legacy BIOS 上で動作するブートローダーを開発します。ブートローダーは、コンピュータに電源が入った時に、BIOS によって呼び出され、オペレーティング・システム(OS、Operating System)を起動する責務を負います。

完成品のソースコードは「https://github.com/Takym/primers/blob/kncs/2026-03-02/src/KernelNuclearCoreShell/boot」にて MIT ライセンスで配布しています。尚、新しいバージョンについては MIT ライセンスで公開しないかもしれません。

基本概念

Windows や Linux 等の OS を起動できるコンピュータは、IBM PC/AT 互換機とよく呼称されています。IBM PC/AT 互換機の中央処理装置(CPU、Central Processing Unit)には、Intel 社や AMD 社の x86 系が採用されています。x86 系の命令セットを調べる時は、英語で書かれていますが「Intel® 64 and IA-32 Architectures Software Developer Manuals」を推奨します。「Instruction Set Reference」を探して PDF ファイルをダウンロードしてください。また、この記事で用いるアセンブリ言語は NASM ですが、他の言語での開発にも挑戦して頂いて構いません。アセンブリ言語とは、機械語の命令と一対一に対応している人間可読なプログラミング言語です。機械語は、CPU が直接的に解釈する事のできる言語を指します。アセンブリ言語のコンパイラはアセンブラと呼ばれる事が多いです。基本的には、Windows で開発する事を想定していますが、NASM が動作する環境であれば、他の OS でも構いません。この記事で開発するブートローダーは、Legacy BIOS を前提としますが、実はこれは古い技術ですのでご注意ください。近年では UEFI BIOS が普及しています。BIOS は、Basic Input/Output System の略で、基本的な入出力機能をブートローダーに提供するものです。「Legacy」には「遺産」などという意味合いがあります。一方、UEFI は、Unified Extensible Firmware Interface(統一拡張可能ファームウェアインターフェース)の略です。この記事では UEFI については説明しません。

起動ディスクの判定とブートローダーの読み込み

コンピュータに電源が入ると、マザーボード上のメモリから BIOS のプログラムを呼び出し、Power On Self Test(POST)と呼ばれる自己診断処理や初期化処理を実行します。必要な処理が終わると、今度は起動ディスクを捜索します。各ディスクの先頭の 1 セクタを読み取ります。1 セクタは 512 バイトです(機種や環境によっては別の大きさとなる場合もあります)。セクタ内の最後の 2 バイトを確認します。0xAA55 と一致した場合に、起動ディスクと見做します。この数値はブート署名やブートフラグなどと呼ばれます。複数の起動ディスクを発見した場合は、利用者が設定した優先順位に従って、一つの起動ディスクを決定します。起動ディスクの先頭セクタ(MBR、Master Boot Record)は、メモリ上の 0x7C00 へ転送されます。中途半端な位置だと思うかもしれませんが、歴史的に紆余曲折あってこの値に決められました。詳しくは「https://msakamoto-sf.github.io/oldwebarchive/glamenv-septzen/view-614.html」が参考になります。MBR の転送が終わると、BIOS は 0x7C00 に制御を移します。ブートローダーは、MBR に格納されるのです。

MBR の形式

実は、MBR には複数の種類があります。ここでは代表的な二つの形式を簡易的に紹介します。

今回はどちらの形式にも柔軟に対応できる様にします。前述の通り MBR はたった 512 バイトしかありませんので、工夫が必要になります。

実装

NASM の基本

まずは NASM の基本を説明します。

ラベルの貼り方を説明します。

aaa:
bbb:
ccc:

英数字または下線(ただし、一文字目は数字にはできません)からなるラベル名の後ろにコロン(:)を付けます。コロンは省略できる場合もあります。

ドット(.)を付ける事で親子関係を作る事ができます。

parent:
.child1:
.child2:
.child3:

parent の外側で参照するには parent.child1 等と書かなければなりません。内側では parent の部分は省略できます。同一名称のラベルを複数回定義できる事になりますので、便利な機能です。

ラベルを値として用いると、そのラベルのアドレスが代入されます。

コメントはセミコロン(;)の後ろに書きます。

; The quick brown fox jumped over lazy dogs.

定数を宣言するには 定数名 EQU 値 と書きます。例えば、メモリ上の MBR の開始アドレス 0x7C00ADDR_MBR と名付けるには、次の様に書きます:

ADDR_MBR EQU 0x7C00

メモリ上の開始アドレスをアセンブラに伝えるには ORG 擬似命令(CPU 命令ではなくアセンブラへの命令)を使います:

ORG ADDR_MBR

特定のアドレスまで 0 を埋めるには次の擬似命令を使います:

TIMES	0xXXXX - ($ - $$) DB 0x00

TIMES 擬似命令は、指定した回数だけ指定した命令を出力させる命令です。0xXXXX にファイル内におけるアドレスを入れます。$ は現在の命令がメモリ上のどの位置にあるかを表し、$$ はメモリ上の開始位置を表しています。0xXXXX から $ - $$ を引く事で、命令を繰り返す回数を求める事ができます。DB 0x00 は 1 バイトの 0 を出力する擬似命令です。DB 擬似命令は、1 バイトの数値を出力する他に、1 文字あたり 1 バイトとなる文字列を出力する機能も有します。

実は、x86 系の CPU は、起動時は 16 ビットモードで動作しています。x86 系の CPU が発売された当初は 16 ビットしか対応していなかった為に、現代でも互換性を維持するには 16 ビットモードで起動する必要があったのです。BITS 擬似命令を用いて次の様に記述します:

BITS 16

雛形

以上を踏まえて、ブートローダーの雛形を NASM で書きます。

先程説明した様に、MBR には、ブートローダーだけではなく BPB やパーティションテーブルも配置されます。この部分にはブートローダーのプログラムが書き込まれない様にしなければなりません。また、ブートローダーは、初期プログラムローダー(IPL、Initial Program Loader)と呼称される事もあり、プログラム上では IPL と呼称する事にします。

ADDR_MBR EQU 0x7C00 ; MBR の番地

MBR:
	BITS	16
	ORG		ADDR_MBR
	JMP		.IPL
	NOP

.BPB: ; BIOS Parameter Block
	; 取り敢えず空に設定する。
	TIMES	0x005A - ($ - $$) DB 0x00

.IPL: ; Initial Program Loader
.END:
	HLT          ; CPU 停止
	JMP		.END ; 無限ループ

	TIMES	0x01BE - ($ - $$) DB 0x00

.PT: ; Partition Table
	; 取り敢えず空に設定する。
	TIMES	0x01FE - ($ - $$) DB 0x00

.BOOT_SIGN:
	DW		0xAA55

BPB は先頭の 3 バイトが予約されており、この領域を使って JMP 命令で .IPL へ移る事ができます。JMP .IPL は 2 バイトになりますから、1 バイト命令の NOP を付けています。

BPB やパーティションテーブルには何も設定していません。外部ツールなどで上書きしても良いでしょう。

ブート署名を出力する為に使っている DW 擬似命令は、2 バイトの数値を出力するものです。代わりに DB 擬似命令を使って DB 0x55, 0xAA とする事もできます。順序が逆になるのは、x86 系がリトルエンディアンを採用しているからです。

TIMES 擬似命令で指定しているファイル内におけるアドレスは十六進数で書かれています。以下はラベルとアドレスの対応表です。先頭の 0x は省きます。

ラベル 十六進数 十進数 補足
.BPB 5A 90 BPB の大きさと一致します。
.PT 1BE 446 ブート署名の位置から 64 を引いた値です。
.BOOT_SIGN 1FE 510 512 バイトから 2 バイトを引いた値です。

尚、.PT.BOOT_SIGN の場合、正確なアドレスをラベルに設定する為に、TIMES 擬似命令はラベルの直前に置いています。

この雛形は、起動直後に CPU を停止し、何らかの原因で処理が再開した時は、再び CPU を停止する様にしています。つまり、何もしていません。

レジスタの初期化

Legacy BIOS による初期化を過信するのではなく、幾つかのレジスタは明示的に初期化します。

尚、この記事では x86 系の CPU の 16 ビットモードで使用できるレジスタについて詳しく説明しませんので、ご了承ください。

.IPL:
	CLI                  ; 割り込み禁止
	XOR		AX, AX       ; AX = 0
	MOV		ES, AX       ; ES = 0
	MOV		SS, AX       ; SS = 0
	MOV		DS, AX       ; DS = 0
	MOV		SP, ADDR_MBR ; スタック位置を ADDR_MBR に再設定
	STI                  ; 割り込み許可
	CLD                  ; アドレスの増減方向の設定(常に加算モード)
	MOV		BP, SP       ; BP = SP

XOR 命令は、排他的論理和を演算する命令ですが、同じ数値に対して使うと 0 になる性質があります。これを活かして XOR AX, AX とする事で AX レジスタに 0 を代入しています。この方が微妙に効率が良いらしいです。AX レジスタは、16 ビットの汎用レジスタです。

ESSSDS はセグメントレジスタと呼ばれるもので、何れも 16 ビットです。16 ビットモードでは、セグメントレジスタの値を 16 倍した値に、オフセットとなる値を加える事で、アドレスを 20 ビットで表現しています。つまり、1 MB までしか扱う事ができません。ES レジスタはプログラマが用途を決める事ができます。Legacy BIOS の API の引数として使われる事もあります。API とは、Application Programming Interface の略で、プログラム同士の通信(ネットワークに接続しないものも含む)の手順や規約を定めたものです。SS レジスタは、スタック領域のセグメントを指定するレジスタです。DS レジスタは、データ領域のセグメントを指定するレジスタです。セグメントレジスタには直接的に値を設定する命令が用意されていませんので、AX レジスタを介して設定しています。

SP レジスタはスタック領域のアドレスを指定する 16 ビットのレジスタです。雑な式で書くと、(SS * 16) + SP がスタックの現在位置となります。尚、セグメントとアドレスをまとめて SS:SP などと書かれる事もあります。スタックを確保するには、SP の値を減らし、破棄するには、SP の値を増やします。ADDR_MBR に設定しているのは、スタックの確保に伴って、ブートローダーのプログラムが上書きされない様にする為です。スタックの確保や破棄によって最初のスタック位置が失われない様に BPSP の値をコピーしています。BP レジスタは、16 ビットの汎用レジスタですが、基本的にアドレスを保持する役割を持ちます。

CLI 命令と STI 命令は割り込みの可否を設定する命令です。セグメントレジスタの初期化中は外部機器からの通信を遮断しています。割り込み禁止期間を短くする為に MOV BP, SPSTI 命令よりも後に呼び出していますが、どちらでも構いません。逆に XOR AX, AXCLI 命令より先に呼び出しても特に問題無いでしょう。CLD はアドレスの増減方向を加算モードに設定する命令です。

ドライブ情報の取得&保存

次に、Legacy BIOS の API を呼び出してドライブ情報を取得し、スタックに保存します。ここで確保するスタックは、破棄しなければ、二次ローダーからでもアクセスできます。

取得と保存を行うコードの全体を示します。尚、新たな定数を定義していますが、具体的な値は後程示します。

	SUB		SP, -IDX_DTABLE_ADDR       ; ドライブ情報用のスタックを確保
	MOV		[BP + IDX_DRIVE_NUM], DL   ; ドライブ番号保存
	MOV		AH, 0x08                   ; ドライブ情報を取得する
	INT		0x13                       ; BIOS 関数呼び出し
	JC		.FAIL                      ; 失敗した時
	MOV		AL, CL                     ; セクタ数の取得処理
	AND		AL, 0x3F                   ; セクタ数の取得処理
	SHR		CL, 6                      ; シリンダ数の取得処理
	ROR		CX, 8                      ; シリンダ数の取得処理
	INC		CX                         ; シリンダ数の取得処理
	INC		DH                         ; ヘッド数の取得処理
	MOV		[BP + IDX_CYLN_CT    ], CX ; シリンダ数を保存
	MOV		[BP + IDX_HEAD_CT    ], DH ; ヘッド数を保存
	MOV		[BP + IDX_SECT_CT    ], AL ; セクタ数を保存
	MOV		[BP + IDX_DTABLE_SEGM], ES ; ディスクベーステーブルのセグメントを保存
	MOV		[BP + IDX_DTABLE_ADDR], DI ; ディスクベーステーブルのアドレスを保存

このコードは「レジスタの初期化」から続けて書いてください。

今回はスタックを減算命令で確保していますが、処理的には加算命令でも問題ありません。減算に確保、加算に破棄の意図を持たせる事で、コードを分かり易くしています。マイナスの符号はアセンブラが処理しますので、符号反転の為の命令が実機で実行される事は無く、加算でも減算でも効率に違いはありません。IDX_DTABLE_ADDR は最後の項目への相対アドレスです。これをドライブ情報全体の大きさとして流用しています。相対アドレスとは、特定のアドレスからの距離であり、今回の場合では ADDR_MBR = 0x7C00 からの距離です。一方、メモリの先頭からの距離は絶対アドレスと呼ばれます。

Legacy BIOS はブートローダーを起動する前に DL に起動ディスクのドライブ番号を設定しています。DL は、8 ビットの汎用レジスタであり、DX レジスタの下位 1 バイトです。DX レジスタの上位 1 バイトは、DH です。幾つかのレジスタには上位や下位に直接アクセスする事ができます。ドライブ番号は、MOV 命令を使ってメモリ上の BP + IDX_DRIVE_NUM に保存しています。BP には SP の元の値 ADDR_MBR = 0x7C00 が格納されています。

ドライブ情報を取得するには、AH レジスタ(AX レジスタの上位 1 バイト)に 0x08 を設定し、DL レジスタにドライブ番号を設定して INT 0x13 を呼び出す必要があります。起動ディスクの情報が欲しいので、DL レジスタの値は初期値のままにしています。ドライブ情報はレジスタに設定されます。

ドライブ情報の取得に失敗した場合は .FAIL に跳ぶようにしています。.FAIL の処理については後述します。

ドライブ情報にあるディスク容量は、シリンダ・ヘッド・セクタ方式(CHS、Cylinder-Head-Sector)で表されています。1 セクタは前述した通り 512 バイトです。シリンダやヘッドの大きさはディスク毎に異なります。ディスクの最大容量は、$\text{シリンダ数} \times \text{ヘッド数} \times \text{セクタ数} \times 512$ バイトです。例えば、1440 KB のフロッピーディスクの場合、シリンダ数は 80、ヘッド数は 2、セクタ数は 18 になります。2880 KB の場合、シリンダ数とヘッド数は 1440 KB の場合と同じになり、セクタ数だけ 36 になります。

ドライブ情報を取得する Legacy BIOS の API は、シリンダ数・ヘッド数・セクタ数ではなく、シリンダ番号・ヘッド番号・セクタ番号の最大値を返します。シリンダ番号とヘッド番号は 0 始まりですが、セクタ番号は 1 始まりになります。従って、シリンダ数とヘッド数を得るには、シリンダ番号とヘッド番号に 1 を加算する必要があります。1 を加算するには INC 命令を用います。最大セクタ番号はそのままセクタ数になります。

シリンダ番号とセクタ番号は、16 ビットの CX に格納されます。シリンダ番号は 10 ビットで表され、セクタ番号は 6 ビットで表されています。これは少ない桁数で効率良くシリンダ番号とセクタ番号を表す先人達の知恵です。セクタ番号は、CX レジスタの下位 1 バイトを表す CL レジスタの値と 0x3F の論理積を取る事で得られます。0x3F は二進数で 00111111 となります。結果は AL レジスタ(AX レジスタの下位 1 バイト)に格納しています。

	MOV		AL, CL
	AND		AL, 0x3F

CL レジスタにはシリンダ番号の上位 2 ビットも格納されています。CL レジスタを 6 ビット分だけ右方向に論理シフトし、回転シフトを用いて上位 8 ビットを下位に持って来ています。

	SHR		CL, 6
	ROR		CX, 8

これでシリンダ番号とセクタ番号が取得できました。ヘッド番号は DH レジスタに格納されています。

ES:DI にはディスクベーステーブルへのアドレスが格納されています。筆者もこの辺の事情はよく分かっていませんので、詳しい説明は省きます。

取得したドライブ情報の保存先は、メモリ上の次のアドレスにしています。これらの値は ADDR_MBR = 0x7C00 からの変分です。

IDX_DRIVE_NUM   EQU -1 ; ドライブ番号
IDX_CYLN_CT     EQU -3 ; シリンダ数
IDX_HEAD_CT     EQU -4 ; ヘッド数
IDX_SECT_CT     EQU -5 ; セクタ数
IDX_DTABLE_SEGM EQU -7 ; ディスクベーステーブルのセグメント
IDX_DTABLE_ADDR EQU -9 ; ディスクベーステーブルのアドレス

この記述は ADDR_MBR EQU 0x7C00 の直後くらいに書いてください。

文字列を出力する関数

Legacy BIOS の API を呼び出して画面に文字列を出力する関数を実装します。これは .FAIL の実装で使います。また、二次ローダーから呼び出す事もできます。.BPB.PT.BOOT_SIGN の位置関係が変わらなければ、どの場所にでも配置する事ができます。TIMES 0x01BE - ($ - $$) DB 0x00 の直前に置くのが妥当でしょう。SI レジスタ(16 ビットの汎用レジスタ)に、文字列のアドレスを引数として指定し、NULL 文字('\0')に到達するまで出力する事にします。値は何も返しません。

まずは、PUSH 命令を用いて AXBX の値をスタックに退避します。どちらも 16 ビットの汎用レジスタです。

.PRINT:
	PUSH	AX ; AX の値をスタックへ退避
	PUSH	BX ; BX の値をスタックへ退避

文字を一つだけ出力するには、AH0x0E を設定します。文字色とページ番号は、BX レジスタで制御します。既定のままで良いでしょう。

	MOV		AH, 0x0E ; 文字を一つだけ表示する
	XOR		BX, BX   ; 文字色とページ番号に 0 を指定

次に文字列の出力処理の本体を解説します。

.PRINT_PUT:
	LODSB              ; 次の文字を読み込む
	CMP		AL, 0x00   ; NULL 文字判定
	JE		.PRINT_END ; 終端を検出したら終了
	INT		0x10       ; BIOS 関数呼び出し
	JMP		.PRINT_PUT ; 次の文字へ

LODSB 命令を呼び出すと、SI レジスタが指し示すメモリ上の値を AL レジスタに転送します。SI は、現在のアドレスの増減方向に応じて、1 が加えられるか引かれます。先程、アドレスの増減方向を加算モードに設定したのはこの為です。

メモリから取得した出力すべき文字が NULL 文字である場合は、文字列の出力を停止します。

文字を出力する Legacy BIOS の API は INT 0x10 です。これの呼び出しが終わったら次の文字の出力に移ります。

文字列の出力処理が全て完了したら、POP 命令を用いてスタックから AXBX の値を復元し、RET 命令で .PRINT の呼び出し元に返ります。

.PRINT_END:
	POP		BX ; BX の値をスタックから復元
	POP		AX ; AX の値をスタックから復元
	RET        ; 制御を呼び出し元へ返す

この関数は次の様に使います:

	MOV		SI, .MSG_DATA ; メッセージのアドレスを SI に設定
	CALL	.PRINT        ; .PRINT 関数呼び出し

メッセージは DB 擬似命令を用いて設定します:

; 表示するメッセージ
.MSG_DATA DB "Hello, World!!", 0x0D, 0x0A, 0x00

失敗終了

ブートローダーの処理が失敗した時は、エラーメッセージを表示し、そのまま無限ループする様にします。電源を切らないのは、人間がエラーメッセージを確認できる様にする為です。

.FAIL:
	MOV		SI, .MSG_FAIL ; エラーメッセージ取得
	CALL	.PRINT        ; エラーメッセージ表示
.END:
	HLT                   ; CPU 停止
	JMP		.END          ; 無限ループ

.IPL の全ての処理が終わった直後に配置するのが良いでしょう。こうする事で、コードの書き誤りなどが原因で上手く処理が継続できない時等に自動的に .FAIL へ処理が移る事を期待しています。尤も、不具合によって .FAIL にさえ到達せず、不測の事態に陥る可能性を完全に排除する事はできませんが。先述の通りドライブ情報の取得に失敗した場合や、起動ディスクの読み込みに失敗し、二次ローダーを起動できない場合などに呼び出されます。

エラーメッセージは適当な場所に次の様に書いてください。

.MSG_FAIL DB "DISK SYSTEM ERROR", 0x00

長い文字列を設定すると MBR に入り切らなくなってしまう場合もあります。

ディスクからデータを読み込む関数

ディスクの読み込み処理も一つの関数にまとめてしまいましょう。

副記憶装置(ストレージ、Storage)が必ずしも「円盤状」であるとは限りませんので、本来は「ディスク」(Disk、Disc)ではなく「ドライブ」(Drive)と表現すべきですが、今回は慣例的に「ディスク」と呼ぶ事にします。

コードの配置場所は、文字列を出力する関数.PRINT)の場合と同様に、.BPB.PT.BOOT_SIGN の位置関係が変わらなければ、どの場所にでも配置する事ができます。特に拘りが無ければ、.PRINT の直後で良いでしょう。

使用するレジスタの元の値をスタックへ事前に退避しておきます。

.READ_DISK:
	PUSH	AX ; AX の値をスタックへ退避
	PUSH	BX ; BX の値をスタックへ退避
	PUSH	DX ; DX の値をスタックへ退避
	PUSH	SI ; SI の値をスタックへ退避
	PUSH	BP ; BP の値をスタックへ退避

ディスクからデータを読み込むには、Legacy BIOS の API にドライブ情報を指定する必要があります。起動ディスクのドライブ情報は、メモリに保存しましたので、それを使います。

BP レジスタの値は確実に ADDR_MBR にします。

	MOV		BP, ADDR_MBR ; BP = ADDR_MBR

この関数は、読み込むデータのディスク上の位置を引数として受け取っています。CX レジスタにシリンダ番号とセクタ番号が設定されています。この CX の値はそのまま Legacy BIOS のディスク読み込み API に渡されます。ヘッド番号は DH に格納されています。つまり、ドライブ情報取得 API とレジスタの使い方がほぼ似ている訳です。

ドライブ情報からセクタ数を取得し、その値からセクタ番号を差し引く事で、連続して読み込むセクタ数を割り出します。

	MOV		AL, [BP + IDX_SECT_CT] ; セクタ数取得
	MOV		BH, CL                 ; セクタ番号を BH に複写
	AND		BH, 0x3F               ; 上位 2 ビットにあるシリンダ番号を除去
	DEC		BH                     ; セクタ番号は 1 始まりなので減算して調整
	SUB		AL, BH                 ; 読み込むセクタ数からセクタ番号を引く

ドライブ番号はドライブ情報から取得します。

	MOV		DL, [BP + IDX_DRIVE_NUM] ; ドライブ番号設定

ディスク読み込み API は、ES:BX の位置にデータを展開しますので、BX に読み込み先のメモリ上のアドレスを設定します。この関数の呼び出し元で設定されたセグメント(ES レジスタの値)の先頭に読み込みたいので、BX の値はゼロにします。

	XOR		BX, BX ; BX に読み込み先のアドレスを設定

ディスク読み込み API の呼び出しに失敗しても再試行する事で成功する場合があります。試行回数は SI レジスタに保持する事にします。

	XOR		SI, SI ; 試行回数はゼロ

ディスクの読み込み処理は下記の通りです:

.READ_DISK_RETRY:
	MOV		AH, 0x02             ; ディスクからデータを読み込む
	INT		0x13                 ; BIOS 関数呼び出し
	JNC		.READ_DISK_END       ; 成功したら終了
	INC		SI                   ; 試行回数加算
	CMP		SI, ATTEMPTION_LIMIT ; 試行回数上限と比較
	JAE		.FAIL                ; 失敗した時
	MOV		AH, 0x00             ; ドライブ再設定
	INT		0x13                 ; BIOS 関数呼び出し
	JMP		.READ_DISK_RETRY     ; 再試行

Legacy BIOS のディスク読み込み API は、AH レジスタに 0x02 を設定し INT 0x13 を実行する事で呼び出せます。成功した場合は、.READ_DISK_END に跳びます。

失敗した場合は、SI レジスタを加算し、試行回数の上限と比較します。試行回数の上限は、適当に 10 回に設定しています。最低でも 3 回は試行した方が良いと筆者は考えています。尚、試行回数を増やし過ぎると起動に時間が掛かってしまいますので、ご注意ください。10 回以上失敗した場合は、.FAILを呼び出します。

ATTEMPTION_LIMIT EQU 10 ; 試行回数上限

AH レジスタに 0x00 を設定し INT 0x13 を呼び出すと、DL で指定したドライブを再設定されます。安全に倒して再試行前に一旦初期化しています。

最後に各レジスタの値をスタックから復元し、呼び出し元の文脈で処理を続行できる様にします。

.READ_DISK_END:
	POP		BP ; BP の値をスタックから復元
	POP		SI ; SI の値をスタックから復元
	POP		DX ; DX の値をスタックから復元
	POP		BX ; BX の値をスタックから復元
	POP		AX ; AX の値をスタックから復元
	RET        ; 制御を呼び出し元へ返す

起動ディスクの読み込み

起動ディスクの先頭の 1 セクタしか読み込まれていない状況では、複雑な処理は殆ど何もできません。起動ディスクから OS のプログラムを読み込んで、それを呼び出す必要があります。

ところで、シリンダ番号が x、ヘッド番号が y、セクタ番号が z である時の CHS 方式におけるディスク内の位置は Cx-Hy-Sz と表記する事にします。

ドライブ情報の取得&保存」の直後に次のコードを書いてください:

	MOV		AX, (ADDR_MBR + BYTES_PER_SECT) >> 4 ; AX に読み込み先のセグメントを計算
	MOV		ES, AX                               ; ES に読み込み先のセグメントを設定
	MOV		CX, 0x02                             ; 二番目のセクタ&最初のシリンダ
	MOV		DH, 0x00                             ; 最初のヘッド
	CALL	.READ_DISK                           ; ディスク読み込み

C0-H0-S2 以降のデータは MBR の直後に読み込みます。つまり、ディスクと全く同じ順序でデータをメモリ上に展開する訳です。先程実装した .READ_DISKを用いています。

1 セクタの大きさは多くの場合では 512 バイトとなるのですが、定数にしました。当然に分かり切っている値でも、定数名を与える事には利点があります。数値の意図をコードの読者へ伝える役割が期待されます。

BYTES_PER_SECT EQU 0x0200 ; 1 セクタ当たりのバイト数は 512

C0-H1-S1 以降を読み込むには工夫が必要です。シリンダ番号やヘッダ番号を正しく計算して設定するだけではなく、メモリ上のセグメントも意識しなければなりません。実は、Legacy BIOS のディスク読み込み API は、セグメントを超えて処理を継続する事ができません。これは 64 KB 境界などと呼ばれています。なんとセグメントレジスタの更新は自動的に行われず、再設定の責務は API の呼び出し元にあるのです。

以下、セクタ数を調整しながらディスクを読み込むコードです。

	MOV		AX, BYTES_PER_SECT >> 4 ; AX は 1 セクタ当たりのバイト数
	MOV		BH, 0                   ; BX の上位バイトはゼロ
	MOV		BL, [BP + IDX_SECT_CT]  ; BX の下位バイトはセクタ数
	MUL		BX                      ; DX:AX = AX * BX
	CMP		DX, 0                   ; DX と 0 を比較
	JNE		.FAIL                   ; 巨大なアドレスはエラー
	MOV		BX, AX                  ; 1 ループ毎にセグメントに加算する値を BX に複写
	ADD		AX, ADDR_MBR >> 4       ; AX に読み込み先のセグメントを計算
	MOV		ES, AX                  ; ES に読み込み先のセグメントを設定
	MOV		CX, 0                   ; 最初のシリンダ
	MOV		DH, 1                   ; ヘッド番号を 1 に設定
	MOV		DL, [BP + IDX_HEAD_CT]  ; ヘッド数を DL にキャッシュ
	MOV		SI, [BP + IDX_CYLN_CT]  ; シリンダ数を SI にキャッシュ
	CMP		SI, MAX_CYLN            ; シリンダ数の上限と比較
	JBE		.READ_NEXT              ; 上限以下ならそのまま読み込み開始
	MOV		SI, MAX_CYLN            ; 上限超過なら修正
.READ_NEXT:
	CMP		DH, DL                  ; ヘッド番号の比較
	JB		.ADJUST_64KB_BOUND      ; 最大ヘッド数未満なら読み込み処理開始
	MOV		DH, 0                   ; 最初のヘッドに戻る
	INC		CX                      ; シリンダ番号加算
	CMP		CX, SI                  ; シリンダ番号の比較
	JAE		.INVOKE_PL2             ; 二次ローダー起動
.ADJUST_64KB_BOUND:
	ADD		AX, BX                  ; AX に次の読み込み先のセグメントを計算
	PUSH	BX                      ; BX の値をスタックへ退避
	PUSH	AX                      ; AX の値をスタックへ退避
	MOV		BX, ES                  ; ES の値を BX に複写
	AND		BH, 0xF0                ; 今の読み込み先のセグメントの上位 4 ビット取り出し
	AND		AH, 0xF0                ; 次の読み込み先のセグメントの上位 4 ビット取り出し
	CMP		AH, BH                  ; 上位 4 ビットを比較
	POP		AX                      ; AX の値をスタックから復元
	JE		.CALL_READ_DISK         ; 一致しているならば 64 [KB] の境界を跨っていない
	MOV		BX, ES                  ; ES の値を BX に再複写
	AND		BH, 0x0F                ; BX の上位 4 ビットを除去
	NEG		BX                      ; BX を符号反転
	AND		BH, 0x0F                ; BX の上位 4 ビットを再度除去し、境界までの距離を計算
	XCHG	AX, BX                  ; AX と BX を交換
	PUSH	DX                      ; DX の値をスタックへ退避
	PUSH	CX                      ; CX の値をスタックへ退避
	XOR		DX, DX                  ; DX = 0
	MOV		CX, BYTES_PER_SECT >> 4 ; CX = BYTES_PER_SECT >> 4
	DIV		CX                      ; AX = DX:AX / CX, DX = DX:AX % CX
	CMP		DX, 0                   ; DX と 0 を比較
	JNE		.FAIL                   ; 細かいズレには未対応
	CMP		AX, 0x40                ; AX と 0x40 を比較
	JAE		.FAIL                   ; 巨大なセクタ数はエラー
	MOV		AH, [BP + IDX_SECT_CT]  ; セクタ数を AH へ退避
	MOV		[BP + IDX_SECT_CT], AL  ; 読み込むセクタ数をメモリに保存
	POP		CX                      ; CX の値をスタックから復元
	POP		DX                      ; DX の値をスタックから復元
	ROL		CX, 8                   ; シリンダ番号設定位置を調整
	SHL		CL, 6                   ; シリンダ番号を上位へ移動
	AND		CL, 0xC0                ; 最初のセクタ(セクタ番号の部分を 0 に初期化)
	OR		CL, 0x01                ; 最初のセクタ(セクタ番号を 1 に再設定)
	CALL	.READ_DISK              ; ディスク読み込み
	MOV		[BP + IDX_SECT_CT], AH  ; セクタ数を AH から復元
	INC		AL                      ; セクタ番号を調整
	AND		CL, 0xC0                ; セクタ番号の部分を 0 に初期化
	OR		CL, AL                  ; セクタ番号を AL に再設定
	MOV		AX, ES                  ; ES の値を AX に複写
	MOV		AL, 0                   ; AL = 0
	AND		AH, 0xF0                ; 上位 4 ビット取り出し
	ADD		AH, 0x10                ; AH += 0x10
	MOV		ES, AX                  ; 読み込み先のセグメントを再設定
	CALL	.READ_DISK              ; ディスク読み込み
	MOV		AX, BX                  ; AX の値を BX から復元
	POP		BX                      ; BX の値をスタックから復元
	JMP		.PREPARE_NEXT           ; 次の読み込みの準備
.CALL_READ_DISK:
	POP		BX                      ; BX の値をスタックから復元
	ROL		CX, 8                   ; シリンダ番号設定位置を調整
	SHL		CL, 6                   ; シリンダ番号を上位へ移動
	AND		CL, 0xC0                ; 最初のセクタ(セクタ番号の部分を 0 に初期化)
	OR		CL, 0x01                ; 最初のセクタ(セクタ番号を 1 に再設定)
	CALL	.READ_DISK              ; ディスク読み込み
.PREPARE_NEXT:
	SHR		CL, 6                   ; セクタ番号を除去し、シリンダ番号を下位へ移動
	ROR		CX, 8                   ; シリンダ番号設定位置を調整
	INC		DH                      ; ヘッド番号加算
	MOV		ES, AX                  ; ES に次の読み込み先のセグメントを設定
	JMP		.READ_NEXT              ; 次の読み込みへ移行する

コメントを読めば理解できると思いますので、今回はこの部分の詳細な説明は割愛させて頂きます。

MAX_CYLN は読み込むシリンダ数の上限です。筆者の環境では、12 番目のシリンダまでなら読み込む事ができましたので、その値に設定しています。どうやらディスク全体を読み込める訳では無い様です。

MAX_CYLN EQU 12 ; シリンダ数の上限は取り敢えず 12 とする

二次ローダーを起動するコードは、.BPB の直後に書いています。後から外部ツールで JMP 命令の跳躍先を書き換え易くする為です。起動処理をディスクの読み込み処理と別けて書かず、直接的に呼び出す様にしても構いません。

.INVOKE_PL2:
	JMP		PL2
	NOP

PL2 は、筆者が二次プログラムローダー(Secondary Program Loader)を略したものです。SPL は、レジスタSP レジスタの下位 1 バイト、64 ビットモードでのみ利用可能)の名称として既に使われていますので、ラベル名としては使う事ができません。ラベル名の先頭に数字を置く事はできませんので、末尾に付け加えました。

これでブートローダーは大方完成です。ソースコードの全体についてはこちらよりご確認ください。

改造ポイント

この記事で開発したブートローダーを改造できる箇所を紹介します。色々試してみてください。

ブートローダーは、OS を読み込んで起動するという単純な責務しかありませんが、実装方法に正解はありません。不具合や脆弱性が起きなければどの様に実装しても構わないのです。この記事を参考に、貴方にとって最高なブートローダーを是非つくってみてください!

ビルドスクリプトとリストファイル

機械語を生成するには、nasm コマンドを実行する必要があります。

mbr.asm をアセンブルし、mbr.bin を出力するには、次のコマンドを呼び出してください。

nasm mbr.asm -o mbr.bin -l mbr.lst -f bin

併せて mbr.lst という名称のリストファイルも出力されます。リストファイルは、ソースコードからどの様な機械語が生成されたのかを一行ずつ確認する為のファイルです。ブートローダーをデバッグする時は、目的の機械語が正しく出力されたのかも検証する必要があります。-f bin を付けると、ヘッダ等が付かない純粋な機械語のみが出力される様になります。

mbr.bin をディスクイメージファイルの先頭に書き込む必要がありますが、実は TIMES 擬似命令を使ってディスクイメージ全体をアセンブラに生成させるという裏技があります。詳しくは disk.asm をご参照ください。

disk.asm をアセンブルするには、次のコマンドを呼び出してください。

nasm disk.asm -o disk.vfd -l disk.lst -f bin

ソースコードを変更した都度、この長いコマンドを打ち込むのも大変でしょう。Windows の場合、テキストファイルにコマンドを貼り付け、拡張子を .bat.cmd にする事で、ビルドスクリプトとして使う事ができます。コマンドプロンプトでファイル名(拡張子は省略可)を打ち込む事で、楽に呼び出せます。例えば、build.cmd と名付ければ、44 文字が 5 文字に減ります。39 文字分も減らせます!

起動方法

作成したブートローダーを仮想環境で起動する方法を説明します。デバッグにお役立てください。

実機上で別のコンピュータを再現するソフトウェアは、仮想環境や仮想機械や仮想マシンやエミュレータなどと呼ばれます。仮想環境の外側の OS はホスト OS と呼ばれ、内側の OS はゲスト OS と呼ばれます。仮想環境は多層化させる事もできますので、ホスト OS は必ずしも実機上で実行されている訳ではありません。また、実は、ホスト OS を介さない仮想化技術もあり、その場合は狭義のハイパーバイザと呼ばれます。広義のハイパーバイザには、ホスト OS 上で実行されるエミュレータも含まれます。

今回は、QEMU と VirtualBox を紹介します。Hyper-V、VMware、Bochs など他のエミュレータを用いる場合はご自身でお調べください。

WSL + QEMU を用いる場合

  1. 仮想マシンの実行環境を準備します。
  2. WSL を起動し、作成したディスクイメージが含まれるディレクトリへ移動します。
    • ブートローダー(mbr.bin)ではなくディスクイメージ(disk.vfd)が格納されているディレクトリです。ご注意ください。
  3. 次のコマンドを実行してください:
    $ qemu-system-x86_64 -drive if=floppy,file=disk.vfd,index=0,media=disk -monitor stdio
    
    • qemu-system-x86_64 は、64 ビット版の x86 系 CPU のエミュレータです。AMD64 や Intel 64 や EM64T や IA-32e などと呼ばれる命令セットのエミュレータです。歴史的な事情で複数の名称があります。尚、IA-64 は x86 系とは互換性がありません。今回は、16 ビットモードしか使いませんが、64 ビット対応の仮想環境でも構いません。もし、32 ビット版しか起動できない場合は、代わりに qemu-system-i386 をお使いください。
    • -drive if=floppy,file=disk.vfd,index=0,media=disk は、フロッピーディスクを指定するオプションです。
      • if=floppy は、インターフェースがフロッピーディスクである事を指定します。
      • file=disk.vfd は、ディスクイメージのファイル名を指定します。
      • index=0 は、ディスクのポート番号に最初の番号を指定します。0 は、Windows における A ドライブの様なものです。
      • media=disk は、ディスクである事を指定します。cdrom にすると、CD-ROM(読み取り専用のコンパクトディスク)になります。
    • -monitor stdio を指定すると、対話型のデバッグモニターをホスト OS 側のターミナル上(コンソール画面上)で有効化できます。例えば、info registers と入力すれば、レジスタの中身を表示できます。詳しくは QEMU Monitor をご確認ください。
    • その他のオプションや、厳密な仕様、調整方法は、QEMU User Documentation(英語)をご覧ください。
  4. 起動すると、次の様に表示されます。(※尚、下記のスクリーンショット画像で表示されている起動情報は、pl2_playground.asm によるものです。二番目以降のセクタに pl2_playground.asm をアセンブルしたものを書き込んでいます。詳しい仕組みはソースコードをご覧ください。pl2_playground.asm を読み込ませない場合、何も表示されません。)
    QEMU 実行画面

VirtualBox を用いる場合

  1. https://www.virtualbox.org/ から VirtualBox をダウンロードし、インストールしてください。
    • 筆者の環境では、7.2.8 がインストールされています。
    • ウェブサイトは英語ですが、ソフトウェア自体は日本語にできます。
  2. VirtualBox を起動し、「新規(N)」ボタンを押下してください。新しい仮想マシンを作成します。
    VirtualBox ホーム画面
  3. 仮想マシンの名前とフォルダは、どの様に設定しても構いません。ディスクイメージファイルと同じフォルダにする必要もありません。ご自身で整理し易い様に設定してください。OS は「Other」、OS バージョンは「Other/Unknown (64-bit)」を選択してください。
    VirtualBox 仮想マシン作成画面
  4. 「無人ゲスト OS インストールの設定(U)」は初期設定のままにしてください。
  5. 次に「仮想ハードウェアを指定(A)」を開き、「EFI を使用(U)」のチェックが外されている事を確認してください。今回は EFI を使用しません。メモリや CPU のコア数は既定のままでも構いません。
    VirtualBox 仮想マシン作成画面
  6. 次に「仮想ハードディスクを指定(K)」を開き、「仮想ハードディスクなしで仮想マシンを作成(R)」を選択してください。このページではフロッピーディスクを指定できませんので、後ほど設定します。最後に「完了(F)」ボタンを押下し、仮想マシンを作成してください。
    VirtualBox 仮想マシン作成画面
  7. 左側のサイドバーから「仮想マシン」を選んでください。作成した仮想マシンが選択されている事を確認し、「設定(S)...」ボタンを押下してください。
    VirtualBox ホーム画面
  8. 設定画面の上部にある「高度」タブを選び、サイドバーから「ストレージ」を選んでください。ストレージの設定画面が表示されましたら、ストレージ一覧の真下にある最も左側のアイコン(下図の赤枠)を押下してください。フロッピーディスクを指定する為のコントローラーを追加します。
    VirtualBox 設定画面
  9. メニューから「I82078 (フロッピー)」(蛇足ですが、一文字目は、エルでもイチでもなく、大文字のアイです。Intel を意味しています。)を選んでください。
    VirtualBox 設定画面 - コントローラーメニュー
  10. 「コントローラー: Floppy」の右横にあるアイコン(下図の赤枠)を押下してください。フロッピーディスクを指定します。
    VirtualBox 設定画面
  11. 「追加(A)」ボタンを押下し、表示されたファイル選択ダイアログにてディスクイメージファイルを指定してください。ディスクイメージのファイル名は、この記事と同じ名称なら、disk.vfd となる筈です。ダイアログを閉じたら、追加したディスクイメージファイルが選択されている事を確認し、「選択(H)」ボタンを押下してください。
    VirtualBox 仮想ディスク追加画面
  12. disk.vfd の「フロッピー ドライブ(D)」属性に「フロッピーデバイス 0」を指定してください。「フロッピーデバイス 0」は、Windows における A ドライブの様なものです。
    VirtualBox 設定画面
  13. 設定画面を閉じたら、作成した仮想マシンが選択されている事を確認し、「起動(T)」ボタンを押下してください。
    VirtualBox ホーム画面
  14. 起動すると、次の様に表示されます。(※QEMU の場合と同様に、こちらでも pl2_playground.asm を読み込ませています。)
    VirtualBox 実行画面

osdev-jp の紹介

ブートローダーや OS 等の開発に行き詰った時に相談できるコミュニティがあります。osdev-jp には低レイヤプログラミングに詳しい方々が集っております。私も参加しております。osdev-jp の Discord では初心者向けの相談も行われています。この記事に疑問がありましたら、osdev-jp に参加する事を是非検討してみてください。参加するにはこちらの指示に従ってください。

尚、当サイトのメニューにある「Takym Server」とは運営元の異なるコミュニティですのでご注意ください。

勿論、このリポジトリの GitHub Discussions や Takym Server(しかし、現時点では、悲しい事に正式な参加者は私一人のみです)でも質問を受け付けていますが、osdev-jp には OS 開発の専門家も多く、そちらの方が確実に回答を得られるかと思います。

osdev-jp と似た名称の OSDev.org と呼ばれるコミュニティもありますが、こちらは英語圏の OS 開発のコミュニティです。双方に公式的な関係はありません。

その他の参考文献

この記事を執筆するにあたって、上記でリンクしているサイト以外で、参考にした書籍は「30日でできる! OS自作入門」と「作って理解するOS x86系コンピュータを動かす理論と実装」です。ブートローダーのその先の OS 開発について興味がある方は買ってみては如何でしょうか。

UEFI でブートローダーを開発するには、英語で書かれていますが、公式の仕様書が最も参考になります。UEFI に対応した OS 開発の書籍としては、「ゼロからのOS自作入門」がおすすめです。他にも以下のサイトが参考になるでしょう。

(改めて読み返してみると、なにやらこの記事は生成型 AI が書いた様な文体になっていますが、AI の手は借りず、自分自身で書きました。どうやら筆者は AI と対話を重ねる内に、知らず知らずの内に AI の影響を受けていた様です。以前のブートローダーの記事でも同じ様な事を述べていましたね。)