《真是想不到系列》
每次看大师的东西到了精彩之处,我就会拍案叫绝:"哇噻,真是想不到!"。在经过很多次这种感慨之后,我发现只要我们动了脑筋,我们自己也能有让别人想不到的东西。于是想到要把这些想不到的东拿出来和大家一起分享,希望抛砖引玉,能引出更多让人想不到的东西。
真是想不到系列之一:VB到底为我们做了什么?
关键字:VB、底层、WIN32、API、COM
难度:中级
要求:熟悉VB,会用VC调试器,了解WIN32 SDK、COM。
VB一直以来被认为有以下优缺点:优点是上手快、开发效率高;缺点是能力有限,运行效率低。这正是有些软件把VB做为首选语言,而有些软件肯定不会用VB做的原因。而很多VC,DELPHI的程序员都认为VB里搞开发不*,它让我们做事变容易的同时,也让我们发挥的余地越来越小。的确,简单和功能强大这两者本身就是一对矛盾。那怕一行代码不写,仅仅起动运行一个空窗体这样简单动作,VB在底下就为我们做了大量复杂的工作(决不仅仅是注册窗口类、显示窗口、起动消息循环这么简单),这些工作对程序员是透明的。我们在感谢VB开发小组对我们程序员体贴入微的同时,不禁也要责怪为什么在文档中对这些底层的动作只字未提,虽然这些动作对最终的程序也许并无影响,但我们拥有知情权,更何况这些动作有时的确会影响我们的工作(我将在本系列后面的《VB多线程》中谈到这种影响)。
然而,所有希望从本文得到"未公开技术秘密"的朋友你将会很失望,因为我能够知道的和你一样多,我们所能做的一切就是站在外面来猜VB在里面做了什么?所以我决不是要带大家一起去将VB反向工程,而是想通过猜想VB的内部工作来将一些原来比较模糊的概念搞清楚。作为一个系列的第一篇文章,它的目的是为了后面的深入打下基础,所以我会在需要的时候指出我们必须掌握的知识点,如果你不清楚,请及时地学习相关书籍来补课,具体见《参考书目》。
最后,要声明我在本文中所做的各种实验和推断仅是我个人的观点,不能保证其正确性,并且不承担任何相关的法律责任。
好,开始吧!首先准备好我们的武器,我下面要使用的工具主要有:VB6中文企业版+SP5(废话),还有SPY++、Dependency Walk和OLE Viewer(以下简称SPY和DEPEND和OLEVIEW,SPY在VB光盘的common/tools/vb/下的SPY目录中,OLEVIEW是其下OLETOOLS目录中的OLEVIEW.EXE,注意其下还有一个OLE2VW32.EXE功能类似,不过本文所指的是OLEVIEW.EXE,还Denpend在其下的Unsupprt/DEPEND里)。还要用用VC(上面提的工具在VC里有),因为我们还要看看VB生成的代码,搞VB高级开发的朋友一定要会用VC调试器,懂点汇编更好。当然,本文的重点不在这儿,所以没有VC也不要紧。
打开VB6新建一标准EXE工程,在"工程"->"引用"对话框里应该已有四个引用,简单点就是:1、Visual Basic For Application(VBA) 2、VB运行时对象库 3、VB对象库 4、OLE自动化。前面三个是任何VB工程都必须的,你想不要都不行,不信你试着去掉对它们的引用。那么这三个核心类型库各有什么用,在最终生成的可执行程序中扮演怎样的角色,这是本文要分析的第一个问题。
1)VB、VBA、VBS的区别你搞清楚了吗?
首先VBS不应该和VB、VBA放在一起比较,它是微软按照自己定义的ActiveX Scripting规范完全从头开始写成的脚本语言,虽然它的语法结构和VB非常相似,但VBS仅仅依靠自动化对象来扩充其功能(只有后期绑定),它不能用implements来实现接口,不可能在VBS里直接使用API,没有VarPtr这样能得到指针的函数,而VBS缺少的这些功能正是VB和VBA所特有的。当然,这不是说VBS不如VB或VBA,Windows已经为VBS提供了足够强大的功能,我们可以用VBS来做脚本COM组件,而且借自动化对象的能力VBS可以说能力无限,所以有病毒用VBS来写,对程序员来说VBS最重要的功能莫过于可以给自己的软件提供宏功能,就象VC中提供的VBS宏功能那样。注意,VBS是Free的,这和在Office中使用VBA来提供宏功能不同,要集成VBA需要价格不低的许可证费用,关于脚本语言可参见MSDN中Platform SDK/Tools and Languages/Scripting。(在本系列后面的文章《脚本功能》中我会实做一个用VBS来提供宏功能的小软件)
那么VB和VBA又有什么不同呢?好吧,眼见为实,开始我们的实验吧!
如果装了Office 2000以上版本,那么打开OLEVIEW,点击File下的View TypeLib查看位于E:/Program Files/Common Files/Microsoft Shared/VBA/VBA6下的VBE6.dll的类型库,再用同样的方法看看MSVBVM60.dll的类型库,你会发现它们的类型库基本上一模一样,除了VBE6多了一个VBEGlobal接口和实现这个接口的Global对象,这个Global对象我们也可以在VBA编程环境(比如用WORD的VB编辑器)中用对象浏览器看到。它有二个方法Load和UnLoad,还有一个UserForms属性,这是因为VBA6使用MS Form 2.0 Form设计器(FM20.dll)来设计和使用UserForm窗体(而在VB6中,我们可以使用多个设计器。比如通过使用MS Form 2.0 Form设计器,我们就能在VB中使用VBA所使用的UserForm用户窗体)。和VBA的Global对象类似,在VB中也有GLobal对象,从VB的对象浏览器中可以知道它在vb6.olb这个类型库中,这个类型库就是每个工程都必须引用的VB对象库,所有的VB内置对象都在这里。而VBA的UserForm中使用的对象都在FM20.dll中。
除了上述不同外,VB和VBA还有一个最大的不同,就是VBA不能生成EXE可执行文件,但可以猜想在IDE环境中VBA和VB都要把代码编译成p-code来执行,后面我将用实验来证明的确是这样,虽然在具体的实现上VB和VBA有很大的不同。
从上面的分析上可以看到VB和VBA还是有很大不同的,这种不同主要体现在编程环境和对象结构上,但在本质上它们之间却有着不可割舍的血源关系。如果刚才你仔细地观察了MSVBVM60.dll的类型库,你就会发现如下的片断:
// Generated .IDL file (by the OLE/COM Object Viewer)
[
dllname("VBA6.DLL"),
uuid(35BFBDA0-2BCC-1069-82D5-00DD010EDFAA),
helpcontext(0x000f6ec4)
]
module Strings {
[entry(0x60000000), helpcontext(0x000f665f)]
short _stdcall Asc([in] BSTR String);
[entry(0x60000001), helpcontext(0x000f6e9f)]
BSTR _stdcall _B_str_Chr([in] long CharCode);
……………
}
什么?在MSVBVM60.dll中的对象其方法却定义在VBA6.DLL中?!VB安装目录下不就有个VBA6.DLL吗?再用OLEVIEW看看它,哇噻,真是想不到它居然和MSVBVM60.DLL的一模一样。怎么回事?赶快再拿出DEPEND来看看VBA6.dll、MSVBVM60.dll和VBE6.dll这三个DLL的输出函数。哈,又有新发现,我们可以发现在三个DLL的输出函数中从编号512到717绝大部分都是一模一样的一些以rtc开头的函数,比如595的rtcMsgBox(rtc是什么?应该是Run Time Component? Control? Code?有谁知道吗?),这说明三个DLL都有着相同的运行时VBA函数。
我们再用DEPEND来观察一下VB6.EXE, 我们可以发现VB6.EXE引入了VBA6.DLL中一些它特有的以Eb和Tip开头的函数,从这些函数的名称上可以发现它们的功能都是IDE相关的,比如79的EbShowCode和82的TipDeleteModule。VB6.EXE恰恰没有引入任何rtc开头的函数(注意一)。我们再来看看MSVBVM60.DLL,随便找一个用了MsgBox函数的编译后的文件,用DEPEND来观察它,就会发现它引入MSVBVM60.DLL输出的595号rtcMsgBox函数(注意二)。并且引入MSVBVM60.DLL中很多以下划线开头的函数,比如__vbaVarAbs(注意三)。其实从这个三个"注意"中我们已经可以进行一些猜想,无论对错,你可以先想想。
如果你没有跟着我做实验,而仅仅是看这篇文章的话,我猜想你应该有点昏了。如果你自己动手做了这些实验,现在你应该充满了疑问而急侍看到结论。所以请一定要亲手试一试,学习研究问题的方法比看结论更重要。
到这里至少我们可以得出结论:VB和VBA本就是同宗的姐妹,只不过姐姐VB的功夫要比妹妹VBA历害些。不过姐姐只会单打独斗是女强人;妹妹却只会傍大款。姐姐有生育能力,是真正的女人;妹妹却不会生崽,但深谱相夫之道,一番教导指挥之下可使她老公增色不少,而VBS呢,也是大户人家的女儿,不过没有VB和VBA姐妹优秀的血统,娇小玲珑干不得粗活只能指挥些自动听话的对象来干活,她乐于助人品德好不象VBA那样只认大款,VB、VBA、vbs三个女人我都喜欢。
2)Native Code(本地代码)到底做了什么?
打起精神,我们再深入一步。用OLEVIEW得到的类型库还不能正确的反映各对象方法对应的DLL中的函数入口,你应该已经发现用OLEVIEW得到的IDL文件中各个方法的entry属性值都是0x600000XX这样的假东西。要得到类型库中各方法在DLL中的真正入口,我们需要自己来写段程序。
即使在VB中我们也可以非常容易地获取类型库信息,再加上点COM初始化和调用代码,我们就能用自己的代码实现VB6才引入的CallByName函数(在本系列后面的《Hack COM》中我会更深入谈谈COM,作为一名VB程序员对COM的理解非常重要)。由于本文的关键不是指导如何在VB里使用类型库,所以下面提供的方法尽量从简。
新建一个标准EXE工程,添加对TypeLib Infomation的引用,在Form中放一个名为lblInfo的标签,然后添加如下代码:
´程序1
Private Sub Form_Load()
Dim oTLInfo As TypeLibInfo
Dim oMemInfo As MemberInfo
Dim sDllName As String
Dim sOrdinal As Integer
Set oTLInfo = TLI.TypeLibInfoFromFile("MSVBVM60.DLL")
lblInfo = "MATH模块包含以下方法:" & vbCrLf
For Each oMemInfo In oTLInfo.TypeInfos.NamedItem("Math").Members
With oMemInfo
.GetDllEntry sDllName, vbNullString, sOrdinal
lblInfo = lblInfo & .Name _
& "定义在" & sDllName & "中," _
& "其编号为" & sOrdinal _
& vbCrLf
End With
Next
End Sub
运行以后我们就可以知道MATH模块中的Abs方法定义在VBA6.DLL中,其编号为656。在DEPEND中查看VBA6.DLL中编号为656的函数,果然就是rtcAbsVar,用VBE6.DLL试试结果相同。
还记得前面的注意一吧,VB6.EXE没有引入rtc开头的函数这说明在IDE环境中执行的VBA方法实际上是通过COM调用VBA对象库中的方法(跟踪p-code是噩梦,所以我无法验证它用的是什么绑定方式)。而注意二中提到的最终可执行程序中引入了rtcMsgBox,如我们所料最终的程序会直接调用它,这要比COM调用快一点,但在跟踪最终程序时,我发现rtcMsgBox内部却是经过了二万五千里长征后才会去调用MessageBoxA这个API,其间有多次对其它对象的COM调用,慢!可能是因为显示的是模态对话框,在多进程多线程环境有很多需要考虑的因素吧,如果你是疯狂在意效率的程序员,你应该试试用API来重写MsgBox,绝对快不少。再来看看注意三,让我们把以下的程序编译成使用本地代码的"程序2.EXE"(为了后面的实验,可以在工程属性的编译选项卡中将它设成"无优化"和"生成符号化调试信息"程序2.EXE""):
´程序2
Private Declare Sub DebugBreak Lib "kernel32" ()
Private Sub Main()
Dim i As Long, j As Long
Dim k
i = &H1234
DebugBreak
k = 1234
j = Abs(k)
j = Abs(i)
MsgBox "ss"
j = VarPtr(i)
End Sub
用DEPEND观察"程序2.EXE",我们可以发现"程序2.EXE"并没有如我们预期的一样在引入595的rtcMsgBox的同时引入656的rtcAbsVar,相反它引入了__vbaVarAbs和__vbaI4Abs,看看函数名就知道一个针对的是Variant,一个针对的是long。这说明VB在最终生成的代码中对象Abs这样的可以进一步针对不同类型优化的VBA函数进行了相应的处理,观察一下所有以__vba开头的函数绝大部分都是那些最基本最常用的VBA函数,可以说__vba开头的VBA函数是rtc开头的VBA函数的优化版本,它们基本上是VB开发小组重新写的,绝大多数在函数内部实现自身功能,而rtc开头的函数大多数是调用COM服务对象来完成工作。从这么多__vba开头的函数上可以看出VB小组在Native Code(本地代码)的优化上下了不少功夫,这决对不是吹牛。它的确高度优化了不少科学计算相关的函数,以ABS为例Native Code要比p-code快4倍以上。但是并不是所有的计算函数都经过了这样的优化,比如Rnd函数,它就没有对应的__vba开头的优化函数,而是直接对应到rtcRandomNext函数上,虽然rtcRandomNext也已经优化过,但内部依然用了COM调用,还是不如自己重写的快,我不明白为什么VB开发小组没有考虑为它写一个对应的__vbaRnd。
不要以为上面的分析没有意义,因为我们可以从现象看本质,也可以从本质来解释现象。比如我们再做一个实验,给你的代码加入一个类模块,你可以试试声明一个和内部方法同名的公有的方法(这是一个很有用的技术,在本系列后面的《错误处理》中我们会用到这种方法),比如我们可以声明一个Public Function Rnd(x) as single,同样我们可以自己写一个同名的MsgBox。但是你试试能不能声明一个Public Function abs(x) ,这时VB肯定会弹出一个莫名其妙的编译错误提示框告诉你"缺少标识符",这种错误发生在你的函数名和VB关键字冲突的时候。但是为什么同样是MATH模块中的函数,abs是关键字,rnd却不是,VB文档里是不会告诉你为什么的,但如果你认真的看了我上面的实验分析,我们就能猜想这是因为VB对需要进一步优化的函数已经做了高度优化处理,VB开发小组为了保护他们的劳动成果,并显示他们对自己优化技术的自信,而禁止我们重写这些函数,同时VB开发小组也承认还有些函数有待进一步优化,所以准许我们重写之。在这里我要提出一个伟大的猜想:凡是能够被重写的函数就能够被优化,就象凡是大于2的偶数就能够被分解成两个质因数的和一样。
说到优化,还应该谈谈直接API调用和使用API类型库的差别,还必须谈谈VB所使用的后端优化器(和VC用的是一样的优化器),还想谈谈如何尽最大可能来使用vTable绑定……(准备在本系列中另写一篇《优化》来谈这些问题)。
看了本地代码,我们再来看看p-code,要是你看了MSDN中关于p-code的原理,你肯定会头大。平心而论p-code真是一个了不起的技术,代码大小平均可以缩小50%。我们把程序2编译成p-code看看,还是用DEPEND来观察,发现它并没有引入__vba开头函数(没有使用优化的VBA函数?),却引入了CallEngine这样的东西(肯定是为了调用p-code伪码解释引擎),而且和Native Code一样都引入了rtcMsgBox(编译生成的p-code在调用MsgBox时应该比在IDE环境中运行的p-code快)。
如果你迫不及待地运行了程序2,你就会发现它将弹出一个应用程序错误对话框,说程序发生异常。别怕,这是因为调用了DebugBreak这个API的缘故,这个API其实就是产生一个Int 3中断,使得我们能够中断程序执行。如果你装了VC这样的支持即时调试的调试器,你可以在错误对话框中点击"取消",这样可以起动调试器来调试程序。我就是这样跟踪程序运行的。如果你想看看VB生成的程序反汇编代码可以自己试试,我们可以用同样的技术在VB或VBA的IDE中来中断程序执行,比如我们完全可以在Word的VB编辑器中运行上面程序2的代码,从而中断于Word的进程中,并可观察到VBA生成的p-code代码。比如VB和VBA在IDE中生成的p-code代码就会发现它们这间有很大的不同。
所以,IDE中运行的程序和最终生成的程序是完全不同的。用SPY++看看你在IDE中运行的窗体,你会发现它在VB的主线程下,也就是说在IDE中你用程序做出的窗体和VB IDE工作窗口一样属于VB IDE,你的程序在IDE中运行时申请的资源也属于VB IDE。有些程序在IDE中运行会让IDE死掉(在VB5中写纯API多线程就千万别在IDE中运行,定死无疑,相比之下VB6的IDE健壮得多)。还有些程序可能在IDE中能正常工作,但生成EXE后就工作不了。总之,在写系统程序时要考虑到这种不同可能引起的问题。
3)VB的编译技术,要我怎么夸你,又要我怎么骂你。
看了上面对Native Code的高度评价,你可能会对VB做出的东西更有信心了,腰板更直了。是的,作为VB程序员没有什么需要害羞的,一个功力深厚的VB程序员理应拿比普通VC程序员更多的工资,因为他的生产力是VC程序员的好几倍,而做出的程序在质量上和VC做的相差无几。
甚至有大师开玩笑说VB的内置对象就是用VB写出的,比如我们可以自己写Form.cls、Label.ctl,呵呵,我们还真不能排除这种可能性(虽然用VB不可能直接生成vb6.olb)。如果真是这样,看来VB小组自己都对自己的编译优化技术非常有信心。
实际上我们看看VB安装目录下的C2.exe的属性,再看看VC的C2.DLL的属性,就会发现它们是同一个东西,同样Link.exe也是VC的,所以我们完全可以对VB程序的后端优化编译器以及联结放心了。它们根本就是VC开发小组东西,或者VB、VC都是同一个编译器开发小组在做编译模块。总之,我们可以壮着胆说我们VB做的程序其二次优化和联结用的是和VC一样的技术,嘿嘿,你有的我也有,我有的你没有的(纯属诡辩)。
还有,没有任何编译器比VB编译器更快,因为在IDE中VB就是一种解释型语言,这才是VB开发效率高的关键,快得几乎感觉不得编译过程。其请求时编译,后台编译技术更是一只独秀,厉害啊!想想看,别的语言的程序员有多少时间花在了等待代码编译和重新联结上啊!
不要高兴得太早,因为最终的目的还是要生成可执行文件。在VB中没有分块编译和增量联结的功能,VB在生成可执行程序时总是编译所有模块并完全重新联结,而在别的编译语言中我们可以仅编译最近修改过的文件(分块编译),联结时将新生成的代码附在可执行程序的后面,并将原来的代吗标记为作废(增量联结,最终的可执行程序会越来越大,但联结时间大大缩短)。做实验看看,会发现在VB中每次生成可执行文件所花时间都是相同的。我不知VB开发小组为什么不提供分块编译和增量联结的功能,可能VB开发小组认为生成可执行文件在VB中不是经常要做的工作。但是实际上这种理由是说不过去的,因为如前面所说IDE中运行程序和最终程序有很大不同,如我们要经常编译出可执行文件才能真正对它进行Profile,又如我们要调试多线程程序不能在VB IDE中做,在这些情况下每次修改后都要重新生成可执行文件,我们浪费了不少时间去编译已编译过的代码,联结已联结过的程序。我猜想这是因为VB生成可执行程序时进行了全局优化,所以必须得全部重新编译联结。但提供一个新的功能让我们能够生成不进行全局优化的可以分块编译的调试版本,对Vb开发小组应该不是难事吧!(我有一个变通的解决方案,还在试验中)
在来看看VB6安装目录下的VBAEXE6.lib,怎么只有1k大一点,可以猜想里面应该不会有代码,多半是些象vTable这样的函数地址跳转表,或者是些全局常量,我也不知道。但至少说明VB可以用静态联结库了,为什么不把这个功能提供给我们,让我们有更多的选择。
再做个实验看看,做一个标准EXE工程,里面只有一个标准模块,模块里面只一个Sub Main,Sub Main里面什么也没有,将它生成为EXE文件。看看,嚯,有16k多。你要是有时间跟踪这个什么也不做的程序看看,就会知道它要做很多事,初始化Err和App对象,准备COM调用,准备VB、VBA对象库,甚至为使用ActiveX控制也做了准备,嘿嘿,看服务多周到。你必须得用VB对象库中的控制,不用也不行。你再多找几个EXE工程看看,有很多东西相同,都是一个模子做出的,而且你没有选择模子*。ActiveX工程也是一样,都是Dual双接口,你做的ActiveX控制都必须要躲在一个Extender Object后面。是的,在VB里有很多东西你没有选择的*。如果需要这种*要么不用VB,要么就得采取一些未公开的非官方的古怪的技巧(本系列文章最重要的目的之一,就是介绍这样的非官方技巧)。
这又到文章开头说的,VB让我们做事情变得容易的同时也让我们失去了不少*。在最终代码的生成上则也采取了公式化的做法。当然,我们应该全面地来看待这个问题,如同生产线上生产的东西不一定比手工的精致,群养的家禽不如野味好吃的道理一样,如果需要精致的野味,意味着更多的劳动和更大的成本,这和VB所追求的更容易更便宜的目标是相违背的。
4)VB程序员也得有HACK精神。
本文的最后这个标题是严重离题了,但我想在此为本系列文章定下一个充满HACK精神的基调。HACK精神是什么?没有准确的定义,我的理解是:HACK精神 = 总想探寻未知领域的好奇心 + 凡事总想知道为什么的研究欲 + 总想拿出自己的东西的创新精神 + 解决问题的耐心和恒心。 VB的程序员也一样需要这种精神。
最后,我们都知道VB开发小组已经达上.NET的快车飞起来了,不能不说VB6以后再没有VB的新版本了。微软已经用.NET为我们划出了新的圈子,VB.NET是这个新圈子里的新产物。在圈子里面我们能够飞得更高,但是圈子外面的天空更大,所以我依然乐意站在圈子外,虔诚地祈祷真正的VB7的诞生,阿门。