やさしいアセンブラ入門(第2回)~レジスタ・mov・算術命令・Hello, World!~【GitHub対応】

コンピュータのしくみ

前回は、環境を整えて「終了コード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システムコール番号、関数の戻り値
rdi1つ目の引数
rsi2つ目の引数
rdx3つ目の引数
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, ba に b を足すa += b;
sub a, ba から b を引くa -= b;
imul a, ba に b を掛けるa *= b;
inc aa を1増やすa++;
dec aa を1減らすa--;

どの命令も、計算結果が左側(宛先)に入る。add rdi, 20 は、rdi に20を足した結果を rdi に書き戻す。

【参考】
割り算(div)は、rax と rdx を組み合わせて使うなど決まりが多いので、この連載では必要になった場面で扱う。

計算するプログラム

rdi を計算用に使い、次の順に計算するプログラムを書いてみよう。

  1. 10を入れる
  2. 20を足す(30)
  3. 5を引く(25)
  4. 2を掛ける(50)
  5. 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) run

break _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 = 30
  • stepi: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では、次のように指定する。

レジスタ入れる値
rax1(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 $ - msg
  • msg:文字列の先頭位置につけた名前(ラベル)である
  • 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
./hello
Hello, World!

画面に「Hello, World!」と表示される。
printf などの関数も、画面に文字を出すときは、内部でこのようなシステムコールを使っている。

終了処理を書かなかったら? 
最後の exit を書かないと、CPUはプログラムの最後の命令を実行した後も、そのまま次の命令を実行しようとする。その結果、実行できないメモリ領域にアクセスするなどして、多くの場合、Segmentation fault(セグメンテーション違反)で異常終了する。

5. サンプルコード

参考として、今回のコードは筆者のGitHubリポジトリの次の場所に置いてある。必要に応じて利用してほしい。

・02/calc.asm:計算して終了コードを返すプログラム
・02/hello.asm:Hello, World! を表示するプログラム

HappyTalk10/assembly-learning

6. 今回のまとめ

  • レジスタはCPUの内部にある高速な記憶領域で、計算はレジスタに対して行う
  • mov 宛先, 元 で値をコピーする。宛先が左である
  • add、sub、imul、inc、dec で計算でき、結果は左側のレジスタに入る
  • 文字を出力するには write システムコールを使い、rax に1、rdi に1、rsi に文字列のアドレス、rdx に文字数を入れる
  • 文字列は section .data に db で用意する
  • プログラムの終了処理(exit)は、自分で書く必要がある

次回は、メモリとデータを取り上げる。section .data や section .bss の使い分け、メモリの中身を読み書きする方法、文字列の扱いを見ていく。これで前半の3回が完結する。

タイトルとURLをコピーしました