Imo Soft  | TOP | Imo Soft | ドン!3 | 大学関係 | 掲示板 | リンク |
日曜プログラマのソフト置き場 >> Imo Soft >> VB-Delphi(目次) >> VB-Delphi-第三回
03/11/24更新

Visual BasicプログラマのためのDelphi研究室


第三回 Delphiのインラインアッセンブラを使ってみる(仮公開版)



何をやるかというと...


 今回は、VBは使用しません。Delphiで機械語を直接取り扱ってやろう、という企画です。もちろん、DLLにすればVBからの呼び出しも可能です。
 アッセンブリのコードをはじめて見る方は何がなんだか分からないかも知れません。しかし、よくよく見てみると『GOTO』命令を多用したBASICのコードとさして変わりがないことに気がつくと思います。
 アッセンブリが少し分かるようになると、いろいろ良いことがあります。例えば、stdcallなどの呼び出し規約の意味が分かるし、通常の方法ではできないような関数の呼び出し方(アドレスも引数の数も引数の型も未知とか...)も可能になります。
 もちろん、最適化した高速なコードを書くこともできます。

おしらせ


この企画は、今後数回にわたって書き足されていきます。更新しましたら、トップページにてお知らせします。

アッセンブリとは、


 昔々、コンピュータが専門家だけの機械だった頃、コンピュータに仕事をさせるためには数値で命令とデータを与えてやらなければなりませんでした。数値はコンピュータには解釈しやすいのですが、人間には解釈しづらく、間違えを探したり保守管理をするのが難しいのが問題でした。
 そこで、機械語の命令(数値)に一つ一つ名前(シンボル)を付けていきました。例えば、加算をする命令なら『ADD』などです。こうして、作られた命令の体系がアッセンブリ言語、それを機械語に翻訳するのがアッセンブラです。ですから、プログラミング言語としては、機械語の次に低レベルな言語です。
 通常、Visual BasicやC++やDelphiなどいわゆる言語で書かれたプログラムはその意味をコンパイラが解釈して機械語に変換します。
 ですから、コンパイラのバージョンが上がったりすると同じコードに対して生成される機械語が変わってくることがあります。同じ動作をする機械語の組み合わせは1つではないのでいろいろな表現の仕方があるためです。これは、英語を日本語に直すときに表現の仕方が何種類もあるのと似ています。
 それに対して、アッセンブリ言語では、前述のように一つのシンボルには必ず1つの機械語(数値)が対応しますから、良くも悪くもプログラマが書いたとおりの機械語ができるわけです。
 つまり、その他の言語(例えばVBとか)に比べて、同じ機能を実現するのに多くの時間がかかります。その代り、あらゆる制約がなく自由なコーディングができるわけです。

アッセンブリでプログラムを書くには、


 一般的には、MicrosoftのMASMやBorlandのTurbo Assembler、Visual C++のインラインアッセンブラ、そして、Delphiについているインラインアッセンブラがあります。
 前二者は、純粋なアッセンブラですべてのコードをアッセンブリで書くことになります。もちろん、OBJファイルを作ってそれを別の言語にリンクすることもできます。それに対して後二者はそれぞれの言語で書かれたプログラムの一部にアッセンブリのコードを埋め込むために使います。ですから、必要なところだけをアッセンブリで書いて残りは手軽な高級言語で記述すると言った事ができます。また、変数の確保なども簡単にすることができます。

Delphiでインラインアッセンブラを使うには


インラインアッセンブラを使うには、次のように記述します。


procedure SomeProcudure();
begin
  …
  asm
    {アッセンブリのコード}
  end;
end;

『asm』と『end;』で囲まれた中がアッセンブリコードを書き込む部分です。
『{』と『}』で囲まれた部分はコメントになります。

文法は


 基本的には、IntelやAMDのサイトからCPUのリファレンスをダウンロードしてきて読解することになります。
とはいえ、膨大なリファレンスを読解するのは不可能(私には)ですから、裏技を伝授しましょう。
 ただし、以下は各自の自己責任にてお願いします。
 まず、目的のコードを書きます。例えば、変数の値をレジスタに読み込んで、1を足して、元の変数に戻す方法を知りたいなら、


procedure SomeProcudure();
var I: Integer; begin
  I:=I+1;
end;

[ブレークポイントを入れた図]  つぎに、目的の部分(つまりアッセンブリでどう書くか知りたい部分)にブレークポイントを設定します。やり方は、設定したい行の右側の青いドットのついた所をクリックしてください。
[CPUウィンドウの表示の仕方]  そして、実行をクリックします。目的の部分が実行される直前に実行が一時停止されます。
 そこで、『表示(V)』→『デバッグ(D)』→『CPU(U)』をクリックします。「Ctrl」+「Alt」+「C」の同時押しでもよいでしょう。
 すると、現在実行される直前の部分を中心にアッセンブリコードが表示されます。
 ご親切なことに、コメントにDelphiで書いたコードが書いてあるので、それを参考にします。
 あとは、このコードを参考に試行錯誤すれば徐々につかめてきます。
 …すみません。手抜きです。時間があるときにサンプルコードなど具体的な内容を追加していきます。

CPUウィンドウの見方


CPU ウィンドウは,5 つのペイン(画面の部位)から構成されます。 [CPUウィンドウ]

  1. 逆アセンブルペイン
    Delphiでコンパイルし、生成した機械語から逆アセンブルされたアセンブリ命令が表示されます。アセンブリ命令の上には,コンパイル前のソースコードが表示されます。
  2. CPU レジスタペイン
    CPU レジスタの現在の値が表示されます。CPUレジスタとは、CPUの最も近くに配置されたメモリで演算を行うときはまず演算対象をこの領域に格納します。
  3. フラグペイン
    CPU フラグの現在の値が表示されます。CPUフラグとはCPUの演算結果の状態が記録される領域です。たとえば、桁あふれや演算の結果による符号の状態などが記録されます。記録された値は条件分岐などに利用します。
  4. ダンプペイン
    Delphiでコンパイルしたソフトが確保したメモリの中身がすべて16進数とそれに対応するテキストで表示されています。
  5. スタックペイン
    プログラムスタックの現在の内容が表示されます。
    スタックとは、関数呼び出しなどで、制御がほかの位置に飛ぶときに情報をいったん退避させたりするのに使用する、特殊なメモリ領域で、名前のとおり後入れ先出しの仕組みになっています。

まずは、簡単な例から


整数の四則演算を試してみましょう。

最初は、インクリメント


procedure SomeProcedure();
var I: Integer;
begin
  asm
    mov eax,I; {EAXレジスタに変数Iの値を格納する}
    xor eax,I; {EAXレジスタの値と変数Iの値(同じ)の排他的論理和をとる}
    mov I,eax; {演算結果を変数Iに戻す}
    inc I;     {Iの値を1加算する}
  end;
  ShowMessage(IntToStr(I));
end;

 まず、整数型の変数Iを宣言します。これは、インラインアッセンブラからも参照が可能です。
 最初の3行の記述が「I:=0;」に相当します。鋭い人ならわかると思いますが、同じ値同士で排他的論理和をとると必ず0になります。ちなみに、演算結果は最初に書いたほうのレジスタに格納されます。
 ちなみに、「xor I,I」では駄目なのかと思う方もおられると思いますが、CPUは必ずレジスタに入った値とそのほかの値、あるいはレジスタの値同士しか演算できません。
 ですから、「xor I,I」という記述ではエラーになってしまいます。ちなみに、Delphiは変数の数が少ないと優先的に変数にレジスタを割り当てるのでレジスタを直に指定してやればもう少し最適化をすることができます。ただし、宣言する変数の順番などが変わったときにその変数が同じところに割り当てられる保障はないので注意してください。
 デクリメントする場合は、decという命令があります。(使い方は同じ)

足し算(I:=2+3;)


procedure SomeProcedure();
var I: Integer;
begin
  asm
    mov eax,2; {EAXレジスタに数値2を格納する}
    add eax,3; {EAXレジスタの値と数値3を加算する}
    mov I,eax; {演算結果を変数Iに格納する}
  end;
  ShowMessage(IntToStr(I));
end;


引き算(I:=2-I;)


procedure SomeProcedure();
var I: Integer;
begin
  asm
    mov I,3;   {変数Iに数値3を格納する}
    mov eax,2; {EAXレジスタに数値2を格納する}
    sub eax,I; {EAXレジスタの値から変数Iを減算する}
    mov I,eax; {演算結果を変数Iに格納する}
  end;
  ShowMessage(IntToStr(I));
end;


掛け算(I:=2*(-3);)


procedure SomeProcedure();
var I: Integer;
begin
  asm
    mov eax,2;    {EAXレジスタに数値2を格納する}
    mov ecx,-3;   {ECXレジスタに数値-3を格納する}
    imul eax,ecx; {EAX,ECX両レジスタの値の積を求める}
    mov I,eax;    {演算結果を変数Iに格納}
  end;
  ShowMessage(IntToStr(I));
end;


整数除算(I:=5 DIV 2; J:= 5 MOD 2;)


procedure SomeProcedure();
var I,J: Integer;
begin
  asm
    mov eax,5; {EAXレジスタに数値3を格納}
    mov ecx,2; {ECXレジスタに数値2を格納}
    cdq;       {符号拡張}
    idiv ecx;  {EAX,EDXをECXで割った商をEAXに、剰余をEDXに格納}
    mov I,eax; {商をIに格納}
    mov J,edx; {剰余をJに格納}
  end;
  ShowMessage(IntToStr(I));
  ShowMessage(IntToStr(J));
end;


<手動で>関数呼び出し


今度は、インラインアッセンブラを使って、stdcall呼び出し規約で、関数を呼び出して見たいと思います。

全体の方針

関数の呼び出しを大きく分けると、

  1. 引数の引渡し
  2. 関数のアドレスへのジャンプ
  3. 返り値の設定・受け取り

という、3つのステップにわけることができます。ここでは、それぞれのステップに分けて説明をして行きたいと思います。

呼び出し規約とは

 呼び出し規約とは、関数を呼び出すに当たってどのような手順で上方のやり取りを行うのかというのを定めたものです。
 下の表に挙げたように何種類かが使われています。
 Win32 APIでは、stdcallが使われています。


引数の渡される順序

引数の削除

レジスタをの使用

register

左から右

ルーチン側

あり

pascal

左から右

ルーチン側

なし

cdecl

右から左

呼び出し側

なし

stdcall

右から左

ルーチン側

なし

safecall

右から左

ルーチン側

なし


引数の引渡し

 stdcallでは、引き数は後ろから順にスタックに積み込み、最後に目的の関数にジャンプします。
 それでは、変数の型別に、コードを見て行きましょう。

整数型

整数型では値をそのままスタックに積みます。


//例えば、整数5を引数として渡すなら
asm
 mov eax,5; {EAXに数値5を読み込む}
 push eax;  {EAXの内容をスタックに積む}
end;

//例えば、変数Iの値を引数として渡すなら
asm
 mov eax,I; {EAXに整数型変数Iの内容を読み込む}
 push eax;  {EAXの内容をスタックに積む}
end;


文字列型

 文字列型を渡す時は文字列のアドレス(文字列のありか)をスタックに積みます。


//例えば、文字列型変数Sを引数として渡すなら
asm
  lea eax,S;            {EAXに文字列方変数Sのアドレスを読み込む}
  push dword ptr [eax]; {EAXの内容をスタックに積む}
end;



浮動小数点数型

今回使用したDouble型は8Byteの精度なので2つに分けてスタックにアドレスを積んでやる必要があります。


asm
  lea eax,D+4;         {Double型は8Byte使っているので、}
  lea ecx,D;           {連続した2つの領域のアドレスを渡す必要がある。}
  push dword ptr[eax]; {半分をスタックに積む}
  push dword ptr[ecx]; {残りの半分をスタックに積む}
end;


ジャンプ

 関数のアドレスにジャンプするには、「call」命令を使います。「call」命令は、現在実行中のアドレスをスタックに積んだ後で「jmp」命令(無条件ジャンプ)を行って関数のアドレスに実行を移します。ジャンプ先の関数で「ret」命令を実行すると、そのときスタックの一番上にある値を戻り先のアドレスとしてジャンプします。


procedure SomeProc1(); stdcall;
begin
  ShowMessage('Called!');
end;

procedure SomeProc2();
begin
  asm
    call SomeProc1;
  end;
end;


 今回は、手続きの名前(SomeProc1)をそのまま使いましたが、整数型の変数に格納されたアドレスでも問題ありません(内部的には同じ)。

引き数の領域の削除


 引数の領域の削除は呼び出された関数側で行います。つまり、何もしなくてよいです。

返値の受け取り


整数型

返り値はEAXレジスタに格納されて戻ってきますので、それを変数に移すなどして保持します。


asm
  mov eax,I; {変数IにEAXへ返された値を受け取る}
end;


浮動小数点数型

 浮動小数点数型の返り値はFPU(浮動小数点数演算ユニット)のスタックに積まれて戻ってきますので、それを取り出します。


asm
  fstp D; {浮動小数点数型の変数Dに返り値を受け取る}
end;


文字列型変数

 一番曲者なのが、文字列型の変数を返り値とする場合で、関数の呼び出し前に準備が必要です。
 つまり、呼び出し側で戻り先の領域を確保して、そのアドレスを渡しておかなければなりません。
 方法は、すべての引数をスタックに積んだ後に返り値を格納するための文字列型変数のアドレスをスタックに積んでやります。(文字列型変数を引数に渡すのと同じ)
 そうすると、関数から制御が戻るときに返り値として変数に値が代入されます。


asm
  lea eax,S; {EAXに文字列型変数Sのアドレスを読み込む}
  push eax;  {EAXの値をスタックに積む}

end;


 呼出し後は、すでにSに値が返っているので特にやることはないです。

例題

 という訳で、一通り関数の呼び出し方について、説明してきたのですが、最後に例題を。
お題は、

function SomeFnc(SS: String; II: Integer; DD: Double): String; stdcall;

の呼び出しです。


asm
  {倍精度浮動小数点数型変数Dを渡す}
  lea eax,D+4;          {Double型は8Byte使っているので、}
  lea ecx,D;            {連続した2つの領域のアドレスを渡す必要がある。}
  push dword ptr[eax];  {半分をスタックに積む}
  push dword ptr[ecx];  {残りの半分をスタックに積む}

  {整数型変数Iの値を渡す}
  mov eax,I;            {EAXに整数型変数Iの内容を読み込む}
  push eax;             {EAXの内容をスタックに積む}

  {文字列型変数Sを渡す}
  lea eax,S;            {EAXに文字列方変数Sのアドレスを読み込む}
  push dword ptr [eax]; {EAXの内容をスタックに積む}

  {返り値を文字列型変数S2に渡してもらうように設定する}
  lea eax,S2;           {EAXに文字列方変数S2のアドレスを読み込む}
  push eax;             {EAXの内容をスタックに積む}

  call SomeFnc;         {関数の呼び出し}
end;


インラインアッセンブラを使う上では、関数の呼び出しをアッセンブラでやる意味はほとんどないのですが、私は今、インタプリタを開発中で、その核心を握る技術(?)の一つだったので覚書もかねて書かせていただきました。

浮動小数点演算


[続く...]

<目次へ> <第二回へ> <第四回へ>