Jun 14

ふと思い立ち、gccの秘密(?)関数_Unwind_Backtrace()を使って実行途中プロセスのbacktraceを取ってみようとしたら、驚くほど動かず、延々と調査するはめに。結局わかったことは以下:

  • C++のコードの中から使った場合は(試した範囲内では)いつでも動く
  • Cのコードから使った場合、x86_64(amd64)やIA64では動くが、x86(32ビット)とかsparcでは動かない。また、これらはOSにも依存しない
  • Cの場合で、x86_64とx86の違いは、ELFの.eh_frame section内に実行コードのframeに関するcall frame information(CFI)が含まれているかどうか。含まれていれば動くが、そうでなければ動かない。
  • x86におけるC++とCの差もELFの.eh_frame sectionの中身の違い
  • .eh_frame sectionに必要な情報がなくても、.debug_frame sectionに実行コードのCFIが含まれていれば、それをちょっといじって__register_frame()(さらに秘密な関数?)で登録すれば動くようになる
  • IA64の場合は上記とは別な扱いになっている模様。CでもC++でも、そもそも.eh_frame sectionが存在しないが_Unwind_Backtrace()はちゃんと動く

Cで動かすためのテストコードも公開しているので、万一このページを読んだ方がいらっしゃいましたら、(とくにi386やsparc以外で)動いたとか動かなかったとかご報告いただければ幸いです。

以下はとっても長い詳細。

_Unwind_Backtrace()の不思議な挙動

たとえば、以下のようなテストコードを考える。

#ifdef __cplusplus
extern "C" {
#endif	/* __cplusplus */
#include <stdio.h>

extern int _Unwind_Backtrace(int (*fn)(void *, void *), void *);

static int count = 0;

static int
btcallback(void *context, void *opq) {
	count++;
	return (0);
}

static void
foo() {
	_Unwind_Backtrace(btcallback, 0);
	printf("callback counter: %d\n", count);
}

int main() {
	foo();
	return (0);
}
#ifdef __cplusplus
}
#endif	/* __cplusplus */

btcallback()foo()main()を含むcall frameの数だけ呼ばれるので、少なくとも2回は呼ばれるはずだが、x86なマシンでコンパイルして動かすとそうならない。

% uname -m; ./bt
i386
callback counter: 1

一方、同じコードをx86_64とかIA64とかの上で走らせると期待通りの結果になる。

% uname -m; ./bt
amd64
callback counter: 4

(なお、2でなくて4なのはmain()を呼んでいるlibc内のコードの分が含まれているため)

また、このコードをC++として、つまりg++でコンパイルするとi386でも期待通り動くようになる。

% g++ -o bt bt.c; uname -m; ./bt
i386
callback counter: 3

この不思議な現象の解明にかなり時間を要してしまったが、結局、実行バイナリ中にどれだけスタックフレームの情報が含まれているかの違いだということがわかった。以下ではおもにFreeBSD 6.2(i386) + gcc 3.4.6とFreeBSD7.0(amd64/x86_64) + gcc 4.2.1を例に考えるが、試した範囲ではLinuxやSolarisにも当てはまる(MacOS Xでもかなりの部分はあてはまる)。ハードウェアアーキテクチャ的にはSparc(32bit)にもあてはまる。また、FreeBSDの場合、実行ファイルの形式はELFなので、以下でもおもにELFフォーマットを仮定するが、話の本質は実行ファイルの形式にはよらない(実際たとえば、MacOS Xでも成り立つ部分は結構ある)。

gccのソースコードを参照している個所ではおもにgcc 3.4.6を用いるが、ざっと見た感じでは、gcc 4の比較的最近のバージョンも含めて大体どのバージョンのgccでも成り立つようだ。

_Unwind_Backtrace()の動作

多くのプラットフォームにおいて、_Unwind_Backtrace()DWARF2(もどき)のcall frame情報を頼りにbacktraceに必要な情報を生成している。その処理の中でも重要なのが、unwinde-dw2-fde.cの中で定義されている_Unwind_Find_FDE()という関数。

const fde *
_Unwind_Find_FDE (void *pc, struct dwarf_eh_bases *bases)

この関数は、実行プロセスのコード内の与えられた位置を指すポインタ(pc)を含むDWARF2(もどき)のFrame Description Entry(FDE)を探し、あればそれへのポインタを返す。各FDEはCでいえばそれぞれ一つの関数に対応していて、その関数内のどの位置でどんなレジスタがどのように変更されているかといった情報が記録されている。gccの_Unwind_Backtrace()は、まず呼び出された位置からの戻りアドレスをbuiltin関数の__builtin_return_address(0)で求め、それをpcとして_Unwind_Find_FDE()を呼んで一つ上のフレームに対応するFDEを求め、その情報を使ってレジスタを(仮想的に)復元してさらにその上の戻りアドレスを求め…という方法によってbacktraceに必要な呼び出しアドレスの列を生成している。

実行コードに対するFDEは、初期化時に.init sectionのframe_dummy()(crtstuff.cで定義される)経由で、unwinde-dw2-fde.c内の__register_frame_info()によってunwinde-dw2-fde.c localなstatic変数に登録される。

static void __attribute__((used))
frame_dummy (void)
{
...
  if (__register_frame_info)
    __register_frame_info (__EH_FRAME_BEGIN__, &object);
}

__EH_FRAME_BEGIN__の中身までは追いかけてないが、名前からしていかにもELFの.eh_frame sectionを指しているように見えるし、実際にそうであることもデバッガ経由で確認できる:

% gdb bt
GNU gdb 6.1.1 [FreeBSD]
...
(gdb) b __register_frame_info
Breakpoint 1 at 0x8049fa6
(gdb) r
Starting program: /usr/home/jinmei/tmp/bt 

Breakpoint 1, 0x08049fa6 in __register_frame_info ()
(gdb) c
Continuing.

Breakpoint 1, 0x08049fa6 in __register_frame_info ()
(gdb) p ((void **)$ebp)[2]
$1 = (void *) 0x804c318
(gdb) q
The program is running.  Exit anyway? (y or n) y

% readelf -S bt | grep .eh_frame
  [12] .eh_frame         PROGBITS        0804c318 003318 0003c8 00   A  0   0  4

つまり、__register_frame_info()への最初の引数は0x804c318で、これは実行ファイル内の.eh_frame sectionへのoffsetに等しい(なお、上の例が示すように、__register_frame_info()は複数回呼ばれる場合がある。frame_dummy()から呼ばれたときの値を調べる必要がある)。

_Unwind_Backtrace()はCではなぜ動かないか

というところまでわかると、.eh_frame section内の違いが_Unwind_Backtrace()の挙動の違いの原因だろうという予測が立つ。実際に調べてみるとその通りだということがわかる。

% readelf --debug-dump=frames bt
The section .eh_frame contains:
...
00000014 00000018 00000018 FDE cie=00000000 pc=08048968..0804898c
...
00000398 00000028 0000039c FDE cie=00000000 pc=0804ad04..0804ae58
...
The section .debug_frame contains:
...
0000002c 00000018 00000000 FDE cie=00000000 pc=080486bc..080486ec
...
00000048 00000014 00000000 FDE cie=00000000 pc=080486ec..08048714

各FDEの行において、pcがそのフレームに対応するコード(関数)のアドレスの範囲を指している。.eh_frame sectionには他にも多数のFDEが含まれているが、これらはpcに関してすでにソート(昇順)されている。これらがそれぞれ何を指しているか調べると、

% nm -n bt
...
080486ac t btcallback
080486bc t foo
080486ec T main
08048714 T _Unwind_GetTextRelBase
...
08048968 T _Unwind_FindEnclosingFunction
  (注: これが.eh_frame内の最初のFDE)
0804898c t execute_stack_op
...
0804a7cc t search_object
0804ad04 T _Unwind_Find_FDE
  (注: これが.eh_frame内の最後のFDE)

というわけで、本体のプログラムで定義された関数に対応するFDEは.eh_frame section内には存在しないことがわかる。

これに対し、C++としてコンパイルした結果のバイナリに含まれる.eh_frame sectionにはmain()foo()に対応するFDEも含まれている。

% g++ -g -o bt bt.c   

% readelf --debug-dump=frames bt|grep FDE
0000001c 00000018 00000020 FDE cie=00000000 pc=080485d0..08048600
00000038 00000018 0000003c FDE cie=00000000 pc=08048600..08048628
00000014 00000014 00000000 FDE cie=00000000 pc=080485c0..080485d0
(ここまで.eh_frame sectionのFDE)
0000002c 00000018 00000000 FDE cie=00000000 pc=080485d0..08048600
00000048 00000014 00000000 FDE cie=00000000 pc=08048600..08048628

% nm -n bt| egrep (main|foo)
080485d0 t foo  [.eh_frameの最初のFDE]
08048600 T main [.eh_frameの2番目のFDE]

また、amd64のCでコンパイルした結果のバイナリでも、.eh_frame sectionに必要なFDEが含まれている。

% cc -g -o bt bt.c

% readelf --debug-dump=frames bt|grep FDE
00000018 0000001c 0000001c FDE cie=00000000 pc=004004f0..00400583
00000038 00000014 0000003c FDE cie=00000000 pc=00400590..004005c8
00000050 00000014 00000054 FDE cie=00000000 pc=004005d0..004005f2
00000068 0000001c 0000006c FDE cie=00000000 pc=00400600..00400622
00000088 0000001c 0000008c FDE cie=00000000 pc=00400630..0040065a
000000a8 0000001c 000000ac FDE cie=00000000 pc=00400660..00400675
(ここまで.eh_frame sectionのFDE)
00000018 0000001c 00000000 FDE cie=00000000 pc=00400600..00400622
00000038 0000001c 00000000 FDE cie=00000000 pc=00400630..0040065a
00000058 0000001c 00000000 FDE cie=00000000 pc=00400660..00400675

% nm -n bt | egrep (main|foo)
0000000000400630 t foo  [.eh_frameの最後から2つめのFDE]
0000000000400660 T main [.eh_frameの最後のFDE]

_Unwind_Backtrace()をCで無理やり動かすためのハック

.eh_frameのeh = exception handlingで、これは本来はC++(等)の例外処理におけるスタック巻戻し操作のための情報のようなので、言語として例外の仕組みを持たないCの場合にこのsectionの中身が不十分なのは仕方ないともいえる(なぜamd64の場合にはCでもこれが生成されるのかは不明)。

しかし、さらによく調べると、(上の例でもわかるように)ほぼ同種かつ必要な情報が.debug_frameというsection(あれば)に含まれている。かつ、__register_frame_info()自体はどこからでも呼べるglobal関数で、実装を見ると実行途中にframe情報を足してもうまく動きそう。そこで、_Unwind_Backtrace()を使っているプログラム自身で自分のELFフォーマットを解析して、.debug_frame sectionの中身を指すポインタを__register_frame_info()で後から渡せばCのコードからでも正しくbacktraceが取れそうな気がしてくる。

で、実際にやってみると、上記の単純な方法ではうまくいかない。これは、gccのunwinde-dw2-fde.cが期待しているFDEのフォーマットがDWARF2での定義(.debug_frame sectionはこちらに準拠)と微妙にずれているため(わざとなのかバグなのかはわからないが…)。具体的には以下の部分が違っている。

  • CIE (Common Information Entry, ある一連のFDEのセットに共通するルールを指定する特別なエントリ。.eh_frameと.debug_frame sectionは、「CIE 1つ、それに続く0個以上のFDE」のセットから構成されている)を示すIDが、.eh_frame sectionでは0x00000000なのに対し、DWARF2仕様では0xffffffffである
  • FDEのCIE_pointer field(そのFDEが属するCIEの位置を示す)の値が、.eh_frame sectionではそのFDEの先頭からの(逆)offsetになっているのに対し、DWARF2仕様ではsectionの先頭からCIEまでのoffsetになっている
  • unwinde-dw2-fde.cは、CIE+FDEの列の最後に0x00000000という4バイトから成る終端子があることを期待している

ということで、.debug_frame sectionの中身に対して、上の差分を埋める修正をした上で__register_frame_info()に渡すとようやく動くようになる。

検証コード

以上で調べた内容を検証するためのテストコードを書いてみた。ソースコードはここに公開している。このプログラム(以下”unwindtest”と呼ぶ)は、main()foo()bar()のように呼び出した後、_Unwind_Backtrace()でこれらの呼び出し地点のアドレスを取得し、各アドレスが該当する関数の先頭アドレスから一定の範囲内(256バイト)に含まれているかどうかを調べることで、_Unwind_Backtrace()の動作を検証している(256バイトというのは「ほどほどに小さい」値ということで適当に選んだ)。

さらに-eオプションを付けると、実行ファイル名(argv[0])の中身をELFだと仮定して調べ、.debug_frame sectionがあればその中身を上記の方法によってgccが期待する形式に直した上で__register_frame_info()を使って登録し、その後に_Unwind_Backtrace()を実行する。(なお、ELFの中身を調べるコードではvalidity checkをかなりさぼっている。よい子は(そのまま)マネしないでください)

i386場のFreeBSDで動かすとこんな感じ:

% cc -g -o unwindtest unwindtest.c

% ./unwindtest 
[1]    6372 exit 1     ./unwindtest

% ./unwindtest -e
frame[0] 0x8048e72: entrance pt=0x8048e44, diff=46
frame[1] 0x8048f23: entrance pt=0x8048f18, diff=11
frame[2] 0x8048f5a: entrance pt=0x8048f28, diff=50

% nm unwindtest | egrep (main|foo|bar)
08048e44 t bar
08048f18 t foo
08048f28 T main

i386なので、何も指定せずに実行すると失敗する。一方、-eを付けると正しいbacktraceが取れていることがわかる。

手元で使える環境で試したところでは、以下のような環境で_Unwind_Backtrace()が動作するようになったことが確認できた:

  • FreeBSD (amd64, i386, ia64)
  • Linux (x86_64, i386)
  • Solaris 10 (x86_64-ただしgccは32bit版, sparc)

なお、x86_64/amd64とia64の場合は-eオプションなしでも動く。(というか、ia64ではこのオプションを指定すると .debug_frame sectionがないために動作しない)

Mac OS Xの場合、実行ファイル形式がELFでないので動かない…というか、必要なヘッダファイルがないのでコンパイルも通らない。ただし、_Unwind_Backtrace()の実装自体は他のOSと同じものを使っているようなので、実行バイナリを解析してDWARF2のframe情報を生成し、__register_frame_info()で登録するというところまでやれば動きそうな気はする。

なお、コンパイラのoptimizationを有効にすると、関数呼び出しがinline化されて、検証コードが期待しているチェックを通らなくなる場合があるので注意:

% cc -g -O2 -o unwindtest unwindtest.c

% ./unwindtest -e
frame[0] 0x8048c72: entrance pt=0x8048a20, diff=594
frame[1] 0x80487aa: entrance pt=0x8048acc, diff=-802
[1]    6415 exit 2     ./unwindtest -e

(ただしこれは_Unwind_Backtrace()自体が失敗しているということを意味しているわけではない)

また、上の例では-g付きでコンパイルしているが、これも重要で、-gがないと.debug_frame sectionが生成されずにテストに失敗したり、生成されていてもOSによってはなぜかうまく動かなかったりすることがあった(後者の理由は調べてない)

% cc -o unwindtest unwindtest.c 

% ./unwindtest -e
.debug_frame section not found
[1]    6399 exit 1     ./unwindtest -e

% readelf -S unwindtest | grep frame
  [13] .eh_frame         PROGBITS        0804c018 004018 0003c8 00   A  0   0  4

同様に、strip(1)でダイエットさせると.debug_frame sectionも消えてしまって動かなくなることが多いようだ。

% cc -g -o unwindtest unwindtest.c   

% strip unwindtest

% ./unwindtest -e
.debug_frame section not found
[1]    6424 exit 1     ./unwindtest -e

IA64の場合

上で述べたように、IA64の場合はCでも特別なハックなしに_Unwind_Backtrace()が動くのだが、これはコードベース自体が違うからのようだ。

実際、unwind-ia64.cとかいう名前の怪しげなソースがあるし、DWARF2(もどき)ベースの他のアーキテクチャの場合と違って(少なくともFreeBSD 7では)実行バイナリを見てもそもそも .eh_frame section すら存在しない:

% readelf -S unwindtest | grep frame | wc -l
       0

し、__register_frame_info()みたいなグローバル関数も定義されていないようだ。

これ以上は究明してないけど、いまのところそんなに頻繁に使う必要のあるアーキテクチャじゃないのでこれでいいことにしよう…

おまけ: IA64の関数ポインタ

本題とは関係ないおまけ。上記の検証コードを書いていろいろ試していたら、IA64の場合だけなぜかoffsetが極端にずれてしまう(ように見える)という現象に遭遇した:

frame[0] 0x2000000000002510: entrance pt=0x2000000000002c50, diff=-1856
frame[1] 0x2000000000002890: entrance pt=0x2000000000002c60, diff=-976
frame[2] 0x2000000000002940: entrance pt=0x2000000000002c20, diff=-736
[1]    13159 exit 2     ./unwindtest1

かなり悩んだ挙げ句、実はIA64では関数ポインタ自体はその関数のコードへのエントリポイントを直接指しているわけではないということが判明した。インテルの「IA-64ソフトウェア規則およびランタイム・アーキテクチャ・ガイド」51ページより:

関数ポインタ: 関数ポインタは、常にターゲット・プロシージャの関数記述子のアドレスである。…関数記述子には、少なくとも2つの64ビット・ダブルワードが含まれている。 第1のダブルワードは入口点のアドレスであり、第2のダブルワードはターゲット・プロシージャのgp値である。

検証コードは、基本的に_Unwind_Backtrace()経由で得られたコード内のアドレスと、それを含んでいるはずの関数ポインタの値の差分を比較しているため、上記のような場合には当然うまく動かない。つまり、foo()という関数があったとき、(void *)fooではなく、((void **)foo)[0]を使う必要がある。

知ってる人には当たり前の話なんだろうけど、はじめて体験した人間にはかなり新鮮だったので一応メモ。

コメント 4 件

  1. ono Says:

    動作報告です。

    powerpc(32bit)でためしてみたところ、-e付きだと動作しているようにみえます。
    % ./unwindtest
    % ./unwindtest -e
    frame[0] 0x10001078: entrance pt=0x10001038, diff=64
    frame[1] 0x10001204: entrance pt=0x100011ec, diff=24
    frame[2] 0x10001250: entrance pt=0x10001224, diff=44

    環境:
    PowerPC 405EX
    Linux 2.6.29
    gcc 4.2.3

  2. shinh Says:

    > なぜamd64の場合にはCでもこれが生成されるのかは不明

    amd64 はデフォルトで -fomit-frame-pointer がかかってる感じなので(正確にはABIでフレームポインタ無くていいと書いてあると思います)、デバッガでスタックトレース出すために .eh_frame を必ず作る必要がある、という理解です。

  3. jinmei Says:

    onoさん: ご確認ありがとうございます。

    shinhさん:

    > amd64 はデフォルトで -fomit-frame-pointer がかかってる感じなので(正確にはABIでフレームポインタ無くていいと書いてあると思います)、

    amd64/x86_64の場合、-fomit-frame-pointerがより頻繁に使われているということは確かに関係あるかもしれません(ちなみに僕が確認できる範囲でいえば、コンパイラの最適化オプション(-Oxなど)を指定しなければamd64でもフレームポインタありのコードが生成されてました)。

    > デバッガでスタックトレース出すために .eh_frame を必ず作る必要がある、という理解です。

    これが理由だとすると、 .debug_frame を使うのでもよさそうですが…。.debug_frameの場合、stripすると消えてしまうという問題はありますが、stripしたバイナリを使ってデバッガで追いかけるのはもともとかなり厳しいですし(優秀なバイナリアンなら問題ないのかとは思いますが:-)。まあ、gccを作った人が、そういう場合でもせめて各frameのアドレスくらいは確実に表示できるようにしようと思って.eh_frameに置くことにしたのだとしたら、そういうこともあるかなとは思います。

  4. _Unwind_Backtrace()と-fomit-frame-pointer Says:

    […] 一つ前のエントリの続き。gccの_Unwind_Backtrace()には、フレームポインタなしでコンパイルされたコードに対しても動くという特長がある。はじめは、最初のフレームのときだけbuiltin関数 […]

コメントを投稿 / Submit Comments


Warning: Undefined variable $user_ID in /usr/home/jinmei/src/itheme/comments.php on line 74



(あれば / Optional):