Assembly

Assembly入門

CPUが命令を実行する流れを、短いコードと実行結果で一歩ずつ学びましょう。

x86-64 Linux・NASM記法を対象にしています。

01

Assemblyとは

Assembly languageは、CPUの命令を人間が読み書きしやすい名前で表す言語です。movで値を移動し、addで加算し、jmpで実行位置を移動するように、コンピューターの動作を細かく指定します。

この講座で扱う範囲
  • CPU:x86-64(64ビットのPCで広く使われる命令セット)
  • アセンブラー:NASM(Intel記法に近い書き方)
  • OS:Linux 64ビット
  • リンク:Linuxのldを使う

AssemblyはCPUごとに異なります。ARM64やRISC-Vのコードをそのままx86-64で実行することはできません。最初に対象環境を固定すると、命令やシステムコールの違いに迷いにくくなります。

02

環境を作って実行する

DebianやUbuntu系Linuxでは、NASMとリンカーを次のように導入できます。すでに入っている場合はバージョン確認だけで構いません。

インストールと確認COMMAND
sudo apt update
sudo apt install nasm binutils
nasm -v
ld --version

hello.asmを作ります。Linux x86-64では、writeシステムコールで標準出力へ文字を送り、exitで終了します。

hello.asmASSEMBLY
section .data
    message db "Hello, Assembly!", 10
    message_length equ $ - message

section .text
    global _start

_start:
    mov rax, 1              ; write
    mov rdi, 1              ; 標準出力
    mov rsi, message        ; 文字列の先頭アドレス
    mov rdx, message_length ; バイト数
    syscall

    mov rax, 60             ; exit
    xor rdi, rdi            ; 終了コード0
    syscall
アセンブル・リンク・実行COMMAND
nasm -f elf64 hello.asm -o hello.o
ld hello.o -o hello
./hello
実行結果OUTPUT
Hello, Assembly!
3段階を覚える .asm.oへ変換するのがアセンブル、オブジェクトファイルを実行ファイルへまとめるのがリンク、その後に./helloで実行します。
03

基本の書き方

NASMの基本形は命令 オペランド1, オペランド2です。x86のIntel記法では、代入先を左、値や読み出し元を右に書きます。;から行末まではコメントです。

命令の順番ASSEMBLY
section .text
    global _start

_start:
    mov rax, 10
    add rax, 5
    sub rax, 2

    mov rdi, rax
    mov rax, 60
    syscall

この例は画面には表示せず、計算結果13を終了コードとしてOSへ返します。実行後にecho $?で確認できます。

実行結果を確認COMMAND / OUTPUT
nasm -f elf64 result.asm -o result.o && ld result.o -o result
./result
echo $?
13
命令ごとにできることが違う すべての命令がすべての組み合わせを受け付けるわけではありません。たとえば多くのx86命令では、メモリーからメモリーへ直接movすることはできません。必要なら一度レジスターへ読み込みます。
04

レジスターを理解する

レジスターはCPU内部にある、とても高速な小さな記憶場所です。x86-64では、計算やシステムコールの引数をレジスターへ置いて命令へ渡します。

名前主な用途
rax計算結果、システムコール番号
rbx計算途中の値や保存しておきたい値
rcxループ回数や一時的な値
rdi第1引数、終了コード
rsi第2引数、バッファーのアドレス
rdx第3引数、データの長さ
rspスタックの現在位置
rbpスタックフレームの基準位置として使われることがある

64ビットのraxの下位32ビットはeax、下位16ビットはax、下位8ビットはalです。命令のオペランドサイズを意識してください。

レジスターの一部ASSEMBLY
mov rax, 0
mov al, 7
add rax, 3
; raxは10になる
mov rdi, rax
mov rax, 60
syscall
32ビット書き込みの特徴 mov eax, 7のように32ビットレジスターへ書き込むと、x86-64では上位32ビットが0になります。一方、mov al, 7raxの上位部分を保持します。
デバッガーで見る 命令の前後でraxの値を確認すると、「命令が状態を変える」という感覚をつかめます。後半のデバッグ章で手順を紹介します。
05

値を移動する

movは、レジスター、定数、メモリーの間で値をコピーします。名前はmoveですが、元の値を消すのではなく、コピー先へ値を置く命令です。

レジスター間のコピーASSEMBLY
mov rax, 8
mov rbx, rax
add rbx, 4
; raxは8のまま、rbxは12

定数の代入とレジスター間のコピーを分けて考えます。2つのメモリーオペランドを扱いたいときは、レジスターを中継します。

メモリーを中継するASSEMBLY
mov rax, [number_a]
mov rbx, [number_b]
add rax, rbx
mov [result], rax
サイズを揃える mov eax, [number]のように、読み出すサイズを明示できる場合があります。サイズが判断できないときはbyteworddwordqwordなどの指定を確認します。
06

足し算・引き算・掛け算

addsubは第1オペランドを変更します。imulは符号付き整数の掛け算、incdecは1増減です。符号なし整数の割り算にはdiv、符号付き整数の割り算にはidivを使います。

合計を計算するASSEMBLY
mov rax, 12
add rax, 8
sub rax, 5
imul rax, 3
; (12 + 8 - 5) * 3 = 45

割り算は少し特殊です。divは割られる値をrdx:raxという組み合わせで受け取り、商をrax、余りをrdxへ返します。64ビットの正の整数なら、上位側をxor rdx, rdxで0にします。

商と余りASSEMBLY
mov rax, 23
mov rbx, 5
xor rdx, rdx
div rbx
; rax = 4(商)、rdx = 3(余り)
0で割れない 割る値が0だとCPU例外になります。実際のプログラムでは、割り算の前に0ではないことを確認します。
07

メモリーを読み書きする

メモリーは大きな記憶領域です。ラベルはデータのアドレスを表し、角かっこを付けると、そのアドレスに置かれた値を読み書きします。

数値を保存するASSEMBLY
section .data
    number dq 42

section .text
    mov rax, [number] ; メモリーから42を読む
    add rax, 8
    mov [number], rax ; 50を保存する

dbは1バイト、dwは2バイト、ddは4バイト、dqは8バイトを確保します。文字列も1バイトの並びとして保存されます。

配列の要素を読むASSEMBLY
section .data
    scores dq 70, 85, 92

section .text
    mov rax, [scores]       ; scores[0] = 70
    mov rbx, [scores + 8]   ; scores[1] = 85
    add rax, rbx            ; 155
アドレスと値を区別 mov rsi, messageはアドレス、mov al, [message]は先頭1バイトの値です。表示や読み書きでは、この違いが特に重要です。
08

比較とフラグ

CPUには、直前の計算結果の特徴を記録するフラグがあります。cmpは2つの値を比較し、結果をレジスターへ保存せず、条件分岐が読むフラグを更新します。

cmpとje(complete.asm)ASSEMBLY
section .text
    global _start

_start:
    mov rax, 10
    cmp rax, 10
    je equal
    mov rdi, 1       ; 異なる場合の終了コード
    jmp finish

equal:
    mov rdi, 0    ; 等しい場合の終了コード
finish:
    mov rax, 60   ; exit
    syscall

jeは等しい場合、jneは異なる場合、jgは符号付きで大きい場合、jlは符号付きで小さい場合にジャンプします。比較する値が符号なし整数ならjajbなどを使います。

命令意味
je / jz等しい / 0
jne / jnz異なる / 0ではない
jg / jge符号付きで大きい / 以上
jl / jle符号付きで小さい / 以下
jmp無条件ジャンプ
cmpの順序 cmp left, rightは概念的にleft - rightを比較します。cmp rax, rbxcmp rbx, raxでは、大きい・小さいの判定が反対になります。
09

分岐とループ

ラベルはジャンプ先の名前です。条件分岐を組み合わせると、他の言語のifを表現できます。ループは、終了条件を比較して本体の先頭へ戻ります。

カウンターを減らしてループを終了するASSEMBLY
mov rcx, 3

count_loop:
    ; ここでrcxを使った処理を行う
    dec rcx
    cmp rcx, 0
    jne count_loop
cmpは学習用に残している decはすでにゼロフラグを更新するため、この例の直後のcmp rcx, 0は実際には冗長です。条件分岐の流れを読みやすくするため、ここでは明示的に比較しています。

このコードは、decで値を減らし、0でない間だけ繰り返します。ジャンプ先を間違えたり、終了条件に関係するレジスターを更新し忘れたりすると無限ループになります。

配列の合計ASSEMBLY
section .data
    values dq 4, 7, 9, 2
    value_count equ 4

section .text
    xor rax, rax
    xor rcx, rcx

sum_loop:
    add rax, [values + rcx * 8]
    inc rcx
    cmp rcx, value_count
    jl sum_loop
    ; rax = 22
配列の添字 1要素が8バイトのdqなので、アドレスへrcx * 8を足しています。要素のサイズと添字の倍率を揃えることがポイントです。
10

スタックと関数

スタックは後入れ先出しのメモリー領域です。pushで値を積み、popで最後に積んだ値を取り出します。関数呼び出しでは、戻り先や一時的な値を保存するために使われます。

pushとpopASSEMBLY
mov rax, 10
mov rbx, 20
push rax
push rbx
pop rcx       ; rcx = 20
pop rdx       ; rdx = 10

callは戻り先をスタックへ積んでラベルへ移動し、retは戻り先を取り出して呼び出し元へ戻ります。

関数から値を返すASSEMBLY
section .text
    global _start

_start:
    mov rdi, 6
    call double_value
    ; rax = 12
    mov rdi, rax
    mov rax, 60
    syscall

double_value:
    mov rax, rdi
    add rax, rax
    ret
スタックを壊さない pushした回数とpopした回数が合わないままretすると、戻り先のアドレスを誤って読み、クラッシュすることがあります。
11

システムコールでOSへ依頼する

CPUの命令だけでは、端末への表示やファイル操作は完結しません。Linuxのシステムコールを使い、OSへ処理を依頼します。x86-64 Linuxでは、raxへ番号、rdi以降へ引数を置いてsyscallを実行します。

処理番号引数の例
writerax=1rdi=fd, rsi=アドレス, rdx=長さ
exitrax=60rdi=終了コード

標準出力のファイルディスクリプターは1です。戻り値として、writeは実際に書けたバイト数、失敗時は負の値を返します。

syscallが壊すレジスター syscallrcxr11を内部で使用して内容を破壊します。ループカウンターにrcxを使ったままシステムコールを呼ぶ場合は、別のレジスターへ退避するなどの対策が必要です。
表示処理を関数に分けるASSEMBLY
section .data
    message db "Assembly is close to the CPU.", 10
    message_length equ $ - message

section .text
    global _start

_start:
    call print_message
    mov rax, 60
    xor rdi, rdi
    syscall

print_message:
    mov rax, 1
    mov rdi, 1
    mov rsi, message
    mov rdx, message_length
    syscall
    ret
実行結果OUTPUT
Assembly is close to the CPU.
番号を丸暗記しない システムコール番号や引数の規則はOS・アーキテクチャ・ABIに依存します。Linux x86-64以外で動かすときは、対象環境の公式資料を確認してください。
12

デバッガーで変化を追う

Assemblyでは、ソースコードだけでなく、命令の前後でレジスターとメモリーがどう変化したかを見ることが重要です。GDBを使うと、ブレークポイントを置いて1命令ずつ進められます。

GDBの基本操作COMMAND
gdb ./hello
(gdb) break _start
(gdb) run
(gdb) layout asm
(gdb) info registers rax rdi rsi rdx
(gdb) stepi
(gdb) x/s $rsi
(gdb) quit
操作意味
break _startラベルに停止点を置く
run実行を開始する
stepi1命令だけ実行する
info registersレジスターを表示する
x/s $rsirsiが指す文字列を表示する

「表示されない」「値が違う」ときは、まず命令を一つずつ進め、入力値・出力先・サイズ・フラグのどれが想定と違うかを切り分けます。大きなコードを眺め続けるより、最小の再現例を作るほうが早く解決できます。

PRACTICE

練習問題

最初は終了コードを使って結果を確認し、慣れたら数字を文字列へ変換して表示してみましょう。全問に完成形の解答例を、問題3~5には自力で考えるためのヒントも用意しています。解答例を写すだけで終わらず、入力値や条件を変えて動作を予想してください。

終了コードの注意 echo $?で確認できるのは通常0~255です。300などを返すプログラムでは下位8ビットだけが表示されるため、計算結果が大きい場合はwriteで文字列表示する方法へ進みましょう。
問題1:3つの数の合計

4、7、9raxへ順番に加え、合計20を終了コードとして返してください。

解答例(complete.asm)ASSEMBLY
section .text
    global _start

_start:
    mov rax, 4
    add rax, 7
    add rax, 9
    mov rdi, rax
    mov rax, 60
    syscall
実行結果OUTPUT
echo $?
20
問題2:偶数かどうかを判定

値が偶数なら終了コード0、奇数なら終了コード1を返してください。ヒントはtest rax, 1または割り算の余りです。

解答例(complete.asm)ASSEMBLY
section .text
    global _start

_start:
    mov rax, 14
    test rax, 1
    jz even
    mov rdi, 1
    jmp finish
even:
    xor rdi, rdi
finish:
    mov rax, 60
    syscall
実行結果OUTPUT
echo $?
0
問題3:配列の最大値

12、5、21、9の最大値を探すループを作ってください。最初の値を最大値の候補にし、残りを比較します。

考え方 最大値をrax、次の要素をrbxへ読み、cmp rbx, raxの後にjleで候補を更新しない分岐を作ります。
解答例(complete.asm)ASSEMBLY
section .data
    scores dq 12, 5, 21, 9
    score_count equ 4

section .text
    global _start

_start:
    mov rax, [scores]
    mov rcx, 1
find_max:
    cmp rcx, score_count
    jge finish
    mov rbx, [scores + rcx * 8]
    cmp rbx, rax
    jle next
    mov rax, rbx
next:
    inc rcx
    jmp find_max
finish:
    mov rdi, rax
    mov rax, 60
    syscall
実行結果OUTPUT
echo $?
21
問題4:安全な割り算

割る値が0なら処理を中止し、0でなければ23 / 5の商を終了コードにしてください。解答例のdivisorを0に書き換えて再実行すると、終了コード1になります。

確認すること divの前にcmp rbx, 0で検査し、rdxを0にする必要があります。
解答例(complete.asm)ASSEMBLY
section .data
    divisor dq 5

section .text
    global _start

_start:
    mov rax, 23
    mov rbx, [divisor]
    cmp rbx, 0
    je divide_by_zero
    xor rdx, rdx
    div rbx
    mov rdi, rax
    jmp finish
divide_by_zero:
    mov rdi, 1
finish:
    mov rax, 60
    syscall
実行結果OUTPUT
echo $?
4
問題5:文字列の長さを数える

messageの終端に0を置いた文字列を用意し、1バイトずつ調べて長さを数えてください。cmp byte [rsi + rcx], 0inc rcxがヒントです。

発展 数えた長さを数字の文字へ変換し、writeで表示してみましょう。
解答例(complete.asm)ASSEMBLY
section .data
    message db "Hello", 0

section .text
    global _start

_start:
    mov rsi, message
    xor rcx, rcx
count_loop:
    cmp byte [rsi + rcx], 0
    je finish
    inc rcx
    jmp count_loop
finish:
    mov rdi, rcx
    mov rax, 60
    syscall
実行結果OUTPUT
echo $?
5
練習の進め方
  • まず紙に、各レジスターの初期値と命令後の値を書く
  • 次に最小のソースを作り、アセンブルして実行する
  • 失敗したらエラー全文を読み、GDBで直前の命令を確認する
  • 解答例を使った後、値・配列の長さ・条件を変更する
NEXT

次に学ぶこと

基礎を終えたら、数字の文字列変換、ファイルの読み書き、C言語との連携、ELF形式、呼び出し規約、SIMD命令へ進めます。Assemblyは環境依存が強いため、常にCPU・OS・アセンブラーを確認する習慣を持ちましょう。

公式資料の読み方 公式マニュアルは網羅性と正確さを優先しているため、最初から全ページを読む必要はありません。まずこの講座の短い例を動かし、分からない命令を公式資料で確認し、また自分の課題へ戻る流れがおすすめです。