X86アセンブラ/GASでの文法
はじめに 編集
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のシステムコールをアセンブリコードから叩く方法で文字列表示を行ってみます。
- アセンブリコードの作成
- 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 セクションでアセンブリコードを書いています。
- アセンブル
- 上記のアセンブリコードを hello.s という名前で保存し、次のコマンドを実行してアセンブルします。
$ as -o hello.o hello.s
- これにより、アセンブリファイル hello.s からオブジェクトファイル hello.o が作成されます。
- リンク
- 次に、オブジェクトファイル hello.o をリンクして実行可能なファイルを作成します。次のコマンドを実行します。
$ ld -s -o hello hello.o
- これにより、オブジェクトファイル hello.o から実行可能ファイル hello が作成されます。
- 実行
- 最後に、以下のコマンドで実行可能ファイルを実行します。
$ ./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 についてもっと知ることができる。
脚注および参考文献 編集
- ^ これは、コンパイラにバグがないことと、さらに重要なこととして、「あなたが書いたコードがあなたの意図を正しく実装していること」を前提としています。また、コンパイラはコードを最適化するために、低レベルの操作の順序を並べ替えることがあります。これにより、コードの全体的なセマンティクスは維持されますが、アセンブリの命令フローがアルゴリズムのステップと正確に一致しない可能性があります。