こんにちは、 @kz_morita です。
前回の記事で「プログラムはなぜ動くのか?」C 言語から生成されたアセンブリを眺めていたらいろいろ知見があったので今日はそのことについてまとめていこうと思います。
アセンブリコードをみてみる
今回対象とするのは、以下のような C 言語のソースコードです。 c という変数に、100 と 123 を足した結果を保持するだけのものになります。
sample.c
// 2つの引数の加算結果を返す関数
int add(int a, int b)
{
return a + b;
}
int main()
{
int c;
c = add(100, 123);
return 0;
}
これをアセンブリコードに変換するためには以下のコマンドを用います。
$ gcc -S sample.c -o sample.s
これを実行すると以下のようなアセンブリファイルが生成されます。
.section __TEXT,__text,regular,pure_instructions
.build_version macos, 10, 15 sdk_version 10, 15
.globl _add ## -- Begin function add
.p2align 4, 0x90
_add: ## @add
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl -4(%rbp), %esi
addl -8(%rbp), %esi
movl %esi, %eax
popq %rbp
retq
.cfi_endproc
## -- End function
.globl _main ## -- Begin function main
.p2align 4, 0x90
_main: ## @main
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
subq $16, %rsp
movl $0, -4(%rbp)
movl $100, %edi
movl $123, %esi
callq _add
xorl %esi, %esi
movl %eax, -8(%rbp)
movl %esi, %eax
addq $16, %rsp
popq %rbp
retq
.cfi_endproc
## -- End function
.subsections_via_symbols
上記のファイルをみると、 .cfi◯◯ という記述がありますがこちらはどうやらデバッグ用の情報らしいです。
https://sourceware.org/binutils/docs/as/CFI-directives.html
これらの情報を削除するためには、以下のようなコマンドでアセンブリを生成します。
$ gcc -fno-asynchronous-unwind-tables -S sample.c -o sample.s
すると以下のようなアセンブリコードが生成されます。
.section __TEXT,__text,regular,pure_instructions
.build_version macos, 10, 15 sdk_version 10, 15
.globl _add ## -- Begin function add
.p2align 4, 0x90
_add: ## @add
## %bb.0:
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl -4(%rbp), %esi
addl -8(%rbp), %esi
movl %esi, %eax
popq %rbp
retq
## -- End function
.globl _main ## -- Begin function main
.p2align 4, 0x90
_main: ## @main
## %bb.0:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl $0, -4(%rbp)
movl $100, %edi
movl $123, %esi
callq _add
xorl %esi, %esi
movl %eax, -8(%rbp)
movl %esi, %eax
addq $16, %rsp
popq %rbp
retq
## -- End function
.subsections_via_symbols
今回はこのアセンブリコードを眺めていきます。
アセンブリのシンタックスの種類
アセンブリには、GAS (GNU Assembler) と NASM (Netwide Assembler) が存在します。
今回の説明で用いるのは、GAS の方になります。
上記2つは、そもそもシンタックスが違うので注意が必要です。
例えば、データ転送命令である mov
という命令 (オペコード) では、src → dest という方向に値をコピーするのですが、
GAS では、
mov {src} {dest}
と表記するのに対して、NASM では
mov {dest} {src}
と表記したりするため、読み間違えないよう注意が必要です。
オペコードとレジスタ
まず、上記のアセンブリコードを読む前にアセンブリの命令と、オペコード・レジスタについて簡単に説明します。
アセンブリのコードは以下のような形式の命令が基本となります。
movl $100, %edi
この 1 行を 1 命令とし、このことをニーモニックと呼びます。
また、命令を表す部分 (上の例で言う movl
) をオペコード、演算の対象 (上の例でいう $100
, %edi
) をオペランドと呼びます。
オペコードと Suffix
オペコードには、add
, sub
, mov
, push
, pop
, ret
, call
などがあります。
それぞれの細かい説明については今回は省きますが、登場する際に簡単に説明します。
ところで、先ほど載せた今回眺めていくアセンブリコードの中に、movl
や、 addq
のように、末尾に英字がついているオペコードがあったかと思います。
これは各演算が 何 bit 演算なのかを表す Suffix となっています。
- b = byte (8 bit).
- s = single (32-bit floating point).
- w = word (16 bit).
- l = long (32 bit integer or 64-bit floating point).
- q = quad (64 bit).
- t = ten bytes (80-bit floating point).
( https://en.wikibooks.org/wiki/X86_Assembly/GAS_Syntax#Operation_Suffixes より参照 )
つまり、movl
は, 32bit 整数 もしくは、64bit 浮動小数点 のデータ転送を行う命令ということになります。
レジスタと Prefix
オペランドとして指定できるのは、数値などのリテラルや、レジスタ、メモリアドレス(の値)などになります。
レジスタは、CPU 内部の記憶装置のようなもので基本的に CPU が演算するときはメモリからデータをロードしてきてレジスタに格納しつつ演算を進めていきます。CPU にとってはメモリは外部装置なのでレジスタにロードしてから演算を行った方が高速です。
今回登場するレジスタについて簡単にご紹介します。
%rbp %rsp %edi %esi %eax
名前 | 用途 |
---|---|
AX (アキュムレータレジスタ) | 演算の途中経過や演算結果などを格納します。 |
SP (スタックポインタ) | 関数呼び出しなどで使用するスタックの場所をさすポインタです。このポインタを基準にローカル変数などのアドレスを計算したりします。 |
BP (ベースポインタ) | 基準となるアドレスをさすポインタが格納されます。こちらも関数呼び出しなどで使用されます。 |
SI (Source Index) | MOV 命令などの入力元のポインタとして使用されます。 |
DI (Destination Index) | MOV 命令などの転送先のポインタとして使用されます。 |
この記事の冒頭で紹介したアセンブリコードには、Prefix がついていたと思います。
movl -4(%rbp), %esi
例えば上記の命令では、%rbp, %esi と BP と SI の頭に e や、r がついています。(先頭の%はレジスタの頭につく接頭語だと思ってます)
レジスタの Prefix はそのレジスタが何 bit レジスタかを表しています。
prefix が r
であれば 64bit レジスタで、e
であれば 32bit レジスタ、prefix がついていなければ 16bit レジスタとなります。
つまり、rax は 64bit、 eax は 32bit、ax は 16bit レジスタとなります。また一部のレジスタでは 16bit レジスタの上位 8bit と下位 8bit にアクセスすることもできます。AX レジスタの例ですと、AH で上位 8bit, AL で下位 8bit にアクセスすることが可能です。
アセンブリコードをながめる
それでは、前置きが長くなりましたが実際のアセンブリコードを眺めてみます。
main 関数の前半
まずは main 関数部分を抜き出してみます。
.globl _main ## -- Begin function main
.p2align 4, 0x90
_main: ## @main
## %bb.0:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl $0, -4(%rbp)
movl $100, %edi
movl $123, %esi
callq _add
xorl %esi, %esi
movl %eax, -8(%rbp)
movl %esi, %eax
addq $16, %rsp
popq %rbp
retq
まず、main 関数がラベルとして定義されています。この内側のブロックが main 関数の処理となりそうです。(_
がついているのはアセンブリの予約語と関数名がかぶらないようにするための措置でしょうか)
_main: ## @main
続く部分では、現在のベースポインタを pushq
によってスタックに退避しておき、現在のスタックポインタをベースポインタに設定しています。
pushq %rbp
movq %rsp, %rbp
この処理はおそらく、関数の最初に必ずされている処理で、関数のスコープに入ったタイミングでベースポインタを現在のスタックポインタに設定してスコープを抜ける時に pop することで、ベースポインタを元に戻しているかと思います。
つづく箇所では、ローカル変数のセットアップをしています。
subq $16, %rsp
movl $0, -4(%rbp)
先頭で、スタックポインタから 16 を引いていますがこれは、ローカル変数の領域を 16 バイト分保持しているのだと思います。
つづく、
movl $0, -4(%rbp)
では、32bit 整数型で 0 を rbp - 4 のアドレスに書き込んでいます。つまり以下の画像のような状態になります。
これは元のソースコードで言うところの、
int c;
という変数宣言 (と変数の初期化) の箇所に相当します。
続く箇所では、add 関数の引数に渡す数値の 100 と 123 を edi, esi というレジスタに転送しています。
movl $100, %edi
movl $123, %esi
これらは、add 関数のアセンブリの中で参照されているため後ほど説明します。
次に add 関数を呼び出している箇所です。call 命令を使用しています。
callq _add
これで、add 関数を呼び出す直前までコードを追うことができました。引き続き add 関数をみていきます。
add 関数
add 関数の中身は以下のようになっています。こちらも順にコードを追ってみます。
_add: ## @add
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl -4(%rbp), %esi
addl -8(%rbp), %esi
movl %esi, %eax
popq %rbp
retq
まずは前半の 2 行です。
pushq %rbp
movq %rsp, %rbp
こちらは、main 関数の先頭にもあらわれたものです。
add 関数の処理に入った直後に rbp
レジスタに入っているのは main 関数で使用していたベースポインタになります。rbp
レジスタは add 関数でも使用したいので main 関数で使用していたものをスタックに退避する必要があります。
1 行目の pushq 命令でスタックへの退避を行っています。
そして新しいベースポンタとして、現在のスタックポインタを設定しています。
続く命令では、add 関数を呼び出す際に引数として渡した値をスタック領域にコピーしています。
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
つづく箇所では、実際にたし算が行われます。
movl -4(%rbp), %esi
addl -8(%rbp), %esi
movl %esi, %eax
まず movl 命令で、rbp - 4
のアドレスにある 100
という値を esi レジスタに転送しています。 (なぜ esi レジスタが使われているのかは謎です)
次に addl 命令で、 rbp - 8
のアドレスにある 123
と言う値を esi レジスタに加算し、esi レジスタに結果を保持します。
そして最後に、esi レジスタの値を、eax
レジスタに転送しています。
ここで eax
レジスタにコピーしているのは、関数の return される値は rax レジスタとするといった規則があるためです。
add 関数の最後では、退避していた ベースポインタを rbp レジスタに戻し関数を return しています。
popq %rbp
retq
main 関数の後半
ここまでで、関数呼び出しをし演算を行って関数からもどってくるところまでいきました。あと一息です。
残りのコードは以下のようになっています。
xorl %esi, %esi
movl %eax, -8(%rbp)
movl %esi, %eax
addq $16, %rsp
popq %rbp
retq
まず初めに以下の命令です。
xorl %esi, %esi
こちらはわかりにくいですが、esi レジスタを初期化しています。xor
で排他的論理和をとっていますが、同じ内容ならば 0 になる性質を利用して、esi レジスタを 0 で初期化しているコードになります。
続く命令で、add 関数の結果が格納されている eax レジスタの値を、main 関数のベースポインタ -8 の位置に保存しています。
movl %eax, -8(%rbp)
これはつまり、c = add(100, 123);
で ローカル変数 c に add 関数の戻り値を代入しています。
最初に c を初期化した際は、-4(%rbp)
の場所にデータを保存していたため、どうやら同じアドレスには保存していないようです。
そしてローカル変数に add 関数の戻り値は設定できたので、eax レジスタは esi レジスタの値 (つまり 0) で初期化しています。
movl %esi, %eax
0 で初期化しているのは、main 関数の最後に return 0;
とあるのも影響しているのかもしれません。(関数の戻り値は eax レジスタに格納するため)
最後に main 関数の終了処理を行っています。
addq $16, %rsp
popq %rbp
retq
まず add 命令で、rsp レジスタに 16 足しているのですがこれは main 関数の先頭で、ローカル変数の領域を確保するために 以下のように rsp レジスタに 16 を引いていたため、その確保した領域の後始末となります。
subq $16, %rsp
そして、popq で main 関数に入る時に退避した ベースポインタを元に戻して main 関数を return しています。
非常に長くなってしまいましたが、これで簡単な C 言語のプログラムのフローをアセンブリコードで追うことができました。
まとめ
今回は、簡単な C 言語のコードがアセンブリでどのように表現されているかを確認するために、実際にアセンブリコードをはいてみて中身を眺めてみました。 パッとみた限りでは難しく読めなさそうなアセンブリコードでしたが、ひとつずつ読み解いていくと何をおこなっているのかを理解することができるかと思います。CPU がどのように動いているかなどは日頃意識しなくてもプログラミングは可能ですが、この低レイヤーな部分を覗いてみるのも面白いなと感じました。