这是个很典型的问题,在MSDN上也有描述。问题是这样的:
在一个DLL里面分配内存,然后在DLL的调用者EXE那里释放内存。
当DLL和EXE里面有一个是使用MT连接CRT的时候就有问题。如果DLL和EXE都使用MD,那么就没有问题。
先来看一下问题
直接使用原生指针来传递
在DLL里面创建一个导出函数,如:
void TestOriginalPointer(int** p) { delete *p; int* temp = new int; *temp = 1; *p = temp; }这段代码的意思就是将传进来的数据先删除,再从新分配一个。
调用者代码:
// test1 typedef void(*fTest)(int**); fTest TestOriginalPointer = (fTest)GetProcAddress(h, "TestOriginalPointer"); int* p = new int; *p = 0; TestOriginalPointer(&p);
当DLL里面的函数TestOriginalPointer尝试去delete的时候,就crash了。再来看个例子:
创建一个class来传递一段内存
class MyWrapper { public: explicit MyWrapper(int* p) : m_p(p) {} ~MyWrapper() { if (m_p) { delete m_p; m_p = nullptr; } } void ChangeValue(int* p) { if (m_p) { delete m_p; m_p = p; } } private: int* m_p; };这个class很简单,构造的时候,把传进来的内存地址保存一下,然后析构的时候释放,另外有一个函数可以用来改变里面的内存。
在DLL里面再创建一个导出函数:
void TestMyWrapper(MyWrapper& p) { p.ChangeValue(new int); }
// test2 typedef void(*fTestMyWrapper)(MyWrapper& p); fTestMyWrapper TestMyWrapper = (fTestMyWrapper)GetProcAddress(h, "TestMyWrapper"); MyWrapper w(new int); TestMyWrapper(w);这段代码也会crash:
看了这两个例子,我们来分析一下根本原因吧。
根本原因
假设DLL是静态link crt (MT),EXE是动态link (MD)。我画了个示意图。
C++的new在windows上面,应该就是用malloc来实现的,malloc是CRT的一个函数。
在第一个例子中,假如EXE分配的内存地址是0x00008952,那么这个地址只有在灰色的那个CRT里面才有效,它指向了一块内存。然后我们在DLL里面想释放,就调用delete,这里问题就来了,DLL里面静态link了CRT, 那么delete的时候就会在DLL里面的CRT的heap里面找地址0x00008952,鬼知道指向哪里,这个时候去delete就会导致不可预测的后果了。所以这个问题的根本原因就是同一个内存地址在不同的CRT里面指向的地方是不一样。
如果DLL和EXE都是动态link crt,那么就没这个问题了,因为动态link的时候,就只有一个CRT DLL.DLL和EXE都用的是同一个CRT, 所以没问题。但是一旦其中有一个使用了静态link,就出问题了,这个时候就有2个CRT了。每一个静态link crt的DLL或者EXE, 内部都有自己的一份copy。
那么有什么解决方案呢?首先我觉得我们应该尽量避免DLL里面分配,EXE释放,或者反过来。这种代码会有隐患的。但是有些时候不可避免的时候,怎么办呢?办法也是有的。其实我们可以这么想,假设分配和释放是在同一个CRT里面就没有这个问题了。那么我们如何做到这一点呢?malloc,new等函数,我们是不能改变的,但是我们可以考虑给他们包装一层。我们可以使用虚函数。如果我们创建2个虚函数,一个用来分配内存,一个用来释放内存。在对象构造的时候,这个对象的虚表里面就已经指向了创建这个对象的模块里面的CRT的new和delete,那么当我们在DLL里面调用虚函数来释放的时候,系统会为我们找到构造对象时候的释放函数。这样就没有问题了。写代码试试吧。
用虚函数来分配释放内存
将之前的MyWrapper改造一下。其实就是将ChangeValue改成了虚函数。
class MyWrapperEx { public: explicit MyWrapperEx(int* p) : m_p(p) {} virtual ~MyWrapperEx() { if (m_p) { delete m_p; m_p = nullptr; } } virtual void ChangeValue(int* p) { if (m_p) { delete m_p; m_p = p; } } private: int* m_p; };
void TestMyWrapperEx(MyWrapperEx& p) { p.ChangeValue(new int); }调用:
// test3 typedef void(*fTestMyWrapperEx)(MyWrapperEx& p); fTestMyWrapperEx TestMyWrapperEx = (fTestMyWrapperEx)GetProcAddress(h, "TestMyWrapperEx"); MyWrapperEx w2(new int); TestMyWrapperEx(w2);这样,当w2被创建的时候,w2的虚表里面指向的是EXE里面的那个虚函数ChangeValue。这样当DLL调用ChangeValue的时候,系统会根据虚表来查找虚函数ChangeValue,显然ChangeValue是EXE里面的那份。这样new和ChangeValue里面的delete就在同一个CRT里面了,就是EXE的那份CRT,所以就没有问题了。看一下call stack就会很清楚了。
首先MyTest.exe调用MyDll2.dll的TestMyWrapperEx.然后在TestMyWrapperEx里面,当调用p.ChangeValue的时候,因为ChangeValue是虚函数,所以会通过虚表来查找,这个虚表刚好是MyTest.exe创建的,所以系统找到了MyText.exe里面的那份ChangeValue,这样new和delete就处于同一个CRT了。如果ChangeValue不是虚函数,那么在编译的时候就已经绑定好了,ChangeValue是DLL里面的那一份,这样new和delete就处于不同的CRT了,所以crash。
上面的代码其实有个问题,当TestMyWrapperEx里面调用p.ChangeValue的时候,先释放内存,在存储一个DLL里面new出来的一个内存,这样当对象析构的时候,就会发生问题了。这个对象(w2)是在EXE里面构造的,所以虚表里面的析构函数指的是EXE里面的那一份,那么现在的情况就是ChangeValue的参数指向的内存是DLL分配的,但是释放在EXE里面了,这样就又crash了。其实解决这个问题很简单,在ChangeValue的参数不要直接传个指针,可以传个需要的内存的大小,在ChangeValue内部来分配,这样就没有问题了。
其实我们可以自己创建一个专门的class来管理内存分配和释放。就好象是std::shared_ptr,如果你阅读std::shared_ptr的源代码,你会发现std::shared_ptr内部就是有一个class来处理delete,这个函数就是个虚函数。原理是差不多的。
OK,最后在总结一下,如果我们使用一个虚函数来管理new和delete,那么就可以通过虚表来找到构造对象的那个模块里面的虚函数。这样就可以保证new和delete处于同一个CRT. 好像说起来还是挺简单的,但是实际上想真的搞清楚这个问题,还是得搞自己一步一步去跟一下,这样就会很清楚了。