GCCが生成したアセンブリコードを読む
この章では単純なCのプログラムをGCCがどのようなアセンブリコードに変換するかを見ていきます. これより以前の章で学んだ知識を使えば,C言語のコードが意外に簡単にアセンブリコードに変換できることが分かると思います. 実際にコンパイラの授業で構文解析の手法を学べば, (そして最適化器の実装を諦めれば)コンパイラは学部生でも十分簡単に作れます.
制御文
if文
// if.c
int x = 111;
int main ()
{
if (x > 100) {
x++;
}
}
- ifの条件式
x > 100は反転して「x <= 100なら,then部分をスキップして,thenの直後(ラベル.L2)にジャンプする」というコードを出力しています. (反転する必然性はありません.x > 0を評価してその結果が0に等しければ,.L2にジャンプするコードでも(実行速度を除けば)同じ動作になります) - then部分では「
xに1を加える」コードを出力しています.
なぜGCCはinc命令を使わないの?
incl x(%rip)なら1命令で済みますよね.
もし-Oや-O2などの最適化オプションを付ければ,
GCCはincを使うかも知れませんが,
そうしてしまうと,(最適化が賢すぎて)元のif文の構造がガラリと変わってしまう可能性があるため避けています.
この章では全て「最適化オプションを付けていないので,GCCは無駄なコードを出力することがある」と考えて下さい.
if-else文
// if-else.c
int x = 111;
int main ()
{
if (x > 100) {
x++;
} else {
x--;
}
}
- ifの条件式
x > 100は反転して「x <= 100なら,then部分をスキップして,thenの直後(ラベル.L2)にジャンプする」というコードを出力しています. - then部分では「
xに1を加える.次にelse部分をスキップするために.L3にジャンプする」コードを出力しています. - else部分では「
xから1減らす」コードを出力しています.
while文
// while.c
int x = 111;
int main ()
{
while (x > 100) {
x--;
}
}
- while条件判定はwhileボディのコードの後に置かれています(これは必然ではありません).
最初にwhileループに入る時,
.L2にジャンプします. - whileの条件式
x > 100が成り立つ間は,.L3に繰り返しジャンプします. - 「whileボディ」実行後に必ず「while条件判定」が必要になるので,これでうまくいきます.
for文
// for.c
int x = 111;
int main ()
{
for (int i = 0; i < 10; i++) {
x--;
}
}
- ほぼwhile文と同じです.違いは「for初期化」 (
int i = 0)があることと, 「forボディ」の直後に「for更新」(i++)があることだけです. - GCCが条件判定のコードを,
i < 10(cmpl $10, -4(%rbp); jl .L3)ではなく,i <= 9(compl $9, -4(%rbp); jl .l3)としています. どちらも同じなのですが,なぜこうしたのか,GCCの気持ちは分かりません.
switch文 (単純比較)
// switch.c
int x = 111;
int main ()
{
switch (x) {
case 1:
x++;
break;
case 111:
x--;
break;
default:
x = 0;
break;
}
}
- ジャンプテーブルを使わない,単純に比較するコード生成です.
例えば,
case 1:は,if (x == 1)と同様のコード生成になっています. - この方法ではcaseが\(n\)個あると,\(n\)回比較する必要があるので, \(O(n)\)の時間がかかってしまいます.
switch文 (ジャンプテーブル)
// switch2.c
int x = 111;
int main ()
{
switch (x) {
case 1: x++; break;
case 2: x--; break;
case 3: x = 3; break;
case 4: x = 4; break;
case 5: x = 5; break;
default: x = 0; break;
}
}
// switch3.c
int x = 111;
int main ()
{
void *jump_table [] = {&&L2, &&L8, &&L7, &&L6, &&L5, &&L3} ; // ❶
goto *jump_table [x]; // ❷
L8: // case 1:
x++; goto L9;
L7: // case 2:
x--; goto L9;
L6: // case 3:
x = 3; goto L9;
L5: // case 4:
x = 4; goto L9;
L3: // case 5:
x = 5; goto L9;
L2: // default:
x = 0; goto L9;
L9:
}
switch2.cをジャンプテーブルを使って書き換えたのがswitch3.cです. ❶と❷の部分はGCC拡張機能で「ラベルの値を配列変数に格納し❶」, 「gotoで格納したラベルにジャンプ❷」することができます.- 変数
xの値をジャンプテーブル(配列)のインデックスとして, ジャンプ先アドレスを取得し,間接ジャンプしています.movl %eax, %eaxはレジスタ%raxの上位32ビットをゼロクリアしています.notrackはIntel CET (control-flow enforcement technology)による拡張命令です.notrack付きの場合,間接ジャンプ先がendbr64ではなくても例外は発生しなくなります.
- ジャンプテーブルを使うと,\(O(1)\)でcase文を選択できます. ただし,ジャンプテーブルが使えるのはcaseが指定する範囲がある程度, 密な場合に限ります(巨大な配列を使えば,疎な場合でも扱えますが…).
定数
整数定数
// const-int.c
int main ()
{
return 999;
}
main:
endbr64
pushq %rbp
movq %rsp, %rbp
movl ❶ $999, %eax
popq %rbp
ret
- 整数定数
999は❶即値 ($999)としてコード生成されています
文字定数
// const-char.c
int main ()
{
return 'A';
}
main:
endbr64
pushq %rbp
movq %rsp, %rbp
movl ❶ $65, %eax
popq %rbp
ret
- 文字定数は❶即値 (
$65, 65は文字AのASCIIコード)としてコード生成されています
文字列定数
// const-string.c
#include <stdio.h>
int main ()
{
puts ("hello\n");
}
❶ .section .rodata
❷ .LC0:
❸ .string "hello\n"
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
leaq ❹ .LC0(%rip), %rax
movq %rax, %rdi
call puts@PLT
movl $0, %eax
popq %rbp
ret
- 文字列定数
"hello\n"の実体は❶.rodataセクションに置かれます. ❸.string命令で"hello\n"のバイナリ値を配置し, その先頭アドレスを❷ラベル.LC0:と定義しています. - 式中の
"hello\n"の値は「その文字列の先頭アドレス」ですので, ❹.LC0(%rip)と参照しています(ここでは文字列の先頭アドレスが%raxに格納されます)
配列
以下で使うint a[3]の配列のメモリレイアウトは上の図のようになってます.
このため,a[2]を参照する時はアドレスa+8のメモリを読み,
a[i]を参照する時はアドレスa+i*4のメモリを読む必要があります.
// array3.c
int a [] = {111, 222, 333};
int main ()
{
return a [2];
}
.globl a
.data
.align 8
.type a, @object
.size a, 12
❶ a:
❷ .long 111
❷ .long 222
❷ .long 333
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
movl ❸ 8+a(%rip), %eax
popq %rbp
ret
- 配列はメモリ上で連続する領域に配置されます.
この場合は❷
.long命令で4バイトずつ,111,222,333の2進数を隙間なく配置しています.個人的には.long 111, 222, 333と1行で書いてくれる方が嬉しいのですが… (なお,配列とは異なり,構造体の場合はメンバー間や最後にパディング(隙間)が入ることがあります) - 配列の先頭アドレスを❶ラベル
a:と定義しています. a[2]の参照は❸8+a(%rip)という%rip相対のメモリ参照になっています. 指定したインデックスが定数(2)だったため,変位が8+aになっています. (a[i]などとインデックスが変数の場合はアセンブラの足し算は使えません).
a[i]の場合のコンパイル結果は例えば以下のとおりです.
a[i]の値を読むために,a+i*4の計算をする必要があり,
少し複雑になっています.
// array4.c
int a [] = {111, 222, 333};
int i = 1;
int main ()
{
return a [i];
}
.globl a
.data
.align 8
.type a, @object
.size a, 12
a:
.long 111
.long 222
.long 333
.globl i
.align 4
.type i, @object
.size i, 4
i:
.long 1
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
movl i(%rip), %eax # iの値を %eax に代入
cltq # %eax を %rax に符号拡張
leaq 0(,%rax,4), %rdx # i*4 を %rdx に代入
leaq a(%rip), %rax # aの絶対アドレスを %rax に代入
movl (%rdx,%rax), %eax # アドレス a+i*4 の値(4バイト,これがa[i])を %eax に代入
popq %rbp
ret
.size main, .-main
構造体
// struct5.c
struct foo {
char x1;
int x2;
};
struct foo f = {10, 20};
int main ()
{
return f.x2;
}
.globl f
.data
.align 8
.type f, @object
.size f, 8
❶ f:
❷ .byte 10 # x1
❸ .zero 3 # 3バイトのパディング
❹ .long 20 # x2
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
movl ❺ 4+f(%rip), %eax
popq %rbp
ret
- 構造体
fooがメモリ上に配置される際,アラインメント制約を満たすために パディングが入ることがあります. - ここでは,メンバー❷
x1と❹x2の間に❸3バイトのパディングが入っています. - 構造体メンバーの参照
foo.x2は ❺4+f(%rip)という%rip相対のメモリ参照になっています.
共用体
// union2.c
#include <stdio.h>
union foo {
char x1 [5];
int x2;
};
union foo f = {.x1[0] = 'a'};
int main ()
{
f.x2 = 999;
return f.x2;
}
.globl f
.data
.align 8
.type f, @object
❶ .size f, 8
❷ f:
❸ .byte 97
❹ .zero 4
❺ .zero 3
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
movl $999, ❻ f(%rip)
movl ❼ f(%rip), %eax
popq %rbp
ret
- 共用体は以下を除き,構造体と一緒です
- 同時に1つのメンバーにしか代入できない
- 全てのメンバーはオフセット0でアクセスする
- 共用体のサイズは最大サイズを持つメンバ+パディングのサイズになる
- 上の例では最大サイズのメンバ
char x1 [5]に3バイトのパディングがついて, 共用体fのサイズは8バイトになってます
- 上の例では最大サイズのメンバ
- 上の例では指示付き初期化子(designated initializer)の記法
(
union foo f = {.x1[0] = 'a'};)を使って,メンバx1を初期化しています. ❸'a'の値が.byteで配置され,残りの7バイトは❹❺0で初期化されています. - 共用体
fへの代入や参照はメモリ参照❻❼f(%rip)でアクセスされています.
変数
初期化済みの静的変数
// var-init-static.c
int x = 999;
int main ()
{
return x;
}
.globl x
❶ .data
.align 4
.type x, @object
.size x, 4
❷ x:
❸ .long 999
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
movl ❹ x(%rip), %eax
popq %rbp
ret
- 初期化済みの静的変数の実体は❶
.dataセクションに配置されます. 初期値 (999)を(この場合はint型なので).long命令で999のバイナリ値を配置し,その先頭アドレスをラベルx:と定義しています. - 変数
xの参照は❹x(%rip)という%rip相対のメモリ参照です.
未初期化の静的変数
// var-uninit-static.c
int x;
int main ()
{
x = 999;
return x;
}
.globl x
❶ .bss
.align 4
.type x, @object
.size x, 4
❷ x:
❸ .zero 4
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
movl $999, ❹ x(%rip)
movl ❺ x(%rip), %eax
popq %rbp
ret
- 未初期化の静的変数の実体は❶
.bssセクションに配置されます..bssセクション中の変数は❸ゼロで初期化されます. 初期化した4バイトの先頭アドレスをラベル❷x:と定義しています.- ただし
.bssセクションが実際にゼロで初期化されるのは実行直前です
- ただし
- 変数
xの参照は,メモリ参照❹❺x(%rip)でアクセスされています.
自動変数 (static無しの局所変数)
// var-auto.c
int main ()
{
int x;
x = 999;
return x;
}
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
movl $999, ❶ -4(%rbp)
movl ❷ -4(%rbp), %eax
popq %rbp
ret
- 自動変数は実行時にスタック上にその実体が配置されます.
上の例では変数
xは❶❷-4(%rbp)から始まる4バイトに割り当てられ, アクセスされています. (この変数xはレッドゾーンに配置されています)
実引数
// arg.c
void foo (long a1, long a2, long a3, long a4, long a5, long a6, long a7);
int main ()
{
foo (10, 20, 30, 40, 50, 60, 70);
}
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
❹ subq $8, %rsp # パディング
❷ pushq $70 # 第7引数
❶ movl $60, %r9d # 第6引数
❶ movl $50, %r8d # 第5引数
❶ movl $40, %ecx # 第4引数
❶ movl $30, %edx # 第3引数
❶ movl $20, %esi # 第2引数
❶ movl $10, %edi # 第1引数
❸ call foo@PLT
❺ addq $16, %rsp # 第7引数とパディングを捨てる
movl $0, %eax
leave
ret
-
第6引数までは❶レジスタ渡しになります. 第7引数以降は❷スタックに積んでから関数を呼び出します.
❶で
movqではなくmovl命令を使っているのは, こちらで説明した通り, 例えばmovl $10, %ediを実行すると%rdiレジスタの上位32ビットがゼロになるからです. -
System V ABI (AMD64) により ❸
call命令実行時には%rspの値は16の倍数である必要があります. そのため,❹で8バイトのパディングをスタックに置いています. -
関数からリターン後は❷でスタックに積んだ引数と❹パディングを❸スタック上から取り除きます.
仮引数
// parameter.c
#include <stdio.h>
void
foo (long a1, long a2, long a3, long a4, long a5, long a6, long a7)
{
printf ("%ld\n", a1 + a7);
}
.text
.section .rodata
.LC0:
.string "%ld\n"
.text
.globl foo
.type foo, @function
foo:
endbr64
pushq %rbp
movq %rsp, %rbp
subq $48, %rsp
movq ❶ %rdi, -8(%rbp) # 第1引数
movq ❶ %rsi, -16(%rbp) # 第2引数
movq ❶ %rdx, -24(%rbp) # 第3引数
movq ❶ %rcx, -32(%rbp) # 第4引数
movq ❶ %r8, -40(%rbp) # 第5引数
movq ❶ %r9, -48(%rbp) # 第6引数
movq -8(%rbp), %rdx
movq ❷ 16(%rbp), %rax # 第7引数
addq %rdx, %rax
movq %rax, %rsi
leaq .LC0(%rip), %rax
movq %rax, %rdi
movl $0, %eax
call printf@PLT
nop
leave
ret
- GCCはレジスタで受け取った第1〜第6引数をスタック上に❶置いています.
一方,第7引数はスタック渡しで,その場所は❻
16(%rbp)でした.
式 (expression)
単項演算子 (unary operator)
// unary.c
int x = 111;
int main ()
{
return -x;
}
.text
.globl x
.data
.align 4
.type x, @object
.size x, 4
x:
.long 111
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
movl x(%rip), %eax
❶ negl %eax
popq %rbp
ret
- 単項演算は対応する命令,ここでは❶
neglを使うだけです.
二項演算子(単純な加算)
// binop-add.c
int x = 111;
int main ()
{
return x + 89;
}
.globl x
.data
.align 4
.type x, @object
.size x, 4
x:
.long 111
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
movl x(%rip), %eax
❶ addl $89, %eax
popq %rbp
ret
- 基本的に二項演算子も対応する命令 (ここでは❷
addl)を使うだけです.
二項演算子(割り算)
// binop-div.c
int x = 111, y = 9;
int main ()
{
return x / y;
}
.globl x
.data
.align 4
.type x, @object
.size x, 4
x:
.long 111
.globl y
.align 4
.type y, @object
.size y, 4
y:
.long 9
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
movl x(%rip), %eax
movl y(%rip), %ecx
❷ cltd
❶ idivl %ecx
popq %rbp
ret
- 割り算はちょっと注意が必要です.
- 例えば,32ビット符号ありの割り算を❶
idivl命令で行う場合,%edx:%eaxを第1オペランドで割った商が%eaxに入ります. - このため,
idivlを使う前に❷cltd命令等を使って,%eaxを符号拡張した値を%edxに設定しておく必要があります (%edxの値の設定を忘れるて%idivlを実行すると,割り算の結果がおかしくなります)
二項演算(ポインタ演算)
- 復習: C言語のポインタ演算 (オペランドがポインタの場合の演算)は普通の加減算と意味が異なります.ポインタが指す先のサイズを使って計算する必要があります.
| 演算 | 意味 (int i, *p, *qの場合) |
|---|---|
p + i | p + (i * sizeof (*p)) |
i + q | p + (i * sizeof (*p)) |
p + q | コンパイルエラー |
p - i | p - (i * sizeof (*p)) |
i - q | コンパイルエラー |
p - q | (p - q) / sizeof (*p) |
// pointer-arith.c
#include <stdio.h>
int a [] = {0, 10, 20, 30};
int main ()
{
printf ("%p, %p\n", a, &a[0]); // 同じ
printf ("%p, %p, %p\n", &a[2], a + 2, 2 + a); // 同じ
printf ("%p, %p\n", a, &a[2] - 2); // 同じ
printf ("%ld\n", &a[2] - &a[0]);
// printf ("%p\n", &a[2] + &a[0]); // コンパイルエラー
// printf ("%p\n", 2 - &a[2]); // コンパイルエラー
}
$ gcc -g -no-pie pointer-arith.c
$ ./a.out
0x404030, 0x404030
0x404038, 0x404038, 0x404038
0x404030, 0x404030
2
- 復習: 式中で配列名(
a)はその配列の先頭要素のアドレス(&a[0])を意味します. - 復習: 式中で配列要素へのアクセス
a[i]は,*(a+i)や*(i+a)と書いても同じ意味です. - 例えば,上の例で
a+2は,配列の要素がint型で,sizeof(int)が4なので, \(0x404030 + 2\times 4 = 0x404038\) という計算になります. このため,a+2は&a[2]と同じ値になります.
// pointer-arith2.c
int a [] = {0, 10, 20, 30};
int* foo ()
{
return a + 2; // ❶
}
.globl a
.data
.align 16
.type a, @object
.size a, 16
a:
.long 0
.long 10
.long 20
.long 30
.text
.globl foo
.type foo, @function
foo:
endbr64
pushq %rbp
movq %rsp, %rbp
leaq ❷ 8+a(%rip), %rax
popq %rbp
ret
- 上の例でCコード中の❶
a+2は,アセンブリコード中では❷8+aになっています. 配列要素のサイズ4をかけ算して,\(a + 2\times 4\)という計算をするからです.
アドレス演算子&と逆参照演算子*
// op-addr.c
int x = 111;
int *p;
int main ()
{
p = &x;
return *p;
}
.globl x
.data
.align 4
.type x, @object
.size x, 4
x:
.long 111
.globl p
.bss
.align 8
.type p, @object
.size p, 8
p:
.zero 8
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
❶ leaq x(%rip), %rax
movq %rax, p(%rip)
❷ movq p(%rip), %rax
❸ movl (%rax), %eax
popq %rbp
ret
- アドレス演算子
&xには変数xのアドレスを計算すれば良いので,leaq命令を使います. 具体的には❶leaq x(%rip), %raxで,xの絶対アドレスを%raxに格納しています. - 逆参照演算子
*pにはメモリ参照を使います. まず❷movq p(%rip), %raxで変数pの中身を%raxに格納し, ❸movl (%rax), %eaxとすれば,メモリ参照(%rax)でpが指す先の値を得られます.
比較演算子
// pred.c
int x = 111;
int main ()
{
return x > 100;
}
.globl x
.data
.align 4
.type x, @object
.size x, 4
x:
.long 111
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
movl x(%rip), %eax
❶ cmpl $100, %eax
❷ setg %al
❸ movzbl %al, %eax
popq %rbp
ret
>などの比較演算子にはset␣命令を使います.- 例えば,
x > 100の場合,- ❶
cmpl命令で比較を行い, - ❷
setgを使って「より大きい」という条件が成り立っているかどうかを%alに格納し - ❸
movzblを使って,必要なサイズ(ここでは4バイト)にゼロ拡張しています
- ❶
論理ANDと論理OR,「左から右への評価」
// land.c
int x = 111;
int main ()
{
return 50 < x && x < 200;
}
.globl x
.data
.align 4
.type x, @object
.size x, 4
x:
.long 111
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
movl x(%rip), %eax
cmpl $50, %eax
❶ jle .L2 # if x <= 50 goto .L2
movl x(%rip), %eax
cmpl $199, %eax
❷ jg .L2 # if x > 199 goto .L2
movl $1, %eax # 結果に1をセット
jmp .L4
.L2:
movl $0, %eax # 結果に0をセット
.L4:
popq %rbp
ret
-
多くの二項演算子では「両方のオペランドを計算してから,その二項演算子 (例えば加算)を行う」というコードを生成すればOKです.
-
しかし,論理AND (
&&) や論理OR (||)ではそのやり方ではNGです. 論理ANDと論理ORは左から右への評価 (left-to-right evaluation)を 行う必要があるからです.-
論理ANDでは,まず左オペランドを計算し,その結果が真の時だけ, 右オペランドを計算します.(左オペランドが偽ならば,右オペランドを計算せず,全体の結果を偽とする)
-
論理OR では,まず左オペランドを計算し,その結果が偽の時だけ, 右オペランドを計算します.(左オペランドが真ならば,右オペランドを計算せず,全体の結果を真とする)
-
要するに左オペランドだけで結果が決まる時は,右オペランドを計算してはいけないのです. このおかげで,以下のようなコードが記述可能になります. (右オペランド
*p > 100が評価されるのはpがNULLではない場合のみになります)int *p; if (p != NULL && *p > 100) { ...
-
-
このため,上のコード例でも❶左オペランドが真の場合だけ, ❷右オペランドが計算されています.
代入
// assign.c
int x = 111;
int main ()
{
return x = 100;
}
.globl x
.data
.align 4
.type x, @object
.size x, 4
x:
.long 111
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
❶ movl $100, x(%rip)
❷ movl x(%rip), %eax
popq %rbp
ret
- 代入式は単純に❶
mov命令を使えばOKです. - 代入式には(代入するという副作用以外に)「代入した値そのものを
その代入式の評価結果とする」という役割もあります.
そのため❷で,
returnで返す値を%eaxに格納しています.
文 (statement)
式文
// exp-stmt.c
int x = 111;
int main ()
{
x = 222;
333;
}
.globl x
.data
.align 4
.type x, @object
.size x, 4
x:
.long 111
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
❶ movl $222, x(%rip)
movl $0, %eax
popq %rbp
ret
- 復習: 式にセミコロン
;を付けたものが式文です. x = 222;という式文(代入文)は,代入式の ❶mov命令をそのまま出力すればOKです.- 式文中の式の計算にスタックを使った場合は,スタック上の値を捨てる必要があることがあります
333;は文法的に正しい式文なのですが,意味がないのでGCCはこの式文を無視しました
ブロック文
// block-stmt.c
int x = 111;
int main ()
{
{
x = 222;
x = 333;
x = 444;
}
}
.globl x
.data
.align 4
.type x, @object
.size x, 4
x:
.long 111
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
❶ movl $222, x(%rip) # 文1
❷ movl $333, x(%rip) # 文2
❸ movl $444, x(%rip) # 文3
movl $0, %eax
popq %rbp
ret
- 復習: ブロック文 (あるいは複合文 (compound statement))は, 複数の文が並んだ文です.
- ブロック文のコード出力は簡単で,文の並びの順番に,それぞれの アセンブリコード❶❷❸を出力するだけです.
goto文とラベル文
// goto.c
int x = 111;
int main ()
{
foo:
x = 222;
goto foo;
}
.globl x
.data
.align 4
.type x, @object
.size x, 4
x:
.long 111
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
.L2:
movl $222, x(%rip)
jmp .L2
- C言語のラベル
fooはアセンブリコードでは.L2になっていますが, (名前の重複に気をつければ)ラベルとして出力すればOKです goto文もそのまま無条件ジャンプjmpにすればOKです
return文 (intを返す)
// return.c
int x = 111;
int main ()
{
return x;
}
.globl x
.data
.align 4
.type x, @object
.size x, 4
x:
.long 111
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
❶ movl x(%rip), %eax
❷ popq %rbp
❸ ret
intなどの整数型を返すreturn文は簡単です. ❶返す値を%raxレジスタに格納し,❷スタックフレームの後始末をしてから, ❸ret命令で,リターンアドレスに制御を移せばOKです.
return文 (構造体を返す)
// return2.c
struct foo {
char x1;
long x2;
};
struct foo f = {'A', 0x1122334455667788};
struct foo func ()
{
return f;
}
.globl f
.data
.align 16
.type f, @object
.size f, 16
f:
.byte 65
.zero 7
.quad 1234605616436508552
.text
.p2align 4
.globl func
.type func, @function
func:
endbr64
❶ movq 8+f(%rip), %rdx
❷ movq f(%rip), %rax
ret
-
復習: C言語では,配列や関数を,関数の引数に渡したり,関数から返すことはできません (配列へのポインタや,関数へのポインタなら可能ですが). 一方,構造体や共用体は,関数の引数に渡したり,関数から返すことができます.
-
8バイトより大きい構造体や共用体を関数引数や返り値にする場合, 通常のレジスタ以外のレジスタやスタックを使ってやりとりをします. 具体的な方法は System V ABI (AMD64) が定めています.
-
上の例では
%raxと%rdxを使って,構造体fを関数からリターンしています. (コードが簡単になるように,ここではgcc -O2 -Sの出力を載せています)
関数
関数定義
// add5.c
int add5 (int n)
{
return n + 5;
}
.text
.globl add5
.type add5, @function
❷ add5: # 関数名のラベル定義
endbr64
pushq %rbp # スタックフレーム作成
movq %rsp, %rbp # スタックフレーム作成
❶ movl %edi, -4(%rbp) # 関数本体
❶ movl -4(%rbp), %eax # 関数本体
❶ addl $5, %eax # 関数本体
popq %rbp # スタックフレーム破棄
ret # リターン
.size add5, .-add5
- 関数を定義するには関数本体のアセンブリコード❶の前に関数プロローグ,
後に関数エピローグのコードを出力します.
また,関数の先頭で❷関数名のラベル(
add5;)を定義します. - 関数プロローグはスタックフレームの作成や,callee-saveレジスタの退避などを行います.
- 関数エピローグはcallee-saveレジスタの回復や,スタックフレームの破棄などを行い,
retでリターンします.
関数コール
// main.c
#include <stdio.h>
int add5 (int n);
int main ()
{
printf ("%d\n", add5 (10));
}
.section .rodata
.LC0:
.string "%d\n"
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
❶ movl $10, %edi
❷ call add5@PLT
❸ movl %eax, %esi
leaq .LC0(%rip), %rax
movq %rax, %rdi
movl $0, %eax
call printf@PLT
movl $0, %eax
popq %rbp
ret
- 関数コールをするには,
call命令の前に引数をレジスタやスタック上に格納してから,call命令を実行します.その後,%raxに入っている返り値を引き取ります. - 上の例では,
- ❶で
10を第1引数として%ediレジスタにセットしてから, - ❷で
callを実行して,制御をadd5関数に移します - ❸で
add5の返り値 (%eax)を引き取っています
- ❶で
- デフォルトの動的リンクを前提としたコンパイルなので,
関数名が
add5ではなく❷add5@PLTとなっています (PLTについてはこちらを参照)
関数コール(関数ポインタ)
// main2.c
#include <stdio.h>
int add5 (int n);
int main ()
{
int (*fp) (int n) = add5;
printf ("%d\n", fp (10));
}
.section .rodata
.LC0:
.string "%d\n"
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
❶ movq add5@GOTPCREL(%rip), %rax # GOT領域のadd5のエントリ(中身はadd5の絶対アドレス)
movq %rax, -8(%rbp)
movq -8(%rbp), %rax
❷ movl $10, %edi
❸ call *%rax
movl %eax, %esi
leaq .LC0(%rip), %rax
movq %rax, %rdi
movl $0, %eax
call printf@PLT
movl $0, %eax
leave
ret
- 上のコード例では
add5を変数fpに代入して,fp中の関数ポインタを使って,add5を呼び出しています. - これをアセンブリコードにすると❶
movq add5@GOTPCREL(%rip), %raxになります.add5@GOTPCRELはGOT領域のadd5のエントリなので, メモリ参照add5@GOTPCREL(%rip)で,add5の絶対アドレスを取得できます (GOT領域についてはこちらを参照) - ❷で第1引数(
10)を%ediに渡して - ❸
call *%raxで%rax中の関数ポインタを間接コールしています
ライブラリ関数コール
// main.c
#include <stdio.h>
int add5 (int n);
int main ()
{
printf ("%d\n", add5 (10));
}
.section .rodata
.LC0:
.string "%d\n"
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
movl $10, %edi
call add5@PLT
❷ movl %eax, %esi
❶ leaq .LC0(%rip), %rax
❶ movq %rax, %rdi
❸ movl $0, %eax
❹ call printf@PLT
movl $0, %eax
popq %rbp
ret
- ここではライブラリ関数代表として,
printfを呼び出すコードを見てみます.- ❶で
printfの第1引数である文字列"%d\n"の先頭アドレス (.LC0(%rip))を第1引数のレジスタ%rdiに格納します - ❷は
add5が返した値を,第2引数のレジスタ%esiに格納します - ❸で
%eaxに0を格納しています.%alはprintfなどの可変長引数を持つ関数の隠し引数です%alにはベクタレジスタを使って渡す浮動小数点数の引数の数をセットします- これはSystem V ABI (AMD64)が定めています
- ❹で
printfをコールしています
- ❶で
システムコール
// syscall-exit.c
#include <unistd.h>
int main ()
{
_exit (0);
}
.text
.globl main
.type main, @function
main:
endbr64
pushq %rbp
movq %rsp, %rbp
movl $0, %edi
❶ call _exit@PLT
exitはライブラリ関数なので,ここではシステムコールである_exitを呼び出しています.- が,
_exitもただのラッパ関数で,_exitの中で実際のシステムコールを呼び出します. このため,❶を見れば分かる通り,_exitの呼び出しは ライブラリ関数の呼び出し方と同じになります.
$ objdump -d /lib/x86_64-linux-gnu/libc.so.6 | less
(中略)
00000000000eac70 <_exit>:
eac70: f3 0f 1e fa endbr64
eac74: 4c 8b 05 95 e1 12 00 mov 0x12e195(%rip),%r8 # 218e10 <_DYNAMIC+0x250>
eac7b: be e7 00 00 00 mov $0xe7,%esi
eac80: ba 3c 00 00 00 mov $0x3c,%edx
eac85: eb 16 jmp eac9d <_exit+0x2d>
eac87: 66 0f 1f 84 00 00 00 nopw 0x0(%rax,%rax,1)
eac8e: 00 00
eac90: 89 d0 mov %edx,%eax
eac92: 0f 05 ❶ syscall
eac94: 48 3d 00 f0 ff ff cmp $0xfffffffffffff000,%rax
eac9a: 77 1c ja eacb8 <_exit+0x48>
eac9c: f4 hlt
eac9d: 89 f0 mov %esi,%eax
eac9f: 0f 05 ❶ syscall
eaca1: 48 3d 00 f0 ff ff cmp $0xfffffffffffff000,%rax
eaca7: 76 e7 jbe eac90 <_exit+0x20>
eaca9: f7 d8 neg %eax
eacab: 64 41 89 00 mov %eax,%fs:(%r8)
eacaf: eb df jmp eac90 <_exit+0x20>
eacb1: 0f 1f 80 00 00 00 00 nopl 0x0(%rax)
eacb8: f7 d8 neg %eax
eacba: 64 41 89 00 mov %eax,%fs:(%r8)
eacbe: eb dc jmp eac9c <_exit+0x2c>
_exit関数の中身を逆アセンブルしてみると, ❶syscall命令を使ってシステムコールを呼び出している部分を見つけられます. (お作法を正しく守れば,_exitを使わず,直接,syscallでシステムコールを呼び出すこともできます)
memcpyと最適化
-
ライブラリ関数
memcpyの呼び出しは,最適化の有無により例えば次の3パターンになります:call memcpy(通常の関数コール)movdqa src(%rip), %xmm0; movaps %xmm0, dst(%rip)(SSE/AVX命令)rep movsq(ストリング命令)
-
最適化無しの場合
// memcpy.c
#include <stdio.h>
#include <string.h>
char src [4096], dst [4096];
int main ()
{
memcpy (dst, src, 64);
}
$ gcc -S memcpy.c
$ cat memcpy.s
main:
pushq %rbp
movq %rsp, %rbp
movl $64, %edx
leaq src(%rip), %rax
movq %rax, %rsi
leaq dst(%rip), %rax
movq %rax, %rdi
call memcpy@PLT # 普通の call命令でライブラリ関数memcpyを呼ぶ
-
最適化した場合
$ gcc -S -O2 memcpy.c $ cat memcpy.s main: movdqa src(%rip), %xmm0 # 16バイト長の%xmm0レジスタに16バイトコピー movdqa 16+src(%rip), %xmm1 xorl %eax, %eax movdqa 32+src(%rip), %xmm2 movdqa 48+src(%rip), %xmm3 movaps %xmm0, dst(%rip) # %xmm0レジスタからメモリに16バイトコピー movaps %xmm1, 16+dst(%rip) movaps %xmm2, 32+dst(%rip) movaps %xmm3, 48+dst(%rip)memcpy.cを-O2でコンパイルすると,movdqaとmovaps命令を使うコードを出力しました.アラインメントなどの条件が合うと,こうなります.%xmm0〜%xmm3はSSE拡張で導入された16バイト長のレジスタです.
-
サイズを増やして最適化した場合
// memcpy2.c
#include <stdio.h>
#include <string.h>
char src [4096], dst [4096];
int main ()
{
memcpy (dst, src, 1024);
}
$ gcc -S -O2 memcpy2.c
$ cat memcpy.s
main:
leaq dst(%rip), %rax
leaq src(%rip), %rsi
movl $128, %ecx
movq %rax, %rdi
xorl %eax, %eax
rep movsq
- サイズを増やすと,
rep movsqというストリング命令を出力しました. rep movsqは,%ecx回の繰り返しを行います. 各繰り返しでは,メモリ(%rsi)の値を(%rdi)に8バイトコピーし,%rsiと%rdiの値を8増やします. (DFフラグが1の場合は8減らしますが,ABIが「関数の出入り口で(DF=1にしていたら)DF=0に戻せ」と定めているので,ここではDF=0です)
%rsiと%rdiの名前の由来
%rsiは source index,%rdiは destination index が名前の由来です.