2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

初期化順に依存関係のあるライブラリのトラブル

Posted at

はじめに

まとめ

  • ライブラリの初期化処理に依存関係がある時、初期化順でトラブることがある
  • アプリの実行ファイル等をいじれない場合、LD_PRELOAD環境変数を使う回避策がある
  • アプリを再ビルドできるなら、リンクするライブラリの指定順序で対処できる
  • そもそもの話として、依存関係を持ったライブラリを作るときに注意しよう

背景

Linux環境においてC言語でアプリを作る場合、main関数を呼ぶ前に(明示的に初期化関数を呼ばなくても)自動で初期化処理を行うということができるようになっています。
これは、Linuxで採用しているELFという実行ファイルフォーマットとそのローダの持つ機能で、標準のものも含め各種ライブラリ ( *.soファイル ) には、大抵そういった自動で行う初期化処理が仕組まれています。
※今回はCを例に挙げていますが、C++でのグローバル変数へのコンストラクタ呼び出しなんかは、この初期化処理の仕組みを活用している典型になります。

しかし、ライブラリの初期化処理に依存関係があり、かつマズい条件が重なると、まだ初期化が済んでないのにあるライブラリの機能を使おうとしたということで、トラブルになるケースがあります。

本記事は、そういったトラブルの原因と対処に迫ります。

サンプル

ソースおよび検証環境

ここであげる事例は、Ubuntu18 ( Windows10/WSL ) で試したものです。
ソース等一式は https://github.com/angel-p57/qiita-sample/tree/master/linkdep 以下にあります。

想定する動作

ここでサンプルとして、tbase というライブラリと、それに依存する tdep というライブラリを考えます。
「依存する」というのは、この場合 tdepライブラリの中で tbaseライブラリ内の変数なり関数なりを使っている、ということを指しています。
今回は初期化に関するトラブルが発生する状況を作り出すためにtdepライブラリの初期化時にtbaseライブラリの関数を呼び出すよう実装しています。処理としては単純にメッセージを出力するだけですが、各ライブラリに次のような性質を持たせています。

  • tbase
    • main以前に初期化処理を行う ( 初期化開始・終了の2回分メッセージを出力する )
    • 初期化処理後、use_tbaseを呼ぶことができる。( メッセージを出力する )
    • 初期化処理が済んでいない場合、use_tbaseを呼ぶとアボートする。
  • tdep
    • main以前に初期化処理を行う ( 初期化開始・終了の2回分メッセージを出力する )
    • 初期化処理の中で use_tbase を呼び出す。
    • 初期化処理後、use_tdepを呼ぶことができる。( メッセージを出力する )
      ※こちらは検証に影響ないので、初期化処理が済んでない場合のケアはしていません

そのため、以下のようにmainを実装したアプリに対して、ちゃんと動いた時の出力は次のようになることを想定しています。

サンプルのmain
int main(void) {
  use_tbase();
  use_tdep();
}
正常な出力の想定
tbase: initializing...
tbase: initialized.
tdep: initializing...
tbase: used.
tdep: initialized.
tbase: used.
tdep: used.

つまり、「自動的な初期化」によってまずtbaseの初期化が行われ、次にtdepの初期化が行われる中でtbaseが一度使われ、最後mainが実行されるときにtbase,tdepが使われる、という想定です。

しかしマズい条件の場合、次の出力のように、tbase初期化の前にtdepの初期化が動いてしまい、そこでtbaseを使おうとしてトラブルになるケースがありえます。
※このサンプルでは丁寧にエラーを出してますが、実アプリだと原因も分からずSEGVで落ちる、あるいはもっと酷い場合は、状態異常を抱えたままアプリが動いて後で異常動作を起こすことも考えられます。

異常時の想定
tdep: initializing...
tbase: not initialized yet, aborted.
Aborted (core dumped)

ソースと機能分担

ということで、ここでソースと機能分担を挙げておきます。
全内容については、上で挙げたgithubのリポジトリをご参照ください。
※後の説明でも取り上げます

  • libtbase.c … ライブラリ libtbase.so のもと
    • 初期化処理 init ( staticのため外部からは見えない )
    • ライブラリの機能 use_tbase
      初期化が済んでない場合は明示的にアボートする
  • libtdep.c … ライブラリ libtdep1.so, libtdep2.so のもと
    • 初期化処理 init ( staticのため外部からは見えない )
      初期化処理の中でuse_tbaseを呼ぶ。そのためライブラリtbaseに依存する
    • ライブラリの機能 use_tdep
  • main.c … 実行ファイル exe1-bd, exe1-db, exe2-bd, exe2-db のもと
    • メイン処理 main
      use_tbaseuse_tdepを呼ぶ
  • Makefile … makeの設定ファイル
    • 各種ビルドや、できた実行ファイルのテスト実行を行えるようターゲットを設定している

リンク順により挙動が変わるケース

サンプルのビルドと実行

まず、次のようにして共通部分 libtbase.so,libtdep1.so,tdep.oをビルドしておきます。
※以下では make コマンドでターゲットを指定して処理していますが、直接対応する gcc コマンドを叩いても構いません

共通部分のビルド
$ make clean
rm -f exe1-db exe1-bd exe2-db exe2-bd *.so *.o
$ make libtdep1.so libtbase.so main.o
gcc -c -I. -fpic libtdep.c
gcc -o libtdep1.so -shared libtdep.o
gcc -c -I. -fpic libtbase.c
gcc -o libtbase.so -shared libtbase.o
gcc -c -I. main.c

そのうえで、-ltdep1-ltbaseのリンク順を入れ替えた2つの実行モジュール、exe1-db,exe1-bdをリンクし実行すると、前者は特に問題なく実行できますが、後者は初期化問題が顕在化していることが分かります。

実行モジュールのリンクと実行
$ make exe1-db
gcc -o exe1-db main.o -Wl,--no-as-needed -L. -Wl,-rpath=. -ltdep1 -ltbase
$ make exe1-bd
gcc -o exe1-bd main.o -Wl,--no-as-needed -L. -Wl,-rpath=. -ltbase -ltdep1
$ ./exe1-db
tbase: initializing...
tbase: initialized.
tdep: initializing...
tbase: used.
tdep: initialized.
tbase: used.
tdep: used.
$ ./exe1-bd
tdep: initializing...
tbase: not initialized yet, aborted.
Aborted (core dumped)

挙動の違いが出る原因

この挙動の違いが出る原因は、ご想像の通り、リンク指定順を替えたことによるものです。
その影響がどのように出ているか、これは LD_DEBUG 環境変数に files や all といった値を指定することで見ることができます。

libtdep→libtbaseの順
$ LD_DEBUG=files ./exe1-bd
(略)
      1903:     file=libtbase.so [0];  needed by ./exe1-bd [0]
(略)
      1903:     file=libtdep1.so [0];  needed by ./exe1-bd [0]
(略)
      1903:     file=libc.so.6 [0];  needed by ./exe1-bd [0]
(略)
      1903:
      1903:     calling init: /lib/x86_64-linux-gnu/libc.so.6
      1903:
      1903:     calling init: ./libtdep1.so
      1903:
tdep: initializing...
tbase: not initialized yet, aborted.
Aborted (core dumped)

この通り、libtdepを先にリンクした場合、ライブラリのロード順は libtbaselibtdepと逆ですが、初期化処理 ( calling init の部分 ) は libtdep が先になり、アボートを引き起こします。

libtbase→libtdepの順
$ LD_DEBUG=files ./exe1-db
(略)
      1904:     file=libtdep1.so [0];  needed by ./exe1-db [0]
(略)
      1904:     file=libtbase.so [0];  needed by ./exe1-db [0]
(略)
      1904:     file=libc.so.6 [0];  needed by ./exe1-db [0]
(略)
      1904:
      1904:     calling init: /lib/x86_64-linux-gnu/libc.so.6
      1904:
      1904:
      1904:     calling init: ./libtbase.so
      1904:
tbase: initializing...
tbase: initialized.
      1904:
      1904:     calling init: ./libtdep1.so
      1904:
tdep: initializing...
tbase: used.
tdep: initialized.

一方で、libtbaseを先にリンクした場合、初期化処理も libtbase が先になるため、こちらは正常終了になるという寸法です。

このように、リンク順が実行時のライブラリロード順、ライブラリの初期化呼び出し順に関わってくることが分かります。

回避策

では、リンク順を誤ってしまった場合は、その実行ファイルは捨てるしかないのでしょうか。もちろん捨てた方が良いとは思いますが、実はこの場合も回避策があります。
それは、LD_PRELOAD環境変数に、ライブラリファイル libtdep1.so を指定して実行する方法です。

先ほどと同じように LD_DEBUG でデバッグ情報を出しつつ、この環境変数を指定した場合の挙動を見てみます。

LD_PRELOADによる回避策
$ LD_PRELOAD=libtdep1.so LD_DEBUG=files ./exe1-bd
()
      1905:     file=libtdep1.so [0];  needed by ./exe1-bd [0]
()
      1905:     file=libtbase.so [0];  needed by ./exe1-bd [0]
()
      1905:     file=libc.so.6 [0];  needed by ./exe1-bd [0]
()
      1905:     calling init: /lib/x86_64-linux-gnu/libc.so.6
      1905:
      1905:
      1905:     calling init: ./libtbase.so
      1905:
tbase: initializing...
tbase: initialized.
      1905:
      1905:     calling init: ./libtdep1.so
      1905:
tdep: initializing...
tbase: used.
tdep: initialized.
()

上の実行例のように、初期化処理の呼び出し順が変わって、症状が回避できていることが分かります。

LD_PRELOADは PRE(事前に)LOAD(ロードする)という言葉通り、実行時に先にロードしておくライブラリを指定するための環境変数です。
ありがちな使い方としては、入出力のバッファリングの挙動を調整してコマンドを実行する stdbuf コマンドが内部で活用していたり ( 標準ライブラリに介入するための専用のライブラリをPRELOADする )、あるいは必要なライブラリがリンクされていないファイルに実行時にライブラリを付け足して実行できるようにしたり、といったところです。
※後者の例は、@yoh2 氏の、記事「-pthread を忘れると std::thread で例外が発生する仕組み」の「実は libpthread をリンクするかどうかだけの違い」の章にあります。

が、今回必要なライブラリは既にリンクされているので、これらの使い方には当てはまりません。
今回は、LD_PRELOADを指定することで真っ先に当該ライブラリがロードされるようになる ( 逆に初期化処理が他のライブラリより後に呼ばれるようになる ) という性質を利用した回避策になります。

根本的な対策

根本原因

これまでで、問題がリンク順序に起因する初期化処理実行順序であることを見てきました。
しかし、リンク順序が根本原因だとすると、ビルド時に常に順番を意識しなければいけなくなってきます。それは流石に何かおかしいと言えます。
そこで、ldd コマンドで改めてリンクの状況を見てみます。

lddの結果
$ ldd exe1-bd libtdep1.so
exe1-bd:
        linux-vdso.so.1 (0x00007ffff62ce000)
        libtbase.so => ./libtbase.so (0x00007f4bad450000)
        libtdep1.so => ./libtdep1.so (0x00007f4bad240000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f4bace40000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f4bada00000)
libtdep1.so:
        linux-vdso.so.1 (0x00007fffd6e36000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fcdcfb70000)
        /lib64/ld-linux-x86-64.so.2 (0x00007fcdd0200000)

まず、exe1-bd は、libtbase.so, libtdep1.so の両方に依存しているので、両方リンクしているのは自然なことです。
しかし、libtdep1.so を見ると、このライブラリ自体が libtbase.so に依存しているのに、リンクがありません。つまり、依存関係の解決は、実行ファイル本体の exe1-bd に頼っていることになります。

よく考えると ( 考えなくても ) これはおかしい話です。依存関係があるのにリンクをしていないという状況にあるわけですから。
で、実はこの点が根本原因となります。

対策

そこで、ちゃんとライブラリ自身に libtbase.so をリンクさせた libtdep2.so を作って動作を試してみます。
ビルドコマンドは次の通りです。
※明示的に make libtdep2.so しなくても、2番目の make exe2-bd だけでも両方芋づる式にビルドしてくれるのですが、一応分けて行ってます。

別版のビルド
$ make libtdep2.so
gcc -o libtdep2.so -shared libtdep.o -Wl,--no-as-needed -L. -Wl,-rpath=. -ltbase
$ make exe2-bd
gcc -o exe2-bd main.o -Wl,--no-as-needed -L. -Wl,-rpath=. -ltbase -ltdep2

lddでリンク状況を確認すると、libtdep2.so からも libbase.so がリンクされており、上で問題となったリンク順であるにも関わらず、正常に実行できることが分かります。

lddおよび実行確認
$ ldd exe2-bd libtdep2.so
exe2-bd:
        linux-vdso.so.1 (0x00007ffff18e7000)
        libtbase.so => ./libtbase.so (0x00007f5ea0a50000)
        libtdep2.so => ./libtdep2.so (0x00007f5ea0840000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f5ea0440000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f5ea1000000)
libtdep2.so:
        linux-vdso.so.1 (0x00007ffff519f000)
        libtbase.so => ./libtbase.so (0x00007ffa98f70000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007ffa98b70000)
        /lib64/ld-linux-x86-64.so.2 (0x00007ffa99400000)
$ ./exe2-bd
tbase: initializing...
tbase: initialized.
tdep: initializing...
tbase: used.
tdep: initialized.
tbase: used.
tdep: used.

こちらでも以下のように LD_DEBUG 環境変数を指定して見てみると、ライブラリのロード順は問題があった時と変わらないものの、初期化順が適切になるように調整されていることが分かります。これが、ライブラリ自身に依存性のあるライブラリをリンクした効果です。

$ LD_DEBUG=files ./exe2-bd
(略)
      2050:     file=libtbase.so [0];  needed by ./exe2-bd [0]
(略)
      2050:     file=libtdep2.so [0];  needed by ./exe2-bd [0]
(略)
      2050:     file=libc.so.6 [0];  needed by ./exe2-bd [0]
(略)
      2050:     calling init: /lib/x86_64-linux-gnu/libc.so.6
      2050:
      2050:
      2050:     calling init: ./libtbase.so
      2050:
tbase: initializing...
tbase: initialized.
      2050:
      2050:     calling init: ./libtdep2.so
      2050:
tdep: initializing...
tbase: used.
tdep: initialized.
(略)

おわりに

依存関係のあるところでしっかりリンクしていればそもそも発生しない問題ですが、登場するライブラリの数が増えると、今回のような問題が発生することもあるかもしれません。
そういった場合の対策として、本記事が役に立てば幸いです。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?