在我开发基于动态代理的轻量容器过程中,动态装入外部的客户自定义接口/类/组件功能是一个必要的组成部分。对于应该选择用DLL还是BPL来作为自定义组件的实现方式一直不能确定。在反复的试验过程中,发现了一些其中的技术细节,特别是在用字符串类型作为参数或返回值的情况下。
凡是用DELPHI开发过DLL的,都知道DELPHI的DLL向导生成的代码中,在DLL Project Source一开头就有一段长长的“关于DLL内存管理的重要说明”,其内容大致是说:如果在DLL的exports函数中使用string类型作为参数或返回值的,必须在DLL的Uses段和你的应用程序的Uses段的最前面加入ShareMem,它会使二者共同使用BORLNDMM.DLL进行内存管理,才能保证string类型的内存分配/释放正确。
这是因为string类型在DELPHI内部,由编译器为其提供了动态内存分配/释放机制和引用计数机制,这才能使得string类型可以像一般简单类型一样地使用,而不用像在C/C++中那样麻烦地考虑内存管理和防止内存泄漏。但这同时也带了像DLL中的这样的问题:如果不使用ShareMem的话,就有可能发生在一处分配的内存被错误地在另一处释放,最终导致讨厌的Access Violation。
对于简单的函数调用,用DLL+ShareMem就可以实现了,但是如果涉及到接口和类的时候就不行了。
考虑下面这个简单的接口及实现。
//---------------------------------------------------------
// 定义在接口单元
{$M+}
IDemoIntf = interface
['{5F3C4D61-B885-41B6-B43B-C4725DF5D901}']
Function GetHello( nID : Integer ) : String; StdCall;
End;
{$M-}
//---------------------------------------------------------
// 定义在实现单元
type
TDemoImpl = class(TInterfacedObject, IDemoIntf)
protected
{ Protected declarations }
Function GetHello( nID : Integer ) : String; StdCall;
end;
Procedure CompRegister( aIntfReg : TMRegisterIntfEvent ); Cdecl;
implementation
Procedure CompRegister( aIntfReg : TMRegisterIntfEvent );
Begin
aIntfReg( IDemoIntf, TypeInfo( IDemoIntf ), TDemoImpl );
End;
{ TDemoImpl }
function TDemoImpl.GetHello(nID: Integer): String;
begin
Result := 'Hello ' + IntToStr( nID );
end;
首先来看DLL版的实现。创建一个DLL Project,然后把上述两个单元加入,并将CompRegister函数Exports出来。关于这个CompRegister函数要作一下简单说明:
这个CompRegister就是一个注册入口,容器将该DLL调用入立即执行这个注册函数,而用户组件包必须实现这个注册函数,并在其中向容器注册用户实现的接口/类等。TMRegisterIntfEvent是一个方法类型,容器调用注册函数时通过参数将它的注册方法引用传给这个注册函数供它调用。
下面是对应的用DUnit实现的单元测试程序。
procedure TTestCaseDLLPackageLoader.Setup;
Var
funcInit : TMFuncCompRegister;
begin
hPkg := LoadLibrary( 'demopkg.dll' );
funcInit := TMFuncCompRegister( GetProcAddress( hPkg, 'CompRegister' ) );
funcInit( GMIntfReg.RegisterIntf );
end;
procedure TTestCaseDLLPackageLoader.TearDown;
begin
GMIntfReg.UnregisterIntf( IDemoIntf );
FreeLibrary( hPkg );
end;
procedure TTestCaseDLLPackageLoader.TestLoader;
Var
f : IDemoIntf;
begin
f := GMIntfReg.GetIntfInstance( IDemoIntf ) As IDemoIntf;
Check( f.GetHello( 10 ) = 'Hello 10' );
end;
这个测试很简单:首先在初始化(Setup方法[1])载入DLL,然后调用CompRegister注册接口和实现类。在测试函数TestLoader中,通过接口注册管理器(GMIntfReg,由容器提供)的GetIntfInstance方法取得接口实例。这个方法的内部实现原理就是通过接口的GUID找到对应的类类型,然后创建一个实例,再通过Supports函数转成接口引用。调用接口方法GetHello并用DUnit的Check函数进行检查。最后在清理过程(TearDown方法[1])中删除接口注册信息并释放DLL。
但结果却很不幸,测试没有通过。问题就在于,f.GetHello( )返回了一个string,而这个string所用的内存是在DLL中分配的,但因为DELPHI的编译在TestLoader返回前,会自动清理这样的返回值string引用。经过调试可以发现问题就在这里,首先可以肯定,Check函数的检查是通过的,但是打开CPU调试窗口跟踪就可以发现在编译生成的清理代码执行时就发生异常了。
为什么明明使用了ShareMem但还是会出错呢?因为这里调用的接口方法f.GetHello( )并非一个DLL的exports函数,所以它的参数或返回值里用到了string并不会被ShareMem所管理,出错也就是当然的事了。
再来看看BPL方式的实现。同样是创建一个BPL Project,然后把前面那个接口单元和实现单元加入,不过这里就不需要像DLL那样exports了,但需要加入一个Hack的东西:
Procedure HackRegister;
Asm
push edx
push eax
call CompRegister
pop ecx
pop ecx
End;
为什么要这样呢?虽然BPL本质上也是一种DLL,但DELPHI对它的实现是不太一样的。最大的一个特点就是BPL中的内容不需要显式export,而是全部Interface部分的内容都按一定的规律自动export。这个规律大致上是这样:一个单元中的函数的export名是由它的单元名和函数名加上编译附加字符串组成,比如上面这个HackRegister的导出名就是@Unit1@HackRegister$qqrv,其中$qqrv就是编译附加字符串。如果是类方法则还要加上类名,如果有参数或返回值的话,这些信息也会被编译到附加字符串中用于区分overload等的情况。因为那个CompRegister有一个复杂的参数,所以就会形成一个像这样复杂的导出名:@Unit1@CompRegister$qynpqqrrx5_GUIDpx17Typinfo@TTypeInfopx17System@TMetaClass$v。为了避免这样的麻烦,DELPHI的标准BPL用的是一个无参数的Register函数,但这样的话,它就需要依赖一个全局变量来供主程序和BPL共用,而要真正实现共用,这个全局变量又必须在一个公共的BPL中,比如标准BPL是依赖VCL.bpl和RTL.bpl这两个包[2]。我不想再另做一个单独的BPL,所以使用上面那个无参数的Hack方法中转一下,参数通过edx:eax隐含传递。
下面是BPL版的测试代码。
procedure TTestCaseBPLPackageLoader.Setup;
Var
funcInit : TProcedure;
begin
hPkg := LoadPackage( 'demobpl.bpl' );
funcInit := TProcedure( GetProcAddress( hPkg, '@Unit1@HackRegister$qqrv' ) );
CompRegisterHack( funcInit, GMIntfReg.RegisterIntf );
end;
procedure TTestCaseBPLPackageLoader.TearDown;
begin
GMIntfReg.UnregisterIntf( IDemoIntf );
UnloadPackage( hPkg );
end;
procedure TTestCaseBPLPackageLoader.TestLoader;
Var
f : IDemoIntf;
begin
f := GMIntfReg.GetIntfInstance( IDemoIntf ) As IDemoIntf;
Check( f.GetHello( 10 ) = 'Hello 10' );
end;
基本上与DLL版一样,只是调用注册入口是通过CompRegisterHack间接实现,在其中将参数存入edx:eax后调用HackRegister函数指针。除此之外,与DLL版本完全一样。
理论上这样应该是没有问题的,但为了保险起见,我用了调试方式运行这个测试,跟踪了CPU窗口的代码,直到测试函数返回时,似乎都没有问题。但是继续运行下去却在DUnit的测试框架中发生了意料之外的异常。这让我好几天百思不得其解,因为DELPHI本身也是这样使用BPL的,使用String的地方也很常见,从来没听说会有问题。为了找到问题所在,我甚至试过把实现类改为从TComponent派生,但异常仍然存在。
最后我注意到了在这里,虽然在GetIntfInstance中创建的是TDemoImpl的实例,但返回后调用的GetHello方法是接口类型IDemoIntf的成员。理论上调用接口方法本质上是通过OOP的虚函数机制映射到类实例上的,应该没什么区别,但是这里涉及到了跨Module调用(在EXE与BPL之间调用代码)的问题,会不会在这里经过映射后破坏了ShareMem要求的条件?
为了证实这个猜测,我在测试程序中包含了类定义代码,并且将接口方法调用改为类方法调用,一试果然成功。为了深入了解问题的本质,我再次查看了类方法调用和接口方法调用两种情况下的CPU窗口。果然接口方法调用后经过一次虚函数的跳转,而跳转后的目标地址居然与类方法调用的地址不同!但两种情况下不同代码地址处的代码却又是完全一样的!
这就让我一筹莫展了。我甚至为此实现了一个基于接口的String类来代替String类型,虽然用这个可以实现通过参数传递String,但是返回值还是不行。而且这种方法实在是难看而且难用。
今天偶然想到我的测试程序是按静态包方式编译的,即不带BPL方式运行的,而DELPHI本身是编译为带BPL方式运行的。所以我抱着试试看的心态,把测试程序改为带VCL.bpl和RTL.bpl的方式。
果然测试通过了。
参考文献
[1]蔡煥麟《DUnit Delphi 的終極測試工具》
[2]王锐《动态加载和动态注册类技术的深入探索》
[Mental Studio]猛禽 Apr.24-05
本文地址:http://com.8s8s.com/it/it4178.htm