主記憶領域
これまでの授業で作ったプログラムは,記憶装置としてレジスタしか使わなかった。 レジスタの記憶容量は全部合わせても数十バイトしかなく,それだけでは出来ることに限りがある。 一方,機械語プログラム自身は主記憶装置の中に置かれ,1命令ずつCPUに読み出されて実行されるが,計算に使うデータや計算の途中結果も,同じように主記憶装置の中に置くことができる。 特に,文字列やリスト状のデータを扱うためには,それらを主記憶装置内の領域に配置する必要がある。
さて,今回の目標は以下のプログラムを作ることだ。
- EAXレジスタの値を10進数で端末画面に出力するプログラム
これまで終了コードを介して出力を行うプログラムを作ってきたが,使いにくい(利用者への思いやりに欠ける)し,0〜255の範囲の数しか出力できないなどの制約もある。 printfが行っているような「数値から10進数字列への変換」を自作し,数値を10進数で出力するという「普通の出力」ができるようにすることが,今回の課題だ。 そのためには,前回までの「算術演算」と「ジャンプ命令」に加えて,主記憶装置を使う方法を学ぶ必要がある。プログラムの外部への出力を行う「writeシステムコール」に,出力内容を「文字列」で渡さなければならないからだ。
読み出し専用のデータ
主記憶内にデータを置き,それを使う方法を見ていこう。 下記のプログラムは,主記憶内に3つの32ビット数を置き,それらに対する加減算 (123 + 57 − 11) を行って結果を出力する。
; 123 + 57 - 11 を計算
section .text
global _start
_start:
mov ebx, [data1]
add ebx, [data2]
sub ebx, [data3]
mov eax, 1 ; システムコール番号
int 0x80 ; exitシステムコール
data1: dd 123
data2: dd 57
data3: dd 11
[data1]
のように [ ]
で囲んだオペランドは,「主記憶内の指定された番地に格納された値を使う」ことを表す。もしラベル data1 の指す番地が 0x8048079 なら,mov ebx, [data1]
は mov ebx, [0x8048079]
と書くのと同じだ。これを実行すると,0x8048079番地から始まる32ビット (= 4バイト) のデータを読み出してEBXに代入する。ADD, SUB も同様に,主記憶からデータを読み出し,加算または減算を行う。
DD (define doubleword) は,指定された32ビット数をそのまま機械語プログラム中に出力する疑似命令だ。 疑似命令とは「アセンブラにとっての命令だが,機械語命令(CPUにとっての命令)ではない」という意味だ。 アセンブル結果を逆アセンブルすると,DD命令によって生成されたデータが機械語プログラムの後に並んでいることがわかる(下記)。 0x8048079番地から始まる4バイト 7b 00 00 00 は,0x0000007b (= 123) を表す(i386 はリトルエンディアン方式(下位桁のバイトが前,上位桁のバイトが後)で16ビット以上の数を格納する)。 0x804807d番地からの 39 00 00 00 (= 0x39 = 57), 0x8048081番地からの 0b 00 00 00 (= 0x0b = 11) も同様だ。
$ nasm mem.s ; ld mem.o
$ objdump -z -M intel -d a.out -- -zは「0をスキップしない」
-- 中略 --
08048060 <_start>:
8048060: 8b 1d 79 80 04 08 mov ebx,DWORD PTR ds:0x8048079
8048066: 03 1d 7d 80 04 08 add ebx,DWORD PTR ds:0x804807d
804806c: 2b 1d 81 80 04 08 sub ebx,DWORD PTR ds:0x8048081
8048072: b8 01 00 00 00 mov eax,0x1
8048077: cd 80 int 0x80
08048079 <data1>:
8048079: 7b 00 jnp 804807b <data1+0x2>
804807b: 00 00 add BYTE PTR [eax],al
0804807d <data2>:
804807d: 39 00 cmp DWORD PTR [eax],eax
804807f: 00 00 add BYTE PTR [eax],al
08048081 <data3>:
8048081: 0b 00 or eax,DWORD PTR [eax]
8048083: 00 00 add BYTE PTR [eax],al
32ビットの領域を確保する DD に対し,1バイトのデータ領域を定義する DB 命令 (define byte) もある。 上記プログラムの末尾3行は,以下のように書いても同じ結果になる。
data1: db 123, 0, 0, 0 ; 1バイトの領域を4個確保
data2: db 57, 0, 0, 0 ; 〃
data3: db 11, 0, 0, 0 ; 〃
data1というラベルはこのデータ領域の先頭番地を指しているだけで,「そこから何バイトのデータが続いているか」は表していない。 1命令で何バイトのデータが読み出されるかは,もう一方のオペランドのビット幅で決まる。
mov ebx, [data1] ; 4バイト読み出される (オペランドは32ビット)
mov bx, [data1] ; 2バイト読み出される (オペランドは16ビット)
ADD, SUB も同様に,レジスタオペランドのビット幅に合わせて主記憶からデータが読み出され,加算あるいは減算が行われる。
data1のようなラベルは,番地を値とする「名前付き定数」と考えればよい。
data1 が 0x8048079 番地を指している場合,mov ebx, [data1]
は mov ebx, [0x8048079]
と同じだし,mov ebx, data1
は mov ebx, 0x8048079
と同じだ。前者は「主記憶に格納されている値をレジスタに代入」し,後者は「即値をレジスタに代入」する。
mov ebx, [data1] ; data1番地に格納されている値をebxに代入
mov ebx, data1 ; data1が指す番地 (即値) をebxに代入
補足: ラベルに対する演算
すべてのデータにラベルを付ける必要はない。 例えば下記のように,ラベル data1 からの相対位置でデータの位置を指定することもできる。
; 123 + 57 - 11 を計算
section .text
global _start
_start:
mov ebx, [data1]
add ebx, [data1 + 4]
sub ebx, [data1 + 8]
mov eax, 1 ; システムコール番号
int 0x80 ; exitシステムコール
data1: dd 123
dd 57
dd 11
DD命令を3行書く代わりに以下のように書いてもよい。
data1: dd 123, 57, 11 ; 32ビットの領域を3個確保
逆アセンブルするとわかるが,ラベル data2, data3 が無い以外は先のプログラムと全く同一だ。
$ objdump -z -M intel -d a.out
-- 中略 --
08048060 <_start>:
8048060: 8b 1d 79 80 04 08 mov ebx,DWORD PTR ds:0x8048079
8048066: 03 1d 7d 80 04 08 add ebx,DWORD PTR ds:0x804807d
804806c: 2b 1d 81 80 04 08 sub ebx,DWORD PTR ds:0x8048081
8048072: b8 01 00 00 00 mov eax,0x1
8048077: cd 80 int 0x80
08048079 <data1>:
8048079: 7b 00 jnp 804807b <data1+0x2>
804807b: 00 00 add BYTE PTR [eax],al
804807d: 39 00 cmp DWORD PTR [eax],eax
804807f: 00 00 add BYTE PTR [eax],al
8048081: 0b 00 or eax,DWORD PTR [eax]
8048083: 00 00 add BYTE PTR [eax],al
[data1 + 4]
という記述では +
が使われているが,アセンブラが「data1が指す番地に4加えた番地」を計算して機械語プログラム中に出力し,機械語プログラム中では単なる番地に置き換わる。
(通常のコンピュータでは)番地は1バイトごとに付けられているため,ダブルワードのデータが並んでいる場合は次のデータは4番地後ろになる。
レジスタ間接指定
レジスタを使って主記憶内領域を指定することもできる。これを「レジスタ間接指定」と言う
(逆に,[data1]
のように番地(ラベル)を直接指定することを「絶対番地指定」と言う)。
; 123 + 57 - 11 を計算
section .text
global _start
_start:
mov ecx, data1 ; データ領域の先頭番地を代入
mov ebx, [ecx]
add ecx, 4 ; 次のデータの番地を計算
add ebx, [ecx]
add ecx, 4 ; 次のデータの番地を計算
sub ebx, [ecx]
mov eax, 1 ; システムコール番号
int 0x80 ; exitシステムコール
data1: dd 123, 57, 11
mov ecx, data1
は,data1 番地に格納されている中身ではなく,番地そのものをECXに代入する。
2番目の命令
mov ebx, [ecx]
のように,レジスタを [ ]
で囲むと,「レジスタが指す番地の中身」という意味になる。
レジスタ間接指定は特に,繰り返しと組み合わせて使うのに役立つ。 下記は,data1から始まる領域に置かれた5つの数の合計を計算するプログラムだ。
; data1から始まる5数の和を計算
section .text
global _start
_start:
mov edx, data1 ; データ領域の先頭番地
mov ecx, 5 ; データ数
mov ebx, 0 ; 累算器の初期化
loop0:
add ebx, [edx] ; 累算器に加算
add edx, 4 ; 次のデータの番地
dec ecx
jnz loop0 ; 残りデータ数 > 0 ならloop0に戻る
mov eax, 1 ; システムコール番号
int 0x80 ; exitシステムコール
data1: dd 123, 57, 11, 13, 17
番地を格納しているレジスタEDXの値を4ずつ増やすことで,連続して並んでいるデータを順に読み出し,加算することができる。
補足: EQU疑似命令,データ数の自動計算
上記プログラムにおいて,末尾のデータ定義部を変更したら「データ数」(= 5) も変更しなければならない。 このような数は,変更し忘れを防ぐため,プログラムの中に埋没させない方がよい。 例えばC言語では,このような数は #define 文を使って「名前付き定数」として定義するのが慣習だ。 アセンブリ言語では,名前付き定数を定義する疑似命令である EQU (equal) を使う。 下記は,上記のプログラムの「データ数」を,名前付き定数 ndata に置き換えた例だ。
-- 略 --
mov ecx, ndata ; データ数
-- 略 --
data1: dd 123, 57, 11, 13, 17
ndata: equ 5 ; データ数
EQU は ラベル: EQU 値
の形で使う。「左辺(ラベル)に右辺(値)を代入する」と読めばよい。
ndata を定義する位置はプログラム中のどこでもよいが,上の例ではデータ定義部の近くに ndata の定義を書いている。
データ数(上の例では「5」)を手で書く代わりに,アセンブラに計算させることもできる (プログラミング一般の作法として,自動化できることはそうした方が,楽だし誤りも防げる)。 例えば以下のように記述すればよい。
data1: dd 123, 57, 11, 13, 17 ; 32ビットデータの列
ndata: equ ($ - data1)/4 ; データ数 (= 総バイト数/4バイト)
定義済み変数 $
は,「プログラム上のその行の番地」を表す。
もし下記のように書けば,nbyte はdata1以降のデータの総バイト数に等しくなる。
data1: dd 123, 57, 11, 13, 17 ; 32ビットデータの列
nbyte: equ $ - data1 ; 総バイト数 (= この行の番地とdata1の差)
1個のデータが4バイトなので,総バイト数を4で割れば,データの個数に等しくなる。
参考: ディスプレースメント付きレジスタ間接指定
下記は,上記のプログラムを少し変更した例だ。
EDXの初期値は0で,ループの中で 0, 4, 8, 12, ... という値をとる。
add ebx, [data1 + edx]
で,「data1からEDXバイト後ろ」の値が読み出されて加算される。
[data1 + edx]
のような指定を「ディスプレースメント付きレジスタ間接指定」と言う。data1が「ディスプレースメント(変位量)」だ。[edx + data1]
と書いてもよい。
; data1から始まる5数の和を計算
section .text
global _start
_start:
mov edx, 0 ; データ読み出し位置の初期化
mov ecx, 5 ; データ数
mov ebx, 0 ; 累算器の初期化
loop0:
add ebx, [data1 + edx] ; 累算器に加算
add edx, 4 ; 次のデータの番地
dec ecx
jnz loop0 ; 残りデータ数 > 0 ならloop0に戻る
mov eax, 1 ; システムコール番号
int 0x80 ; exitシステムコール
data1: dd 123, 57, 11, 13, 17
[data1 + 4]
と [data1 + edx]
は,アセンブリ言語上の表記は似ているが,扱いは全く別だ。
前者の +
は,アセンブル時に計算されて,計算結果の定数が機械語命令の中に出力される。
後者は「ディスプレースメント付きレジスタ間接指定という種類の機械語命令」として,data1の値とレジスタ番号の両方が機械語命令の中に出力される。
例えば以下の4命令をアセンブルし,結果を逆アセンブルすると,下記のようになる。
add ebx, [data1]
add ebx, [data1 + 4]
add ebx, [edx]
add ebx, [data1 + edx]
8048060: 03 1d 74 80 04 08 add ebx,DWORD PTR ds:0x8048074
8048066: 03 1d 78 80 04 08 add ebx,DWORD PTR ds:0x8048078
804806c: 03 1a add ebx,DWORD PTR [edx]
804806e: 03 9a 74 80 04 08 add ebx,DWORD PTR [edx+0x8048074]
[data1]
と [data1 + 4]
は,参照する番地が違うだけで,同じ機械語命令に翻訳されている。
番地の指定方法は機械語命令の第2バイトで表され,使用するレジスタ番号も第2バイトに格納されている。
絶対番地指定やディスプレースメント付きレジスタ間接指定の場合,番地またはディスプレースメントの値がその後に続く。
後の章で [esp + 8]
,[esp + 12]
といった記述を使うが,これもディスプレースメント付きレジスタ間接指定だ。ESPが指す番地から8バイトまたは12バイト離れた主記憶内領域からデータを読み出すときに使う。
書き込み可能データ領域
下記のプログラムは,data1を先頭番地とする3つの32ビット数に対して加減算を行い,結果をdata2が指す番地に書き込む。
mov [data2], eax
のように,ディスティネーションオペランドを主記憶内領域にすれば,その領域に数値が書き込まれる。
data2が指す記憶領域も,レジスタと同じように,数値を書き込んだり,値を計算に使ったりすることができる(レジスタ不足を解決できる)。
; 123 + 57 - 11をdata2に書き込む (実行時エラー発生)
section .text
global _start
_start:
mov eax, [data1]
add eax, [data1 + 4]
sub eax, [data1 + 8]
mov [data2], eax ; data2番地に書き込む
mov eax, 1 ; システムコール番号
mov ebx, [data2] ; data2番地から読み出す
int 0x80 ; exitシステムコール
data1: dd 123, 57, 11
data2: dd 0 ; ダブルワード1個分の領域を確保
ただし,このプログラムを実行すると実行時エラーが発生する。
$ nasm mem.s
$ ld mem.o
$ ./a.out
セグメンテーション違反です
何がいけないのだろうか?
実はi386用Linuxでは,プログラムを主記憶に読み出して実行開始する際,プログラムを格納する主記憶内領域(セグメント)に「読み出し専用」という印を付けてから実行する。 「読み出し専用」である領域に書き込むような命令を実行しようとすると,CPUがその命令の実行を止め,OSに制御を移す。 これは,「実行中にプログラム自身を書き換えてしまう」ことを防ぐためだ。 プログラムの誤りで自身を書き換えてしまうと暴走の原因になるし,悪意を持ってプログラムの動作を変えようとする攻撃を防ぐ意味もある。
従って,書き込み可能データ領域は,プログラム本体とは別の場所に配置しなければならない。 具体的には,「.text セクションではなく .data セクション」の中でデータ領域を定義すればよい。以下のようにsection疑似命令を追加すれば,それ以降(次にsectionが現れるまで) .data セクションになる。 i386用Linuxのldは,.text という名前のセクションに機械語プログラム本体,.data というセクションに初期値付きデータ領域が置かれていると解釈して,実行可能ファイルの生成を行う。
-- 略 --
section .data ; .dataセクションの開始
data1: dd 123, 57, 11
data2: dd 0 ; ダブルワード1個分の領域を確保
ここまでのプログラム例では,読み出し専用のデータ領域は .text セクションに置いていたが,これも .data セクションに置く方が,.text と .data の使い分けとして本来正しい。 以降のプログラム例では,データ領域は .data セクションに置く。
補足: TIMES疑似命令
上記プログラム例で,data2の中の初期値は特段必要ないが,領域を確保するためにDD命令を使っている。
もしダブルワード100個分の領域を作りたい場合,どうすればよいだろうか?
そのようなときには疑似命令 TIMES を使うと便利だ。
これは,「指定した回数だけ指定した命令を繰り返す」命令で,times 回数 命令
の形で使う。
section .data
data2: times 100 dd 0 ; ダブルワード100個分の領域を確保
参考:別の方法として,「初期値なしデータ領域」を作る命令もある。 初期値なしデータ領域は .data ではなく .bss というセクションに置くことになっている (.dataセクションに初期値なし領域を作ると警告が出る。逆に.bssセクションに初期値付き領域を作っても警告が出る)。 初期値なし領域を作る疑似命令として RESB (reserve bytes), RESD (reserve doublewords) がある。
section .bss data2: resd 100 ; ダブルワード100個分の初期値なし領域を確保
初期値なし領域は,オブジェクトファイルや実行可能ファイルの中に「領域の大きさ」だけ記述すればよいので,ファイルサイズを節約できる。
補足: オペランドサイズの指定
mov [data2], eax
を実行すると,EAXの値がdata2から始まる4バイトに書き込まれる。この場合,ソースオペランドがレジスタなので,オペランドの大きさ(バイトかワードかダブルワードか)が明白だ。
一方,ソースオペランドが即値の場合(下記),オペランドの大きさが曖昧なのでエラーになる。
mov [data2], 169 ; エラー (オペランドサイズが曖昧)
このような場合は,どちらかのオペランドの前に byte または word または dword と書いて,オペランドのビット幅を指定すればよい。一方の大きさを指定すれば,他方は「それと同じ」と解釈される。
mov dword [data2], 169 ; ok。4バイトの領域に書き込む
mov [data2], dword 169 ; こう書いてもよい。4バイトの即値を書き込む