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

出典: フリー教科書『ウィキブックス(Wikibooks)』
移動先: 案内検索

概要[編集]

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

GASの命令は、ニーモニック 転送元 転送先の順序で記述する。 例えば、mov命令では以下のようである。

movb $0x05, %al

これは、数値5をレジスタalに転送する。

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

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は転送先のレジスタを指すオペランドのサイズが暗黙の内に指定されていることとする。

プレフィクス[編集]

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

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

アドレスを示すオペランドは、最大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に格納する


紹介[編集]

この章では、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

このコマンドによりC言語のファイルはコンパイルされ、「hello_c.exe」という名前を持つ実行可能なファイルが生成される。もしエラーが発生したら、「hello.c」ファイルの内容が正しいか確認すること。

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

./hello_c.exe

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

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

gcc -S hello.c

これにより「hello.s」というファイルが生成された (「.s」は、GNUシステムでアセンブリ言語ファイルに使われる拡張子である)。このアセンブリファイルを実行ファイルにコンパイルしてみよう。

gcc -o hello_asm.exe hello.s

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

./hello_asm.exe

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

「hello.s」の内部はどのようになっているのか見てみよう。

        .file   "hello.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    $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を使った場合に生成されたものである。

ピリオドから始まる行「.file」「.def」「.ascii」などは、アセンブラディレクティブ(疑似命令; アセンブラへこのファイルをどのようにアセンブルするかを伝えるコマンド)である。 コロンで終わる行「_main:」などは、ラベルあるいはコード内での名前付き位置である。 これら以外の行は、アセンブリ言語の命令である。

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

        .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


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

        .text

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

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

このコードは、ラベルを宣言し、生のASCIIテキストを、プログラム中のラベルの場所から始まる位置に置いている。 「\12」は、ラインフィード文字(LF)を意味しており、「\0」は文字列の最後に置かれるヌル文字である。 Cのルーチンはヌルで終わる文字列があることで分かる。 私たちはCの文字列ルーチンを呼び出そうとしているので、ヌルがここに必要である。

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

.globl _main

この行はアセンブラに「_main」はグローバルラベルであると伝えている。 グローバルラベルはプログラムの他の部分が見ることができるようにする。 この場合、リンカは「_main」ラベルを見ることのできる必要がある。 リンクされたプログラムのスタートアップコードは「_main」をサブルーチンとして呼び出す。

_main:

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

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

これらの行はEBPの値をスタックに保存し、ESPの値をEBPにコピーし、ESPから8を引いている。 それぞれのオペコードの最後の「l」は、「ロング」(32ビット)のオペランドにはたらくバージョンのオペコードを使おうとすることを示している。 通常、アセンブラはオペランドで適切なオペコードを選ぶことができるが、単に安全のためである。 「l」「w」「b」やほかのサフィックスを含めるのは良い考えである。 パーセント記号は、レジスタの名前を示し、ドル記号はリテラル値を示す。 この一連の命令は、ローカル変数をスタックに保存するサブルーチンの最初として典型的なものである。 EBPはベースレジスタとしてローカル変数を参照するのに使用される。 そして、値がESPから引かれ、スタック上の場所に保持される。 (インテルのスタックは、メモリ上の位置をアドレスの大きい方から小さい方へ使って行く) この場合、スタックには8バイトが確保されている。 後に、なぜこの場所が必要になるか見ることとになる。


        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

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

        movl    $0, %eax

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

        leave

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

        ret

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


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

注意してほしいのは、私たちは「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 を見ると良い。


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

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

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