Legacy BIOSで32bit OSのブートローダを作る時のメモ

前回、BIOSの機能を使ってOS無しで画面に文字を表示させたのが楽しかった。
もう少しBIOSの機能を勉強する。

以下のアセンブリから理解をしていく。
ORG 0x7c00
BITS 16
start:
mov ah, 0eh
mov al, 'A'
int 10h
jmp $
times 510-($-$$) db 0
dw 0xaa55

GPT-4に聞いてみた。
各行の意味は以下のとおりです。
ORG 0x7c00
ORGはORiGinの略で、アセンブリ言語におけるプログラムがメモリ上のどのアドレスから実行されるかを指定する命令です。この例であれば、プログラムはメモリ内の0x7c00アドレスから開始されます。このアドレスは、一般的にPCがBIOSから制御を引き継いだときの起動セクタの開始アドレスです。
BITS 16
BITSディレクティブは、アセンブラの動作モードを切り替えます。BITS 16という指定で、この後に続くコードは16ビットモードで動作することを示しています。8086以降のx86アーキテクチャは当初16ビットをベースとしていたため、このモードではそのような互換モード下で動作します。
start:
ラベル"start"を定義しています。これはプログラムコード内の特定の位置を指します。この場合、このラベルはORG命令で指定されたアドレス、つまり、0x7c00に対応します。
mov ah, 0eh
mov al, 'A'
int 10h
1つ目のmov
命令は、16ビットレジスタAXの上位8ビット(AH)に値0x0Eを移動します。つまり、BIOSの文字表示サービス(Teletype出力)を指定します。2つ目のmov
命令は、下位8ビット(AL)にASCII文字'A'の値を設定します。int
命令は、ソフトウェア割り込みを発生させ、ここではBIOSの割り込みサービスを呼び出して画面に'A'を表示します。
jmp $
無限ループを作成します。$
は現在のアセンブリの位置を示す特別な記号です。この命令により、プロセッサはこの命令を永遠に実行し続けます。
times 510-($-$$) db 0
times
命令は指定した数だけ前の命令を繰り返します。この場合、510 - (現在のアドレス - プログラム開始アドレス)回だけ0を出力します。つまり、ブートセクタはちょうど512バイト(標準のディスクセクタサイズ)になるようにパディングするためのコードです。
dw 0xaa55
dw
はDefine Word(ワード定義)を意味します。これにより16ビットの値0xAA55が生成され、これがブートセクタの末尾に配置されます。この値は、PCがこれが有効なブート可能なセクタであると判断するためのマジックナンバー、ブートシグネチャと呼ばれます。

以下のコード以外の部分はBIOSのおまじないだと思う。
mov ah, 0eh
mov al, 'A'
int 10h
int 10hを実行した時に、
ah (= raxの下位8bit) に 0eh (=14)が代入されていれば、
al (=raxの下位8bitから下位16bit) に代入された文字 'A' を画面に表示する。
int 10hという命令はBIOSの機能として準備されている特殊な命令である。

以下に仕様が書いてある。

int 10h以外にもBIOSの機能として割り込み処理が用意されている。
int 13h : ディスクI/Oサービスへのインターフェース。
int 16h : キーボードサービスへのインターフェース。
int 1Ch : システムタイマー割り込み。

このサイトで調べられる。

上記のコードをnasm -f bin ./boot.asm -o ./boot.bin
でコンパイルする。
ndisasm boot.bin
で内容を確認する。
00000000 B40E mov ah,0xe
00000002 B041 mov al,0x41
00000004 CD10 int 0x10
00000006 EBFE jmp short 0x6
00000008 0000 add [bx+si],al
...
000001FC 0000 add [bx+si],al
000001FE 55 push bp
000001FF AA stosb

最初の8bytesが重要であることがわかる。

jmpの位置をstartに変更したら、Aが無限に表示された。
jmp start

Hello, World!
を表示するプログラム。
ORG 0x7c00
BITS 16
start:
mov si, message
call print
jmp $
print:
mov bx, 0
.loop:
lodsb
cmp al, 0
je .done
mov ah, 0eh
int 10h
jmp .loop
.done:
ret
message db "Hello, World!", 0
times 510-($-$$) db 0
dw 0xaa55
mov si, message
は、messageのメモリ位置をsiに格納する。
lodsb
は、siに格納された1bytesを取り出しalに格納し、その後siを1bytesずらす。
message db "Hello, World!", 0
は、db "Hello, World!", 0
という宣言にmessage
というラベル付けを行っている。db "Hello, World!", 0
はBIOS上に1bytesずつデータを並べているだけ。

バイナリで見ると命令とデータが一緒の部分においてあって不思議な気持ちになるが、これがノイマン型コンピュータということなんだろう。

16bit時代のCPUについて
BIOSではBITS 16
と宣言している。これはintel 8086の互換モードとしてCPUを動作させることを意味している。
これをリアルモードと言ったりする。

一つのレジスタに16bitしか保存できないと、65536個の値しか表現できない。
レジスタはメモリのアドレスの番地を指定するのに使う。
16bitでは、最大で65536 bytes = 64 KiBのメモリしか扱うことはできない。
intel 8086ではこの問題を解決するために、普通のレジスタの他にセグメントレジスタというものが用意された。メモリのアドレスを計算する場合は、普通のレジスタの値(この文脈でこの値のことをオフセットと呼ぶ)に、セグメントレジスタを16倍した値を足し、合計で20bitでメモリのアドレスを指定する。20bitあれば1MiBのアドレスを扱うことができる。(この辺の説明はwikiに詳しく書いてある。)

Intel 8086のレジスタ

AX, BX, CX, DX: 汎用レジスタ
SI, DI: 文字列操作用の特殊レジスタ
IP: 次のCPU命令のアドレスを保持する特殊レジスタ
BP, SP: ベースポインタとスタックポインタ
Flag: 前回のCPU命令の結果を示すフラグ

Intel 8086にはセグメントレジスタがある。それぞれ以下のように使う。
CS (Code Segment): IPと組み合わせて使う。次のCPU命令のアドレス位置のセグメントが保存される。
DS (Data Segment): 汎用レジスタと組み合わせて使う。
SS (Stack Segment): SPまたはBPと組み合わせて使う。
ES (Extra Segment): SIやDIと組み合わせて使うことが多い。

割り込みベクタテーブル

x86のリアルモードではメモリの最初の1KB(0x0000から0x03FF)が割り込みベクターテーブルとなっている。4Bytesごとに256個のエントリがあり、それぞれ割り込みに対応している。

割り込みベクターテーブルを定義してみる。
ORG 0
BITS 16
start:
jmp 0x7c0:step2
print_A:
mov ah, 0eh
mov al, 'A'
int 10h
iret
print_B:
mov ah, 0eh
mov al, 'B'
int 10h
iret
step2:
mov ax, 0
mov ss, ax
mov word[ss:0x00], print_A
mov word[ss:0x02], 0x7c0
mov word[ss:0x04], print_B
mov word[ss:0x06], 0x7c0
int 1
int 0
times 510-($-$$) db 0
dw 0xaa55
word は x86アセンブリ言語におけるデータサイズの指定子で、16ビット(2バイト)のデータを表す。

diskからメモリにデータを読み出す方法。
ah = 2でint 13を呼び出せばdiskからメモリにデータを読み出せるみたい。

プロテクトモード
Intel 80286以降のCPUに導入されているモード。メモリにアクセス権限を付与する機能。

Legacy BIOSのパソコンはリアルモードとして起動する。リアルモードからプロテクトモードに移行しないと、古いCPUの互換モードとしてしかパソコンが使えない。
移行のの仕方はいかに書いてある。
わからん。むずい。

クロスコンパイル環境のセットアップ
これまでM1 Macでやってたけど、qemuやgdbのデバッグ機能を使えないのでUbuntu Desktopでやる。
持っててよかったThinkpad X230。

エディタはvscode serverでMacから繋いでやる。Remote SSHでもいいかも。
デバッグの時だけThinkpadのキーボードを使う。

Ubuntu 22.04だと以下のコマンドを順番に実行していけば環境は整うと思う。
# Install dependencies
sudo apt install build-essential bison flex libgmp3-dev libmpc-dev libmpfr-dev texinfo libisl-dev
# Download source code
mkdir ~/src
cd ~/src
wget https://ftp.gnu.org/gnu/binutils/binutils-2.41.tar.gz
tar -xvf binutils-2.41.tar.gz
wget https://ftp.tsukuba.wide.ad.jp/software/gcc/releases/gcc-13.2.0/gcc-13.2.0.tar.gz
tar -xvf gcc-13.2.0.tar.gz
# Set up environment variables
export PREFIX="$HOME/opt/cross"
export TARGET=i686-elf
export PATH="$PREFIX/bin:$PATH"
# Compile binutils
cd $HOME/src
mkdir build-binutils
cd build-binutils
../binutils-2.41/configure --target=$TARGET --prefix="$PREFIX" --with-sysroot --disable-nls --disable-werror
make
make install
# Compile GCC
cd $HOME/src
# The $PREFIX/bin dir _must_ be in the PATH. We did that above.
which -- $TARGET-as || echo $TARGET-as is not in the PATH
mkdir build-gcc
cd build-gcc
../gcc-13.2.0/configure --target=$TARGET --prefix="$PREFIX" --disable-nls --enable-languages=c,c++ --without-headers
make all-gcc
make all-target-libgcc
make install-gcc
make install-target-libgcc
参考:

Legacy BIOSからカーネルを起動するのは難しかったので、Grubから起動させてみる。以下がとても参考になった。
日本語だと以下が参考になる。

リアルモードからプロテクトモードに移行してOSを起動する方法は、以下を読めば実装できそうではある。
とりあえずgrubから簡単に起動できることと、UEFIになった現代にこれをマスターするのは優先順位低いかな。ただ、Global Descriptor Tableは理解しといた方がいいかもしれない。

GDTの説明は以下にある。
まだわかってない。

x86でカーネルを0x100000に配置する理由。

リアルモードでは、セグメントレジスタとオフセットレジスタで合わせて20bitでしかアドレスを指定できない。その最大は0xfffffである。そのため、プロテクトモードに移行した時には、リアルモードの次のアドレスから利用する。
つまり、0xfffff + 1 = 0x100000
である。

dmesg
でメモリマップが確認できる。手元のx86_64のLinuxでは以下のようになっていた。
[ 0.000000] BIOS-provided physical RAM map:
[ 0.000000] BIOS-e820: [mem 0x0000000000000000-0x000000000009d7ff] usable
[ 0.000000] BIOS-e820: [mem 0x000000000009d800-0x000000000009ffff] reserved
[ 0.000000] BIOS-e820: [mem 0x00000000000e0000-0x00000000000fffff] reserved
[ 0.000000] BIOS-e820: [mem 0x0000000000100000-0x000000001fffffff] usable
[ 0.000000] BIOS-e820: [mem 0x0000000020000000-0x00000000201fffff] reserved
この中の0x100000
(1MB)から0x1fffffff
(512MB)の領域の一部がLinuxカーネルに使われていると思う。

x86のメモリマップ

ちなみに、画面に文字列を描画する際には、0xb8000
からのアドレスを用いる。
ハードウェアにマップされたメモリ領域で、16bitの時代からこのメモリが使われている。
カーネルを開発の際に、一番最初にHelloする際に用いる。