📚

CのライブラリをmacOSアプリにリンクして呼び出す

2022/10/10に公開

Cで書いた動的ライブラリ(.dylib)をmacOSアプリにリンクできないか試したメモ。
やってみたら案外すんなり動いたので嬉しかったようです。

忙しい人のためのまとめはこちら
また、今回の実装は Enchan1207/dylib4mac にも置いてありますので、よろしければ参考までに…

環境

今回使用したビルド環境はこんな感じ。

  • MacBook Pro 2020, Intelコア (macOS 12.6 Monterey)
  • Xcode 14.0.1
  • Apple clang version 14.0.0 (clang-1400.0.29.102)

動的ライブラリを作るとき

たとえば、こんな感じのライブラリを作るとします:

helloworld.h
//
// Hello, World!を返すだけ
//
#ifndef HELLO_WORLD_H
#define HELLO_WORLD_H

#include <string.h>

char nextChar();

#endif /* HELLO_WORLD_H */
helloworld.c
#include "helloworld.h"

const char* str = "Hello, World!";

char nextChar() {
    static int i = 0;
    size_t length = strlen(str);
    if (i > length) {
        i = 0;
    }
    return str[i++];
}

nextCharは、呼び出すたびに 'H', 'e', 'l', 'l', 'o', ','... と出力されていく関数です。
内部で「今どこを参照しているか」を保持しており、毎回文字列の長さをstrlenで計算し、範囲チェックした上でcharを返す…という処理を行っています。

コンパイルする場合はこんな感じで:

$clang -c -fpic helloworld.c
$clang -shared -o libhelloworld.dylib helloworld.o

成功すると libhelloworld.dylib が生成されます。

次に、これを呼び出すためのmain.cを用意します:

main.c
#include <stdio.h>

#include "helloworld.h"

int looplimit = 100, loop = 0;

int main(int argc, char const *argv[]) {
    char c = nextChar();
    while (c != '\0' && loop < looplimit) {
        printf("%c", c);
        c = nextChar();
        loop++;
    }
    printf("\n");
    return 0;
}

コンパイル時にライブラリをリンクします。

$clang main.c -lhelloworld -o main
$./main
Hello, World!

無事に Hello, World! することができました。
ここまでの内容は create_library ディレクトリにまとめています。

macOSアプリケーションにリンクする

次に、このライブラリをmacOSアプリケーションにリンクする方法を考えます。

プロジェクトの作成, ヘッダとライブラリの追加

Xcodeから、何も考えずにmacOSのAppを作成し…

create project

ヘッダファイル bridge.h を作り、TargetのBuild Settingsタブにある Objective-C Bridging Header にパスを設定します。 これでこのファイルがBridging-headerとして認識されるようになりました。

bridging

ヘッダ参照先の設定

このままでは先ほどのhelloworld.hを探しに行けないので、TargetのBuild Settingsタブにある Header Search Paths を指定します。
ヘッダを探しに行ければ正直なんでもいい気もしますが、絶対パスではなく$(SRCROOT) などを用いたほうが良いでしょう。(移植性的な意味で)

ビルド設定の編集

次に、追加したライブラリをアプリケーションに埋め込んでいきます。

1. Targetにライブラリを埋め込む

TargetのGeneralタブにあるFrameworks, Libraries, and Embedded Contentにdylibファイルを追加し、"embed & sign" を選択します。
今回はdylibつまり動的リンクライブラリなので、署名した上でアプリケーションに埋め込む必要があります。

embed and sign

2. バイナリにライブラリをリンクする

ライブラリのリンク設定と、リンクするライブラリの(アプリ内での)配置場所を指定します。

TargetのBuild PhasesタブにあるLink Binary With Librariesにdylibファイルを追加し、Requiredを選択します。
さらに、Embed Librariesにも同様にdylibファイルを追加し、DestinationFrameworksを設定します。これにより、dylibファイルの配置先が決定されます。

link library

これで完了です。

Swiftから呼び出す

それでは、このライブラリをSwift側から呼び出してみます。
Bridging-header内でhelloworld.hをimport(ObjCなのでincludeではなくimportです)するとヘッダ内のシンボルたちがグローバルで定義され、どこでも使えるようになります。

https://github.com/Enchan1207/dylib4mac/blob/master/dylib_invoke/bridge.h

ViewController.swiftにこんな感じで呼び出すコードを書いて…

https://github.com/Enchan1207/dylib4mac/blob/master/dylib_invoke/dylib_invoke/Controllers/MainWindowController.swift#L40-L51

実行するとエラーで落ちます。おい。

ライブラリの設定を見直す

やたら長ったらしいエラーが出ますが、要約するとこのようなことを言っています:

dyld[99575]: Library not loaded: 'libhelloworld.dylib'
  Referenced from: '/path/to/build_folder/test.app/Contents/MacOS/test'
  Reason: 
    tried:
        '/path/to/build_folder/libhelloworld.dylib' (no such file),
        '/usr/lib/system/introspection/libhelloworld.dylib' (no such file),
        'libhelloworld.dylib' (no such file),
        '/usr/local/lib/libhelloworld.dylib' (no such file),
        '/usr/lib/libhelloworld.dylib' (no such file),
        '/path/to/build_folder/libhelloworld.dylib' (no such file),
        '/usr/lib/system/introspection/libhelloworld.dylib' (no such file),
        '//libhelloworld.dylib' (no such file),
        '/usr/local/lib/libhelloworld.dylib' (no such file), 
        /usr/lib/libhelloworld.dylib' (no such file)

つまり、

dyld「ライブラリ一生懸命探したけど見つからんかったわ、すまん🥺」

と言っているようです。
そんなとこにあるわけないだろみたいなパスにまで健気にもライブラリを探しに行ってくれるdyldくん、ちょっとかわいいですね。NovelAIか何かで擬人化してくれないものでしょうか。

ライブラリはどこにある?

しかし、我々は確かに Frameworks/以下にライブラリを置くよう指示したはず。
メニューバーから Product > Show Build Folder in Finder でビルドフォルダを表示させると、このようなディレクトリ構造になっています:

tree
.
├── Intermediates.noindex
│   (省略)
└── Products
    └── Debug
        ├── test.app
        │   └── Contents
        │       └── Frameworks
        │           └── libhelloworld.dylib
	│ (省略)
        └── test.swiftmodule (省略)

dylibが問題なくコピーされています。
ではなぜ、dyldはこのFrameworks配下を読みに行ってくれないのでしょうか。

otoolでdylibの情報を見る

いったんライブラリ側を詳しく見てみましょう。macOSには、dylibの情報を表示するコマンドとしてotoolが存在します。

$otool -L libhelloworld.dylib
libhelloworld.dylib:
	libhelloworld.dylib (compatibility version 0.0.0, current version 0.0.0)
	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1311.100.3)

なにやら出てきました。 オプションとして渡している -Lは、manページによれば

Display the names and version numbers of the shared libraries that the object file uses, as well as the shared library ID if the file is a shared library.

オブジェクトファイルが使用している共有ライブラリの名前とバージョン番号、およびファイルが共有ライブラリである場合は共有ライブラリIDを表示します。

とのこと。 要するにその共有ライブラリが依存するライブラリを表示してくれるものです。
(自身がリストアップされるのはちょっと不思議な感じがしますが)

出力をよく見ると、libSystem.B.dylibの方は絶対パスであるのに対し 我らがlibhelloworld.dylibは…なに…この…なに…? になってしまっています。 /libhelloworld.dylib なのか、 ./libhelloworld.dylib なのか…
このあたりでdyldはワケがわからなくなり、先ほどのような暴挙に出たとそういうわけです。

ライブラリのビルド時に自身の配置先を指定する

dyldが困る原因はわかりましたが、次に困るのは我々デベロッパ。Xcodeの気分次第で変わるビルドフォルダのパスを毎回dylibに渡してビルドしなおしていたのではキリがありません。
この対策というか普通にそういう機能だと思いますがとして、dyldには 「パス内の特定の文字列を変数として解釈し、実行時に展開して読み込む」という機能があります。
その変数の一つが @rpath です。 これは、XcodeのプロジェクトではBuild SettingsRunpath Search Path に設定した値に展開されます。

rpath

@executable_pathappname.app/Contents/MacOS/に置かれている実行ファイルのパスを表すので、@executable_path/../Frameworksappname.app/Contents/Frameworks/を指すことになります。ここには先ほど置いたlibhelloworld.dylibが存在するので、dylibの中のパスを@rpath/libhelloworld.dylibに設定してあげればよさそうです。

調べてみると 「install_name_toolを使うと変更できるよー」という記述を見かけますが、自作ライブラリであればclangの-install_nameオプションで指定できます。

最終的に、ライブラリのビルドコマンドはこのようになり…

$clang -c -fpic helloworld.c
$clang -shared -o libhelloworld.dylib -install_name @rpath/libhelloworld.dylib helloworld.o

これで生成されたdylibを再びXcodeの方に持っていくことで

動作イメージ

動的リンクに成功し、無事に実行することができました。

デプロイターゲットの罠

ふーやれやれ動いた動いた… リンカ周りは情報が少なかったり古かったりで大変です。

おっと、デプロイターゲットの設定を忘れていました。
誰もが最新版のmacOSを使っているとは限らないので、最低限動作するくらいまでバージョンを落としてしまいます。なんとなくで 「OSX」から「macOS」に改名された macOS 10.12 Sierraまで対応させましょう。

version

TargetのGeneralタブからバージョンを選択して、再ビルドすると…

ビルドが…

成功して…

WHAT

*WHAT*

なにやらWarningが出てしまいました。

ライブラリのビルド時に最小バージョンを指定する

ログを読んでみます。

Dylib (/path/to/project/libhelloworld.dylib) was built for newer macOS version (12.0) than being linked (10.12)
Dylib (/path/to/project/libhelloworld.dylib) は、リンクされている (10.12) よりも新しい macOS バージョン (12.0) 用に構築されました。

…なるほど。つまり、デプロイターゲットに対してライブラリが新しすぎると…

というわけでライブラリの再ビルドです。clangの-mmacosx-version-minオプションを使用して設定します。

$clang -mmacosx-version-min=10.12 -c -fpic helloworld.c
$clang -mmacosx-version-min=10.12 -shared -o libhelloworld.dylib -install_name @rpath/libhelloworld.dylib helloworld.o

これで生成されたdylibを再びプロジェクトに追加すれば、Warningは消えてくれます。

ちなみに、otoolを使用して対応バージョンを確認することも可能です:

$ otool -l libhelloworld.dylib
(中略)
Load command 8
      cmd LC_VERSION_MIN_MACOSX
  cmdsize 16
  version 10.12 <--- コンパイル時に指定したものが適用されている!
      sdk 12.3
(中略)

まとめ

この記事のまとめです:

  • Cでコンパイルした動的ライブラリはXcodeのプロジェクトから読み込んでリンクできる
  • 埋め込み、署名、配置先は Build Settings , Build Phases で指定する
  • ライブラリが配置される場所は -install_nameで設定できる
  • サポートするOSのバージョンは -mmacosx-version-minで指定する
  • otool超便利
  • 健気なdyldくんがかわいい
  • ところでdyldってなんて読むんでしょう

プロジェクトのビルドには成功するのに実行ができないというのはなんとも原因の特定が難しく…
cmakeを使うともう少し簡単になったりするらしい、というかそもそもライブラリ本体をXcodeで作ればこんな問題は起こらないのですが、中でどのような処理になっているのかを追ってみようということで記事にしました。

次回予告

CのライブラリをiOSアプリにリンクして呼び出す

(本当はこっちがメインのはずでしたが、rpath周りの話で予想外に文字数が増えたので記事を分けることにしました)


個人的な話…

Zennデビューです。これまでQiitaやHatenablog等で色々と書いてきましたが、Zennはエディタがシンプルなのがとてもいい感じ。
もしかしたら随時こちらに移行するかもしれません。

Discussion