FFI関数呼び出し規約の正規化(Normalized C Calling Convention)

(同じことを何度も書いている。何度も書くとだんだんと内容が洗練されるんじゃないかということで)

ucid時代はgcccと呼んでいたが紛らわしいのでncccに改名した

目的

yuniFFIは(将来)yuniライブラリの一部として提供する。つまり、chibiとかRacketのような処理系間でポータブルでなければならないため移植性のあるFFIフレームワークという地味な難題を解決する必要がある。
その解決策の一つがnccc(Normalized C Calling Convention)によるAPI呼び出し規約の正規化で、大げさに聞こえはするが"インターフェースとなるC APIシグネチャを限定して処理系自体の拡張をなるべく書かなくても良いようにしよう"というもの。

ncccのシグネチャ

ncccな関数は、uint64_tの配列で表現される入力パラメタと、出力バッファのためのポインタを取る。戻り値は常にvoidとなる。

void
nccc_func(const uint64_t* in, uint64_t* out){
  (内容)
}

Scheme処理系として要求されるのは、ncccな関数の関数ポインタをbytevector 2つを引数として呼びだせることのみで、関数に渡すバッファの準備等はScheme側のコードで実装される。
通常のC APIはnccc関数としてwrapされる必要が有り、このため、yuniFFIはあらかじめwrap関数を収録したDLLを出力しないと関数を呼び出すことができない(ただし、生成したDLLはScheme処理系間で共有することができる)。
この方式をtype 1 FFIと呼んでいる( http://d.hatena.ne.jp/mjt/20130623/p1 )。type 1 FFIは呼び出し規約の知識が必要無いというメリットが有る。このため、emscriptenのように明示的な呼び出し規約の存在しないC言語環境にも応用できる。
配列がuint64_tとなっているのは、C APIの取り得る最大のワードサイズであるため。doubleを格納するためには64bitでalignされている必要が有る。小さなintばかりの引数を大量に取るケースでは無駄が多いが、C APIの引数は一般に少いので大きな問題にはならない。
inとoutで引数を分けているのは、今のところ特に根拠が無い。可変長引数な関数をwrapした場合でも、outのフォーマットを一定にできるメリットは有るが、用意すべきbytevectorの量(= allocationの数)は単純に倍になる。
yuniFFIでは、nccc関数からSchemeオブジェクトを受けとることは無い。このため、GCに対する統合は考察が無く、例えば、C APIを使用して開いたハンドルを、Schemeオブジェクトの解放に合わせて解放するということは考えていない。処理系間で互換性のあるweak pointerが無いため、これを良く実装する方法が無い。

forward wrapper / backward wrapper

C APIのncccへのwrapは、forwardとbackwardの2方向が考えられる。forwardはC APIシグネチャをncccにwrapし、backwardはncccシグネチャをC APIにwrapする。

int pthread_create(pthread_t * thread, pthread_attr_t * attr, void * (*start_routine)(void *), void * arg); 

pthread_createを例にすると、pthread_create API自体を呼び出すためのwrapperがforward wrapperであり、pthread_create APIから呼び出されるコールバックのwrapperがbackward wrapperとなる。

  • forward wrapper
void
nccc_pthread_create_0(const uint64_t* in, uint64_t* out){
  int r;
  r = pthread_create((pthread_t*)in[0], (pthread_attr_t*)in[1], (pthread_start_routine_t*)in[2], (void*)in[3]);
  out[0] = r;
} 
  • backward wrapper
// backward wrapperは一般に"特殊shim"を含む(後述)
void*
nccc_pthread_start_routine_t_1(void* arg /* context */){
  void* r;
  // Shim
  const uint64_t* in = (const uint64_t*)arg;
  nccc_backward_wrapper_1 call = (nccc_backward_wrapper_1_t) arg[0];
  uint64_t arg0 = arg[1];
  uint64_t out;

  // Call body
  call(&arg0, &out);
  r = out[0];
  return r;
}

全ての関数がncccの定義するシグネチャ(void func(uint64_t*, uint64_t*))を経由して呼び出せるため、Scheme処理系に必要なプリミティブは必要最低限に抑えられる。
特に、backward wrapperを使用することで、トランポリンコードを生成せずにC callbackをScheme側に実装することができる。トランポリンコードの生成は、家庭用ゲーム機のような一部のプラットフォームではセキュリティ制約のために行えないので、このような考察が必要になる。
(これは単に例で、pthreadをyuniFFIでこのように使用することは無い。pthread APIでスレッドを生成しても、多くのScheme VMはリエントラントでないため良く動作しないだろう。)
これらのwrapperコードは、yuniFFIのAPI記述を元にスタブジェネレータが生成する。通常のシチュエーションではwrapperコードを手書きする必要は無い。

特殊shim

ncccでwrapされた関数の呼び出しプロトコルは、wrapされる関数の性質によって変化する。shimはyuniFFIのFFIジェネレータに実装され、FFIジェネレータが生成するScheme側のライブラリとwrapperコードで適当に処理される。
shimの基本的なアイデアは、引数と出力の拡張にある。たとえば、callback_context shimは、通常callbackが良く取るvoid*コンテキストの処理をyuniFFI側で処理し、callbackに渡されるコンテキストを拡張する。

  • callback_context

callback_context shimは、callbackのコンテキストをnccc関数へのポインタと、その関数へのin/outポインタとして固定的に使用する。これは上記の nccc_pthread_start_routine_t_1 で使用される。
(当然)C APIのcallbackはncccでは無いため、backward wrapperのようなcallbackには事実上必須のshimとなる。

  • function_pointer

function_pointer shimは、APIの入力にAPI関数自体の関数ポインタを追加する。これは、OpenGLの拡張APIのように、関数ポインタを後から取得する形式のAPIで必要となる。
上記の nccc_pthread_create_0 に function_pointerを適用すると:

void
nccc_pthread_create_0(const uint64_t* in, uint64_t* out){
  int r;
  nccc_pthread_create_t call = (nccc_pthread_create_t)in[0];
  r = call((pthread_t*)in[1], (pthread_attr_t*)in[2], (pthread_start_routine_t*)in[3], (void*)in[4]);
  out[0] = r;
} 

このようにすることで、スタブコード内でpthread_createが解決される必要が無くなるため、pthreadランタイムを遅延ロードできるかもしれない。callback_context shimは、暗黙にfunction_pointer shimを含む。

  • errno / GetLastError

一部のAPIはerrnoやGetLastErrorのような独自のAPIを使用してエラーコードを間接的に返す。このため、APIがエラーになった場合にerrnoやGetLastErrorの値を第二の戻り値として追加する必要が有る。(errnoは単純な変数のようにアクセスされるが、OSによっては関数callのマクロとなっている。)
これらを同様にncccでwrapすることも不可能では無いが、処理系の呼び出しフローによってerrnoが上書きされてしまう可能性が有るため、その場で保持することが重要となる。(例えば、APIの呼び出しからScheme側に戻る際にアロケーションが発生し、それに失敗すると本来のerrnoが上書きされる。)

  • timing / API call trace ...

shimは実際のAPI利用に必須のものだけではなく、タイムスタンプカウンタを用いた計時やエラーインジェクション、コールトレースの収集等プログラムの分析に必要なものも考えられる。