真是想不到系列文章(1-6) - VB6指针技术大揭秘 下载本文

Matthew Curland简介:

Visual Studio开发小组成员,参与开发了VB的IntelliSense和Object Browser。他是VB资深专家,对VB有非常深入的研究,堪称VB大师。所著《Advanced Visual Basice》是阐述VB高级编程技巧的一本好书。 本文英文原著可见2000年2月份《Visual Basic Programmer's Journal》(VB程序员月刊)里的《Call Function Pointers》,这是他发表的妙文之一,他的书里的第11章和本文同名,本文应该是这一章节的精华。

之所以推荐此文,是因为它综合运用了VB里的不少技术。我们可从中看到Matt大师对VB的深刻理解,而各位技术的综合运用正体现了他深厚的功力。

本文原文:http://www.devx.com/premier/mgznarch/vbpj/2000/02feb00/mc0200/mc0200.asp (要先注册成premier用户) 本文配套代码:

http://www.devx.com/free/mgznarch/vbpj/code/2000/02feb00/vb0002mc_p.zip

关键字:函数指针,COM、对象、接口,vTalbe,VB汇编,动态DLL调用。 级别:高级

要求:了解VB对象编程,了解汇编。

调用函数指针 通过使用函数指针,我们能够动态地在代码中插入不同行为的函数,从而使代码拥有动态改变自身行为的能力。

作者:Matther Curland

要求:使用本文的示例代码,你需要VB5或VB6的专业版或企业版。

从Visual Basic 5.0开始Basic语言引入了一个重要的特性:AddressOf运算符。这个运算符能够让VB程序员直接体会到将自己的函数指针送出去的快感。比如我们在VB里就能够得到系统字体的列表,我们能够通过标准的API调用来进行子类化。一句话,我们终于可以象文档里所说的那样来使用Win32 API了。

不过,这个新玩具只能给我们带来短暂的快感,因为这个礼物并不完整。我们可以送出函数指针,但却没人能将函数指针送给我们。事实上,我们甚至不能给我们自己送函数指针,这使我们不能够体验送礼的真正乐趣(译者:呵呵,光送礼却不能收礼的确没趣)。AddressOf让我们看到了广袤天地的一角,但是VB却不让我们全面地探索它,因为VB根本就不让我们调用函数指针,我们只能提供函数指针(译者:可以先将函数指针送给API,然后让API回调自已的函数指针来完成函数指针调用的功能,但这还是要先把礼物送给别人)。其实,我们能够自己来实现调用函数指针的功能,我们可以手工将一个对COM接口的vTable绑定调用变成一个函数指针调用。最妙的是:我们能够在纯VB里写出调用函数指针的代码,不需要任何辅助的DLL。

告诉编译器函数指针是什么样子,是使VB能够调用任何函数的关键。将参数类型和返回值类型交给VB编译器,让编译器将我们的函数调用编译到我们的程序里,这样程序才能在运行时知道怎样去定位函数。在程序被编译后,一个函数就是内存里一串汇编字节流,通

45

过CPU解释执行而形成我们的程序。调用一个函数指针,首先需要程序获得指向这个函数字节流的指针,再通过x86汇编指令call将当前指令指针(译注:即x86汇编里的IP寄存器)转到函数所在的字节流上。在函数完成后,再用ret指令返回给调用此函数的程序来继续操作。

我下面将要提到的方法,利用了VB自己的函数调用方式,所以我先来解释一下VB是怎样来实现函数调用的。VB内部使用三种函数指针,但是,在本质上,不论VB是如何来定位这几类函数指针,调用它们的方法却是一样的。VB编译器必须知道准确的函数原型才能生成调用函数的代码。

第一类,最常见的函数指针类型,就是VB用来调用函数的普通指针,这样的函数定义在标准模块内(或类模块里的友元函数和私有函数)。调用友元函数和私有函数时,调用指令定位在当前指令指针的一个偏移地址处,或者先跳到一个记录着函数位置的查找表里,再跳到函数内(译者:即先\绝对地址\跳到一个跳转表内,表里的每个入口都是一个\到函数)。这些函数都在同一个工程内,联结器总是将所有的模块联结在一起,所以总是知道在内存何处能够找到VB内部函数,因此转移控制到内部函数时,其运行时开销是很少的。

VB对某些函数指针的调用却困难得多

对于另两类函数指针,VB必须在运行时进行额外的工作才能够找出它们。

第二类,VB调用一个COM对象接口里的方法。我们可能认为建立COM对象的工作是相当复杂的,如果完全用VB来为我们建造COM的所有组成部分的话,但事实上并不是这样。按照COM的二进制标准,一个COM对象是一个指针,这个指针指向一个结构,这个特定结构的第一个元素是一个指向函数指针数组的指针。这个函数指针数组(又叫虚拟函数表,简称vTable)里的前三个指针,一定是标准QueryInterface,AddRef,Release函数。vTable里接下来的函数符合给定的COM对象接口定义里的函数定义(见图一)

图一:

函数指针代理是怎么工作的?click here

当VB通过一个对象类型的变量来调用一个COM对象的方法或属性时,这个变量里存放着对这个COM对象接口的引用。VB要定位函数时,首先要通过COM引用的第一个元素来获得指向vTalbe的指针,然后才能在vTable里定位函数指针。对一个vTable调用来说,编译器提供了COM引用和函数指针在vTable里的偏移量。这样函数指针才能在运行时被动态地选出来。这种双向间接的方式——两种指针都必须被计算(译注:指向vTalbe的指针和vTable里的函数指针都必须在运行时才确定)——使得vTable调用比同一个工程内的直接调用慢得多,因为直接调用不需要任何在运行时才能进行的指针间接指定。

VB对待同一个工程里的类的公有方法和对待外部COM对象里方法完全一样,都需要查找vTable,这就是为什么在同一个对象内调用一个友元函数会比调用一个公有函数快得多的原因。但是,查找vTable是COM的基础,它使得VB能够使用从外部库里载入的COM对象,也是象Implements这样的编程概念的实现基础。动态载入不可能通过静态联结来实

46

现,查找vTable的花费是使用动态载入必须付出的代价。

通过Object型变量来进行的后期绑定调用不同于vTable绑定调用。当然,这种差别不在于VB用没用vTable,这种差别是因为对后期绑定调用VB使用了不同的vTable。当进行后期绑定调用时,编译器会调用IDispatch接口的GetIDsOfNemes和Invoke。这需要两次vTable调用和相当多的参数传递,所以这样的处理非常慢,而且必须不断地定位Invoke,才能通过类型信息调用到真正的函数指针(译者:真正慢的原因还是Invoke所进行的参数调整。当拥有相应对象的接口类型库信息时,VB会进行另一种后期绑定——DispID绑定,它只需要在第一次访问对象时调用GetIDsOfNemes,来获得所有属性和方法的DispID,以后的调用只需要对Invoke进行一次vTalbe调用,但由于Invoke才是慢的原因,所以DispID绑定比一般后期绑定快不了多少)。毋庸置疑,当在同一个线程里调用COM对象时,后期绑定将比vTalbe绑定慢几个数量级(译者:同线程内要慢数百倍。由于跨边界的调配开销,随跨线程、跨进程、跨机器,两种绑定方式在速度上的差别将越来越小)

第三类,通过Declare语句来使用函数指针。Declare使得VB能够动通过LoadLibraray API来动态载入特定的DLL,并通过GetProcAddress API和函数名(或函数别名)来得到DLL里特定的函数指针。声明在类型库里的函数指针是在程序装入时通过import table(输入表)来载入的,而通过Declare语句声明的函数指针是在此函数第一次被调用时装入(译者:这两种方式各有优缺点。使用Declare在调用时载入,一来VB运行时直接支持,使用简单,二来当需要载入的DLL不存在时可以在运行时通过错误捕获来处理。而使用类型库一次性载入,一是会增加载入时间,二是当相应的DLL找不到时程序根本就无法起动,但是通过类型库调用API可以绕过VB运行时动态的DLL载入过程,这在某些时候很有必要)。

动态指定函数指针

无论是Declare还是库型库,当函数载入后,VB调用函数指针的方式是一样的。指针已经因为先前的调用而被载入了,所以第二次调用会更快,并且速度接近调用静态联结的函数。Declare语句是VB调用动态载入的函数指针的最自然的方法。但是,函数指针由VB决定而不是由我们来指定(译者:此为原文直译,意思应该是:函数指针只能在编译前指定,由VB来载入,而不能在运行时指定由我们自己动态载入的函数指针),所以我们不能用Declare语句来调用任意的函数指针。Declare语句的限制使我们只能载入在设计时通过Lib和Alias字句指定的函数。

到这里,我已经解释了VB是怎么样来调用自己的函数指针的。对VB本身没有的功能进行扩展都应该通过VB本身提供的工具来实现(译者:看来作者Matt是一位VB纯粹论支持者)。静态联结不用考虑——如果你喜欢自己修改PE文件头的话,请自便(译者:关于修改PE头来Hook输入函数的方法,在1998年2月MSJ专栏Bugslayer里,John Robbins大师就用纯VB实现了HookImportedFunctionsByName,不过用来调用函数指针那是杀鸡用牛刀)。我们不可能静态地指定函数指针,所以Declare语句也不用考虑。但是,我们能够在VB里自己用LoadLibaray和GetProcAddress这两个API来从外部DLL里获取函数指针,就象Declare为我们做的那样。vTable调用是唯一一种让VB自已绑定函数的调用方式。我们的任务是建一个符合COM二进制标准的结构,再将这个手工建立的COM对象的引用放到一个对象类型的变量里,然后调用手工建立的vTable入口。通过调用这个vTable里的函数,就能够直接代理到要调用的函数指针。我称这个对象为FunctionDelegotor(函数代理者)。

47

这个方法需要我们解决三个特有的问题。第一,vTalbe调用有额外的参数(this指针),我们不想将它也传给我们的函数指针。所以我们需要一个通用的代理函数来将这个额外的this指针处理掉,然后才能进行调用。第二,我们需要建立一个vTable里有这个代理函数的COM对象。第三,我们需要一个接口定义才能让VB编译器知道我们的函数指针的样子。接口定义应该将函数原型也包括在vTable里,并且和代理函数在对象vTable里的位置一样(译者:当通过接口调用函数指针时,只有这样才能够让代理函数处理掉做为函数参数压在栈里的this指针)。

我们可以用汇编代码很容易地的写出代理函数(译者:对作者Matt来说的确很容易,因为他对在VB里插入线内汇编代码有相当深入的研究。其实作者这里的容易也是相对于Alpha平台来说的)。在Intel平台,所有传递给COM对象或标准API调用的参数都是通过堆栈来传的。不幸的是,对Alpha平台的VB来说不是这样,它不能提供一种简单的方法来写出同样功能的汇编代码(译注:Alpha平台是一个RISC精简指令集系统,其参数传递多直接使用寄存器,要在这个平台上手工写汇编代码要难得,从他的书的目录里知道他在书里专门拿出一节介绍Alpha平台下的汇编代码)。 压栈

只要我们知道栈是什么样子,我们就可以很清楚的知道汇编代码需要做什么。VB仅仅支持符合stdcall调用规范的函数。这种调用规范,参数总是从右向左压入栈中,并且是由调用者来负责栈的清理。清理的义务跟本文没什么关系,但是压栈的顺序却很重要。尤其要注意的是COM类里的this指针(在VB类里称为Me),它总是作为最左边的参数压栈的。当函数被调用时,函数返回地址(函数返回后程序继续执行的地方)也被call指令本身压入栈中。在任何COM接口输出函数被执行前,栈的样子如下:

parameter n (第n个参数,最右边的参数) ...

parameter 2

parameter 1 (第1个参数)

this pointer(暗藏的this指针才是最左前的参数) return address (返回地址)

但是,我们只想调用函数指针,并不需要暗藏的相关联的this指针。调用一个符合vTable调用却没有额外参数的函数,需要我们将this指针从栈里挤出来,然后才能将控制转移到目标函数指针。让this指针在栈里放着的好处是因为它指向结构。考虑我们定义了一个结构,它的第二个成员是一个函数指针。这个成员距结构开始位置的偏移是4个字节。那么将这个函数指出挤出来并通过代理函数调用它的汇编代码如下:

;弹出返回地址到临时的ecx寄存器, ;后面还要将它恢复。 pop ecx

;从栈里弹掉this指针(译注:做为后面跳转的基址) pop eax

48