D言語で開発した自作Lisp,ChickenClispをFFIに対応させた話

もともとこの記事は8月3日に書く予定だったようである.(僕はBlogの記事を書くときは,テキストエディタを用いて日付_記事の内容.mdとする 習慣がありそれによると2018-08-03_chickenclisp_ffi.mdとあったのでそういうことぽっぽい.) それで,今回の記事ではD言語で開発した自作LispであるChickenClispFFI(Foreign Function Interface)を 使えるようにしたので,それについて書く.(なお,以下の内容は 情報科学特別講義A「Rubyインタプリタに見る実際のシステムソフトウェア」という授業のレポートで提出した内容に加筆・修正を加えたものである.)

また,前回の記事 C言語でヒープ領域にある機械語列に実行権限を与え,実行する方法. は今回の話でも出てくるので未読なら読んでおくと良いかもしれない.(最後の方にチョロっと出てきます)

それでは,本文は続きから.

はじめに

2年ほど前から,Schemeを参考にした自作のプログラミング言語,ChickenClisp(リポジトリ:https://github.com/alphaKAI/ChickenClisp)の開発を続けている.
この言語は,libcurlなどを組み込むことで様々な用途で使えるような言語であると共に,D言語製のアプリケーションに組み込むことも容易にできる.
実際,以前,D言語で作ったUNIXシェルライクなプログラムでは,シェルスクリプトとしてChickenClispを利用した.(そのシェルっぽいプログラム: https://github.com/alphaKAI/dsh) そして,今回,libffiというライブラリを用いることで,ChickenClispにFFI(Foreign Function Interface)を導入することで,CのABIでコンパイルされた共有ライブラリをChickenClispから呼び出すことに成功した.

今回,libffiをD言語から利用するに当たり,libffiのヘッダーファイルをもとに,D言語バインディグを作り,それを利用した.https://github.com/alphaKAI/ChickenClisp/blob/master/source/orelang/Util.d

そして,今回,FFIを追加するためのコードはリポジトリの中の次のファイルに記述した: https://github.com/alphaKAI/ChickenClisp/blob/master/source/orelang/operator/DllOperator.d

基本的には,dlopenで,共有ライブラリをオープンし,dlsym関数で,共有ライブラリの中の呼び出したい関数のポインタを取得し,それをlibffiを用いてコールするという手順である.
それを今回,次のようなS式から,共有ライブラリのパス,及び呼び出したい関数の引数リストと戻り値をDの型と対応させ,libffiの型を表す表現(ffi_type_sint32といったような変数がそれに対応する)にマップするような処理を行わせることで実装した.

その実装をいかにコメント付きで掲載する.

/*
  ChickenClispはD言語の組み込みの型(整数型など)をValueというクラスに
  複数のメンバとして持たせ,それを型タグを用いて現在Valueが何の型の値を持っているかを識別し
  複数の型の値をValueという単一のクラスで扱っている.そこで,FFIするにあたり,D言語側の型(Valueの中身)
  とFFIにおける型との写像を構成する必要がある.そのため,`enum D_TYPE`でD言語における組み込まの型を
  列挙し(全てではないが少なくともChickenClispで使う分についてはすべてサポートした)それを用いる.
 */
enum D_TYPE {
  VOID,
  UBYTE,
  BYTE,
  USHORT,
  SHORT,
  UINT,
  INT,
  ULONG,
  LONG,
  FLOAT,
  DOUBLE,
  POINTER,
  STRING,
}

/*
 文字列として与えられた型名をD_TYPEに変換する.
 */
D_TYPE string_to_dtype(string s) {
  final switch (s) with (D_TYPE) {
  case "void":
    return VOID;
  case "ubyte":
    return UBYTE;
  case "byte":
    return BYTE;
  case "ushort":
    return USHORT;
  case "short":
    return SHORT;
  case "uint":
    return UINT;
  case "int":
    return INT;
  case "ulong":
    return ULONG;
  case "long":
    return LONG;
  case "float":
    return FLOAT;
  case "double":
    return DOUBLE;
  case "pointer":
    return POINTER;
  case "string":
    return STRING;
  }
}

/*
  D_TYPEからFFIにおける型ffi_type*型の特定のインスタンスにマップする関数.
 */
ffi_type* d_type_to_ffi_type(D_TYPE type) {
  return [&ffi_type_void, &ffi_type_uint8, &ffi_type_sint8,
    &ffi_type_uint16, &ffi_type_sint16, &ffi_type_uint32,
    &ffi_type_sint32, &ffi_type_uint64, &ffi_type_sint64, &ffi_type_float,
    &ffi_type_double, &ffi_type_pointer, &ffi_type_pointer][type];
}

/*
  d_type_to_ffi_typeを用いて配列で与えられた複数のDの型をFFIの型にマップする関数
 */
ffi_type*[] d_types_to_ffi_types(D_TYPE[] types) {
  ffi_type*[] ret;
  foreach (type; types) {
    ret ~= d_type_to_ffi_type(type);
  }
  return ret;
}

/*
  関数の情報
  string name: 関数名
  void* ptr: 関数ポインタへのポインタ
  D_TYPE[] arg_types: 引数の型一覧
  D_TYPE r_type: 戻り値の型
  ffi_cif cif: FFIで使うメタ情報を保持する
 */
struct Func {
  string name;
  void* ptr;
  D_TYPE[] arg_types;
  D_TYPE r_type;
  ffi_cif cif;
}

/*
  dlopenされたlh(library handler)をもとにnameに対応する関数をLookupし,与えらた引数と戻り値の型情報をもとにFuncを作る.
 */
Func newFunc(void* lh, string name, D_TYPE[] arg_types, D_TYPE r_type) {
  import std.string;

  void* ptr = dlsym(lh, name.toStringz);
  char* error = dlerror();

  if (error) {
    throw new Error("dlsym error: %s\n".format(error));
  }

  ffi_cif cif;
  ffi_status status;

  auto _arg_types = d_types_to_ffi_types(arg_types);
  auto _r_type = d_type_to_ffi_type(r_type);

  if ((status = ffi_prep_cif(&cif, ffi_abi.FFI_DEFAULT_ABI,
      arg_types.length.to!uint, _r_type, cast(ffi_type**)_arg_types)) != ffi_status.FFI_OK) {
    throw new Error("ERROR : %d".format(status));
  }

  return Func(name, ptr, arg_types, r_type, cif);
}

/*
  念のために,void* ptrをT*にキャストできるか確認する関数
 */
bool checkCastable(T)(void* ptr) {
  return (cast(T*)ptr) !is null;
}

/*
  Func funcとして渡された関数にvoid*[] argsで渡された値を渡して実行し,その結果をValueとして返す関数.
  つまり,実際にFFIで関数を実行する関数.
 */
Value invokeFunc(Func func, void*[] args) {
  foreach (i, type; func.arg_types) {
    final switch (type) with (D_TYPE) {
    case VOID:
      break;
    case UBYTE:
      if (!checkCastable!(ubyte)(args[i])) {
        throw new Error("Invalid argument<type error>");
      }
      break;
    case BYTE:
      if (!checkCastable!(byte)(args[i])) {
        throw new Error("Invalid argument<type error>");
      }
      break;
    case USHORT:
      if (!checkCastable!(ushort)(args[i])) {
        throw new Error("Invalid argument<type error>");
      }
      break;
    case SHORT:
      if (!checkCastable!(short)(args[i])) {
        throw new Error("Invalid argument<type error>");
      }
      break;
    case UINT:
      if (!checkCastable!(uint)(args[i])) {
        throw new Error("Invalid argument<type error>");
      }
      break;
    case INT:
      if (!checkCastable!(int)(args[i])) {
        throw new Error("Invalid argument<type error>");
      }
      break;
    case ULONG:
      if (!checkCastable!(ulong)(args[i])) {
        throw new Error("Invalid argument<type error>");
      }
      break;
    case LONG:
      if (!checkCastable!(long)(args[i])) {
        throw new Error("Invalid argument<type error>");
      }
      break;
    case FLOAT:
      if (!checkCastable!(float)(args[i])) {
        throw new Error("Invalid argument<type error>");
      }
      break;
    case DOUBLE:
      if (!checkCastable!(double)(args[i])) {
        throw new Error("Invalid argument<type error>");
      }
      break;
    case POINTER:
      if (!checkCastable!(void)(args[i])) {
        throw new Error("Invalid argument<type error>");
      }
      break;
    case STRING:
      if (!checkCastable!(char)(args[i])) {
        throw new Error("Invalid argument<type error>");
      }
      break;
    }
  }

  ffi_arg result;

  ffi_call(&func.cif, func.ptr, &result, cast(void**)args);

  final switch (func.r_type) with (D_TYPE) {
  case VOID:
    return new Value;
  case UBYTE:
    auto v = cast(ubyte)result;
    return new Value(v);
  case BYTE:
    auto v = cast(byte)result;
    return new Value(v);
  case USHORT:
    auto v = cast(ushort)result;
    return new Value(v);
  case SHORT:
    auto v = cast(short)result;
    return new Value(v);
  case UINT:
    auto v = cast(uint)result;
    return new Value(v);
  case INT:
    auto v = cast(int)result;
    return new Value(v);
  case ULONG:
    auto v = cast(ulong)result;
    return new Value(v);
  case LONG:
    auto v = cast(long)result;
    return new Value(v);
  case FLOAT:
    auto v = cast(float)result;
    return new Value(v);
  case DOUBLE:
    auto v = cast(double)result;
    return new Value(v);
  case POINTER:
    void* ptr = cast(void*)result;
    return new Value(ptr);
  case STRING:
    auto v = cast(char*)result;
    return new Value(cast(string)v.fromStringz);
  }
}

具体的にChickenClispでFFIする方法について

さて,次から具体的にどのようにしてCの関数をChickenClispから呼び出すかについて見ていく.

たとえば,次のようなCの関数をChickenClispから呼び出したいとする.

// sq.c
int sq(int x) {
  return x * x;
}

これは引数で与えられた数を平方する素朴な関数である.
これを次のように共有ライブラリとしてコンパイルする:

gcc -c sq.c -fpic
gcc -shared -o libsq.so sq.o

そして,次のようにChickenClispのコードを書くと実際にこの関数を使うことができる.

; libsq_test.ore
; このように共有ライブラリのパスを渡し,関数名,及び引数と戻り値を書くことで,ロードされる
; "sq"の部分に関数名,次に戻り値の型,そして,次に,引数リストの型を書く
(dll
  (dll-path "libsq.so")
  (dll-functions
    ("sq" int (int))))

(println "sq 4 : " (sq 4))

これを実行してみると,

% ./chickenclisp libsq_test.ore
sq 4 : 16

正しく計算できている.
また,Cでは文字列はchar*として扱うのが一般的である.しかし,libffiはポインタはポインタとしてしか存在せず(ffi_type_pointerというのがあるのみ),文字列の扱いをどのようにするかは少し考える必要があった.しかし,D言語側で文字列は特殊化し,stringであるという情報から自動的に関数を呼び出すとき,及び返り値が文字列である場合に変換を行うようにしたら,無事に,文字列をCの関数とやり取りすることに成功した.
以下にその例を示す.

// str.c
#include <string.h>
#include <stdlib.h>
#include <stdio.h>

char* dub(char* str) {
  size_t len = strlen(str) * 2 + 1;
  char* s = malloc(sizeof(char) * len);
  sprintf(s, "%s%s", str, str);
  s[len - 1] = '\0';
  return s;
}

与えられた文字列を複製する関数dubを定義する.
そして,これを次のようにコンパイルする.

% gcc -c str.c -fpic
% gcc -shared -o libstr.so str.o

そして,ChickenClispのコードを書く.

; libstr_test.ore
(dll
  (dll-path "libstr.so")
  (dll-functions
  ("dub" string (string))))

(println "dub abc: " (dub "abc"))

そして,これを実行すると,次のような結果が得られる.

./chickenclisp libstr_test.ore
dub abc: abcabc

また,ChickenClispでは,内部でValueという型として,値を保持している.
そこの定義に,void*も持てるような拡張を施すことで,一般のポインタをもたせることができ,次のように構造体を扱うこともできるようにした. いかに例を示す.

// ptr.c
#include <stdlib.h>
#include <stdio.h>

typedef struct S {
  char* name;
  int age;
} S;

S* newS(char* name, int age) {
  S* ret = malloc(sizeof(S));

  ret->name = name;
  ret->age = age;

  return ret;
}

void showS(S* s) {
  printf("s->name : %s\n", s->name);
  printf("s->age : %d\n", s->age);
}

そして,これを共有ライブラリとしてコンパイルする.

% gcc -c ptr.c -fpic
% gcc -shared -o libptr.so ptr.o

そして,これを呼び出すChickenClispのコードを書く.

; libptr_test.ore
(dll
  (dll-path "libptr.so")
  (dll-functions
    ("newS" pointer (string int))
    ("showS" void (pointer))))

(println "newS")
(def-var s (newS "alphaKAI" 20))
(println "showS")
(showS s)

そして,これを実行すると,正しくポインタの受けたわしができ,関数を呼び出せていることがわかる.

% ./chickenclisp libptr_test.ore
newS
showS
s->name : alphaKAI
s->age : 20

また,今回は実装はできなかったので,その方法を述べるに留めるが,Cの関数は関数ポインタを引数に取ることができ, それをコールバック関数とするような関数も存在する.(例えばCの標準ライブラリのqsortはcompareという関数ポインタをとり,比較を行う)
そのような関数をChickenClispから呼ぶ場合,動的にそのようなコールバック関数を生成する必要がある.
そこで,コールバック関数を動的に生成し,それにmprotectを用いて実行権限を与えて共有ライブラリの関数に渡すことに成功したので,それを示す.
将来的には実行時にJITコンパイルでコールバック関数を生成することでChickenClispのFFIを完全なものにしたいと考えており,XbyakD言語版の開発を考えている.

まず,次のようなコールバック関数をとる関数をつくる.

// callback.c
int cb_test(int n, int (*cb)(int)) {
  return cb(n);
}

これは引数nを与えられたコールバック関数cbに渡し,その結果を返す関数である.

そして,それを共有ライブラリとしてコンパイルしておく.

gcc -c callback.c -fpic
gcc -shared -o libcallback.so callback.o

また,動的にコールバック関数を生成する例を示すために,ここでは,先程のsq.cをコンパイルする際に得られる機械語を objdumpで逆アセンブルすることで得て,それをcharの配列に確保しておき,それを実行時にmallocした配列にコピーし mprotectで実行権限を与え,そのメモリへのポインタを返すことで動的にコールバック関数の生成を行う.

先程のsq.cコンパイルしたsq.oをobjdumpを用いて逆アセンブルした結果を以下に示す.

0000000000000000 <_sq>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   89 7d fc                mov    %edi,-0x4(%rbp)
   7:   8b 7d fc                mov    -0x4(%rbp),%edi
   a:   0f af 7d fc             imul   -0x4(%rbp),%edi
   e:   89 f8                   mov    %edi,%eax
  10:   5d                      pop    %rbp
  11:   c3                      retq

そして,この機械語を16進数としてchar[]としてハードコードして,libffiをもちいてコールするコードを書く.

// main_cb.c
#include <ffi/ffi.h>
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>
#include <sys/mman.h>
#include <errno.h>
#include <unistd.h>
#include <stdint.h>
#include <string.h>

// 動的にコールバック関数を生成する.ハードコードされたマシン語を
// posix_memalignで確保したメモリにコピーしmprotectで実行権限を与え,そのポインタを返す
char* gencode() {
  long page_size = sysconf(_SC_PAGESIZE);
  char _code[] = {
    0x55,
    0x48,0x89,0xe5,
    0x48,0x89,0xf8,
    0x48,0x0f,0xaf,0xc7,
    0x48,0x89,0xec,
    0x5d,
    0xc3};
  char *code;
  size_t code_len = sizeof(code) / sizeof(code[0]);

  if((posix_memalign((void **)&code, page_size, code_len))) {
    fprintf(stderr, "posix_memalign\n");
    exit(1);
  }

  memcpy(code, _code, code_len);

  if((mprotect((void*)code, code_len, PROT_WRITE | PROT_EXEC)) == -1) {
    fprintf(stderr, "mprotect");
    exit(1);
  }

  return code;
}

// gencodeを呼び,コールバック関数を生成し,libffiを用いてlibcallback.soを開き
// そのコールバック関数を渡し,32の平方を計算する.
int main(int argc, const char **argv) {
  void *lh = dlopen("libcallback.so", RTLD_LAZY);
  if (!lh)
  {
    fprintf(stderr, "dlopen error: %s\n", dlerror());
    exit(1);
  }
  printf("libcallback.so is loaded\n");

  void* addr = dlsym(lh, "cb_test");
  char *error = dlerror();
  if (error)
  {
    fprintf(stderr, "dlsym error: %s\n", error);
    exit(1);
  }
  printf("cb_test() function is found\n");

  ffi_cif cif;
  ffi_type *arg_types[2];
  void *arg_values[2];
  ffi_status status;

  ffi_arg result;

  arg_types[0] = &ffi_type_sint32;
  arg_types[1] = &ffi_type_pointer;

  if ((status = ffi_prep_cif(&cif, FFI_DEFAULT_ABI, 2, &ffi_type_sint32, arg_types)) != FFI_OK)
  {
    printf("ERROR : %d\n", status);
  }

  int arg1 = 32;
  char* arg2 = gencode();
  arg_values[0] = &arg1;
  arg_values[1] = &arg2;

  ffi_call(&cif, addr, &result, arg_values);

  printf("result is %d\n", (int)result);

  printf("unloading libcallback.so\n");
  dlclose(lh);

  return 0;
}

そして,これをコンパイルして,実行してみると,

% gcc -o main_cb main_cb.c -lffi
% ./main_cb
libcallback.so is loaded
cb_test() function is found
result is 1024
unloading libcallback.so

となり,コールバック関数を動的に生成し,呼び出すことに成功している.

posix_memalignでメモリを確保し,mprotectでメモリに実行権限を与え,動的に機械語を実行する方法については 前回Blogの記事を書いたのでそちらを参考にしてほしい: C言語でヒープ領域にある機械語列に実行権限を与え,実行する方法.

評価

これで,ChickenClispでCの共有ライブラリを呼ぶことができるようになった.
これを実装するに当たり,以前受講したRubyハックチャレンジでのRubyの拡張ライブラリを記述した経験が役に立った.
S式で共有ライブラリの仕様を書くことで簡単にロードできるような仕様にしたことで簡単にFFIを利用することができるようになった.

考察

動的なコールバック関数の生成は,その方法がわかったので(実行時にJITコンパイルをすることで,コールバック関数を生成し,mprotectで実行権限を与えてlibffiを用いて呼び出せば良い),今後,XbyakD言語版を開発すればChickenClispに完全なFFIを搭載することができると考えられる.

まとめ

2年程度開発を続けている自作のLisp,ChickenClipに,libffiを用いてFFIを導入し,CのABIでコンパイルされた共有ライブラリの関数をChickenClispから呼ぶことに成功し,ChickenClispのレイヤーとCのレイヤーの融和に成功した.