主記憶領域

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バイト)

定義済みラベル $ は,「プログラム上のその行の番地」を表す。 もし下記のように書けば,nbytedata 以降のデータの総バイト数に等しくなる。

data:   dd      123, 57, 11, 13, 17     ; 32ビットデータの列
nbyte   equ     $ - data                ; 総バイト数 (= この行の番地とdataの差)

総バイト数を4で割れば,ダブルワードの個数に等しくなる。

(当然だが,上記のような ndatanbyte の定義は「$ がデータ列の直後の番地を指している」ことを前提としているので,データ列の定義の直後に書かなければならない。)

参考: ディスプレースメント付きレジスタ間接指定

下記は,上記のプログラムを少し変更した例だ。 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バイトの即値を書き込む

results matching ""

    No results matching ""