コーディング · プログラミング言語
アセンブリ言語入門:初心者向けチュートリアル(x86-64、NASM)
結論
アセンブリ言語は、プロセッサーの機械語命令を人間が読める形にしたものです。各行はたいてい、mov rax, 1やadd rax, rbxのような1つの命令になります。レジスタ、メモリアドレス、スタックを直接扱い、変数も型もなく、ループはジャンプを組み合わせて自分で作ります。このチュートリアルでは、LinuxのNASMアセンブラーでx86-64を使います。.asmファイルを書き、nasm -f elf64でアセンブルし、ldでリンクします。
アセンブリとは何か、誰が使うのか
プロセッサーのファミリーごとに独自の命令セットがあり、したがって独自のアセンブリ言語があります。x86-64(AMD64とも呼ばれます)はほとんどのデスクトップPCとサーバーで動き、ARM(AArch64)はほとんどのスマートフォン、Apple siliconのMac、多くのサーバーで動いています。アセンブラーはアセンブリを機械語に変換し、リンカーがその結果をまとめて実行ファイルにします。
今日では、全体をアセンブリで書くプログラムはほとんどありません。OSカーネルやブートローダー、性能が重要なルーチン(暗号、コーデック、数学ライブラリ)、組み込みファームウェア、コンパイラ開発、セキュリティ研究、リバースエンジニアリングで使われます。最もよくあるのは、コンパイラが何を生成したかを理解したり、クラッシュをデバッグしたりするために、開発者がアセンブリを読む場面です。
NASMのインストールとhello, worldの実行
DebianやUbuntuでは、sudo apt install nasm binutilsでツールをインストールします(binutilsにリンカーのldが含まれます)。このプログラムはシステムコールでLinuxカーネルを直接呼び出すので、Cライブラリは不要です。
; hello.asm - x86-64 Linux, NASM syntax
section .data
msg db "Hello, world!", 10 ; 10 = newline
msglen equ $ - msg ; length computed by the assembler
section .text
global _start
_start:
mov rax, 1 ; system call number 1 = write
mov rdi, 1 ; file descriptor 1 = stdout
mov rsi, msg ; address of the bytes
mov rdx, msglen ; number of bytes
syscall
mov rax, 60 ; system call number 60 = exit
xor rdi, rdi ; exit code 0
syscallnasm -f elf64 hello.asm -o hello.o
ld hello.o -o hello
./hello
# Hello, world!section .dataは初期化済みデータ、section .textはコードを置き、_startはldが探すエントリーポイントです。コメントは;で始まります。最後のexitシステムコールがないと、プロセッサーは続くバイト列をそのまま実行し続け、プログラムはクラッシュします。
レジスタとデータサイズ
レジスタは、CPUの内部にある、小さくて非常に高速な記憶場所です。x86-64には16個の64ビット汎用レジスタがあります。それぞれ、より小さな部分としても使えます。
| レジスタ | 主な役割 | 小さな部分 |
|---|---|---|
rax | 戻り値、システムコール番号 | eax(32ビット)、ax(16)、al(8) |
rdi, rsi, rdx | 関数またはシステムコールの最初の3つの引数 | edi, esi, edx |
rcx, r8, r9 | 関数の4番目から6番目の引数 | ecx, r8d, r9d |
rbx, rbp, r12–r15 | 関数呼び出しをまたいで保持すべき値(呼び出される側が保存) | ebx, r12d |
rsp | スタックポインター:スタックの先頭のアドレス | データには使わない |
rip | 命令ポインター(ジャンプ、呼び出し、リターンでのみ変わる) | 直接書き込めない |
メモリ上のデータは、db(バイト、8ビット)、dw(ワード、16)、dd(ダブルワード、32)、dq(クワッドワード、64)で宣言します。初期化されないバッファは、resb、resqなどを使ってsection .bssに置きます。eaxのような32ビットレジスタに書き込むと、raxの上位32ビットもクリアされます。そのため、xor eax, eaxはraxをゼロにする一般的な方法です。
基本の命令と制御フロー
ほとんどの命令は、先にデスティネーション(書き込み先)、次にソース(読み出し元)を取ります。add rax, rbxはrax = rax + rbxを意味します。ifやwhileの文はありません。cmpで値を比較してフラグを設定し、条件付きジャンプを使います。
| 命令 | 意味 |
|---|---|
mov rax, rbx | rbxをraxにコピーする |
add, sub, imul, inc, dec | 整数演算 |
cmp rax, 10 | 比較(rax - 10を計算し、フラグだけを設定する) |
je, jne, jl, jg, jle, jge | 等しい、等しくない、より小さい、より大きい…ならジャンプ(符号付き) |
jmp label | 無条件にジャンプする |
push, pop | 値をスタックに置く、取り出す |
call, ret | 関数を呼び出す、関数から戻る |
; sum.asm - add 1..10, return the result as the exit code
section .text
global _start
_start:
xor rax, rax ; sum = 0
mov rcx, 1 ; i = 1
.loop:
cmp rcx, 10
jg .done ; if i > 10, leave the loop
add rax, rcx ; sum += i
inc rcx ; i++
jmp .loop
.done:
mov rdi, rax ; exit code = sum
mov rax, 60 ; exit
syscallnasm -f elf64 sum.asm -o sum.o && ld sum.o -o sum
./sum; echo $?
# 55.loopのようにドットで始まるラベルは、直前の通常のラベルのローカルなラベルです。jlとjgは値を符号付きとして扱い、jbとjaは符号なしのバージョンです。
メモリアドレッシングと配列
角かっこは「このアドレスのメモリ」を意味します。アドレスは、ベースレジスタ、1・2・4・8を掛けたインデックスレジスタ、定数を組み合わせられます:[base + index*scale + offset]。これにより、配列アクセスが1つの命令で済みます。
section .data
nums dq 3, 1, 4, 1, 5 ; five 64-bit integers
count equ ($ - nums) / 8
section .text
global _start
_start:
xor rax, rax ; total = 0
xor rcx, rcx ; index = 0
.next:
add rax, [nums + rcx*8] ; total += nums[index]
inc rcx
cmp rcx, count
jl .next
mov rdi, rax ; exit code 14
mov rax, 60
syscallmov rax, numsはアドレスをロードし、mov rax, [nums]はそこに格納された値をロードします。lea rsi, [rbx + 8]は、メモリを読まずにアドレスを計算します。レジスタからサイズがわからない場合は、mov qword [rbx], 0のように明示します。
関数と呼び出し規約
Linuxでは、関数はSystem V AMD64 ABIに従います。整数の引数はrdi、rsi、rdx、rcx、r8、r9に入り、戻り値はraxに入ります。関数はrbx、rbp、r12–r15、rspを保存しなければならず、callの直前ではrspが16の倍数でなければなりません。これらの規則に従うと、アセンブリからprintfなどのC関数を呼べ、Cからアセンブリを呼べます。
; square.asm - define a function and call printf from the C library
extern printf
global main
section .data
fmt db "%ld squared is %ld", 10, 0
section .text
square: ; long square(long x)
mov rax, rdi
imul rax, rdi
ret
main:
push rbx ; save rbx; also aligns rsp to 16
mov rbx, 7
mov rdi, rbx
call square ; rax = 49
lea rdi, [rel fmt] ; argument 1: format string
mov rsi, rbx ; argument 2
mov rdx, rax ; argument 3
xor eax, eax ; varargs: no vector registers used
call printf wrt ..plt
xor eax, eax ; return 0 from main
pop rbx
ret
section .note.GNU-stack noalloc noexec nowrite progbitsnasm -f elf64 square.asm -o square.o
gcc square.o -o square
./square
# 7 squared is 49gccでリンクすると、mainを呼び出すCランタイムが加わります。[rel fmt]とwrt ..pltは位置独立コードを生成します。最近のLinuxディストリビューションは、これをデフォルトで使います。最後の行は、スタックを実行不可としてマークし、リンカーの警告を避けます。
エラー処理
アセンブリに例外はありません。失敗したLinuxのシステムコールは、raxに負のエラー番号を返す(たとえば-2はENOENTで「そのようなファイルはない」)ので、呼び出しのたびに符号を確認します。C標準ライブラリの関数は、戻り値とerrnoでエラーを報告します。無効なアドレスの読み取りやゼロ除算のようなハードウェアの障害は、シグナル(セグメンテーション違反や浮動小数点例外)でプログラムを止めます。
mov rax, 2 ; system call 2 = open
lea rdi, [rel path] ; path is a zero-terminated string
xor esi, esi ; flags: O_RDONLY
syscall
test rax, rax
js .open_failed ; negative result = -errno
mov r12, rax ; success: rax is the file descriptor小さな完成プログラム:数値を10進数で出力する
数値を出力するには、自分で数字に変換する必要があります。このプログラムは、1 + 2 + ... + 100を計算し、10で繰り返し割って右から左へ桁を取り出し、1回のシステムコールで書き出します。
section .bss
buf resb 32
section .text
global _start
_start:
xor rax, rax
mov rcx, 100
.sum:
add rax, rcx
loop .sum ; rcx -= 1, repeat while rcx != 0
lea rsi, [buf + 31]
mov byte [rsi], 10 ; newline at the end of the buffer
mov rbx, 10
.digit:
xor rdx, rdx ; div uses rdx:rax as the dividend
div rbx ; rax = quotient, rdx = remainder
add dl, '0' ; remainder to ASCII digit
dec rsi
mov [rsi], dl
test rax, rax
jnz .digit
mov rdx, buf + 32
sub rdx, rsi ; length = end - start
mov rax, 1 ; write(1, rsi, rdx)
mov rdi, 1
syscall
mov rax, 60 ; exit(0)
xor rdi, rdi
syscall
; prints 5050AT&T構文とIntel構文、そしてARM
同じx86-64の命令でも、2つの構文で書けます。NASMと、IntelやMicrosoftのほとんどのドキュメントはIntel構文を使います。GNUアセンブラー(as)、GCCの出力、objdumpは、デフォルトでAT&T構文を使います。objdump -d -M intelやgcc -S -masm=intelでIntel構文に切り替えられます。
| 項目 | Intel (NASM) | AT&T (GNU as) |
|---|---|---|
| オペランドの順序 | mov rax, 1(デスティネーションが先) | movq $1, %rax(ソースが先) |
| レジスタと定数 | rax, 5 | %rax, $5 |
| メモリオペランド | [rbx + rcx*8 + 16] | 16(%rbx,%rcx,8) |
| オペランドのサイズ | mov qword [rbx], 0 | movq $0, (%rbx)(サフィックスはb、w、l、q) |
ARMプロセッサーは、別の命令セットを使います。AArch64には31個の64ビット汎用レジスタ(x0–x30)、固定長32ビットの命令エンコーディング、ロード/ストア設計があります。算術はレジスタに対してだけ行い、メモリはldrとstrでアクセスします。Linuxでは、システムコール番号をx8に入れ、svc #0で呼び出します。
// AArch64 Linux, GNU as syntax: exit(0)
mov x0, #0 // exit status
mov x8, #93 // system call 93 = exit
svc #0次に学ぶこと
- ディレクティブ、マクロ、出力形式についてのNASMマニュアル。
- すべての命令の正確な動作を載せたIntel 64 and IA-32 Architectures Software Developer's Manuals。
- 呼び出し規約とスタックのレイアウトについてのSystem V AMD64 ABIの文書。
- 小さなC関数を
gcc -O2 -S -masm=intel(またはオンラインのコンパイラエクスプローラー)でコンパイルし、生成されたアセンブリを読む。
よくある質問
アセンブリ言語は今でも学ぶ価値がありますか?
はい。コンピューターがどう動くかの理解、クラッシュのデバッグ、コンパイラの出力を読むこと、セキュリティの仕事、組み込み開発に役立ちます。アプリケーション全体をアセンブリで書くことは、今日ではほとんど現実的ではありません。
x86アセンブリとARMアセンブリは同じですか?
いいえ。レジスタ、命令、エンコーディングが異なる、別の命令セットです。概念(レジスタ、メモリ、ジャンプ、呼び出し規約)は共通ですが、コードはそのままでは使えません。
NASMとGASの違いは何ですか?
NASMはIntel構文を使う単体のアセンブラーです。GNUアセンブラーのGASはGCCが使うもので、デフォルトはAT&T構文ですが、.intel_syntax noprefixでIntel構文も受け付けます。
このコードはWindowsやmacOSで動きますか?
そのままでは動きません。命令はどのx86-64システムでも同じですが、システムコール番号、実行ファイルの形式、呼び出し規約が異なります。Windowsは別の呼び出し規約を使い、Apple silicon Macはx86-64ではなくARMを使います。