在线时间:8:00-16:00
迪恩网络APP
随时随地掌握行业动态
扫描二维码
关注迪恩网络微信公众号
使用Delphi的BASM 由于Delphi是基于真正编译器的编程环境,因此可以把汇编语言代码嵌入Object Pascal的过程和函数中。这种能力主要得益于Delphi内建了汇编程序(BASM)。在学习BASM之前,首先应该清楚在Delphi程序中什么时候需要用汇编语言。虽然,能嵌入汇编语言代码是很让人兴奋的事情;但有时,使用BASM也会变成坏事。不过,如果能遵守下列的BASM的规则,就会写出更好、更简捷而且更可移植的代码。
◆只要能够利用Object Pascal 语言实现的事情就不要用汇编语言完成。例如:不要用汇编语言编写串口通信的例程,因为Win32 API提供了串口通信的函数。
◆不要过分依赖汇编语言来优化程序。汇编语言代码虽然比Object Pascal 语言代码稍快一些,但在可读性和可维护性上要差许多。另外,Delphi的优化编译器能使代码更优化,其效果并不亚于手工编写的汇编语言代码。
◆应该总是对汇编语言代码进行注释。因为,代码可能会被别的程序员阅读;而且,在没有注释的情况下,即使自己读起来也会感到困难。
◆不要使用BASM来访问机器硬件。虽然Windows95/98对此宽容些,但Windows NT对此绝对禁止。
◆.尽量把汇编语言代码封装在Object Pascal语言的过程或函数中。这样会使代码可读性更强,并且更易于移植到其他开发平台。
一、BASM是如何工作的 在Delphi程序中使用汇编语句要比想像的简单。事实上,它只需要先键入关键字asm,然后直接输入汇编语句,最后加上end就行了。如下面代码所示: var
二、简易的参数访问 不仅访问全局变量以及过程或函数中声明的局部变量是非常容易的,而且,访问传递给过程或函数的参数也同样很方便。 请看下列代码: procedure Foo(I: Integer); 直接通过参数的名称来访问参数的能力是很重要的,这样就不需要再通过ebp寄存器了。在一般的汇编语言过程中,必须用[ebp+4]代表I。 注意当使用BASM去访问一个传递给过程的参数时,可以只利用它的名称访问,而不需要通过ebp寄存器的偏移来访问。这使代码更易于维护。
三、var 声明的参数
下面代码片断中阐述了如何利用汇编代码访问用var声明的变量: procedure Foo(var I: Integer);
四、Register 调用约定
function BlahBlah(I1, I2, I3: Integer): Integer; 可以认为参数I1的值被存储在eax中,I2在edx中,I3在ecx中。 再看另一个声明: procedure TSomeObject.SomeProc(S1, S2: PChar); 这里参数S1的值被存储在eax中,S2在edx中,隐含的self参数被存储在ecx中。 ★注:根据我的经验,在一个对象的过程中,隐含的self参数是存储在eax中的;而S1在edx中,S2在ecx中。
五、全汇编过程
function IncAnInt(I: Integer): Integer; 注意如果你还在迷恋于16位代码的开发,应该知道Delphi 1时代的assembler指示字已经过时。它已被32位的Delphi编译器忽略。
注意一个ShortString类型是作为一个指向栈内字符串临时实例的指针来返回的。
六、记录
汇编语句由指令和零至三个表达式构成。表达式由常数(立即数)、寄存器和标识符构成。例如: movsb // 单指令语句 一段BASM代码以ASM关键字开始,END关键字结束。中间有任意多个汇编语句。 program TestBASM; 表达式的类别与类型 因此,表达式的类别(Expression classes),按表达式的计算结果可分成三类:寄存器、立即数和内存引用(存储器)。 与内存引用相关的表达式,会涉及到存储器寻址模式的问题,请查阅相关资料。下一小节会简要讲述在BASM中访问Delphi所定义的变量与常量,但不涉及寻址模式。 在BASM中,表达式的类型(Expression types)是一个长度值,它是指表达式值占用空间的字节数,即值的大小。这与Delphi中SizeOf()函数含义是一样的。但BASM中用关键字TYPE来返回表达式的类型(大小)。 如下例: type
下例简单解释了如何进行数据定义和访问: type TRec = record rI : Integer; rS : String; end;
var I : Integer; R : TRec; S : String = '1234567'; A : Array [0..10] of char = 'abcdefghij'#0; const C = 3124; Str = 'abcde';
asm 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=$37363534
mov ebx, OFFSET A mov eax, [ebx+4] // 将 A[4]..S[7]四字节以DWORD值送入eax, eax=$68676665
mov eax, C // eax = 3124 mov eax, [C] // eax = PInteger(3124)^, 非法的内存地址访问 end; 在上例中,常量C总是作为数值直接被编码。因此,“mov eax, C”中,它作为立即数3124被送入EAX。而在“mov eax, [C]”却表明要访问内存地址“3124”,因为“[C]”表明是内存引用。 由于常量总是被直接编码,上例中,无法访问常量Str——Str的长度大于4,所以无法送入EAX。 同样的原因,在BASM中,对常量使用OFFSET是没有意义的——尽管在Delphi中,字符串常量可以具有内存地址。下例中,EAX总是被送入Str的值,而非地址。 Str = 'abcd'; Str2 = 'ab'; asm // eax = $61626364, OFFSET是无意义的 mov eax, OFFSET Str // eax = $00006162, 如果字符串长不大于4, 可以送入eax.长度不够时, 在左侧补0 mov eax, Str2 end;
BASM不支持访问数组下标(可以用地址运算来替代这样的语法)。 尽管类似“mov eax, TYPE Arr[2]”这样的语句可以编译通过,但它总是返回数组的整个长度(如上一节例子中的值11)。这也正好解释了“mov al, Arr[2]”这样的语句为什么不能被编译——因为要将一个类型长度为11的数据放入al寄存器,是无法做到的。 BASM中支持两种类型强制转换的语法,效果是完全一致的。 type TCode = Record I : Integer; S : String; end;
var aRec : TCode; aInt : Integer;
asm mov eax, aInt.TCode.I // 使用“表达式.类型”的强制转换格式 mov eax, integer(aRec) // 使用“类型(表达式)”的强制转换格式 end; 这里的强制转换的语义与Delphi是一样的。但是,BASM的强制转换,只是把地址上的变量强制识别成目标类型,而不进行长度校验。因此可以看到,TCode的长度为8,而整型长度为4,它们之间仍然可以转换,这样的转换在Delphi中是行不通的。 BASM代码块中,也可以定义数据。但是,用BASM语句定义的数据总是在代码段里,这也是对Delphi无法在代码段里定义数据的一个弥补。
BASM支持四个用于定义数据的汇编指令DB、DW、DD、DQ,与ASM不同,不能为这些数据命名。例如: asm DB 0FFH // 定义一个字节 aVar DB 0FFH // 在ASM中可用,但在BASM中不支持 end; 可以通过一些技巧来解决命名问题。但是,必须同时用操作系统的API来打开代码访问权限,才能真正的写这些数据。下面的例子展示数据定义、命名和读取的方法: type TCode = packed Record CODE : WORD; // jmp @, 2 Bytes I : Integer; S1 : array [1..26] of char; S2 : array [1..11] of byte; end;
var I : Integer; S : String; Code : ^TCode;
function ReadCode : Integer; asm 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 end;
// ... I := ReadCode; // I = 12344213 S := Code^.S1; // S = 'ABCDEFGHIJKLMJNOQRSTUVWXYZ' type TCode = packed Record I : Integer; // ... end;
var I : Integer;
function ReadCode : Integer; asm jmp @ @CodeRec : DD 12344213 // ... @: mov EAX, @CodeRec.TCode.I // 使用标号作为变量 end;
// ... I := ReadCode; // I = 12344213
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种调用约定(如表所列)。 例程调用约定
根据调用约定,通常以register约定来调用Delphi的函数和过程,以cdecl约定来与其他语言混合编程,以stdcall约定来调用Windows的API。
例程和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); end;
var GS : String = '12345678'; procedure RegisterCall; // 调用该例程,显示局部变量LS和Len的值 var LS : String; Len : Integer; begin LS := 'This is a test!';
//以下汇编代码相当于Delphi语句 // Len := DelphiFunc(8, LS, GS); asm mov eax, 8 lea edx, LS // 传入局部变量 LS. 局部变量必须使用lea指令载入地址 mov ecx, OFFSET &GS // 传入全局变量 GS. 变量名与BASM保留字中的GS(段地址寄存器) // 冲突, 因此加复写标识符"&". 也可以使用语句lea ecx, &GS call DelphiFunc mov Len, eax end; writeln(LS); // 'This is 12345678' writeln(Len); // 16 end;
下面的例子演示如何调用Windows API: function GetFileSize(Handle: Integer; x: Integer): Integer; stdcall; external 'kernel32.dll' name 'GetFileSize';
function stdcallDemo : Integer; var FH : THandle; begin FH := FileOpen('C:\boot.ini', fmOpenRead);
//以下汇编代码相当于Delphi语句 // Result := GetFileSize(FH, nil); asm push 0 // 第二个参数 nil 入栈 push FH // 第一个参数 FH 入栈 call GetFileSize // 依据stdcall约定, 例程GetFileSize()将清理栈, 所以BASM中不考虑nil和FH参数的出栈 mov @Result, eax // 按约定, 返回值在eax中. 将eax值送入stdcallDemo()的返回值. // @Result由BASM定义 end; FileClose(FH); // ... end;
可能的情况下,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指令,这样,在错误处理后,出错点的后续指令将不会再被执行。
如果要使JMP指令跳转返回到下一行,那么,可以用类似下面的技巧修改EIP指针来实现: DB $E8, $0, $0, $0, $0, $8F, $04, $24, $83, $04, $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个字节,作为真实的返 jmp proc // 无条件远程跳转, 长度为5字节 @@ReturnHere: 也就是说,“jmp proc”跳转到的目标例程返回(RET)时,使用的将是“Call @@GetEIP”时入栈的EIP值,而这个EIP值又通过“+12”被修改成@@ReturnHere的地址。因此,“jmp proc”总是返回到@@ReturnHere位置,从而得到了与“call proc”类同的效果。 完全汇编例程与内嵌汇编例程
完全汇编是指用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 Bytes)。但是,如果所有变量在栈上分配的总空间不大于4字节,那么编译器会处理成: 0044C86F 51 push ecx 这样实际上也使esp调整了4字节。但效率会比“add esp, -$4”要好得多。
如果局部变量是字符串、变体或接口类型,那么这些变量会被初始化为0。因此,这样的情况下,编译器通常采用“push $00”的方式来实现空间分配。而在一些复杂的情况下,编译器会直接写栈来初始化这些变量,例如: 0044C872 33C0 xor eax,eax 0044C874 8945FC mov [ebp-$04],eax 对应于在入口代码中加入的“push ebp”,代码出口处,编译器会生成“pop ebp”。
除了上述的这些情况之外,编译器不会为完全汇编例程加入其他多余的代码。 如果需要在例程中加入局部变量,但又不影响堆栈,可以使用在例程中定义类型化常量的方法,来代替变量声明。 汇编例程中的返回值约定 按照数据类型的长度,序数类型和一些简单类型(例如集合)使用AL、AX或EAX返回。 实数类型通过浮点运算器的寄存器堆栈的ST(0)返回。Currency类型须先放大10000倍。 指针类型、类类型以及类引用类型使用EAX返回。 对于字符串、动态数组、方法指针、变体以及其他一些大小超过4字节的数据类型(例如短字符串、变体等)的返回值来说,返回值是通过在函数声明的参数之后另外传入的变量参数返回的。 对于最后一条规则,开发人员通常并不需要计算Delphi将如何“另外传入一个变量参数”,而只需要在汇编代码中通过@Result返回值即可——Delphi会按照上述的规则完成编译。
对于内嵌汇编例程来说,上面的规则完全不适用——编译器将按Delphi的规则为例程的关键字“Begin .. End” 生成入口与出口的处理代码,返回值也由例程(而非内嵌汇编代码)处理。 在使用Registry调用约定的例程的内嵌汇编代码中,EAX、EDX和ECX未必总是例程入口参数的前三个——因为例程的其他代码可能已经重写了这些寄存器。
|
2023-10-27
2022-08-15
2022-08-17
2022-09-23
2022-08-13
请发表评论