标签搜索

DLL内存从哪里来

anker
2021-06-27 / 0 评论 / 60 阅读 / 正在检测是否收录...

对VS编译选项中的MT/MD一直不太明白其中区别,另外也遇到过从DLL申请内存,在主线程释放遇到错误。以下是网络资源总结,未验证

转载来源:
浅谈C++跨模块释放内存
DLL和进程的地址空间

在开发主程序和动态库时,首要原则就是:避免跨模块申请和释放内存。这一点,我们在很多开源库或者平常项目中也都碰到过,对于动态库中的堆内存申请与释放,动态库总是会提供两个接口分别实现new和delete操作,而不会让调用方自己去操作。但有时候如果违背了这个原则呢,在linux平台上不会存在这样的忧虑,因为在linux下,每个进程只有一个heap,在任何一个动态库模块so中通过new或者malloc来分配内存的时候都是从这个唯一的heap中分配的,那么自然你在其它随便什么地方都可以释放。这个模型是简单的。而windows下就变得复杂了,下面主要介绍一下windows下的主程序和dll之间跨模块内存释放的问题。

windows允许一个进程中有多个heap,那么当需要在堆上分配内存时就要指明在哪个heap上分配,win32提供了HeapAlloc函数可以在指定的堆上分配内存。这样的设计虽然比较灵活,但是问题在于,每次分配内存的时候就必须要显式的指定一个heap,对于crt中的new/malloc,显然需要特殊处理。那么如何处理就取决于crt的实现了。vc的crt是创建了一个单独的heap,叫做__crtheap,它对于用户是看不见的,但是在new/malloc的实现中,都是用HeapAlloc在这个__crtheap上分配的,也就是说malloc(size)基本上可以认为等同于HeapAlloc(__crtheap, size)(当然实际上crt内部还要维护一些内存管理的数据结构,所以并不是每次malloc都必然会触发HeapAlloc),这样new/malloc就和windows的heap机制吻合了。

如果一个进程需要动态库支持,系统在加载dll的时候,在dll的启动代码_DllMainCRTStartup中,会创建这个__crtheap,所以理论上有多少个dll,就有多少个__crtheap。最后主进程的mainCRTStartup 中还会创建一个为主进程服务的__crtheap。(由于顺序总是先加载dll,然后才启动main进程,所以你可以看到各个dll的__crtheap地址比较小,而主进程的__crtheap比较大,当然排在最前面的堆是每个进程的主heap。)

由此可见,对于crt来说,由于每个dll都有自己的heap,所以每个dll通过new/malloc分配的内存都是在自己dll内部的那个heap上用HeapAlloc来分配的,而如果你想在其它模块中释放,那么在释放的时候HeapFree就会失败了,因为各个模块的__crtheap是不一样的。

那么如果有非要用到跨模块释放的场景呢,可以使用以下几种方式来解决:

一, MT改MD

一个进程的地址空间是由一个可执行模块和多个DLL模块构成的,这些模块中,有些可能会链接到C/C++运行库的静态版本,有些可能会链接到C/C++运行库的DLL版本。当使用运行库的DLL版本时,由于dll加载到进程中只会在地址空间中存有一份,因此共用的是同一个堆。所以将可执行模块和DLL模块统一修改为MD编译,则可以直接实现跨模块之间的内存申请和释放,而不会存在任何问题。
更多MT和MD,以及DLL和进程地址空间的知识可以参见博客:DLL和进程的地址空间

二, DLL提供释放接口

DLL提供统一的对外接口,供外部模块(可执行模块或其它DLL模块)调用,由该DLL内部来进行内存的释放。简单实现
如下:

void __stdcall MyFree(void *ptr)
{
    if (ptr)
    {
        free(ptr);
    }
}
void __stdcall MyDelete(void *ptr)
{
    if (ptr)
    {
        delete ptr;
    }
}
void  __stdcall MyDeleteArray(void *ptr)
{
    if (ptr)
    {
        delete[] ptr;
    }
}

三, 使用进程堆申请内存

在一个进程中,可执行模块和DLL模块都属于同一个进程地址空间,而每个进程又都有一个为主进程服务的堆(一般也称为进程的默认堆),当我们需要跨模块进行内存申请和释放时,可以在进程主堆上进行申请,同样地,释放时,也直接在进程主堆上进行释放,这样就可以不用考虑MT导致的跨进程释放的问题。API的使用此处不讲解,直接附上简易代码:
在DLL中:

void* __stdcall Test(int *len)
{
    void* pData = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 100);
    if (pData == NULL)
        return NULL;

    char pBuf[] = "十点十分十分十分";
    memcpy(pData, pBuf, sizeof(pBuf));
    *len = 100;
    return pData;
}

在可执行模块中:

int main()
{
    HMODULE hLib = LoadLibraryA("Dll1.dll");
    if (nullptr == hLib)
    {
        std::cout << "LoadLibraryA fail, error:" << GetLastError() << std::endl;
        return 0;
    }

    Fun fun = (Fun)GetProcAddress(hLib, "Test");
    if (nullptr == fun)
    {
        std::cout << "GetProcAddress fail, error:" << GetLastError() << std::endl;
        return 0;
    }

    int nLen = 0;
    char *pData = (char*)fun(&nLen);

    std::string strTemp(pData, nLen);

    HeapFree(GetProcessHeap(), 0, pData);

    std::cout << strTemp << std::endl;

    return 0;
}

使用默认的进程堆来申请内存还需要注意,很多Windows系统函数都用到了进程的默认堆,而且应用程序会有多个线程同时要调用各种windows函数,因此系统保证不管在什么时候,一次只让一个线程从默认堆中分配或者释放内存快。当两个线程同时想要从默认堆中分配一块内存,那么只有一个线程能够分配,另一个线程必须等待第一个线程的分配完成。这种依次访问对性能会有轻微影响,在一般的应用程序中可以忽略不计,对性能要求较高的程序需要注意。

大家在使用windows编程时,都会发现有一个运行库的编译选项,MT和MD(MTD和MDD只是对应的debug调试模式)。我们以c/c++运行库为例,如果我们的应用程序选择连接到C/C++运行库的静态版本,那么诸如_tcscpy,malloc之类的函数会在内存中出现多次;但是如果连接到C/C++运行库的DLL版本,那么这些函数就只是在内存中出现一次,这意味着内存的使用率非常高,而这个也是windows操作系统从诞生之初就推出DLL的主要原因。

1,使用MD的场景:
(1)程序就不需要静态链接运行时库,可以减小软件的大小;
(2)所有的模块都采用/MD,使用的是同一个堆,不存在A堆申请,B堆释放的问题;
(3)用户机器可能缺少我们编译时使用的动态运行时库。(补充:如果我们软件有多个DLL,采用/MT体积增加太多,则可以考虑/MD + 自带系统运行时库)

2,使用MT的场景:
(1)有些系统可能没有程序所需要版本的运行时库,程序必须把运行时库静态链接上。
(2)减少模块对外界的依赖。

0

评论 (0)

取消