めくるめくマシン記述の世界

prev: http://d.hatena.ne.jp/mjt/20100811/p1
コンパイラやJITCを作る上で、マシン記述(MachineDescription)は現代的には必須のテクニックになっている。例えば、IA32やARMの世界ではCPUのモデル毎に使用できる命令が大幅に異なり、Xtensaのように生成的なプロセサも存在する。コンパイラを特定のCPUにべったり依存する形で書くことは殆んどなく、何らかのかたちで、

  • CPUにはどのようなレジスタが有るのか
  • CPUの使える命令は何か
  • CPU命令が利用/破壊するレジスタは何か

といったことを統一的なフォーマットで記述し、可能なかぎりマシン依存部を減らす努力が求められている。

gccのケース: CGENと.md

GCCは.mdという形で、CPUが利用できる"アセンブラ命令"と、実際の命令の機能を記述できるようにしている。GCCは直接バイナリを出力することはなく、裏でこっそりGNU asにアセンブラ命令を渡してアセンブルしてもらっていることに注意。
たとえば、MIPSのADD命令は、

(define_insn "add<mode>3"
	  [(set (match_operand:ANYF 0 "register_operand" "=f")
	        (plus:ANYF (match_operand:ANYF 1 "register_operand" "f")
	                   (match_operand:ANYF 2 "register_operand" "f")))] ; ← 動作記述
	  ""
	  "add.<fmt>\t%0,%1,%2" ; ← 出力するアセンブリ
	  [(set_attr "type" "fadd")
	   (set_attr "mode" "<UNITMODE>")])

のようになっている。マシン記述はこのようにS式で書かれる。
gccの利用するアセンブラは一般にbinutilsのasで、binutilsも一部のターゲットではマシン記述を活用している。
binutilsの使用するマシン記述フレームワークはCGENと呼ばれ、Guile(GNUScheme)で実装されている。記述力はそれなりに高く、備えるレジスタセットや、命令のビットフィールド構造なども含めて記述することができる。
MIPSのADDは、

(define-pmacro (ar-insn-s mnemonic op2-op op5-op)
  (begin
     (dni (.sym l- mnemonic)
          (.str "l." mnemonic " reg/reg/reg")
          ()
          (.str "l." mnemonic " $rD,$rA,$rB")
          (+ OP1_3 OP2_8 rD rA rB (f-f-10-7 0) op5-op)
          (set rD (mnemonic rA rB)) ;; ← 命令の動作記述
          ()
     )
     (dni (.sym l- mnemonic "i")
          (.str "l." mnemonic " reg/reg/lo16")
          ()
          (.str "l." mnemonic "i $rD,$rA,$lo16")
          (+ OP1_2 op2-op rD rA lo16)
          (set rD (mnemonic rA lo16))
          ()
     )
   )
)
(ar-insn-s add OP2_5  OP7_0) ; ← ADDは0(OP7_0)

これで、add命令は(set rD (add rA rB))という動作をする命令だという定義になっている。記述はLispらしくマクロを多用する。
CGENはbinutilsの多くを生成することができる。つまり、アセンブラだけではなく、gdbで使用するシミュレータや逆アセンブラもCGENから(ある程度)生成される。
CGENはi386のような複雑なターゲットには使用されていないようだ。

LLVMのケース: tblgen

LLVMgcc+binutilsと違い、今後は一つのプラットフォームでアセンブルまでを担当する方向性になっている。
LLVMのマシン記述はtblgenと呼ばれるプログラムで処理できる表形式になっている。LLVMでは他にコマンドラインオプションの処理など様々な側面でtblgenを使用している。

let isCommutable = 1 in
class ArithR<bits<6> op, bits<6> func, string instr_asm, SDNode OpNode,
             InstrItinClass itin>:
  FR< op,
      func,
      (outs CPURegs:$dst),
      (ins CPURegs:$b, CPURegs:$c),
      !strconcat(instr_asm, "\t$dst, $b, $c"),
      [(set CPURegs:$dst, (OpNode CPURegs:$b, CPURegs:$c))], itin>;
(中略)
def ADDu    : ArithR<0x00, 0x21, "addu", add, IIAlu>; // ← ADDは0(先頭の0x00)

一部はS式のような書きかたをしているが、全体的には独自の記法となっている。S式のような書かれ方をしている部分はdag型のオブジェクトで、命令の実際の動作や、命令によって操作される対象等を記述している。

マシン記述のメリット

端的に言えば、"CPUの仕様書を入力するとコンパイラが出てくる"こと。
また、アセンブラを作るのは地味に面倒な作業なので、その作業を一度で済ませられることも大きい。

マシン記述の限界

しかし、現実はそれほど甘くない。binutilsではi386アセンブラはこのようなインフラに頼らず、ベタ書きのC言語で書いている。
また、マシン記述のインフラそのものが大きなものになるので、小さなコンパイラプロジェクト(TCCやrui氏の8cc - http://github.com/rui314/8cc )では、基本的にこのような抽象化を用いていない。
また、LLVMであっても、実際の命令フォーマットがtblgenフォーマットで記述されず、C++コードで実装されているターゲットが有る。

tblgenのようなインフラを作るとしても、アルゴリズムをそのようなインフラの枠内で書かせることは少い。