第一報:アセンブラの基礎と資料について
プログラムを追求していくと、いずれはアセンブラに興味を持ち始めるものです。ですが、これがなかなかに本が少ないのです。本があったとしても、16ビット時代の古い情報しか得られなかったりすることが殆どです。それでも貴重な情報は手に入りますが、今ではそこに書いてあることを完全に実行する環境がなかなかないというのがまた問題になります。
では、最新の情報はどこで手に入れればいいのでしょうか? 一部の技術者の特権でしか手に入れられないのでしょうか? もちろん、そんなことはありません。MASMの情報は、実はINTELのHPからダウンロードできるのです。
ここには色々な情報がありますが、リファレンスマニュアルなんかが役に立つと思います。MMX命令を含む全命令リストはもちろん、とにかくアセンブラについて、CPUについての情報はこれだけでもかなりのものです。(これらのファイルはPDFファイルというものですが、これはアドビ社のアクロバットというソフトで閲覧できます。読み込み専用のアクロバットリーダーなら、無料で手に入ります。)
基本は上巻で理解。ある程度分かったら中巻の命令リファレンスが役に立ちます。下巻はかなりマニアックなことまで突っ込んでいます。そして、別巻で最適化に関する情報があります。別なページでは、CPUのアーキテクチャに関する細かい資料もダウンロードすることができます。
ただ、その量のせいで基本を理解しようという意気込みを失わせてしまうかもしれません。なので、ここでアセンブラの基本、CPUの基本について話そうと思います。かなりかいつまんでいますので、ちょっと理解が不十分になるかも知れませんが、ご容赦を。
CPUはいくつかのレジスタというものを持っています。これは変数みたいなものですが、数や種類はもうすでに決まっています。新しくレジスタを作ることは当然できません。レジスタには、通常のレジスタ、浮動小数点レジスタ、MMXレジスタといった種類がありますが、ここでは普通のレジスタについてのみ話します。
レジスタはCPU内の変数のようなものです。全ての普通のレジスタは以下の通りです。
主レジスタ | EAX(AX, AH, AL), EBX(BX, BH, BL), ECX(CX, CH, CL), EDX(DX, DH, DL) |
ポインタレジスタ | ESI(SI), EDI(DI), EBP(BP), ESP(SP), EIP |
セグメントレジスタ | CS, DS, SS, ES, FS, GS |
フラグレジスタ | EFLAG |
主レジスタは、特別な意味を持つこともありますが、普通はどんな利用をしてもいい32ビットレジスタです。16ビット、8ビットのレジスタとしても使え、その際は AX, AH, AL のようになります。AX は EAX の下位16ビット、AH は AX の上位8ビット、AL は AX の下位8ビットになります。H は High の H で、L は Low の L です。
ポインタレジスタは、主としてアドレスを指し示すための32ビットレジスタです。EIP 以外は16ビットレジスタとしても利用でき、その際は先頭の E を除けます。ESP レジスタはスタックの位置を指し示すためのレジスタです。なので、通常直接操作することはありませんが、スタックの位置を直接移動させたいときに操作することがあります。EIP レジスタはプログラムの実行位置を指し示すためのレジスタです。直接操作は許されていません。EBP は関数の引数を表すときに使うレジスタです。引数のない場合にも、インラインアセンブラでは使うべきではないレジスタです。ESI, EDI は基本的には自由に使えるレジスタです。ポインタとして使わなくても構いません。インラインアセンブラでは勝手にやってくれるので気にする必要はありませんが、関数の初めに値を保存しておき、最後で値を戻しておく必要があります。プログラムを逆アセンブルしてみると、実際には EBX レジスタも保存されるようです。
セグメントレジスタは、アドレスのセグメント値を格納するための16ビットレジスタです。アドレスは、16バイトを1単位とするセグメント値と、その位置からのずれであるオフセット値という2つの値から成り立っています。普通Cで扱うアドレスとは、このオフセット値の方です。CS は命令の詰まった領域の位置を示すためのレジスタです。DS はデータ領域の位置を示すためのレジスタです。SS はスタック領域の位置を示すためのレジスタです。ES 以下は、自由に使うことのできるレジスタです。
最後に、フラグレジスタは演算の結果など、色々な状態フラグの集合体である32ビットレジスタです。1ビット毎にどの状態を示すかが決まっていますが、32ビットの全てが利用されているわけではありません。直接操作できるフラグは一部で、あとは間接的に操作することができるのみです。フラグレジスタを操作することは殆どなく、実際には状態を取得し、条件分岐を行うのが常です。
CPUはこのレジスタと、メモリと、即値(定数)を主に操作することができます。ですが、メモリ同士の演算はできず、片方の値を一旦レジスタに入れ、その後に演算を行う必要があります。よって、C言語の a = b; という文は、アセンブラでは2文になります。レジスタに余裕が無く、レジスタの値を一旦メモリ(スタック)に保持する必要があれば、さらに命令数は増えます。この制約は複雑な処理においては非常に厳しいものですが、これをうまく最適化するのもアセンブラの楽しみの1つでしょう。
では、アセンブラの命令の話に移ります。アセンブラの命令はマシン語と1対1に対応しています。マシン語を覚えるのが大変なので、覚えやすい形にしたのがアセンブラです。このことから、アセンブラの命令はニーモニック(mnemonic:(形)記憶を助ける)と呼ばれます。例えば、値の代入に使うニーモニックは mov で、加算に使うニーモニックは add です。このように、ニーモニックは英数字で構成されており、= や + などの記号は使いません。
ニーモニックは普通オペランドをとります。オペランドとは、引数のようなものです。例えば、C言語の a = 1000; はアセンブラだと mov a, 1000 となります。普通、第一オペランドが操作を受けます。add a, eax だと、a に eax の内容が足されます。
メモリは、アドレスを使って操作します。静的な変数は即値を、動的な変数は普通は EBP レジスタを利用してアクセスします。インラインアセンブラではこういったことは勝手にやってくれます。変数名を使って操作することができるのです。あとは、ポインタも利用できます。主レジスタ、ポインタレジスタにアドレスを入れると、dword ptr [eax] のようにして参照できます。dword というのは4バイトのことです。例えば、変数 a のアドレスを EAX レジスタに入っているとすると、mov dword ptr [eax], 1000 とすると a に1000を代入することができます。サイズを書かなくてもサイズが勝手に決まってしまうときは、サイズを省略することができます。
アドレスの指定はもっと複雑にできます。dword ptr [eax + ebx * 4 + 8] なんてのもできます。レジスタ1個、1,2,4,8倍指定のついたレジスタが1個、即値が1個の和を指定することができます。1次元配列を扱うことができるというわけです。アドレスを取得するニーモニック lea を使えば、この条件にあう演算を1命令でできたりするというテクニックもあります。
今度は関数に関して話します。C言語の関数は、普通、引数はスタックに渡します。(インライン関数やファストコール関数は例外です。)スタックとは、値を一時的に退避するところです。内部変数もスタックに確保されます。戻り値は普通 EAX レジスタに入れます。ただ、インラインアセンブラの部分で戻り値を表記すると、戻り値が指定されていませんというコンパイラの警告が出ます。その場合は、とりあえず return 文は書いておき、関数の定義を
#pragma warning(disable:<警告番号>)
と
#pragma warning(default:<警告番号>)
で囲む必要があります。
例として、配列の平均値を求める関数を作ります。
#pragma warning(disable:4035) int Average(const int data[], int nData) { __asm { mov ebx, data mov ecx, nData xor eax, eax cmp ecx, 0 jle END_FUNC LOOP_HEAD: add eax, [ebx] add ebx, 4 loop LOOP_HEAD cdq idiv nData END_FUNC: } return; } #pragma warning(default:4035)
先ず、ポインタは変数のままでは扱えません。アドレス先を参照するには、ポインタの中身を一旦レジスタに渡す必要があります。それは、メモリにある値を使って参照ができないからです。ここでは EBX レジスタを使いました。
次に、データの数を ECX レジスタに入れました。loop というニーモニックを使うと、ECX レジスタに入っている数だけループする事ができます。一回ループをする毎に ECX レジスタの値を1ずつマイナスしていき、0になったらループをやめる、という風になっています。
ただ、データ数に0以下の値が入れられるとまずいので、その際には0を返すようにしたいと思います。xor eax, eax というのは、レジスタを0にクリアする方法の1つです。mov eax, 0 でもできますが、こっちの方がマシン語に直すとサイズが小さくなります。その後に、cmp ecx, 0 という文があります。これは ECX と0を比較するという文です。比較した結果をフラグレジスタにセットします。jle END_FUNC という文は、前回の比較の結果が Less than or Equal(以下)であったとき分岐を行うという文です。前回の比較は ECX と0との比較でした。なので、ECX≦0の時に関数の最後に飛ぶ、という文になります。j は Jump の j で、le はもちろん Less than or Equal の略です。
そして、ループに入ります。ここでは、入力値の合計を求めます。さっき0にクリアした EAX レジスタに値を足していくことにします。add eax, [ebx] で、データを足し、add ebx, 4 でアドレスを次の要素に進めます。int は4バイトなので、4を足します。そして、loop LOOP_HEAD でループを行います。
最後に、合計値をデータ数で割ります。除算は EDX, EAX 両レジスタにまたがって被除数を入れておく必要があります。ですが、データは EAX レジスタにしか入っていません。これを EDX:EAX に拡張するニーモニックが cdq です。で、除算を idiv nData で行います。idiv は符号付き除算を行うニーモニックです。結果は、商が EAX に、余りが EDX に格納されます。何と、アセンブラでは商と余りを同時に出してくれるのです。そのかわり、400億割る1なんてしたら、 EAX に400億なんて入らないのでオーバーフロー(桁あふれ)になります。これは除算エラー例外を発生します。ただ、平均値を求める時には、普通、このオーバーフローは起こり得ないので、特に気にする必要はありません。データ数に異常な値を入れたときにはその限りではありませんが。
アセンブラはこんな感じで使います。では、次は関数を使ってみましょう。もちろんインラインアセンブラ内でです。
int main() { static int data[] = { 65, 100, 92, 75, 66 }; static char szFormat[] = "%d\n"; __asm { push dword ptr 5 push offset data call Average add esp, 8 push eax push offset szFormat call printf add esp, 8 } return 0; }
ここでは printf("%d\n", Average(data, 5)); をしています。
関数を呼ぶニーモニックは call です。関数内の ret というニーモニックに当たるとここに戻ってきます。
引数はスタックに渡すと言いました。スタックに値を渡すニーモニックは push です。Average に渡す引数のうち、一番終わりの引数からプッシュします。ここ注意して下さい。で、関数が終わると、スタックの位置をプッシュする前の位置に戻す必要があります。プッシュすると ESP が小さくなります。ここも注意して下さい。だから、ESP にプッシュしたサイズを足すと、スタックの位置を戻すことができます。スタックからプッシュした数だけ値を取り出したのでも位置を戻すことができます。スタックから値を取り出すニーモニックは pop です。また、変数のアドレスは offset という演算子を使うと取得することができます。ただし、それは静的な変数のみです。動的な変数に対しては lea というニーモニックを用いて取得します。
引き続いて printf に値を渡したいと思います。文字列 "%d\n" はインラインアセンブラの中に書くことはできません。あらかじめ外で定義しておく必要があります。あとは Average を呼んだときと同じです。
それにしても、両例とも何だかC言語で書いたものと変わりないものになってしまいました。これも、使い方を示すために簡単な例しか言っていないからであり、複雑な処理では人間の最適化の方が勝ります! ここの例だけ見て「アセンブラも大したことないな」とか思わないで下さいね。ま、VC++の最適化はかなりのものだというのも事実ですが。
アセンブラの基礎は、かなりかいつまんで話しましたが、大体こんな感じです。基礎さえ分かってしまえば、あとは命令集を頼りにプログラムを組む事ができるようになります。最後に基本的な命令を列挙するので、あとは命令集を参考にして勉強してみて下さい。
代入:mov, movzx, movsx, lea, xchg, movs/rep/cld/std, stos, lods (, cmovcc) 演算:add/adc, sub/sbb, mul, imul, div, idiv/cdq, neg (, lea) 1の加減算:inc, dec ビット演算:and, or, xor, not ビットシフト:shl/sar/shr, rcl/rcr/rol/ror ビット操作:bts, btc, btr 比較:cmp, test, bt, bsf, bsr, cmps, scas スタック操作:push, pop, pusha, popa, pushf, popf 分岐:jmp, jcc, call/ret フラグの取得:setcc
では、健闘を祈ります。
Last update was done on 2000.6.4