他言語との連携 〜 NLFFIをつかう

最終更新: 令和3年8月20日

SML/NJからのC言語関数の呼出や変数操作を提供する外部関数インターフェース(Foreign Function Interface; FFI)としてMatthias Blume氏によるNLFFIがある。これは、C言語のヘッダファイルからSML/NJへスタブを生成するコマンドライン・ツールとC言語変数を操作するライブラリから構成される。残念なことにマニュアルがバイナリ配布パッケージには含まれておらず、また計算幾科学の専門家を対象とした書きぶりのために、普通のプログラマが利用するには必要な情報を得るのが難しい。そこで、手持ちのCやFortranのコードを接続するためにどんな作業をすればよいかという観点から解説を試みたい。

1. 作業の流れ

つぎの簡単なC言語ルーチンを呼び出すことを考える。

// sample1.c

void foo()
{
printf("Hello! Sandybell.\n");
}

SML/NJから呼び出す外部手続きは、ダイナミックリンクライブラリ(*.dllまたは*.soファイル)に格納されていなければならない。このプログラムをダイナミックリンクライブラリとしてコンパイルするには、

[cmd] gcc -shared -mrtd sample1.c -o sample1.dllWindows (mingw32)
[cmd] gcc -shared sample1.c -o sample1.soUnix

を実行する。必要に応じて、コマンドプロンプトへの入力を[cmd]で、SML/NJの対話型環境への入力を[sml]で表す。なお、-mrtdは、Windows (mingw)でデフォルトの呼出規約を__stdcallとするためのものである。この例では引数がないので関係ないが、NLFFIは__stdcallにのみ対応し、Cの呼出規約を使うとただちにクラッシュする(かといって[関数名]@[n]形式のシンボルにも対応していないので、ソース上で__stdcall修飾するのではだめである)。つぎに作成したDLLを読み込む短いSMLコードを記述する。これは定型的なもので、以下のとおりに書く。

(* libh.sml *) structure Library = struct
  local
        val lh = DynLinkage.open_lib { name = "./sample1.dll", global = true, lazy = true }
    in
        fun libh s = let
            val sh = DynLinkage.lib_symbol (lh, s)
        in
            fn () => DynLinkage.addr sh
        end
    end
end

Cのソースコード(sample.c)とこのファイル(libh.sml)から、接続手続きへのスタブコードを自動生成する。

[cmd] ml-nlffigen.bat -DNLFFI -include ../libh.sml sample1.c

このコマンドを実行すると、NLFFI-Generated というフォルダが出現し、その中にスタブコードが作成される。多数のファイルが作成されるが、利用者が気にする必要があるのは、f-foo.sml と nlffi-generated.cm である。f-foo.smlにスタブコードが書かれているが、これを直接ひらくのではなく、以下のようnlffi-generated.cmをつぎのうちどちらかの方法で開く。
[cmd] sml -m NLFFI-Generated/nlffi-generated.cm
[sml] CM.make "NLFFI-Generated/nlffi-generated.cm"

SML/NJには、Compilation Manager (CM)という複数のプログラム単位を管理する機構がある。use文のチェインでは、結局すべてのコードをコンパイルすることになるのに対し、CMを使うと変更があったソースコードにコンパイルが限定され、コンパイル効率が高くなる。ここでは、CMの詳細に立ち入らず、nlffi-generated.cmにf-foo.smlのコンパイルの方法が書かれていると述べるに留めておく。f-foo.smlでは、structure F_fooが定義されており、その中のfがCの手続きfoo()へのスタブである。実際、SML/NJ上で、たしかにfoo()に対応する型付けがなされるていることと、正しく呼び出せることが確認できる。

[sml] F_foo.f;
val it = fn: unit -> unit
[sml] F_foo.f ();
Hello! Sandybell.

さて、C言語手続きを作成して、これを呼び出すまでを見てきた。ただし、今回の例では、引数/返り値ともvoid (unit)型で、SMLとCの間で情報のやりとりはしていない。これを行うには、次節で紹介する、SMLのデータ型とCのデータ型とを相互に変換する方法を知る必要がある。

2. C言語のしきたりでデータを操作する

2.1 簡単な例とSML/NJでのC言語オブジェクトの操作

C言語のデータオブジェクトを生成・操作するモジュールstructure Cが用意されている。ListやStringなどのBasisライブラリと異なり、デフォルトでは読み込まれないため、使う前に
[sml] CM.make "$c/c.cm"

とする必要がある。例として、符号付き4byte整数変数を操作してみよう。C言語では(signed) intと呼ばれるこの型は、structure Cではsintという名前で参照される。

[sml] val i = C.new C.T.sint: (C.sint, C.rw) C.obj; (* 生成 *)
[sml] C.Set.sint (i, 33) (* 書き込み *)
[sml] C.Get.sint i (* 読み出し *)
val it = 33: Int32.int

字面から、操作の内容は明らかであろうが、この3行の操作を詳説することでSML/NJでのC言語データの取り扱いを紹介しよう。SML/NJでは、(1)C言語の型を表現するデータ、(2)(1)で指定された型をもつC言語オブジェクトを生成する関数、(3)C言語オブジェクトから値を読み出す関数、(4)C言語オブジェクトに値を書き込む関数、の4つを通してC言語変数へのアクセスを実現している。(1)のC言語の型を表現するデータは、structure C.Tで、値の読み出し関数はC.Getに、値の書き込み関数はC.Setに定義されている。これらの3つのstructureではC言語の型ごとに共通の名前が使用されている。対応関係を、SML/NJでの値がもつ型とともに表1に示す。

表1 SMLとCの間の型の対応
SML/NJでの名称C言語での型名SML/NJ上での値の表現
schar/ucharsigned/unsigned charInt32.int/Word32.word
sshort/ushortsigned/unsigned shortInt32.int/Word32.word
sint/uintsigned/unsigned intInt32.int/Word32.word
slong/ulongsigned/unsigned longInt32.int/Word32.word
slonglong/ulonglongsigned/unsigned long longInt64.int/Word64.word
floatfloatreal
doubledoublereal

(2)のオブジェクトは、C.new [型]で生成する。この例の1行目では、これを使ってsigned int型の変数を確保し、それをiと置いている。型制約(C.sint, C.rw) C.objの部分は、この変数が読み書き可能であることを表す。C.rwをC.roに変更すると、この変数はconst signed int型になり、2行目でのC.Set.sintによる変更ができなくなる(しようとすると型エラーが起こる)。ここでは無意味に思えるが、接続するC言語のグローバル変数や関数引数の定数性を正しく表現するためにC.roによる指定が用意されている。なお、型制約を省略するとC.newが生成するものの型が(C.sint,?.X1) C.objになってしまい、やはりSetできない。これは不便なので、

Unsafe.cast: 'a ->'b

というバックドアを使って

val i = C.new C.T.sint
C.Set.sint (Unsafe.cast i, 33) (* 書き込み *)
C.Get.sint (Unsafe.cast i) (* 読み出し *)
と書くことも可能である。Unsafe.castは不用意に使うと簡単にSegmentation falutが起こったりするので注意が必要だが、SML/NJのC言語インターフェースを厳格すぎて書くのが疲れるので、適宜使っていくことにする。多少ましな方法は、C.newの返り値を読み書き可能変数に制約した
[sml] val new = C.new: 'a C.T.typ -> ('a, C.rw) C.obj;
を使うことである。最後に3行目でC.Set.sintを使ってiの内容を読み出すと、確かに書き込んだ値33が取り出される。

2.2 配列の操作

例えば、要素数1024のdouble型配列を生成するには、以下のように書く。

[sml] val a = C.new (C.T.arr (C.T.double, Unsafe.cast 1024));

ここで、C.T.arr([型],[寸法])が要素数3のdouble型配列という型を表すデータである。寸法の正しい書き方には、興味深い話題を提供してくれるのたが、単に配列を使うには煩雑すぎるので、ここではUnsafe.castを使っている。要素を表すオブジェクトは、関数C.Arr.sub([配列],[添字])で取り出せる。したがって、ここで生成した要素への読み書きは、

C.Get.double(C.Arr.sub(a,2));(* x = a[2]; *)
C.Set.double(C.Arr.sub(a,5),135.3);(* a[5] = 135.3;*)
なとどすれば良い。

毎回このような低レベル操作を行うのは大変なので、つぎのようなC⇔ML間の変換関数を使うと良いだろう。

fun arrFromC c = let
  open C
  val nLen = Dim.toInt (Arr.dim c)
in
  Vector.tabulate(nLen, fn i => C.Get.double(C.Arr.sub(c,i)))
end;
fun arrToC m = let
  open C
  val nLen = Vector.length m
  val c = C.new (C.T.arr (C.T.double, Unsafe.cast nLen))
in
  (Vector.appi (fn (i,m_i) => C.Set.double(C.Arr.sub(c,i),m_i)) m
  ;c)
end


ここでは、double型の配列とreal vectorとを対応させている。

Cではポインタを介して配列を操作することが多い。これに対応する操作関数がC.Pointerに定義されている。これらとC言語の式との対応を表2に示す。

表2 例文によるポインタ操作関数
... int a[123]; int i; int *p
val p = C.Arr.decay ap = a;
val p = C.Ptr.|&|ip = &i;
val i = C.Ptr.sub(p,5)i = p[5];
C.Ptr.inject p(void *)p
C.Ptr.cast (C.T.pointer C.T.uchar) (C.Ptr.inject q)

(void *)を経由する必要があることに注意

(unsigned char *)p

2.3 構造体の操作

Cの関数で構造体に設定した値をMLで取り出す例を通じて 構造体の操作の仕方を解説する. 次のようなCコードをMLから呼び出したいとしよう.
/* struct.c */
struct St {
  int m_int;
  double m_double;
};

void set_number(struct St *st) {
  (*st).m_double = 5.5;
  (*st).m_int = (int)(*st).m_double;
}
このコードでは,構造体Stはm_intm_doubleをメンバにもつ. また,構造体の操作例として受け取った構造体のポインタに 数値を設定する関数 set_number() で中身を設定している. まず,DLLとFFIをつくる.このコードをstruct.c,DLLをstruct.dllとしよう. DLLに合わせて,先のlibh.sml{name = "./struct.dll",...}と修正したものを同じフォルダ に準備した上で,
  gcc -mrtd -shared struct.c -o struct.dll
  ml-nlffigen -include ../libh.sml -DNLFFI struct.c
とする. 出来上がった NLFFI-Generate ディレクトリには以下が含まれる:
  • nlffi-generated.cm: 生成されたsmlコードを束ねるCMファイル.
  • f-set_number.sml: 関数 set_number()に対応するF_set_number.f, F_set_number.f'を提供.
  • st-St.sml : 構造体 struct St の型 S_St.typ および寸法 S_St.size を提供.
  • s-St.sml: 構造体の要素を取り出す関数が入った S_St.m_int, S_St.m_double を提供.
これらを使って,構造体Stの操作は以下のようにできる. なお,Cのオブジェクトの型のML上での表現には,型にタグ付けしたものと寸法だけを識 別するものの2系統がある.この例ではダッシュ付のものを使った.余計な情報がない後 者の方が軽い(らしい).
val it = CM.make "NLFFI-Generated/nlffi-generated.cm"
val it = CM.make "$c/c.cm";

/* サイズを指定してオブジェクト生成 */
val obj = C.new' S_St.size ; 

/* St構造体のポインタを C.Ptr.|&! で取得し,set_member()に渡して呼び出す */
val it = F_set_number.f'(C.Ptr.|&! (C.rw' obj)) ;

/* set_member()がセットした値を取り出す */
val m_int = C.Get.sint' (S_St.f_m_int' obj) ;
val m_double_ng  = C.Get.double' (S_St.f_m_double' obj) ;
このコードを動かすと m_int = 5, m_double_ng = 5.5 になる. ……はずであるが,アラインメントの整合が取れておらず,ver 110.85 時点 ではm_double = 1.01595548785E~316などの壊れた値になる.つまり
  • gcc (16byte): m_int (4byte), padding (4byte), m_double (8byte)
  • mlffi(12byte): m_int (4byte), m_double (8byte)
となっている. 実際,先頭から8byteにすすめてdoubleとして取り出すと正しい値が読める.
val offset8 = Unsafe.cast( (Unsafe.cast obj: Word32.word) + 0w8 ): C.rw C.double_obj';
val m_double_ok = C.Get.double' offset8;

[以上の説明のサンプルコード]