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

asm

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的语法,用于访问记录、数组、对象等复杂的数据结构。

下例简单解释了如何进行数据定义和访问:

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的值,而非地址。

const

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

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 apiwindows 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

var

gs : string = '12345678';

procedure registercall;

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;

// ...

registercall; // 调用该例程,显示局部变量ls和len的值

下面的例子演示如何调用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;

// ...

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, $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个字节,作为真实的返

// 回地址在@@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 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”。

除了上述的这些情况之外,编译器不会为完全汇编例程加入其他多余的代码。

如果需要在例程中加入局部变量,但又不影响堆栈,可以使用在例程中定义类型化常量的方法,来代替变量声明。

7 汇编例程中的返回值约定

在完全汇编例程中,函数必须按如下的规则来返回值[④]:

f 按照数据类型的长度,序数类型和一些简单类型(例如集合)使用al、ax或eax返回。

f 实数类型通过浮点运算器的寄存器堆栈的st(0)返回。currency类型须先放大10000倍。

f 指针类型、类类型以及类引用类型使用eax返回。

f 对于字符串、动态数组、方法指针、变体以及其他一些大小超过4字节的数据类型(例如短字符串、变体等)的返回值来说,返回值是通过在函数声明的参数之后另外传入的变量参数返回的。

对于最后一条规则,开发人员通常并不需要计算delphi将如何“另外传入一个变量参数”,而只需要在汇编代码中通过@result返回值即可——delphi会按照上述的规则完成编译。

对于内嵌汇编例程来说,上面的规则完全不适用——编译器将按delphi的规则为例程的关键字“begin .. end” 生成入口与出口的处理代码,返回值也由例程(而非内嵌汇编代码)处理。而且,在使用registry调用约定的例程的内嵌汇编代码中,eax、edx和ecx未必总是例程入口参数的前三个——因为例程的其他代码可能已经重写了这些寄存器。


======================================================
在最后,我邀请大家参加新浪APP,就是新浪免费送大家的一个空间,支持PHP+MySql,免费二级域名,免费域名绑定 这个是我邀请的地址,您通过这个链接注册即为我的好友,并获赠云豆500个,价值5元哦!短网址是http://t.cn/SXOiLh我创建的小站每天访客已经达到2000+了,每天挂广告赚50+元哦,呵呵,饭钱不愁了,\(^o^)/
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值