callstubによる呼び出し規約の吸収

nmosh pffiの"p"はPseudoのPで、これはpffiの

という制約のために付いている。この制約は地味に大きな制約で、nmoshは実用性を確保するためにプラグイン(共有ライブラリとしてC関数をインタプリタに提供する機能)をそれなりに考察している。

callstub

pffiのキモはcallstubと呼ばれるスタブを経由して関数を呼ぶことで、"コンパイル時に知っている"とは、"callstubが提供されている"と同義となる。callstubはCで書かれてnmoshと一緒にコンパイルされる(か、プラグインとして共有ライブラリで提供される)。
callstubは以下のようなシグネチャを持つ関数で、本来の関数への単なるゲートウェイを提供する。

typedef int (*FUNCTION_TYPE_t)(char, char);
void /* callstub自体は値を返さない; ポインタを渡しそこに入れる */
callstub_FUNCTION_NAME_(FUNCTION_TYPE_t *func, uint64_t* in_stack, intptr_t* out){
    *out = func(in_stack[0], in_stack[1]);
}

この呼出はcallstubへの関数ポインタを使用して専用のVMプリミティブ(CProcedureオブジェクト)で呼び出すことが出来る。C言語の呼び出し規約を実装するには、任意のレジスタやスタックレイアウトを実現できる必要があるが、C言語では不正なテクニックを駆使しないとそれらを記述できない。このため、呼び出し規約の実現はコンパイラに任せ、仮想のスタック(in_stack)と仮想のレジスタ(out)を使用した(Cで自然に記述できる)仮想の呼び出し規約を定義して使用する。
callstubはビルド時にAPI記述から生成される。このため、人間がcallstubを書く必要は無い。
↑のcallstubは、呼び出される関数の呼び出し規約を表現するために型FUNCTION_TYPE_tを定義しているが、更に2つのバリエーションが考えられる。

  • 関数型をAPI記述がシンボルとして提供するタイプ(GLの拡張等に見られる)
#include <some_api.h>
void
callstub_FUNCTION_NAME_(SOME_API_PROC func, uint64_t* in_stack, intptr_t* out){
    *out = func(in_stack[0], in_stack[1]);
}
  • APIを直接呼び出すタイプ - 関数ポインタは使用しない
#include <some_api.h>
void
callstub_FUNCTION_NAME_(void* bogus, uint64_t* in_stack, intptr_t* out){
    *out = some_api(in_stack[0], in_stack[1]);
}

後者2つはC標準でない呼び出し規約を使用する場合に必須となる。(より正確には、APIの仕様として呼び出し規約を規定しないが、実装として非標準の呼び出しを使用しているケース)
これらは現状サポートされておらず、まさにこれからサポートを入れていく所。これが本当に問題になるケースは大きく2つある:

  • WindowsWindowsは、stdcallとcdeclという2つの呼び出し規約があり、相互に互換性が無い。Win32 APIはstdcallで書かれているが、普通のC関数はcdeclになる。
  • 可変長引数amd64のように、可変長引数の関数と通常の関数の呼び出し規約に互換性がないケースがある。

C言語は、可変長引数として書かれた関数を固定長引数の関数として呼び出すことは保証していない - 要するにlibcのprintfさえ(可変長引数をサポートしていないFFIでは)呼び出せない事になる。
callstubのメリットは、これらの呼び出し規約の違いを吸収できることと、FFIの実現のための環境依存性を減らせるという点にある。
逆に、callstubを通すことによって引数のコピーが発生するため、呼び出しのパフォーマンスは低下する。多くのケースではこれは問題にならないが、一部のアーキテクチャはint←→floatの変換が遅かったりするので頻繁に関数を呼び出すケースで問題になる可能性が有る。(ただ、今のところこれがパフォーマンス上の問題になったことは無い)
最後の直接呼び出しだけが、APIが実際にはマクロで書かれているケースに対応できる。

デザインチョイスと将来の拡張

callstubは、引数の数を検査するべきかもしれない。つまり、in_stackは単にScheme的なbytevectorで表現されるので、引数長の不一致が起こる可能性は有る。nmoshの実行バイナリやプラグインに埋め込まれているcallstubと、Scheme側のコードが認識しているcallstubの引数長が一致していない場合は実行を拒否するべきかもしれない。
戻り値を void にしたのは正しくないかも知れない。これは、outとしてGCのroot setに含まれる領域を指せるようにした配慮だが、BoehmGC では、そもそもレジスタやスタックはroot setの一部だし、そうでないGCを将来採用するにしても、C言語関数を呼び出す段階で適切な参照の保持を行わなければならないのは変わらない。pointerサイズの数値はヒープオブジェクトなので、void関数のときにまでヒープアロケーションが1回増えるのは無視できないが、単にvoidとintptr_tの2種類を用意すれば済む話と言える。
引数のサイズは64bitsに統一している。これは32bitマシンでは単に無駄で、削減努力をすべきかどうかは悩ましい所。long doubleのような64bitを超える値を渡すことも出来ないが、常識的に考えてAPIとしてlong doubleのような引数は採用すべきでない。
生成するcallstubにDTrace/SystemTapのprobeを埋め込む良い方法がなかなか見つからない。.d等も同時に生成すれば良いように見えるが、まだ実装できていない。
callstubの呼び出しと簡単な演算、スレッド同期のみ行えるVMを考えるのは良いかもしれない。つまり、VM heapを触る必要がない独立スレッドのために、軽量VMとして使用することで、GCによるworld stopの被害に遭わないようにする。