FFIブリッジを作ろう - chibi-scheme / Larceny

というわけで、chibi-schemeと最近R7RSサポートがリリースされて話題のLarcenyにもFFIブリッジを書く。ncccやFFIブリッジの目的については前回参照。
これで、nmosh / Gauche / Racket / chibi-scheme / Larcenyで共通のコードを使いつつnccc関数を呼ぶ準備ができた。次に、パラメタスタックの読み書きとかGaucheやchibi-scheme用のDLLローダを書けば、これらの処理系で共通のFFI環境が出来上がることになる。

chibi-scheme

chibi-schemeには動的FFIのためのインフラは付いてこないため、

  1. .stubファイルを書いてchibi-ffiコマンドに通し、ライブラリ登録コード(C)を生成
  2. ライブラリ登録コードとライブラリ本体をリンクしてDLLを作成

という手順になる。

(c-include "yuniffi_stub.h")

(define-c (pointer void) yuniffi_nccc_bootstrap ())
(define-c void yuniffi_nccc_call 
          ((pointer void)
            sexp int int 
            sexp int int))

.stubファイルの中身は簡単で、関数名と引数、戻り型を列挙するだけ。void*は(pointer void)で受けとることができるが、nmoshと違ってbytevectorをvoid*で受けとることはできないので、Cコードの内部で変換している。

void
yuniffi_nccc_call(void* func /* Cpointer */,
                  sexp in /* U8 */, int in_offset, int in_len,
                  sexp out /* U8 */, int out_offset, int out_len){

    uint64_t* in0;
    uint64_t* out0;
    yuniffi_nccc_func_t callee;
    
    callee = (yuniffi_nccc_func_t)func;
    in0 = (uint64_t*)sexp_bytes_data(in);
    out0 = (uint64_t*)sexp_bytes_data(out);

    callee(&in0[in_offset], in_len, &out0[out_offset], out_len);
}

生成したライブラリは、define-libraryからinclude-sharedを使用してロードさせることができる。chibi-schemeはDLLを直接load手続きで読むこともできるが、移植性のあるライブラリを書くためにはincludeを使用することが想定されている。
SchemeオブジェクトをCから操作するためには、sexpオブジェクトと操作用の関数群を使う。これもマニュアルにちゃんとドキュメントされている。bytevectorのデータへのポインタを得るには sexp_bytes_data() 。

(define-library (yuniffi-runtime)
                (export yuniffi_nccc_call
                        yuniffi_nccc_bootstrap)
                (include-shared "chibi-yuniffi")) ;; ここでchibi-yuniffi.dllを読む

特に難しいポイントは無し。

Larceny

Larcenyはここしばらくリリースが無かったが、R7RSをサポートしたJIT実装ということでc.l.s.でもかなり話題になっている。今のところIA32専用。
Larcenyは動的FFIを実装しているが、nmoshに比べて細かい手続きがあまり充実していない。

(library (larceny-yuni compat ffi primitives)
         (export
           yuniffi-nccc-call
           yuniffi-module-load
           yuniffi-module-lookup)
         (import (yuni scheme)
                 ;; FIXME: Move them into runtime
                 (primitives
                   sizeof:pointer
                   ffi/handle->address
                   foreign-file
                   foreign-procedure))

;; NB: It seems Larceny's FFI do not support any namespacing..

(define (yuniffi-nccc-call func 
                           in in-offset in-size 
                           out out-offset out-size)
  ;; NB: We assume 'in' and 'out' are automagically GC-protected.
  ;; NB: It seems there is no public function for ffi/handle->address
  ;; Ref:
  ;;   larceny/lib/Ffi/memory.sch
  ;;   larceny/src/Rts/Sys/syscall.c
  ;;   larceny/include/Sys/macros.h
  (define in-addr (+ (* sizeof:pointer (+ 1 in-offset)) ;; 1 = Skip header
                     (ffi/handle->address in)))
  (define out-addr (+ (* sizeof:pointer (+ 1 out-offset)) ;; 1 = Skip header
                      (ffi/handle->address out)))
  (func in-addr in-size out-addr out-size))

(define (yuniffi-module-load path)
  (foreign-file path)
  ;; No FFI module handle on Larceny
  #t)

(define (yuniffi-module-lookup handle str) ;; => procedure
  ;; FIXME: Wow, no pointer type! Seriously?
  (foreign-procedure str '(int int int int) 'void))
         
)

これで全部となる。
foreign-file手続きでDLLを読んだ後、foreign-procedure手続きにシンボルと型情報を渡してScheme手続きを得る。これらはLarcenyのR5RSライブラリなので、R6RSライブラリから使うにはprimitivesをimport節に書く。この作法はnmoshと同様(nmoshとLarcenyのR6RSフロントエンドは同じコードを元にしている)。
Larcenyには、bytevectorをポインタに直接変換する方法が存在しないため、内部手続きのffi/handle→addressを直接使用して先頭アドレスを得た上で、オフセットを加算している。これはLarcenyのヒープの内部構造に依存しているのであんまり良くない。
実際、Larcenyのbytevector-refは以下のように実装されている:

/* Given tagged pointer, return pointer */
#define ptrof( w )          ((word *)((word)(w) & ~TAG_MASK))
...
#define bytevector_ref(x,i)    (*((byte*)(ptrof(x)+1)+i))

ffi/handle→addressのような低レベルプリミティブはsyscallと呼ばれている。Larcenyには明確なBytecode VMが存在しない(コンパイラであるtwobit自体には番号付けされた命令コードは有るが)ため、非常にシンプルなリストになっている。

(define syscall:object->address 36)
...
(define (ffi/handle->address obj)
  (syscall syscall:object->address obj))
	/* 30 */      { (fptr)primitive_flonum_cosh, 2, 0 },
		      { (fptr)osdep_system, 1, 1 },
		      { (fptr)larceny_C_ffi_apply, 4, 1 },
		      { (fptr)larceny_C_ffi_dlopen, 1, 0 },
		      { (fptr)larceny_C_ffi_dlsym, 2, 0 },
		      { (fptr)primitive_allocate_nonmoving, 2, 0 },
		      { (fptr)primitive_object_to_address, 1, 0 },
		      { (fptr)larceny_C_ffi_getaddr, 1, 0 },

コメントにも入れているが、何故かLarcenyには直接的なポインタ型が無いように見える。多分文献を当たらないと不味い。IA32においては、sizeof(int) == sizeof(void*)なので、ひとまず全てのパラメタをintと宣言しておけば呼ぶことができる。(uintの方が安全かもしれない。Win32等は負値のintも正当なポインタを構成する(いわゆる3GiBクリーンの要求)。)
あと、Larcenyにはハンドルを指定して共有ライブラリをLookupする仕組みも無い。このためシンボルが衝突すると不味いことが起こる。これはプラグイン方式のプログラムを作るときに地味に問題になる。nmoshは、ライブラリのエントリポイントのシンボルにはライブラリ名自体を入れることで、シンボルの衝突が(少くとも処理系のレベルでは)起こらないようにしている。