查看: 515  |  回复: 0
  VB6 VB真是想不到系列之七(完):Matthew Curland的VB函数指针调用收藏?
楼主
发表于 2023年12月17日 00:50

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的深刻理解,而各位技术的综合运用正体现了他深厚的功力。

关键字:函数指针,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编译器,让编译器将我们的函数调用编译到我们的程序里,这样程序才能在运行时知道怎样去定位函数。在程序被编译后,一个函数就是内存里一串汇编字节流,通过CPU解释执行而形成我们的程序。调用一个函数指针,首先需要程序获得指向这个函数字节流的指针,再通过x86汇编指令call将当前指令指针(译注:即x86汇编里的IP寄存器)转到函数所在的字节流上。在函数完成后,再用ret指令返回给调用此函数的程序来继续操作。

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

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

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

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

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

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

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

    这个方法需要我们解决三个特有的问题。第一,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接口输出函数被执行前,栈的样子如下:

具体代码只能由VIP查看,请升级

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

具体代码只能由VIP查看,请升级

这四条指令的连在一起需要6个字节:59 58 51 FF 60 04。我们在后面补两个Int3指令(CC CC)以凑足8个字节,这正好可以一个VB的Currency变量内。这样一个Currency变量的地址里会放着如下的magic number(幻数)——368956918007638.6215@ ——这个Currency变量是指向代理函数的函数指针。这个代理函数挤掉this指针,并可跳到任何函数,而不用考虑函数的参数。这就是说,我们可以用同样的汇编代码来代理任何函数指针。我们现在需要一个vTable来包含这个指向字节流的指针,它实际上是一个函数。(译者:即用vTable的某个入口包含代理函数指针)。   

    使用代理函数需要用到一个结构,它偏移4字节处是我们要调用的函数指针。我们还需要它偏移0个字节处是一个指向vTable的指针,这样才能让这个结构和一个COM对象一样,只有这样VB才能调用到vTable里的函数。我们并没必要为了一个简单的函数指针调用而在堆里分配内存;相反,我们仅需在调用代码的某个地方声明一个FunctionDelegator结构的变量。虽然我们提供了AddRef和Release函数,但它们不做任何事,只不过是迁就一下VB(译者:VB她对我们的对象引用进行严格的跟踪。每当我们新增一个对我们对象的引用,她就会调用一次我们对象里的AddRef,以准确计录对象被引用的次数;每当我们的一个引用和对象分手,她又会调用Release来通知我们的对象减少引用计数。VB她这样做是为了当我们所有的引用都和对象分手后,对象能够在内存里被干净地抛弃。为了迁就VB她的这个习惯,哪怕我们手工建立的对象并不动态分配内存,我们的对象也必须提供AddRef和Release)。所以第四个vTable入口是一个指向代理函数汇编代码的指针。函数代理的代码里声明了一个UDT来包含一个vTalbe数组指针。(代码见Listing1)

    将结构转换成COM对象

    当我们将一个指向合法vTable的指针传给FunctionDelegator结构,并将这个结构拷贝到一个对象变量里,这个结构就成为合法的COM对象了。这个对象的QueryInterface(译者:以下简称QI)函数相信我们所要求的接口vTalbe的第四个入口的函数原型总是和函数指针相符的。如果不支持所要求的接口,QI函数通常返回E_NOINTERFACE错误。这个错误状态在VB里表现出来就是在停在Set语句上的类型不符错误。FunctionDelegator对象的这种信任的设计要求我们必须自己来保证类型安全,我们永远不要向这个对象请求一个不符合函数指针原型的接口。如果我们破坏了这个规则,对我们的惩罚就将是崩溃而不是类型不匹配错误了(译者:要体会这种惩罚,可以试着将Listing1代码里的InitDelegator返回的接口用VB里的任意接口来引用,比如用Shape,由于其第四个接口定义不符,崩溃)。

    FunctionDelegator的vTalbe不进行任何引用计数,所以我们不用编写任何tear-down(严重错误处理)或内存释放代码。当栈越出它的scope时(译者:此处的scope是指FunctionDelegator对象变量的变量范围,即声明和使用它的过程级或模块级范围),COM对象所使用的内存会自动从栈里清除,这意味着InitDelegator所返回的COM对象必然在结构自己销毁之前(或同时)被销毁。

    在VB能够调用到代理函数之前,还有一个步骤:我们必须为我们想要调用的函数指针定义一个接口。通过使用mktylib工具来生成对象定义语言(ODL)文件,我们能够非常容易地做到这一点。尽管mktylib.exe是midl.exe的一个官方的功能简化版本,但当我们要生成给VB使用的严格的类型库时,mktylib.exe相对更容易使用。而且,不同于midl.exe,mktylib.exe它是和单独的VB产品一起销售的。我们的接口定义必须继承自IUnknown并且有一个附加的函数。当我们仅仅使用ODL待性而不使用oleautomation特性时,我们能够避免OLE自动化在注册表里的HKCR/Interface主键下写入不必要的注册键值。虽然我们的QI函数忽略uuid,但是它还是需要我们建立类型库。(译者:虽然可以通过ActiveX工程来生成包含类型库的组件,这样可以不用外部工具就能生成类型库,但是VB里所有的组件都是支持OLE自动化的,它们必须在注册表里注册键值。更重要的是,VB所生成的接口都继承自IDispatch,其vTable并不符合本文的要求。如果不想使用对象定义语言,而想用更纯的VB地来做,就必须修改代理函数的实现,因为继承至IDispatch后,我们只能在vTable的第八个入口里放代理函数指针。虽然这种做法可行,但是实现起来很复杂,因为需要手工建立能迁就VB的IDispatch,而这决不象本文手工建立 IUnknown接口这么简单。虽然可能,但这个弯子绕得太大了)

    作为例子,这里定义了三种函数。第一种是在排序算法中回调的标准的比较函数原型。第二种函数指针调用能够返回COM HRESULT错误代码,比如DllRegisterServer。第三种是一个即没有参数也没有返回值的函数。我们可以按照自己的需要来加入函数声明。保存经过我们修改的FuncDecl.odl文件,并且执行mktylib FuncDecl.odl,然后再将FuncDecl.tlb的引用加入我们的工程。(见Listing2里的ODL)

    我们能够看到,通过调用下面的一对函数,我们的确是可以实时调用函数指针了,而很长时间以来,对VB程序员来说,想使用这对函数是不可能的,这对函数就是DllRegisterServer和DllUnregisterServer。通过访问这两个标准的ActiveX DLL和OCX入口函数,可以让我们的EXE按照自已的需要来定位和注册自己的组件(译者:这个技术还是有相当价值的。虽然能够通过Shell语句调用RegSvr32.exe来注册组件,但是它仅支持标准的入口:DllRegisterServer和DllUnregisterServer。而使用这里的技术,我们就能够调用非标准的入口,在ATL工程里将两个两个输出函数换个名字,我们在VB里依然可以注册,这样简单的操作就能起到一定的保护组件的作用)。对这样的外部函数来说,我们是通过LoadLibrary和GetProcAddress调用来从外部DLL获取函数指针,并将这个函数指针移到FunctionDelegator结构里以使我们能够调用这个函数指针本身。(见Listing3)

    使用函数指针来排序

    (译者:这里原文用了几段来演示如何通过函数指针回调的方法来进行数组排序。仅就本文要谈的函数指针调用来说,这和Listing3里的处理方式类似,因为此处省略这几段。)

    我们能够在很多方面使用这种调用函数指针技术。比如,我们可以通过在运行时插入具有不同行为的函数来动态改变某段代码的行为。我们也可以通过这种技术在VB里实现type casting(强制类型转换)(译者:通过VarPtr得到一个变量的无类型指针,然后将这个指针做为参数,将这个指针传给不同的类型转换函数指针,并调用之,即可实现强制类型转换)。我不可能把所有可能的应用都列出来,但是这里我再来演示一段小程序。

    我们经常想在调试已编译的VB组件时,能在捕获一个错误的同时跳到调试器内。标准的方法就是运行Int3命令,这时会出现一个系统异常对话框来让我们选择是起动调试器还是直接结束崩溃的程序。我们需要运行的函数有两条汇编指令:break(Int3)和return(ret)。相应的ASM指令为CC和C3。用下面来代码来实现一个这样的

具体代码只能由VIP查看,请升级

    在VB里的In-line assembly(线内汇编)代码给VB的表达能力提供了无限的可能性(译者:实际上这和C里的线内汇编有很大不同,我们只能插入机器代码,我觉得此处称为In-Line Machine Code线内机器代码更合适)。 我们这里演示的函数实际上和DebugBreak这个API的功能是一样的(译者:仅就这个函数的功能来说还不如直接用DebugBreak),但是实现别的功能就不是这么简单了。如果我们需要更多的字节,可以用一个Long或Currency数组来填字节流,并用VarPtr取得指向数组第0个元素的指针来作为函数指针。

(全文完)

Listing 1 这段代码将一个FunctionDelegator转换成一个支持特定函数指针的COM对象。这是一个特殊的COM对象,因为它不要求任何内存分配并且对我们的接口请求总是盲目合作。请求仅有的正确接口是我们的责任。

具体代码只能由VIP查看,请升级

    译者:上面的代码在原文已经发表后经过了修改,因此原文没有提到为什么上面的代码需要两个不同的vTable。Matt在更新的示例代码的Readme文件里解释这个原因。我下面将这个原因简单的叙述如下:

    这是因为当调用的函数指针需要返回HRESULT错误代码时,VB会用再次调用QI来向对象请求一个ISupportErrorInfo接口的引用。但是,由于原来代码里的QI完全采用盲目合作的信任方式,它总是返回对象自身的接口指针,哪怕它并不支持所要求的接口。由于返回的接口引用并不支持ISupportErrorInfo,所以当VB试图用ISupportErrorInfo的方法来搜集错误信息时程序就会崩溃。解决的办法,就是提供两个vTable。当第一次调用初始化后的vTable里的QI时,它采取信任方式返回接口指针,并在返回之前将包含失败QI的vTable交换进来。这样下一次访问的QI将是失败QI,而失败QI拒绝所有接口请求,这样就有效的阻塞了后继的QI请求,包括VB对ISupportErrorInfo的请求。在后面的Listing3的代码中我们可以看到,一旦我们增加引用就会有类型不匹配错误。

    还有VB在对Err对象的处理上有BUG,那就是当VB用QI向某个对象请求ISupportErrorInfo接口失败后,Err对象内总是保留着对这个对象的引用。由于我们的vTalbe会先于Err对象释放,所以Err对象里有一个挂起的引用,当释放Err对象时程序会崩溃。解决的方法是:在程序结束前自己用Err.Raise来引发一个新错误。具体做法,见源代码。

Listing 2 用来告诉VB编译怎样调用我们的函数指针的外部ODL文件。没有对这个接口的描述,我们虽仍能生成代理到正确函数指针的COM对象,但却没有办法来调用vTable里的函数。

具体代码只能由VIP查看,请升级

Listing 3 为了实现标准的ActiveX DLL和OCX的注册,我们需要将DLL装入内存,找到用来注册的入口函数指针,然后再调用这个指针。通过使用FunctionDelegator对象,我们能对任意的DLL进行同样的操作。

具体代码只能由VIP查看,请升级


您需要登录后才可以回帖 登录 | 立即注册
【本版规则】请勿发表违反国家法律的内容,否则会被冻结账号和删贴。
用户名: 立即注册
密码:
2020-2024 MaNongKu.com