はじめに 編集

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

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

一方、GCCは、GNU Compiler Collectionの略で、C、C ++、Objective-C、Fortran、Ada、およびその他の言語のコンパイラとして使用されます。GCCは、オブジェクトファイルを生成するためにGASを使用することができますが、GASに依存しない方法でもオブジェクトファイルを生成することができます。 GASは、低レベルのアセンブリ言語を書く必要がある場合や、特定のアーキテクチャの詳細な制御が必要な場合に便利です。また、GASを使用することで、特定のCPUアーキテクチャに最適化されたアセンブリ言語を生成することができます。

GASは、GNU/Linuxのディストリビューションをお使いの方は、すでにシステムにインストールされていることが多いです。Windows オペレーティングシステムのコンピュータを使用している場合は、CygwinまたはMingwをインストールすることで、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言語のルーチンをアセンブリ言語に書き換える最初のステップとしては、Cコンパイラを使ってアセンブリ言語を自動生成するのが良いでしょう。 これにより、正しくコンパイルされたアセンブリファイルが得られるだけでなく、アセンブリルーチンがあなたの意図した通りに動作することが保証されます[1]

ここでは、GAS アセンブリ言語の構文をしるために、GNU C コンパイラを使用してアセンブリ コードを生成します。

ここでは、C言語で書かれた古典的な「Hello, world」プログラムを紹介します。

hello.c
#include <stdio.h>

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

これを「hello.c」というファイルに保存して、プロンプトで次のように入力します。

$ gcc -o hello_c hello.c

これで、Cファイルがコンパイルされ、"hello_c "という実行ファイルが作成されます。エラーが発生した場合は、"hello.c "の内容が正しいことを確認してください。

これで、プロンプトで次のように入力できるようになります。

$ ./hello_c
Hello, world!

「hello.c」が正しく入力され、目的の動作をすることがわかったので、それに相当する64ビットx86アセンブリ言語を生成してみましょう。プロンプトで次のように入力します。

$ gcc -S hello.c

これで「hello.s」というファイルが作成されるはずです(「.s」はGNUシステムがアセンブリファイルに与えるファイル拡張子です)。

$gcc -o hello_asm hello.s

(なお、gccはアセンブラ(as)とリンカ(ld)を呼び出してくれます) 次に、プロンプトで次のように入力します。

$ ./hello_asm

このプログラムは、コンソールに「Hello, world!」と同じ様に表示します。驚くことではありませんが、これはコンパイルされたCファイルと同じことをしています。

それでは、「hello.s」の中身を見てみましょう。

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

「hello.s」の内容は、インストールされている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.def.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>

void cpuid_0(char vendor_id[12 + 1]) {
  vendor_id[12] = 0;
  int32_t *p = (int32_t *)vendor_id;
  p[0] = *(int32_t *)"TEST";
  p[1] = *(int32_t *)"test";
  p[2] = *(int32_t *)"TEXT";
}

int main(void) {
  char vendor_id[12 + 1];
  cpuid_0(vendor_id);
  printf("VendorID=\"%s\"\n", vendor_id);
}

main() はこのまま使い、cpuid_0() の部分に細工することになります(別ファイルとするのが筋なのですが、分割コンパイルの説明が長くなりC/アセンブラーインターフェースの部分がぼやけるので、分割はしませんでした)。

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

% clang -S -O ccpuid.c

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

ccpuid.s
       .text
        .file   "ccpuid.c"
        .globl  cpuid_0                         # -- Begin function cpuid_0
        .p2align        4, 0x90
        .type   cpuid_0,@function
cpuid_0:                                # @cpuid_0
        .cfi_startproc
# %bb.0:
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset %rbp, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register %rbp
        movb    $0, 12(%rdi)
        movabsq $8391162081026721108, %rax      # imm = 0x7473657454534554
        movq    %rax, (%rdi)
        movl    $1415071060, 8(%rdi)            # imm = 0x54584554
        popq    %rbp
        .cfi_def_cfa %rsp, 8
        retq
.Lfunc_end0:
        .size   cpuid_0, .Lfunc_end0-cpuid_0
        .cfi_endproc
                                        # -- End function
        .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
        pushq   %rbx
        subq    $24, %rsp
        .cfi_offset %rbx, -24
        leaq    -21(%rbp), %rbx
        movq    %rbx, %rdi
        callq   cpuid_0
        movl    $.L.str.3, %edi
        movq    %rbx, %rsi
        xorl    %eax, %eax
        callq   printf
        xorl    %eax, %eax
        addq    $24, %rsp
        popq    %rbx
        popq    %rbp
        .cfi_def_cfa %rsp, 8
        retq
.Lfunc_end1:
        .size   main, .Lfunc_end1-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 11.0.1 (git@github.com:llvm/llvm-project.git llvmorg-11.0.1-0-g43ff75f2c3fe)"
        .section        ".note.GNU-stack","",@progbits
        .addrsig

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

ccpuid.mod.s
        .text
        .file   "ccpuid.c"
        .globl  cpuid_0                         # -- Begin function cpuid_0
        .p2align        4, 0x90
        .type   cpuid_0,@function
cpuid_0:                                # @cpuid_0
        .cfi_startproc
# %bb.0:
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset %rbp, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register %rbp
        movb    $0, 12(%rdi)
#;      movabsq $8391162081026721108, %rax      # imm = 0x7473657454534554
#;      movq    %rax, (%rdi)
#;      movl    $1415071060, 8(%rdi)            # imm = 0x54584554
        pushq   %rbx
        pushq   %rcx
        pushq   %rdx
        xorl    %eax, %eax
        cpuid
        movl    %ebx, (%rdi)
        movl    %edx, 4(%rdi)
        movl    %ecx, 8(%rdi)
        popq    %rdx
        popq    %rcx
        popq    %rbx
        popq    %rbp
        .cfi_def_cfa %rsp, 8
        retq
.Lfunc_end0:
        .size   cpuid_0, .Lfunc_end0-cpuid_0
        .cfi_endproc
                                        # -- End function
        .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
        pushq   %rbx
        subq    $24, %rsp
        .cfi_offset %rbx, -24
        leaq    -21(%rbp), %rbx
        movq    %rbx, %rdi
        callq   cpuid_0
        movl    $.L.str.3, %edi
        movq    %rbx, %rsi
        xorl    %eax, %eax
        callq   printf
        xorl    %eax, %eax
        addq    $24, %rsp
        popq    %rbx
        popq    %rbp
        .cfi_def_cfa %rsp, 8
        retq
.Lfunc_end1:
        .size   main, .Lfunc_end1-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 11.0.1 (git@github.com:llvm/llvm-project.git llvmorg-11.0.1-0-g43ff75f2c3fe)"
        .section        ".note.GNU-stack","",@progbits
        .addrsig

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

コンパイルと実行
% clang -o ccpuid ccpuid.mod.s
% ./ccpuid
VendorID="GenuineIntel"
(参考)GCCの__asm__拡張での実装例
#include <stdio.h>

void X86CPUID( int param,
                unsigned *eax,
                unsigned *ebx,
                unsigned *ecx,
                unsigned *edx )
{
    __asm__( "cpuid"
             : "=a" (*eax), "=b" (*ebx), "=c" (*ecx), "=d" (*edx)
             : "0" (param) );
}
int main(void){
    char str[12+1];
    unsigned int eax;
    str[12] = 0;
    X86CPUID(0, &eax, (unsigned *)str, (unsigned *)(str + 8), (unsigned *)(str + 4));
    printf("CPUID/VenderID = \"%s\"", str);
}

語順 編集

GASの命令は一般的に、ニーモニック 転送元 転送先という形式をしています。例えば、次のような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に格納する

なお。「#」は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 についての読み物 編集

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

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

脚注および参考文献 編集

  1. ^ これは、コンパイラにバグがないことと、さらに重要なこととして、「あなたが書いたコードがあなたの意図を正しく実装していること」を前提としています。また、コンパイラはコードを最適化するために、低レベルの操作の順序を並べ替えることがあります。これにより、コードの全体的なセマンティクスは維持されますが、アセンブリの命令フローがアルゴリズムのステップと正確に一致しない可能性があります。