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で実行することはできません。最初に対象環境を固定すると、命令やシステムコールの違いに迷いにくくなります。
環境を作って実行する
DebianやUbuntu系Linuxでは、NASMとリンカーを次のように導入できます。すでに入っている場合はバージョン確認だけで構いません。
sudo apt update
sudo apt install nasm binutils
nasm -v
ld --versionhello.asmを作ります。Linux x86-64では、writeシステムコールで標準出力へ文字を送り、exitで終了します。
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
syscallnasm -f elf64 hello.asm -o hello.o
ld hello.o -o hello
./helloHello, Assembly!.asmを.oへ変換するのがアセンブル、オブジェクトファイルを実行ファイルへまとめるのがリンク、その後に./helloで実行します。基本の書き方
NASMの基本形は命令 オペランド1, オペランド2です。x86のIntel記法では、代入先を左、値や読み出し元を右に書きます。;から行末まではコメントです。
section .text
global _start
_start:
mov rax, 10
add rax, 5
sub rax, 2
mov rdi, rax
mov rax, 60
syscallこの例は画面には表示せず、計算結果13を終了コードとしてOSへ返します。実行後にecho $?で確認できます。
nasm -f elf64 result.asm -o result.o && ld result.o -o result
./result
echo $?
13movすることはできません。必要なら一度レジスターへ読み込みます。レジスターを理解する
レジスターはCPU内部にある、とても高速な小さな記憶場所です。x86-64では、計算やシステムコールの引数をレジスターへ置いて命令へ渡します。
| 名前 | 主な用途 |
|---|---|
rax | 計算結果、システムコール番号 |
rbx | 計算途中の値や保存しておきたい値 |
rcx | ループ回数や一時的な値 |
rdi | 第1引数、終了コード |
rsi | 第2引数、バッファーのアドレス |
rdx | 第3引数、データの長さ |
rsp | スタックの現在位置 |
rbp | スタックフレームの基準位置として使われることがある |
64ビットのraxの下位32ビットはeax、下位16ビットはax、下位8ビットはalです。命令のオペランドサイズを意識してください。
mov rax, 0
mov al, 7
add rax, 3
; raxは10になる
mov rdi, rax
mov rax, 60
syscallmov eax, 7のように32ビットレジスターへ書き込むと、x86-64では上位32ビットが0になります。一方、mov al, 7はraxの上位部分を保持します。raxの値を確認すると、「命令が状態を変える」という感覚をつかめます。後半のデバッグ章で手順を紹介します。値を移動する
movは、レジスター、定数、メモリーの間で値をコピーします。名前はmoveですが、元の値を消すのではなく、コピー先へ値を置く命令です。
mov rax, 8
mov rbx, rax
add rbx, 4
; raxは8のまま、rbxは12定数の代入とレジスター間のコピーを分けて考えます。2つのメモリーオペランドを扱いたいときは、レジスターを中継します。
mov rax, [number_a]
mov rbx, [number_b]
add rax, rbx
mov [result], raxmov eax, [number]のように、読み出すサイズを明示できる場合があります。サイズが判断できないときはbyte、word、dword、qwordなどの指定を確認します。足し算・引き算・掛け算
addとsubは第1オペランドを変更します。imulは符号付き整数の掛け算、incとdecは1増減です。符号なし整数の割り算にはdiv、符号付き整数の割り算にはidivを使います。
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にします。
mov rax, 23
mov rbx, 5
xor rdx, rdx
div rbx
; rax = 4(商)、rdx = 3(余り)メモリーを読み書きする
メモリーは大きな記憶領域です。ラベルはデータのアドレスを表し、角かっこを付けると、そのアドレスに置かれた値を読み書きします。
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バイトの並びとして保存されます。
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 ; 155mov rsi, messageはアドレス、mov al, [message]は先頭1バイトの値です。表示や読み書きでは、この違いが特に重要です。比較とフラグ
CPUには、直前の計算結果の特徴を記録するフラグがあります。cmpは2つの値を比較し、結果をレジスターへ保存せず、条件分岐が読むフラグを更新します。
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
syscalljeは等しい場合、jneは異なる場合、jgは符号付きで大きい場合、jlは符号付きで小さい場合にジャンプします。比較する値が符号なし整数ならja、jbなどを使います。
| 命令 | 意味 |
|---|---|
je / jz | 等しい / 0 |
jne / jnz | 異なる / 0ではない |
jg / jge | 符号付きで大きい / 以上 |
jl / jle | 符号付きで小さい / 以下 |
jmp | 無条件ジャンプ |
cmp left, rightは概念的にleft - rightを比較します。cmp rax, rbxとcmp rbx, raxでは、大きい・小さいの判定が反対になります。分岐とループ
ラベルはジャンプ先の名前です。条件分岐を組み合わせると、他の言語のifを表現できます。ループは、終了条件を比較して本体の先頭へ戻ります。
mov rcx, 3
count_loop:
; ここでrcxを使った処理を行う
dec rcx
cmp rcx, 0
jne count_loopdecはすでにゼロフラグを更新するため、この例の直後のcmp rcx, 0は実際には冗長です。条件分岐の流れを読みやすくするため、ここでは明示的に比較しています。このコードは、decで値を減らし、0でない間だけ繰り返します。ジャンプ先を間違えたり、終了条件に関係するレジスターを更新し忘れたりすると無限ループになります。
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 = 22dqなので、アドレスへrcx * 8を足しています。要素のサイズと添字の倍率を揃えることがポイントです。スタックと関数
スタックは後入れ先出しのメモリー領域です。pushで値を積み、popで最後に積んだ値を取り出します。関数呼び出しでは、戻り先や一時的な値を保存するために使われます。
mov rax, 10
mov rbx, 20
push rax
push rbx
pop rcx ; rcx = 20
pop rdx ; rdx = 10callは戻り先をスタックへ積んでラベルへ移動し、retは戻り先を取り出して呼び出し元へ戻ります。
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
retpushした回数とpopした回数が合わないままretすると、戻り先のアドレスを誤って読み、クラッシュすることがあります。システムコールでOSへ依頼する
CPUの命令だけでは、端末への表示やファイル操作は完結しません。Linuxのシステムコールを使い、OSへ処理を依頼します。x86-64 Linuxでは、raxへ番号、rdi以降へ引数を置いてsyscallを実行します。
| 処理 | 番号 | 引数の例 |
|---|---|---|
write | rax=1 | rdi=fd, rsi=アドレス, rdx=長さ |
exit | rax=60 | rdi=終了コード |
標準出力のファイルディスクリプターは1です。戻り値として、writeは実際に書けたバイト数、失敗時は負の値を返します。
syscallはrcxとr11を内部で使用して内容を破壊します。ループカウンターにrcxを使ったままシステムコールを呼ぶ場合は、別のレジスターへ退避するなどの対策が必要です。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
retAssembly is close to the CPU.デバッガーで変化を追う
Assemblyでは、ソースコードだけでなく、命令の前後でレジスターとメモリーがどう変化したかを見ることが重要です。GDBを使うと、ブレークポイントを置いて1命令ずつ進められます。
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 | 実行を開始する |
stepi | 1命令だけ実行する |
info registers | レジスターを表示する |
x/s $rsi | rsiが指す文字列を表示する |
「表示されない」「値が違う」ときは、まず命令を一つずつ進め、入力値・出力先・サイズ・フラグのどれが想定と違うかを切り分けます。大きなコードを眺め続けるより、最小の再現例を作るほうが早く解決できます。
練習問題
最初は終了コードを使って結果を確認し、慣れたら数字を文字列へ変換して表示してみましょう。全問に完成形の解答例を、問題3~5には自力で考えるためのヒントも用意しています。解答例を写すだけで終わらず、入力値や条件を変えて動作を予想してください。
echo $?で確認できるのは通常0~255です。300などを返すプログラムでは下位8ビットだけが表示されるため、計算結果が大きい場合はwriteで文字列表示する方法へ進みましょう。問題1:3つの数の合計
4、7、9をraxへ順番に加え、合計20を終了コードとして返してください。
section .text
global _start
_start:
mov rax, 4
add rax, 7
add rax, 9
mov rdi, rax
mov rax, 60
syscallecho $?
20問題2:偶数かどうかを判定
値が偶数なら終了コード0、奇数なら終了コード1を返してください。ヒントはtest rax, 1または割り算の余りです。
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
syscallecho $?
0問題3:配列の最大値
12、5、21、9の最大値を探すループを作ってください。最初の値を最大値の候補にし、残りを比較します。
rax、次の要素をrbxへ読み、cmp rbx, raxの後にjleで候補を更新しない分岐を作ります。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
syscallecho $?
21問題4:安全な割り算
割る値が0なら処理を中止し、0でなければ23 / 5の商を終了コードにしてください。解答例のdivisorを0に書き換えて再実行すると、終了コード1になります。
divの前にcmp rbx, 0で検査し、rdxを0にする必要があります。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
syscallecho $?
4問題5:文字列の長さを数える
messageの終端に0を置いた文字列を用意し、1バイトずつ調べて長さを数えてください。cmp byte [rsi + rcx], 0とinc rcxがヒントです。
writeで表示してみましょう。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
syscallecho $?
5- まず紙に、各レジスターの初期値と命令後の値を書く
- 次に最小のソースを作り、アセンブルして実行する
- 失敗したらエラー全文を読み、GDBで直前の命令を確認する
- 解答例を使った後、値・配列の長さ・条件を変更する
次に学ぶこと
基礎を終えたら、数字の文字列変換、ファイルの読み書き、C言語との連携、ELF形式、呼び出し規約、SIMD命令へ進めます。Assemblyは環境依存が強いため、常にCPU・OS・アセンブラーを確認する習慣を持ちましょう。
