OSECPUのバイトコードの詳細仕様
(0) はじめに
(1) 整数レジスタ
- OSECPUは32bitの signed int な整数レジスタを64本持っています。実装仕様としては64bitで演算する場合も許されているので、最低でも精度が32bit分ある、ということだと思ってください。
- R00~R3F と表記します。
- R00やR01など番号の若いレジスタは、実際のCPUの実レジスタに割り当てるように推奨されているので、R00への代入やR00の値の参照は、R20に対する操作と比べて数倍高速になることが多いです。
- 結局どうなるかは処理系依存で、保障はされていません。
- ということで、特にこだわりがないのならR00やR01を使いましょう。
- ちなみにx86版では、R00~R02までが実レジスタに割り当てられていて、残りはメモリを使ってレジスタをエミュレーションしています。
- これらのレジスタはすべて対等で汎用的に使われるというわけではなく、ある程度の使い方が決まっています。これに逆らってはいけないということはないですが、ライブラリなどで食い違うといろいろ面倒かもしれません。
- R00~R1F (32本) : 最も汎用的な整数レジスタで、通常は関数ごとにローカルとして扱えます。つまり関数を呼び出しても破壊されたりはしません。
- R20~R27 ( 8本) : 汎用ですが、関数ごとにローカルというわけではなく、グローバル変数的に使うことを想定しています。関数呼び出しによって変更される可能性もあります。
- R28~R2F ( 8本) : これも汎用ですが、関数ごとにローカルではなく、グローバル変数的に使われます。主にOSが用途を決定しています。これに対してR20~R27はアプリが自由に用途を決定できます。
- R30~R3B (12本) : 基本的にはこれらも汎用なのですが、関数の引数を渡したり、返値を入れたりするためにも使われるレジスタで、値が破壊されやすいです。ASKAでは複雑な数式を計算しなければいけなくなると、R3BやR3Aをテンポラリとして勝手に使い、値を破壊してしまうこともあります。R39やR38にまで手をつけることだってあります。しかし最悪でもR30までで、R2Fに手出しすることはありません。
- R3C~R3E ( 3本) : 将来の拡張のためにリザーブされています。
- R3F ( 1本) : 後で説明する特別な用途のための整数レジスタです。汎用には使えません。
- 全体として、OSECPUのレジスタはかなり多いほうだと思います。これはOSECPUがメモリ操作を苦手としていて、できるだけレジスタだけで主要な演算が完結できるようにという設計方針によるものです。
(2) フラグレジスタ
- x86でもARMでも、さらに6502やZ80でも、みんなフラグレジスタというものを持っていました。
- しかしOSECPUにはフラグレジスタはありません。MIPSの仕様に似ています。
- フラグレジスタがない代わりにCMPcc命令の結果に応じて任意の整数レジスタを0か-1に変更することができます。つまり普通のレジスタをフラグレジスタの代わりにしてしまったようなものです。
- これで設定された値を後述のCNDプリフィクス命令(04 Rxx)で使えば、条件分岐や条件付き代入などができます。
(3) 定数即値代入命令
[02] [Rxx] [imm32]
LIMM(Rxx, imm32);
- 6バイト命令です。Rxxには0x00から0x3fのレジスタ番号を指定します。imm32は定数即値を4バイトのビックエンディアンで指定します。
(4) 単純代入命令
[10] [reg0] [reg1] [FF]
CP(reg0, reg1);
- 4バイト命令です。Rxxフィールドが複数ある命令では、ここではreg0, reg1のように区別して表記することにします。
- reg1の値がreg0へコピーされます。
- reg0にR3Fを指定することはできません。reg1にもR3Fを指定してはいけません。
- この命令はバイトコード的には、OR命令でreg2にFFを指定した形式になっています。
- メモリの内容をコピーすることはこの命令ではできません。
(5) 三項演算命令
[10] [reg0] [reg1] [reg2]
OR(reg0, reg1, reg2);
(6) 整数比較命令
[20] [reg0] [reg1] [reg2]
CMPE(reg0, reg1, reg2);
- 4バイト命令です。reg1とreg2を比較して等しいかどうかを判定します。trueならreg0には-1が代入されます。falseなら0が代入されます。
- 他にも以下のような命令がこの形式になっています。
- 21 CMPNE (等しくなければtrue)
- 22 CMPL ( reg1 < reg2 が成立すればtrue)
- 23 CMPGE ( reg1 >= reg2 が成立すればtrue)
- 24 CMPLE ( reg1 <= reg2 が成立すればtrue)
- 25 CMPL ( reg1 > reg2 が成立すればtrue)
- 26 TSTZ ( reg1 & reg2 の演算結果が0ならtrue)
- 27 TSTNZ ( reg1 & reg2 の演算結果が0でなければtrue)
- 三項演算命令と同様にreg2に対してはR3Fの指定が可能です。reg1に対しては指定できません。
- そしてなんとreg0に対してもR3Fを指定することができるのですが、この場合は定数値
代入という意味ではなくて、特別な慣用句の指定を意味します。それについては後述します。とりあえずは「reg0にはR3Fを指定できない、指定したら意味が変わる」くらいに思っていてください。
(7) 何もしない命令
[00]
NOP();
- 1バイト命令です。何もしない命令です。JITコンパイラはこの命令を翻訳しません(実CPUに対するNOP命令になるわけではない)。
- ここからしばらくは制御系の命令を紹介します。
(8) ラベル定義命令
[01] [opt] [imm32]
LB(opt, imm32);
- 6バイト命令です。OSECPUでは分岐命令を実行する際には、絶対アドレスや相対アドレスなどを利用せずに、ラベル番号を指定する方式になっています。ということで、分岐先はこの命令を使ってラベル番号を定義しておかないといけません。imm32がラベル番号に相当します。
- 当然ですが、同じラベル番号を複数回使うことはできません。使えるのはひとつのアプリの中では1回だけです。
- DLLとアプリとでラベル番号がぶつかってしまうことに関しては問題ありません。
- optですが、今のところ指定できるのは0か1だけです。0は普通のラベル属性で、分岐先指定できます。1はpublic属性を意味して、分岐先に指定できるだけではなく、ポインタレジスタに代入したり、メモリに格納したりできます。逆に言えば、0属性のラベルは、ポインタレジスタやメモリに格納することができません。その代わり消費リソースは少なくてすみますので、なんでも全部1属性にするとかは勘弁してください。
- ラベル番号は本来は32ビットの任意の整数(負の数もOK)なのですが、現状の実装では0~4095までしか処理できません。すみません手抜きです。
(9) 無条件分岐命令
[03] [3F] [imm32]
PLIMM(P3F, imm32);
- 6バイト命令です。OSECPUではP3Fというポインタレジスタに特別な意味があり、これに対してラベル番号を代入するとそこへ無条件分岐します。いわゆるgotoです。
- しつこいですがポインタレジスタについては後述します。とりあえず分岐先を保持できるレジスタだと思ってください。整数レジスタRxxとは別の存在なので、P02を使ったらR02が使えないとかそういうことはありません。
- PLIMMというのはLIMMのポインタレジスタ版で、ラベルの値をポインタレジスタに代入することができます。・・・とにかく詳細は後述します。
(10) 条件分岐命令など
[04] [Rxx] [03] [3F] [imm32]
CND(Rxx); PLIMM(P3F, imm32);
- これは条件分岐命令です。よく見ると無条件分岐命令の前にCNDプリフィクス命令がついているだけです。CND命令は2バイトで、PLIMM命令は6バイトなので、このフレーズは合計8バイトです。
- CNDプリフィクスには後続の1命令を条件実行命令化する作用があって、つまり後続のPLIMM命令が実行されたりされなかったりするのです。
- CNDプリフィクスはRxxの下位1bitしか参照しません。それが1だと後続の命令をそのまま実行します。0だと後続の命令は実行されません。
- CNDプリフィクスは他の命令、たとえばLIMMやADDの前につけることもできます。しかしCNDプリフィクスの前にCNDプリフィクスをつけることはできません。R3Fへの代入のためのLIMM命令にはCNDプリフィクスをつけられません(その代わり、その直後の演算命令にはつけられますので定数を含む命令の条件実効命令化は可能です)。
- 他にもいくつかCNDプリフィクスがつけられない命令があります。
- CNDプリフィクスのRxxに定数即値としてのR3Fを指定することはできません。CNDプリフィクスにR3Fを指定することそのものは不可能ではないのですが、その場合意味が変わります。
(11) 条件分岐慣用句
- OSECPU用アプリをいくつか作ってみてわかったことですが、
CMPxx(Rxx, ...); CND(Rxx); PLIMM(P3F, ...);
- のパターンが頻出します。比較して条件分岐ということです。そしてこの後でRxxの値を参照することもありません。純粋に条件分岐のためだけに一時的にRxxに値を入れているだけです。
- 一方でJITコンパイラは、CMP系の命令のときに、結構苦労して0や-1の値を生成してRxxに代入しています。CND命令の部分では一手間かけて最下位ビットを抽出しています。これは明らかに無駄です。結局正確な値なんていらなくて、直後の条件分岐さえ間違わずに実行してくれればそれでいいわけです。
- そうであれば、もっと単純なコードを生成できるので、そのことをJITコンパイラに教えてやりたいと思いました。ということで、以下のような特別な慣用句が用意されています。
CMPxx(R3F, ...); CND(R3F); PLIMM(P3F, ...);
- つまりRxxの部分が形式的にR3Fになっているのです。このR3Fは定数即値指定のR3Fと同じように見えますが、内部では完全に別なものとして扱われています。したがってCMPxx命令の第3項にR3Fを使用することもできますし、それで混同することはありません。そして以降の命令でR3Fを参照してもこのCMPxxの結果を得ることはできません(次のLIMM(R3F, ...); を実行するまでR3Fの値は保障されません)。
- この慣用句を使えば、条件分岐のためにわざわざ整数レジスタを一個使う必要もなくなります。
- なお、ANDやORを含むような複雑な条件では、この構文は使えません。本当にCMPxx, CND, PLIMMが連続で並んでいて、間に余計な命令が挟まっていないときだけ使えるのです。
if (R00 <= R02 & R02 <= R01) goto 3;
CMPLE(R30, R00, R02); CMPLE(R31, R02, R01); AND(R30, R30, R31); CND(R30); PLIMM(P3F, 3);
- CNDの直後はPLIMM(P3F, ...);でなければいけないので、分岐以外の構文でもこれは使えません。
- しかしそれでもこの構文は便利なので本当によく使います。CND命令を使うところの8割くらいはこれだと思います。
(12) ループの構成法
- 次に上記のLB命令の直後に適当な条件式を加えて、それが成立した場合に、PLIMMの直後へ分岐させてみます。
LB(0, 123);
CMPE(R3F, R00, R01); CND(R3F); PLIMM(P3F, 124);
...
PLIMM(P3F, 123);
LB(0, 124);
- これはつまり while(R00 != R01) { ... } というループです。ループはいる前に初期値を設定したり、ループの末尾からジャンプする前に更新式を入れれば、forループにもなります。
- 途中でbreakしたいときは、いつでも PLIMM(P3F, 124); すればいいことになります。
- ループ末尾のジャンプ命令を、条件付きに変更すると、do { ... } while (R00 != R01); にもできます。
LB(0, 123);
...
CMPNE(R3F, R00, R01); CND(R3F); PLIMM(P3F, 123);
(13) 間接分岐命令
(14) 関数の呼び出し
- ちなみに関数のほうでは、処理が終わって呼び出し元にreturnしたくなったら、PCP(P3F, P30); を実行しているだけです。
- こういうことになっているので、ポインタ引数を渡したいときはP30は使えなくてP31~P3Bを使うことになります。
(15) 関数の構成法
- [01] [01] [imm32] : これはラベル宣言命令なので特に説明はなくてもわかるでしょう。関数を表すラベルの属性は、レジスタに代入することがなくても1にすることになっています。
- [FE] [01] [00] : これは DEBUG-INFO-1 と呼ばれている3バイトのリマーク命令で、詳細は「(17) APIの呼び出し(1)」で確認してください。
- [3C] [00] [20] [20] [00] [00] [00] : この7バイト命令が、R00~R1FとP00~P1FとP30の退避処理を行っています。20の部分を変更したり、00の部分を変更したら、なにかいろいろできそうな気がしてくると思いますが、現在のバージョンでは他の数字の組み合わせを想定できていないので、どうかこのままで使ってください。関数宣言以外のところでこの命令を使うことも想定されていません。
- [3D] [00] [20] [20] [00] [00] [00] : この7バイト命令は上記で退避したレジスタを復元します。関数終了部分以外では使わないでください。この命令は、関数の末尾にしか置けません。したがって、関数処理の途中で関数から抜けたくなったら、関数処理の末尾へジャンプする必要があります。
- [1E] [3F] [30] : PCP(P3F, P30); です。これで呼び出し元に帰ることができます。
(16) 条件処理の構成法
(17) APIの呼び出し(1)
- ここではR30~R3Bしか引数に使わないAPIの呼び出し方を紹介したいと思います。いきなり全部説明しても混乱するだけなので。
- この方法で呼び出せるAPIは次の10個です。この表に出てくる123というラベル番号はもちろん仮のものです。
drawPoint #02: [FE] [05] [01] [0002(32bit)] R30=0xff44; R31=mode; R32=x; R33=y; R34=col; PLIMM(P30, 123); PCP(P3F, P28); LB(1, 123); [FE] [01] [00]
drawLine #03: [FE] [05] [01] [0003(32bit)] R30=0xff45; R31=mode; R32=x0; R33=y0; R34=x1; R35=y1; R36=col; 以下略
fillRect #04: [FE] [05] [01] [0004(32bit)] R30=0xff46; R31=mode; R32=xsiz; R33=ysiz; R34=x0; R35=y0; R36=col; 以下略
fillOval #05: [FE] [05] [01] [0005(32bit)] R30=0xff47; R31=mode; R32=xsiz; R33=ysiz; R34=x0; R35=y0; R36=col; 以下略
exit #08: [FE] [05] [01] [0008(32bit)] R30=0xff06; R31=i; PLIMM(P30, 123); PCP(P3F, P28); LB(1, 123); [FE] [01] [00]
sleep #09: [FE] [05] [01] [0009(32bit)] R30=0xff42; R31=mode; R32=msec; PLIMM(P30, 123); PCP(P3F, P28); LB(1, 123); [FE] [01] [00]
inkey #0d: [FE] [05] [01] [000d(32bit)] R30=0xff43; R31=mode; PLIMM(P30, 123); PCP(P3F, P28); LB(1, 123); [FE] [01] [00]
openWin #10: [FE] [05] [01] [0010(32bit)] R30=0xff40; R31=xsiz; R32=ysiz; PLIMM(P30, 123); PCP(P3F, P28); LB(1, 123); [FE] [01] [00]
flushWin #11: [FE] [05] [01] [0011(32bit)] R30=0xff41; R31=xsiz; R32=ysiz; R33=x0; R34=y0; PLIMM(P30, 123); PCP(P3F, P28); LB(1, 123); [FE] [01] [00]
rand #13: [FE] [05] [01] [0013(32bit)] R30=0xff49; PLIMM(P30, 123); PCP(P3F, P28); LB(1, 123); [FE] [01] [00]
- まず前後の [FE] [05] [01] [00??(32bit)] と [FE] [01] [00] を完全に無視してください(あとで説明します)。これらさえなければ、もう知っている命令ばかりです。基本的にはR30に機能番号を入れて、R31以降にパラメータを入れて、それでP28に格納されているラベルへ関数呼び出ししているだけです。
- P28の値はOS(もしくはVM)が設定した状態でアプリを起動しますので、その値を壊さないようにしてください。壊してしまったら(=どこかに控えることもしないで上書きしてしまったら)、もうAPIを呼び出す手段は残っていません。
- inkeyとrandについては、結果がR30に格納されて戻ってきますので、CP命令で適当なレジスタに代入してください。
- さっき無視してもらったFEで始まる命令群ですが、これはリマーク命令なので、本当に無視してバイトコード内に含めないという選択肢もありです。仮そうしても、おそらく動作に問題はありません。・・・ってこれで説明を終わるとさすがにひどいので、ちゃんと説明します。
- リマーク命令というのは、本来の実行には直接関係のない補助情報です。必ず [FE] [len] で始まります。lenの部分は1バイトで、ここで追加情報の長さをバイト単位で表しています。
- [FE] [01] [00] は通称 DEBUG-INFO-1 と呼ばれている3バイトのリマーク命令で、 LB(1, ...); の直後に置きます。これは「実行しているモジュールが切り替わった(もしくは切り替わったかもしれない)」ことを示しています。どういうことかというと、この文脈では、API呼び出ししてOSやライブラリに制御が移った後、ここへ PCP(P3F, P30); へ戻ってくるはずなのです。
- この DEBUG-INFO-1 は、もしエラーが起きた場合に、エラーがアプリの中で起きたのか、システムの中で起きたのか、そういうのを管理するためのもので、デバッグレジスタに「今実行しているのはアプリ内」という設定をするためのコードをもし入れるとしたら、それはここですよー、というヒントをOSECPUに教えているのです。
- 裏返せば、もし絶対にバグがないのならこんな情報はあってもなくても同じことです。というかないほうがむしろ高速ですね。・・・しかしOSECPUはセキュアなOSを目指していることもあって、これはぜひとも省略してほしくないのです。 LB(1, ...); のあとには是非 [FE] [01] [00] をおいてあげましょう。
- これを置き忘れると、エラー扱いになって実行を拒否するモードを実装する可能性も少しあります。・・・すみません、ちょっと脅かしてみただけです(笑)。なんといってもこれは所詮リマーク命令なので、なくてもいい存在なのです。
- 次は [FE] [05] [01] [imm32] の7バイトのリマーク命令です。これは「以下の記述はAPIの呼び出しだよ」というヒントになっています。これがないと、どこから引数代入のための命令が始まっているのかの頭出しが面倒極まりなくて、それで書いています。imm32にはAPIの表向きの機能番号を書きます。R30に入っている番号は内部の汚い番号です。
- このリマーク命令の後には、R30からレジスタ番号順にLIMM命令かCP命令が並んでないといけません。他の命令でR3xに代入してはいけませんし、他の演算命令もダメです。そういうことがしたいならしてもいいですが、そのときはこのリマーク命令をつけないでください。期待したフォーマットと違うと、osectolsが混乱してしまいます。
- 値を返すAPIの場合は [FE] [01] [00] の直後に CP(Rxx, R30); のコードも必要です。これを全部含めてフォーマットがそろっていることを [FE] [05] [01] [imm32] は保証しているのです。
- なんかめんどくさいなーと思ったら(確かにめんどくさい)、 [FE] [05] [01] [imm32] は取っちゃっていいと思います。
(18) ポインタレジスタ
- ここからしばらくはメモリアクセス関係の命令を紹介します。
- OSECPUはメモリアドレスを指し示すためのポインタレジスタを64本持っています。中のビット数は実装依存です。
- P00~P3F と表記します。
- P00やP01など番号の若いレジスタは、実際のCPUの実レジスタに割り当てるように推奨されているので、P00への代入やP00の値の参照は、P20に対する操作と比べて高速になることが多いです。
- 結局どうなるかは処理系依存で、保障はされていません。
- ということで、特にこだわりがないのならP00やP01を使いましょう。
- ちなみにx86版では、P00~P02までが実レジスタに割り当てられていて、残りはメモリを使ってレジスタをエミュレーションしています。
- これらのレジスタはすべて対等で汎用的に使われるというわけではなく、ある程度の使い方が決まっています。これに逆らってはいけないということはないですが、ライブラリなどで食い違うといろいろ面倒かもしれません。
- P00 ( 1本) : ベースポインタです。つまり汎用ではありません。今はベースポインタのサポートが不十分なので、使えないということと同義です。
- P01~P1F (31本) : 最も汎用的なポインタレジスタで、通常は関数ごとにローカルとして扱えます。つまり関数を呼び出しても破壊されたりはしません。
- P20~P27 ( 8本) : 汎用ですが、関数ごとにローカルというわけではなく、グローバル変数的に使うことを想定しています。関数呼び出しによって変更される可能性もあります。
- P28~P2F ( 8本) : これも汎用ですが、関数ごとにローカルではなく、グローバル変数的に使われます。主にOSが用途を決定しています。これに対してP20~P27はアプリが自由に用途を決定できます。
- P30~P3B (12本) : 基本的にはこれらも汎用なのですが、関数の引数を渡したり、返値を入れたりするためにも使われるレジスタで、値が破壊されやすいです。ASKAでは複雑な数式を計算しなければいけなくなると、P3BやP3Aをテンポラリとして勝手に使い、値を破壊してしまうこともあります。P39やP38にまで手をつけることだってあります。しかし最悪でもP30までで、P2Fに手出しすることはありません。
- P3C~P3E ( 3本) : 将来の拡張のためにリザーブされています。
- P3F ( 1本) : 「(9) 無条件分岐命令」などで説明した特別な用途のためのポインタレジスタです。汎用には使えません。
- 全体として、OSECPUのレジスタはかなり多いほうだと思います。これはOSECPUがメモリ操作を苦手としていて、できるだけレジスタだけで主要な演算が完結できるようにという設計方針によるものです。
(19) データ記述命令
- プログラム内にデータを置くことができます。これらのデータはいわゆる.dataに置かれるもので、プログラムの実行中に書き換えることも許されます。
- この命令そのものは何もしないので、プログラムの実行コード中に混在させて構いません(データを実行してしまうような事故は起きません)。
- (つづく)
(20) ラベル番号代入命令
[03] [Pxx] [imm32]
PLIMM(Pxx, imm32);
- 6バイト命令です。Pxxには0x00から0x3fのレジスタ番号を指定します。imm32はラベル番号の定数即値を4バイトのビックエンディアンで指定します。
(21) メモリへのアクセス命令
(22) ポインタ演算命令
(23) ポインタ比較命令
(24) malloc命令
(25) talloc命令
こめんと欄