社内でやった勉強会の資料です。いちおう、これだけ読んでもわかるように書いたつもりです。ご自由にご利用ください。
なにげなく使っているコンピュータ。でもコンピュータはマシン語で動いています。マシン語を知ることはコンピュータの仕組みを知ること。本書を読むことで、これまでブラックボックスであった“本当のコンピュータ”を学べます。
「はじめて読む8086」帯の惹句より
... and I show you how deep the rabbit-hole goes.
- Morpheus, "The Matrix"
勉強会の目的
とりあえずプログラム実行
- emu8086を起動
- newして、以下をコピー
- emulateで実行する
- original source codeのウインドウ上で「sum」の行をクリックする
- emulatorウインドウで、0111hにあるワードが0000hであることを確認
- single stepでプログラムを最後まで流す
- emulatorウインドウで、0111hにあるワードが0300hになっていることを確認
- val_aとval_bを変えて実行して、結果が変わることを確認
; プログラム1(sum2.asm) org 100h mov ax, val_a mov bx, val_b add ax, bx mov sum, ax ret val_a dw 0100h val_b dw 0200h sum dw 0
sum2.asmの解説
; プログラム1(sum2.asm) org 100h ;オリジン(アドレス)を100hにする(擬似命令) mov ax, val_a ;val_aの値をAXレジスタにロード mov bx, val_b ;val_aの値をBXレジスタにロード add ax, bx ;AXレジスタとBXレジスタの和をAXレジスタに設定 mov sum, ax ;AXレジスタの値をsumにストア ret ;プログラム終了 val_a dw 0100h ;メモリ上の領域にval_aと名づけ、0100h(16bit)に初期化 val_b dw 0200h ;メモリ上の領域にval_bと名づけ、0200h(16bit)に初期化 sum dw 0 ;メモリ上の領域にsumと名づけ、0(16bit)に初期化
org擬似命令により、その直後の命令のアドレスが100hとなる。プログラム本体が100hで始まり、retでプログラムが終了するのをCOM形式と呼ぶ。現在の環境だと動かない(と思ったら、DOSプロンプトから実行可能なのね。16bitモードってこういうとき起動するのか)が、昔はこれが一番書くのもロードするのも簡単な形式だった。
MOVは値をコピーする命令のニーモニックで、方向は常に右から左。つまり、
MOV dst, src
という意味。なお、引数はオペランドと呼ぶ。dstとsrcは、レジスタ、メモリ、即値(イミディエイト)が指定できる。あ、当然dstに即値は来ません。メモリ→レジスタをロード、レジスタ→メモリをストアと呼ぶ。
mov ax, val_aは、AXレジスタにval_aの値をコピーする。val_aは下の方で定義しているラベル(変数ではない。8086というかマシン語に変数はない!)で、dw擬似命令によって1ワード=16bitの領域に0100hと書いてある部分になる。
ラベルとは、メモリ上のあるアドレスにつける名前。emulatorウインドウで見ると、この命令は
MOV AX, [0010Dh]
となっている。val_aというラベルが、アセンブラによって0010Dhという実アドレス(オフセット指定)に変換されていることがわかる。
- 質問: じゃあval_bとsumのアドレスはいくつ?それはなぜ?
- 質問: val_aが0010Dhになってるのはなぜ?
mov bx, val_bは上の行と同様。次のadd ax, bxも、見たとおりaxとbxを足すシンプルな命令。結果は左のオペランドに入る。
mov sum, axで、上の計算結果がsum(メモリ)にストアされる。このプログラムは、計算結果をメモリに格納して終了する。
終了するのが、次のret。これはemu8086の仕様だったかCOMモデルの仕様だったかうろおぼえだが、とにかくここではプログラムの実行を終了させる働きをする。
さて、ここまで、ごくシンプルながらマシン語(アセンブリ言語)のプログラムを見てきた。意外と普通に見えるかもしれないが、アセンブリ言語が面白いのはまだまだこれから。続いては、アセンブリ言語で制御構造を書く方法を検討する。
- その前に: 和に加えて、2つの数の差もメモリに残すようにせよ
- 質問: ロード/ストアの回数を減らす方法はあるだろうか?誰でも知っているように、メモリアクセスは遅い。
- さらに: 積と商もメモリに残すようにせよ
- 質問: 積と商について、プログラムを書く前に決める必要がある仕様はなにか
マシン語のプログラムとは
- 質問: プログラムの制御構造の基本はなにか(ダイクストラの構造化的に)
1. メモリやI/Oからレジスタにデータを転送する。
2. レジスタに記憶されているデータに対して演算をおこなう。
3. レジスタからメモリやI/Oにデータを転送する。マシン語のプログラムは、最終的にはたったこれだけのことの組み合わせでしかありません。
(p.96「はじめて読む8086」村瀬康治監修 蒲池輝尚著、アスキー出版 以下、特にことわらない限り引用は本書からとする)
データの転送と演算しかできないマシン語でさまざまな処理が実現できる方法を理解するには、まずレジスタとフラグを知っておく必要がある。
レジスタとはCPU内の小さな記憶装置で、1つのレジスタにつき1ワード(=16bit)の値を保持しておくことができる。8086には以下のレジスタがある。(p.98から一部抜粋)
レジスタ | 名称 | 固有の機能 |
---|---|---|
汎用レジスタ | ||
AX | アキュムレータ | 各種の演算(他のレジスタより高速)、乗除算 |
BX | ベースレジスタ | 特定のメモリを指し示すポインタ |
CX | カウントレジスタ | 転送や繰り返しの回数を数えるカウンタ |
DX | データレジスタ | データの一時記憶用、AXと組み合わせて、32ビットの乗除算 |
インデックスレジスタ | ||
SI | ソースインデックス | |
DI | デスティネーションインデックス | |
特殊レジスタ | ||
IP | インストラクションポインタ | 実行する命令のポインタ |
SP | スタックポインタ | スタック領域のポインタ |
BP | ベースポインタ | データアクセスに使うポインタ |
- | フラグレジスタ | フラグの値を格納する。普通の方法では操作できない |
セグメントレジスタ | ||
CS | コードセグメント | プログラムが格納されているセグメント |
SS | スタックセグメント | スタック領域のセグメント |
DS | データセグメント | データ領域のセグメント |
ES | エクストラセグメント | DS以外のデータ領域をアクセスするのに使う |
(ちなみに8086が16ビットが14本に対し、Z80では8ビットが8本*1、68000では32ビットのレジスタが16本、8086の子孫であるPentium4では32ビットが10本と16ビットが6本。R3000では32ビットが32本。)
上記の説明はまだ全部わからなくてもよい。汎用レジスタはそれぞれ固有の使い方もあるが、基本的には名前の通りどれを使ってもかまわない(一部、命令によって使えるレジスタが決まっているものもある)。
なお、AX〜DXには、8ビットでアクセスすることもできる。AHがAXの上位8ビット(MSB側)、ALがAXの下位8ビット(LSB側)で、MOV命令などでAHレジスタを使うと、8ビット単位のアクセスとなる。
- 質問: 汎用ならなぜ「典型的な」使用方法を名前につけているのか
ジャンプ
特殊レジスタの中のIPは、実行する命令のメモリ上のアドレスを指す。CPUはIPの値にしたがって、次に実行する命令を読み込む(フェッチする)。8086CPUが命令を処理する流れは次のようになっている。
- IPが指す命令を読み込む
- IPを次の命令を指すよう変更する
- 命令を解読する
- 命令を実行する
- 1に戻る
この結果、IPが次から次へと命令を指していき、CPUはそれを順番に実行するようになる。そのため、プログラムは上から下(アドレスが増える方向)へ順次進んでいく。
ということは、順次ではない処理をするには、IPを変更してやればいい。幸い、IPはmovでは変更できないので、専用のジャンプ命令を使う。ニーモニックはJMPだ。
; プログラム2(inf_add1.asm) org 100h mov ax, 0h add ax, 1 jmp 103h
- emulationでステップ実行してみる
- 実行の経過と、AXレジスタの値の変化を確認する
ステップ実行すると、jmp 103hを実行した後、add ax, 1の行に戻っていることがわかる。順次実行ではなく実行位置を戻すことで、いわゆる無限ループを実現していることになる。なお、ステップ実行でなくrunすると、止まらなくなる。
jmpはIPを操作して、命令を実行する位置を変更する命令である。オペランドの103hは、次に実行する命令のアドレスを指す。この場合は、add ax, 1の命令となる。
- 質問: なぜか?
この記述からわかるように、ジャンプする先はアドレスで指定しなくてはならない。たとえば15命令先にジャンプするには、15命令が合計何バイトになるか計算しなくてはならない(ネタばらしをすると、ラベルを使うこともできる)。命令の長さは命令(とオペランド)によって変わるので、これは重労働となる。
さらに話がややこしいところを紹介する。jmp 103hが、マシンコード(マシン語)でどうなっているか見てみる。original source codeウインドウで該当行をクリックすると、対応するコードがemulator上で強調される。
0106: EB FB
この2バイトが、jmp 103hに対応するマシンコードである。EBh=11101011bがjmpに対応する(http://courses.ece.uiuc.edu/ece390/resources/opcodes.html#Main参照)。FBhが0103hに対応するのだが、この指定は相対値となっている。つまり、jmp 0103hというニーモニックは、「現在のIPにFBh加算する」という命令に変換される。他のジャンプ命令でも同じく、マシンコードでは常にジャンプ先を現在のIPに対する相対値で指定する。
- やってみる: IPにFBhを加算すると0103hになることを確認せよ。「現在のIP値」はいくつか?
- 質問: ジャンプ先の指定は1バイトで表現されている。どんな問題があるか?
ところで、ここらで一度、命令とかアセンブリ言語とかニーモニックとか、その辺の言葉を整理しておく。(p.90-91)
マシン語命令の1つ1つにはそれぞれ異なるビットパターンが割り当てられています。...16進数の羅列を眺めてもどういう内容であるかはさっぱりわかりません。...
...人間がマシン語を扱う場合には、マシン語と1対1に対応したアセンブリ言語を使います。...
...マシン語には1つ1つ記号が割り当てられており、その記号のことをニーモニックと呼びます。この記号は英語を略したもので、そのニーモニックに対応するマシン語によってCPUがおこなう動作を表しています。...「B01A」というマシン語は、ニーモニックの「MOV AL,1A」に対応し、”ALレジスタに値1Ahを転送せよ”というCPUに対する命令を表しています。アセンブリ言語はこのようなニーモニックの組み合わせを体系的にまとめたものと考えてよいでしょう。
条件分岐 - なにそれ?
構造化プログラムには、実は「ジャンプ」という構造はない。条件分岐によって処理の流れを変えるのが、構造化プログラムである。いっぽうアセンブリ言語には、条件分岐の命令は存在しない。できるのは、条件ジャンプである。しかも、条件といっても「式が真である場合」というあまっちょろい発想など存在しないのがマシン語である。楽しいでしょ。
条件ジャンプを説明するには、フラグを説明する必要がある。フラグを説明するには、演算について簡単に触れておかなくてはならない。
8086でできるいわゆる演算には、算術演算と論理演算がある(似たものにシフト/ローテート命令もあるが、後述する「結果を出さない」ものはない)。算術演算は加減乗除とインクリメント/デクリメント、論理演算は論理和・論理積・否定、排他的論理和という、お馴染みのものがそろっている(当たり前だが、アセンブラにあるから高級言語にもあるのだ)。
演算の結果はレジスタ(ないしメモリ)に格納されるが、それ以外にも結果が残る。結果が残るのがフラグで、フラグには以下の種類がある。(p.158)
フラグ | 名称 | 機能 |
---|---|---|
OF | オーバーフローフラグ | 符号付演算で桁あふれが生じたときにセットされる |
DF | ディレクションフラグ | ストリング操作命令においてポインタの増減方法を示す |
IF | インタラプト・イネーブルフラグ | クリアすると外部割込みを受け付けなくなる |
TF | トラップフラグ | シングルステップモードで実行するときのフラグ |
SF | サインフラグ | 演算結果の符合を表す。負ならばセットされる |
ZF | ゼロフラグ | 演算結果がゼロであればセットされる |
AF | 補助キャリーフラグ | BCD演算で使用されるキャリーフラグ |
PF | パリティフラグ | 演算の結果、1となるビット数が偶数のときセットされ、奇数のときリセットされる |
CF | キャリーフラグ | 演算の結果、桁上がりが生じた場合にセットされる |
- 余談: BCD演算って知ってる?
これらのフラグはすべて1ビットで表現され、0/1の値を持つ。フラグを1にすることをセット、0にすることをリセットと呼ぶ。
- 質問: フラグの意味と0/1の意味づけは、高級言語にどのような影響を与えたと思うか?
この中で、通常の算術・論理演算で影響されるのはOF,SF,ZF,PF,CFと考えてよい(BCD演算ならAFも)。ただし、どの演算でどのフラグが変化するかは、命令ごとにそれぞれ定義されているので注意(たとえば、インクリメントではCFは変化しない。乗除算では多くのフラグが不定となる。演算以外でもフラグに影響する命令がある)。Download your software on emu8086.com - Free downloadsに命令ごとの情報が詳しく載っている。
以下に演算の命令を整理する。
算術演算 | |||
---|---|---|---|
結果を残す命令 | 結果を残さない命令 | 機能 | 影響するフラグ |
ADD | - | 加算 | OSZAPC |
ADC | - | 加算(キャリーつき) | OSZAPC |
SUB | CMP | 減算 | OSZAPC |
SBB | - | 減算(キャリーつき) | OSZAPC |
INC | - | インクリメント(1加算) | OSZAP |
DEC | - | デクリメント(1減算) | OSZAP |
MUL | - | 乗算 | OC(SZAPは不定) |
DIV/IDIV | - | 除算 | (OSZAPCは不定) |
NEG | - | 正負を反転 | OSZAP(Cは常に1) |
論理演算 | |||
---|---|---|---|
結果を残す命令 | 結果を残さない命令 | 機能 | 影響するフラグ |
AND | TEST | 論理積 | SZP(OCは常に0) |
OR | - | 論理和 | SZP(OCは常に0) |
NOT | - | 否定 | なし |
XOR | - | 排他的論理和 | SZP(OCは常に0) |
演算を実際に試しながら、フラグの挙動を確認しよう。
; プログラム3 (calc.asm) org 100h ; set location counter to 100h mov ax, 0 mov bx, 1000h add ax, bx sub ax, bx sub ax, bx sub ax, 0f000h mov ax, 0fffeh inc ax inc ax inc ax xor ax, ax and ax, 0cdcdh dec ax and ax, 0cdcdh mov ax, 5000h mov bx, 5000h add ax, bx mov ax, 8001h dec ax dec ax ret
- emulateで実行する
- flags(右下のボタン)でフラグの状態を表示する
- ステップ実行して、フラグの変化を確認する
- 命令を実行する前にフラグの変化を予想してみる
- やってみる: AXを1にする方法を5通り考えよ。マシンコードとクロック数を考慮して評価せよ。参考
これらの演算命令は、前に見たADDのように、結果を第1オペランドに格納する。ところが、一部の演算については、結果を「どこにも残さない」というシリーズの命令も存在する。つまり、「第1オペランドから第2オペランドを減算し、結果を残さない」という働きをする。結果を残さない命令は、CMPとTESTの2つである。
フラグと条件ジャンプ
CMPとTESTは、計算結果を残さない。結果そのものは残さないが、結果に応じてフラグが変化する。フラグを変化させることが目的の命令である。フラグの変化は、条件ジャンプによって識別することができる。条件ジャンプとは
「フラグがこれこれの条件を満たしていたら、指定したアドレスにジャンプ(=IPを変化)する。さもなければ、次の命令を実行する」
という命令である。条件ジャンプのニーモニックには、同じマシン語(コード)に対して2つの名前がつけられているものが多い。以下にジャンプ命令を示す(一部抜粋)。
ニーモニック | 条件 | CMPの結果に対する意味 |
---|---|---|
JA JNBE | CFとZFが両方0のとき | Op1 > Op2 |
JAE JNB | CFが0のとき | Op1 >= Op2 |
JB JNAE | CFが1のとき | Op1 < Op2 |
JBE JNA | CFまたはZFどちらかが1のとき | Op1 <= Op2 |
JC | CFが1のとき | |
JCXZ | CXレジスタが0のとき | CX=0 |
JE JZ | ZFが1のとき | Op1 = Op2 |
JG JNLE | SFとOFが一致し、かつZFが0のとき | Op1 > Op2 |
JGE JNL | SFとOFが一致するとき | Op1 >= Op2 |
JL JNGE | SFとOFが一致しないとき | Op1 < Op2 |
JLE JNG | SFとOFが一致せず、ZFが1のとき | Op1 <= Op2 |
JMP | 常に | |
JNC | CFが0のとき | |
JNE JNZ | ZFが0のとき | Op1 <> Op2 |
フラグによる条件と、大小関係の意味を併記してある。ニーモニックは素敵な暗号チックだが、以下の単語の組み合わせである。
- Above
- 大きい(符号なし)
- Below
- 小さい(符号なし)
- Greater
- 大きい(符号あり)
- Less
- 小さい(符号あり)
- Equal
- 等しい
- Not
- 論理の反転
- ZとかCとか
- フラグの名前
質問: フラグの組み合わせが、なぜ表に示したような意味になるのか?
質問: 同じ命令コードに異なるニーモニックがあるのはなぜか?
; プログラム4(cond_jmp.asm) org 100h mov ax, 0000h ; axが0だったらresult1を1にする ; if(ax == 0) result = 1; else result1 = 0; cmp ax, 0 je zero mov result1, 0 jmp out_1 zero: mov result2, 1 out_1: mov ax, 0200h mov bx, 0100h mov cx, 0 ; axがbxより大きく、かつcxが0なら、result2を1にする ; if(ax > bx && cx == 0) result2 = 1; else result2 = 0; cmp ax, bx ja above mov result2, 0 jmp out_2 above: test cx, 0ffffh jz cx_zero mov result2, 0 jmp out_2 cx_zero: mov result2, 1 out_2: ret result1 dw 0 result2 dw 0
- フラグの挙動とジャンプ先に気をつけながらステップ実行せよ
- レジスタの値を変えて実行して、結果の違いを見よ
やってみる: 「if(((ax == bx && cx == dx) || (ax == bx * 2 && cx < dx / 2)) && (AX & 0x88cc == 0))」を実現せよ
ループ、繰り返し
やってみる: ここまでの知識を使えば、1から100まで足し合わせた合計を計算するループが書ける。やれ。
算術演算と条件ジャンプを使えばカウンタによるループを書くことはできるが、ループをもっと簡単に書くための命令もある。LOOP命令を使うと、
- CXを1減らす
- CXが0でなければ、指定アドレスにジャンプする
という処理を1命令で書ける。
やってみる: 先に書いたループ処理を、LOOP命令を使って実現せよ。
スタックとサブルーチンコール
ここまで、順次処理と分岐、繰り返しをマシン語で実装する方法を見てきた。もうひとつ、構造化プログラミングで重要なのがサブルーチンコールである。サブルーチンコールを理解するには、まずスタックを扱えるようになる必要がある。
スタックに値を加えるにはPUSH、値を取り出すにはPOPを使う。オペランドには汎用レジスタ、セグメントレジスタ、メモリを指定できる。
; プログラム5 (push_pop.asm) org 100h mov ax, 8888h push ax xor ax, ax pop ax push ax pop cx push data pop ax xor ax, ax push ax pop data push 0123h push 4567h push 89abh push 0cdefh pop ax pop bx pop cx ret data dw 1234h
- POPしたレジスタの値を確認しながらステップ実行せよ
- stackボタンでスタックの中身を表示しながらステップ実行せよ。複数の値をPUSHしたとき、値がどう並ぶかに注意せよ。
- プログラム終了後もステップ実行を何回か続けよ。
PUSH/POPで扱えるスタックは、基本的には1つだけである。スタックはSS(スタックセグメントレジスタ)とSP(スタックポインタ)で示されるメモリ上に配置される。メモリはアドレスが小さくなる方向に成長する。
プログラム中で扱うデータの数が増えたときに、レジスタだけではまかなえなくなることがある。メモリを使ってもよいが、恒久的でないデータのためにメモリを使うのは勿体ない*2ので、一時的に使えるメモリ空間がほしい。スタックはそのような場合に利用する。
まだセグメントの話をしていないが、今まで使ってきたCOMモデル(タイニーモデル)では、コード、データ(メモリ)、スタックすべて合わせて、64KBしか使えない。スタックはメモリ空間の上位(アドレスの大きいほう)から下位(小さいほう)に向かってに「伸びて」いくので、スタックを使いすぎるとデータやコードを破壊することになる。
; プログラム6 (stack_overflow.asm) org 100h mov sp, 0120h loop: push ax jmp loop ret
- IPとSPの値を観察しながらステップ実行せよ
- 破壊後も実行を続けてみよ
メモリが貴重な環境では、必要となるスタックサイズを計算し、できるだけ切り詰める努力が払われていた(いまでも払われている)。
さて、スタックは単純に値を退避するのに使えるが、以下のような使い道もある。
- 直接movできないとき(メモリ→メモリ)にpush/popで受け渡す
- サブルーチンコールするとき、呼び出し元アドレスを取っておく
- サブルーチンコールするとき、呼び出し元で必要なレジスタの値を取っておく
- サブルーチンコールするとき、呼び出し先に受け渡す値を格納する
CALLとRET
サブルーチンコールに使う命令が、CALLとRET。名前の通り呼び出しと復帰(RETurn)の機能がある。とはいえ、「サブルーチンを呼び出す命令」と思ってはいけない。CALLの処理は、
- 現在のIPをPUSHする
- つまり、SPを2デクリメントし、
- SS:SPにIPをストアする
- IPを指定された値に変更する
同様にRETの処理は
- IPをPOPする
- つまり、SS:SPをIPにロードし、
- SPを2インクリメントする
という、これだけ。あいかわらず「レジスタの演算とメモリ⇔レジスタのやりとり」ですべてを済ませている。
;プログラム7 (call_ret.asm) org 100h mov ax, 0100h mov bx, 0200h push ax ;引数1をPUSH push bx ;引数2をPUSH call sub_add ;サブルーチン呼び出し pop ax ;結果を取り出す nop ret sub_add: pop cx ;戻り先を保存 pop ax ;引数1を取り出す pop bx ;引数2を取り出す add ax, bx push ax ;結果をPUSH push cx ;戻り先をPUSH ret ;呼び出し元に戻る
- SPとスタックの内容に注意しながらステップ実行せよ
ここではsub_addというサブルーチンを定義している。このサブルーチンのインターフェース仕様は、
と記述できる。この例でわかるように、基本的にはアセンブラのサブルーチンは副作用がありまくる。そのため、どのような副作用があるか、逆に、なにを保証するのか、明確にしておく必要がある。
質問: 引数と戻り値を受け渡すのに、他にどんな方法があるか?
質問: 呼び出し元で戻り値をPOPしている。これを忘れるとどうなるか?
やってみる: 4つの引数を取り、すべてを足した和(符号なし)を返すサブルーチンを書け。戻り値は32ビットとせよ。仕様を定義せよ。
(基本的にすべて保証するような書き方もできる。使用するレジスタ、フラグをすべてスタックを使って保存しておき、帰るときにはすべて復帰するというやりかただ。だが、コストが高すぎるので好まれない。フラグはPUSHF/POPFで保存できる。)
質問: このような手法が必須となるのはどういうときか?
ここまでのまとめ
以上で説明してきた命令によって、
- 順次実行
- 条件分岐
- 繰り返し
- サブルーチン呼び出し
が実現できることがわかった。高級言語(と言ってもCとか)のプログラムは、ここで示したようなやりかたを使って、マシン語に変換されている。
大事なポイント:
- マシン語のプログラムは、メモリのロード→レジスタの演算→メモリへのストア、以上終わり
- コード、データ、スタックは、どれもただのメモリ上のデータ
- プログラムの実行はIPで制御する
- 分岐はフラグと条件ジャンプで実現できる
- サブルーチンはスタック操作で実現できる
さて、いままで話してきた内容は8086というCPUに依存しない、かなり抽象的な話で、いろいろなことをはしょってきた。また構造化プログラムという軸で説明してきたが、マシン語は構造化プログラムを書くためにあるわけではない。マシン語への理解をより深めるには、以下のようなことも知っておくべきだ。
トピック:
知っておくべきだが、とりあえず時間がないのでこれでおしまい。続きは、リクエストがあったらやります(書きます)。