CRT堆

CRT

N年前,在探讨C++对象创建及管理方法时,写了些代码,管理对象 (factory methodsingletonprototype),发现了windows进程及dll退出时的一些行为及由此引起的问题,本文将问题列举并进行讨论,以下讨论都是以程序静态连接为前提,在本文的最后将会回过头来讨论这个大前提,并提出一些解决问题的方法。

CRT

首先CRT就是C RunTime的缩写,意思是C运行库。CRT可以理解为windows操作系统对C语言提供的一套支撑库,使得C程序通过C标准库函数就能与操作系统交互,而不需要调用windows API。普通CRT进程退出时,会首先以LIFO调用atexit注册的例程,执行完这些例程后,跟着清理CRT堆空间(而不是清理进程堆空间),接着调用ExitProcess退出。注意了,不要以为进入ExitProcess后,进程就退掉,其实在进入ExitProcess后,将调用已加载的DllDllMainCRTStartup,这个函数以Process_Detach调用加载到进程的DllDllMain函数,之后,还会调用Dllatexit注册例程,之后又是清理Dll自身的CRT堆空间。CRT堆就供newmalloc分配内存的进程堆空间。一个重要的概念,MainCRTStartup(DllMainCRTStartup)会调用main(DllMain)atexit序列及清理CRT堆空间(静态连接为前提)

Dllatexit注册例程

其实,每个Dll模块都有自己的atexit stack,这个stack只有在Process_Detach时才会调用,每个Dllatexit stack单独维护,并且与进程的atexit stack毫无关系。所以,请注意,凡是Dll内注册的atexit,不要引用非自身模块的变量或者非自身模块的CRT堆空间(静态连接为前提),因为,Dllatexit例程调用序列只按照自身Dll注册的序列调用,与外部的atexit序列不会产生任何关系。而引用的外部变量,很可能在Dll调用atexit注册的例程时已无效,特别是不要引用非自身CRT堆空间(静态连接为前提),本文将围绕自身CRT堆空间进行讨论(静态连接为前提时,才有自身CRT堆空间这个概念),但我们先来看一个atexit的例子:

如果左图是代码内注册atexit的序列,那么,右图将会是实际情况的一种。可以确定的是,由进程注册的atexit序列是最先调用的,之后将根据Dll加载顺序的倒序,执行Dllatexit序列。下图是一个完整的调用序列:

重点注意,这里提及的Dll注册atexit是指atexit调用发生在Dll内部,而不是在main函数内使用atexit注册Dll的导出函数。atexit与模块相关但不与代码相关。Dllatexit序列在FreeLibrary的时候也会调用,因为FreeLibrary会导致系统调用DllDllMainCRTStartup函数。

Dll全局对象在DllMainCRTStartup调用DllMain之前就被初始化,所有,你可以在DllMain内使用Dll的全局对象,同样,全局对象在调用DllMain之后才会析构。一个准则,DllMain内可以随意使用全局对象,无论是ATTATCH还是DETACH

同样重点注意,自身CRT堆空间是模块相关的,当一个Dll被清理自身CRT堆空间后,该Dll通过newmalloc分配的堆内存都变得无效。

经观察发现,如果进程在ExitProcess之前还保留对其他Dll的引用,那么,调用ExitProcess时,windows操作系统将使用LIFO顺序调用这些DllDllMainCRTStartup,而不管这些Dll是如何相互引用的。看以下例子(所有执行模块静态连接)

0.先解释这个例子的原意,对象管理器管理对象的创建及销毁对象,并在对象上实施singletonprototype的特性,但对象的具体创建、销毁方法由具体的dll提供。

1.进程起来,加载对象管理器Dll,管理器Dll通过注册atexit来释放对象池。

2.进程向对象管理器发出请求,获取A.dll的对象AObject,对象管理器按请求加载A.dllA.dllnew创建AObject,返回给管理器,管理器把AObject保存到对象池内,管理引用计数。

3.进程main函数returnMainCRTStartup调用ExitProcessExitProcess导致DllDllMainCRTStartup依次被调用。注意,这时调用顺序(LIFO)是先调用A.Dll,再调用管理器Dll。在调用管理器DllDllMainCRTStartup时,管理器的atexit注册例程会释放对象池,也就是通过A.dll释放AObject,好,错误产生了:由于ExitProcess是先调用A.DllDllMainCRTStartup,清理了属于A.dllCRT堆空间,使得A.Dllnew创建的AObject变得无效,所以,当管理器的atexit注册例程释放AObject(变成了使用一个无效地址调用AObject的析构函数)时,AV异常。另外,管理器保留A.dll的句柄是合法的,可以顺利调用FreeLibrary,但是,A.dllDllMainCRTStartup在这种情况下是不会被调用的。以下是图形化的描述:

1管理器加载到进程

 

2 A.dll加载到进程

 


通过A.dll创建AObject

 

4 ExitProcess

A.dllDllMainCRTStartup清理自身CRT堆空间,导致AObject无效

自身CRT堆究竟是什么

CRT堆是我们使用newmalloc的基础,CRT堆在可执行模块的入口点(MainCRTStartupDllMainCRTStartup)使用HeapCreate创建。基于win32虚地址机制,CRT堆对象的地址与进程默认堆是在同一个线性地址空间内,使得进程可以通过加减内存地址轻松访问。

好,我们现在知道CRT堆的由来,那么问题来了,当可执行模块是静态连接(或连接到不同版本的CRT运行库)的时候,这份创建CRT堆的代码会存在于各个的可执行模块内部,于是,每个可执行模块在加载时都会创建自己的堆,我把这种堆称为自身CRT

当这些拥有自身CRT堆的可执行模块加载到同一个进程空间内的时候,它们的堆空间虽然能交叉访问,但却不能交叉释放(A模块分配的内存不能在B模块内释放),因为这些内存不是属于同一个堆的。同样,A模块分配的内存在A模块卸载后就变得无效了,因为A模块卸载会释放A模块自身CRT堆。当使用STL容器作模块间传递时,这些问题特别容易显现。想象一下容器内的元素来自不同的CRT堆,当容器析构时是怎么样的情况。

所以,当应用程序以可执行模块划分的时候,应该统一使用动态连接CRT运行库,并且,连接同一个版本的运行库。那么,什么是同一个版本的运行库呢?下一节展开分析。

话说回来,如果所有堆操作都使用进程默认堆,就不会出现问题了。不过,使用默认堆空间效率会比CRT堆低,因为CRT堆会预先保留提交内存以备使用,另外,使用进程默认堆,就不能使用newdelete创建销毁对象了。离开new,如何创建C++对象?^_^,使用placement new嘛。如何delete对象?先显式析构,再HeapFree吧。

附上一个静态连接C程序启动及退出的主要步骤:

静态连接时MainCRTStartup在源文件crt0.c内,DllMainCRTStartupdllcrt0.c内。动态连接时MainCRTStartup在源文件crtexe.c内,DllMainCRTStartupcrtdll.c内。

同一个版本的运行库

静态连接的可执行模块不会依赖msvcrtmsvcp*msvcr*这些CRT运行库Dll,所以,静态连接的模块会各自创建自身CRT堆。动态连接时情况就不一样,CRT堆会在CRT运行库加载时创建并且只创建一份(因为CRT运行库Dll只会加载一次)。那么,执行模块会加载哪个版本的CRT运行库呢?这跟vc编译器版本有关,现分别列举以VC67.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的依赖。

MicrosoftMSDN内对msvcr*.dll的作用作出了解释:原有的msvcrt.dllwindows操作系统的关键模块。由于在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子系统的WFMAllocateBufferWFMAllocateMoreWFMFreeBuffer内存管理API,这类API看上去虽然只是提供了共享内存的控制,其实,更多的是考虑到跨模块内存管理。


总结

本文对CRT堆及atexit作出了分析并阐述个人观点,这些观点是建立在阅读及跟踪CRT源代码的基础上。这些观点解释了一些奇怪的现象,例如:当STL对象跨模块传递时,为什么会出现非法访问,为什么编译时设置为DLL连接就没问题了;为什么XFS要求我们要使用XFSAPI分配释放内存。这些观点也指出,在模块化程序设计时,内存管理方面需要注意的地方。如果观点有误,请提出。其实,只要我们明白个中原理,就不用害怕跨模块传递对象了。

为什么会有这篇文章,因为我在研究对象管理器时,编写了一些代码,由于忽略了编译选项,导致后期集成测试时出现了古怪现象:对象还没delete,但对象的内存区域已无效,起初是与atexit顺序有关,后来更发现了与CRT堆也有关系。其实,本文就是排错过程的总结,希望对其他人有帮助。也可参考MSDN: Potential Errors Passing CRT Objects Across DLL Boundaries

特别注意,VC6创建win32 Dll工程时,默认的编译选项是静态连接!!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
初识Visual Leak Detector   灵活自由是C/C++语言的一大特色,而这也为C/C++程序员出了一个难题。当程序越来越复杂时,内存的管理也变得越加复杂,稍有不慎就出现内存问题。内存泄漏是最常见的内存问题之一。内存泄漏如果不是很严重,在短时间内对程序不有太大的影响,这也使得内存泄漏问题有很强的隐蔽性,不容易被发现。然而不管内存泄漏多么轻微,当程序长时间运行时,其破坏力是惊人的,从性能下降到内存耗尽,甚至影响到其他程序的正常运行。另外内存问题的一个共同特点是,内存问题本身并不有很明显的现象,当有异常现象出现时已时过境迁,其现场已非出现问题时的现场了,这给调试内存问题带来了很大的难度。   Visual Leak Detector是一款用于Visual C++的免费的内存泄露检测工具。相比较其它的内存泄露检测工具,它在检测到内存泄漏的同时,还具有如下特点:   1、 可以得到内存泄漏点的调用栈,如果可以的话,还可以得到其所在文件及行号;   2、 可以得到泄露内存的完整数据;   3、 可以设置内存泄露报告的级别;   4、 它是一个已经打包的lib,使用时无须编译它的源代码。而对于使用者自己的代码,也只需要做很小的改动;   5、 他的源代码使用GNU许可发布,并有详尽的文档及注释。对于想深入了解内存管理的读者,是一个不错的选择。   可见,从使用角度来讲,Visual Leak Detector简单易用,对于使用者自己的代码,唯一的修改是#include Visual Leak Detector的头文件后正常运行自己的程序,就可以发现内存问题。从研究的角度来讲,如果深入Visual Leak Detector源代码,可以学习到内存分配与释放的原理、内存泄漏检测的原理及内存操作的常用技巧等。   本文首先将介绍Visual Leak Detector的使用方法与步骤,然后再和读者一起初步的研究Visual Leak Detector的源代码,去了解Visual Leak Detector的工作原理。   使用Visual Leak Detector(1.0)   下面让我们来介绍如何使用这个小巧的工具。   首先从网站上下载zip包,解压之后得到vld.h, vldapi.h, vld.lib, vldmt.lib, vldmtdll.lib, dbghelp.dll等文件。将.h文件拷贝到Visual C++的默认include目录下,将.lib文件拷贝到Visual C++的默认lib目录下,便安装完成了。因为版本问题,如果使用windows 2000或者以前的版本,需要将dbghelp.dll拷贝到你的程序的运行目录下,或其他可以引用到的目录。   接下来需要将其加入到自己的代码中。方法很简单,只要在包含入口函数的.cpp文件中包含vld.h就可以。如果这个cpp文件包含了stdafx.h,则将包含vld.h的语句放在stdafx.h的包含语句之后,否则放在最前面。如下是一个示例程序:   #include   void main()   {   …   }   接下来让我们来演示如何使用Visual Leak Detector检测内存泄漏。下面是一个简单的程序,用new分配了一个int大小的内存,并没有释放。其申请的内存地址用printf输出到屏幕上。   #include   #include   #include   void f()   {   int *p = new int(0x12345678);   printf("p=%08x, ", p);   }   void main()   {   f();   }   编译运行后,在标准输出窗口得到:   p=003a89c0   在Visual C++的Output窗口得到:   WARNING: Visual Leak Detector detected memory leaks!   ---------- Block 57 at 0x003A89C0: 4 bytes ---------- --57号块0x003A89C0地址泄漏了4个字节   Call Stack: --下面是调用栈   d:\test\testvldconsole\testvldconsole\main.cpp (7): f --表示在main.cpp第7行的f()函数   d:\test\testvldconsole\testvldconsole\main.cpp (14): main –双击以引导至对应代码处   f:\rtm\vctools\crt_bld\self_x8
一 组件基础 1 软件开发的阶段 1.1 结构化编程 采用自顶向下的编程方式,划分模块 和功能的一种编程方式。 1.2 面向对象编程 采用对象的方式,将程序抽象成类, 模拟现实世界,采用继承、多态的方式 设计软件的一种编程方式。 1.3 面向组件编程 将功能和数据封装成二进制代码,采用 搭积木的方式实现软件的一种编程方式。 2 组件和优点 2.1 组件 - 实际是一些可以执行的二进 制程序,它可以给其他的应用程序、操 作系统或其他组件提供功能 2.2 优点 2.2.1 可以方便的提供软件定制机制 2.2.2 可以很灵活的提供功能 2.2.3 可以很方便的实现程序的分布式 开发。 3 组件的标准 - COM(Component Object Model ) 3.1 COM是一种编程规范,不论任何开发语言 要实现组件都必须按照这种规范来实现。 组件和开发语言无关。 这些编程规范定义了组件的操作、接口的 访问等等。 3.2 COM接口 COM接口是组件的核心,从一定程度上 讲"COM接口是组件的一切". COM接口给用户提供了访问组件的方式. 通过COM接口提供的函数,可以使用组件 的功能. 4 COM组件 4.1 COM组件-就是在Windows平台下, 封装在动态库(DLL)或者可执行文件(EXE) 中的一段代码,这些代码是按照COM的 规范实现. 4.2 COM组件的特点 4.2.1 动态链接 4.2.2 与编程语言无关 4.2.3 以二进制方式发布 二 COM接口 1 接口的理解 DLL的接口 - DLL导出的函数 类的接口 - 类的成员函数 COM接口 - 是一个包含了一组函数指针 的数据结构,这些函数是由组件实现的 2 C++的接口实现 2.1 C++实现接口的方式,使用抽象类 定义接口. 2.2 基于抽象类,派生出子类并实现 功能. 2.3 使用 interface 定义接口 interface ClassA { }; 目前VC中,interface其实就是struct 3 接口的动态导出 3.1 DLL的实现 3.1.1 接口的的定义 3.1.2 接口的实现 3.1.3 创建接口的函数 3.2 DLL的使用 3.2.1 加载DLL和获取创建接口的函数 3.2.2 创建接口 3.2.3 使用接口的函数 4 接口的生命期 4.1 问题 在DLL中使用new创建接口后,在用户 程序使用完该接口后,如果使用delete 直接删除,出现内存异常. 每个模块有自己的内存(crtheap) EXE - crtheap DLL - crtheap new/delete/malloc/free默认情况 下都是从自己所在模块内存(crtheap) 中分配和施放内存.而各个模块的 这个内存是各自独立.所以在DLL中 使用new分配内存,不能在EXE中delete. 4.2 引用计数和AddRef/Release函数 引用计数 - 就是一个整数,作用是 表示接口的使用次数 AddRef - 增加引用计数 +1 Release - 减少引用计数 -1, 如果 当引用计数为0,接口被删除 4.3 使用 4.3.1 创建接口 4.3.2 调用AddRef,增加引用计数 4.3.3 使用接口 4.3.4 调用Release,减少引用计数 4.4 注意 4.4.1 在调用Release之后,接口指针 不能再使用 4.4.2 多线程情况下,接口引用计数 要使用原子锁的方式进行加减 5 接口的查询 5.1 每个接口都具有唯一标识 GUID 5.2 实现接口查询函数 QueryInterface 6 IUnknown 接口 6.1 IUnknown是微软定义的标准接口 我们实现所有接口就是继承这个接口 6.2 IUnknown定义了三个函数 QueryInterface 接口查询函数 AddRef 增加引用计数 Release 减少引用计数 7 接口定义语言 - IDL(Interface Definition Language ) 7.1 IDL和MIDL IDL - 定义接口的一种语言,与开发 语言无关. MIDL.EXE - 可以将IDL语言定义接口, 编译成C++语言的接口定义 7.2 IDL的基础 import "XXXX.idl" [ attribute ] interface A : interface_base { } 7.2.1 Import 导入,相当于C++的 #include 7.2.2 使用"[]"定义区域,属性描述 关键字 1) object - 后续是对象 2) uuid - 定义对象GUID 3) helpstring - 帮助信息 4) version - 版本 5) point_default - 后续对象 中指针的默认使用方式 比如: uniqune - 表示指针可以 为空,但是不能修改 7.2.3 对象定义 1) 父接口是IUnknown接口 2) 在对象内添加函数,函数定义必须 是返回 HRESULT. HRESULT是32位整数,返回函数是否 执行成功,需要使用 SUCCESSED和 FAILED宏来判断返回值.

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值