2006年2月19日

Linux の共有ライブラリを作るとき PIC でコンパイルするのはなぜか

通常、Linux の共有ライブラリを作るときは各 .c ファイルを PIC (Position Independent Code) となるようコンパイルします。しかし、実は PIC でコンパイルしなくても共有ライブラリは作れます。それでは PIC にする意味はあるのでしょうか。

 

さっそく実験してみます。

int
func ()
{
    printf("");
    printf("");
    printf("");
}

PIC でコンパイルするには gcc に -fpic または -fPIC を渡します。-fpic の方が小さく高速なコードを生成する可能性がありますが、プロセッサによっては -fpic で生成できる GOT (Global Offset Table) のサイズに制限があります。一方、-fPIC はどのプロセッサでも安心して使えます。ここでは -fPIC を用います (x86 では -fpic も -fPIC も同じです)。

% gcc -o fpic-no-pic.s -S fpic.c
% gcc -fPIC -o fpic-pic.s -S fpic.c

上のように生成したアセンブラのソースを見ると、PIC 版は printf を PLT (Procedure Linkage Table) 経由で呼んでいることがわかります。

% grep printf fpic-no-pic.s
        call    printf
        call    printf
        call    printf
% grep printf fpic-pic.s
        call    printf@PLT
        call    printf@PLT
        call    printf@PLT

次に、共有ライブラリを作ります。

% gcc -shared -o fpic-no-pic.so fpic.c
% gcc -shared -fPIC -o fpic-pic.so fpic.c

これらの共有ライブラリの動的セクション (dynamic section) を readelf で見ると、非PIC 版には TEXTREL というエントリがあり (テキスト内の再配置が必要)、さらに RELCOUNT (再配置の数) が 5 と、PIC版より 3つ多くなっています。3つ多いのは printf() の呼び出しを 3回行っているためです。

% readelf -d fpic-no-pic.so|egrep 'TEXTREL|RELCOUNT'
 0x00000016 (TEXTREL)                    0x0
 0x6ffffffa (RELCOUNT)                   5

% readelf -d fpic-pic.so|egrep 'TEXTREL|RELCOUNT'
 0x6ffffffa (RELCOUNT)                   2

非PIC版の RELCOUNT が 0 でないのは gcc がデフォルトで使うスタートアップファイルに含まれるコードが原因です。gcc に -nostartfiles オプションを渡すと 0 になります。

PIC と非PIC の共有ライブラリの性能比較

上の例では非PIC版は実行時 (動的リンク時) に 5 つのアドレスの再配置が必要と書きました。では、再配置の数が膨大に増えたらどうなるでしょうか。

次のシェルスクリプトを実行すると、printf() の呼び出しを 1000万回含む共有ライブラリを非PIC 版と PIC版で作り、それらをリンクした実行ファイル fpic-no-pic と fpic-pic を作ります。

#! /bin/sh
rm -f *.o *.so
num=1000
for i in `seq $num`; do
  echo "void func$i() {" > fpic$i.c
  ruby -e "10000.times { puts 'printf(\"\");' }" >> fpic$i.c
  echo "}" >> fpic$i.c
  gcc -o fpic-no-pic$i.o -c fpic$i.c
  gcc -fPIC -o fpic-pic$i.o -c fpic$i.c
done

gcc -o fpic-no-pic.so -shared fpic-no-pic*.o
gcc -o fpic-pic.so -shared fpic-pic*.o

echo "int main() { return 0; }" > fpic-main.c

gcc -o fpic-no-pic fpic-main.c ./fpic-no-pic.so
gcc -o fpic-pic fpic-main.c ./fpic-pic.so

実行結果は以下の通りです。非PIC版が初回2.15秒、2回目以降約0.55秒かかっているのに対し、PIC版は初回 0.02秒、2回目以降は0.00秒となっています。

% repeat 3 time ./fpic-no-pic
2.15s total : 0.29s user 0.48s system 35% cpu
0.56s total : 0.25s user 0.31s system 99% cpu
0.55s total : 0.30s user 0.25s system 99% cpu

% repeat 3 time ./fpic-pic
0.02s total : 0.00s user 0.00s system 0% cpu
0.00s total : 0.00s user 0.01s system 317% cpu
0.00s total : 0.00s user 0.00s system 0% cpu

main() の中身は空ですから、非PIC版は動的リンク時の再配置に 2.15~0.55秒を要していることがわかります。実行環境は Xeon 2.8 GHz + Debian GNU/Linux sarge + GCC 3.3.5 です。

非PIC版のデメリットは実行時の再配置に時間がかかるだけではありません。再配置が必要な部分のコードを書き換えるために、「テキストセグメント内の再配置が必要なページをロード→書き換え→copy on write発生→他のプロセスとテキストが共有できない」という事態が発生します。つまりこれではテキスト (プログラムのコード) を他のプロセスと共有するという「共有」ライブラリの主要なメリットが消失してしまいます。

ところで、非PIC版の fpic-no-pic.so と PIC版の pic.so のサイズを比べると前者は 268 MB、後者は 134MB と大きく異なりました。readelf -S でセクションヘッダを見ると、次のような違いがありました。

.rel.dyn.text
非PIC152MB114MB
PIC0MB133MB

非PIC版はコード (.text) は PIC 版より小さくなっていますが、再配置に必要な情報 (.rel.dyn) が膨大な容量を占めています。

まとめ

共有ライブラリを作成する際になぜ PIC でコンパイルする必要があるのか調べてみました。非PICの共有ライブラリを作成することは可能ですが、実行時の再配置に時間がかかり、さらに他のプロセスとテキストが共有できないという大きなデメリットがあります。共有ライブラリを作成する際には .c ファイルを PIC でコンパイルするようにしましょう。

とはいうものの PIC にすると外部に export された関数の呼び出しがライブラリ内でも PLT を経由するために、それらの関数呼び出しが遅くなるという欠点もあります (x86 の場合 GOT ポインタのために ebx が占有されるので特に)。関数呼び出しのコストが重要な場合は敢えて非PICにするという選択肢も考えられます。この辺のトレードオフについては Linkers & Loaders の8章で取り上げられています。

謝辞

本記事について、 ukaiさんと yaegashiさんから助言をいただきました。ありがとうございます。