読者です 読者をやめる 読者になる 読者になる

自作エミュレータで自作OSを動かしてみようとした話

これは自作OS Advent Calendar 2016の22日目の記事です。

『30日でできる! OS自作入門』という(このアドベントカレンダー5,6,9,15日目の記事のKさん著)本があります。 この本はhikaliumさんも書かれていましたが、まさに「OS自作における聖書」といえるような本で、例によって今回やったこともこの本が発端となっています。

実際何をやったのかというと、タイトルの通り「自作OSを自作エミュレータで動かしてみたい」と思ったので色々やってみた、ということです。

エミュレータと自作OS

突然ですが、「ソフトウェアを開発する」ということに着目した時、一般的なアプリケーションの開発とOSの開発の一番の違いはなんでしょうか? 僕は、「デバッグに仮想環境を使うか使わないか」ではないかと思います。一般的なアプリケーション開発ではデバッグしようと思ったらとりあえず実行してみればいいですが、OSだとそうもいきません。なぜなら、OSを作って動作確認をするとき、いちいちCDやDVDに焼いたり、ハードディスクを犠牲にするのは面倒だからです(また、ファイルシステムを作っている時などに安易に実機で実行してしまうと重要なファイルを壊してしまうかもしれません)。そのため、OSを開発するときにはよくエミュレータ(仮想環境)を使います。QEMUBochsなどですね。

しかし、ここで一つ疑問が生じます。一体全体この「エミュレータ」というプログラムは、「どうやってコンピュータをエミュレーションしているのか?」ということです。OSというとかなり低レイヤーな分野ですが、エミュレータを作ろうと思ったらそれより下層のハードウェアなどの動作を再現しなければなりません。ということで、エミュレータがどのような仕組みで動いているのかが気になりました。幸いにしてQEMUオープンソースですので、思い立ったが吉日、QEMUのソースを見てみましょう。

まずはGitHubからソースコードを取ってきます。とりあえずどんなファイルがあるのか見てみましょう。

$ git clone https://github.com/qemu/qemu.git
$ cd qemu
$ ls

f:id:sksat:20161220221252p:plain

ごめんなさい、ファイルがたくさんありすぎて何を見ればいいのかわかりましぇん。。。 ということで僕のエミュレータへの思考はかなり断絶していました(QEMU開発者の方、すみません・・・)。

しかし、今年の夏にあったセキュリティキャンプ全国大会参加前に、色々な講義の事前課題を眺めていたら、「USBメモリからブートしてみよう」というなかなかそそられる講義を受ける人には「自作エミュレータで学ぶ x86アーキテクチャ」(このアドベントカレンダー1日目,8日目,18日目のuchanさん著)なる本が教材として与えられるとのこと。 なんだそのとても面白そうな本は・・・ 残念ながら、僕はその講義の時間帯は別の講義を取っていたのでこの本を貰うことはありませんでしたが、とても気になっていたので、学校の文化祭も終わって落ち着いた10月に買いました。

「自作エミュレータで学ぶ x86アーキテクチャ」のエミュレータ

僕なんかが書くより本を読んでサポートページからtolset_p86をダウンロードしてソースコードを見たほうが速いとは思いますが、本のエミュレータがどのような仕組みなのか簡単に説明すると、

  • レジスタに対応した変数を用意する(構造体にしてまとめてある)
  • 仮想マシン用のメモリを用意する(とりあえず1MB)
  • 外部から実行するバイナリファイルを読み込んで、用意したメモリの0x7c00番地にコピーする(512バイト)
  • エミュレータのEIPに0x7c00を代入
  • EIPがメモリをあふれない範囲でループして、EIPの番地の機械語を逐次実行していく
typedef struct {
    uint32_t registers[REGISTERS_COUNT];    // 汎用レジスタ
    uint32_t eflags;                        // EFLAGSレジスタ(キャリーフラグなど)
    uint8_t* memory;                        // メモリのアドレス
    uint32_t eip;                           // EIPレジスタ
} Emulator;

int main(int argc, char **argv){
    /* 中略(初期化処理)*/
    while(emu->eip < MEMORY_SIZE){
        uint8_t code = get_code8(emu, 0);
        /* 中略(ログ出力したり、実装してない命令がきたらbreakしたり)*/
        instructions[code](emu);    // 命令の実行
        if(emu->eip == 0){
               break;
        }
    }
    /* 中略(終了処理)*/
    return 0;
}

instructionsというのは関数ポインタの配列で、なるほど頭いいなあと思ったのですが、ここに各機械語に対応した関数のアドレスを入れています。

で、結局なにをやったのか

はい。実はここからが本題です。 先ほど紹介したエミュレータプログラムを見て、僕は「おお!これにどんどん機能追加していけばQEMUBochsみたいなことができるのでは?」と思いました。 では、その目標のために僕がどのようなことをしたのかを書いていきたいと思います。

まずは、すでにあるプログラムをC++で書き直しました。なんでそんなことをしたのかというと、一から書き直すことでちゃんとプログラムを理解したかったのと、あとはクラスにまとめてみたかったからです(ここは趣味ですね...)。

次に、OSを起動するためにはどのような機能をエミュレーションする必要があるかを考えました。とは言っても、僕がある程度中身を知っているOSははりぼてOSぐらいしかないので、はりぼてOSのエミュレーションに必要な機能を考えてみました。

  • 起動時は16bitモードで、あとから32bitモードに遷移できる
  • リアルモードとプロテクトモード
  • セグメンテーション
  • 割り込み処理
  • 画面表示
  • バイスが使える

他にもあるかもしれませんが、簡単にまとめるとこんなかんじでしょうか。 この中で、デバイスについては本でIN,OUTが実装されているので、各ポートに対応した外部装置を実装していけばいいですかね。 リアルモード・プロテクトモードはどう違うのかイマイチよく分かっていないので今はパスです。 セグメンテーションや割り込みも難しそうなのでとりあえず後回しですね。

ということで、色々良くわからないところは飛ばして(いやIntelの資料とか見ろよというかんじですが)、画面表示をやってみたいと思いました。

ウィンドウを出す

画面表示というと、まずはウィンドウが無いとどうしようもありません。ウィンドウを作りましょう。
ウィンドウを作っていきたいのですが、去年ぐらいの僕がこんなことを考えると、Win32 APIを叩き初めてしまいます。まあそれでもできなくはないですが、最近Windowsマシンを学校の情報の授業以外で起動すらしていないので、ちょっとダメです。ということで、描画ライブラリを使いましょう。
さて、描画ライブラリを選ぶことになったわけですが、残念ながら僕はよく使われてる描画ライブラリがあんまり好きじゃなくてWin32 APIに走ったという経緯があるので、よく使われる描画ライブラリをまともに触ったことがありません。
じゃあどうするんだというと、流石に汎用性の高い描画ライブラリを作っていたら日が何回も暮れてしまうので、OpenGL、正確にはfreeglutを使うことにしました。
なんでOpenGLなのかというと、ほんのちょっとだけ使ったことがあるのと、設計がかなり汎用性を重視してるように思えたからです。
あとは、開発は基本的にUbuntuですが、Windowsでも使えたほうがいいだろうということでマルチプラットフォーム対応なものとして選びました。 なんでglutじゃなくてfreeglutなのかというのは、実はやってる途中に色々あって変えたので今回は割愛します。

また、ウィンドウ、というかGUIを作るためにはメッセージループを行わなければいけません。しかし、エミュレーションをしながらメッセージを処理するのはソースコードもぐちゃぐちゃになりますし僕もよくわからなくなります。そこで、新しくGUIのメッセージループ用にスレッドを作ることにしました。そして、スレッドやウィンドウの処理をGUIというクラスにまとめました。

class GUI {
private:
    std::thread *hThread;
    bool msgflg;            // メッセージループの制御フラグ
    int scrnx, scrny;       // 描画するX,Y方向サイズ
    void ThreadProc();      // メッセージループスレッド
    void display();         // 描画処理
public:
    GUI();
    ~GUI();
    void OpenWindow();
}

しかし、ここで問題が発生しました。OpenGLを使ったプログラムというと、

void display(void);

int main(int argc, char **argv){
    glutInit(&argc, argv);
    glutCreateWindow("title");
    glutDisplayFunc(display);
    glutMainLoop();               // 帰ってこない
}

とかやるのが普通なんです。でもこれは困ります。glutMainLoop()は決して帰ってこないので、メインスレッドの方で、「エミュレーションが終わったからウィンドウも閉じたい!」と思っても、何かのフラグを変えてglutMainLoop()から脱出する、というようなことはできません(後から考えてみればできなくはなかったけれどいずれにしろきれいに書けない)。じゃあどうするんだ、ということでglutMainLoopについて調べていたらちょうどいい関数を見つけました。glutMainLoopEvent()という関数です。これを

while(true){
    glutMainLoopEvent();
}

というようにループに入れてやればglutMainLoop()の代わりになります。恐らく、Win32 APIで言うところのGetMessage()とTlanslateMessage()を合わせたようなものでしょう。

画面表示の仕組み

ようやく画面表示を実装していく準備が整いました。それでは、画面表示がどのように行われているかを『30日でできる! OS自作入門』で復習してみました。

[VRAM]に0xa0000を入れているのですが、PCの世界でVRAMというのはビデオラムのことで「video RAM」と書き、画面用のメモリのことです。このメモリは、もちろんデータを記憶することがいつも通りできます。
しかしVRAMは普通のメモリ以上の存在で、それぞれの番地が画面上の画素に対応していて、これを利用することで画面に絵を出すことができるのです。

つまり、VRAMにあるデータを見てその通りにウィンドウに描画していけばどうにかなりそうですね。

ではまず、描画関数であるGUI::displayを整備します。

void GUI::display(){
    glClearColor(0.0, 0.0, 0.0, 0.0);    // 背景色を黒に設定
    glClear(GL_COLOR_BUFFER_BIT);
    glRasterPos2f(-1, 1);                // ラスター座標変更
    glDrawPixels(scrnx, scrny, GL_RGB, GL_UNSIGNED_BYTE, img);    // unsigned char配列の中身を描画
    glFlush();    // 描画を反映させる
}

表示するRGBデータを保存するunsigned char配列であるimgをGUIクラスに加えて、glDrawPixels()という関数で描画するようにしました。 これでimgにいいかんじにRGBデータを書き込んでやれば、このようにちゃんと表示されます。

f:id:sksat:20161221145414p:plain

これでもう画面表示のエミュレーションができたような気持ちになってしまいますが、実はそうではありません。
glDrawPixels()で指定しているアドレスはimgであって(Emulator.memory + VRAM_ADDR)ではないのです。

何故VRAMを直接glDrawPixels()に渡してはいけないのかというと、実はVRAMに保存してあるのはRGBデータではなく、0x00から0xffまでの色番号だからです。
これではRGBで色を指定できないように思えますが、実はRGBデータはパレットという外部装置に、「どの色番号をどのRGBと対応させるか」ということをあらかじめ設定しておくという仕様になっているので、VRAMには色番号を書くだけで画面描画ができるようになっているのです。おそらく、画面描画を高速に行えるように、このように設計されているのでしょう。

ということで、このエミュレータでもパレットにRGBを設定して、VRAMを読んでその色番号に登録されているRGBで描画するようにしたいと思います。
パレットの作り方ですが、ようは0xff個のRGBデータがあればいいので、unsigned char palette[0xff * 3];でいいでしょう。 そして、これは外部装置なのでin/outでpaletteのポート(0x03c7, 0x03c8, 0x0xc9)が来たときに設定とか読み出しができればいいわけです。
今回はin/outについていじる気はないのでやりませんが、外部装置はDeviceクラスを継承して作るようにしたので、Displayクラスを作ってその中にpaletteを置いています。

class Display : public Device {
private:
    unsigned char palett[oxff * 3];
    uint8_t *vram;
    unsigned char *img;
    int scrnx, scrny;

    void init_palette();
public:
    Display(uint8_t *vram);
    ~Display();
    unsigned char* Draw();
};

今回はin/outについていじる気は無いので、init_palette()でパレットの初期設定をしてしまいます。

void Display::init_palette(){
    static unsigned char table_rgb[16 * 3] = {
        0x00, 0x00, 0x00,
        0xff, 0x00, 0x00,
        0x00, 0xff, 0x00,
        0xff, 0xff, 0x00,
        0x00, 0x00, 0xff,
        0xff, 0x00, 0xff,
        0x00, 0xff, 0xff,
        0xff, 0xff, 0xff,
        0xc6, 0xc6, 0xc6,
        0x84, 0x00, 0x00,
        0x00, 0x84, 0x00,
        0x84, 0x84, 0x00,
        0x00, 0x00, 0x84,
        0x84, 0x00, 0x84,
        0x00, 0x84, 0x84,
        0x84, 0x84, 0x84
    };
    
    for(int i=0; i<(16*3); i++){
        palette[i]  = table_rgb[i];
    }
}

やっていることは「はりぼてOS」の画面表示初期化関数であるinit_palette()とほとんど同じです。 設定するRGBのデータをそのままpaletteにコピーしています。

パレットができたので、次は画面描画をパレットに対応させます。ここまでできているので、あとはそんなに難しくありません。

unsigned char* Display::Draw(){  
    for(int x=0;x<scrnx;x++){
        for(int y=0;y<scrny;y++){
            int i=(y*scrnx + x)*3;
            char n = vram[y*scrnx + x]; //当該座標の色番号
            img[i]  = palette[n*3]; //色番号に対応したRGB
            img[i+1]= palette[n*3+1];   //green
            img[i+2]= palette[n*3+2];   //blue
        }
    }
    return img;
}

Displayクラスの中にDraw()という関数を作りました。この関数はVRAMから色番号を読んで、その色番号のパレットのRGBを参照してRGBの画面データを作るものです。あとはこの関数で作った画面データをglDrawPixels()で指定してやれば、VRAMの内容を反映して画面描画できるようになるはずです。

void GUI::display(){
    glClearColor(0.0, 0.0, 0.0, 0.0);
    glClear(GL_COLOR_BUFFER_BIT);
    glRasterPos2f(-1,1);
    glDrawPixels(scrnx, scrny, GL_RGB, GL_UNSIGNED_BYTE, disp->Draw());
    glFlush();
}

glDrawPixels()で指定している画面データがimgからDisplay.Draw()になっただけですね。

ではmake runして動作確認してみましょう。

f:id:sksat:20161221182751p:plain

何も表示されません...
まあそれはそうですね。いまのところVRAMに何も書き込んでいないわけですから(正確には、色番号0が黒に設定されているから)。 というわけで、VRAMになにか書き込んでみましょう。
では、『OS自作入門』の4日目の「画面表示の練習」と同程度のことをやってみます。 ここで、VRAMにデータを書き込むバイナリを読み込んでできれば文句はないのですが、僕が機械語の実装をサボっていた(本にすでにあるものぐらいやっとけよ、というかんじですね・・・)ので、バイナリのほうからメモリに値を書き込むことができません。そこで、main.cpp内でVRAMに書き込んでしまいます。

int i;
char *p;
for(i = 0xa0000; i< = 0xaffff; i++){
    p = (char*)(emu->memory + i);
    *p = i & 0x0f;
}

これでVRAMに書き込めたはずなので、make runしてみます。

f:id:sksat:20161221184720p:plain

おお!ちゃんとしましま模様が表示されました!成功です!

終わりに

(内部からVRAMに書き込むというズルはしていますが)画面表示のエミュレーションが一通りできたので、この記事はここまでです。ここまで見てくださり、ありがとうございました!

とりあえず、しばらくはバイナリのほうからVRAMに書き込めるようにすることを目標にして頑張ってみます。進捗が上がったらまたなにか書くかもしれません。

この記事で作っているエミュレータは、以下のリポジトリで公開しています。良かったら見てみてください。

github.com

「はりぼてOS」の背景を表示してみた(おまけ)

f:id:sksat:20161221185532p:plain 内部でboxfill()を作って、はりぼてOSと同じ値でboxfill()しています。