最適な画面モードの設定で苦戦した話

こんにちは．[だいみょーじん](https://twitter.com/Egh2Deywos)です．
この記事は，[自作OS Advent Calendar](https://adventar.org/calendars/6581) 6日目の記事です．
また，[第27回 自作OSもくもく会オンライン](https://osdev-jp.connpass.com/event/223896/)で発表した内容をそのまま記事にしたものです．

対象読者:OS自作をやっていたり，興味がある人．特に[30日でできる! OS自作入門](https://book.mynavi.jp/ec/products/detail/id=22078)(以下「30日本」)を読んだことがある人．

# 背景

私は現在，30日本を参考にレガシーBIOSを用いた自作OSである[hariboslinux](https://github.com/TaiseiIto/hariboslinux)を開発しています．
OS開発にはさまざまな要素がある中で，GUIを実装する上で欠かせないのが画面モードの設定です．
この記事では，自分がhariboslinuxを開発する上で画面モードの設定に苦戦した話を書き残そうと思います．

# 課題

VESAの規格で多数ある画面モードの中で，30日本のharibote OSでは基本的に画面モード0x0105を使用しています．
この画面モードは，

* 画面の幅1024ピクセル
* 画面の高さ768ピクセル
* 1ピクセル当たり1バイト(最大256色)

となっています．
しかし，haribote OSを動かしている実機の画面は，本当にこの大きさで，本当に256色しか出せないのでしょうか？
今どきのPCの画面なら大抵もっと解像度は高いし，16777216色出せると思います．

<font color="#c0c0c0">そもそも今どきのPCはレガシーBIOS非対応だから(ry</font>

何か聞こえた気がしますが気のせいでしょう．
実際に実機でharibote OSを動かしたことのある人ならわかるでしょうが，実機の画面がより高い解像度を持っている場合，画面が引き延ばされて表示されます．
動作に支障は出ませんが，画面の性能を発揮しきれてなくて嫌です．
色に関しても，30日本ではアルゴリズムの力でグラデーションを実現していますが，やはり画面が16777216色出せるなら本物のグラデーションがやりたいわけですよ．
そこで，画面の性能を遺憾無く発揮するために最適な画面モードを設定してやろうということになりました．

# 手順

[このページ](https://wiki.osdev.org/VESA_Video_Modes)を参考に，画面モードの設定は以下の手順でやります．

1. 利用可能な画面モードの一覧を取得
1. 最適な画面モードを探す
1. 最適な画面モードに移行

これらの手順を順番に説明しましょう．

## 利用可能な画面モードの一覧を取得

AXレジスタに0x4f00を入れ，INT 0x10を実行すると，ES:DI番地に以下の構造体が書き込まれます．

```c:画面モード一覧
struct VbeInfoBlock {
   char VbeSignature[4];             // == "VESA"
   uint16_t VbeVersion;                 // == 0x0300 for VBE 3.0
   uint16_t OemStringPtr[2];            // isa vbeFarPtr
   uint8_t Capabilities[4];
   uint16_t VideoModePtr[2];         // isa vbeFarPtr
   uint16_t TotalMemory;             // as # of 64KB blocks
} __attribute__((packed));
 
VbeInfoBlock *vib = dos_alloc(512);
v86_bios(0x10, {ax:0x4f00, es:SEG(vib), di:OFF(vib)}, &out);
if (out.ax!=0x004f) die("Something wrong with VBE get info");
```

`VideoModePtr`が指し示す領域に，利用可能な画面モードの一覧が以下のように格納されます．

![VideoModeList.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2354177/26b25120-9a85-4138-7cae-05e5a647e500.png)

2バイトの画面モード番号の配列で，0xFFFFは配列の終端を表しています．

## 最適な画面モードを探す

AXレジスタに0x4f01を，CXレジスタに画面モード番号を入れ，INT 0x10を実行すると，ES:DI番地に以下の構造体が書き込まれます．

```c:画面モード情報
struct vbe_mode_info_structure {
	uint16 attributes;		// deprecated, only bit 7 should be of interest to you, and it indicates the mode supports a linear frame buffer.
	uint8 window_a;			// deprecated
	uint8 window_b;			// deprecated
	uint16 granularity;		// deprecated; used while calculating bank numbers
	uint16 window_size;
	uint16 segment_a;
	uint16 segment_b;
	uint32 win_func_ptr;		// deprecated; used to switch banks from protected mode without returning to real mode
	uint16 pitch;			// number of bytes per horizontal line
	uint16 width;			// width in pixels
	uint16 height;			// height in pixels
	uint8 w_char;			// unused...
	uint8 y_char;			// ...
	uint8 planes;
	uint8 bpp;			// bits per pixel in this mode
	uint8 banks;			// deprecated; total number of banks in this mode
	uint8 memory_model;
	uint8 bank_size;		// deprecated; size of a bank, almost always 64 KB but may be 16 KB...
	uint8 image_pages;
	uint8 reserved0;
 
	uint8 red_mask;
	uint8 red_position;
	uint8 green_mask;
	uint8 green_position;
	uint8 blue_mask;
	uint8 blue_position;
	uint8 reserved_mask;
	uint8 reserved_position;
	uint8 direct_color_attributes;
 
	uint32 framebuffer;		// physical address of the linear frame buffer; write here to draw to the screen
	uint32 off_screen_mem_off;
	uint16 off_screen_mem_size;	// size of memory in the framebuffer but not being displayed on the screen
	uint8 reserved1[206];
} __attribute__ ((packed));
```

見てみると，画面の大きさに関するwidth，heightや，表現可能な色数に関するbpp（bits per pixel）といった重要な情報が含まれていることがわかります．

## 最適な画面モードに移行

ここまで出来たら，あとは最適な画面モードを選択して移行するだけです．
最適な画面モードは以下の疑似コードに示すアルゴリズムで選択することにしました．

```
best_mode;
for each mode {
	if(0x18 <= mode.bits_per_pixel){
		if(best_mode.width <= mode.width && best_mode.height <=mode.height){
			best_mode = mode;
		}
	}
}
```

この疑似コードを実行後，best_modeに格納されている画面モードに移行することになります．
画面モードの移行はharibote OSと同様にAXレジスタに0x4f02，BXレジスタに画面モード番号+0x4000を入れ，INT 0x10を実行です．

# 実行結果

QEMUやVirtual Boxでは画面表示に成功

![virtualbox.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2354177/bc60c3c3-e5ae-745f-2db2-193d4c3c536e.png)

実機での実行結果

![realmachine.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2354177/c0da46a7-8e4b-6b11-2174-abf55b563c8f.jpeg)

ファッ！
Intel入ってるし，上の赤い点々なんだよ！

# バグ退治の旅に出る

とりあえず各手順がうまくいってるか確認していきましょう．

## 利用可能な画面モードの一覧を取得できているか？

```
video mode = 0x0000
video mode = 0x0000
video mode = 0x0000
video mode = 0x0000
video mode = 0x0000
video mode = 0x0000
video mode = 0x0000
video mode = 0x0000
```

うっ．．．
果てしない無効値の連続である．

で色々調べた結果，私はあることを勘違いしていることがわかりました．

```c:画面モード一覧
struct VbeInfoBlock {
   char VbeSignature[4];             // == "VESA"
   uint16_t VbeVersion;                 // == 0x0300 for VBE 3.0
   uint16_t OemStringPtr[2];            // isa vbeFarPtr
   uint8_t Capabilities[4];
   uint16_t VideoModePtr[2];         // isa vbeFarPtr
   uint16_t TotalMemory;             // as # of 64KB blocks
} __attribute__((packed));
 
VbeInfoBlock *vib = dos_alloc(512);
v86_bios(0x10, {ax:0x4f00, es:SEG(vib), di:OFF(vib)}, &out);
if (out.ax!=0x004f) die("Something wrong with VBE get info");
```

この構造体の`VideoModePtr`ってやつ，無意識に32ビットの物理アドレスだと思ってましたが，`VideoModePtr[1]`がセグメント、`VideoModePtr[0]`がオフセットでした．
なので物理アドレスは`(VideoModePtr[1] << 4) + VideoModePtr[0]`になる．
そうだよな．リアルモードだもんな．普通に考えてそうだよな．
なんでこんな勘違いをしたんだか．
ここを直して実機で動かしたところ，無事にまともな画面モードリストが取得できました！
そして，実機における最適な画面モードが0x017fであることもわかりました！

## 最適な画面モードを取得できているか？

しかし！

![bug.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2354177/a1b0865c-c772-e0bc-4ffa-69cdcd6950be.jpeg)

解像度もゼロ，色数もゼロ．．．
無の画面モードである．

また色々調べた結果，最適な画面モードを選択するアルゴリズムが原因なんじゃないかということに．

```
best_mode;
for each mode {
	if(0x18 <= mode.bits_per_pixel){
		if(best_mode.width <= mode.width && best_mode.height <=mode.height){
			best_mode = mode;
		}
	}
}
```

疑似コードを再掲しますが，このfor文の中で各画面モードの情報を取得するために，vbe_mode_info_structure構造体を0x0600番地に書き込んでいました．
で，best_modeが更新された後も，次の画面モードがある場合ループを繰り返して，0x0600番地を次の画面モードのvbe_mode_info_structure構造体で上書きするわけです．
なので，for文を抜けたらbest_modeのvbe_mode_info_structure構造体をもう一度取得しなおさないといけないわけです．
この，同じ画面モードの情報を複数回取得するというのがいけなかったんじゃないかと思いまして，アルゴリズムを以下のように修正しました．

```
best_mode;
for each mode {
	modeの画面モード情報を0x700番地に書き込み;
	if(0x18 <= mode.bits_per_pixel){
		if(best_mode.width <= mode.width && best_mode.height <=mode.height){
			best_mode = mode;
			0x700番地の画面モード情報を0x600番地に書き写す;
		}
	}
}
```

こうすればfor文を抜けたときに0x0600番地にちゃんとbest_modeの画面モード情報が書き込まれているわけです．

修正して，実機で実行！

![debug.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2354177/39897dc5-c27f-d7f4-7ef8-305e13697435.jpeg)

最適な画面モードの情報は正常に取得できました！
bits per pixelはちゃんと3バイト以上だし，解像度も画面の物理的な解像度と一致していることが確認できました．

## 画面モードを移行できているか？

いざ！画面モード切替！

![lastbug.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2354177/a21718ee-e57d-318c-3670-fbb8a306aa1f.jpeg)

ううっっっ！

画面が使えない．．．
シリアルポートない．．．
printデバッグできない．．．

万策尽きたかと思いましたが，もしかしたら画面モードとは全く関係ないところでバグってる可能性もあるなと思いました．
実機のVRAMのアドレスもわかっています．（0xe0000000）
そこで，画面モードを切り替えた直後に，VRAMの最初の0x200000バイトに0xffを書き込みまくってみました．
すると！

![white.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2354177/47328e27-412b-4b50-4f83-40b4353d034f.jpeg)

これは来たぞ！
ではどこでバグってるのか？
実験を繰り返した結果，マウスを有効化する`init_mouse()`関数を実行することが，バグを引き起こす必要十分条件っぽいことがわかりました．

```c:マウス有効化
void init_mouse(void)
{
	// upgrade mouse ID from 0 to 3
	set_mouse_sample_rate(200);
	set_mouse_sample_rate(100);
	set_mouse_sample_rate(80);
	send_to_mouse(MOUSE_COMMAND_GET_ID);
	printf_serial("mouse ACK = %#04x\n", receive_from_keyboard());
	mouse_id = receive_from_keyboard();
	printf_serial("mouse ID = %#04x\n", mouse_id);

	if(mouse_id == 3)
	{
		// upgrade mouse ID from 3 to 4
		set_mouse_sample_rate(200);
		set_mouse_sample_rate(200);
		set_mouse_sample_rate(80);
		send_to_mouse(MOUSE_COMMAND_GET_ID);
		printf_serial("mouse ACK = %#04x\n", receive_from_keyboard());
		mouse_id = receive_from_keyboard();
		printf_serial("mouse ID = %#04x\n", mouse_id);
	}
	// enable packet streaming
	send_to_mouse(MOUSE_COMMAND_ENABLE_PACKET_STREAMING);
	...
```

マウスを使えるようにするためにマウスと対話してます．
で，マウスとのやり取りはキーボード制御回路を通して行います．

```c:マウスとのやり取り
void send_to_mouse(unsigned char data)
{
	send_command_to_keyboard(KEYBOARD_COMMAND_SEND_TO_MOUSE, data);
}
```

キーボードとのやり取りはこんな感じ

```c:キーボード制御回路とのやり取り

void send_command_to_keyboard(unsigned char command, unsigned char data)
{
	wait_to_send_to_keyboard();
	outb(PORT_KEYBOARD_COMMAND, command);
	wait_to_send_to_keyboard();
	outb(PORT_KEYBOARD_DATA, data);
}

void wait_to_send_to_keyboard(void)
{
	while(inb(PORT_KEYBOARD_STATUS) & KEYBOARD_STATUS_UNSENDABLE);
}
```

`wait_to_send_to_keyboard`関数のwhile文，怪しすぎる．
ここで無限ループに陥っている可能性がある．
ポーリングでキーボードとやり取りするためのwhile文なわけだが，もしかして割り込みを禁止せずにポーリング処理をやってしまっている？
確認してみると，確かに割り込みが禁止されていなかった．
そこで，マウスの有効化などのOSの初期化部分をまとめて割り込み禁止命令CLIと割り込み許可命令STIで挟んでみたところ．．．

![win.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2354177/32ddafb2-6736-c6d1-e5a2-dec83a7b0dc5.jpeg)

勝利した！

# まとめ

* 実機で最適な画面モードが選択できた
* 画面やシリアルポートのありがたみがわかった（デバッグのつらみがすごかった）
* あれだけのバグがありながらちゃんと表示できてしまった仮想マシン...

# 参考文献

* [30日でできる！OS自作入門](https://book.mynavi.jp/ec/products/detail/id=22078)
* [VESA Video Modes](https://wiki.osdev.org/VESA_Video_Modes)