やさしいアセンブラ入門(第3回)~メモリとデータ~【GitHub対応】

コンピュータのしくみ

前回は、レジスタを使った計算と、write システムコールによる文字の出力を扱った。ただ、レジスタは数に限りがあるため、大量のデータや文字列は置いておけない。
そこで今回は、プログラムのデータを置くメモリの扱い方をご紹介しよう。
メモリに値を置き、読み出し、書き換えられるようになると、アセンブラでできることが一気に広がる。

作業用のディレクトリは、前回と同様の手順で、 ~/asm/03 を作成して使用する。
まずは、Windows11で「PowerShell」を立ち上げ、「wsl」と入力してWSL2を起動しよう。
(詳しくは第1回を参照)
次に、Ubuntuで、次のコマンドを実行して「~/asm/03」を作成し、 「cd ~/asm/03」で移動しよう。

mkdir -p ~/asm/03
cd ~/asm/03

1. メモリとアドレス

メモリは、1バイトずつ番号(アドレス)がつけられた、大きな記憶領域である。レジスタとの違いを、次の表にまとめる。

レジスタメモリ
場所CPUの内部CPUの外
数・容量16本(64ビットの汎用レジスタ)大量にある
速度非常に速いレジスタより遅い
指定方法名前(rax など)アドレス(番号)

C言語の変数や配列、文字列は、実際にはメモリ上のどこかに置かれている。
アセンブラでは、その置き場所を自分で決め、アドレスで指定して読み書きする。

2. データを置く場所とその書き方

セクション

前回は、文字列を section .data に置いた。プログラムは、役割ごとにセクションという領域に分けて作られる。ここでは、次の3つを覚えておこう。

セクション置くもの
.text実行する命令(プログラム本体)
.data初期値のあるデータ
.bss初期値のないデータ(実行開始時にすべて0になる)

.data と .bss の使い分けは、「最初から決まった値を入れておきたいか」で決まる。
.bss には初期値を書かない分、実行ファイルの大きさが小さくなる。

.data:初期値つきのデータ

.data では、データの大きさに応じた命令で、値を置く。

命令大きさCでのたとえ
db1バイトchar
dw2バイトshort
dd4バイトint
dq8バイトlong

※ C言語のデータ型のサイズは環境によって異なる。ここでは、Ubuntuの64ビット環境を前提としている。

section .data
num   dq 30             ; 8バイトの整数(初期値30)
arr   dq 10, 20, 30     ; 8バイトの整数が3つ並ぶ(配列)
msg   db "Hello", 10    ; 1バイトずつ文字が並ぶ

num や arr は、データの先頭の位置につけた名前(ラベル)である。

.bss:初期値のない領域

.bss では、値ではなく確保する個数を指定する。

命令確保するもの
resb1バイトの領域
resw2バイトの領域
resd4バイトの領域
resq8バイトの領域
section .bss
result resq 1           ; 8バイトの領域を1つ確保する
buf    resb 16          ; 1バイトの領域を16個(16バイト)確保する

3. メモリを読み書きする

アドレスと中身

NASMでは、ラベルをそのまま書くとアドレスを意味し、[ ] で囲むと、そのアドレスにある中身を意味する。

書き方意味Cでたとえると
numnum のアドレス&num
[num]num の中身(値)num

第2回で mov rsi, msg と書いたのは、文字列のアドレスを rsi に入れていたわけである。
中身を取り出す場合は、次のようにする。

mov rax, [num]      ; num の値を rax に読み込む(rax = 30)
mov [num], rax      ; rax の値を num に書き込む

mov は、レジスタ同士のほか、レジスタとメモリの間でもコピーできる。ただし、第2回で触れたとおり、メモリからメモリへ直接コピーすることはできない。必ずレジスタを経由する。

サイズを指定する

mov rax, [num] では、相手が64ビットの rax なので、8バイトを読み書きすることが決まる。しかし、レジスタを使わず、メモリに定数を書き込むときは、どれだけの大きさを書くのかを明示する必要がある。

指定大きさ
byte1バイト
word2バイト
dword4バイト
qword8バイト
mov qword [num], 5      ; num に8バイトの5を書き込む
mov byte [buf], 65      ; buf の先頭1バイトに65を書き込む

4. メモリの値を使って計算する

メモリに置いた2つの値を足して、結果をメモリに書き込むプログラムを書こう。
memory.asm という名前で作成する。作り方は第1回と同じで、vi memory.asm で開き、i で入力モードにしてコードを貼り付け、Esc を押したあとに :wq で保存して終了する。

global _start

section .data
a      dq 30
b      dq 12

section .bss
result resq 1

section .text
_start:
    mov rax, [a]        ; rax = a の値(30)
    add rax, [b]        ; rax = rax + b の値(42)
    mov [result], rax   ; result に書き込む

    mov rdi, [result]   ; result の値を終了コードにする
    mov rax, 60
    syscall

「cat memory.asm」で正しく保存されていることを確認しよう。

add rax, [b] のように、算術命令でもメモリの値を直接使える。アセンブルして実行しよう。

nasm -f elf64 memory.asm -o memory.o
ld memory.o -o memory
./memory
echo $?

実行例

42

gdbでメモリの中身を見る

gdb では、x コマンドでメモリの中身を表示できる。

gdb ./memory
(gdb) break _start
(gdb) run
(gdb) x/gd &result
(gdb) stepi 3
(gdb) x/gd &result
(gdb) x/8xb &a
  • x/gd &result:result のアドレスから、8バイト(g)を10進数(d)で表示する
  • stepi 3:3命令を続けて実行する
  • x/8xb &a:a のアドレスから、1バイトずつ(b)16進数(x)で8個表示する

最初の x/gd &result では result が0、stepi 3 のあとの x/gd &result では42と表示される。mov [result], rax で、メモリに値が書き込まれたことが確認できる。

さらに x/8xb &a では、a(30)の8バイトが、先頭のアドレスに続けて、次の順に並んで表示される(アドレスは環境によって異なるので、ここでは省略する)。

0x1e 0x00 0x00 0x00 0x00 0x00 0x00 0x00

30は16進数で 0x1e である。値が下位のバイトから先に並んでいることに注目してほしい。x86-64のCPUは、複数バイトの数値を、小さい桁(下位バイト)から順にメモリに置く。この方式をリトルエンディアンと呼ぶ。gdb でメモリを見るときに、数値の並びが逆に見えるのは、このためである。

確認が終わったら、quit と入力して Enter キーを押し、gdb を終了する。
Quit anyway? (y or n) と確認されたら、y と入力して Enter キーを押す(詳しくは第1回を参照)。

5. 配列とアドレスの計算

配列は、同じ大きさのデータがメモリ上に隣り合って並んでいるものである。dq で置いた配列では、1要素が8バイトなので、arr[1] は先頭から8バイト先、arr[2] は16バイト先にある。

アドレス:  arr     arr+8   arr+16
内容  :  10      20      30

そのため、アセンブラでは、先頭のアドレスに「要素の大きさ × 添字」を足して、目的の要素を指定する。[ ] の中では、足し算を書ける。array.asm という名前で作成する。

vi array.asm で開き、i で入力モードにしてコードを貼り付け、Esc を押したあとに :wq で保存して終了する。

global _start

section .data
arr    dq 10, 20, 30

section .text
_start:
    mov rbx, arr            ; rbx = 配列の先頭アドレス
    mov rdi, [rbx]          ; rdi = arr[0] = 10
    add rdi, [rbx + 8]      ; rdi += arr[1] → 30
    add rdi, [rbx + 16]     ; rdi += arr[2] → 60

    mov rax, 60
    syscall

cat array.asmで正しく保存されていることを確認しよう。

実行すると、終了コードは3つの要素の合計の60になる。

nasm -f elf64 array.asm -o array.o
ld array.o -o array
./array
echo $?

実行例

60

[ ] の中には、[基準 + 添字 * 倍率 + 定数] の形でも書ける。たとえば、rcx に添字が入っているとき、[rbx + rcx * 8] で arr[rcx] を指定できる。

6. 文字列を書き換える

文字列は、1バイトずつ文字コードが並んだ配列である。したがって、.data に置いた文字列は、バイト単位で書き換えられる。第2回の hello.asm を、先頭の1文字を書き換えるように変えてみよう。rewrite.asm という名前で作成する。

vi rewrite.asm で開き、i で入力モードにしてコードを貼り付け、Esc を押したあとに :wq で保存して終了する。

global _start

section .data
msg db "Hello, World!", 10
len equ $ - msg

section .text
_start:
    mov byte [msg], 'J'     ; 先頭の1文字を 'J' に書き換える

    mov rax, 1              ; write
    mov rdi, 1              ; 標準出力
    mov rsi, msg
    mov rdx, len
    syscall

    mov rax, 60             ; exit
    mov rdi, 0
    syscall

cat rewrite.asmで正しく保存されていることを確認しよう。

nasm -f elf64 rewrite.asm -o rewrite.o
ld rewrite.o -o rewrite
./rewrite

実行例

Jello, World!

mov byte [msg], 'J' で、メモリ上の先頭1バイトが H から J に書き換わり、そのまま出力された。'J' のように、1文字を引用符で囲むと、その文字コードの数値として扱われる。

反対に、メモリから1文字(1バイト)だけを読み出して、64ビットのレジスタに入れたい場合は、movzx を使う。

movzx rax, byte [msg]   ; 1バイトを読み、残りの上位ビットを0で埋める

mov rax, [msg] と書くと、8バイトをまとめて読み込んでしまう。movzx は、byte で読む大きさを指定し、残りの上位ビットを0で埋めるので、1文字の文字コードがそのまま rax の値になる。

7. サンプルコード

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

  • 03/memory.asm:メモリの値を使って計算するプログラム
  • 03/array.asm:配列の要素を足し合わせるプログラム
  • 03/rewrite.asm:文字列を書き換えて出力するプログラム
  • HappyTalk10/assembly-learning

8. 今回のまとめ

  • メモリは、1バイトごとにアドレスがつけられた大きな記憶領域である
  • .data には初期値のあるデータ(db、dw、dd、dq)、.bss には初期値のない領域(resb、resw、resd、resq)を置く
  • ラベルはアドレス、[ラベル] はその中身を意味する
  • mov はレジスタとメモリの間でコピーできるが、メモリ同士は直接コピーできない
  • メモリに定数を書き込むときは、byte、word、dword、qword でサイズを指定する
  • 配列の要素は、[先頭アドレス + 要素の大きさ * 添字] で指定できる
  • x86-64では、数値は下位のバイトから順にメモリに置かれる(リトルエンディアン)

これで、前半の3回が完結した。環境を整えて、レジスタとメモリを使って簡単なプログラムが書けるようになった。

次回からは後半に入る。第4回では、比較と分岐・ループを取り上げる。cmp と jmp で if や for に当たる処理を作り、今回学んだ配列の要素を、ループで順に足していく。

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