エミュレータについて
遊びで作っているエミュレータについていろいろまとめておこうかなというだけの投稿。
実験で作った CPU のエミュレータです。 最初は実機でデバッグするのが辛いだろうということでアセンブラに簡易的な実行機能を つけようといった程度のモチベーションで始めたんですが、デバッガの実装が意外に面白かったので だんだん機能が増えてきているという感じです。
機能
今の所こんな感じです。
- アセンブリで書いたプログラムを実行する
- 分岐命令の飛び先のアドレスをラベルで指定できる
- メモリの初期化ファイルに書かれた内容で内部のメモリを初期化
- 命令のアドレスにブレークポイントを置く
- 実行した命令をもとに戻す (Undo)
- 未定義動作を踏みそうになったら例外を飛ばして威嚇する
- ワード単位でのイミディエイトをサポートする疑似命令
- 実際にメモリの内容を読んで実行する (自己書き換えみたいなプログラムも動く)
で今後無駄に実装しようと思っているのが
- レジスタとかメモリにウォッチポイントを置く
- 条件付きブレークポイント
という感じです。 実験で使えるという実用面からしてかなりオーバースペックになりそう。
使い方
実行
基本の使い方はこんな感じです。
- デフォルトでも適当なプログラムが入っていますが、左側のエディタの「Program」タブを選び、アセンブリでプログラムを書きます。
- 初期化が必要なメモリ領域があれば、同じように左側のエディタの「Memory」タブを選び、初期化ファイルの内容を書きます。
- 上のバーの一番左にある「Load」をクリックします (これで一通りエミュレータの初期化が行われます)。
- 上のバーの右の方の「Next Clock」ボタンを押すか、キーボードの右の方向キーを押すと 1 クロック分前に進みます (パイプラインは考慮していないので 1 クロック目でいきなりレジスタとかメモリが変わる)。
注意点としては今の所メモリから読んだ命令を実行しているわけではないので、自己書き換えみたいなコードは 動かないです。
見たいメモリの範囲を変えたい場合はメモリ表示エリアの一番上の入力欄から変えられます。 開始アドレス (inclusive) と終了アドレス (exclusive) を入れて「Set Range」を押すと メモリの表示範囲が変わります。手動で入力するのが面倒な場合はそれぞれの入力欄の上でスクロールすると 範囲が変わります。あと「Set Range」ボタンの上でスクロールすると広さはそのままで範囲がずれる 感じの挙動になっています。
最初に未定義動作を踏みそうになったら例外が上がるという話をしましたが、現状で実装してあるのは
- ワードアクセスの命令でアラインされていないアドレス (奇数番地) にアクセスする
という部分です。遅延スロットに分岐命令を入れた場合も未定義らしいのでそのうち実装します。
アセンブラ
上のバーの「Copy Executable」というボタンを押すと、 アセンブリのコードをメモリの初期化ファイルに変換したものがクリップボードに入ります。
エディタの Memory タブに入力した内容は考慮されていないので、必要であれば結合したりして使うことになります。
メモリの内容が丸ごとほしい場合は「Download Memory」
というボタンが使えます。
これは mem
という unsigned char
の配列にメモリの内容を入れるという
C のソースコードになっていて、こういう使い方を意図しています (mem.inc
というファイルに保存するとします)。
int main(void) {
static unsigned char mem[0x10000];
#include "mem.inc"
// mem を使っていろいろする
}
一応 ANSI C でもコンパイルできるはず。
ラベルへのジャンプ
分岐とかジャンプとかの命令は相対アドレスのかわりにラベルを引数として取ることができ、 そうすることで飛ぶ位置とのアドレスの差を意識せずにコードを書けるようになっています。 また、[-128, 128) の範囲にしかジャンプできないという ISA の制約がありますが、 ラベルを使った場合は途中に飛び石みたいにジャンプ命令を置いてこの制約を意識せずに コードを書けるという感じになっています。 途中に命令を入れる関係上、既存の分岐命令の相対アドレスをそのまま使った場合に 壊してしまう可能性がありますが、その部分も適当に処理して既存の命令列の意味を変えないように しています。
命令のアドレスにラベルを置くには @任意の名前
と行頭に書きます。名前は C の識別子と同じ感じのルールで
つけられます (ちなみに L1:
みたいなよくある感じの文法にしなかったのは、:
を読むまで命令かラベルかの区別がつかず、
パースするのが若干面倒臭くなるからです)。
逆にラベルを参照するにはこれまでイミディエイトを入れていた箇所に代わりに @名前
と入れます。
1行目と5行目の間でループする例です:
@loop sw r2, (r0)
addi r0, 2
mov r3, r1
sub r3, r0
bnez r3, @loop
nop
ラベルは定義の前に参照が来るのも合法ということになっています。これはどういうことかというと、 このコードは 1 行目から 4 行目まで飛ぶコードということになります:
j @label
nop
nop
@label addi r0, 1
また、同じ場所でずっとループさせたいみたいなときはちょっと歪な感じですが、こうなります:
@loop j @loop
nop
注意点としては遅延スロットという呪縛からは逃れられないのでちゃんと考慮して命令を入れるなり nop を入れるなりしないといけないです。
ワード単位でのイミディエイトのロード
「上位 8 ビットを読み込んで下位 8 ビットを OR することで 16 ビット読み込む」パターンは 疑似命令として実装してあり、1 行で書けます。
.li r0, 0xbeef
# 以下と等価
lui r0, 0xbe
ori r0, 0xef
このパターンでは特別なラベルの使い方をサポートしており、ラベルを置くことでそのアドレスが代入されます。 また、ラベルのアドレスへの加算と減算をサポートしています。
.li r0, @foo
.li r1, @foo+1
.li r2, @foo-1
@foo nop
# 以下と等価
lui r0, 0x00
ori r0, 0x0C
lui r1, 0x00
ori r1, 0x0D
lui r2, 0x00
ori r2, 0x0B
nop
これにより、プログラムの最後の位置にラベルを置いておくことで簡単にデータの書き込みに利用できるアドレスを 特定することができます。
任意のバイト列の埋め込み
.word
疑似命令を使って任意のバイト列を埋め込めます。
.word 0xDEAD
.word 0xBEEF
↓
@00 11011110 10101101 // <raw data>
@02 10111110 11101111 // <raw data>
ラベルのアドレスを読み込む疑似命令 (.li
) と組み合わせると埋め込んだデータを
利用できます。
注意点としてはデータが埋め込まれたアドレスを実行しようとすると (たとえそれが有効な命令を表すデータであっても) Illegal instruction の例外が飛びます。というわけで continue とかで実行するときには データのアドレスの直前にブレークポイントとかを入れといたほうがいいかもしれません (どっちにしても実行は止まるので関係ないといえば関係ないですが)。
自己書き換えについて
このコミット までの一連のコミットでとりあえずメモリから読んだコードを実行できる感じにしました (プログラムエクスポート機能を壊してしまったので一回慌てて revert したのは内緒)。
ただ、ここでパイプライン処理を実装にしなかったことが祟っていて、 書き換えが実機よりも遅くても間に合うようになっているはずです。 ただ、間に合わないタイミングで書き換えると書き換え前のコードを走らせるみたいな実装にするのは 今の実装では相当きついので、とりあえず優しい気持ちで触って動けばいいやという気持ちで実装しています。
副次的な効果として、Program のタブに何も書かずに Memory のタブにアセンブル済みのデータを流し込んで実行、 みたいなのも動くようになりました。
デバッガ
実行されているアドレスがハイライトされますが、普通のデバッガと違って実行済みの命令のところに
ハイライトがつきます。そのうち直します。
→普通のデバッガと同じように、次に実行される行がハイライトされるようになりました。
ブレークポイント
真ん中のペインの下の方の Trace というところに実行される命令がズラズラと並んでいます。 その左側に Break というチェックボックスがありますが、それにチェックをつけることで ブレークポイントを置けます。
ブレークポイントのアドレスがフェッチされたら実行が止まり、そこからnextしたところでブレークポイントの下の 命令が実行されるという感じです。このへんは普通のデバッガと同じです。
メモリやレジスタの書き換え
メモリやレジスタの中身が表示されている部分に値を入力して対応する部分の「Apply Changes」 を押すと入力した内容がエミュレータのメモリに反映されます。
10 進数で入れても 16 進数 (0x
のプレフィックスが必要) で入れても動くはずです。
Continue
上の「Continue」ボタンを押すことでブレークポイントまでが高速に実行されます。 一回「Continue」を押すとボタンが「Interrupt」に変わるのですが、それを押すことで continue しているのを止められます。ただし 10 クロックごとにしか止まらないと思うので 厳密なことはできないと思います。
逆方向に実行
そのまんまです。「Reverse Next」を押すと時間が巻き戻され、実行していない状態に戻ります。
メモリやレジスタを書き換えていると書き換えたタイミングでその値も元に戻ります。 ただ、その後に順方向に実行しても自分が書き換えなかった場合の実行結果しか得られない ので注意が必要です。「Reverse Next」は undo ですが、「Next Clock」は redo ではないです。単純に現在の状態から次のクロックの状態まで実行するだけです。
rr とかを使うと GDB でも rn
ができますが、これが便利だと思っていたので
実装しました。
内部実装について
OSSとして公開していて、ソースコードはここに置いてあります。
アセンブラやエミュレータ本体は C++ で実装されていて、それを Emscripten で
コンパイルしてブラウザで走らせています。
C++ で実装した理由としては、最初はブラウザで動かすことをあんまり意識していなかったのと、
C か C++ か Rust か Go とかが候補で、ここから選んだからです。
C はまずめんどくさいので外すとして、で、まあこの中だったら適当なものを適当に作るのは
C++ が一番楽かなという短絡的な考えで決めました (結局テスト書いたりしているので
Rust とか Go とかの方が良かったんですが)。
一応ベタッと実装するんでなく、命令セットを定義した JSON ファイルからコード生成で アセンブラとエミュレータを生み出すという無駄な努力によって拡張性を確保しています。 後で命令を足したくなるかもしれないので。
あんまり関係ないですが、なんとなく自動でビルドとデプロイ走ったほうが気持ちがいいという理由で master に push したときに GitHub Actions でビルドが走り、GitHub Pages にデプロイされるようになっています。