Go言語の -buildmode=c-archive と Lazarus でのスタティックリンク(Windows/Linux)

前回の記事では Windows で -buildmode=c-archive を試した。今回はそれと同じことを Ubuntu 上でも行ってみて、最終的に同じソースコードで両方の環境でコンパイルできるところまで持ち込む。


最初の一歩

前回の話を踏まえると、Lazarus(Free Pascal Compiler) で go build -buildmode=c-archive でコンパイルしたバイナリをリンクしようとする時は、概ね以下のようにすると最低限処理が動くようになるはずだった。
  • FPC のリンカだと上手く動かないので Use external linker オプションを付ける必要がある
    → プロジェクトオプションのコンパイラオプション → Custom Options から -Xe を追加
  • ライブラリのファイル名が lib*.a でなければ上手く認識させられない
    → Go言語側で main パッケージのディレクトリ名を libhoge という感じにしておく
  • 初期化処理が呼ばれないので自分で呼ぶ必要がある
    _rt0_amd64_linux_lib を呼びたい(呼べなかった)
というわけで、_rt0_amd64_linux_lib が呼べなくて最初に躓いた。

もう少し詳しく見てみると、Windows では
nm libhoge.a | grep _lib
などとすると _rt0_amd64_windows_lib は外部シンボルとして定義されているのだが、Ubuntu で同じことをすると _rt0_amd64_linux_lib は内部シンボルとして定義されていた。そして、FPC で
procedure _rt0_amd64_windows_lib(); cdecl; external;
みたいな形で定義したものは外部シンボルでなければ参照できず、このままでは無理、という状況。

「この問題を解決するためにどうするのがいいか」という以前にそもそもこの辺あまり詳しくないので、わかる範囲で少し調べながら対応方法を検討した。

その上で一番正しいっぽい道のりは多分「FPC 側でオブジェクトファイルの .init_array / .ctor セクションを参照して初期化処理を実行する」みたいな感じだと思うのだが、ちょっと話のスケールが大きい上に既存の振る舞いとの互換性の問題もありそうで手を出しにくいので、この方向は諦めた。
もし実現させるならプログラム固有のコードじゃなくてランタイム側のコードを変える話になりそう。

次に考えたのは「Go言語側でのビルド時に Windows では外部シンボルになっているんだから、挙動が違う原因を突き止めて両方外部シンボルにできれば解決するのでは?」という方向性。
しかし、そもそも外部シンボルになっているものを nm -g libhoge.a で列挙してみると、むしろ Windows 側が不必要な部分まで外部シンボルになってしまっているのではないか、と思えるぐらいに差があったので、将来的には逆に Windows 側で多くが内部シンボルになる方が正しそうな予感がして、このアプローチもやめた(だから挙動が違う原因もわからないまま)。

最終的に選んだアプローチは「_rt0_amd64_linux_lib を外部シンボル化する方法を探る」という感じ。
Linux 側で nm -g libhoge.a した時に出てくる cgo 関係の外部シンボルを見る限り、この辺りで使われている機能を上手く利用すれば外部シンボルにできそうだった。
最初はGo言語のソースコードの src/runtime 辺りにファイルを追加して、全体を make し直して使えるようになることを確認していたのだが、もう少し調べてみると別に src/runtime に置く必要は全然なかったようなので、github.com/oov/rt0 という、コードが全然ないパッケージを作った。
darwin とか試してないものもあるが、取りあえず _rt0_GOARCH_GOOS_lib が現状定義されているものに関しては全部揃えておいた。

これで、-buildmode=c-archive を使う main パッケージ辺りで
import _ "github.com/oov/rt0"
だけで外部シンボルになるようになった。

初期化処理を正しく呼ぶ

Windows の時はパラメータを特に定義せずに呼んでいたのだが、同じように Linux で _rt0_amd64_linux_lib を呼んでみると、プログラムの起動時に
runtime: kernel page size (134593276374883) is larger than runtime page size (4096)
という、なんか値の差が大きすぎて何かが根本的に噛み合ってなさそうな気配のするエラーメッセージが出て上手く動かなかった。
Go 1.6.2 で同じことをしてもエラーメッセージが出ずに実行されたのだが、つい最近追加された部分だったらしい

そこで rt0_linux_amd64.s のコードを読んでみると、tip の rt0_windows_amd64.s とは違い DI と SI には本来 argc と argv が入っているらしいことがわかった。呼び出し規約を確認してみると2つの引数が渡されてきた時の形なので「あ、これ要するに main 関数じゃん!」などとやっと気付いた勘の悪い自分であったが、結果的に
procedure _rt0_amd64_linux_lib(argc: Longint; argv: PPChar); cdecl; external;

initialization
  _rt0_amd64_linux_lib(argc, argv);
という形に辿り着くことができたので良しとしよう。

動いたー やったー
前回の記事で SIGFPE で止まっていた問題は Windows でもいつからか再現しなくなってしまったので、Linux 上でも起こるかどうかなどは謎。

処理をまとめる

これで Windows と Linux で同じ地点まで辿り着いたので、あとは同じソースコードで動くようにしたい。

まずはGo言語側。前回より少しメモリコピー回数がマシになっている。
OS によって処理が分かれている部分も特にないのであまり見どころはない。
package main

// #include <string.h>
import "C"

import (
 "bytes"
 "image/png"
 "os"
 "unsafe"

 "github.com/oov/psd"
 _ "github.com/oov/rt0"
)

//export PSD2PNG
func PSD2PNG(filename *C.char, buf *unsafe.Pointer, ln *C.int) C.int {
 file, err := os.Open(C.GoString(filename))
 if err != nil {
  return 1
 }
 defer file.Close()

 img, _, err := psd.Decode(file, &psd.DecodeOptions{SkipLayerImage: true})
 if err != nil {
  return 2
 }

 var b bytes.Buffer
 pe := png.Encoder{CompressionLevel: png.BestSpeed}
 err = pe.Encode(&b, img)
 if err != nil {
  return 3
 }

 *buf = C.malloc(C.size_t(b.Len()))
 C.memcpy(*buf, unsafe.Pointer(&b.Bytes()[0]), C.size_t(b.Len()))
 *ln = C.int(b.Len())
 return 0
}

func main() {}
そして Lazarus 側。初期化処理用のシンボル名は環境毎に変わるので、まずそれに対応する処理が必要。
あとは Windows の時はリンクするファイルが増えるのでそれも ifdef で囲っておく。
Windows では argc / argv を参照していないが、呼び出し規約的に動作に影響はないはずなので処理は分けてない。
unit psdtest;

{$mode objfpc}{$H+}

interface

function PSD2PNG(Filename: PChar; Buf: PPointer; Len: PInteger): Integer;
  cdecl; external Name 'PSD2PNG';
procedure Cfree(P: Pointer); cdecl; external Name 'free';

implementation

{$linklib libpsdtest.a}

{$ifdef WINDOWS}
  {$linklib libntdll.a}
  {$linklib libws2_32.a}
  {$linklib libadvapi32.a}
  {$linklib libwinmm.a}
  {$linklib libkernel32.a}
  {$linklib libmsvcrt.a}
{$endif}

const
  ARCH = {$ifdef CPU386}'386'{$endif}{$ifdef CPUAMD64}'amd64'{$endif}{$ifdef CPUARM}'arm'{$endif}{$ifdef CPUAARCH64}'arm64'{$endif};
  OS = {$ifdef WINDOWS}'windows'{$endif}{$ifdef LINUX}'linux'{$endif}{$ifdef DARWIN}'darwin'{$endif};

procedure GoInit(argc: longint; argv: PPChar); cdecl;
  external Name '_rt0_' + ARCH + '_' + OS + '_lib';

initialization
  GoInit(argc, argv);

end.
まあ Lazarus 側はこのままだと扱いにくいので、もうちょっと使いやすくしたのがこちら。
unit psdtest;

{$mode objfpc}{$H+}

interface

uses
  Classes;

function PSD2PNG(FileName: string): TStream;

implementation

uses
  SysUtils;

function PSD2PNG(Filename: PChar; Buf: PPointer; Len: PInteger): integer;
  cdecl; external Name 'PSD2PNG';
procedure Cfree(P: Pointer); cdecl; external Name 'free';

{$linklib libpsdtest.a}

{$ifdef WINDOWS}
  {$linklib libntdll.a}
  {$linklib libws2_32.a}
  {$linklib libadvapi32.a}
  {$linklib libwinmm.a}
  {$linklib libkernel32.a}
  {$linklib libmsvcrt.a}
{$endif}

const
  ARCH = {$ifdef CPU386}'386'{$endif}{$ifdef CPUAMD64}'amd64'{$endif}{$ifdef CPUARM}'arm'{$endif}{$ifdef CPUAARCH64}'arm64'{$endif};
  OS = {$ifdef WINDOWS}'windows'{$endif}{$ifdef LINUX}'linux'{$endif}{$ifdef DARWIN}'darwin'{$endif};

procedure GoInit(argc: longint; argv: PPChar); cdecl;
  external Name '_rt0_' + ARCH + '_' + OS + '_lib';

// --------------------------------

type
  TCMemoryStream = class(TCustomMemoryStream)
  public
    constructor Create(const P: Pointer; Sz: PtrInt);
    destructor Destroy; override;
  end;

constructor TCMemoryStream.Create(const P: Pointer; Sz: PtrInt);
begin
  inherited Create;
  SetPointer(P, Sz);
end;

destructor TCMemoryStream.Destroy;
begin
  Cfree(Memory);
  inherited Destroy;
end;

function PSD2PNG(FileName: string): TStream;
var
  Errno, Size: Integer;
  Buf: Pointer;
begin
  Errno := PSD2PNG(PChar(FileName), @Buf, @Size);
  if Errno <> 0 then
     raise Exception.Create('cannot convert psd to png: '+FileName);
  try
    Result := TCMemoryStream.Create(Buf,Size);
  except
    Cfree(Buf);
  end;
end;

initialization
  GoInit(argc, argv);

end.
ファイル名を string、戻り値を TStream にすることでフレンドリーに使える形になった。 TCustomMemoryStream を継承したストリームを作る事で無駄なメモリコピーも抑制できて一石二鳥。
入力もファイル名の代わりに TStream にできると思うけど、巨大なファイルをメモリの固まりじゃなくてストリームとして綺麗に渡すのは結構面倒な話になりそうなのでここでは触れないでおく。

使う側は
procedure TForm1.Button1Click(Sender: TObject);
var
  S: TStream;
begin
  if not OpenDialog1.Execute then
    Exit;
  S := PSD2PNG(OpenDialog1.FileName);
  try
    Image1.Picture.LoadFromStream(S);
  finally
    S.Free;
  end;
end;
みたいな普通な感じで使えて、これで両方の環境でコンパイルできるソースコードになった。