さまざまな演算命令
まずは,簡単な計算をするプログラムを作ってみよう。 例えば,以下のようなプログラムを考える。
- 123 + 45 − 67 + 8 − 9 を計算するプログラム
- 98 + 7 − (6 × 5) + (4 × 3 × 2) + 1 を計算するプログラム
加算,減算,乗算を行えたらいいわけだ(ちなみに結果はいくらになる?)。
C言語やJavaでは数式をそのまま記述すればよいけど,機械語ではどうプログラムすればよいだろうか?
計算結果の出力
演算命令の話の前に,アセンブリ言語プログラムの基本構造と,計算結果の出力方法を知ろう。
以下は「最小」のアセンブリ言語プログラムだ。
このプログラムを打鍵入力し,123.s
というファイル名で保存しなさい。
section .text
global _start
_start:
mov eax, 1 ; システムコール番号
mov ebx, 123 ; 終了コード
int 0x80 ; exitシステムコール
最初の3行は「決まり文句」だ。
section .text
は,以降が(データではなく)機械語命令であることを表す。global _start
は,ラベル_start
を外部から参照可能にするための指示。
3行目の _start:
のように行の左端に書かれた「名前:
」はラベルと言い,プログラム中の「位置」を表す。上記プログラムの場合,_start
はプログラムの先頭位置を表している。
_start: mov eax, 1
のように,ラベルと命令を同じ行に書いてもよい。
特に指定がなければ _start
という名前のラベルがプログラム実行開始位置となる(Linuxのldコマンドの仕様)。
空白の量は自由だが,「ラベルは字下げしない。それ以外は1レベル字下げする」というスタイルで記述するのが慣習だ。
;
から行末まではコメントとして扱われる。
上の例では説明を兼ねて細かくコメントを書いてある。「すべての行にコメントを書く」ほど細かく書く必要はないが,後で「この行はどういう意味だろう?」と思いそうな箇所にはコメントを付けておくべきだ。
機械語 / アセンブリ言語は1命令の粒度が小さいので,命令だけでは意味がわかりにくくなりがちだ。
プログラムの先頭に「何をするプログラムか」を書くのはよい作法だ。
; 123を終了コードとして出力するあまり意味のないプログラム
section .text
global _start
_start:
mov eax, 1 ; システムコール番号
mov ebx, 123 ; 終了コード
int 0x80 ; exitシステムコール
0x
は16進数を表す接頭辞だ(CやJavaも同じ)。1
や123
のような数字のみの列は10進数を表す(0x80
の代わりに 128
と書いても,123
の代わりに 0x7b
と書いても同じ。機械語命令に変換するときにすべて2進数化される)。
このプログラムをアセンブルして実行してみよう。
$ nasm 123.s -- アセンブル
$ ld 123.o -- a.outを生成
$ ./a.out -- 実行
あれ? 何も起こらないぞ? …いやいや,実はちゃんと数値が出力されている。echo $?
を実行すればわかる。
$ ./a.out -- 実行
$ echo $? -- 直前のコマンドの終了コードを表示
123
123.s
はexitシステムコールを実行するだけのプログラムだ。exitシステムコールは,そのプログラムを終了すると同時に,外部に対して「終了コード」という数値を1個出力する(C言語における main 関数の返り値と同じ)。UNIX系OSにおいては,各プログラムは,正常終了の場合は0,異常終了の場合は非0のエラー番号を終了コードとして出力する慣習になっている。
シェル変数$?
は,直前に実行したコマンドの終了コードを表す。
終了コードは0〜255の範囲の値しか取れない(EBXの下位8ビットのみ出力される)ので注意。
ちょっと回りくどいやり方だが,この章ではこの方法を使って計算結果を出力することにする。 例えばC言語では,printf関数を使えば簡単に数値を端末画面に出力できるが,これはprintfが「数値を10進数の各桁に分解してそれぞれ文字に変換してwriteシステムコールを実行する」という仕事をしてくれているからだ。 後の章では,数値を16進や10進の数字の列に変換して出力するプログラムを自作して使う。
exitシステムコール
exitシステムコールは,EAXに1,EBXに終了コードを格納した状態で int 0x80
を実行することで呼び出せる。exitシステムコールを実行するとそのプログラムは終了する。
プログラムの終端では必ずexitシステムコールを実行しなければならない。 プログラムを実行する際は,実行可能ファイル中の機械語プログラムが主記憶装置にコピーされて実行が開始されるが,主記憶装置の中ではもはや「どこがプログラムの終端か」はわからなくなる。「プログラムの実行を終了する」という命令に出会わない限りは,CPUはその先の記憶領域にプログラムが続いていると見なして,いつまでも実行を継続してしまう(暴走状態に陥る。「未定義領域にfall-offする」とも言う)。
実際は,OSの保護機能が働いて,「読み書きが許可されていない記憶領域から命令を読み出そうとした」ことによるエラーでプログラムが停止する。しかし,「エラーを起こして止める」のではなく正常な手順でプログラムを終了させる方がスマートだ。
加算命令
次は 123 + 45 を計算するプログラムを作ろう。以下はその例だ。
section .text
global _start
_start:
mov ebx, 123 ; ebx = 123
add ebx, 45 ; ebx = ebx + 45
mov eax, 1 ; システムコール番号
int 0x80 ; exitシステムコール
int 0x80
を実行する時点で 123 + 45 の結果がEBXに,システムコール番号 1 がEAXに,それぞれ入っていればよい(EAXへの代入を先にしても構わないが,プログラム作法として一般に,「代入位置とその値を使う位置は近い方がよい」)。
- MOVは,第1オペランドのレジスタに第2オペランドの値を代入する命令だ。転送 (move) 命令とも言う。
- ADDは,第1オペランドのレジスタの値に第2オペランドの値を加算する命令だ。C言語やJavaで言うと
x = x + y;
の形の代入文(被演算数の一方が代入先と同じ)に相当する。
(「オペランド」(operand) は被演算数のこと。)
CPUの内部ブロック図を思い出すとわかるように,機械語における演算は「演算装置 (ALU) に2つの被演算数を入力する」ことで行われる。 つまり,123 + 45 − 67 のような式を一度に計算することはできず,まず 123 + 45 を計算し,次に (123 + 45の結果) − 67 を計算する,というように,各演算を順に実行していく必要がある(言い換えると,一つの演算子が一つの機械語命令に対応している)。
演算命令ごとに演算結果の代入先を指定しなければならない(「どこでもいいから適当な場所に保存しておいて」という指示はできない)。代入先はいずれかの記憶装置(レジスタ,あるいは主記憶装置内の領域)である。i386の場合,一方の被演算数が代入先として使われる。 ARM CPUでは,2つの被演算数と代入先をすべて異なるものにできる (three-address instruction formatと言う)。
MOVもADDも,「第2オペランドの値を使って第1オペランドの値を変化させる」命令だ。その意味で,第2オペランドをソースオペランド (source operand),第1オペランドをディスティネーションオペランド (destination operand)(目的オペランド,宛先オペランド)と言う。
それでは,123.s
を上記のように変更して,アセンブルして実行してみよう。結果が 123 + 45 と等しいか確かめよう。
$ nasm 123.s
$ ld 123.o
$ ./a.out
$ echo $?
168
即値
mov ebx, 123
の123や add ebx, 45
の45のような,数そのものであるオペランドのことを即値 (immediate value) と言う。
「(在処を指定されるのではなく)機械語命令の中から直接読み出される数」というような意味だ。
これらの命令は機械語では bb 7b 00 00 00 81 c3 2d 00 00 00 となるが,7b 00 00 00 が123,2d 00 00 00 が45のことで,確かに機械語命令の中に埋め込まれている。
i386の主な演算命令
以下は,i386の主な演算命令の一覧だ。たくさんあるがとりあえず,減算を行う命令は SUB (subtract) であることがわかる。使い方は ADD と同じで,第1オペランドから第2オペランドの値を引く。加算と減算がわかったから,123 + 45 − 67 + 8 − 9 の計算もできるはずだ。
演習1.2-0 第I部のプログラム作成を共同で行う,2人以上3人以下のグループを作りなさい。「グループ用GitHubリポジトリの作成」を読んで,共同開発のためのリポジトリを作成し,ローカルコピーを取り出して,共同開発が行えるよう準備しなさい。
演習1.2-1 123 + 45 − 67 + 8 − 9 を計算して結果を出力するアセンブリ言語プログラムを作り,共有リポジトリにpushしなさい。演習1.2-0で結成したグループで共同開発すること(ペアプログラミングを実践すること)。 ソースファイルは,サブディレクトリ chap2 の中に 123.s という名前で作成すること。
乗算
次は 98 + 7 − (6 × 5) + (4 × 3 × 2) + 1 の計算だ。加減算に加えて乗算が必要だ。
乗除算はハードウェアにとって面倒な処理なので,CPUによっては乗除算命令が存在しないこともある。乗算命令がないCPUで乗算を行う方法は,後の章で考える。
上記の表の通り,幸いi386には乗算命令 MUL がある。ただし,(おそらくハードウェアを簡素化するために)以下の制約がある。
- MULの引数はソースオペランド(乗数)のみ,かつ,即値をオペランドにできない。
記述例)mul ebx
; eaxにebxを乗じる - 被乗数と代入先は,ソースオペランドのビット幅に応じて自動的に決まる。
- srcがバイト (BL, BHなど) なら,ALを被乗数とし,結果をAXに代入する。つまり,C言語風に書けば AX = AL * src;
- srcがワード (BX, CXなど) なら,AXを被乗数とし,結果の上位16ビットをDX,下位16ビットをAXに代入する。
- srcがダブルワード (EBX, ECXなど) なら,EAXを被乗数とし,結果の上位32ビットをEDX,下位32ビットをEAXに代入する。
(積のビット幅は,乗数と被乗数のビット幅の和になる。例えば,32ビット同士を掛けると積は64ビットになる。)
例えば EAX の値が 6,EBX の値が 5 のときに mul ebx
を実行すると,EAX の値は 30,EDX の値は 0 になる。他のレジスタの値は変わらない。
乗数は EAX や EDX でもよい。EAX が 6 のときに mul eax
を実行すると,EAX の値は 36,EDX の値は 0 になる。C言語風に書けば EDXEAX = EAX *
src; で,srcが EAX や EDX であってもよい。現在の各レジスタの値を使って乗算を行い,結果をEDXとEAXに代入するだけだ。
以降,計算の途中結果を32ビットで扱うことにしよう(Javaのint型と同じだ)。乗算は,32ビット同士の積を計算すればよい。結果は64ビットになるが,上位32ビットは単に無視することにする。
さて 98 + 7 − (6 × 5) + (4 × 3 × 2) + 1 の計算だが,MUL を使うと自動的に EAX と EDX を使うことになる。それ以外のレジスタを一つ選び,加減算の途中結果を常に保持するようにしよう(機械語では,一つのレジスタに順番に値を加えたり減じたりしていく計算戦略がよく使われる。それに使われるレジスタを累算器 (accumulator) と言う)。最終的に計算結果を EBX に入れたいので,加減算の途中結果も EBX に保持すれば無駄がない。6 × 5 や 4 × 3 × 2 は EBX を使わずに計算し,積が得られたらEBXから引いたりEBXに加えたりする。
- 参考:どの演算にどのレジスタを使うか決めるのもコンパイラの仕事の一つだ。途中結果を保持しているレジスタの値を壊さないように,やりくりしてレジスタを割り当てる必要がある。
演習1.2-2 98 + 7 − (6 × 5) + (4 × 3 × 2) + 1 を計算して結果を出力するアセンブリ言語プログラムを作り,共有リポジトリにpushしなさい。 ソースファイルは,サブディレクトリ chap2 の中に 98.s という名前で作成すること。 ただし,演習1.2-1のプログラムをコミットしたメンバーとは別のメンバーがコミットすること(共有リポジトリ上の98.sの最終変更者が,123.sの最終変更者と異なるようにすること)。
除算
乗算命令の次は除算命令を見てみよう。除算ができれば次のようなプログラムが作れる。
- 12356秒が何時間何分何秒か計算するプログラム。
- ある数 n を k 進法で表したときの各桁がいくらか計算するプログラム。
- 例えば n = 23579, k = 10 ならば 2, 3, 5, 7, 9 と出力する。n = 23579, k = 16 ならば 5, 12, 1, 11 と出力する(23579 = 0x5c1b)。
- printfが行うような「数値から数文字列への変換」も,これを応用して実現できる。
- 上の「時間・分・秒」に分ける問題は n = 12356, k = 60 の場合に当たる。
i386の場合,除算命令 DIV も,乗算と同様にオペランドに制約がある。
- DIVの引数はソースオペランド(除数)のみ,かつ,即値をオペランドにできない。
記述例)div ebx
; edxeaxをebxで割る - 被除数と代入先は,ソースオペランドのビット幅に応じて自動的に決まる。
- srcがバイト (BL, BHなど) なら,AXを被除数とし,商をALに,剰余をAHに代入する。
- srcがワード (BX, CXなど) なら,DXを上位16ビット,AXを下位16ビットとする32ビットの数を被除数とし,商をAX,剰余をDXに代入する。
- srcがダブルワード (EBX, ECXなど) なら,EDXを上位32ビット,EAXを下位32ビットとする64ビットの数を被除数とし,商をEAX,剰余をEDXに代入する。
例えば EAX の値が 215,EDX の値が 0,EBX の値が 7 のときに div ebx
を実行すると,EAX の値は 30,EDX の値は 5 になる。他のレジスタの値は変わらない。
被除数のビット幅が除数の2倍であることに注意。div ebx
を実行する場合,被除数は「EDXを上位32ビット,EAXを下位32ビットとする64ビットの数」だ。
32ビット同士の除算をしたい場合,DIVを実行する前にEDXを0にしないと,間違った結果になる。
「12356秒が何時間何分何秒か計算する」プログラムを考えよう。 「時間(上位桁)」から順に求めてもよいし「秒(下位桁)」から順に求めてもよい。 いずれにせよ除算は2回必要だ。DIV命令1個で商と剰余の両方が求まる。 除算の前に被除数の上位ビット(除数が32ビットの場合はEDX)を0にしておくことを忘れずに。
被除数,商,剰余を格納するレジスタは固定なので,必要な情報を上書きして消してしまわないようにしなければならない。除算命令の前や後で,どのレジスタの値をどのレジスタに転送すればよいか,紙の上などで検討するとよいだろう。
結果の出力だが,終了コードを使う方法では(0〜255の範囲の)1個の数値しか出力できない。 ここでは(あまり意味のない計算だが)「12356秒が何時間何分何秒か計算し,時×10 + 分×5 + 秒 を出力する」ということにしよう。「12356秒は3時間25分56秒なので 3×10 + 25×5 + 56 (= 211) を出力する」ということだ。
演習1.2-3 12356秒が何時間何分何秒か計算し,時×10 + 分×5 + 秒 を計算して出力するアセンブリ言語プログラムを作りなさい。 ソースファイルは,サブディレクトリ chap2 の中に hms.s という名前で作成すること。 作成したプログラムを共有リポジトリにpushしなさい。 ただし,3人グループの場合,演習1.2-1及び演習1.2-2のプログラムをコミットしたメンバーとは別のメンバーがコミットすること(共有リポジトリ上のhms.sの最終変更者が,123.sの最終変更者とも98.sの最終変更者とも異なるようにすること)。2人グループの場合はどちらがコミットしても構わない。
- 参考:以前実行したのと同じコマンドを実行する際は
!
記法を使うと便利。$ nasm hms.s $ ld hms.o $ ./a.out ; echo $? -- ; は「複数のコマンドを順に実行」 56 $ !na -- 最後に実行した'na'で始まるコマンドを再実行 nasm hms.s $ !ld -- 最後に実行した'ld'で始まるコマンドを再実行 ld hms.o $ !./a -- 最後に実行した'./a'で始まるコマンドを再実行 ./a.out ; echo $? 181