はりぼてOS on くそざこエミュレータ

クリスマスが近づく今日このごろ、x86エミュレータを作って自作OSを動かしたいと思いませんか? ちょうど「30日でできる自作OS」をやり終えて手持ち無沙汰だったので本を参考にしてx86エミュレータを作ってみました。 この記事はその時の学習メモ・覚書みたいなやつです。

koko -> github.com

この本をもとに進めました。

間違いとうありましたら教えていただけると嬉しいです!

x86 とは

x86とは1978年に発売されたintel 8086とその後継に共通している命令アーキテクチャの総称です。かんたんに言ってしまうとみなさんが普段使っているようなパソコンで動いている32bit命令のアーキテクチャのことです。最近のWindowsLinuxなどの家庭用OSの殆どが64bitで動きますが後方互換性のおかげで32bit命令も動きます、安心してエミュレータが作れますね!

x86の基本レジスタ

x86は値を格納する容器(レジスタ)として次のようなものを持っています。

  1. 汎用レジスタ
  2. セグメントレジスタ
  3. EFLAGSレジスタ
  4. EIPレジスタ

f:id:gunyagisa:20201218204915j:plain

汎用レジスタ

汎用レジスタは8個(EAX, EBX, ECX, EDX, ESI, EDI, EBP, ESP)あり32bitの値を保持できます。 これらは * 論理演算と算術演算命令のオペランド * アドレス計算のオペランド * メモリのアドレス を保持します。 ただしESPは原則としてスタックポインタとしてのみ使用する必要があるみたいです。(そういうルール?)

f:id:gunyagisa:20201218205004j:plain

上の図のようにそれぞれのレジスタには下位16bit, さらにそれを分割した2つの8bitレジスタに名前がついています。

セグメントレジスタ

セグメントレジスタは6個(CS, DS, SS, ES, FS, GS)あり、16bitのセグメントセレクタという値を保持します。 x86はメモリ管理をするためにメモリをセグメントと呼ばれる領域に分割します。 メモリを複数のセグメントに分割したときにどのセグメントを利用するかを指定(正確にはセグメントディスクリプタを指す)するのがセグメントセレクタです。 大雑把な役割は上に書いた通りですが、詳細はメモリのところで書きます。

EFLAGS

計算結果のステータス(オーバーフローやキャリーなど)やシステムの制御フラグなどを格納する32bitのレジスタ。 たくさんあるのでココには書ききれません...

EIP

これは次に実行される命令がメモリのどこに格納されているかを示します。 JMPとかCALL命令などはこの命令ポインタの値を変えることできそうです。

ちなみにIntel SDMによると、EIPの読み取りをするにはCALL命令しか方法がないようです。CALL命令を実行するとCALLの次の命令を指すアドレスをスタックに入れたあとにジャンプします。このときスタックに入った値を読み込むことでEIPが得られますね。逆に書き込みをするにはスタックに値を入れたあとリターン命令を実行することで間接的に書き込みが出来るようです。(CTFのpwnでこのようなEIPの書き換えを知りました。初めて聞いたときは考えた人頭良すぎ!って思ったけどintel公認だったんですね~)

メモリ

x86は一つの番地に8bit、全体で64GB(アドレス拡張を使って236-1まで使える)までの物理メモリが使えます。オペレーティングシステムなどがメモリにアクセスするにはメモリ管理機能を介して行います。このときプログラムは物理アドレスを直接扱わずに、つぎの3つのメモリモデルを使ってアクセスします。(理解があっているかわかりませんが、下のようなものが実際に存在しているのではなくて物理メモリが抽象化されてプログラムにはこんな風に見えてるという感じに思ってます)

フラット・メモリ・モデル

このモデルではプログラムからメモリを見ると4GBの単一の連続したメモリのように見えます。(リニアアドレス空間) 命令列(コード)やデータ、スタックなどは全てこのアドレス空間に格納されます。リニアアドレス空間の番地をリニアアドレスと呼び、ページングを使わなければリニアアドレスがそのまま物理アドレスになります。(セグメントセレクタを使わない?んだと思います、調査中です)

f:id:gunyagisa:20201218201032p:plain

セグメント化メモリモデル

セグメントレジスタの所で書いたのはこのメモリモデルを使った場合です。 このモデルではメモリが、プログラムからはセグメントという独立した領域の集まりに見えます。

f:id:gunyagisa:20201218201148j:plain

コード、データ、スタックは一般的には独立したセグメントに格納されます。 f:id:gunyagisa:20201218205345j:plain

セグメントセレクタでセグメントを決定し、セグメント中のアドレスをオフセットで指定します。(合わせて論理アドレス

このようにメモリをセグメントに分割する理由の一つは、プログラムの保護です。仮にスタックオーバーフローが生じたとしてもセグメント同士は独立しているので他のプログラムを破壊することを防げます。論理アドレスは変換テーブルを使ってリニアアドレスに変換されます。

f:id:gunyagisa:20201218202527j:plain

このとき参照される変換テーブルがディスクリプタ・テーブルです。これは次のような構造をしています。

f:id:gunyagisa:20201218203049j:plain

こいつで権限をもとにしたアクセス管理とかもできます、すごい! ...ただこれ以上先はすでに探せば解説があったので省略します。実装もできないので() 参考にビットフィールドでできたディスクリプタ・テーブルの構造体を載せておきます。

struct GDT {
  uint16_t limit_low, base_low;
  uint8_t base_mid;
  uint8_t type : 4;
  uint8_t flag : 1;
  uint8_t dpl : 2;
  uint8_t present_flag : 1;
  uint8_t limit_hi : 4;
  uint8_t avl : 1;
  uint8_t db : 1;
  uint8_t granularity : 1;
  uint8_t base_hi;
};

実アドレスモデル

このモデルは主にリアルモードで使用されます。このモデルではセグメントセレクタディスクリプタ・テーブルを指すものではなく、セグメントセレクタをベースの値と解釈しリニアアドレスを形成します。何言ってるかよくわかりませんね。

リアルモードでは16bitレジスタを使います。216 byte = 64KBしかつかえないのか...と思いきやそうではないのです。 セグメントレジスタ(16bit)を左に4bitシフトしこれと実効アドレスを足すことで220 byte = 1MBのメモリが使えます。

f:id:gunyagisa:20201218211142j:plain

例えば * セグメントセレクタ = 0x1234 * 実効アドレス = 0x1111 のとき、0x1234 * 16 + 0x1111 = 0x13451 がリニアアドレスになります。

f:id:gunyagisa:20201218201651j:plain

命令

~こうして私達のもとに届けられる~

f:id:gunyagisa:20201218214315j:plain

この図は命令フェッチからデコーダに命令が渡されるまでを説明するものです。 エミュレータの中身もだいたいこんな感じになっています。

uint32_t eip;

for (;;) {
    uint8_t opecode = get_code8();
    eip++;
    
    switch (opecode) {
        case 0xf4:
            hlt();
            break;
        default:
            printf("can not implement. ope=0x%02\n", opecode);
            break;
    }
}

命令のフォーマット

Prefix オペコード ModR/M SIB ディスプレースメント 即値
0~4byte 1,2,3byteのいずれか 必要なら1byte 必要なら1byte 0,1,2,4byteの変位 0,1,2,4byteの即値

オペコードで具体的な命令を指し、必要ならModR/M、SIB、即値や変位を指定します。x86は命令の長さが固定ではなく複雑になっています。最短で1byteから最長で15byteのようです。

エミュレータのなかではオペコードをもとにした無限のswitch文とif文によってフォーマットのパースが実現されています。いい話ですね。

ModR/M

f:id:gunyagisa:20201218182934j:plain

ModRMはオペランドの指定を柔軟に行うための仕組みです。

mov REG32, R/M32 (レジスタ/メモリからレジスタへの転送)を例に考えてみます。(オペコードは0x8b) mov ebx, [eax] は 8b 18 と機械語に翻訳されます。つまり 0x18の部分がModR/M…?と考えられますね!

0x18は2進数で0001 1000なのでModRMの中身は次のようになります。 f:id:gunyagisa:20201218222205j:plain

  • Modの表
形式
[ register ] 00
[register]+disp8 01
[register]+disp32 10
register 11
  • Reg・R/Mの表
Resiter
EAX 000
ECX 001
EDX 010
EBX 011
ESP 100
EBP 101
ESI 110
EDI 111

modでR/Mの具体的な形式を決めregisterにどのレジスタを使うかを000~111の値で決定します。 上の表を参照すれば REG=ebx, R/M=[eax]だとわかりますね。 オペコード0x8bはmov REG32, R/M32と決まっているので、これに当てはめると...

8b 19 をmov ebx, [eax]と翻訳出来るわけです!

具体的には次のように実装してみました。

struct ModRM {
    uint8_t mod: 2;

    union {
        uint8_t reg: 3;
        uint8_t ext: 3;
    };
    uint8_t rm: 3;
    uint8_t sib: 2;
    
    union {
        int8_t disp8;
        uint32_t disp32;
    };
};

ModRM構造体を用意してパースした結果を保持し上のような表を参照に命令をデコードしています。

電源投入からOSまで

OSの起動をエミュレートするにはまず最初にコンピュータの電源ボタンが押されてからOSが起動するまではどのようになっているかを知る必要があります。

電源ボタンを押すと次の順で処理が進みます。

  1. POST(Power On Self Test)
    • ハードウェアの自己診断を行います。
    • 電源投入すると自動でメモリや記憶装置、CPUをチェックしてくれる、すごい!
  2. bios(Basic Input Output System)
    • ディスクの先頭512byte(ブートセクタ)を確認し起動可能ならこれらを0x7c00に転送します。
    • 最後に制御を0x7c00に移します。
  3. IPL(Initial Program Loader)
    • ブートセクタに書かれているプログラムの正体!
    • 一般にOSは512byteに収まらないのでbiosとOSの間にIPLを挟んで多段式にして解決しています。
  4. OS

はりぼてOSを動かすには

最初から完璧に動かそうとするのではなく1日目から順に必要なものをエミュレータに実装していくほうがいい気がします。実装つよつよなら最初から全てを実装でも出来るかもですが私にはできませんでした...。 あとどこまで忠実にエミュレートするかは人それぞれだと思いますが私の場合はプロテクトモードの移行などはガバガバですが、何故かうまく動いてるので、なぜなんでしょう?

BIOS命令

はりぼてOSにはビデオモードの設定やHDDの読み込みなどにBIOS割り込みが使われています。私の場合は愚直に割り込み番号で場合分けし、それっぽくしてます。

GUI

freeglutというライブラリのglDrawPixelsという関数がよしなにしてくれるかもです。 この関数はbitmapの配列を与えるとそれにしたがってGUIに出力してくれるので、VRAMをbitmapに変換する仕組みを作るだけで意外と簡単にGUI出力ができちゃいます。カラーパレットなどをどうにかすれば、やるだけですね!

いかがでしたか?

いかがでしたか?を書きたいがためにこの章はつくられました。

最終的にははりぼてOSをの最初の方に出てくる模様の出力まで出来るようになりました!まだまだガバガバですが意外とやれるんだなと感じました。今後は何しようか決まっていないですが最近気になっているRustを触ってみようかなと思っています。

参考文献・引用