🫠

dwarf CFIを読む (aarch64編)

2023/03/16に公開

はじめに

本文章はdwarfデバッグ情報の1つであるCFI(Control Frow Information)についての簡単なまとめです。CFIとはあるアドレスの地点で適切にスタックをunwindするために使用できるデバッグ情報のことで、つまりデバッガー等からバックトレースを出力するために使用できます。以下ではCFIの簡単な説明と、eu-readelf/dwarfdumpコマンドを利用してCFI情報を取得する方法を説明します。

なおCFIというとgcc/clangのControl Flow Integrityの方が有名かと思いますが、それとここで説明している内容は全く関係がありません。

また基本的にどのアーキテクチャでも同じですがここではaarch64の場合を見ていきます。

一次情報

以下を参照してください:

前提知識

dwarfについて

dwarfはelfバイナリで使用されるデバッグ情報の形式です。例えばプログラム中のあるアドレスと元のソースコードの場所を紐づける情報もdwarfのデータから分かります。Cプログラムの場合はgcc/clangで-gオプションをつけてコンパイルすると実行ファイルにdwarfのデバッグ情報がついてきます。

dwarf全体の概要を説明すると長くなるのでここでは省略します。
気になる方はdwarfstdのサイトにある入門pdfが参考になると思います:
https://dwarfstd.org/doc/Debugging using DWARF-2012.pdf

aarch64の命令について

aarch64の命令セット(A64)についても詳しくは説明はしません。ひとまず以下の情報を知っていれば問題ありません:

  • 命令長は4バイト
  • 汎用レジスタは8バイト(64bit)
    • 31個あり、 x0, x1, ... x30で表す
    • このうちx29をframe pointer, x30をlink register(関数の戻り先のアドレス)として使う
  • スタックポインタ(sp)は汎用レジスタとは別にあり、16バイトでアラインされる
    • このためレジスタの値をスタックとやりとりをするときは2つのレジスタをペアにして扱う命令(stp/ldpなど)がよく使われる

CFIとは

dwarfで与えられるデバッグ情報の1つがCFI(Call Frame Information)です。
概念的にはCFIは以下のような関数ごとのテーブルと考えることができます ([1] 6.4.1):

Address  CFA  R0  R1  R2  ...
 0x100   xxx  xxx xxx xxx
 0x104   xxx  xxx xxx xxx
 0x120   xxx  xxx xxx xxx
...

ここでAddressはプログラムのアドレスです。CFAはCanonical Frame Addressと呼ばれるものであり、ISAにより決まるものですが通常はその関数が呼ばれた時のスタックポインタの値になります。aarch64の場合もCFAはこの定義に従います([2])。またR0, R1...は汎用レジスタとなります。

表の中でxxxはCFAやそのレジスタの値がどこにあるのかを表します。例えばプログラムの途中では
レジスタの値がスタック中に待避されているかもしれません。この場合xxxにはそのレジスタの値がある(CFAなどを基準とした際の)スタックのオフセットなどが格納されます。

つまりこの表を参照することであるアドレスにおいてCFAと各レジスタの情報がどこにあるかを判断することができます。これらの情報があれば更に1つ前の(呼び出し元の)アドレスやそのスタックフレームでのCFAを計算することができ、これを繰り返していくことである地点からのバックトレースを導く事が出来ます。これがCFIがスタックトレースのunwindに使用できる理由です。なお着目しているアドレスそのもののエントリがない場合は直前のエントリの内容と同じになります(この例では0x108の場合は0x104と同じになります)。

少しだけ具体的な例を考えてみます。aarch64の場合、典型的には関数の呼び出しにおいて以下のような命令列が見られます:

<foo>
  0  :   stp     x29, x30, [sp, #-32]! # [sp-32] <= x29, [sp-24] <= x30, sp <= sp-32
  4  :   mov     x29, sp               # x29 <= sp
...
  100:   ldp     x29, x30, [sp], #32   # x29 <= [sp], x30 <= [sp+8], sp <= sp+32
  104:   ret

まず関数の冒頭で新しいスタックフレームの領域を作成します。このためにアドレス0においてstp命令を使用してx29, x30のレジスタをスタックに格納し、さらにスタックポインタの値を更新します(!がつくとpost-indexになり、命令実行後にスタックポインタspの値が更新されます)。続いてアドレス4においてframe pointerであるx29を新しいスタックポインタの値に更新します。

この逆に関数の終了時にはスタックに積んだ値を元のレジスタに戻し、spの値も戻します(アドレス100)。

この例ではCFIとしては以下のような表の情報が得られることになります:

Address  CFA     r29     r30
  0:     sp(0)
  4:     sp(32)  CFA-32  CFA-24
...
104:     sp(0)       

(dwarf仕様書では汎用レジスタを表すprefixにrを使用しているのでここではr29/r30としています
また他の汎用レジスタは変化はないものとしてここでは省略しています)

まずアドレス0の時点(命令が実行される前)ではCFA(=関数が呼ばれたときのスタックポインタの値)は変化していませんのでCFAはsp(0)とします(spレジスタの値+0という意味です)。

次にアドレス4の時点ではstp命令が実行された結果、スタックの値が-32されました。よってCFAとしてはsp(32)となります(繰り返しになりますがCFAはこの関数が呼ばれた時点でのspの値のため、この時点ではsp+32になります)。x29とx30はそれぞれCFAを基準として-32,-24のオフセットの位置のスタックに格納されているので、その値を表す情報が入ります(CFA-32, CFA-24)。

最後に100行目のコードが実行されるとsp, x29, x30共に最初の値に戻ります。表のエントリとしてもアドレス0と同じになります。

実際のCFIのフォーマット

CFIは概念的には以上のような表の情報になるのですが、実際のオブジェクトファイルにおいてはそのまま表として格納されてはいません。簡単に言うと表の各行について、1つ上の行からの差分を計算するために必要な情報(Call Frame Instructionsと呼んでいます)を保持しています。もう少し具体的に説明するとinstructionの情報は初期値を表すCIE(Common Information Entry)と各関数内でのinstructionの情報を表すFDE(Frame Description Entry)から成り立ちます。

各FDEには必ず対応するCIEが存在し、1つのCIEが複数のFDEで共有される可能性があります。

このinstructionについての説明が書かれているのが[1]の6.4.2になります。しかし仕様よりも例を見た方が理解がはやいので次の節でシンプルなコードでの例を説明します。なお仕様書にも例がのっていますのでそちらも参考になります(Appendix D.6)。

簡単な例

簡単なCプログラムをコンパイルして実際のCFIを見てみます。

ここではコンパイラとしてはgccを利用しています(恐らくclangでも変わらないと思います)。またelf/dwarfの情報を取得するために以下のツール/コマンドを使用します

  • objdump
  • elfutils (eu-readelf他)
  • libdwarf-tools (dwardump)

※ readlefコマンドなどはbinutilsにも入っていますが、elfutilsパッケージに入っている
コマンド(eu-が先頭に付く)の方がhuman readableに思いますのでこちらを使用しています。
なおbinutils/elfutilsのコマンドは概ね同じように動きますが一部オプションが異なりますので
適宜ヘルプを参照してください。

CFIのセクションについて

初めにCFIが格納れるelfセクションについて説明します。

gccでは-gオプションをつけるとdwarfのデバッグ情報が複数のelfセクションに格納されます。
格納されるセクション名は仕様書に記載されている通りで.debugから始まります。

例えば 適当なCファイルを-gをつけてコンパイルしてeu-readelfコマンドで生成されたオブジェクトファイルのELFセクションを確認すると以下のようになります:

$ gcc -O0 -g test.c -o test
$ eu-readelf --sections test | grep debug # eu-readelf --sectionsでオブジェクトファイル中のセクション一覧を取得
[26] .debug_aranges       PROGBITS     0000000000000000 00004d20 00000030  0        0   0  1
[27] .debug_info          PROGBITS     0000000000000000 00004d50 000000aa  0        0   0  1
[28] .debug_abbrev        PROGBITS     0000000000000000 00004dfa 0000007f  0        0   0  1
[29] .debug_line          PROGBITS     0000000000000000 00004e79 00000074  0        0   0  1
[30] .debug_str           PROGBITS     0000000000000000 00004eed 00000054  1 MS     0   0  1
[31] .debug_line_str      PROGBITS     0000000000000000 00004f41 0000001c  1 MS     0   0  1

さてCFIについて仕様書を確認してみると.debug_frameというセクションが対応すると書かれていますが、上記の中にはありません。実はデフォルトではCFIの情報は.eh_frameに入ります:

$ eu-readelf --sections test | grep eh_frame
[15] .eh_frame_hdr        PROGBITS     0000000000402010 00002010 0000002c  0 A      0   0  4
[16] .eh_frame            PROGBITS     0000000000402040 00002040 00000084  0 A      0   0  8

ここでは深追いしませんがどうやら歴史的な経緯で.debug_frameの情報は.eh_frameに格納されることになっているようです。

なお-fno-asynchronous-unwind-tablesオプションを付けることで.debug_frameセクションを生成することもできます。

$ gcc -O0 -g -fno-asynchronous-unwind-tables test.c -o test
$ eu-readelf --sections test | grep debug
[26] .debug_aranges       PROGBITS     0000000000000000 00004d20 00000030  0        0   0  1
[27] .debug_info          PROGBITS     0000000000000000 00004d50 000000aa  0        0   0  1
[28] .debug_abbrev        PROGBITS     0000000000000000 00004dfa 0000007f  0        0   0  1
[29] .debug_line          PROGBITS     0000000000000000 00004e79 00000074  0        0   0  1
[30] .debug_frame         PROGBITS     0000000000000000 00004ef0 00000068  0        0   0  8
[31] .debug_str           PROGBITS     0000000000000000 00004f58 00000074  1 MS     0   0  1
[32] .debug_line_str      PROGBITS     0000000000000000 00004fcc 0000001c  1 MS     0   0  1

elfutilsなどのツールでは.eh_frameと.debug_frameの両方に対応していますので、どちらかのセクションにCFIの情報があれば問題なくコマンドを使用できます。以下ではデフォルトの.eh_frameを使用しています。

テストプログラム

以下のプログラムを利用します:

$ cat test.c
int foo(int y)
{
        return y+1;
}

int main()
{
        int x = foo(1);
        return x;
}

最適化が入るとわかりにくいので-O0を付けてコンパイルします。

main関数のアセンブラを確認すると以下のようになります:

$ gcc -O0 -g test.c -o test
$ objdump --disassemble=main test
0000000000400670 <main>:
  400670:       a9be7bfd        stp     x29, x30, [sp, #-32]!
  400674:       910003fd        mov     x29, sp
  400678:       52800020        mov     w0, #0x1                        // #1
  40067c:       97fffff7        bl      400658 <foo>
  400680:       b9001fe0        str     w0, [sp, #28]
  400684:       b9401fe0        ldr     w0, [sp, #28]
  400688:       a8c27bfd        ldp     x29, x30, [sp], #32
  40068c:       d65f03c0        ret

最初に新しいフレームを作った後(400670,400674)、bl命令でfoo関数を呼び出し(40067c)、最後にフレームポインタをもとに戻しているだけです(40068c)。

eu-readlefでCFI情報を見る

eu-readelfでは--debug-dumpオプションでdwarfのデバッグセクションを見ることができます。特に.debug_frame(.eh_frame)をダンプするためには--debug-dump=frameを利用します。

上のプログラムでの結果の抜粋は以下のようになります:

$ eu-readelf --debug-dump=frame test
...
Call frame information section [17] '.eh_frame' at offset 0x708:

 [     0] CIE length=16
   CIE_id:                   0
   version:                  1
   augmentation:             "zR"
   code_alignment_factor:    4
   data_alignment_factor:    -8
   return_address_register:  30
   Augmentation data:        0x1b (FDE address encoding: sdata4 pcrel)

   Program:
     def_cfa r31 (sp) at offset 0
...

 [    c0] FDE length=28 cie=[     0]   # 対応するCIEの情報
   CIE_pointer:              196
   initial_location:         0x0000000000400670 <main> (offset: 0x670)
   address_range:            0x20 (end offset: 0x690)

   Program:
     advance_loc 1 to 0x674
     def_cfa_offset 32
     offset r29 (x29) at cfa-32
     offset r30 (x30) at cfa-24
     advance_loc 6 to 0x68c
     restore r30 (x30)
     restore r29 (x29)
     def_cfa_offset 0
     nop
     nop
     nop

前に書いた通りCFIの中には初期値を表すCIEと各関数の情報を表すFDEの2種類があります。

先にmainに対応するFDEの方を探すと(initial_locationの中に"main"の文字列があるのでわかります)、対応するCIEのオフセットが[0]であることがわかります。

CIEを見てみるといくつか情報がありますが(各エントリの定義は仕様書にあります)、CFIの初期値は"Program"に記載されているinstructionsを実行した結果になります。同様にFDEのエントリの中の"Program"の内容がmain関数の中でどのようにCFIの(概念的な表の)エントリが変化するかを表します。なおFDEの"Program"の最後にnopが入っているのはdwarf情報のアライメントのためです

CIEとFDEから"Program"のところだけ抜き出して分りやすいように区切りをつけると以下のようになります:

1. def_cfa r31 (sp) at offset 0

2. advance_loc 1 to 0x674
   def_cfa_offset 32
   offset r29 (x29) at cfa-32
   offset r30 (x30) at cfa-24

3. advance_loc 6 to 0x68c
   restore r30 (x30)
   restore r29 (x29)
   def_cfa_offset 0

ここで"advance_loc"というのが表に新しいエントリを追加する命令と見なすことができ、それに続く命令がそのエントリの中でCFAや各レジスタの値がどう計算されるのかを表します。繰り返しですが命令の詳細は仕様書を見てください。

上の情報をもとに自分で表を作ってみると以下のような形になります:

       CFA    r29     r30
0x670: sp(0)                  # 1.より初期値はspのオフセット0 (要するにCFA == 関数呼出時のスタックポインタの値)
0x674: sp(32) cfa-32  cfa-24  # 2.より、advance_loc 1なのでaddressを+4(命令長4byteなので)する
                              #        def_cfa_offset 32よりCFAのオフセットを+32する
                              #        続く命令よりr29/r30の情報をcfa-32/cfa-24にする
0x68c: sp(0)                  # 3.より、advance_loc 6なのでaddressを+24する。
                              #        他の命令を実行すると結果として0x670と同じになる

ここまでの情報が構築できると、例えばデバッガで0x680の地点でブレークした場合、CFAの情報はsp(32)にある、ということが分かるようになります。

dwarfdumpでCFI情報を見る

さてeu-readlef(readlefも同様)ではどのようなinstructionがあるのかが分かりますが、それらの計算まではやってくれないようです。一方でdwarfdumpコマンドを使うとinstructionを計算して表のような形にしてくれます。

同じプログラムで試してみると以下のようになります:

$ dwarfdump -F test # .debug_frameのときは-Fではなく-fを指定する

.eh_frame

fde:
...
<    7><0x00400670:0x00400690><main><cie offset 0x000000c4::cie index     0><fde offset 0x000000c0 length: 0x0000001c>
       <eh aug data len 0x0>
        0x00400670: <off cfa=00(r31) >
        0x00400674: <off cfa=32(r31) > <off r29=-32(cfa) > <off r30=-24(cfa) >
        0x0040068c: <off cfa=00(r31) >

eu-readelfの情報をもとに自分で計算した内容と同じ内容が得られていることがわかります(r31はdwarf仕様でスタックポインタを表します)。

おわりに

CFIの簡単な説明とeu-readelf/dwarfdumpでCFI情報をダンプする方法を説明しました。上の例はあまりにも簡単な例でしたが、もっと複雑な例でCFIの情報とバイナリの命令列を比較してみると理解が進むかと思います。

補足: 上の例では関数呼び出し時に新しくフレームを作成してx29/x30の情報をスタックに格納しているため、スタックのunwindという点について言えばCFIの代わりにこの格納された情報を利用することができます。ただしコンパイラの最適化によってはフレームを作る処理が省略されることがあり、正しくunwindできない可能性があります。-fno-omit-frame-pointerコンパイルオプションを使用すると必ずフレームが作られるようになりますが命令コードサイズが増加します(これにより命令キャッシュヒット率が低下し性能に影響がある可能性があります)。一方でCFIは最適化されていても使用できますが、dwarf情報が必要な上にここでの説明の通りやや複雑な仕組みのためunwindに時間がかかるという欠点があります。また関連する他の問題もあり、総合的に見てunwindのためにどちらの方式を使うのかは選択の余地があります。これについてはfedora wikiの以下の説明も参考になります(fedora38では提供されるパッケージのデフォルトが-fno-omit-frame-pointerでコンパイルされるように変更になります): https://fedoraproject.org/wiki/Changes/fno-omit-frame-pointer

以上

Discussion