Linux の共有ライブラリの挙動について

前フリが長くなったので基礎的な部分は独立した article にしました。でも基礎的な内容だけに間違いが多そうです。ご指摘お願いします。

下記に「Hack#??」と書いてあるものはすべて Binary Hacks の Hack です。書籍をお持ちの方はそちらをご参照ください。

基礎

まず用語定義を引用しておきます。

シンボル(symbol)
一般的には記号を意味するが,Binary Hacks の文脈では,リンカが関数や変数を識別するときに用いる名前のことを指す。
Binary Hacks - Hack#6

(強調部は dayflower による)

さて,共有ライブラリを利用する際の挙動について「Hack#6 静的ライブラリと共有ライブラリ」から抜粋します。

ここでのポイントは,共有ライブラリ単位で処理が行われるということと,リンクする時には必要としている共有ライブラリの SONAME だけを実行可能ファイルに NEEDED として登録してあるということです。

共有ライブラリをリンクした実行ファイルを実行する時に,動的リンカローダ(ld.so)が NEEDED の情報を使って必要としている共有ライブラリを探し出し,実行時にそのプロセスのメモリマップを操作して共有ライブラリと実行バイナリを同じプロセス空間で使えるようにしています。

Binary Hacks - Hack#6

さらに付け加えると,

  • 最終的なバイナリ(実行ファイルや共有ライブラリ)には,外部参照・動的リンクするシンボルと,必要となる共有ライブラリ(NEEDED)が別々に格納されている
  • 両者のマッピングはない。すなわち,あるシンボルがどの共有ライブラリ由来のものかという情報は格納されていない。


具体例をみてみます。シンボルの探索とバインドの様子をみるためには,環境変数 LD_DEBUGsymbols,bindings を指定して実行します。

$ LD_DEBUG=symbols,bindings ./main 

...... snip snip snip ......

symbol=fwrite;  lookup in file=./main [0]
symbol=fwrite;  lookup in file=/usr/lib/libz.so.1 [0]
symbol=fwrite;  lookup in file=/lib/tls/i686/cmov/libm.so.6 [0]
symbol=fwrite;  lookup in file=/lib/tls/i686/cmov/libc.so.6 [0]
binding file ./main [0] to /lib/tls/i686/cmov/libc.so.6 [0]: 
    normal symbol `fwrite' [GLIBC_2.0]

...... snip snip snip ......

LD_DEBUG=symbols によってシンボルの lookup の様子が,LD_DEBUG=bindings によってシンボルの binding の様子が,ログに出力されています。もちろんこれらは単独で指定することもできます。ほか LD_DEBUG については「Hack#58 プログラムが main() にたどりつくまで」を参照してください。オンラインでは元ネタが 「プログラムはどう動くのか? 〜 ELFの黒魔術をかいまみる」にあります*1

さて,出力をみると,fwrite というシンボルを,./main,libz.so,libm.so,libc.so の順に探していること,最終的に libc.so にバインドしたことがわかります。

これらの共有ライブラリは先の引用の説明のとおり,./main の dynamic section に NEEDED として登録されているためです。これらはビルド時に,

$ gcc -o main main.o -lz -lm

のように指定したために登録されました*2。つまり,リンカ(ld)がバイナリの dynamic section に登録したわけです。このことからも,あるシンボルがどの共有ライブラリに所属するのか判断しようがないことがわかります。

共有ライブラリ内でのシンボルの解決

下記のようなライブラリがあるとします。

/* libplace.c: */

#include <stdio.h>

const char * get_right(void)
{
    return "右";
}

void navigate(void)
{
    fprintf(stdout, "コンビニは%sにあります\n", get_right());
}

身も蓋もない例ですみません。get_right()navigate() も外部に公開している関数となっていることに注意してください。この例だと普通 get_right()static 宣言すると思いますが,実際のライブラリでは,別々のソースになっているなど外部公開関数とせざるをえないケースはよくあります。

これを普通に共有ライブラリとしてビルドします((一般的には -Wl,-soname,〜 でバージョンつき共有ライブラリ名を指定しますが今回は略します))。

$ gcc -shared -fPIC -o libplace.so libplace.c

共有ライブラリというのは実態はひとまとまりのオブジェクトなので,このように,静的ライブラリと異なりアーカイバ(ar)ではなくリンカ(ld)で作成します。

静的ライブラリの場合は,複数のオブジェクトファイルのアーカイブでしたが,共有ライブラリの場合は,複数のオブジェクトファイルを1つの巨大なオブジェクトファイルにしてそれらを共有できるようにしたものです。

Binary Hacks - Hack#6

この共有ライブラリを利用する main.c は下記の通りです。

/* main.c: */

void navigate(void);    /* prototype for external */

int main(int argc, char *argv[])
{
    navigate();

    return 0;
}

これを libplace.so を使うようにビルドします(カレントディレクトリにライブラリがあるので -L. を指定しています)。

$ gcc -o main main.c -L. -lplace


readelf -d コマンドで main のダイナミックセグメントを表示すると NEEDED がわかります。

$ readelf -d main| grep NEEDED

 0x00000001 (NEEDED) Shared library: [libplace.so]
 0x00000001 (NEEDED) Shared library: [libc.so.6]

いっぽう,ダイナミックセグメントのシンボルを表示してみます。

$ readelf -s -D main

Symbol table for image:
  Num Buc:    Value  Size   Type   Bind Vis      Ndx Name
    9   0: 0804835c     0    FUNC GLOBAL DEFAULT  11 _init
    6   0: 08049658     0  NOTYPE GLOBAL DEFAULT ABS _edata
    2   0: 00000000     0  NOTYPE   WEAK DEFAULT UND _Jv_RegisterClasses
    1   0: 00000000     0  NOTYPE   WEAK DEFAULT UND __gmon_start__
    5   1: 0804965c     0  NOTYPE GLOBAL DEFAULT ABS _end
    4   1: 00000000    61    FUNC GLOBAL DEFAULT UND navigate
    3   1: 00000000   434    FUNC GLOBAL DEFAULT UND __libc_start_main
   10   1: 0804851c     0    FUNC GLOBAL DEFAULT  14 _fini
    8   2: 08049658     0  NOTYPE GLOBAL DEFAULT ABS __bss_start
    7   2: 0804853c     4  OBJECT GLOBAL DEFAULT  15 _IO_stdin_used

...... snip snip snip ......

navigate はモジュール(main)内で未定義(UND)のシンボルであることがわかります。


さて,まあ,実行すると下記のようになります(使用する共有ライブラリがカレントディレクトリにあるので LD_LIBRARAY_PATH=. を指定しています)。

$ LD_LIBRARY_PATH=. ./main

コンビニは右にあります

さきほどと同じようにシンボル名の解決模様を眺めるため LD_DEBUG=symbols,bindings を指定して実行してみます。

$ LD_DEBUG=symbols,bindings LD_LIBRARY_PATH=. ./main

...... snip snip snip ......

calling init: /lib/tls/i686/cmov/libc.so.6

calling init: ./libplace.so

symbol=__libc_start_main;  lookup in file=./main [0]
symbol=__libc_start_main;  lookup in file=./libplace.so [0]
symbol=__libc_start_main;  lookup in file=/lib/tls/i686/cmov/libc.so.6 [0]
binding file ./main [0] to /lib/tls/i686/cmov/libc.so.6 [0]:
    normal symbol `__libc_start_main' [GLIBC_2.0]

initialize program: ./main

transferring control: ./main

symbol=navigate;  lookup in file=./main [0]
symbol=navigate;  lookup in file=./libplace.so [0]
binding file ./main [0] to ./libplace.so [0]:
    normal symbol `navigate'

symbol=get_right;  lookup in file=./main [0]
symbol=get_right;  lookup in file=./libplace.so [0]
binding file ./libplace.so [0] to ./libplace.so [0]:
    normal symbol `get_right'

...... snip snip snip ......

コンビニは右にあります

...... snip snip snip ......

先ほど未定義だった navigate というシンボルが libplace.so で見つかったことがわかります。

というのは先ほどの例と同じなので新しい発見はありませんが,その後の部分でおもしろいことがわかります。

libplace.so 内の navigate() という関数の中で get_right() を呼び出しているのですが,これは libplace.so 内にあるシンボルです。よって直接呼び出しても構わないはずですが,get_right シンボルを探索・バインドしています。すなわち,同一モジュール内の(グローバル)関数の呼び出しにおいてすら ld.so(動的リンカ)を経由しているのです。

これを図示すると下記のようになります。


別々の共有ライブラリでのシンボルの衝突

  • 未解決シンボルに共有ライブラリ名があらかじめバインドされているわけではない
  • 共有ライブラリ内のグローバルシンボルの解決においてすら動的リンカを経由する

これらの仕様は時として悲劇を招くことがあります((逆にポジティブに利用すると LD_PRELOAD 環境変数を使って既存の関数を置き換えることができます。が,この稿では説明しません。「Hack#60 LD_PRELOAD で共有ライブラリを置き換える」を参照してください。))。それが「Hack#19 リンク時のシンボルの衝突に注意する」です。

さきほどの例の続きです。下記のようなあたらしいライブラリがあるとします。

/* libcond.c: */

const char * get_right(void)
{
    return "正しい";
}

かなり恣意的ですが get_right() という関数を定義してあります。

これをさきほどと同じように共有ライブラリとしてビルドします。

$ gcc -shared -fPIC -o libcond.so libcond.c

main.c をビルドしなおします。

$ gcc -o main main.c -L. -lcond -lplace

libcond が libplace より先にリンクされているのがミソです。

これを実行すると

$ LD_LIBRARY_PATH=. ./main

コンビニは正しいにあります

このようにおかしな出力になってしまいます。

これは,もうおわかりのとおり,get_right シンボルの解決の際,NEEDED で先に格納されている libcond.so が利用されてしまったことによります。しつこいようですが LD_DEBUG 環境変数を利用した出力を示します。

$ LD_DEBUG=symbols,bindings LD_LIBRARY_PATH=. ./main

...... snip snip snip ......

initialize program: ./main

transferring control: ./main

symbol=navigate;  lookup in file=./main [0]
symbol=navigate;  lookup in file=./libcond.so [0]
symbol=navigate;  lookup in file=./libplace.so [0]
binding file ./main [0] to ./libplace.so [0]:
    normal symbol `navigate'

symbol=get_right;  lookup in file=./main [0]
symbol=get_right;  lookup in file=./libcond.so [0]
binding file ./libplace.so [0] to ./libcond.so [0]:
    normal symbol `get_right'

...... snip snip snip ......


個人でプログラムを書いている場合,このようにシンボルが衝突してしまうケースはあまりないでしょう。しかし,他のライブラリをリンクすることは日常的であり,プログラムが複雑化している現代では,リンクするライブラリ数は膨大になっています。また各ライブラリにどのような関数があるのかすべて知って使うわけではありません。

さらに,今回の場合呼び出し規約が一致しているためそれほどひどい出力にはなりませんでしたが,場合によっては(というよりかなりありがちですが)引数や戻り値などの規約が違うことがありえます。また処理自体が大幅に違うものであれば破壊的動作を示すことになります。

解決策は

この問題の解決策の一つは,簡単には,関数や変数の名前にユニーク名称を付与する,というものです。

今回の例だと,

  • libplace.so の get_right()place_get_right()
  • libcond.so の get_right()cond_get_right()

する,などです。とはいえこれも実施するのはなかなか大変ですね。

C++ の場合,シンボル名にクラス名やネームスペースが含まれますし,呼び出し規約(引数の型や戻り値の型)も含まれるので,(クラス名などがかぶらないかぎり)半自動的にユニークなシンボルになります。

C の場合でも,関数本体は static にしておき,関数ポインタを含む構造体経由で関数を実行するようにすれば(自力 vtbl?),内的な関数は(ソースを分割したとしても)ローカルにバインドできます。そのようなスタイルのコードが増えてきた背景は,オブジェクト指向ということはもちろんあるとは思いますが,案外このようなシンボルの衝突回避が理由としてあげられるのかもしれません。


あと,別の解決策として -fPIC をつけずに共有ライブラリを作るというのがあります(たぶん……それでいけますよね?)。しかしこれはおすすめできません。


さて,こんなのやってらんねー,という方のための情報があるのですが,続きます→ld 2.18 の -Bsymbolic オプションを使うと共有ライブラリ内でシンボルをローカルバインドできる - daily dayflower

Windows の共有ライブラリ(DLL)について

Windows の場合,暗黙的リンク*3においてすら,未解決外部シンボル名(実際には序数経由のケースが多いですが)と共有ライブラリ名(DLL)の対応はとられています(Dependency Walker などのプログラムでその様子を見ることができます)。またライブラリ内で関数を呼び出した場合もリンク時に直接解決されます……と思いますがちょっとこのへんの知識はあやふやです。

さいごに

Binary Hacks ―ハッカー秘伝のテクニック100選 から結構引用してしまいましたが実際役に立ちました。最初は「結構知っていることも多いし,逆に知らない部分はマニアックだし,レベルがまちまちでいまいち使えないなー」と思っていたのですが,すいません,たかをくくってました。会社の備品を参照しながら書きましたが,暇ができたら自分の分も購入したいと思います。

Binary Hacks ―ハッカー秘伝のテクニック100選

Binary Hacks ―ハッカー秘伝のテクニック100選

*1:こちらのほうが実例が詳しい

*2:実例のために無駄に libz と libm をリンクしています

*3:インポートライブラリ(.LIB ファイル)を利用したリンクのこと。late binding とはまた別の話です