天天看點

Delphi内嵌彙編語言BASM精要(轉帖)

1 BASM概念簡要 

彙編語句由指令和零至三個表達式構成。表達式由常數(立即數)、寄存器和辨別符構成。例如: 

movsb        // 單指令語句 

jmp @Here    // 一個表達式: 辨別符 

add eax,1    // 兩個表達式: 寄存器和立即數 

// 三個表達式: 寄存器, 辨別符(記憶體位址), 立即數 

imul edx, [ebx].RandSeed, 08088405H 

一段BASM代碼以ASM關鍵字開始,END關鍵字結束。中間有任意多個彙編語句。 

BASM代碼通常寫在例程中。Delphi的BASM是内嵌于語言的,無法獨立編譯出可執行程式或中間代碼(.Obj)。但是,可以使用BASM來完成一個完全彙編的程式,并使用Delphi編譯器編譯。如下例: 

program TestBASM; 

asm 

     mov eax, 100 

end.

2 表達式的類别與類型 

  在BASM的語句中,每一個表達式都必須能夠在編譯器中計算出準确的值或者尋址位址。如果不能滿足這個條件,語句不會被編譯通過。事實上,對于指令系統來說,每一個表達式都最終對應于一個确定的操作數。是以,表達式的類别(Expression classes),按表達式的計算結果可分成三類:寄存器、立即數和記憶體引用(存儲器)。與記憶體引用相關的表達式,會涉及到存儲器尋址模式的問題,請查閱相關資料。下一小節會簡要講述在BASM中通路Delphi所定義的變量與常量,但不涉及尋址模式。 

在BASM中,表達式的類型(Expression types)是一個長度值,它是指表達式值占用空間的位元組數,即值的大小。這與Delphi中SizeOf()函數含義是一樣的。但BASM中用關鍵字TYPE來傳回表達式的類型(大小)。 

如下例: 

type 

TArr = array [0..10] of char; // SizeOf(TArr) = 11 

var 

Arr : TArr 

mov eax, TYPE Arr 

mov eax, TYPE TArr 

mov eax, TYPE Arr[2] 

end; 

上面的三行彙編語句都會向eax送入值11。第三行看起來是要取Arr數組元素的長度,但實際上隻能取到數組的長度。 

較為複雜的表達式,其類型由第一個操作數的類型來決定。是以下面這個語句送入eax的值仍然為Arr的類型值11: 

mov eax, TYPE (Arr + 2) 

這裡的括号不能了解成函數,而是用來改變運算優先級的。 

同樣的道理,在BASM中,以下兩條語句面對的命運是不同的: 

mov eax, 2 + Arr 

mov eax, Arr + 2 

第一代碼行會被BASM了解成Arr的位址值+2。而第二行代碼右邊表達式的長度為11,不能送入寄存器eax,因而根本不會被編譯通過。

3 資料定義和資料類型強制轉換

BASM可以使用所有通過Delphi文法定義的變量、常量。BASM擴充了ASM的文法,用于通路記錄、數組、對象等複雜的資料結構。 

下例簡單解釋了如何進行資料定義和通路: 

TRec = record 

   rI : Integer; 

   rS : String; 

I : Integer; 

R : TRec; 

S : String = '1234567'; 

A : Array [0..10] of char   = 'abcdefghij'#0; 

const 

C = 3124; 

Str = 'abcde'; 

mov eax, I // I 的值送入 eax 

mov eax, [I] // 同上 

mov eax, OFFSET I // I 的位址送入eax, 相當于 eax = @I 

mov eax, R.rI // 域rI的值送入eax 

mov eax, [TRec.rI + R] // 同上 

mov eax, [Offset R + TRec.rI] // 同上 

mov ebx, S 

dec ebx // 忽略s[0] 

mov esi, 4 

mov al, BYTE [ebx + esi] // 将s[4]的字元值送入al 

mov al, BYTE [ebx + 4] // 同上 

mov eax, [ebx+4] // 将s[4]..s[7]四位元組以DWORD值送入eax, eax=37363534movebx,OFFSETAmoveax,[ebx+4]//将A[4]..S[7]四位元組以DWORD值送入eax,eax=37363534movebx,OFFSETAmoveax,[ebx+4]//将A[4]..S[7]四位元組以DWORD值送入eax,eax=68676665 

mov eax, C // eax = 3124 

mov eax, [C] // eax = PInteger(3124)^, 非法的記憶體位址通路 

在上例中,常量C總是作為數值直接被編碼。是以,“mov eax, C”中,它作為立即數3124被送入EAX。而在“mov eax, [C]”卻表明要通路記憶體位址“3124”,因為“[C]”表明是記憶體引用。 

由于常量總是被直接編碼,上例中,無法通路常量Str——Str的長度大于4,是以無法送入EAX。同樣的原因,在BASM中,對常量使用OFFSET是沒有意義的——盡管在Delphi中,字元串常量可以具有記憶體位址。下例中,EAX總是被送入Str的值,而非位址。

Str = 'abcd'; 

Str2 = 'ab'; 

// eax = 61626364,OFFSET是無意義的moveax,OFFSETStr//eax=61626364,OFFSET是無意義的moveax,OFFSETStr//eax=00006162, 如果字元串長不大于4, 可以送入eax.長度不夠時, 在左側補0 

mov eax, Str2 

end;

BASM不支援通路數組下标(可以用位址運算來替代這樣的文法)。盡管類似“mov eax, TYPE Arr[2]”這樣的語句可以編譯通過,但它總是傳回數組的整個長度(如上一節例子中的值11)。這也正好解釋了“mov al, Arr[2]”這樣的語句為什麼不能被編譯——因為要将一個類型長度為11的資料放入al寄存器,是無法做到的。

BASM中支援兩種類型強制轉換的文法,效果是完全一緻的。

TCode = Record 

S : String; 

aRec : TCode; 

aInt : Integer; 

mov eax, aInt.TCode.I // 使用“表達式.類型”的強制轉換格式 

mov eax, integer(aRec) // 使用“類型(表達式)”的強制轉換格式 

這裡的強制轉換的語義與Delphi是一樣的。但是,BASM的強制轉換,隻是把位址上的變量強制識别成目标類型,而不進行長度校驗。是以可以看到,TCode的長度為8,而整型長度為4,它們之間仍然可以轉換,這樣的轉換在Delphi中是行不通的。

BASM代碼塊中,也可以定義資料。但是,用BASM語句定義的資料總是在代碼段裡,這也是對Delphi無法在代碼段裡定義資料的一個彌補。

BASM支援四個用于定義資料的彙編指令DB/DW/DD/DQ。與ASM不同,不能為這些資料命名。例如:

DB 0FFH // 定義一個位元組 

aVar DB 0FFH // 在ASM中可用,但在BASM中不支援 

可以通過一些技巧來解決命名問題。但是,必須同時用作業系統的API來打開代碼通路權限,才能真正的寫這些資料。下面的例子展示資料定義、命名和讀取的方法:

TCode = packed Record 

   CODE : WORD; // jmp @, 2 Bytes 

   I : Integer; 

   S1 : array [1..26] of char; 

   S2 : array [1..11] of byte; 

Code : ^TCode; 

function ReadCode : Integer; 

jmp @ 

DD 12344213 

DB 'ABCDEFGHIJKLMJNOQRSTUVWXYZ' 

DB 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32 

@: 

mov Code, offset ReadCode 

mov EAX, ReadCode.TCode.I 

// ... 

I := ReadCode; // I = 12344213 

S := Code^.S1; // S = 'ABCDEFGHIJKLMJNOQRSTUVWXYZ'

這個例子以例程名作為變量的位址,但并不是一個好的例子(盡管很多代碼這樣做)。更友善的方法是使用标号作為變量名,與上例類同的例子是這樣: 

@CodeRec : 

mov EAX, @CodeRec.TCode.I // 使用标号作為變量 

I := ReadCode; // I = 12344213

4 例程入口參數及調用約定 

任何情況下,在寄存器的使用上,BASM遵循如下的規則: ASM語句執行過程中,必須儲存EDI、ESI、ESP、EBP、EBX的值。ASM語句可以任意使用EAX、ECX、EDX。 一個ASM代碼塊開始時,EBP指向目前堆棧,ESP指向棧頂。 SS存放堆棧段的段位址;DS存放資料段的段位址;CS存放代碼段的段位址。通常情況下,段位址寄存器滿足如下條件:SS=ES=DS。如果需要,函數總是以EAX(32位)、AX(16位)或AL(8位)作為傳回值的寄存器。

Delphi的例程入口參數有以下幾種:

procedure TestProc(I : Integer); // 值參數

procedure TestProc(var I : Integer); // 變量參數

procedure TestProc(const I : Integer); // 常數參數

procedure TestProc(out I : Integer); // 輸出參數

按照Delphi的文法規定,值參數和常數參數使用相同的傳值規則,但值參數隻是傳入值的備份;變量參數、輸出參數總是傳入值的位址。至于像“無類型參數”、“開放數組參數”等,都是在上面的基礎上聲明的,是以也符合其基本規則。

可以直接修改變量參數和輸出參數傳入的記憶體位址上的值,這種修改能被調用者識别和接收。

對于值參數,必要的情況下,編譯器會生成一段代碼,用于建立值參數的一個備份并用它的位址替換入口參數的位址。除此之外,值參數與常數參數使用相同規則:如果傳入的資料長度小于或等于4 Bytes(這存在一些例外,如Int64),則直接傳值,否則傳值的(對于值參數來說,是值的備份的)記憶體位址。

在不違背上述寄存器使用規則和例程參數傳遞規則的前提下,Delphi支援5種調用約定(如表3-1所列)。

表3-1 例程調用約定

調用約定 

傳參順序 

清除參數責任 

寄存器傳參 

實作目的 

其 他 

register 

由左至右 

例程自身 

是[②] 

提高效率 

Delphi預設規則。

類設計中,公開的聲明強制使用該約定 

pascal   由左至右   例程自身   否   與舊有過程相容   較少使用 

cdecl   由右至左   調用者   否   與C/C++子產品互動   Powerbuilder等其他語言也使用該約定 

stdcall   由右至左   例程自身   否   Windows API   Windows API通常使用該約定 

safecall   由右至左   例程自身   否   Windows API,COM   用于實作COM的雙重接口、錯誤與異常處理  

5 例程和API的調用與流程控制

根據調用約定,通常以register約定來調用Delphi的函數和過程,以cdecl約定來與其他語言混合程式設計,以stdcall約定來調用Windows的API。

下面的例子示範如何調用Delphi的函數: 

function DelphiFunc(I: Integer; var S1, S2:String) : Integer; 

begin 

if I < Length(S1) then 

SetLength(S1, I); 

S1 := S1 + S2; 

Result := Length(S1); 

GS : String = '12345678';

procedure RegisterCall; 

LS : String; 

Len : Integer; 

LS := 'This is a test!';

//以下彙編代碼相當于Delphi語句 

// Len := DelphiFunc(8, LS, GS); 

   mov eax, 8 

   lea edx, LS // 傳入局部變量 LS. 局部變量必須使用lea指令載入位址 

   mov ecx, OFFSET &GS // 傳入全局變量 GS. 變量名與BASM保留字中的GS(段位址寄存器) 

   // 沖突, 是以加複寫辨別符"&". 也可以使用語句lea ecx, &GS 

   call DelphiFunc 

   mov Len, eax 

writeln(LS); // 'This is 12345678' 

writeln(Len); // 16 

// ...

RegisterCall; // 調用該例程,顯示局部變量LS和Len的值

下面的例子示範如何調用Windows API: 

function GetFileSize(Handle: Integer; x: Integer): Integer; stdcall; 

external 'kernel32.dll' name 'GetFileSize'; 

function stdcallDemo : Integer; 

FH : THandle; 

FH := FileOpen('C:\boot.ini', fmOpenRead);

// Result := GetFileSize(FH, nil); 

   push 0 // 第二個參數 nil 入棧 

   push FH // 第一個參數 FH 入棧 

   call GetFileSize // 依據stdcall約定, 例程GetFileSize()将清理棧, 是以BASM 

   // 中不考慮nil和FH參數的出棧 

   mov @Result, eax // 按約定, 傳回值在eax中. 将eax值送入stdcallDemo()的傳回值. 

   // @Result由BASM定義 

FileClose(FH); 

writeln(stdcallDemo); // 輸出檔案'c:\boot.ini'的長度

可能的情況下,BASM總是試圖調整跳轉指令,盡可能地使用短程跳轉(2 Bytes),否則使用近程跳轉(3 Bytes)。隻有在兩者都不可能的情況下,才會使用遠端跳轉(5~6 Bytes)。此外,如果是遠端條件跳轉指令,例如:

JC FarJump

BASM會将指令轉換成這樣的形式:

JNC ShortJump

JMP FarJump

ShortJump:

// next line ...

BASM中,可以用跳轉指令将流程指向目前單元中的任何例程。這使得一些錯誤控制更加簡單而且高效。例如System.pas中,試圖調用純虛方法時會進入例程_AbstractErro(),這時,_AbstractError()會使用一個JMP跳轉到系統的錯誤處理例程_RunError():

@@NoAbstErrProc:

MOV EAX, 210

JMP _RunError

使用JMP,而不是CALL的差別在于:JMP跳轉使得目标例程替代了目前例程的RET指令,這樣,在錯誤處理後,出錯點的後續指令将不會再被執行。如圖3.1所示。

如果要使JMP指令跳轉傳回到下一行,那麼,可以用類似下面的技巧修改EIP指針來實作:

DB E8,E8,0, 0,0,0, 0,0,8F, 04,04,24, 83,83,04, 24,24,0C

jmp proc

在BASM中的任意位置加入上述代碼,即可使得“jmp proc”執行後傳回到下一行。上面用DB定義的内嵌彙編代碼的實際代碼如下:

call @@GetEIP // $E800000000, 将标号@@GetEIP位置作為過程入口調用@@GetEIP:

pop [esp] // $8F0424, 3位元組. 從棧頂彈出EIP值到[esp],該值為@@GetEIP标 // 号的位址

add [esp], 12 // $8304240C, 4位元組. 在@@GetEIP位址上加12個位元組,作為真實的返 

// 回位址在@@GetEIP和@@ReturnHere之間的三條指令長度總是為3+4+5 

// =12 Bytes

jmp proc // 無條件遠端跳轉, 長度為5位元組

@@ReturnHere:

也就是說,“jmp proc”跳轉到的目标例程傳回(RET)時,使用的将是“Call @@GetEIP”時入棧的EIP值,而這個EIP值又通過“+12”被修改成@@ReturnHere的位址。是以,“jmp proc”總是傳回到@@ReturnHere位置,進而得到了與“call proc”類同的效果[③]。

6 完全彙編例程與内嵌彙編例程 

BASM在例程中使用時,可以分成完全彙編例程和内嵌彙編例程兩種。完全彙編是指用asm關鍵字替換了例程的begin,進而使例程完全由彙編代碼實作。在Begin..End中間任意位置加入asm..end的Delphi例程都稱為内嵌彙編例程。 

完全彙編例程中沒有例程入口時的begin,是以,Delphi不會形成值參數的複制。這意味着在完全彙編例程中,值參數與常數參數的處理是一緻的。 

通常情況下,編譯器會自動處理例程的堆棧結構。但是,如果完全彙編例程不是一個子例程(例程嵌套),也沒有入口參數(或它們隻占用寄存器)和局部變量,則編譯器不會為該例程産生堆棧結構。亦即是說,這樣的例程不會在堆棧上配置設定空間。 

完全彙編例程的asm關鍵字會被編譯器解釋成例程入口代碼。例如: 

Unit1.pas.34: asm 

0044C86C 55 push ebp 

0044C86D 8BEC mov ebp,esp 

隻要定義了局部變量,或入口參數使用到了棧,則會生成上面的代碼。但是,局部變量定義還會導緻類似這樣的一行代碼産生: 

0044C86F 83C4D8 add esp,-28這行代碼用于在棧上為局部變量配置設定空間(本例中是28這行代碼用于在棧上為局部變量配置設定空間(本例中是28 Bytes)。但是,如果所有變量在棧上配置設定的總空間不大于4位元組,那麼編譯器會處理成: 

0044C86F 51 push ecx 

這樣實際上也使esp調整了4位元組。但效率會比“add esp, -4”要好得多。如果局部變量是字元串、變體或接口類型,那麼這些變量會被初始化為0。是以,這樣的情況下,編譯器通常采用“push4”要好得多。如果局部變量是字元串、變體或接口類型,那麼這些變量會被初始化為0。是以,這樣的情況下,編譯器通常采用“push00”的方式來實作空間配置設定。而在一些複雜的情況下,編譯器會直接寫棧來初始化這些變量,例如: 

0044C872 33C0 xor eax,eax 

0044C874 8945FC mov [ebp-$04],eax 

對應于在入口代碼中加入的“push ebp”,代碼出口處,編譯器會生成“pop ebp”。 

除了上述的這些情況之外,編譯器不會為完全彙編例程加入其他多餘的代碼。 

如果需要在例程中加入局部變量,但又不影響堆棧,可以使用在例程中定義類型化常量的方法,來代替變量聲明。

7 彙編例程中的傳回值約定 

在完全彙編例程中,函數必須按如下的規則來傳回值[④]: 

F 按照資料類型的長度,序數類型和一些簡單類型(例如集合)使用AL、AX或EAX傳回。 

F 實數類型通過浮點運算器的寄存器堆棧的ST(0)傳回。Currency類型須先放大10000倍。 

F 指針類型、類類型以及類引用類型使用EAX傳回。 

F 對于字元串、動态數組、方法指針、變體以及其他一些大小超過4位元組的資料類型(例如短字元串、變體等)的傳回值來說,傳回值是通過在函數聲明的參數之後另外傳入的變量參數傳回的。 

對于最後一條規則,開發人員通常并不需要計算Delphi将如何“另外傳入一個變量參數”,而隻需要在彙編代碼中通過@Result傳回值即可——Delphi會按照上述的規則完成編譯。 

對于内嵌彙編例程來說,上面的規則完全不适用——編譯器将按Delphi的規則為例程的關鍵字“Begin .. End” 生成入口與出口的處理代碼,傳回值也由例程(而非内嵌彙編代碼)處理。而且,在使用Registry調用約定的例程的内嵌彙編代碼中,EAX、EDX和ECX未必總是例程入口參數的前三個——因為例程的其他代碼可能已經重寫了這些寄存器。

本文轉自 不得閑 部落格園部落格,原文連結:  http://www.cnblogs.com/DxSoft/archive/2010/10/09/1846342.html ,如需轉載請自行聯系原作者

繼續閱讀