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引数以降は❷スタックに積んでから関数を呼び出します.
  • 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 + ip + (i * sizeof (*p))
i + qp + (i * sizeof (*p))
p + qコンパイルエラー
p - ip - (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が評価されるのはpNULLではない場合のみになります)

      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についてはこちらを参照)

関数コール(関数ポインタ)

    .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に格納します
    • ❸で%eax0を格納しています.
      • %alprintfなどの可変長引数を持つ関数の隠し引数です
      • %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でコンパイルすると,movdqamovaps命令を使うコードを出力しました.アラインメントなどの条件が合うと,こうなります.
    • %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 が名前の由来です.