🔬

Cコンパイラの出力を読む——gcc -S で覗くアセンブリの世界

に公開

はじめに

前回の記事では、x86_64・AArch64・z/Architecture の3アーキテクチャを比較しました。今回は「自分で書いたCのコードがどんなアセンブリになるのか」を gcc -S で確認していきます。

自分でアセンブリを書くより先に、コンパイラが生成するアセンブリを読む方が実践的だと感じたので、そこから始めてみます。

gcc -S の基本

gcc -S はコンパイルの途中でアセンブリ出力を止めるオプションです。

gcc -S -O0 add.c -o add.s    # 最適化なし
gcc -S -O2 add.c -o add.s    # 最適化あり

-O0 はCのコードがほぼそのままアセンブリに反映されるので、読みやすいです。-O2 にすると最適化が入り、元のCの構造が見えにくくなりますが、コンパイラが何をしているかがわかります。

3アーキテクチャで出力を見る

前回と同様、Docker で3つとも試せます。

# AArch64: Mac上でそのまま
gcc -S -O0 add.c -o add_arm.s

# x86_64: Docker内で
docker run --rm -v $(pwd):/work -w /work --platform linux/amd64 gcc:14 \
  gcc -S -O0 add.c -o add_x86.s

# s390x: Docker内で
docker run --rm -v $(pwd):/work -w /work --platform linux/s390x gcc:14 \
  gcc -S -O0 add.c -o add_s390.s

あるいは Compiler Explorer をブラウザで開けば、3つ同時に見られます。

実例1:単純な関数

int add(int a, int b) {
    return a + b;
}

x86_64 (-O0)

add:
    push    rbp
    mov     rbp, rsp
    mov     DWORD PTR [rbp-4], edi    # 第1引数をスタックに保存
    mov     DWORD PTR [rbp-8], esi    # 第2引数をスタックに保存
    mov     edx, DWORD PTR [rbp-4]    # スタックからロード
    mov     eax, DWORD PTR [rbp-8]    # スタックからロード
    add     eax, edx                   # 加算
    pop     rbp
    ret

-O0 ではせっかくレジスタに入っている引数(edi, esi)を一度スタックに保存し、またロードし直しています。非効率ですが、デバッガでスタック上の変数を見られるようにするためです。

x86_64 (-O2)

add:
    lea     eax, [rdi+rsi]    # eax = edi + esi を1命令で
    ret

2命令になりました。lea(Load Effective Address)はアドレス計算命令ですが、ここでは加算の手段として使われています。add ではなく lea を使う理由は、結果を第3のレジスタに入れられるからです(add は2オペランドなので片方が上書きされる)。

AArch64 (-O0)

add:
    sub     sp, sp, #16
    str     w0, [sp, #12]        // 第1引数をスタックに保存
    str     w1, [sp, #8]         // 第2引数をスタックに保存
    ldr     w8, [sp, #12]        // スタックからロード
    ldr     w9, [sp, #8]         // スタックからロード
    add     w0, w8, w9           // 加算
    add     sp, sp, #16
    ret

x86_64 と同じパターン——レジスタの引数をスタック経由で回しています。

AArch64 (-O2)

add:
    add     w0, w0, w1
    ret

2命令。3オペランド形式なので、lea のようなトリックは不要です。

s390x (-O0)

add:
    stmg    %r11,%r15,88(%r15)     # レジスタ退避
    lay     %r15,-168(%r15)        # スタックフレーム確保
    lgr     %r11,%r15
    st      %r2,164(%r11)          # 第1引数をスタックに保存
    st      %r3,168(%r11)          # 第2引数をスタックに保存
    l       %r1,164(%r11)          # スタックからロード
    a       %r1,168(%r11)          # 加算(メモリオペランド)
    lgfr    %r2,%r1                # 結果を戻り値レジスタへ
    lg      %r11,0(%r11)
    lmg     %r11,%r15,88(%r15)     # レジスタ復帰
    br      %r14                    # リターン

z/Architecture では a %r1,168(%r11) のように加算命令が直接メモリを読めるのが見えます。また stmg/lmg(Store/Load Multiple)で複数レジスタをまとめて退避・復帰しているのが特徴的です。

s390x (-O2)

add:
    ar      %r2,%r3
    lgfr    %r2,%r2
    br      %r14

3命令。ar(Add Register)でレジスタ同士の加算、lgfr で32bit→64bitの符号拡張、そしてリターン。

実例2:条件分岐

int abs_val(int x) {
    if (x < 0)
        return -x;
    return x;
}

x86_64 (-O2)

abs_val:
    mov     eax, edi
    neg     eax            # eax = -eax
    cmovs   eax, edi       # 符号フラグが立っていたら(negの結果が負なら)ediに戻す
    ret

分岐命令がありません。cmovs(Conditional Move)で分岐を回避しています。分岐予測ミスのペナルティがないため、入力がランダムな場合に有利です。

AArch64 (-O2)

abs_val:
    cmp     w0, #0
    cneg    w0, w0, mi     // w0 が負なら符号反転
    ret

こちらも分岐なし。cneg(Conditional Negate)で条件付き符号反転を1命令で実行します。

s390x (-O2)

abs_val:
    lpr     %r2,%r2        # Load Positive — 絶対値を1命令で
    lgfr    %r2,%r2
    br      %r14

lpr(Load Positive Register)という絶対値専用の命令があります。メインフレームらしい実務的な命令セットです。

実例3:ループ

int sum(int *arr, int n) {
    int total = 0;
    for (int i = 0; i < n; i++) {
        total += arr[i];
    }
    return total;
}

x86_64 (-O2)

sum:
    test    esi, esi          # n == 0 ?
    jle     .L_zero
    xor     eax, eax          # total = 0
    xor     edx, edx          # i = 0
.L_loop:
    add     eax, DWORD PTR [rdi+rdx*4]   # total += arr[i]
    inc     rdx                            # i++
    cmp     edx, esi                       # i < n ?
    jl      .L_loop
    ret
.L_zero:
    xor     eax, eax
    ret

[rdi+rdx*4] のスケールドインデックスが配列アクセスに使われています。rdx*44 は int のサイズ(4バイト)です。

AArch64 (-O2)

sum:
    cmp     w1, #0
    b.le    .L_zero
    mov     w2, #0             // total = 0
.L_loop:
    ldr     w3, [x0], #4      // arr[i] をロードしてポインタを進める
    add     w2, w2, w3         // total += arr[i]
    subs    w1, w1, #1         // n-- (フラグ更新)
    b.ne    .L_loop
    mov     w0, w2             // 戻り値レジスタに移動
    ret
.L_zero:
    mov     w0, #0
    ret

ポストインクリメント [x0], #4 でポインタを進めるので、インデックス変数が不要になっています。totalにはw0(=x0、配列ポインタ)とは別のw2を使い、最後に戻り値レジスタw0に移しています。カウンタをデクリメントする方式で、前回見た「典型的なARMループ」の形です。

-O0 と -O2 の差から見えること

同じCコードでも最適化レベルで生成されるアセンブリは大きく変わります。

観点 -O0 -O2
変数の置き場所 すべてスタック 可能な限りレジスタ
分岐 if文がそのまま比較+ジャンプに cmov や条件付き命令で分岐回避
ループ インデックス変数が素直に残る ポインタ走査やカウンタ逆回しに変換
命令数 多い 少ない
デバッグ 変数がスタック上にあるので見やすい 変数がレジスタに散らばるので追いにくい

普段は -O2 の出力を読んで「コンパイラが何をしてくれているか」を確認し、パフォーマンスが気になるときに具体的な命令レベルで見る、という使い方がよさそうです。

読むときのコツ

1. ノイズを除去する

gcc -S の出力には .cfi_ で始まるデバッグ情報ディレクティブが大量に入ります。読むときは除去すると見やすいです。

gcc -S -O2 add.c -o /dev/stdout | grep -v '^\s*\.' | grep -v '^$'

Compiler Explorer は初期設定でこれらを除去してくれるので、そちらが楽です。

2. 関数のプロローグ・エピローグを認識する

どのアーキテクチャでも、関数の最初と最後にお決まりのパターンが見えてきました。

x86_64:    push rbp / mov rbp,rsp ... pop rbp / ret
AArch64:   stp x29,x30,[sp,-16]! ... ldp x29,x30,[sp],16 / ret
s390x:     stmg %r11,%r15,88(%r15) ... lmg %r11,%r15,88(%r15) / br %r14

これは「関数呼び出しの作法」なので、本体のロジックを読むときは飛ばしています。

3. 呼び出し規約を手元に置く

引数がどのレジスタに入っているかわからないと読めません。

第1引数 第2引数 戻り値
x86_64 edi/rdi esi/rsi eax/rax
AArch64 w0/x0 w1/x1 w0/x0
s390x r2 r3 r2

まとめ

gcc -S で自分のCコードがどう変換されるかを見ていくと、「コンパイラに任せていい部分」と「意識すべき部分」の境界が少しずつ見えてきました。

次回は、ここで少し触れた関数プロローグ・エピローグを深掘りして、スタックフレームの構造を詳しく見ていきます。

Discussion