bobuhiro11's diary

x86エミュレータ開発記録 (4)

19 Sep 2018
[x86]

先月、Go 1.11がリリースされ、WebAssemblyが実験的にサポートされるようになった。 開発中のエミュレータもGoで書かれているということで、せっかくなので、 WebAssemblyに移植した。 単純にGoをWebAssemblyにコンパイルすると標準出力としてブラウザのコンソールが使われるが、 divタグの中に出力したかったので、syscall/jsパッケージでDOMを操作した。 その他の部分は特に変更の必要がなかった。 https://bobuhiro11.net/tiny_x86_emu/に公開している。 まだ開発途中という状態で、ユーザプロセス実行に関する開発が滞っているので、そろそろ手をつけていきたい。 tiny_x86_emu_wasm_screenshot


x86エミュレータ開発記録 (3)

19 Aug 2018
[x86]

mpmain()scheduler()swtch()とプロセス切り替えできるまで、 エミュレータの開発が進んだ。 xv6のコードproc.cの334行目sti命令をずっと発行していて、どうやらループに入っているようなので、 ここでスケジューリング、コンテキストスイッチ周りをまとめたい。 切り替え先のプロセスは、切り替えられた直後forkret()iinit()関数内にて、IOを行っている。 ここで、スリープ状態に入り、割り込みを待っているようだ。

proc構造体

xv6では各プロセスはproc構造体で管理される。 contextには、コンテキストスイッチのためのレジスタを格納する。 CSなどセグメントレジスタは、プロセス間で共通なので、保存する必要がない。 EAX、ECS、EDXなどは関数呼び出し時に自動的にスタックに保存されるため、contextに含めない。 最初のユーザプロセスは、userinit関数とallocproc関数で作成する。 proc構造体のコメントには、最初のユーザプロセスのデータをメモしておく。 トラップフレームは、ハードウェアとtrapasm.Sによりスタック上に積まれるもので、 trap関数へと引き渡される。 forkret関数のreturn先は、trapretに設定する。

struct proc {
  uint sz;                     // Size of process memory (bytes)
  pde_t* pgdir;                // Page table
  char *kstack;                // このプロセスのカーネルスタックの底
  enum procstate state;        // プロセスの状態:EMBRYO
  int pid;                     // PID:1
  struct proc *parent;         // 親プロセス:NULL
  struct trapframe *tf;        // トラップフレーム
  struct context *context;     // このプロセスのコンテキスト
  void *chan;                  // If non-zero, sleeping on chan
  int killed;                  // If non-zero, have been killed
  struct file *ofile[NOFILE];  // Open files
  struct inode *cwd;           // カレントディレクトリ:/
  char name[16];               // プロセス名:initcode
};

struct context {
  uint edi;
  uint esi;
  uint ebx;
  uint ebp;
  uint eip; // forkret
};

struct trapframe {
  // registers as pushed by pusha
  uint edi, esi, ebp, oesp, ebx, edx, ecx, eax;

  // rest of trap frame
  ushort gs, padding1, fs, padding2, es, padding3, ds, padding4;
  uint trapno;

  // below here defined by x86 hardware
  uint err;
  uint eip;
  ushort cs;
  ushort padding5;
  uint eflags;

  // below here only when crossing rings, such as from user to kernel
  uint esp;
  ushort ss;
  ushort padding6;
};

struct {
  struct spinlock lock;
  struct proc proc[NPROC];
} ptable;

カーネルスタック

proc->kstackの中身を図示しておく。 swtchでは、espproc->contextとして、edi、esi、ebx、ebpをpopする。 そして、最後にret命令で、eipを復元する。

(lower address)
 ------------------- 
|                   | <= proc->kstack
|                   |
 ------------------- 
 ------------------- 
| edi               | <= proc->context
| esi               |
| ebx               |
| ebp               |
| eip = forkret     |
 ------------------- 
 ------------------- 
|                   | <= trapret(関数ポインタ)
 ------------------- 
 ------------------- 
|                   | <= proc->tf
 ------------------- 
                      <= proc->kstack + KSTACKSIZE(4096)
(upper address)
 

参考


はじめて読む486の読書メモ

04 Aug 2018
[x86]

初めて読む486を読んだので、気になってことをメモしておく。 エミュレータを作っているので、参考になる。 全体を通して、図が分かりやすいので、図を見るだけでも勉強になる。

セグメント

  • EIPから命令を読み出すときにはCSレジスタの指すセグメントが使われ、命令実行時のメモリアクセスにはDSレジスタの指すセグメントが使われる。また、PUSH、POP、CALL、RETなどスタックに関する操作には、SSレジスタの指すセグメントが使われる。スタックは下位アドレス方向に伸びるため、スタックセグメントのリミットは例外的に下限を指定する。
  • すべてのメモリアクセスのたびにセグメントディスクリプタを参照するのは、無駄が多いので、CPU内にセグメントディスクリプタ自体のキャッシュ(セグメントディスクリプタキャッシュ)を持つ。
  • プロテクトモードでは、CSレジスタが16ビットセグメントを指していれば、オペランドサイズとアドレスサイズは16ビットになる。同様に、32ビットセグメントでは、それらのサイズは32bitになる。
  • セグメントごとにDPL(Descriptor Privilege Level)で特権レベルを指定する。現在実行中のセグメントのDPLによって、CPL(Current Privilege Level)が決まる。CPLはCSレジスタの下位2bit、DPLはセグメントディスクリプタで設定される。ジャンプ先セグメントのDPLがCPLより高い場合には、直接ジャンプすることはできず、コールゲートを経由する必要がある。

ディスクリプタ

  • GDTには、セグメントディスクリプタのほかに、TSS、コールゲート、タスクゲート、トラップゲート、割り込みゲートなど複数のシステムオブジェクトが格納される。
  • 各種ゲートディスクリプタにもDPLを設定しておき、ゲートを呼び出すときに、CPLとDPLを比較する。CPLがゲートのDPL以上であれば、ゲートの先のセグメントのDPLによらず、そのセグメントにジャンプできる。
  • LDTを使うときは、GDT内にLDTを指すディスクリプタを作成して、そのセレクタ値をLDTRに設定する。

割り込み

  • 割り込み番号は0x00~0xFFまで全256種類。
  • 割り込みディスクリプタテーブル(IDT)には、ゲートディスクリプタを並べておく。割り込み番号がIDT内オフセットとなる。ゲートの種類は、割り込みゲート、トラップゲート、タスクゲートの3種類。
  • ふつうRET命令は単純にCS,EIPをPOPするだけだが、POPしたCSのDPLがCPLよりも低ければ、続けてSSとESPもPOPする。
  • 低特権レベルからコールゲートを介してOSの関数を呼び出したとき、引数は低特権レベルのスタックにしか存在しないため、OSから簡単に扱えない。対策として、コールゲートのコピーカウントというパラメタに、引数のバイト数/4を設定しておき、自動的に引数をOS側のスタックにコピーさせる。戻るときは、オペランド付きRET命令を使い、OSのスタックをインクリメントさせる。

I/O

  • IOポートはディスクリプタがないので、DPLを持たないが、代わりにIOポート専用の特権レベルIOPRL(I/O Privilege Level)がある。IOPRLは、EFLAGSレジスタで設定される。また、TSS内のIO許可マップで制御することもできる。

その他

  • クロックダブラーと呼ばれるクロック周波数を2倍にする技術があった。486DX2やODP(Over Drive Processor)で使われていた。
  • OSが利用するシステムレジスタはGDTR、IDTR、LDTR、TR、CR0~CR3。
  • DR0~DR7を操作することでブレークポイントを仕掛けて、デバッグできる。
  • アドレスバスとデータバスは32ビット、IOアドレスバスは16ビット。
  • オペランドサイズとアドレスサイズはそれぞれ32ビットあるいは16ビットで解釈される。どちらもプレフィックス(0x66あるいは0x67)をつけることでサイズを変更できる。
  • 現在のタスクが使っているTSSのセレクト値は、TR(Task Register)に保持する。
  • 同特権レベルでのCALL命令であれば、CSとEIPをPUSHして、ジャンプするだけ。異なる特権レベルへ移るためのコールゲートへのCALL命令であれば、スタックを切り替えてから、SS、ESP、CS、EIPをPUSHする。

x86エミュレータ開発記録 (2)

21 Jul 2018
[x86]

xv6のページングについてまとめておきたい。 カーネルmain関数では、ページテーブルやページディレクトリの初期化が2度行われる。

1度目のページング機構の初期化は、 仮想アドレスの下位4MB 0x0 ~ 0x400000 をそのまま物理アドレス 0x0 ~ 0x400000 にマッピングする。 また、0x80000000をオフセットとした仮想アドレス 0x80000000 ~ 0x80400000 についても、 同じく物理アドレス 0x0 ~ 0x400000 にマッピングする。 このページングはカーネル初期化時にのみ利用される。 CR4のPage Size Extension(4bit目)を有効にすることで、 ページサイズを4MBに拡張している(スーパーページ)。 スーパーページでは、ページテーブルは利用せずページディレクトリだけを使う。 仮想アドレスの上位10ビットがページディレクトリのインデックス、 下位10ビットが4MBページ内のオフセットに対応する。 つまり、1段のページング。 kinit1関数では、カーネルの終端から4MB(0x801154a8 ~ 0x80400000)のページに対してkfreeを実行し、 freelist(空きメモリをつなげたlinked list)に繋いでいく。

続いて、2度目の初期化では、 4MBを超える仮想アドレスを対象としている。 こちらは、スーパーページは使わないので、ページサイズは4KBのままで、 ページディレクトリとページテーブルのどちらも使う。つまり、2段のページング。 kinit2関数で、0x80400000 ~ 0x8e000000のページをfreelistに繋ぐ。

今回は、2度目の初期化のところでバグがあったため、 kinit2内で不正なアドレスに書き込みを行っていた。 2段のページング機構でのページウォークを見直して、解決した。 CR3の上位20bitがページディレクトリのベース物理アドレスを指しているが、 そのアドレスはCR3 >> 20ではなくCR3 & 0xFFFFF000であることに注意する。 ページディレクトリエントリについても同様のことが言える。 つまり、ページディレクトリやページテーブルは4KBでアライメントされている。


x86エミュレータ開発記録 (1)

01 Jul 2018
[x86]

自作エミュレータで学ぶx86アーキテクチャ という本を読んだ。 その続きとして、主に教育向けに使われているx86 OSのxv6を完動させることを目標に、 自作エミュレータを拡張していこうと思う。 比較的長期間にわたって開発することになるので、一旦今の状況をまとめておきたい。 現状では、ブートローダによってカーネル本体をメモリ上に展開し、 カーネルのmain関数から実行を開始できる状態になっている。 より正確には、ブートローダからカーネルのmain関数へと進み、 どこかの地点でカーネルパニックが起きている状態である。 また、シリアルポート(I/O Port 0x3F8)のエミュレートをできるようになっているので、 カーネルから cprintf 関数や panic 関数を呼び出すことで、任意の文字列を出力できる。 このあたりまでくるとCPU自体のエミュレートは落ち着いてきて、 各種IOや割り込みの実装が中心になってきていると感じる。 ある程度までまとまったら、コードを公開したい。

拡張にあたって、まずは本のコードの写経から始めた。 足りない命令については、インテルの仕様書を参考に随時追加していった。 Mod R/MやSIBバイトの動作については、手当たり次第に実装していたが、 インテルの仕様書を見れば一発でわかる話だった。 例外的な動作が多く、例えば、SIBバイトによるアドレス指定で Base=5 としたときには、 レジスタでなく disp8disp32 を使うことになる。 開発初期は各命令の動作を検証するために、QEMUの動作を正として、以下のようにQEMUのレジスタをトレースするGDBスクリプトを用意した。 10万ステップにわたり、自作エミュレータとQEMUのレジスタが一致するかどうかテストしながら、開発を進めた。

target remote localhost:1234
set architecture i8086
set confirm off
break *0x7c00
c
set variable $i = <numstep>
while $i > 0
si
info registers
set variable $i -= 1
end
quit

xv6の動作についても、簡単にまとめておく。 xv6のイメージを指定し自作エミュレータを起動すると、 16bitモードで起動し、EIP=0x7c00 の状態からbootasm.Sのブートローダを開始する。 その後、32bitモードに移行して、Cコード bootmain.cへジャンプする。 bootmain.cでは、ディスクからELF形式のカーネル本体を読み込み、メモリ上に展開する。 main.cのカーネル本体では、 mpinit 関数や lapicinit 関数などから、各種デバイスの初期化を順に実行していく。 上から順番に初期化していれば良いので、わかりやすい。 コンテキストスイッチあたりまで話が進むとデバッグが大変になるんだろうと思う。

参考