前回は、環境を整えて「終了コード42を返すだけ」のプログラムを動かした。
今回は、そのプログラムに出てきたレジスタと mov を詳しく見ていこう。
さらに、足し算や引き算などの算術命令を使い、最後は画面に “Hello, World!” を表示するところまで進めよう。
作業用のディレクトリは、前回と同様の手順で ~/asm/02 を作成して使用する。
まずは、Windows11で「PowerShell」を立ち上げ、「wsl」と入力してWSL2を起動しよう。
(詳しくは第1回を参照)
次に、Ubuntuで、次のコマンドを実行して「~/asm/02」を作成し、 「cd ~/asm/02」で移動しよう。
mkdir -p ~/asm/02
cd ~/asm/02【参考】
PowerShellからwslで入った直後は、Windows側のフォルダ(/mnt/c/Users/...など)にいる。そのため、cd ~/asm/02で移動する必要がある。
1. レジスタとは
レジスタは、CPUの内部にある、高速な小さい記憶領域である。CPUは、メモリの値を直接計算するのではなく、いったんレジスタに値を取り込み、レジスタ同士で計算するのが基本である。C言語の変数にたとえると、「CPUの手元にある、数に限りのある変数」のようなものである。
x86-64には、64ビット(8バイト)の汎用レジスタが16本ある。この連載でよく使うものを、次の表にまとめておいた。
| レジスタ | 主な役割(x86-64 Linuxの慣例) |
|---|---|
rax | システムコール番号、関数の戻り値 |
rdi | 1つ目の引数 |
rsi | 2つ目の引数 |
rdx | 3つ目の引数 |
rbx、rcx ほか | 汎用(計算の作業用) |
rsp | スタックの先頭位置(第5回で扱う) |
rip | 次に実行する命令の位置(16本の汎用レジスタには含まれない、特別なレジスタ) |
rax や rdi の「主な役割」は、CPUの仕様ではなく、OSやコンパイラが決めた約束事である。
前回 rax に60、rdi に42を入れたのも、この約束に従ったものであった。
レジスタの名前は大きさで変わる
同じレジスタでも、使う大きさによって名前が変わる。rax を例にすると、次のとおりである。
| 大きさ | 名前 |
|---|---|
| 64ビット | rax |
| 32ビット | eax |
| 16ビット | ax |
| 8ビット | al |
C言語の型に例えると、今回のUbuntu(64ビット環境)では、long(64ビット)、int(32ビット)、short(16ビット)、char(8ビット)に近いイメージである。ただし、C言語の型のサイズは環境によって異なる。
2. mov命令
mov は、値をコピーする命令である。書き方は次のとおりである。
mov 宛先, 元
宛先が左、元が右である。C言語の代入 a = b; と同じ向きなので、覚えやすい。
mov rax, 10 ; rax = 10(定数を入れる)
mov rbx, rax ; rbx = rax(レジスタからコピーする)
; から行末まではコメントである。
名前は「move(移動)」だが、実際にはコピーであり、元の値は消えない。上の例では、2行目の実行後も rax は10のままである。
なお、mov の宛先と元の両方をメモリにすることはできない。メモリを扱う方法は、第3回で詳しく見る。
3. 算術命令
足し算や引き算などの計算も、レジスタに対して行う。基本の命令を次の表にまとめておいた。
| 命令 | 意味 | Cでの書き方 |
|---|---|---|
add a, b | a に b を足す | a += b; |
sub a, b | a から b を引く | a -= b; |
imul a, b | a に b を掛ける | a *= b; |
inc a | a を1増やす | a++; |
dec a | a を1減らす | a--; |
どの命令も、計算結果が左側(宛先)に入る。add rdi, 20 は、rdi に20を足した結果を rdi に書き戻す。
【参考】
割り算(div)は、raxとrdxを組み合わせて使うなど決まりが多いので、この連載では必要になった場面で扱う。
計算するプログラム
rdi を計算用に使い、次の順に計算するプログラムを書いてみよう。
- 10を入れる
- 20を足す(30)
- 5を引く(25)
- 2を掛ける(50)
- 1を足す(51)
「calc.asm 」という名前でファイルを作成しよう。
方法は第1回と同じで、「vi calc.asm」 でファイル「calc.asm」を新規作成と同時に開き、i で入力モードにして、下記のコードを貼り付け、Esc を押した後に 「:wq 」で保存して終了する。
global _start
section .text
_start:
mov rdi, 10 ; rdi = 10
add rdi, 20 ; rdi = 30
sub rdi, 5 ; rdi = 25
imul rdi, 2 ; rdi = 50
inc rdi ; rdi = 51
mov rax, 60 ; exit
syscall ; 終了コードは rdi の値「cat calc.asm」で正しく作成できたか確認しておこう。
rocky@ThinkCentre:~/asm/02$ cat calc.asm
global _start
section .text
_start:
mov rdi, 10 ; rdi = 10
add rdi, 20 ; rdi = 30
sub rdi, 5 ; rdi = 25
imul rdi, 2 ; rdi = 50
inc rdi ; rdi = 51
mov rax, 60 ; exit
syscall ; 終了コードは rdi の値前回と同じ手順で、アセンブルとリンクをして実行する。
nasm -f elf64 calc.asm -o calc.o
ld calc.o -o calc
./calc
echo $?計算結果の51が、終了コードとして表示される。
51【参考】
終了コードとして使えるのは0〜255の範囲である。256以上の値を渡すと、256で割った余りになる。
gdbで計算の途中を見る
gdb で1命令ずつ実行し、rdi が変化していく様子を確認しよう。
gdb ./calc(gdb) というプロンプトが表示されたら、(gdb)の後に次のコマンドを入力しよう。
(gdb) break _start
(gdb) runbreak _start で _start に停止位置を設定し、run でプログラムを開始する。次のように表示されて、_start の先頭で止まる。
Breakpoint 1, 0x0000000000401000 in _start ()ここから、stepi と p $rdi を繰り返して、rdi の値を確認していく。
(gdb) stepi
0x0000000000401005 in _start ()
(gdb) p $rdi
$1 = 10
(gdb) stepi
0x0000000000401009 in _start ()
(gdb) p $rdi
$2 = 30stepi:1命令だけ実行する。表示される0x...は、次に実行する命令のアドレスであるp $rdi:rdiの値を10進数で表示する。$1、$2の番号は、表示のたびに増えるだけなので、気にしなくてよい
1回目の stepi で mov rdi, 10 が実行されて10に、2回目の stepi で add rdi, 20 が実行されて30になった。同じ操作をさらに繰り返すと、rdi の値は25(sub)、50(imul)、51(inc)の順に変わっていく。なお、アドレスの値は、環境によって異なることがある。
display で自動的に表示する
毎回 「p $rdi 」と入力するのが面倒なときは、display を使うとよい。一度設定すると、stepi で止まるたびに、自動で値が表示される。
(gdb) display $rdi
1: $rdi = 0
(gdb) stepi
0x0000000000401005 in _start ()
1: $rdi = 10
(gdb) stepi
0x0000000000401009 in _start ()
1: $rdi = 30「stepi」の入力だけを繰り返せばよいので、値の変化を追いやすい。
先頭の 1: は、display の番号である。
確認が終わったら、quit と入力して Enter キーを押し、gdb を終了しよう。Quit anyway? (y or n) と確認されたら、y と入力して Enter キーを押す(詳しくは第1回を参照)。
4. Hello, World! を表示する
いよいよ画面に文字を表示する。アセンブラには、printf のような便利な関数はない。
そこで、前回の終了と同じく、OSにシステムコールで依頼する。
write システムコール
文字を出力するには、write システムコールを使う。x86-64のLinuxでは、次のように指定する。
| レジスタ | 入れる値 |
|---|---|
rax | 1(writeの番号) |
rdi | 出力先(1は標準出力) |
rsi | 出力する文字列のアドレス(先頭の位置) |
rdx | 出力する文字数 |
C言語の write(1, msg, 14); と同じ意味である。前回の終了(exit)が rax に60、rdi に終了コードだったのと、同じ仕組みで動いている。
文字列をデータとして用意する
出力する文字列は、プログラムの中にデータとして置いておく必要がある。そのための領域が、section .data である。
section .data
msg db "Hello, World!", 10
len equ $ - msgmsg:文字列の先頭位置につけた名前(ラベル)であるdb:1バイトずつデータを置く指示である。"Hello, World!"の各文字が1バイトずつ並ぶ10:改行(LF)の文字コードであるlen equ $ - msg:文字列の長さを計算してlenという名前をつける。$は「現在の位置」を表すので、「現在の位置 −msgの位置」で、msgから今までに置いたバイト数、つまり14になる
NASMでは、msg と書くと、そのアドレスを意味する。この点は、第3回でメモリの中身を読む方法と合わせて詳しく説明する。
プログラム全体
hello.asm という名前で作成しよう。「vi hello.asm」と入力しよう。
global _start
section .data
msg db "Hello, World!", 10
len equ $ - msg
section .text
_start:
mov rax, 1 ; write
mov rdi, 1 ; 標準出力
mov rsi, msg ; 文字列のアドレス
mov rdx, len ; 文字数(14)
syscall
mov rax, 60 ; exit
mov rdi, 0 ; 終了コード0
syscall入力して終了したら、「cat hello.asm」で確認しよう。
2つ目の mov rax, 60 は、書き忘れないようにしたい。syscall を実行すると、rax には処理結果(書き込んだバイト数)が上書きされるためである。
次のシステムコールを呼ぶ前には、番号を入れ直す必要がある。
アセンブルとリンクをして、実行しよう。
nasm -f elf64 hello.asm -o hello.o
ld hello.o -o hello
./helloHello, World!
画面に「Hello, World!」と表示される。printf などの関数も、画面に文字を出すときは、内部でこのようなシステムコールを使っている。
終了処理を書かなかったら?
最後の exit を書かないと、CPUはプログラムの最後の命令を実行した後も、そのまま次の命令を実行しようとする。その結果、実行できないメモリ領域にアクセスするなどして、多くの場合、Segmentation fault(セグメンテーション違反)で異常終了する。
5. サンプルコード
参考として、今回のコードは筆者のGitHubリポジトリの次の場所に置いてある。必要に応じて利用してほしい。
・02/calc.asm:計算して終了コードを返すプログラム・02/hello.asm:Hello, World! を表示するプログラム
6. 今回のまとめ
- レジスタはCPUの内部にある高速な記憶領域で、計算はレジスタに対して行う
mov 宛先, 元で値をコピーする。宛先が左であるadd、sub、imul、inc、decで計算でき、結果は左側のレジスタに入る- 文字を出力するには
writeシステムコールを使い、raxに1、rdiに1、rsiに文字列のアドレス、rdxに文字数を入れる - 文字列は
section .dataにdbで用意する - プログラムの終了処理(
exit)は、自分で書く必要がある
次回は、メモリとデータを取り上げる。section .data や section .bss の使い分け、メモリの中身を読み書きする方法、文字列の扱いを見ていく。これで前半の3回が完結する。


