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*4 の 4 は 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