主記憶領域
i386はミクロンよりレジスタの個数が多いが,それでも全部合わせて数十バイトしかなく,それだけで出来ることには限りがある。 レジスタだけで足りない場合は,第0部で行ったように,主記憶装置の中に計算に使うデータを置いて使えばよい。 特に,文字列やリスト状のデータは通常,主記憶装置の中に置いて扱う。
第I部1章ですでに,主記憶装置からデータを読み出して使うプログラムは作成した(演習1.1-6〜1.1-8)。 この章ではそれらについて復習するとともに,いくつか新しい概念についても説明する。
メモリからの読み出し
下記は,演習1.1-6と同じプログラムをNasm用に書き直したものである。
; 123 + 57 を計算
section .text
global _start
_start:
mov ecx, data ; データ領域の先頭番地を代入
mov ebx, [ecx]
add ecx, 4 ; 次のデータの番地を計算
add ebx, [ecx]
mov eax, 1 ; exitのシステムコール番号
int 0x80 ; システムコール
data: dd 123, 57
演習1.1-6では,データの先頭番地を人力で計算し,ECXに代入する即値として記述していた。
アセンブラを使う場合は,具体的な番地の代わりにラベルを使うことができる。
上のプログラムのラベル data
は,dd
疑似命令で配置されるデータ列の先頭番地を表す。
mov ebx, [ecx]
や add ebx, [ecx]
の [ecx]
は「ECXが指す番地の中身」という意味になる。
疑似命令 dd
(define doubleword) は,指定された32ビット数をそのまま機械語プログラム中に出力する。
疑似命令 (pseudo-instruction) とは,「アセンブラにとっての命令だが,機械語命令(CPUにとっての命令)ではない」という意味だ。
アセンブル結果を逆アセンブルすると,dd
によって生成されたデータが機械語プログラムの後に並んでいることがわかる(下記)。この例では,ラベル data
の値,すなわちデータ列の先頭番地は 0x08049013 であり,その番地から2個のダブルワード 123 (= 0x7b) と 57 (= 0x39) がリトルエンディアンで配置されている。1個のダブルワードが4バイトを占めていることに注意。従って,2個目のダブルワードの番地 (= 0x08049017) は1個目のダブルワードの番地 (= 0x08049013) より 4 大きい。
$ nasm mem.s
$ ld mem.o
$ objdump -z -M intel -d a.out -- -zは「0をスキップしない」
-- 中略 --
08049000 <_start>:
8049000: b9 13 90 04 08 mov ecx,0x8049013
8049005: 8b 19 mov ebx,DWORD PTR [ecx]
8049007: 83 c1 04 add ecx,0x4
804900a: 03 19 add ebx,DWORD PTR [ecx]
804900c: b8 01 00 00 00 mov eax,0x1
8049011: cd 80 int 0x80
08049013 <data>:
8049013: 7b 00 jnp 8049015 <data+0x2>
8049015: 00 00 add BYTE PTR [eax],al
8049017: 39 00 cmp DWORD PTR [eax],eax
8049019: 00 00 add BYTE PTR [eax],al
32ビットの領域を確保する dd
に対し,1バイトのデータ領域を定義する db
命令 (define byte) もある。
上記プログラムの末尾の行は,以下のように書いても同じ結果になる。
data: db 123, 0, 0, 0 ; 1バイトの領域を4個確保
db 57, 0, 0, 0 ; 〃
data
というラベルはこのデータ領域の先頭番地を指しているだけで,「そこから何バイトのデータが続いているか」は表していない。
1命令で何バイトのデータが読み出されるかは,もう一方のオペランドのビット幅で決まる。
mov ecx, data
mov ebx, [ecx] ; 4バイト読み出される (オペランドは32ビット)
mov bx, [ecx] ; 2バイト読み出される (オペランドは16ビット)
ADD, SUB も同様に,レジスタオペランドのビット幅に合わせて主記憶からデータが読み出され,加算・減算が行われる。
補足: ラベルに対する演算
上記のプログラムにおいて,3番目の命令
add ecx, 4 ; 次のデータの番地を計算
を以下の命令に書き換えても,実行結果は同じである。
mov ecx, data + 4 ; 2番目のデータの番地を代入
逆アセンブルしてみると以下のようになる。
data + 4
の値 (= 0x08049019) が計算されて機械語命令中に埋め込まれていることがわかる。
$ nasm mem.s
$ ld mem.o
$ objdump -z -M intel -d a.out
-- 中略 --
08049000 <_start>:
8049000: b9 15 90 04 08 mov ecx,0x8049015
8049005: 8b 19 mov ebx,DWORD PTR [ecx]
8049007: b9 19 90 04 08 mov ecx,0x8049019
804900c: 03 19 add ebx,DWORD PTR [ecx]
804900e: b8 01 00 00 00 mov eax,0x1
8049013: cd 80 int 0x80
08049015 <data>:
8049015: 7b 00 jnp 8049017 <data+0x2>
8049017: 00 00 add BYTE PTR [eax],al
8049019: 39 00 cmp DWORD PTR [eax],eax
804901b: 00 00 add BYTE PTR [eax],al
このように,ラベルや定数からなる式をプログラム中に書いた場合,アセンブラがその値を計算して値に置き換える。
補足: レジスタ間接指定と絶対番地指定
読み出す番地を指定する際,i386ではレジスタを介さずに番地を直接指定することもできる。
アセンブリ表記では,[ ]
の中にレジスタではなく番地を直接書く。これを絶対番地指定と言う。
上記のプログラムを絶対番地指定を使って書き直すと,以下のようになる。
; 123 + 57 を計算
section .text
global _start
_start:
mov ebx, [data] ; 先頭のダブルワードをebxに読み出す
add ebx, [data + 4] ; 2番目のダブルワードを加算する
mov eax, 1 ; exitのシステムコール番号
int 0x80 ; システムコール
data: dd 123, 57
逆アセンブルすると以下のようになる。 命令中にオペランドとして番地が埋め込まれていることがわかる。
$ nasm mem.s
$ ld mem.o
$ objdump -z -M intel -d a.out
-- 中略 --
08049000 <_start>:
8049000: 8b 1d 13 90 04 08 mov ebx,DWORD PTR ds:0x8049013
8049006: 03 1d 17 90 04 08 add ebx,DWORD PTR ds:0x8049017
804900c: b8 01 00 00 00 mov eax,0x1
8049011: cd 80 int 0x80
08049013 <data>:
8049013: 7b 00 jnp 8049015 <data+0x2>
8049015: 00 00 add BYTE PTR [eax],al
8049017: 39 00 cmp DWORD PTR [eax],eax
8049019: 00 00 add BYTE PTR [eax],al
逆に,レジスタを介して番地を指定することをレジスタ間接指定と言う。 第0部で扱ったミクロンは,絶対番地指定はできず,レジスタ間接指定のみ可能だった。
レジスタ間接指定や絶対番地指定のような,番地の指定の仕方それぞれをアドレッシングモード (addressing mode) と言う。
より正確に言うと,各命令におけるオペランド(被演算数)の指定の仕方をアドレッシングモードと言う。
例えば,命令 mov ebx, [ecx]
において,第1引数のアドレッシングモードは「レジスタ」,第2引数のアドレッシングモードは「レジスタ間接指定メモリ」である。
同様に,add ebx, [data + 4]
の第2引数のアドレッシングモードは「絶対番地指定メモリ」である。
どの命令においてどのアドレッシングモードが使えるかはCPUごとに異なる。
繰り返しとの組み合わせ
下記は,data
番地から始まる5つのダブルワードの合計を計算するプログラムだ。
; data番地から始まる5個のダブルワードの和を計算
section .text
global _start
_start:
mov edx, data ; データ領域の先頭番地
mov ecx, 5 ; データ数
mov ebx, 0 ; 累算器の初期化
loop:
add ebx, [edx] ; 累算器に加算
add edx, 4 ; 次のデータの番地
dec ecx ; 残りデータ数を1減らす
jnz loop ; 残りデータ数 > 0 ならloopに戻る
mov eax, 1 ; exitのシステムコール番号
int 0x80 ; システムコール
data: dd 123, 57, 11, 13, 17
番地を格納しているレジスタEDXの値を4ずつ増やすことで,連続して並んでいるダブルワードを順に読み出し,加算することができる。
このように,繰り返しとメモリへのアクセスを組み合わせて使う場合,レジスタ間接指定が必須である。 高級言語において「配列の添字を変数にすることで,繰り返しのたびに異なる記憶要素にアクセスする」のと同じだ。
補足: 名前付き定数としてのラベル(EQU疑似命令)
上記プログラムの2命令目でECXに代入している「データ数」5 は,データの個数が変わったらそのたびに変更しなければならない。
このような「あとで変更される可能性が高い値」は,命令列の中に埋没させずに,ndata のような「名前の付いた定数」として定義して使うのが望ましい。
例えばC言語では,#define ndata 5
のように #define
文で名前付き定数を定義する。
その方が変更も容易だし,5 と書いてあるより ndata と書いてある方が意味もわかりやすくなる。
アセンブリ言語では,名前付き定数を定義するのに疑似命令 equ
(equal) を使う。
下記は,上記のプログラムの「データ数」を,名前付き定数 ndata
に置き換えた例だ。(equ
を「=」と思って読めばわかりやすい。)
ndata equ 5 ; データ数
...
mov ecx, ndata ; データ数
...
data: dd 123, 57, 11, 13, 17
equ
は ラベル equ 値
の形で使う。(コロンを付けて ラベル: equ 値
でもよい。)
ndata
を定義する位置はプログラム中のどこでもよい。section
等より前でも良いし,下記のようにデータ定義部の近くで ndata
を定義してもよい。
; 使う場所より下でラベルを定義してもよい
...
mov ecx, ndata ; データ数
...
data: dd 123, 57, 11, 13, 17
ndata equ 5 ; データ数
参考: データ数の自動計算
データ数(上の例では「5」)を手で書く代わりに,アセンブラに計算させることもできる。 (プログラミング一般の作法として,自動化できることはそうした方が,楽だし誤りも防げる。) 例えば以下のように記述すればよい。
data: dd 123, 57, 11, 13, 17 ; 32ビットデータの列
ndata equ ($ - data)/4 ; データ数 (= 総バイト数/4バイト)
定義済みラベル $
は,「プログラム上のその行の番地」を表す。
もし下記のように書けば,nbyte
は data
以降のデータの総バイト数に等しくなる。
data: dd 123, 57, 11, 13, 17 ; 32ビットデータの列
nbyte equ $ - data ; 総バイト数 (= この行の番地とdataの差)
総バイト数を4で割れば,ダブルワードの個数に等しくなる。
(当然だが,上記のような ndata
や nbyte
の定義は「$
がデータ列の直後の番地を指している」ことを前提としているので,データ列の定義の直後に書かなければならない。)
参考: ディスプレースメント付きレジスタ間接指定
下記は,上記のプログラムを少し変更した例だ。
EDXの初期値は0で,ループの中で 0, 4, 8, 12, ... という値をとる。
add ebx, [data + edx]
で,「data + edx
番地」の値が読み出されて加算される。
[data + edx]
のような指定を「ディスプレースメント付きレジスタ間接指定」と言う。この例ではdata
が「ディスプレースメント(変位量)」だ。[edx + data]
と書いてもよい。
; data番地から始まるndata個の数の和を計算
section .text
global _start
_start:
mov edx, 0 ; データ読み出し位置の初期化
mov ecx, ndata ; データ数
mov ebx, 0 ; 累算器の初期化
loop:
add ebx, [data + edx] ; 累算器に加算
add edx, 4 ; 次のデータの番地
dec ecx
jnz loop ; 残りデータ数 > 0 ならloopに戻る
mov eax, 1 ; exitのシステムコール番号
int 0x80 ; システムコール
data: dd 123, 57, 11, 13, 17
ndata equ ($ - data)/4 ; ダブルワードの個数
[data + 4]
と [data + edx]
は,アセンブリ言語上の表記は似ているが,扱いは全く別だ。
前者の +
は,アセンブル時に計算されて,計算結果の定数が機械語命令の中に埋め込まれる。
後者は「ディスプレースメント付きレジスタ間接指定という種類の機械語命令」として,data
の値とレジスタ番号の両方が機械語命令の中に引数として埋め込まれる。
例えば以下の4命令をアセンブルし,結果を逆アセンブルすると,下記のようになる。
add ebx, [data]
add ebx, [data + 4]
add ebx, [edx]
add ebx, [data + edx]
8049000: 03 1d 14 90 04 08 add ebx,DWORD PTR ds:0x8049014
8049006: 03 1d 18 90 04 08 add ebx,DWORD PTR ds:0x8049018
804900c: 03 1a add ebx,DWORD PTR [edx]
804900e: 03 9a 14 90 04 08 add ebx,DWORD PTR [edx+0x8049014]
data + 4
は 0x08049018 という値に置き換えられている。
そのため,add ebx, [data]
も add ebx, [data + 4]
もどちらも絶対番地指定の機械語命令に翻訳されている。
一方,add ebx, [data + edx]
は,ディスプレースメントである data
の値 0x08049014 とレジスタ EDX のどちらも引数として含む機械語命令に翻訳されている。
後の章で [esp + 8]
,[esp + 12]
といった記述を使うが,これもディスプレースメント付きレジスタ間接指定だ。ESPが指す番地から8バイトまたは12バイト離れた主記憶内領域からデータを読み出すときに使う。
- 参考:i386はもう少し複雑なアドレッシングモードも持っていて,例えば
[data + edx * 4]
という指定もできる。意味は見た目通りで,EDXの値の4倍とdata
の和を番地とする主記憶内領域にアクセスする。 このように指定できると,配列の要素の読み書きに便利である(この指定で,data
番地から始まるデータ列の edx 番目のダブルワードの番地を得ることができる)。 さらに,レジスタを2つ使った[ebx + edx * 4]
や[data + ebx + edx * 4]
という指定もできる。 もちろんどんな式でも記述できるわけではなく,[レジスタ + レジスタ * 倍率 + ディスプレースメント]
の形が最大で,倍率は 1, 2, 4, 8 のいずれかに限る。
書き込み可能データ領域
下記のプログラムは,data1
番地に置かれた2個のダブルワードの和をdata2
番地に書き込み,その後,data2
番地の値を終了コードとして出力する。
mov [data2], eax
のように,ディスティネーションオペランドを主記憶内領域にすれば,その領域に数値が書き込まれる。
; 123 + 57をdata2に書き込む (実行時エラー発生)
section .text
global _start
_start:
mov eax, [data1]
add eax, [data1 + 4]
mov [data2], eax ; data2番地に書き込む
mov eax, 1 ; exitのシステムコール番号
mov ebx, [data2] ; data2番地から読み出す
int 0x80 ; システムコール
data1: dd 123, 57
data2: dd 0 ; ダブルワード1個分の領域を確保
ただし,このプログラムを実行するとセグメンテーション違反 (segmentation fault) という実行時エラーが発生する。
(なお,下記の例の「(コアダンプ)
」は「エラー発生時のメモリの中身をファイルに保存した」という意味で,エラー自体とは無関係。
参考: セグメンテーション違反,
コアダンプ)
$ nasm mem.s
$ ld mem.o
$ ./a.out
Segmentation fault (コアダンプ)
何がいけないのだろうか?
実はi386用Linuxでは,プログラムを主記憶に読み出して実行開始する際,プログラムを格納する主記憶内領域(セグメント)に「読み出し専用」という印を付けてから実行する。 「読み出し専用」である領域に書き込みを行う命令を実行しようとすると,CPUがその命令の実行を止め,OSに制御を移す。 これは,「実行中にプログラム自身を書き換えてしまう」ことを防ぐためだ。 プログラムの誤りで自身を書き換えてしまうと暴走の原因になるし,悪意を持ってプログラムの動作を変えようとする攻撃を防ぐ意味もある。
従って,書き込み可能データ領域は,プログラム本体とは別の場所に配置しなければならない。
具体的には,「.text セクションではなく .data セクション」の中でデータ領域を定義すればよい。以下のようにsection疑似命令を追加すれば,それ以降(次にsection疑似命令が現れるまで) .data セクションになる。
i386用Linuxのld
コマンドは,.text という名前のセクションに機械語プログラム本体,.data というセクションに初期値付きデータ領域が置かれていると解釈して,実行可能ファイルの生成を行う。
-- 略 --
section .data ; .dataセクションの開始
data1: dd 123, 57
data2: dd 0 ; ダブルワード1個分の領域を確保
- 参考:このような記憶領域保護の仕組みは,本科目の範囲を超えるので詳細は省略する。 ここでは,(Linux上で実行するプログラムの場合)命令列は .text セクション,データは .data セクションに置けばよいこと,.text セクションの中身は実行中書き換えできないこと,を知っていればよい。(OSがない環境では,命令列とデータを区別する必要はない。)
補足: データ読み出し時のセグメンテーション違反
セグメンテーション違反は,読み出し時にも起こり得る。 例として,上述の「data番地から始まる5個のダブルワードの和を計算するプログラム」において,ECXの初期値 5 を 1030 に変えて実行してみなさい。
...
mov ecx, 1030 ; データ数
...
data: dd 123, 57, 11, 13, 17
$ nasm sum.s
$ ld sum.o
$ ./a.out
Segmentation fault (コアダンプ)
今度は,アクセスが許可されていない領域にアクセスしたことによるセグメンテーション違反が起こった。
基本的に,プログラム中で定義していない主記憶領域へのアクセスは禁止されている。
上記のプログラムの場合,dd
命令で確保した5個のダブルワード(20バイト)の範囲しか(本来は)アクセスできない。
実際は,WS室の計算機環境では記憶領域の割当量が4096バイト単位なので,多少はみ出してアクセスしてもエラーにならない場合がある(上記の例でも,5個のデータに対して1030と大幅にはみ出すことでエラーになっていた)。しかし,はみ出して読み出した領域にごみデータがあれば計算を誤る可能性があるし,データ列の末尾がちょうどセグメントの末尾である場合は1バイト分でもはみ出してアクセスすればエラーになる。「多少はみ出してもよい」と考えるのは不適切である。 (例えばJavaでは,配列の添字が1要素分でも範囲を超えれば ArrayIndexOutOfBoundsException が発生する。それと同じと考えればよい。)
(提出物の自動検査では,計算対象データ列の外にアクセスするプログラムを不合格とする場合がある。 過去の授業の提出物にも,計算対象データ列の直後の1要素にアクセスするものがしばしば存在した。)
参考: 大きい領域の確保(TIMES疑似命令)
上述の書き込みを行うプログラム例では,data2
番地の初期値は特段必要ないが,ダブルワード1個分の領域を確保するために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個分の初期値なし領域を確保
初期値なし領域は,.o ファイルや実行可能ファイルの中に「領域の大きさ」だけ記述すればよいので,ファイルサイズを節約できる。
参考: オペランドサイズの指定
mov [data2], eax
を実行すると,EAXの値がdata2から始まる4バイトに書き込まれる。この場合,ソースオペランドがレジスタなので,オペランドの大きさ(バイトかワードかダブルワードか)が明白だ。
一方,ソースオペランドが即値の場合(下記),オペランドの大きさが曖昧なのでエラーになる。
mov [data2], 169 ; エラー (オペランドサイズが曖昧)
このような場合は,どちらかのオペランドの前に byte または word または dword と書いて,オペランドのビット幅を指定すればよい。一方の大きさを指定すれば,他方は「それと同じ」と解釈される。
mov dword [data2], 169 ; ok。4バイトの領域に書き込む
mov [data2], dword 169 ; こう書いてもよい。4バイトの即値を書き込む