X86アセンブラ/GASでの文法

出典: フリー教科書『ウィキブックス(Wikibooks)』
ナビゲーションに移動 検索に移動

概要[編集]

このページに含まれている例は、GNU ASで使われているAT&Tアセンブリ文法を使って作られている。 AT&T記法を使う主な利点は、GCCインラインアセンブリの文法と互換性があるということである。 また、AT&T記法はx86アーキテクチャでの操作を表現するために使われるだけではない。 例えば、NASMはアセンブリのニーモニックやオペランド、アドレッシングモードを表現するのに異なる文法を採用しているし、HALもまた異なる文法を採用している。 AT&T記法は、Unix-likeオペレーティングシステムでは標準であるが、アセンブラによってはインテル記法を使用しているし、インテル記法も選択することができる。GAS自身もこの両方の記法を使うことができる。

語順[編集]

GASの命令は、(GAS標準である)AT&T構文ではニーモニック 転送元 転送先の順序で記述する。 例えば、mov命令では以下のようである。

movb $0x05, %al

これは、数値5をレジスタalにコピーする。

※ movの語源は移動(move)だが、作業内容は(現代でいう)「コピー」。movlを使っても、転送元のデータは失われずに、残っている。アセンブラの開発当初の1950年代あたりは、まだ世間にコピー機とかのOA機器の普及してなさそうな時代だということを念頭にしよう。

インテル構文では語順が異なる。


「$0x」の「0x」とは、16進数という意味。つまり「$0x」なら、16進数の定数の宣言である。「x」とは、16進数を意味する英単語 hexadecimal の3文字目のことだろう。

16新数の「5」は、10進数でも「5」なので、ここでは気にしないでおく。

「0x」がついてなければ、通常のアセンブラでは10進数である。


プレフィクス[編集]

AT&T構文では、レジスタを参照する場合には、レジスタ名の前に「%」をプレフィクスとして付けなくてはいけない。 同様に定数の場合には、AT&T構文ではプレフィクスとして「$」が必要となる。

これらにプレフィックスがついていないコードがあれば、(AT&T構文ではなくて)インテル構文。

とにかく、AT&T構文ではレジスタ名と定数にプレフィックスがつく。

movb $0x05, %al

とは、『数値「5」を、レジスタ「al」に、コピーせよ』という意味。

なおmovbの末尾のbは「バイト長」(8ビット)のこと。

覚えづらいので、日本人の場合、レジスタの「レ」の字に「%」の斜め棒が似てるとか、「$」は定数は英語でconstantで3文字目がsだからとか、コジつけて暗記しよう。


紹介[編集]

この章では、GASの簡単な紹介をする。 GASはGNUプロジェクトの一部であり、以下のような素晴しい特徴がある。

  • 無料で入手できる。
  • 多くのオペレーティングシステムで使用できる。
  • GNU Cコンパイラ(gcc)やGNU linker(ld)といった他のGNUプログラミングツールとの素晴しく連携している。

あなたがLinuxオペレーティングシステムを使っているならば、既にGASはインストールされている可能性がある。 Windowsを使っているならば、CygwinMingwを使うことで、GASと他のプログラミングに有用なツールをインストールすることができる。 以降では、あなたがGASをインストールし、コマンドラインインターフェースを開くことができ、ファイルを編集できることを前提とする。


C言語のコードからアセンブリコードを生成する[編集]

アセンブリ言語はCPUが実行できる操作に直接対応しているので、注意深く書かれたアセンブリルーチンは、例えばC言語のような高水準言語で書かれた同機能のルーチンよりずっと高速に実行することができる。 その一方で、アセンブリルーチンはC言語で同機能のルーチンを書くよりも手間がかかるのが普通である。 そのため、高速に動作するプログラムを手早く書く典型的な方法は、初めはプログラムを書きやすくデバッグも容易な高水準言語で書き、特定のルーチンをアセンブリ言語で書き直して高速化するというものである。 C言語で書かれたルーチンをアセンブリ言語で書き直す最初のステップとして良いのは、Cコンパイラが自動的に生成したアセンブリ言語のコードを使うことである。 これにより正常にコンパイルできるアセンブリ言語コードが手に入るだけでなく、このアセンブリ言語で書かれたルーチンが想定通りに動くことも確実である。 アセンブリ言語コードを生成するのには、GNU Cコンパイラを使うことにしよう。GASのアセンブリ言語の文法を確認するのが目的である。

これは古典的な「Hello, world」プログラムをC言語で書いたものである。

#include <stdio.h>

int main(void) {
    printf("Hello, world!\n");
    return 0;
}

これを「Hello.c」というファイル名で保存し、コマンドプロンプトで以下のように入力しよう。

gcc -o hello_c.exe hello.c

引数「-o」により、オブジェクトファイル(=コンパイル済みのファイルのこと)が作成されている。よって、このコマンド(「gcc -o hello_c.exe hello.c」)によりC言語のファイルはコンパイルされ、「hello_c.exe」という名前を持つ実行可能なファイルが生成される。もしエラーが発生したら、「hello.c」ファイルの内容が正しいか確認すること。

プロンプトに以下のように入力してみよう。

./hello_c.exe

そうすると、プログラムは「Hello, world!」とコンソールに表示するはずだ。

ここまでで「hello.c」は正確に入力され、望んだ動作をすることが分かった、では、同様のx86アセンブリ言語を生成してみよう。以下のように入力してみよう。

gcc -S hello.c

引数「-S」により、機械語への変換(「11010101011」 のような文字列への変換)をしないで、直前の段階までの変換(「movl %esp, %ebp」 みたいな文字列への変換)を行う。 これにより「hello.s」というファイルが生成された (「.s」は、GNUシステムでアセンブリ言語ファイルに使われる拡張子である)。

※ Windows版GCCのMinGWだけでなく、Linuxでgccを使っても、上記コマンド(gcc -S hello.c)で自動的に拡張子「.s」がついた「hello.s」が作成されて中身はアセンぶりコードになっている(Fedora31で確認)。

なお、「movl %esp, %ebp」 みたいな、人間が読みやすいように英数字の文字列への変換(このような状態を「ニーモニック」という)されたプログラムのあつまりを「アセンブリコード」または、「アセンブリ言語」という。

狭い意味での「アセンブル」とは、このアセンブリコードを「「11010101011」 のような文字列への変換をすることである。


このアセンブリファイルを実行ファイルにコンパイルしてみよう。

gcc -o hello_asm.exe hello.s

(ここで、gccがアセンブリ(as)とリンカ(ld)を呼び出してくれていることに注意。) 以下のようにプロンプトに入力してみよう。

./hello_asm.exe

このプログラムも「Hello, world!」とコンソールに表示する。驚くべきことではない。C言語で書いたコードをコンパイルしたのと同じことである。

「hello.s」の内部はどのようになっているのか見てみよう。 下記のコードは主に、Windows版GCC(MinGWなど)で出力した場合である。Linux上では、多少、用語が違っている。


        .text
LC0:
        .ascii "Hello, world!\12\0"
.globl _main
_main:
        pushl   %ebp
        movl    %esp, %ebp
        subl    $8, %esp
        andl    $-16, %esp
        movl    $0, %eax
        movl    %eax, -4(%ebp)
        movl    -4(%ebp), %eax
        call    __alloca
        call    ___main
        movl    $LC0, (%esp)
        call    _printf
        movl    $0, %eax
        leave
        ret
        .def    _printf;        .scl    2;      .type   32;     .endef

この「hello.s」の内容は、インストールされているGNUツールのバージョンによって違う可能性がある。これは、Cygwinでgcc 3.3.1を使った場合に生成されたものである。

もし、上述のコードでエラーが出るなら、新規にC言語のコードからアセンブリ言語に変換されたソースを生成してもらいたい。


ピリオドから始まる行「.file」「.def」「.ascii」などは、アセンブラディレクティブ(疑似命令; アセンブラへこのファイルをどのようにアセンブルするかを伝えるコマンド)である。

「.text」は、この直後の行からが、機械語にされる実行文であることを示しています。

つまり、「.text」は実行文を始まりを示す目印です。

これら「.file」「.def」「.text」などのコマンドは、機械語へのアセンブル時に、翻訳されない。この「.file」「.def」などの部分はアセンブリコードを機械語に変換するための装置(これが狭義の「アセンブラ」である)への命令なので、「擬似命令」という。


「LC0:」というのは、ラベルの宣言である。コロン記号「:」を除いた手前の「LC0」の部分がラベル名である。「:」記号で手前の「LC0」がラベルであることを示している。 上記コードでは、このラベル宣言により、次の命令「.ascii "Hello, world!\12\0"」に、「LC0」という名前をつけています。

なお、「_main:」もラベル宣言であり、ラベル名「_main」でラベルを宣言している。

「 pushl %ebp」から

「 .def _printf; .scl 2; .type 32; .endef」までがラベル「_main」の内容である。


コロンで終わる行「_main:」などは、ラベルあるいはコード内での名前付き位置である。

これら以外の行は、アセンブリ言語の命令である。

「.file」と「.def」ディレクティブはデバッグのためのものである。取り除くことも可能である。


なお「ebp」や「esp」の頭文字「e」とは「4バイト長」のデータであることを意味している[1]


失敗学で学ぶアセンブラ[編集]

正常に動くコードよりも、改造して正常に動かなくしたコードを見て比較したほうが、説明が分かりやすいんで、下記にダメなコードを書きます。

	.file	"test9.c"
	.text
	.def	___main;	.scl	2;	.type	32;	.endef
	.section .rdata,"dr"
LC0:
	.ascii "hello, wrong world!"
	.text
	.globl	_main
	.def	_main;	.scl	2;	.type	32;	.endef
_main:
LFB13:
	.cfi_startproc

	.cfi_def_cfa_offset 8
	.cfi_offset 5, -8

	.cfi_def_cfa_register 5 


	movl	$LC0, (%esp)
	call	_puts


	.cfi_restore 5
	.cfi_def_cfa 4, 4
	ret
	.cfi_endproc
LFE13:
	.ident	"GCC: (MinGW.org GCC-8.2.0-5) 8.2.0"
	.def	_puts;	.scl	2;	.type	32;	.endef


なんと、コレだけでも、WindowsでMinGWとgccで実行すると、

C:\Users\ユーザー名\nnnn>test9.o
hello, wrong world!

と表示されますが、すぐに異常停止します。

なんと、リターンに相当する「ret」を除去しても、hello文を表示します(しかし、すぐに異常停止するが・・・)。

そして、上記コードから、もしも

	movl	$LC0, (%esp)

を除去すると、

オブジェクトファイルの表示結果は

C:\Users\st\nnnn>notest.o
嘉闢(

という意味不明の文字列を表示します(一例)。



推奨されないかもしれないコード[編集]

Windowsの場合[編集]

Windows7では、なんと下記のようなコードが動いてしまう。しかし、「pushl %ebp」など下記コードではコメントアウトしていない命令をコメントアウトしてコンパイルしても、異常な結果になる。


	.file	"test8.c"
	.text
	.def	___main;	.scl	2;	.type	32;	.endef
	.section .rdata,"dr"
LC0:
	.ascii "hello, wrong world!"
	.text
	.globl	_main
	.def	_main;	.scl	2;	.type	32;	.endef
_main:
LFB13:
	.cfi_startproc
	pushl	%ebp
	.cfi_def_cfa_offset 8
	.cfi_offset 5, -8
	movl	%esp, %ebp
	.cfi_def_cfa_register 5 # komento test
	# andl	$-16, %esp
	# subl	$16, %esp
	# call	___main
	movl	$LC0, (%esp)
	call	_puts
	# movl	$0, %eax
	leave
	.cfi_restore 5
	.cfi_def_cfa 4, 4
	# ret
	.cfi_endproc
LFE13:
	.ident	"GCC: (MinGW.org GCC-8.2.0-5) 8.2.0"
	.def	_puts;	.scl	2;	.type	32;	.endef

この事から、これらコメントアウトできない命令には、特別な意味がある事が分かる。


Linuxの場合[編集]

Linux の Fedora 31 では、なんと下記のコードは動く。

下記コードには、のちの節で後述する push もなければ pop もなく、スタックフレームの確保も破棄も解放も何もしてないコードだが、実験事実として動き、ターミナル(コマンド端末)で実行すれば hello文を表示する。

リターン命令 ret すらないコードである。でも動く。事実、動くのだ。

	.file	"hello.c"
	.text
	.section	.rodata
.LC0:
	.string	"hello, 1234 world"
	.text
	.globl	main
	.type	main, @function
main:
.LFB0:
	.cfi_startproc

	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16

	.cfi_def_cfa_register 6
	movl	$.LC0, %edi
	call	puts
	movl	$0, %eax

	.cfi_def_cfa 7, 8

	.cfi_endproc
.LFE0:
	.size	main, .-main
	.ident	"GCC: (GNU) 9.2.1 20190827 (Red Hat 9.2.1-1)"
	.section	.note.GNU-stack,"",@progbits


実験結果
[ユーザー名@localhost ~]$ ./hello.o
hello, 1234 world


Linuxの場合[編集]

基本[編集]

Linuxの場合、やや用語が違うので、アセンブリコード中の用語が違ってくる。

下記のアセンブリコードは、Fedora31 Workstation で「gcc -S hello.c」実行した場合の生成されるファイル「hello.s」の中身である。もし読者が追試した場合、バージョンによって多少、コードの中身が違うかもしれない。

	.file	"hello.c"
	.text
	.section	.rodata
.LC0:
	.string	"Hello, world!"
	.text
	.globl	main
	.type	main, @function
main:
.LFB0:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	movl	$.LC0, %edi
	call	puts
	movl	$0, %eax
	popq	%rbp
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE0:
	.size	main, .-main
	.ident	"GCC: (GNU) 9.2.1 20190827 (Red Hat 9.2.1-1)"
	.section	.note.GNU-stack,"",@progbits


違いはいろいろあるし、バージョンも違うので単純には1対1対応はしないですが、 主にLinux(Fedora)の場合、

相違点

.ascii ではなく.stringである。
esp でなく rsp である。 ebp でなく rbp である。「r」はおそらくレジスタ(register)の頭文字か?
メインラベルが(Windowsの)「_main」 でなく (LinuxのFedora31では)「main」である。
printf ではなくputs である。なお、「call puts」や「call printf」などの「call」は、OSまたはC言語ライブラリの用意する命令を呼び出しています。

といった相違点があります。

いっぽう、ほぼ同じ点もあります。

レジスタの名前の前に「%」パーセント記号がつくのは同じ。
定数値の前に「$」ドル記号がつくのは同じ。
ラベル宣言はコロン記号「:」であるのも同じ。
.globl の使い方は同じ。(ただし、メインラベル(「_main」 か「main」かの問題)は違う。)
メインラベルをどこかで宣言しないといけないっぽい。(「_main」 か「main」かの違いはあるが。)

といった類似点があります。

なお「pushq」や「movq」などの末尾qは64ビットCPUパソコン用のコマンドであるという意味です。

pushやmovなど幾つかのコマンドの語感の部分もWindowsとLinuxで同じです。


なお、ささいな相違点だが、グローバルでないラベル(つまりローカルなラベル)の冒頭にドット記号を付ける[2]かどうかの違いがある。「.LC0:」みたいに、ローカルなラベルの冒頭には、Fedora 31 などではドット記号がある。

ローカルラベルの冒頭にドット記号を付けるほうがLinux流。


いろいろな実例[編集]

実例1[編集]

ところで、printf関数を増やしてみよう。

#include <stdio.h>

int main(void) {
    printf("Hello1, world!\n");
    printf("Hello2, world!\n");
    printf("Hello3, world!\n");
    return 0;
}


これをアセンブリコードにすると

	.file	"hello3.c"
	.text
	.section	.rodata
.LC0:
	.string	"Hello1, world!"
.LC1:
	.string	"Hello2, world!"
.LC2:
	.string	"Hello3, world!"
	.text
	.globl	main
	.type	main, @function
main:
.LFB0:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	movl	$.LC0, %edi
	call	puts
	movl	$.LC1, %edi
	call	puts
	movl	$.LC2, %edi
	call	puts
	movl	$0, %eax
	popq	%rbp
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE0:
	.size	main, .-main
	.ident	"GCC: (GNU) 9.2.1 20190827 (Red Hat 9.2.1-1)"
	.section	.note.GNU-stack,"",@progbits

となります。


もう、これを見ただけで、どこがどう対応してるか、よく分かりますね。

たとえば、「Hello3, world!」につづけて、さらに「good moring!」と言わせたければ、そのぶんラベルを増やして、そして、そういうput命令を追加すればいいのです。

下記のようになります。

	.file	"hellogood.c"
	.text
	.section	.rodata
.LC0:
	.string	"Hello1, world!"
.LC1:
	.string	"Hello2, world!"
.LC2:
	.string	"Hello3, world!"
.LC5:
	.string	"good moring!"
	.text
	.globl	main
	.type	main, @function
main:
.LFB0:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	movl	$.LC0, %edi
	call	puts
	movl	$.LC1, %edi
	call	puts
	movl	$.LC2, %edi
	call	puts
	movl	$.LC5, %edi
	call	puts
	movl	$0, %eax
	popq	%rbp
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE0:
	.size	main, .-main
	.ident	"GCC: (GNU) 9.2.1 20190827 (Red Hat 9.2.1-1)"
	.section	.note.GNU-stack,"",@progbits

これを実際にコンパイルしてみましょう。コマンドは下記のようになるでしょう。

[ユーザー名@localhost ~]$ gcc -o hellok_asm.exe hellogood.s
[ユーザー名@localhost ~]$ ./hellok_asm.exe

実行結果

[ユーザー名@localhost ~]$ ./hellok_asm.exe
Hello1, world!
Hello2, world!
Hello3, world!
good moring!


このような実験事実により、どうやら

call puts は、%edi の中身の値を読み取るっぽいこと.
ラベルの番号は連続してなくてもいいようである。上コード例では「.LC2:」から「.LC5:」へと番号が飛んでいる。

など、いろいろと分かります。

なお、 call puts の代わりに :call printfとすると、Fedoraでは出力文字が改行されないので、見づらくなります。


実例2[編集]

puts を2回連続したら?

もし

	movl	$.LC0, %edi
	call	puts
	call	puts

みたいに、call puts をつづけて2回使ったら、どうなるのでしょうか?

コード
	.file	"helloGood.c"
	.text
	.section	.rodata
.LC0:
	.string	"Hello1, world!"
.LC1:
	.string	"Hello2, world!"
.LC2:
	.string	"Hello3, world!"
.LC5:
	.string	"good moring!"
	.text
	.globl	main
	.type	main, @function
main:
.LFB0:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	movl	$.LC0, %edi
	call	puts
	call	puts
	movl	$.LC1, %edi
	call	puts
	movl	$0, %eax
	popq	%rbp
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE0:
	.size	main, .-main
	.ident	"GCC: (GNU) 9.2.1 20190827 (Red Hat 9.2.1-1)"
	.section	.note.GNU-stack,"",@progbits


実行結果

[ユーザー名@localhost ~]$ ./hellok_asm.exe
Hello1, world!

Hello2, world!

空行が1行ぶん、増えてますね。

どうやら call puts をすると、使い終わったデータはレジスタ内部からは消えるらしいですね。


あと、使わないラベルがあっても平気なようですね(以前のコードを流用したので「.LC2:」などの未使用のラベルが残っている)。


ラベルを2回連続で使用したら?

では、

	movl	$.LC0, %edi
	call	puts
	movl	$.LC0, %edi
	call	puts

みたいに、ラベル「.LC0」を2回使ったら、どうなるでしょうか?

実験してみましょう。

コード
	.file	"rezokurabel.c"
	.text
	.section	.rodata
.LC0:
	.string	"Hello1, world!"
.LC1:
	.string	"Hello2, world!"
.LC2:
	.string	"Hello3, world!"
.LC5:
	.string	"good moring!"
	.text
	.globl	main
	.type	main, @function
main:
.LFB0:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	movl	$.LC0, %edi
	call	puts
	movl	$.LC0, %edi
	call	puts
	movl	$.LC1, %edi
	call	puts
	movl	$0, %eax
	popq	%rbp
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE0:
	.size	main, .-main
	.ident	"GCC: (GNU) 9.2.1 20190827 (Red Hat 9.2.1-1)"
	.section	.note.GNU-stack,"",@progbits

実行結果

[ユーザー名@localhost ~]$ ./hellok_asm.exe
Hello1, world!
Hello1, world!
Hello2, world!

どうやらラベル自体は残るようです。


つまり、

putsを使うと、レジスタ %edi の内部自体からはデータは消えますが、しかし参照元のラベルなどはそのまま残ります。

コメントつきの説明[編集]

        .text
LC0:
        .ascii "Hello, world!\12\0" # ラベルの定義
.globl _main  # 「_main」がグローバルラベルであると宣言
_main: 
        pushl   %ebp           # ebpの値をスタックメモリに保存。
        movl    %esp, %ebp     # 現時点のespの値を現時点のebpに書き込んでいる。
        subl    $8, %esp       # スタックポインタから8引いている
        andl    $-16, %esp
        movl    $0, %eax
        movl    %eax, -4(%ebp)
        movl    -4(%ebp), %eax
        call    __alloca
        call    ___main
        movl    $LC0, (%esp)
        call    _printf
        movl    $0, %eax
        leave
        ret
        .def    _printf;        .scl    2;      .type   32;     .endef

hello.s を一行ずつ読む[編集]

        .text

この行は、コードのセクションの始まりを示している。 このディレクティブを使い、セクションに名前を付け、実行可能ファイル中で機械語のコードがどこへ行くかを精度良く制御することができる。これは、組み込みシステムでのプログラミングのような場合に有用である。


「.text」ディレクティブ自体は、アセンブラに以降のコードがデフォルトのセクションであることを伝える。ほとんどの場合にはこのように使うので十分である。

LC0:
        .ascii "Hello, world!\12\0"

このコードは、ラベルを名称「LC0」で宣言し、生のASCIIテキストを、プログラム中のラベルの場所から始まる位置に置いている。 「\12」は、ラインフィード文字(LF)を意味しており、「\0」は文字列の最後に置かれるヌル文字である。

ラインフィードとは、改行のこと。

もし「\12」を省いて

.ascii "Hello, world!\0"

とした場合のコードを3回実行すると、改行が行われないので、行間のスキマなく続けて実行される。

C:\Users\ユーザー名\nnnn> hello_asm.exe
Hello, world!
C:\Users\ユーザー名\nnnn> hello_asm.exe
Hello, world!
C:\Users\ユーザー名\nnnn> hello_asm.exe
Hello, world!

いっぽう、「\12」の残っている

        .ascii "Hello, world!\12\0"

の場合なら、これを3回実行すれば

C:\Users\ユーザー名\nnnn> hello_asm.exe
Hello, world!

C:\Users\ユーザー名\nnnn> hello_asm.exe
Hello, world!

C:\Users\ユーザー名\nnnn> hello_asm.exe
Hello, world!

と、改行が毎回実行されるので行間がある。


Cのルーチンはヌルで終わる文字列があることで分かる。 私たちはCの文字列ルーチンを呼び出そうとしているので、ヌルがここに必要である。

(注意! Cでの文字列は、データ型charの配列(char[])であり他の形式ではない。 しかし、文字列は多くのプログラミング言語では、一つのデータ型であるので、 このように書いておく。)

.globl _main

「 .globl」命令は、次のラベルが、外部ファイルから呼び出すことを許可する命令である。

アセンブラに「_main」はグローバルなラベルであると伝えている。


また、「_main」という名前のラベルには特別な意味があって、プログラムの開始地点などを表すラベルである。この名前を別のラベルに変えてコンパイルしても、コンパイルがエラーになる(※ Windows7環境でのMinGWとNASMで確認ずみ。2019年12月2日)。(アンダーバー「_」の無い)「main」ラベルでもダメで、アンダーバーのある「_main」ラベルで宣言しないとエラーになる。

また、グローバル宣言をしないで「_main:」ラベルとそれ以降を書いても、エラーになる。(※ 以上、Windows7環境でのMinGWとNASMで確認ずみ。2019年12月2日)


事実上、

.globl _main
_main: 

で、コードのプログラム開始地点を表す慣用句のようなものである。


なお「 .globl」命令の無いラベルは、同じオブジェクトファイルからしか呼び出せない。 つづりに注意しよう。「global」ではなく「globl」である。後ろ「bal」から「a」が抜けている。

上述のプログラムの用途の場合、リンカは「_main」ラベルを見ることのできる必要がある。 リンクされたプログラムのスタートアップコードは「_main」をサブルーチンとして呼び出す。


暗黙の前提だが、「 _main」ラベルの内容を記述する前に、まず、「 _main」がグローバルなラベルであることを宣言する必要がある。

一般に機械語やアセンブラなどのコードは、上から順番に実行していくので、まず上のほうのコードで準備や設定をしておく必要があるからだ。

さて、

_main:

について。この行は、スタートアップコードから呼び出される場所をマークする「_main」ラベルを宣言している。

さて、

        pushl   %ebp
        movl    %esp, %ebp
        subl    $8, %esp

この3つの命令は、私達のhello worldのプログラムのアセンブリコードだけでなく、ほかの一般のプログラムのアセンブリコードでもよくある、初期設定のような命令です。よく、この順番の3命令をまとめて「スタックフレームの確保」のようにいいます。

何のためにコレを行うかというと、たとえば(C言語でいう)「関数」のような機能を実装するために行います[3]。ある関数を実行している最中でも、別の場所に呼び出し元のアドレスを保存しておく必要があります。

上述の3命令は、関数の呼び出し始めの時のアドレス保存作業に相当します。なお、関数の終了時の(さっき保存したアドレスなどの)使用は(「スタックフレームの破棄」という)は、leave 命令で行います(後述する)。(※ 「破棄」というが、けっしてスタックに保存したデータを捨てるのではなくて、保存したデータを取り出して使ってから、データを上書き可能な状態にすることである。)


sub のあとの引き算する数だけが、他のプログラムのアセンブリでは違っている場合がよくありますが、それ以外のpush → mov → sub の順番は、まず他のプログラムでも、この通りです。

では、このよくある一連の3命令が、なにをしているのか、調べましょう。


なお、EBPとESPとは、スタックポインタのアドレス記憶用のレジスタです。

pushl命令は、次の指定されたアドレス(ebp)にある数値が、現在のスタックポインタ(esp)が指し示すアドレス先に保存され、さらに、自動的にスタックポインタの値が4減ります[4]。その結果、いま格納したデータの先頭を、スタックポインタが指し示します[5]

32ビットCPUパソコンなら4減ります。64ビットCPUパソコンでは違うかもしれませんので、それぞれの環境でご確認ください。

なお、poplなどのpop系コマンドの内容は、push系コマンドの逆です。   EBPが、スタックポインタの基準(base)です。もしスタックポインタについて分からなければ、一般的な大学理系の課程のオペレーティングシステム論の教材に書いてありますので、分からなければ、そこらへんを調べてください。


まず、上コードでは、pushlによってebpの値を退避させています(退避先はメモリなど適切な場所が選ばれる[6])。


スタックとは

「スタック」とは何か、おおまかにいうと、まずパソコンは、何かのデータを蓄えるとき、そのプログラムそのものを保存するためのメモリ領域を(ハードディスク領域とは別に)確保しておく必要がある。だって、画像データとか長文データとか、画素1個ずつとか文字1個ずつとかをハードディスクから毎回読み出してたら、読み込み回数が多すぎて時間が掛かるでしょ?

なので、まずメモリに、ある程度のデータをまとめてメモリにロードしておく必要があるわけです。

で、たとえば、アドレス100個ぶんのメモリ領域を確保したい、としましょう。

このためには、「メモリのアドレスの、ここからアソコまでを、プログラムの保存のために使うぞ!」と、パソコンに宣言して教えてやる必要があるわけです。


100行ぶんのデータを保存するための領域の、メモリの最初の番地と、終わりの番地を指定するわけです。

たとえば、メモリのアドレスの200番から300番まで確保したとしましょう。(植木算っぽいことは本質でないので、考慮しない。1個分の誤差があっても読者が脳内で補正してくれ)

で、保存に使える大きさは、確保した100行ぶんだけですから、1行なにかを保存するたびに、残りの保存可能なプログラム数は1個ぶん減るわけです。(Push操作)

で、慣習的に、確保した領域の終わりの300番から、1個なにかを保存するたびに、スタックポインタというのを1つアドレスを前にして299番にしていくのです。もう1個なにかを保存したら、さらに1つアドレスを前にして、298番にあわせるのです。

で、スタックポインタがもし200番に合わさってしまったら、「もう、これ以上は保存できないよ!」ってなるわけです。こういうのがスタックポインタ。


さて、使い終わったデータは保存の必要が無いし、また、他のデータを保存できるようにするために、データを廃棄する仕組みも必要そうですね。


で、たとえば仮に300番から220番まで保存されてて、220番目のデータを使い終わったとしましょう。すると、220番目のデータはもう使わないので上書きしてもいいので、221番目にスタックポインタを戻すわけです。(POP操作)


このプッシュとポップの関係は、後から保存された新しいデータのほうが、先に使われます。なので、先に保存されたデータは、取り出すのが後回しになります。

つまり、保存したデータの順番と、実行・使用するデータの順番とが逆になります。(このため、プログラムの処理みたいなのには不便。プログラムの実行順の管理には、(スタックポインタではなく)プログラムカウンタという別の仕組みがある。)


さて、プログラム処理でもスタックポインタの使い方はあって、それはサブルーチン(if文やfor文など)が入れ子構造になっている場合の処理である。(コールスタック)

たとえばfor文のなかに、別のfor文があるとか。

この場合、たとえば最初のfor文の開始(仮にこれをforAと予防)と、次のfor文(forB)の開始という順番なら、for文の終わりは、ForBが先に終わってから、あとからforAが終わることになる。

なので、積んだ順番と、取り出し(データ廃棄)の順番がちょうど逆なので、スタックポインタが使えるわけである。

サブルーチンを呼び出した際の帰り先のアドレスが、これらの場合のスタックポインタに保存されている。


(再掲)

        pushl   %ebp
        movl    %esp, %ebp
        subl    $8, %esp

sublは、減らす命令です。この行のあとに8バイト分のデータサイズのデータ1個を使用する予定なので、あらかじめスタックポインタを8減らす必要があるのです。「subl $8, %esp」によってespの値から8引いて、それをespに代入しています。espの値の内容とは、スタックポインタの指し示すアドレスの番号なので、結局「subl $8, %esp」によってスタックポインタの指し示すアドレス番号が8減らされることになります。


これらの行はEBPの値をスタックに保存し、ESPの値をEBPにコピーし、ESPから8を引いている。

「movl」とは、由来は move (ムーブ、移動)ですが、作業内容は(今でいう)コピーです。


「pushl」や「muvl」「subl」などのそれぞれのオペコードの最後の「l」は、「ロング」(32ビット)のオペランドにはたらくバージョンのオペコードを使おうとすることを示している。

また、このようにオペコードの末尾につく、特別な意味を持った1文字ていどの文字のことをオペランドという。

通常、アセンブラはオペランド(末尾の文字)で適切なオペコードを選ぶことができるが、単に安全のためである。 「l」「w」「b」やほかのサフィックス(英語で「添え字」という意味)を含めるのは良い考えである。 パーセント記号は、レジスタの名前を示し、ドル記号はリテラル値(定数)であることを示す。

この一連の命令は、ローカル変数をスタックに保存するサブルーチンの最初として典型的なものである。 EBPはベースレジスタとしてローカル変数を参照するのに使用される。 そして、値がESPから引かれ、スタック上の場所に保持される。 (インテルのスタックは、メモリ上の位置をアドレスの大きい方から小さい方へ使って行く)

この場合、スタックには8バイトが確保されている。 後に、なぜこの場所が必要になるか見ることとになる。

なお、スタックフレームを確保するための命令として、インテルでは enter という専用の命令が容易されており、引数を適切につける事により上述の3コマンドを代替することもできる。スタックフレームに関して enter 命令と leave 命令とは、機能が反対どうしで対(つい)になっている。だが、このエンター命令は、あまりサポート状況がよくないらしく、たとえばgcc -S 系コマンドでソースを自動生成しても enter コマンドは使われないのが普通であったりする。

なので、本書では enter 命令についてのこれ以上の説明を省略する。(※ 市販の文献でも、あまり詳しく書かれていない。カットシステムの日向さんの本(『プログラミングの不思議を解く』には、付録225ページにあるが、説明は少ない(しかも巻末索引にenterの項目は無い)。マイナビ出版の内田さんの本『自作エミュレータで学ぶX86アーキテクチャ』にも、まったく書かれていない。わずかに翔泳社の『低レベルプログラミング』で数行ばかり触れられているだけである[7]。しかも、その『低レベルプログラミング』には巻末索引にはleave命令が書いてない。索引でleave命令で調べたページ先で、ついでにenterが見つかるだけである。)



        andl    $-16, %esp

このコードは、EPSと0xFFFFFFF0を「and」している。 つまり、次の最も低位の16バイトごとの境界にスタックを配置している。 Mingwのソースコードを見てみると、これは適切に配置されたアドレスにのみ作用するSIMD命令が、「_main」ルーチン内に現われるために存在するものである。 私たちのルーチンにはSIMD命令は含まれていないので、この行は不必要である。

        movl    $0, %eax
        movl    %eax, -4(%ebp)
        movl    -4(%ebp), %eax

このコードは、ゼロをEAXにコピーし、EAXをEBP-4の値のメモリの場所にコピーし、それをEAXへ書き戻している。ここで、EBP-4は最初にスタックに格納されていた値を一時的に保存しておくための場所として使われている。 このコードは最適化されていないことが分かる。 丸カッコ ( ) で囲んでいるのは、メモリの場所を示しており、括弧で囲まれた部分の前にある数字は、メモリの場所からのオフセットを示している。

        call    __alloca
        call    ___main

これらの函数はCライブラリのセットアップの一部である。

私たちはCライブラリ中の関数を呼び出しているので、これらは必要なはずである。 これらが厳密にどうはたらくかということは、プラットフォームとインストールされているGNU toolsに依存する。

        movl    $LC0, (%esp)
        call    _printf

最後になるが、このコードは私たちのメッセージを印字する。「call _printf」でCライブラリのprintf関数を呼び出している。


まず、ASCII文字列の場所をスタックの最上部にコピーする。 Cコンパイラが、「popl %eax; pushl $LC0」という命令を、一つのスタックの最上部へのコピーに最適化したようである。 次に、Cライブラリの中の_printfサブルーチンを呼び出し、コンソールにメッセージを印字する。

        movl    $0, %eax

この行は、戻り値であるゼロをEAXに保存している。 Cの呼び出しのきまりでは、ルーチンを終了する時には、EAXに戻り値を保存する。

        leave

この行はサブルーチンの最後に典型的に見られるものである。leave命令はスタック上に確保された領域を、pushの1個ぶんだけ解放するために EBPをESPにコピーし、EBPに保存された値をEBPにpop する命令です。


つまり、leave 命令は、

mov esp, ebp
pop ebp

と同じ働きです[8]。主に64ビットCPUで、R8 〜 R15 が追加されている[9]

pushで確保された領域1個を解放するので、対応するpopを1回だけ行っているのです。

一般に、leave 命令は「スタックフレームの破棄」とよく説明されますが、むしろ、スタック領域の1個ぶんの「解放」と考えたほうが良いでしょう。


さて、

        ret

この行は、スタックから保存された命令ポインタ(eip)をpopすることで制御を呼び出したプロシージャに返している。

その他[編集]

コメントを追加したい場合、Windowsでは「#」を入れる。Windows版GCC(MinGW)だけでなく LinuxでもFedora31のGCCなら同様に「#」でコメント記号になる。

andl $-16, %esp # komento test

のように、どこかの行にコメントを入れてみよう。上の例の場合「 komento test」がコメント文。

そして、変更前のプログラムとの区別のため(※ Windows版で説明)

        .ascii "Hello12345, world!\12\0"

とでも、ハロー文を書き換えておこう。

gcc -o hello_asm.exe hello.s
./hello_asm.exe

で 「Hello12345, world!」 と出れば成功である。


なお、Linuxの場合やアセンブリ言語の種類によっては、セミコロン「;」がコメント(正確にはコメントアウト)の記号であることもある。(コメントアウトの記号の後ろにコメント文の本文を書く。)

しかしWindowsの場合、おそらく、すでにセミコロン「;」が別の意味で使われているからか、セミコロンでコメントしようとしようと失敗する(MinGW.org GCC-8.2.0-5)で2019年12月2日にWin7で確認)。

なおFedora 31 でもWindows同様に「;」をコメントのつもりで使ってもエラーになる。なので、Fedoraではアセンブラのコメント記号には「#」を使わなければいけない。


オペレーティングシステムと直接対話する[編集]

注意してほしいのは、私たちは「printf」のようなCライブラリーにあるファンクションコールを必要とする場合のみ、Cライブラリーセットアップルーチンを呼び出さなくてはいけないということである。 オペレーティングシステムと直接コミュニケートするならば、これらのルーチンを呼び出すのを避けることができる。 オペレーティングシステムと直接コミュニケートすることの不利な点は、移植性が失われることである。つまり、コードが特定のオペレーティングシステムにロックされてしまう。 しかし、教える目的のために、これを Windows でどのようにするのか見てみることにしよう。 これは Mingw または Cygwin でコンパイルできるCソースコードである。

#include <windows.h>

int main(void) {
    LPSTR text = "Hello, world!\n";
    DWORD charsWritten;
    HANDLE hStdout;

    hStdout = GetStdHandle(STD_OUTPUT_HANDLE);
    WriteFile(hStdout, text, 14, &charsWritten, NULL);
    return 0;
}

理想的には、「GetStdHandle」と「WriteFile」の戻り値により正しく働いたか確認したいところだが、これでも私たちの目的には十分である。 以下のようなアセンブリーコードが生成される。

        .file   "hello2.c"
        .def    ___main;        .scl    2;      .type   32;     .endef
        .text
LC0:
        .ascii "Hello, world!\12\0"
.globl _main
        .def    _main;  .scl    2;      .type   32;     .endef
_main:
        pushl   %ebp
        movl    %esp, %ebp
        subl    $4, %esp
        andl    $-16, %esp
        movl    $0, %eax
        movl    %eax, -16(%ebp)
        movl    -16(%ebp), %eax
        call    __alloca
        call    ___main
        movl    $LC0, -4(%ebp)
        movl    $-11, (%esp)
        call    _GetStdHandle@4
        subl    $4, %esp
        movl    %eax, -12(%ebp)
        movl    $0, 16(%esp)
        leal    -8(%ebp), %eax
        movl    %eax, 12(%esp)
        movl    $14, 8(%esp)
        movl    -4(%ebp), %eax
        movl    %eax, 4(%esp)
        movl    -12(%ebp), %eax
        movl    %eax, (%esp)
        call    _WriteFile@20
        subl    $20, %esp
        movl    $0, %eax
        leave
        ret

C標準ライブラリーを一切使っていないにもかかわらず、生成されたコードは、それを初期化している。 また、多数の不必要なスタック操作がされている。単純化すると以下のようになる。

        .text
LC0:
        .ascii "Hello, world!\12"
.globl _main
_main:
        pushl   %ebp
        movl    %esp, %ebp
        subl    $4, %esp
        pushl   $-11
        call    _GetStdHandle@4
        pushl   $0
        leal    -4(%ebp), %ebx
        pushl   %ebx
        pushl   $14
        pushl   $LC0
        pushl   %eax
        call    _WriteFile@20
        movl    $0, %eax
        leave
        ret

順々に解析していこう。

        pushl   %ebp
        movl    %esp, %ebp
        subl    $4, %esp

古いEBPを保ったまま、スタック上に4バイト確保する。これは、WriteFileはどこかに4バイトの値で書き込む文字数を保存しておく必要があるためである。

        pushl   $-11
        call    _GetStdHandle@4

定数STD_OUTPUT_HANDLE (つまり -11)をスタックにプッシュし、GetStdHandleを呼び出す。戻り値はEAXに入る。

        pushl   $0
        leal    -4(%ebp), %ebx
        pushl   %ebx
        pushl   $14
        pushl   $LC0
        pushl   %eax
        call    _WriteFile@20

WriteFileのパラメーターをプッシュし、WriteFileを呼び出す。 注意してほしいのは、Windowsの呼び出し規則では、パラメーターは右から左の順にプッシュされていなくてはいけないということである。 load-effective-address (lea) 命令は、EBPの値に-4を加え、表示された文字の数のためのスタックを保存した場所を与る。それは、EBX に保管され、スタックにプッシュされる。 また、注意してほしいのは、EAXはまだGetStdHandleコールの戻り値を保持しているということである。ですので単に直接それをプッシュした。

        movl    $0, %eax
        leave

ここでプログラムの戻り値をセットし、EBPとESPの値を「leave」命令を使って戻した。

補足説明[編集]

The GAS manual's AT&T Syntax Bugs sectionによると、


UnixWareのアセンブラーとおそらく他のAT&T由来のix86 Unixアセンブラーは、浮動小数点命令でソースと書き込み先レジスターが逆な命令な命令を生成する。 残念なことにgccとおそらく他の多くのプログラムはこの逆な構文を使用する。私たちはこれにはめられている。

例えば、

        fsub %st,%st(3)

では、%st(3)%st - %st(3)にアップデートされる。これは、予想されように%st(3) - %stにアップデートされるのとは異なる。 これは、2つのレジスターオペランドをとる全ての非可換な算術浮動小数点操作で起きる。つまり、読み込み元レジスターが%stであり、書き込み先レジスターが%st(i)になる。

注意してほしいのは、objdump -d -M intel も逆になったオペコードを使っていることである。よって違った逆アセンブラーをこれを確認するのに使う。 より詳しく知るには、http://bugs.debian.org/372528 を見ると良い。


アドレスオペランドの文法[編集]

アドレスを示すオペランドは、最大4個のパラメータを取ることができる。これはディスプレイスメント(ベースレジスタ, オフセットレジスタ, スケーラ)の形式をとる。 これは、インテル記法での[ベースレジスタ + ディスプレイスメント + オフセットレジスタ * スケーラ]という表記と同じ意味である。 パラメータ注の数値部分のいずれかあるいは両方は省略可能であり、同時に、レジスタ部分の一方は省略可能である。

movl    -4(%ebp, %edx, 4), %eax  # 完全な例: (ebp - 4 + (edx * 4))のアドレスの内容をeaxに転送する
movl    -4(%ebp), %eax           # よくある例: スタックの値をeaxに転送する
movl    (%ecx), %edx             # オフセットのない場合: ポインタの指す内容をレジスタに転送する
leal    8(,%eax,4), %eax         # 算術演算の例: eaxに4を掛け8を足したアドレスをeaxに格納する
leal    (%eax,%eax,2), %eax      # 算術演算の例: eaxの指す値を3倍したアドレスをeaxに格納する


なお。「#」はWindows版NASMでのコメントアウトの記号である。その行でのコメントアウト以降の文字列は、アセンブルからは除去される。 Linuxなどウィンドウズ以外の場合では、コメントアウト記号がセミコロン「;」になっている場合もあるので、適宜に応用のこと。


オペレーションサフィックス[編集]

「pushl」とか「movl」とか、プッシュ命令やムーブ命令のうしろにエル「l」がついているが、こういうのがサフィックスである。

データサイズなどを指定するためにサフィックスを指定する必要がある。

GASのアセンブリ命令では、一般にオペランドがどのサイズのデータを扱うか指定するために、b、s、w、l、q、tのいずれかの文字をオペランドの最後に付ける。これをサフィックスという。

b
バイト(8ビット)
s
ショート (16ビット整数)またはsingle(32ビット浮動小数点数)
w
ワード(16ビット)
l
ロング(32ビット整数または64ビット浮動小数点数)
q
クワッド(64ビット)
t
10バイト(80ビット浮動小数点数)

サフィックスが指定されていない場合、GASはメモリをオペランドにとる命令は使用できない。GASは転送先のレジスタを指すオペランドのサイズが暗黙の内に指定されていることとする。

他の GAS についての読み物[編集]

GNU gas ドキュメントのページで、gas についてもっと知ることができる。

http://sourceware.org/binutils/docs-2.17/as/index.html

アセンブリ言語に無い命令[編集]

GASに限った話題ではないが、アセンブリ言語の専用命令には、C言語でいう「関数」に相当する機能は無い[10]。OSから関数を呼び出すことになる。LinuxでのOSでの関数の実装は、割り込みコントローラーや割り込みテーブルの制御をして、アセンブリ言語などをつかって関数の機能を実装している[11]、といわれている。

脚注および参考文献[編集]

  1. ^ 橋本洋志ほか『組み込みユーザのためのアセンブリ/C言語読本』、オーム社、平成22年5月25日 初版 第1刷 発行、55ページ
  2. ^ Igor Zhirkov 著、古川邦夫 監訳『低レベルプログラミング』、翔泳社、2018年01月19日 初版 第1刷 発行、30ページ
  3. ^ 内田公太・上川大介 著『自作エミュレータで学ぶX86アーキテクチャ』、株式会社マイナビ (※出版社名)、2015年8月30日 初版 第1刷 発行、109ページ
  4. ^ 内田公太・上川大介 著『自作エミュレータで学ぶX86アーキテクチャ』、株式会社マイナビ (※出版社名)、2015年8月30日 初版 第1刷 発行、106ページ
  5. ^ Igor Zhirkov 著、古川邦夫 監訳『低レベルプログラミング』、翔泳社、2018年01月19日 初版 第1刷 発行、17ページ
  6. ^ Q&Aで学ぶマイコン講座(30):スタックの役割 (1/3) 2019年12月3日に閲覧
  7. ^ Igor Zhirkov 著、古川邦夫 監訳『低レベルプログラミング』、翔泳社、2018年01月19日 初版 第1刷 発行、335ページ
  8. ^ 日向俊二 著『プログラムの不思議を解く』、カットシステム(※出版社名)、2016年9月10日 初版 第1刷 発行、26ページ
  9. ^ 日向俊二 著『プログラムの不思議を解く』、カットシステム(※出版社名)、2016年9月10日 初版 第1刷 発行、237ページ
  10. ^ 平田豊 著『超例解Linuxカーネルプログラミング』、C&R研究所、2019年7月23日 初版 第1刷、76ページ
  11. ^ 平田豊 著『超例解Linuxカーネルプログラミング』、C&R研究所、2019年7月23日 初版 第1刷、76ページ