CRT堆
N年前,在探讨C++对象创建及管理方法时,写了些代码,管理对象 (factory method、singleton、prototype),发现了windows进程及dll退出时的一些行为及由此引起的问题,本文将问题列举并进行讨论,以下讨论都是以程序静态连接为前提,在本文的最后将会回过头来讨论这个大前提,并提出一些解决问题的方法。
CRT堆
首先CRT就是C RunTime的缩写,意思是C运行库。CRT可以理解为windows操作系统对C语言提供的一套支撑库,使得C程序通过C标准库函数就能与操作系统交互,而不需要调用windows API。普通CRT进程退出时,会首先以LIFO调用atexit注册的例程,执行完这些例程后,跟着清理CRT堆空间(而不是清理进程堆空间),接着调用ExitProcess退出。注意了,不要以为进入ExitProcess后,进程就退掉,其实在进入ExitProcess后,将调用已加载的Dll的DllMainCRTStartup,这个函数以Process_Detach调用加载到进程的Dll的DllMain函数,之后,还会调用Dll的atexit注册例程,之后又是清理Dll自身的CRT堆空间。CRT堆就供new和malloc分配内存的进程堆空间。一个重要的概念,MainCRTStartup(DllMainCRTStartup)会调用main(DllMain)、atexit序列及清理CRT堆空间(静态连接为前提)。
Dll的atexit注册例程
其实,每个Dll模块都有自己的atexit stack,这个stack只有在Process_Detach时才会调用,每个Dll的atexit stack单独维护,并且与进程的atexit stack毫无关系。所以,请注意,凡是Dll内注册的atexit,不要引用非自身模块的变量或者非自身模块的CRT堆空间(静态连接为前提),因为,Dll的atexit例程调用序列只按照自身Dll注册的序列调用,与外部的atexit序列不会产生任何关系。而引用的外部变量,很可能在Dll调用atexit注册的例程时已无效,特别是不要引用非自身CRT堆空间(静态连接为前提),本文将围绕自身CRT堆空间进行讨论(当静态连接为前提时,才有自身CRT堆空间这个概念),但我们先来看一个atexit的例子:
如果左图是代码内注册atexit的序列,那么,右图将会是实际情况的一种。可以确定的是,由进程注册的atexit序列是最先调用的,之后将根据Dll加载顺序的倒序,执行Dll的atexit序列。下图是一个完整的调用序列:
重点注意,这里提及的Dll注册atexit是指atexit调用发生在Dll内部,而不是在main函数内使用atexit注册Dll的导出函数。atexit与模块相关但不与代码相关。Dll的atexit序列在FreeLibrary的时候也会调用,因为FreeLibrary会导致系统调用Dll的DllMainCRTStartup函数。
Dll全局对象在DllMainCRTStartup调用DllMain之前就被初始化,所有,你可以在DllMain内使用Dll的全局对象,同样,全局对象在调用DllMain之后才会析构。一个准则,DllMain内可以随意使用全局对象,无论是ATTATCH还是DETACH。
同样重点注意,自身CRT堆空间是模块相关的,当一个Dll被清理自身CRT堆空间后,该Dll通过new或malloc分配的堆内存都变得无效。
经观察发现,如果进程在ExitProcess之前还保留对其他Dll的引用,那么,调用ExitProcess时,windows操作系统将使用LIFO顺序调用这些Dll的DllMainCRTStartup,而不管这些Dll是如何相互引用的。看以下例子(所有执行模块静态连接):
0.先解释这个例子的原意,对象管理器管理对象的创建及销毁对象,并在对象上实施singleton、prototype的特性,但对象的具体创建、销毁方法由具体的dll提供。
1.进程起来,加载对象管理器Dll,管理器Dll通过注册atexit来释放对象池。
2.进程向对象管理器发出请求,获取A.dll的对象AObject,对象管理器按请求加载A.dll,A.dll用new创建AObject,返回给管理器,管理器把AObject保存到对象池内,管理引用计数。
3.进程main函数return,MainCRTStartup调用ExitProcess,ExitProcess导致Dll的DllMainCRTStartup依次被调用。注意,这时调用顺序(LIFO)是先调用A.Dll,再调用管理器Dll。在调用管理器Dll的DllMainCRTStartup时,管理器的atexit注册例程会释放对象池,也就是通过A.dll释放AObject,好,错误产生了:由于ExitProcess是先调用A.Dll的DllMainCRTStartup,清理了属于A.dll的CRT堆空间,使得A.Dll用new创建的AObject变得无效,所以,当管理器的atexit注册例程释放AObject(变成了使用一个无效地址调用AObject的析构函数)时,AV异常。另外,管理器保留A.dll的句柄是合法的,可以顺利调用FreeLibrary,但是,A.dll的DllMainCRTStartup在这种情况下是不会被调用的。以下是图形化的描述:
图1管理器加载到进程
图2 A.dll加载到进程
图3 通过A.dll创建AObject
图4 ExitProcess
A.dll的DllMainCRTStartup清理自身CRT堆空间,导致AObject无效
自身CRT堆究竟是什么
CRT堆是我们使用new、malloc的基础,CRT堆在可执行模块的入口点(MainCRTStartup、DllMainCRTStartup)使用HeapCreate创建。基于win32虚地址机制,CRT堆对象的地址与进程默认堆是在同一个线性地址空间内,使得进程可以通过加减内存地址轻松访问。
好,我们现在知道CRT堆的由来,那么问题来了,当可执行模块是静态连接(或连接到不同版本的CRT运行库)的时候,这份创建CRT堆的代码会存在于各个的可执行模块内部,于是,每个可执行模块在加载时都会创建自己的堆,我把这种堆称为自身CRT堆。
当这些拥有自身CRT堆的可执行模块加载到同一个进程空间内的时候,它们的堆空间虽然能交叉访问,但却不能交叉释放(A模块分配的内存不能在B模块内释放),因为这些内存不是属于同一个堆的。同样,A模块分配的内存在A模块卸载后就变得无效了,因为A模块卸载会释放A模块自身CRT堆。当使用STL容器作模块间传递时,这些问题特别容易显现。想象一下容器内的元素来自不同的CRT堆,当容器析构时是怎么样的情况。
所以,当应用程序以可执行模块划分的时候,应该统一使用动态连接CRT运行库,并且,连接同一个版本的运行库。那么,什么是同一个版本的运行库呢?下一节展开分析。
话说回来,如果所有堆操作都使用进程默认堆,就不会出现问题了。不过,使用默认堆空间效率会比CRT堆低,因为CRT堆会预先保留提交内存以备使用,另外,使用进程默认堆,就不能使用new、delete创建销毁对象了。离开new,如何创建C++对象?^_^,使用placement new嘛。如何delete对象?先显式析构,再HeapFree吧。
附上一个静态连接C程序启动及退出的主要步骤:
静态连接时MainCRTStartup在源文件crt0.c内,DllMainCRTStartup在dllcrt0.c内。动态连接时MainCRTStartup在源文件crtexe.c内,DllMainCRTStartup在crtdll.c内。
同一个版本的运行库
静态连接的可执行模块不会依赖msvcrt、msvcp*、msvcr*这些CRT运行库Dll,所以,静态连接的模块会各自创建自身CRT堆。动态连接时情况就不一样,CRT堆会在CRT运行库加载时创建并且只创建一份(因为CRT运行库Dll只会加载一次)。那么,执行模块会加载哪个版本的CRT运行库呢?这跟vc编译器版本有关,现分别列举以VC6、7.1(2003)、8(2005)为编译器时的情况:
编译器 依赖 | msvcrt.dll | msvcp*.dll | msvcr*.dll |
VC6 | √ | msvcp60.dll | ╳ |
VC7.1 | * | msvcp71.dll | msvcr71.dll |
VC8 | * | msvcp80.dll | msvcr80.dll |
注意,如果是调试版本的模块,依赖的dll将会是调试版本,即文件名最后带一个d字。另外注意打*号的地方,从VC7开始,动态连接编译出来的代码不再直接依赖msvcrt.dll,而是依赖一个与编译器版本相关的msvcr*.dll,编译出来的代码通过这个msvcr*.dll使用msvcrt.dll,无论是调试版本还是发行版本,都只使用msvcrt.dll。最后,只有模块使用到C++的库函数,才会添加msvcp*.dll的依赖。
Microsoft在MSDN内对msvcr*.dll的作用作出了解释:原有的msvcrt.dll是windows操作系统的关键模块。由于在VC6编译出来的程序直接引用这个关键模块,那么,程序发布时的一些误操作(使用发布程序的msvcrt.dll覆盖目标系统的msvcrt.dll)可能会覆盖掉目标系统的msvcrt.dll,导致系统不稳定。在VC7引入msvcr*.dll可避免上述情况出现,而且msvcr*.dll可随程序一同发布。
默认情况下,使用同一个版本的运行库就是指使用同一个版本的VC以动态链接方式编译。如何识别某模块的运行库版本?如果某模块只依赖了msvcrt.dll,它就是VC6编译出来的;如果依赖msvcr71,就是VC7.1;如果依赖msvcr80.dll,就是VC8。
当应用程序以可执行模块划分(并且存在隐式或显式内存交叉释放),模块可能会分开编译。随着时间推移,系统不断升级,可能会导致模块依赖的CRT运行库不相同。那么,应当在模块加载前检查模块的导入表,确认模块的运行库依赖关系,当依赖关系不满足时,拒绝加载模块。这是一个有效的手段,阻止不同版本的CRT运行库加载到同一个进程内。在无法管理模块CRT运行库版本时,应用程序就不应该存在内存交叉释放行为。当无法管理模块CRT运行库版本并且应用程序存在内存交叉释放行为时,应该考虑提供独立的内存管理DLL模块,作为基础设施给上层模块提供内存分配释放功能,一个典型例子就是XFS子系统的WFMAllocateBuffer、WFMAllocateMore和WFMFreeBuffer内存管理API,这类API看上去虽然只是提供了共享内存的控制,其实,更多的是考虑到跨模块内存管理。
总结
本文对CRT堆及atexit作出了分析并阐述个人观点,这些观点是建立在阅读及跟踪CRT源代码的基础上。这些观点解释了一些奇怪的现象,例如:当STL对象跨模块传递时,为什么会出现非法访问,为什么编译时设置为DLL连接就没问题了;为什么XFS要求我们要使用XFS的API分配释放内存。这些观点也指出,在模块化程序设计时,内存管理方面需要注意的地方。如果观点有误,请提出。其实,只要我们明白个中原理,就不用害怕跨模块传递对象了。
为什么会有这篇文章,因为我在研究对象管理器时,编写了一些代码,由于忽略了编译选项,导致后期集成测试时出现了古怪现象:对象还没delete,但对象的内存区域已无效,起初是与atexit顺序有关,后来更发现了与CRT堆也有关系。其实,本文就是排错过程的总结,希望对其他人有帮助。也可参考MSDN: Potential Errors Passing CRT Objects Across DLL Boundaries。
特别注意,VC6创建win32 Dll工程时,默认的编译选项是静态连接!!