cv.cm

コーディング · プログラミング言語

アセンブリ言語入門:初心者向けチュートリアル(x86-64、NASM)

約14分で読めます

結論

アセンブリ言語は、プロセッサーの機械語命令を人間が読める形にしたものです。各行はたいてい、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
    syscall
nasm -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, rbxrbxを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
    syscall
nasm -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
    syscall

mov 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 progbits
nasm -f elf64 square.asm -o square.o
gcc square.o -o square
./square
# 7 squared is 49

gccでリンクすると、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 5050

AT&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], 0movq $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を使います。

コーディング講座の一覧