* kharcs #1
-(by [[K]], 2025.06.03)
** (0) これはなに?
-(kharcsページは、kharc開発でわかったことを整理して説明するためのページ群です。)
-今回説明したいこと:
--[1]独自の仮想マシンの命令セットを考える
--[2]そいつのアセンブラを設計する
--[3]サンプルコードを書いてみる
--[4]エミュレータを作ってサンプルコードを実行してみる
** (1)
-開発のきっかけはいろいろありましたが、まあそれは本題ではないのでここでは省略します。
-簡単なCPUを設計してみました。レジスタマシンです。
-レジスタ:
--整数レジスタ(32bit): R0~R7
--アドレスレジスタ(32bit): A0~A7
--浮動小数点レジスタ(64bit): F0~F7 (しかし今回の実装ではこれは出てきません)
-一般的なCPUではレジスタは「汎用レジスタ」とされ、整数用とアドレス用に分かれてはいませんが、このkharcでは用途別にレジスタを分けてみました。
--(ちなみに今回の実装の範囲では、この整数レジスタとアドレスレジスタを分けたことは、実装の手間が増えただけで、メリットらしいものはありませんでした。)
--(ああ、だから多くのCPUで汎用レジスタとして統合されているんだなーと、むしろ納得しました。)
-レジスタの別名:
--SP: A6の別名。スタックポインタ用。
--RP: A7の別名。return-pc。リターン用のプログラムカウンタ。
-演算系の命令(四則演算やビット演算など)はレジスタ間、レジスタ・即値間のみとして、メモリを指定しないことにします。
-メモリ上のスタックに対する命令はなしにしました。何かをPUSHしたければ、 SP-=4; [SP]=Rx; とすればいいです。POPしたければ、 Rx=[SP]; SP+=4; とすればいいです。
-CALLも戻り先をスタックには積まずに、リターンアドレスをRPに入れておくだけにします。これを必要に応じてメモリ上のスタックに積むのはプログラム側の仕事です。
-こうすることで、メモリアクセスを伴う命令はload/store命令だけに限定されます。まあRISC風にしただけのことですが。
-関数呼び出し規約(とりあえず):
--SP以外のレジスタは保存しなくてよい。
--関数の引数はすべてスタック渡し。レジスタ渡しはしない。
--スタック上の引数はintもポインタもdoubleもすべて8バイトアラインで並べることにする。そうすればstdargが簡単になりそう。
--各関数のローカル変数については、好きなアラインでよい。
** (2)
-kharcのアセンブラは、C言語っぽい構文を採用してみました(単なる趣味です)。引数はカッコでくくります。文末にはセミコロンを付けます。
-ラベルの名前解決が面倒だったので、ラベルはすべて番号にしました。かっこいい「名前が自由に使えるラベル」は、アセンブラよりも上のレイヤで実現することにします。
-このアセンブラはバックエンドとして動けばいいので、頑張らないことにしました。
|MovRI(r,imm);|R0~R7に即値定数immを代入、Rx=imm;|
|MovAI(a,imm);|A0~A7に即値定数immを代入、Ax=imm;|
|LodRMd(r,base,disp);|Rxの値をメモリから読み込みます(load)、base=ベースレジスタAx, disp=即値ディスプレイスメント、Rx=[Ax+disp];|
|StoRMd(r,base,disp);|Rxの値をメモリに書き込みます(store)、[Ax+disp]=Rx;|
|LodAMd(a,base,disp);|Ax=[Ax+disp];|
|StoAMd(a,base,disp);|[Ax+disp]=Ax;|
|AddRI(r,imm);|Rxに即値定数immを加算、Rx+=imm;|
|AddAI(a,imm);|Ax+=imm;|
|AddRR(r0,r1);|r0+=r1;|
|SubRI(r,imm);|Rx-=imm;|
|SubAI(a,imm);|Ax-=imm;|
|SubRR(r0,r1);|r0-=r1;|
|CmpJleRII(r,imm,lab);|Rxを即値定数immと比較して結果に応じてラベル番号labにジャンプする、if(Rx<=imm)goto lab;|
|CmpJccRII(r,imm,lab);|他にもJe/Jne/Jl/Jge/Jgがあるが、書くのが面倒なので省略|
|LbI(lab);|ラベル宣言、JmpやCalの飛び先指定に使用|
|JmpI(lab);|無条件分岐、goto lab;|
|JmpA(a);|無条件分岐、goto Ax;|
|CalII(lab,tmp);|サブルーチンのCALL命令、tmpは内部処理用のラベル番号(欄外に補足説明あり)、call lab;|
|Ret();|サブルーチンからのリターン、return;|
-補足説明:
--CalII(lab,tmp); は実際はマクロになっていて、 MovAI(RP,tmp); JmpI(lab); LbI(tmp); と展開されます。だからtmpが必要なのです。
--Ret(); もマクロで、 JmpA(RP); になっています。
--load/storeでのメモリアドレッシングでは、ベースレジスタAxは絶対に必要で、命令としては省略させない想定です(Axに0を入れておけば、0アドレスからの指定はできます)。
** (3)
-サンプルコードとして、再帰でフィボナッチ数を求めるプログラムを書きました。あとでベンチマークするので、そこそこ重い処理にしたいのです。
-まず、C言語で書くとこんな感じです(prog1)。
int fib(int i)
{
if (i <= 1) return i;
return fib(i - 2) + fib(i - 1);
}
int main()
{
printf("fib(46)=%d\n", fib(46));
return 0;
}
-これをkharcアセンブラで書くとこうなります(prog2)。
LbI(1); // fib.
SubAI(SP,16);
StoAMd(RP,SP,8); // RPを保存.
LodRMd(R0,SP,16); // R0=引数.
CmpJleRII(R0,1,5);
LbI(2);
SubRI(R0,2); // fib(i-2)
StoRMd(R0,SP,0);
CalII(1,3);
StoRMd(R0,SP,12); // 結果を保存.
LodRMd(R0,SP,16); // fib(i-1)
SubRI(R0,1);
StoRMd(R0,SP,0);
CalII(1,4);
LodRMd(R1,SP,12); // 結果の加算.
AddRR(R0,R1);
LbI(5);
LodAMd(RP,SP,8); // RPの復元.
AddAI(SP,16);
Ret();
LbI(6); // main
SubAI(SP,8);
MovRI(R0,46); // fib(46)
StoRMd(R0,SP,0);
CalII(1,7);
// この時点のR0に答えが入っている.
** (4)
-ちょっとずるい方法でエミュレータを作りました(prog3)。
#include <stdio.h>
#include <stdlib.h>
#define kharcMain_bgn() int R0,R1,RP; vJmp: switch (PC) {
#define kharcMain_end() }
#define MovRI(r,i) r=i
#define MovAI(a,i) a=i
#define LodRMd(r,b,d) r=*(int*)&m[b+d]
#define StoRMd(r,b,d) *(int*)&m[b+d]=r
#define LodAMd(a,b,d) a=*(int*)&m[b+d]
#define StoAMd(a,b,d) *(int*)&m[b+d]=a
#define AddRI(r,i) r+=i
#define AddAI(a,i) a+=i
#define AddRR(r0,r1) r0+=r1
#define SubRI(r,i) r-=i
#define SubAI(a,i) a-=i
#define CmpJleRII(r,i,l) if(r<=i)goto lb##l
#define LbI(l) lb##l: case l:
#define CalII(l,t) MovAI(RP,t); goto lb##l; LbI(t)
#define Ret() PC=RP; goto vJmp
int kharcMain(int PC, int SP, char *m)
{
kharcMain_bgn();
(上記のコード:prog2)
kharcMain_end();
return R0;
}
int main()
{
char *mem = malloc(1024);
int R0 = kharcMain(6, 1024, mem);
printf("R0=%d\n", R0);
return 0;
}
** (5)
-上記のプログラム(prog3)をCコンパイラでコンパイルして実行したところ、正しく「R0=1836311903」と表示されました。
-またprog1と実行時間を比較したところ、1.5倍ほどの違いしかありませんでした。
--これは実装がこんなに平易だったことも考えれば、劇的に良い結果です!
--普通にエミュレータを書いたら10倍くらいは遅くなるのが普通です。
--それより速くするにはJITコンパイラ型のエミュレータを作る必要がありますが、それはCPUに依存して書くことになります。
-[結論] この方法で、CPUのアーキテクチャを考えて、エミュレータを作って実験するくらいなら、数日あればできる!
-[次の課題] kharcアセンブラを出力するCコンパイラを作る。→[[a25_kharc2]]
-[次の課題] kharcアセンブラを出力するCコンパイラを作る。→[[a25_kharcs2]]
** (6) 補足
-まあ結局、17行の#defineだけでアセンブラとCPUアーキテクチャを表現できているようなものですね。
-実行速度を上げるために、レジスタやメモリイメージ配列m[]などはすべてローカル変数として書いています。たぶんこれをグローバル変数にするとCコンパイラの最適化の効きが悪くなります。
-CmpとJcc命令を一体化させることで、ゼロフラグとかキャリーフラグのようなフラグ管理を不要にしています。
-[Q]今はCのマクロで簡単に書ける内容の処理しかしてないけど、複雑なことをしたくなったらどうするの?
-[A]マクロに関数呼び出しを書くことはできますから、拡張性に制約はほぼないです。