起動するまでの長い道のり IPL編(8) カーネル読み込み実装の巻

 前回でディスクからのデータ読み込み方法と、その時にぶつかる壁について解説した。今回はいよいよデータの読み込み処理を実装していく。

ソースファイルを追加する

 さて、読み込み処理を書くに当たって読み込むデータ(プログラム)が何か無いと始まらない。今までコードは全てipl.sという1つのソースファイルに書いていた。これに加えて、setup.sというソースファイルを別に用意する。この後機能拡張をしていくときは、ipl.sではなくsetup.sの方にコードを追加していく。因みに、わざわざsetupなんて名前にしたのは、今後ここに色々な初期化処理を書くよという布石だ。

 とりあえず最初は無限ループで止まるようにしておけばいいだろう。

# setup.s

.code16

begin:
    jmp begin

リンカスクリプトを書く

 setup.oは、今まで書いたIPLのすぐ後ろに追加したい。そのためには、リンカ(GNU ld)とリンカスクリプトというものを使ってオブジェクト・コードの配置を制御する必要がある。ついでにカーネル終端のアドレスもソースコードから参照できるようシンボルを定義しておく。
 リンカスクリプトファイルは以下のような感じになる。拡張子は別に何でも良いようだがとりあえずlsとしておく。

/* ipl.ls */

SECTIONS {
	. = 0x1000;
	_kernel_begin = .;
	.ipl 0x1000 : {
		ipl.o;
		setup.o;
	}
	_kernel_end = .;
	_kernel_end_sector = ((_kernel_end - _kernel_begin) + 511) / 512;
}

 見て分かる通り「SECTIONS」という部分に色々書くことで生成されるオブジェクト=実行ファイルの構成を変えることができる。
 最初の「. = 0x1000」という変な行は、現在位置(.)を0x1000番地に指定している。これを指定することで、プログラムの開始アドレスがメモリの0x1000番地にあるものとしてプログラムが生成される。が、以前に書いたとおり実際には起動時は0x7c00から始まるので、0x1000にコードを移動する処理が必要となる。
 次の「_kernel_begin = .」は、_kernel_beginというシンボルに現在位置(0x1000)を指定して定義するという行だ。これでアセンブラからラベルと同じように_kernel_beginが使えるようになる。
「.ipl」と書かれている部分はセクションと呼ばれるもので、プログラムコードのセクションだったりデータのセクションだったりする。今回はプログラムもデータも一緒になっている単一のセクションを使う。
 ipl.oやsetup.oはセクションに取り込むオブジェクト・ファイルを指定している。ここで指定した順番に並べられる。つまり、ipl.sのコードの直後にsetup.sのコードが続くことになる。
「_kernel_end = .」もシンボル定義で、セクションが終わった後・プログラムの末尾を指すようになる。
「_kernel_end_sector」は、カーネルが何セクタあるかを定義している。_kernel_end - _kernel_beginで全体のサイズを得て、それをセクタサイズ(512)で切り上げつつ割っている。

IPLを移動させる。

 開始アドレスを0x1000としてしまったので、それにあわせてコードを移動させたりスタックの開始位置を変えなければならない。
 ipl.sを編集する。

# ipl.s

# constants
boot_begin = 0x7c00
ipl_size = 0x200
fd_sector_per_track = 0x12

# generate real mode code
.code16

    jmp begin
    nop
    
# 中略

    # setup registers
    cld
    xorw    %ax, %ax
    movw    %ax, %ss
    movw    %ax, %es
    movw    %ax, %fs
    
    # setup stack pointer
    movw    $_kernel_begin, %sp
    
    # move ipl to kernel begin
    xor     %ax, %ax
	
    # source
    movw %ax, %ds
    movw $boot_begin, %si
	
    #dest
    movw %ax, %es
    movw $_kernel_begin, %di
	
    # count
    movw $(ipl_size / 2), %cx
	
    # move
    rep movsw
	
    # setup code segment
    ljmp    $0x0000, $set_cs
set_cs:

    # show message
    movw    $BOOT_MSG, %si
    call    print

# 中略

# boot signature.
. = 510
.short 0xaa55

ipl_end:

 主な変更点は以下の通り。

  • 「boot_begin」と「ipl_size」という定数を追加した。それぞれ起動時のIPLのアドレスとサイズ。
  • 「setup stack pointer」でスタックボトムを_kernel_beginにした。ssは「setup registers」で0x0000に設定。
  • 「move ipl to kernel begin」以下でIPL全体を_kernel_begin以降に移動させた。
  • 「setup code segment」でcsに0を入れるようにした。(set_csラベルは既に_kernel_beginを基準としたアドレスになっている)
  • 「ipl_end」というラベルを定義した。ディスクから読み込んだコードはこれ以降に配置される。

の2点だ。

 この時点でちゃんと実行できるか試して見た方がいい。メッセージが出なくなったりしたらどこか間違っている。

ディスクアクセス関数を書く

 上記コードでちゃんと動くようになったら、いよいよsetup.sのコードをディスクから読み込むようにする。そのために、ディスクからの読み込み関数を作る。
 さて、前回説明したようにディスクからの読み込みには色々面倒なパラメータがある。セクタ・ヘッド・トラックなどなどだ。こんなのを毎回指定していたのでは面倒極まりない。
 そこで、ディスクをフラットなセクタの集まりとしてアクセスできるように関数を作ってしまうことにする。ディスクが合計2880セクタある場合、0〜2879をレジスタで指定することでその番号に該当するセクタが読み込まれるようにするのだ。関数を呼び出す場合はセクタの番号とコピー先アドレスだけを指定すれば良いことにする。
 で、またいきなりコード。

# reset disk system
#	params:
#		dl = drive no
#	returns:
#		al = error code. 0 if succeeded.
reset_disk:
	xor		%al, %al
	xor		%ah, %ah
	int		$0x13
	ret

# read disk sector
#	params:
#		es:di = dest buffer address
#		si = source sector no (0 - 2879)
#		dl = drive no
#	returns:
#		al = error code. 0 if succeeded.
read_disk_sector:
	# save drive no
	pushw	%dx
	xorw	%dx, %dx
	movw	%si, %ax
	
	# logical sector to track no
	movb	$fd_sector_per_track, %dl
	divb	%dl
	
	# track no
	movb	%al, %ch
	shrb	$0x01, %ch
	
	# head no
	jnc		head_0
	mov		$0x01, %dh
head_0:
	
	# sector no
	movb	%ah, %cl
	incb	%cl
	
	# dest buffer
	movw	%di, %bx
	
	# drive no
	popw	%ax
	movb	%al, %dl
	
	# sector count
	movb	$0x01, %al
	
	# read command
	movb	$0x02, %ah
	
	# execute
	int		$0x13
	
	ret

「reset_disk」ではディスク・システムのリセットを行う。起動時に1回だけ呼ぶので別関数にしてある。
「read_disk_sector」が今回のキモとなるディスク読み込み関数だ。これは以下の処理を順に行っている。

  1. 引数に指定されたドライブ番号をスタックに退避する。
  2. コピー元のセクタ番号をaxレジスタに移す。
  3. セクタ番号を1トラック当たりのセクタ数で割り(divb)、トラック番号を得る。余りも同時に得られる。余りは、トラック内でのセクタ番号(ヘッド跨ぎ含む)となる。
  4. トラック番号をchレジスタに移す。
  5. トラック番号で割った余りを1ビット右シフト(つまり2で割る)し、ヘッド内でのセクタ番号を得る。
  6. トラック内でのセクタ番号が偶数かどうか(jnc)によりヘッド番号を得る。
  7. セクタ番号をclレジスタに移す。セクタ番号は1から始まるので同時にインクリメントも行う。
  8. コピー先バッファを指定する。
  9. ドライブ番号をスタックから元に戻す。
  10. 1セクタ読み込むよう指定する。
  11. 読み込みコマンドを指定する。
  12. 読み込み処理を行う。

 divb命令についてはIntelのリファレンス参照。加減算とかとはだいぶ動きが違う。余りが同時に求められたりするので、ある意味C言語の除算より便利(笑)。

 上記コードを「boot signature」より前のどこかに書き込むこと。後に書いてしまうと512バイト以降になってしまってコード自体が読み込まれない(汗)。

起動ドライブを得る

 上記関数を使ってディスクから読み込んで行くわけだが、1つの疑問が生じる。
 ドライブ番号って、何。
 いや当然、ドライブが複数あった場合、起動ディスクの入っているドライブ番号を指定して読み込まなければならないわけだが、その起動ディスクの入っているドライブってどうやって知るんだろうか。
 実は、今まで黙っていたが(またかよ)、起動時にdlレジスタの値として渡される。
 なので、適当な場所にデータ領域を用意して起動時にコピーするようにすれば後から参照できる。

# 前略

# setup registers
    cld
    xorw    %ax, %ax
    movw    %ax, %sp
    movw    %ax, %es
    movw    %ax, %fs

    movb	%dl, boot_drive # save boot drive

# 中略

BOOT_MSG: .string "Hello,World!\r\n"
boot_drive:	.byte	0x00

# 後略

読み込み処理を書く

 いよいよ本チャンの読み込み処理を書く。流れは以下の通り。

  1. ディスクシステムのリセット。(reset_disk)
  2. 成功メッセージ表示。
  3. 読み込み元セクタ・読み込み先バッファを指定する。
  4. ディスクからの読み込み。(read_disk_sector)
  5. セクタ・バッファを進める。
  6. 全セクタを読み終えたら終了。
  7. 成功メッセージ表示。

 で、コードは以下の通り。

#前略

fd_sector_length = 512
kernel_begin_sector = 0x01
read_kernel_es_offset = 0x1000

# 中略

    # show message
    movw    $BOOT_MSG, %si
    call    print
    
    # reset disk drive
    movb    %dl, boot_drive
    call    reset_disk
    jc      error
    
    # success
    movw    $DISK_RESET_MSG, %si
    call    print

 ここまででディスク・システムのリセットが終わる。続いてディスクからの読み込み処理を行う。

    # read kernel image from disk
    pushw   $0x00
    popw    %es
    xorw    %di, %di
    movw    $ipl_end, %di
    movb    boot_drive, %dl
    movw    $kernel_begin_sector, %si
read_kernel:
    call    read_disk_sector
    jc      error   
    addw    $fd_sector_length, %di
    jnc     advance_read_kernel_sector
    movw    %es, %ax
    addw    $read_kernel_es_offset, %ax
    movw    %ax, %es
advance_read_kernel_sector:
    incw    %si
    cmpw    $_kernel_end_sector, %si
    jb      read_kernel
    
    # success
    movw    $DISK_READ_MSG, %si
    call    print
    
    # jump to setup
    jmp ipl_end

# error
error:
    movw    $ERROR_MSG, %si
    call    print
error_end:
    jmp error_end

# 中略

BOOT_MSG: .string "Hello,World!\r\n"
DISK_RESET_MSG: .string "Success reset disk.\r\n"
DISK_READ_MSG: .string "Success read disk.\r\n"
ERROR_MSG: .string "Disk error!\r\n"
boot_drive: .byte   0x00

# 後略

 ディスクからの読み込み処理(read_kernel以下)では、read_disk_sectorを呼び、成功したらコピー先を指すdiをセクタ分進め、もしdiがオーバーフローしたらesを進めるという処理を行っている。advance_read_kernel_sector以下では全セクタ読み込んだかチェックし、まだ読み込み終えていなければ引き続きread_kernelを実行するようにしている。

ビルドする

$ as -o ipl.o ipl.s
$ as -o setup.o setup.s
$ ld -T ipl.ls -o kernel.exe ipl.o setup.o
$ objcopy -S -O binary kernel.exe kernel.bin
$ dd if=/dev/zero of=fd.img count=2880
$ dd if=kernel.bin of=fd.img conv=notrunc
$ ./qemu/qemu.exe -L ./qemu -m 128 -fda fd.img

 IPLのサイズを越えるので、もうカッコつけてkernelという名前にしてしまった。

次回予告

 以上を実行して、ちゃんと成功メッセージが全部出て暴走しなければ成功だ。でも果たして本当に暴走せずに動いているのかどうかが分からない。あと、ビルド方法もややこしくなってきた。
 そこで次回は、qemuを使ったマシンの状態のモニタ方法と、makeを用いた自動ビルドの方法について少しやろうと思う。
 ああD言語が出てくるのはいつだろう……。