コンテンツにスキップ

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

出典: フリー教科書『ウィキブックス(Wikibooks)』

はじめに

[編集]

GAS(GNU Assembler)は、GNUプロジェクトによって開発されたアセンブラで、GNU Binutilsパッケージの一部として提供されています。Binutilsには、アセンブラ(GAS)、リンカー(ld)、およびオブジェクトファイルの操作ツール(nm、objdump、sizeなど)が含まれています。

GASは、x86、ARM、PowerPC、MIPSなどの多くのアーキテクチャに対応しており、AT&T構文とIntel構文の両方をサポートしていますが、デフォルトではAT&T構文が使用されます。

一方、GCC(GNU Compiler Collection)は、C、C++、Objective-C、Fortran、Adaなどの言語に対応したコンパイラであり、オブジェクトファイルを生成する際にGASを利用することができます。GCCはGASに依存せず、他の方法でオブジェクトファイルを生成することも可能です。 GASは、低レベルのアセンブリコードを書く際や、特定のアーキテクチャに対する詳細な制御が求められる場合に非常に有用です。また、GASを使用することで、特定のCPUアーキテクチャに最適化されたアセンブリコードを生成することができます。

GASは、GNU/Linuxディストリビューションを利用している多くのユーザーには標準でインストールされています。Windows環境で使用する場合は、CygwinMingwをインストールすることで、GASやその他の便利なプログラミングツールを利用することができます。

インテル表記、AT&T表記、およびPlan9表記

[編集]

インテル表記、AT&T表記、およびPlan9表記は、x86/x64アセンブリ言語で使用される3つの主要な表記法です。これらは、アセンブリコードの記述方法を規定する規則であり、処理系によって異なる場合があります。

以下に、各表記法についての説明と、各表記法でのMOVとADD命令のコード例を示します。

インテル表記
インテル表記は、WindowsアセンブラやMicrosoft Visual Studioで使用されます。この表記法では、ソースと宛先の順序がAT&T表記と異なります。
mov eax, 1   ; eaxに1を格納する
add ebx, eax  ; eaxの値をebxに加算する
AT&T表記
AT&T表記は、GNUアセンブラやUnix系OSで使用されます。この表記法では、ソースと宛先の順序がインテル表記に対して逆転しています。
movl $1, %eax   # %eaxに1を格納する
addl %eax, %ebx  # %eaxの値を%ebxに加算する
Plan9表記
Plan9表記は、Plan 9オペレーティングシステムで使用されます。この表記法は、AT&T表記と非常によく似ていますが、いくつかの構文の違いがあります。
MOVW $1, AX   # AXに1を格納する
ADDL AX, BX  # AXの値をBXに加算する

処理系には以下のようなものがあります:

  • AT&T表記: AT&T Assembler (as), GNU Assembler (GAS)
  • インテル表記: Microsoft Macro Assembler (MASM), Netwide Assembler (NASM), Turbo Assembler (TASM)
  • Plan9表記: Plan9 Assembler (8as), Go のビルトインアセンブラ

ただし、注意が必要なのは、アセンブラによっては複数の表記法に対応している場合があることです。また、表記法によっては、同じ命令でもオペランドの記述方法が異なる場合があります。

この記事の例題は、GNU ASで使用されているAT&Tアセンブリ構文を使用して作成されています。

C言語からアセンブリコードを生成する方法

[編集]

アセンブリ言語は、CPUの命令セットに直接対応しているため、慎重に記述されたアセンブリルーチンは、C言語などの高級言語で書かれた同じルーチンよりも高速に実行されることがあります。ただし、アセンブリルーチンは高級言語に比べ、記述と維持に多くの労力を要します。そのため、効率的なプログラム開発の一般的なアプローチは、まず高級言語でプログラムを書き、その後、性能向上のために一部のルーチンをアセンブリ言語で最適化することです。

C言語のルーチンをアセンブリに変換する初手として、Cコンパイラでアセンブリコードを生成するのは有用です。これにより、正しくコンパイルされ、意図通りに動作するアセンブリコードを得ることができます[1]

ここでは、GNUコンパイラコレクション(GCC)を使用し、GAS形式のアセンブリコードを生成します。

hello.c
#include <stdio.h>

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

このプログラムを hello.c というファイルに保存し、次のコマンドを入力します。

$ gcc -o hello_c hello.c

この操作により、hello_c という実行ファイルが作成されます。エラーが出た場合は、hello.c の内容を確認してください。

生成されたプログラムは以下のコマンドで実行できます。

$ ./hello_c
Hello, world!

アセンブリコードの生成

[編集]

次に、Cプログラムに対応する64ビットx86アセンブリコードを生成します。以下のコマンドを入力します。

$ gcc -S hello.c

これにより、hello.s というアセンブリファイルが作成されます(.s はGNUアセンブリファイルの拡張子です)。続いて、アセンブリファイルをコンパイルし、実行可能ファイルを作成します。

$ gcc -o hello_asm hello.s

最後に、生成された実行ファイルを実行します。

$ ./hello_asm

このプログラムも hello_c と同様、「Hello, world!」と表示します。

アセンブリコードの内容

[編集]

hello.s の一部を見てみましょう。

        .file   "hello.c"
        .text
        .def    __main; .scl    2;      .type   32;     .endef
        .section .rdata,"dr"
.LC0:
        .ascii "Hello, world!\0"
        .text
        .globl  main
        .def    main;   .scl    2;      .type   32;     .endef
        .seh_proc       main
main:
        pushq   %rbp
        .seh_pushreg    %rbp
        movq    %rsp, %rbp
        .seh_setframe   %rbp, 0
        subq    $32, %rsp
        .seh_stackalloc 32
        .seh_endprologue
        call    __main
        leaq    .LC0(%rip), %rcx
        call    puts
        movl    $0, %rax
        addq    $32, %rsp
        popq    %rbp
        ret
        .seh_endproc
        .ident  "GCC: (GNU) 10.2.0" 
        .def    puts;   .scl    2;      .type   32;     .endef

補足情報

[編集]

アセンブリファイルは、インストールされているGNUツールチェインのバージョンによって若干異なることがあります。

$ uname -a
MSYS_NT-10.0-19043 HOSTNAME 3.2.0-340.x86_64 2021-09-08 07:03 UTC x86_64 Msys

$ gcc -v
Using built-in specs.
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/lib/gcc/x86_64-pc-msys/10.2.0/lto-wrapper.exe
Target: x86_64-pc-msys
Configured with: /home/eguch/git.co/MSYS2-packages/gcc/src/gcc-10.2.0/configure --build=x86_64-pc-msys --prefix=/usr --libexecdir=/usr/lib --enable-bootstrap --enable-shared --enable-shared-libgcc --enable-static --enable-version-specific-runtime-libs --with-arch=x86-64 --with-tune=generic --disable-multilib --enable-__cxa_atexit --with-dwarf2 --enable-languages=c,c++,fortran,lto --enable-graphite --enable-threads=posix --enable-libatomic --enable-libgomp --disable-libitm --enable-libquadmath --enable-libquadmath-support --disable-libssp --disable-win32-registry --disable-symvers --with-gnu-ld --with-gnu-as --disable-isl-version-check --enable-checking=release --without-libiconv-prefix --without-libintl-prefix --with-system-zlib --enable-linker-build-id --with-default-libstdcxx-abi=gcc4-compatible --enable-libstdcxx-filesystem-ts
Thread model: posix
Supported LTO compression algorithms: zlib zstd
gcc version 10.2.0 (GCC)

アセンブリの行でピリオド(.)で始まるものは疑似命令です。例えば、.file.ascii などが該当します。ラベル(例:main:)は、プログラム内の位置を示し、それ以外の行は実際のアセンブリ命令です。

アセンブリ言語だけで文字列表示を行う

[編集]

先程例は、アセンブリ言語を使ってはいましたが、printf(3) を使っているのでC言語のランタイムライブラリを利用しています。

ここでは、Linux/X86な環境でLinuxのシステムコールをアセンブリコードから叩く方法で文字列表示を行ってみます。

  1. アセンブリコードの作成
    Hello Worldのアセンブリコードは以下のようになります。
    hello.s
    .section .data
    hello:
      .ascii "Hello, world!\n"
      len = . - hello
    
    .section .text
    .globl _start
    _start:
      movl $4, %eax    # writeシステムコールの呼び出し番号をeaxに設定
      movl $1, %ebx    # 標準出力のファイルディスクリプタをebxに設定
      movl $hello, %ecx   # 出力する文字列のアドレスをecxに設定
      movl len, %edx   # 出力する文字列の長さをedxに設定
      int $0x80        # システムコールを実行
    
      movl $1, %eax    # exitシステムコールの呼び出し番号をeaxに設定
      xorl %ebx, %ebx  # ステータスコードを0に設定
      int $0x80        # システムコールを実行
    
    上記のコードでは、.data セクションに "Hello, world!\n" という文字列を格納し、 .text セクションでアセンブリコードを書いています。
  2. アセンブル
    上記のアセンブリコードを hello.s という名前で保存し、次のコマンドを実行してアセンブルします。
    $ as -o hello.o hello.s
    
    これにより、アセンブリファイル hello.s からオブジェクトファイル hello.o が作成されます。
  3. リンク
    次に、オブジェクトファイル hello.o をリンクして実行可能なファイルを作成します。次のコマンドを実行します。
    $ ld -s -o hello hello.o
    
    これにより、オブジェクトファイル hello.o から実行可能ファイル hello が作成されます。
  4. 実行
    最後に、以下のコマンドで実行可能ファイルを実行します。
    $ ./hello
    
    すると、"Hello, world!" という文字列が標準出力に出力されます。

これで、Cのランタイムライブラリに依存せずに文字列を表示できました。

ただし、このコードはLinuxのシステムコールに依存しているのでLinux/X86の環境でしか実行することができず、Windows、macOS、FreeBSDなどのUNIXでは実行できません。

CPUID命令を実行する

[編集]

X86には、実行時にプログラムから「いまどんなCPUで走っているか?」を問い合わせる命令 CPUID があります。 CPUID命令は、それ自体が世代を経るごとに拡張されていますが、一番基本的なファンクション「ベンダーIDを返す」を実行してみます。 CPUID命令は、標準C言語では生成できないのでアセンブラーの出番です。

まず、雛形となるC言語のソースコードを用意します。

ccpuid.c
#include <stdint.h>
#include <stdio.h>

int main(void) {
  char vendor_id[12 + 1];
  int32_t *p = (int32_t *)vendor_id;

  p[0] = *(int32_t *)"TEST";
  p[1] = *(int32_t *)"test";
  p[2] = *(int32_t *)"TEXT";

  printf("VendorID=\"%s\"\n", vendor_id);
}

ccpuid.c を コンパイルして ccpuid.s を得ます。

% clang -S -O ccpuid.c

-O は蛇足なのですが、変更する箇所を発見しやすくするために加えました。

ccpuid.s
        .text
        .file   "ccpuid.c"
        .globl  main                            # -- Begin function main
        .p2align        4, 0x90
        .type   main,@function
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
        movabsq $8391162081026721108, %rax      # imm = 0x7473657454534554
        movq    %rax, -13(%rbp)
        movl    $1415071060, -5(%rbp)           # imm = 0x54584554
        leaq    -13(%rbp), %rsi
        movl    $.L.str.3, %edi
        xorl    %eax, %eax
        callq   printf
        xorl    %eax, %eax
        addq    $16, %rsp
        popq    %rbp
        .cfi_def_cfa %rsp, 8
        retq
.Lfunc_end0:
        .size   main, .Lfunc_end0-main
        .cfi_endproc
                                        # -- End function
        .type   .L.str.3,@object                # @.str.3
        .section        .rodata.str1.1,"aMS",@progbits,1
.L.str.3:
        .asciz  "VendorID=\"%s\"\n"
        .size   .L.str.3, 15

        .ident  "FreeBSD clang version 19.1.4 (https://github.com/llvm/llvm-project.git llvmorg-19.1.4-0-gaadaa00de76e)"
        .section        ".note.GNU-stack","",@progbits
        .addrsig

15-17行目が文字列を書き換えている部分です。この部分をCPUID命令の呼び出しに書き換えます。

ccpuid.mod.s
        .text
        .file   "ccpuid.c"
        .globl  main                            # -- Begin function main
        .p2align        4, 0x90
        .type   main,@function
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
#;      movabsq $8391162081026721108, %rax      # imm = 0x7473657454534554
#;      movq    %rax, -13(%rbp)
#;      movl    $1415071060, -5(%rbp)           # imm = 0x54584554
        pushq   %rbx
        pushq   %rcx
        pushq   %rdx
        xorl    %eax, %eax
        cpuid
        movl    %ebx, -13(%rbp)
        movl    %edx, -9(%rbp)
        movl    %ecx, -5(%rbp)
        popq    %rdx
        popq    %rcx
        popq    %rbx
        leaq    -13(%rbp), %rsi
        movl    $.L.str.3, %edi
        xorl    %eax, %eax
        callq   printf
        xorl    %eax, %eax
        addq    $16, %rsp
        popq    %rbp
        .cfi_def_cfa %rsp, 8
        retq
.Lfunc_end0:
        .size   main, .Lfunc_end0-main
        .cfi_endproc
                                        # -- End function
        .type   .L.str.3,@object                # @.str.3
        .section        .rodata.str1.1,"aMS",@progbits,1
.L.str.3:
        .asciz  "VendorID=\"%s\"\n"
        .size   .L.str.3, 15

        .ident  "FreeBSD clang version 19.1.4 (https://github.com/llvm/llvm-project.git llvmorg-19.1.4-0-gaadaa00de76e)"
        .section        ".note.GNU-stack","",@progbits
        .addrsig

CPUID命令は、EBX,EDX,ECXの3つのレジスターの値を破壊するので予めスタックに退去(18-20)してCPUIDに関連するが終わったら復帰しています(26-28)。 EAXにファンクションコードの0をセットし(21)、CPUIDを実行し(22)、EBX,EDX,ECXにセットされた値を用意した領域に書き込んでいます。

コンパイルと実行
% clang -o ccpuid ccpuid.mod.s
% ./ccpuid
VendorID="GenuineIntel"

語順

[編集]

AT&T表記は一般的に、ニーモニック 転送元 転送先という形式をしています。例えば、次のようなmov命令があります。

 movb $0x05, %al

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

アドレスオペランドの文法

[編集]

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

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

オペレーションサフィックス

[編集]

「pushl」や「movl」などの命令における末尾の「l」は、サフィックスと呼ばれ、データのサイズなどを指定するために使用されます。

AT&T表記(GAS)におけるアセンブリ命令では、オペランドが扱うデータのサイズを指定するために、一般的に「b」「s」「w」「l」「q」「t」などの文字を命令の末尾に付け加えます。これらのサフィックスは、命令が処理するデータのサイズや型を示し、命令の動作を明確にします。

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

サフィックスが指定されていない場合、アセンブラはメモリをオペランドにとる命令はサイズを特定できない。転送先あるいは転送元がレジスタの場合、レジスターオペランドのサイズからサイズを推定できる。

他の GAS についての読み物

[編集]

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

https://sourceware.org/binutils/docs-2.37/as/

脚注および参考文献

[編集]
  1. ^ コンパイラの最適化により、アセンブリ命令の順序が変更されることがありますが、全体のセマンティクスは維持されます。