アセンブリ言語の概要
機械語とアセンブリ言語とは何か?(短い説明)
機械語(マシン語):
- CPUが直接実行できる唯一の言語.
- 機械語命令を2進数(バイナリ,数字の列)で表現.
アセンブリ言語:
- 機械語を記号で表現したプログラミング言語.
- 例1:機械語命令
01010101をアセンブリ言語ではpushq %rbpという記号(ニモニック,mnemonic)で表す(x86-64の場合,以下同様). - 例2:メモリのアドレス
1000番地をアセンブリ言語ではadd5などの記号(ラベル)で表す.
pushq %rbpとは
「レジスタ%rbp中の値をスタックにプッシュする」という命令です.
ここで説明します.
2進数の機械語命令と,機械語命令のニモニックは概ね,1対1に対応しており, 機械的に変換できます.ただし,その変換方法を覚える必要はありません. アセンブルや逆アセンブルしてくれる コマンド(プログラム)にやってもらえばいいのです.
ただ,アセンブリ言語の仕組みを理解するには,オブジェクトファイル*.oや
実行可能ファイルa.outの中身や仕組みを理解する必要があるため,
バイナリファイルの節では説明が多くなっています.
機械語とアセンブリ言語の具体例(逆アセンブル)
まず以下の簡単なCのプログラムadd5.cを用意して下さい.
// add5.c
int add5 (int n)
{
return n + 5;
}
add5.cをgcc -cで処理すると,
オブジェクトファイルadd5.oができます.
このadd5.oに対してobjdump -dを実行すると,
逆アセンブル(disassemble)した結果が表示されます.
$ gcc -c add5.c
$ ls
add5.c add5.o
$ objdump -d add5.o
./add5.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <add5>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 8b 45 fc mov -0x4(%rbp),%eax
e: 83 c0 05 add $0x5,%eax
11: 5d pop %rbp
12: c3 ret
逆アセンブルとは,a.outや*.o中の機械語命令を
アセンブリ言語のニモニック表現に変換することです.
上の実行例で,左側に機械語命令,右側にニモニックが表示されています.
(一番左側の数字は,.textセクションの先頭からのバイト数(16進表記)です).
例えば,4バイト目にある55は機械語命令(を16進数で表記したもの),
55の右側のpush %rbpが,55に対応するニモニックです.
16進数を使っているのは,2進数で表記すると長くなるからです.
Cコードをアセンブリコードにコンパイルする
add5.cに対して,
以下のコマンドを実行して,add5.sを作成して下さい.
これで「アセンブリ言語で書かれたプログラム(アセンブリコード)」がどんなものかを見れます.
$ gcc -S add5.c
$ ls
add5.c add5.s
-Sオプションをつけて処理すると,
gccはCのプログラム(add5.c)からアセンブリコード(add5.s)を生成します.
この処理を「狭義のコンパイル」と呼びます
(広義のコンパイルはCのプログラムから実行可能ファイル(a.out)を
生成する処理を指します).
gcc -Sは「コンパイラ」と呼ばれます.コンパイルするコマンドだからです.
add5.sの中身は例えば以下となります.
注意: gccのバージョンの違いにより,同じLinuxでも
add5.sの中身が以下と異なることがあります.
以下では表示が長いので省略しています.
全てを表示するには右にあるボタンを押して下さい.
(ここではadd5.sの中身は理解できなくてOKです).
$ cat add5.s
.file "add5.c"
.text
.globl add5
.type add5, @function
add5:
.LFB0:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl %edi, -4(%rbp)
movl -4(%rbp), %eax
addl $5, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size add5, .-add5
.ident "GCC: (Ubuntu 11.3.0-1ubuntu1~22.04.1) 11.3.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:
このうち実行に関係する部分だけを残したアセンブリコードが以下になります.
# add5.s
.text
.globl add5
.type add5, @function
add5:
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl -4(%rbp), %eax
addl $5, %eax
popq %rbp
ret
.size add5, .-add5
各行の意味は次の次の節で説明しますが, ちょっとだけ説明します.
.textなどドット.で始まる命令はアセンブラ命令ですadd5:など名前の後ろにコロン:があるものはラベルの定義です%rbpなど,パーセント%で始まるものはレジスタです$5など,ドル$で始まるものは定数(即値)です.addl $5, %eaxは「レジスタ%eaxの値と定数の5を足し算した結果を%eaxレジスタに格納する」という動作を行う機械語命令です#から行末まではコメントです
AT&T形式とIntel形式とは
x86-64用のアセンブラには本書で扱うGNUアセンブラ以外にも, NASM (netwide assembler)などいくつかあり, 困ったことにアセンブリ言語の表記が異なります. この表記方法には大きく2種類:AT&T形式とIntel形式があります. 本書で扱うGNUアセンブラはAT&T形式,NASMやIntelのマニュアルはIntel形式を使っています.
一番大きな違いは機械語命令の引数(オペランドといいます)の順番です.
- AT&T形式は「左から右へ」,つまり代入先のオペランドを右に書きます
- Intel形式は「右から左へ」,つまり代入先のオペランドを左に書きます
他にもAT&T形式には%や$がつくなど,細かい違いがあります.
ここで詳しく説明します.
なお,gccに-S -masm=intelとオプションを設定すると,
出力されるアセンブリコードをIntel形式に変更できます.
$ gcc -S -masm=intel add5.c
.intel_syntax noprefix
.text
.globl add5
.type add5, @function
add5:
push rbp
mov rbp, rsp
mov DWORD PTR -4[rbp], edi
mov eax, DWORD PTR -4[rbp]
add eax, 5
pop rbp
ret
.size add5, .-add5
(DWORDは4バイト (double word)を意味しています)
なお,消した行の説明を以下に書きますが,読み飛ばしてOKです.
.cfi_とは
.cfiで始まるもの(アセンブラ命令)は call frame information を扱う命令です.
本書の範囲では不要です.詳細はdwarf5仕様書を参照下さい.
.fileと.identとは
.fileと.identはコメントとほぼ同じで,実行には関与しません.
.section .note.とは
以下の2つはセキュリティ上,実際には重要です(本書では消してしまいますが).
.section .note.GNU-stack,"",@progbitsはスタック上の機械語命令を実行不可と指定しています..section .note.gnu.property,"a"はIntel CETというセキュリティ技術の一部である IBT (indirect branch tracking)と SHSTK (shadow stack) のための指示です.
endbr64とは
endbr64もセキュリティ上,重要です.
間接ジャンプは脆弱性の大きな原因です.
endbr64はセキュリティ技術であるIntel CET技術の命令であり,
間接ジャンプ先の命令がendbr64以外の時は実行エラーとする,というものです.
本書の学習者としては「endbr64はセキュリティ上,重要だけど,アセンブリ言語を学習する立場では「endbr64はnop命令(何も実行しない命令)」と思えば十分です.
add5.sの各行の意味の説明の前に,説明の都合上,
アセンブルとアセンブラを説明します.
アセンブリコードをオブジェクトファイルにアセンブルする
add5.sに対して,以下のコマンドを実行すると,
add5.oが生成されます.この処理をアセンブル(assemble)といいます.
そして,アセンブルを行うプログラム(コマンド)を
アセンブラ(assembler)と呼びます.
gcc -cは内部的にアセンブラasを呼び出します.
asは本書で使用するGNUアセンブラのコマンド名です.
$ gcc -c add5.s
$ ls
add5.c add5.o add5.s
アセンブル処理は逆アセンブルとちょうど逆の関係です. (逆アセンブルは,バイナリから機械語命令のニモニックを復元しますが, アセンブラ命令やラベルやコメントは復元できません. ですので,完全な逆の関係ではありません.)
add5.oはバイナリファイルです.
また,add5.oから作成する実行可能ファイルa.outもバイナリファイルです.
バイナリ(の中身)については次の章,3節.バイナリで説明します.
アセンブリ言語の構成要素
add5.sはアセンブリ言語のプログラムであり,
アセンブリコード (assembly code)と呼びます.
アセンブリコードは以下の4つを組み合わせて書きます.
- 機械語命令 (例:
pushq %rbp) - アセンブラ命令 (例:
.text) - ラベル定義 (例:
add5:) - コメント (例:
# add5.s)
特に機械語命令(machine instruction)とアセンブラ命令(assembler directive) の違いに注意して下さい.
-
機械語命令はCPUが実行する命令です. 例えば,
pushq %rbpは機械語命令(のニモニック)です. このpushq %rbpはa.outが実行された時にCPUが実行します.一方,アセンブラがすることは例えば
add5.s中のpushq %rbpという機械語命令のニモニックを0x55という2進数(ここでは16進数表記)に変換して,add5.oに出力するだけです. アセンブラはpushq %rbpという機械語命令を実行しません. アセンブラにとって,pushq %rbpも0x55も両方とも単なるデータに過ぎないのです. -
アセンブラ命令はアセンブラが実行する命令です. 例えば,
.textはアセンブラ命令です. 本書が使用するGNUアセンブラではドット記号.で始まる命令は全てアセンブラ命令です.アセンブラは
add5.sからadd5.oを出力(アセンブル)します. そのアセンブラに対して行う指示がアセンブラ命令です. 例えば,.textは「出力先を.textセクションにせよ」を アセンブラに指示しています. アセンブラはアセンブル時に.textというアセンブラ命令を実行します (CPUがa.outを実行するときではありません).
アセンブリ言語は1行に1つが基本
アセンブリ言語は基本的に1行に1つだけ, 「機械語命令」「アセンブラ命令」「ラベル定義」「コメント」 のいずれかを書くのが基本です. ただし,複数を組み合わせて1行にできる場合もあります. 以下に可能な例を示します. (正確な定義はGNUアセンブラの文法を参照下さい).
- OK
add5: pushq %rbp(ラベル定義と機械語命令) - OK
pushq %rbp; movq %rsp, %rbp(機械語命令と機械語命令,セミコロン;で区切る) - OK
pushq %rbp # コメント(機械語命令とコメント) - OK
.text # コメント(アセンブラ命令とコメント)
add5.s中の# add5.s
# add5.sはgcc -Sの出力ではなく,私が付け加えた行です.
この行はコメントです.#から行末までがコメントとなり,
アセンブラは単にコメントを無視します.
つまりコメントは(C言語のコメントと同じで)人間が読むためだけのものです.
add5.s中の.text
.textは「出力先を.textセクションにせよ」と
アセンブラに指示しています.
セクションでも説明しますが,
add5.oやa.outなどのバイナリファイルの中身はセクションという単位で
区切られています.
このため,アセンブラが機械語やデータを2進数に変換して出力する時,
「どのセクションに出力するのか」の指定が必要となるのです.
.textセクション以外には,代表的なセクションとして,
.dataセクション,.rodataセクションがあります.
それぞれの役割は以下の通りです.
.text機械語命令(例:pushq %rbp)を置くセクション.data初期化済みの静的変数の値(例:0x1234)を置くセクション.rodata読み込みのみ(read only)の値(例:"hello\n\0")を置くセクション
例えば,以下のアセンブリコードfoo.sがあるとします
(.rodataセクションを指定する際は,.sectionが必要です).
# foo.s
.text # .textセクションに出力せよ
pushq %rbp
movq %rsp, %rbp
.data # .dataセクションに出力せよ
.long 0x11223344
.section .rodata # .rodataセクションに出力せよ
.string "hello\n"
このfoo.sをアセンブラが処理すると以下になります(以下の図を見ながら読んで下さい).
-
pushq %rbpを2進数にすると0x55,movq %rsp, %rbpを2進数にすると0x48 0x89 0xe5なので, これら合計4バイトを.textセクションに出力します. -
.dataは「.dataセクションに出力せよ」.longは「指定したデータを4バイトの2進数として出力せよ」という意味です.0x11223344を2進数にすると0x44 0x33 0x22 0x11なので これら4バイトを.dataセクションに出力します. (出力が逆順になっているように見えるのは x86-64がリトルエンディアンだからです.) -
.section .rodataは「.rodataセクションに出力せよ」.stringは「指定した文字列をASCIIコードの2進数として出力せよ」という意味です."hello\n"を2進数にすると0x68 0x65 0x6c 0x6c 0x64 0x0a 0x00なので, これら7バイトを.rodataセクションに出力します. (最後の'\0'はヌル文字です.C言語では文字列定数の最後に自動的に ヌル文字が追加されますが,アセンブリ言語では必ずしもそうではありません..stringはヌル文字を追加します. 一方,(ここでは使っていませんが).asciiはヌル文字を追加しません). ASCIIコードはman asciiで確認できます.
.bssセクションは?
.text,.data,rodataに加えて,.bssセクションも代表的なセクションですが,
ここでは説明を省略しました.
.bssセクションは未初期化の静的変数の実体を格納するセクションなのですが,
ちょっと特殊だからです.
未初期化の静的変数はゼロで初期化されることになっているので,
バイナリファイル中では(サイズの情報等をのぞいて)実体は存在しません.
プログラム実行時に初めてメモリ上で.bssセクションの実体が確保され,
その領域はゼロで初期化されます.
add5.s中のadd5:,.globl add5,.type add5, @function,.size add5, .-add5
add5:はラベルの定義
add5:はadd5というラベルを定義しています.
ラベルはアドレスを表しています.
もっと具体的には「ラベルは,そのラベル直後の機械語命令や値が,
メモリ上に配置された時のアドレス」になります.
例えば,次のアセンブリコードがあり,
add5:
pushq %rbp
このpushq %rbp命令の2進数表現0x55が0x1234番地に置かれたとします.
この時,ラベルadd5の値は0x1234になります.
(ここでは話を単純化しています.ラベルの値が最終的に決まるまで,
再配置(relocation)などの処理が入ります)
ラベルの参照
で,大事なラベルの使い方(参照)です.
機械語命令のニモニック中で,アドレスを書ける場所にはラベルも書けるのです.
例えば,関数をコールする命令call命令でadd5関数を呼び出す時,
以下の2行はどちらも同じ意味になります.
ラベルadd5の値は0x1234になるからです.
(ここでも話を単純化しています.関数や変数のアドレスは
絶対アドレスではなく,相対アドレスなどが使われることがあるからです).
call 0x1234
call add5
どちらの書き方でも,アセンブラのアセンブル結果は同じになります. (もちろん通常はラベルを使います.具体的なアドレスを使って アセンブリコードを書くのは人間にとってはつらいからです).
記号表がラベルを管理する
アセンブラはラベルのアドレスが何番地になるかを管理するために, アセンブル時に記号表(symbol table)を作ります. 記号表中の情報は割と単純で,主に以下の6つです.
| アドレス | 配置される セクション | グローバル か否か | 型 | サイズ | ラベル名 (シンボル名) |
|---|---|---|---|---|---|
0x1234 | .text | グローバル | 関数 | 15 | add5 |
ここで,add5.sのラベルadd5が
- 配置されるセクションが
.textなのは,ラベルの定義add5:の前に.textが指定されているから - グローバルなのは,
.globl add5と指定されているから - 関数という型なのは,
.type add5, @functionと指定されているから - サイズが15バイトなのは,
.size add5, .-add5と指定されているから (サイズ15バイトは.-add5から自動計算されます)
です. ここでグローバルの意味は,C言語のグローバル関数やグローバル変数と(ほぼ)同じです. グローバルなシンボルは他のファイルからも参照できます.
ラベル or シンボル?
アセンブラが扱うシンボルのうち,アドレスを表すシンボルのことをラベルと呼んでいます.
シンボルはアドレス以外の値も保持できます.
つまりシンボルの一部がラベルであり,add5は関数add5の先頭アドレスを表すシンボルなのでラベルです.
.-add5 とは
.-add5はアドレスの引き算をしています..は特別なラベルで「この行のアドレス」を意味します.add5はadd5:のアドレスを意味します.
ですので,.-add5は「最後のret命令の次のアドレスから,
最初のpushq %rbp命令のアドレスを引いた値」になります.
つまり引き算の結果は「関数add5中の機械語命令の合計サイズ(単位はバイト)」です.
nmコマンドを使うと記号表の中身を表示できます.
$ nm ./a.out |egrep add5
0000000000001234 T add5
大文字Tは「.text中のグローバルシンボル」であることを意味しています.
(小文字tだと「.text中のグローバルではないシンボル」という意味になります).
このnmの出力では「add5が関数」という情報とサイズが表示できていません.
readelfコマンドを使うと,❶関数であることとサイズが❷15バイトであることを表示できます.
$ readelf -s ./a.out | egrep add5
1: 0000000000001234 ❷15 ❶FUNC GLOBAL DEFAULT 1 add5
readelfコマンドとは
objdumpは汎用のコマンド(ELFバイナリ以外のバイナリにも使える)ため,
ELF特有の情報を表示できないことがあります.
ELF専用のコマンドであるreadelfを使えば,ELF特有の情報も表示できます.
例えば,以下ではreadelfを使って記号表(❶.symtab)のセクションがあることを確認できました.
$ readelf -S add5.o セクションヘッダを表示
There are 12 section headers, starting at offset 0x258:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
0000000000000013 0000000000000000 AX 0 0 1
[ 2] .data PROGBITS 0000000000000000 00000053
0000000000000000 0000000000000000 WA 0 0 1
[ 3] .bss NOBITS 0000000000000000 00000053
0000000000000000 0000000000000000 WA 0 0 1
(中略)↓これが記号表 (symbol table)
[ 9]❶.symtab SYMTAB 0000000000000000 000000d8
00000000000000f0 0000000000000018 10 9 8
add5.s中のpushq %rbp,movq %rsp, %rbp,popq %rbp
movq %rsp, %rbp
%rspと%rbpはどちらもレジスタです.
(GNUアセンブラではレジスタの名前の先頭に必ず%が付きます).
レジスタはCPU内の高速なメモリです.CPUはメモリにアクセスするよりも,
はるかに高速にレジスタにアクセスできます.
%rspと%rbpはどちらも8バイト長のデータを格納できます.
movq %rsp, %rbpという機械語命令は
「%rspレジスタの値を%rbpにコピーする」という命令です.
movqのmovは「move (移動)」,qは「処理するサイズが8バイト」であることを意味しています.
(moveといいつつ,実行内容はコピーです.%rspに古い値が残るからです.)
なぜqが8バイト?
qはクアッドワード(quad word)の略だからです. 以下の通り,クワッドワードは「ワード2バイトの4個分」なので8バイトになります.
- ワード(word)はバイト(byte)と同様に情報量の単位ですが, ワードが何バイトかはCPUごとに異なります. x86-64ではワードは2バイトです. x86の元祖であるIntel 8086が16ビットCPUだったことに由来します.
- クアッド(quad)は4を意味します. 例えば,quadrupleは「4倍の」,quad bikeは「4輪バイク」を意味します.
仮にmovq %rsp, %rbpを実行する前に,
%rspの値が0x11223344,%rbpの値が0x55667788とします.
movq %rsp, %rbpを実行すると,
%rspの値が%rbpにコピーされるので,
%rspの値も%rbpの値も0x11223344になります.
要するに,movq命令はC言語の代入文と同じです.
pushq %rbpとpopq %rbp
pushq %rbpは「スタックに%rbpの値をプッシュする」機械語命令です.
以下の図のように,%rbp中の値をスタックの一番上にコピーします.
スタックはコピー先の部分を含めて上に成長します(赤枠の部分がスタック全体).
popq %rbpは「スタックからポップした値を%rbpに格納する」という機械語命令です.
以下の図のように,スタックの一番上の値を%rbpにコピーします.
スタックはコピー元の部分だけ下に縮みます(赤枠の部分がスタック全体).
これだけだと,pushq %rbpやpopq %rbpの役割がよく分かりませんね.
実はこの2つの命令は以下で説明するスタックフレームの処理に関係しています.
データ構造としてのスタック
スタック(stack)は超基本的なデータ構造であり, 以下の図の通り,プッシュ操作とポップ操作でデータの格納と取り出しを行います.
- プッシュはスタックの一番上にデータを格納します
- ポップはスタックの一番上からデータを取り出します
最後に格納したデータが,取り出す時は先に取り出されるので, 後入れ先出し方式(LIFO: last in first out)とも呼ばれます.
スタックは関数呼び出しの実装に便利なデータ構造です. 関数呼び出しからリターンするときは,呼び出された順番とは逆順にリターンするからです.
キューqueueは?
ちなみに超基本的なデータ構造としてキュー(queue)も重要です. こちらは先に格納したデータが,先に取り出されるので 先入れ先出し方式(FIFO: first in first out)になります.
スタックとスタックフレーム
スタックとはプロセス(実行中のプログラム)が使用するメモリの領域の1つです. ここでのスタックは関数呼び出しのためのスタックなので, コールスタック(call stack)と呼ぶのが正式名称なのですが, 慣習に習って本書でも単にスタックと呼びます.
関数を呼び出すと,スタックフレームというデータがスタックに追加(プッシュ)されて, スタックは上に成長します.その関数からリターンすると, そのスタックフレームはスタックから削除(ポップ)されて縮みます. スタックフレームは関数呼び出し1回分のデータで, 局所変数,引数,返り値,戻り番地(リターンアドレス),退避したレジスタの値などを含みます.
例えば,main関数がadd5関数を呼び出して,add5からリターンすると以下の図になります.
%rspと%rbpは一番上のスタックフレームの上下を指す
さて,ここでようやく%rspレジスタと%rbpレジスタの出番です.
実は%rspと%rbpは以下の図のように,
スタック上の一番上のスタックフレームの上下を指す役割を担っています.
「レジスタがスタックを指す」というのは具体的に以下の図の状態です.
つまり,
スタックフレームの一番上のアドレス(例えば0x11223344)が
%rspに入っていて,%rspの値をそのアドレスとして使う意図がある場合,
「%rspはスタックフレームの一番上を指す」と言い,
上の図のように矢印で図表現します.
(%rbpも同様です)
%rspは常にスタックの一番上を指す
pushq命令で
プッシュすると%rspはプッシュしたデータの一番上を指すようにずれるので,
%rspは常にスタックの一番上(スタックトップ)を指します.
また,%rbpをプッシュしたので下図のように
プッシュした値もスタックフレームの一番下を指しています.
同様にpopq命令でポップした時はポップで取り出したデータ分だけ
%rspが指す先は下にずれて,やはり%rspはスタックトップを指します.
下図では保存した%rbpの値をポップして%rbpに格納したので,
この時だけ「ひとつ下のスタックフレームの一番下」を%rbpは指しています
(が,通常,この直後にリターンして一番上のスタックフレームは破棄されます.
ですので,すぐに「%rspと%rbpは常に一番上のスタックフレームの上下を指す」
という状態に戻ります.)
pushq %rbp と movq %rsp, %rbp は新しいスタックフレームを作る
関数を呼び出すと,その関数のための新しくスタックフレームを作る必要があります. 誰が作るのかというと「呼び出された関数自身」が作ります(これはABIが定める事項です).
ここでは関数mainが関数add5をcall命令で呼び出すとして説明します.
main:
...
call add5
add5:
pushq %rbp
movq %rsp, %rbp
これらの命令を実行した時のスタックの様子は以下の図のとおりです.
(「call前」等のボタンを押して,図を切り替えて下さい)
一つずつ説明していきます.
call命令実行前はmain関数が一番上のスタックフレームです. その上下を%rspと%rbpが指しています.call命令を実行してadd5関数に実行を移す際に,call命令はスタック上に戻り番地(リターンアドレス)をプッシュします. 戻り番地とは「関数からリターンした時にどのアドレスに実行を戻せばよいか」 を表す番地です.この場合ではcall add5命令の次のアドレスが戻り番地になります.push %rbp命令を実行すると,今の%rbpレジスタの値をスタック上にプッシュします. 上の説明と見比べて下さい. 新しいスタックフレームを作る際に,%rbpに新しい値を設定する必要があるために, 今の%rbpの値をスタック上に退避(保存)するため,pushq %rbpが必要となります.- 次に
movq %rsp, %rbpを実行します. 実はadd5のスタックフレームはとても小さくて「古い%rbp」しか入っていません. ですので,%rspの値を%rbpにコピーすれば, 「add5のスタックフレームの上下を%rspと%rspが指している」という状態にできます. この動作も上で説明したので見比べて下さい.
以上で,add5のための新しいスタックフレームを作れました.
popq %rbpは今のスタックフレームを捨てる
これは前節での説明のちょうど逆になります.
popq %rbp
ret
を実行すると,スタックフレームは以下の図になります.
-
popq %rbpの実行前は,スタックトップ付近はこの図の状態になっています. (コンパイラがこの図の状態になるようにアセンブリコードを出力します. 自分でアセンブリコードを書く場合は,この図の状態になるように正しくプログラムする必要があります) 「この図の状態」をもう少し説明すると以下になります.- スタックトップには 古い
%rbpが格納されていて, その 古い%rbpは1つ前のスタックフレームの一番下を指している. - スタックトップのひとつ下には戻り番地が格納されている.
- さらにその下には
add5を呼び出した関数(ここではmain)のスタックフレームがある.
- スタックトップには 古い
-
popq %rbpを実行すると,%rbpはmain関数のスタックフレームの一番下を 指すようになります.(上の説明と合わせて読んで下さい.) また,ポップの結果,%rspが指す先が下にずれて,戻り番地を指すように変わりました. -
ret命令はスタックトップから戻り番地をポップして,次に実行する命令のアドレスをポップした戻り番地に設定します.スタックの状態はadd5を呼び出す前の状態に戻りました.
「この図の状態」の例外
全てのスタックフレームは「古い`%rbp`」で数珠つなぎ
実は下の図のように全てのスタックフレームは「古い%rbp」で数珠つなぎ,
つまり線形リスト(linked list)になっています
戻り番地とプログラムカウンタ
一般的にCPUはプログラムカウンタと呼ばれる特別な役割を持つレジスタを備えています.
プログラムカウンタは「次に実行する機械語命令のアドレス」を保持します.
そして,ret命令などでプログラムカウンタ中のアドレスを変更すると,
「次に実行する機械語命令のアドレス」を変更できるのです.
x86-64では%ripレジスタがプログラムカウンタです.
ret命令はスタックをポップして取り出した戻り番地を
プログラムカウンタ%ripに格納することで,「関数からリターンする」
(つまり,call add5命令の直後の命令を次に実行する)という動作を実現しています.
add5.s中の movl %edi, -4(%rbp), movl -4(%rbp), %eax, addl $5, %eax
ここでは以下の3命令を説明します.
直感的にはこの3命令で「n + 5」を計算しています.
movl %edi, -4(%rbp)
movl -4(%rbp), %eax
addl $5, %eax
-
まず
-4(%rbp)を説明します. これは「%rbp中のアドレスから4を引いた数」をアドレスとしてメモリを 読み書きすることを意味しています.以下の図はスタックをより正確に描いています.- メモリは1バイトごとにアドレスが付いています.
古い
%rbpや戻り番地のデータはそれぞれ8バイトなので, アドレス8つ分(つまり8バイト)の場所を占めています. - 多バイト長のデータはそのデータが占めている先頭のアドレスを使って
メモリを読み書きします.(本書の図ではメモリの0番地が常に上にあることを思い出してください).
ですので,1バイトごとのアドレスで考えると,
%rbpはスタックフレームの 一番下を指していません. - そして,
-4(%rbp)は「%rbpから4を引いたアドレスのメモリ」ですので, 以下の図で-4(%rbp)が指している場所を先頭とするメモリ領域になります.
- メモリは1バイトごとにアドレスが付いています.
古い
-
次に
%ediと%eaxについて説明します.- 以下の図のようにx86-64には8バイト長の
%rdiと%raxという 汎用レジスタがあります(他にも汎用レジスタはありますがここでは割愛). その右半分にそれぞれ%ediと%eaxという名前が付いています.%ediと%eaxは4バイト長です. %rdiレジスタは関数呼び出しでは第1引数を渡すために使われます.add5の第1引数nはint型で,この場合は4バイト長だったため,%ediにnの値が入っています.%raxレジスタは関数呼び出しでは返り値を返すために使われます.add5の返り値の方がint型なので,%eaxに値を入れてから 関数をリターンすれば,返り値が返せることになります.
- 以下の図のようにx86-64には8バイト長の
- 次に以下の2つの命令を説明します.
movl %edi, -4(%rbp)
movl -4(%rbp), %eax
movlのlは4バイトのデータをコピーすることを表しています.ですので, 例えば,movl %edi, -4(%rbp)は%edi中の4バイトデータを 先頭アドレスが-4(%rbp)から4バイト分の領域 (この図で一番上の赤い部分) にコピーする命令になります.
なぜl(エル)が4バイト
l(エル)はlongの略で,GNUアセンブラでは以下の通り,longが4バイトを意味するからです. Intelマニュアルなどでは4バイトのことをdouble wordと呼びます.
| 2バイト | 4バイト | 8バイト | |
|---|---|---|---|
| GNUアセンブラ | short | long | quad |
| Intelマニュアル | word | double word | quad word |
-
この2つの命令で「
%edi中の4バイトを-4(%rbp)にコピー」して,次に 「-4(%rbp)中の4バイトを%eaxにコピー」しています. 「%ediから%eaxに直接コピーすればいいんじゃね?」と思った方,正解です. 実はこの場合は(-4(%rbp)に格納しても使われないので)不要なのですが, コンパイラは 「引数nの実体の場所を-4(%rbp)としたので,-4(%rbp)にもnの値を格納する」という判断をしたようです. -
addl $5, %eax命令を説明します.- この命令は
%eaxの値と定数5の値を足し算した結果を%eaxに格納します. - つまり,
n + 5の結果がこの命令の実行後に%eaxに入ります. - GNUアセンブラでは定数の先頭にはドルマーク
$が付きます. ただし,-4(%rbp)の-4など,ドルマークが付かないこともあります.
- この命令は
以上でadd5.sの説明が終わりました(お疲れ様でした).
即値とは
上で$5は定数と説明しましたが,アセンブラ用語では
即値(immediate value)と呼びます.
それは機械語命令の2進数の中に
即値の値が埋め込まれており,即座に(つまりメモリやレジスタにアクセスすることなく)
値を取り出せることに由来しています.
x86-64のマニュアルなどで imm32 などが出てきます.imm32は「32ビット長の即値」を意味しています.
%rspより上のメモリ領域に勝手に書き込んで良いのか(レッドゾーン)
LinuxのABI System V ABIではOKです.
LinuxのABIでは%rspレジスタの上,128バイトの領域をレッドゾーンと呼び,
この領域には好きに読み書きして良いことになっています.
(ABIが「割り込みハンドラやシグナルハンドラが実行されても,
レッドゾーンの値は破壊されない」ことを保証しています.)
もちろん,自分自身で関数を呼び出すとレッドゾーン中の値は壊れるので,
レッドゾーンは葉関数(leaf function),つまり関数を呼び出さない関数
が使うのが一般的です.
レッドゾーンのおかげで,%rspをずらさずにメモリの読み書きができるので,
その分だけ実行が高速になります.