问题现象及测试代码
前几天有同事无意中用debug版的主程序调用了release版的dll中的某个输出函数,该函数new了一块内存并将指针返回给主程序并由后者释放,主程序在调用delete释放内存时断言失败。在排查问题时,同事念叨了一句“难道debug版和release版分配的内存不一样?”,这个问题我之前的确没有注意过,于是便进行了简单探索。本文内容主要针对VC6进行探索,其他版本VC的实现请大家自行研究。
探索过程
既然问题出在释放环节,那么我就直接先从两个编译版本的堆释放部分看起。在debug版下比较好办,我们可以直接看到源代码,delete最终调用了DBGHEAP.C中的_free_dbg函数,该函数如下:
void __cdecl _free_dbg(void * pUserData, intnBlockUse)
{
_CrtMemBlockHeader * pHead;
/* 判断是否每次请求分配和释放内存时检查内存*/
if (_crtDbgFlag & _CRTDBG_CHECK_ALWAYS_DF)
_ASSERTE(_CrtCheckMemory());
/*检查传入的内存指针是否为空*/
if (pUserData == NULL)
return;
/* 调用内存钩子函数,未做深入研究*/
if (!(*_pfnAllocHook)(_HOOK_FREE, pUserData, 0,nBlockUse, 0L, NULL, 0))
{
_RPT0(_CRT_WARN, "Client hook free failure.\n");
return;
}
/*判断指针是否是在本地堆*/
_ASSERTE(_CrtIsValidHeapPointer(pUserData));
/*获取内存对应的内存块头指针*/
pHead = pHdr(pUserData);
/* 验证块类型,在pHead->nBlockUse的低位字不为4和2,并且pHead->nBlockUse不等于1和3时断言报错*/
_ASSERTE(_BLOCK_TYPE_IS_VALID(pHead->nBlockUse));
/*判断是否每次请求分配和释放内存时检查内存*/
if (!(_crtDbgFlag & _CRTDBG_CHECK_ALWAYS_DF))
{
/* 检测堆下溢 */
if (!CheckBytes(pHead->gap, _bNoMansLandFill, nNoMansLandSize))
_RPT3(_CRT_ERROR, "DAMAGE:before %hs block (#%d) at 0x%08X.\n",
szBlockUseName[_BLOCK_TYPE(pHead->nBlockUse)],
pHead->lRequest,
(BYTE *) pbData(pHead));
/*检测堆上溢 */
if (!CheckBytes(pbData(pHead) + pHead->nDataSize, _bNoMansLandFill,nNoMansLandSize))
_RPT3(_CRT_ERROR, "DAMAGE:after %hs block (#%d) at 0x%08X.\n",
szBlockUseName[_BLOCK_TYPE(pHead->nBlockUse)],
pHead->lRequest,
(BYTE *) pbData(pHead));
}
/*关闭调试堆操作选项被打开*/
if (pHead->nBlockUse == _IGNORE_BLOCK)
{
/*pHead->nLine != 0xFEDCBABC || pHead->lRequest != 0就断言失败报错 */
_ASSERTE(pHead->nLine == IGNORE_LINE && pHead->lRequest ==IGNORE_REQ);
/* 用_bDeadLandFill填充整个内存块(从内存块头开始) */
memset(pHead, _bDeadLandFill,
sizeof(_CrtMemBlockHeader) +pHead->nDataSize + nNoMansLandSize);
_free_base(pHead);
return;
}
/* CRT blocks can be freed as NORMALblocks */
if (pHead->nBlockUse == _CRT_BLOCK && nBlockUse ==_NORMAL_BLOCK)
nBlockUse = _CRT_BLOCK;
/* Error if freeing incorrect memory type */
_ASSERTE(pHead->nBlockUse == nBlockUse);
/* 更新已分配的内存数 */
_lCurAlloc -= pHead->nDataSize;
/* 可再生内存不释放,提高性能*/
if (!(_crtDbgFlag & _CRTDBG_DELAY_FREE_MEM_DF))
{
/* 更新链表 */
if (pHead->pBlockHeaderNext)
{
pHead->pBlockHeaderNext->pBlockHeaderPrev= pHead->pBlockHeaderPrev;
}
else
{
_ASSERTE(_pLastBlock == pHead);
_pLastBlock =pHead->pBlockHeaderPrev;
}
if (pHead->pBlockHeaderPrev)
{
pHead->pBlockHeaderPrev->pBlockHeaderNext =pHead->pBlockHeaderNext;
}
else
{
_ASSERTE(_pFirstBlock ==pHead);
_pFirstBlock =pHead->pBlockHeaderNext;
}
/*用_bDeadLandFill填充整个内存块*/
memset(pHead, _bDeadLandFill,
sizeof(_CrtMemBlockHeader) +pHead->nDataSize + nNoMansLandSize);
_free_base(pHead);
}
else
{
pHead->nBlockUse = _FREE_BLOCK;
/* 将内存格式化为未分配状态*/
memset(pbData(pHead), _bDeadLandFill, pHead->nDataSize);
}
}
内存块头结构如下:
typedef struct _CrtMemBlockHeader{
// Pointer to the block allocated just before thisone:
struct _CrtMemBlockHeader*pBlockHeaderNext;
// Pointer to the block allocated just after thisone:
struct _CrtMemBlockHeader*pBlockHeaderPrev;
char *szFileName; // File name
int nLine; // Line number
size_t nDataSize; // Size of user block
int nBlockUse; // Type of block
long lRequest; // Allocation number
// Buffer just before (lower than) the user's memory:
unsigned char gap[nNoMansLandSize];
} _CrtMemBlockHeader;
通过代码我们可以看出CRT堆对分配的每一块内存都通过链表进行了管理,在释放内存时,要根据头结构中的类型进行检查,同时还检查4字节大小的_CrtMemBlockHeader.gap,其被分配后会以0xFD填充,如果我们在使用过程中发生了堆下溢导致该部分内存被修改,在释放时CRT检查到该部分字节被修改就会断言失败报错。在我们申请的内存尾部,也有4字节的内存,被分配后同样以0xFD填充,如果我们在使用过程中发生了堆上溢导致该部分内存被修改,在释放时CRT检查到该部分字节被修改也会断言失败报错,由此我们可以看出debug版进行了很多比较严格的检查,可以帮助我们及早发现和定位问题。检查完内存和类型,释放内存之前,当然需要将该内存对应的链表位置进行更新,最后才是调用真正的释放操作_free_base。请注意传递给_free_base的指针并不是我们要释放的内存的地址,而是该地址向前32个字节,正好是头结构所在的地址,也就是说,在我们程序申请内存时,debug版的CRT会额外多申请36个字节的内存,其中32字节位于内存开始的地方,用于存放头结构,另外4字节位于内存尾部,用于检测内存上溢。
下面看release版的释放过程,由于没有源代码,我们就直接看汇编吧,流程比较简单。
004011A8 PUSH EBP
004011A9 MOV EBP,ESP
004011AB PUSHECX
004011AC PUSH ESI
004011AD MOVESI,DWORD PTR SS:[EBP+8]
004011B0 TESTESI,ESI
004011B2 JESHORT 0040120E
004011B4 MOVEAX,DWORD PTR DS:[409CE8] ;判断当前选择堆是否是V6版本堆
004011B9 CMPEAX,3
004011BC JNZSHORT 004011D4
004011BE PUSHESI
004011BF CALL00402A8C
004011C4 POP ECX
004011C5 TEST EAX,EAX
004011C7 PUSH ESI
004011C8 JE SHORT 00401200
004011CA PUSHEAX
004011CB CALL00402AB7
004011D0 POPECX
004011D1 POPECX
004011D2 JMPSHORT 0040120E
004011D4 CMPEAX,2 ;判断当前选择堆是否是V5版本堆
004011D7 JNZSHORT 004011FF
004011D9 LEAEAX,DWORD PTR SS:[EBP+8]
004011DC PUSHEAX
004011DD LEAEAX,DWORD PTR SS:[EBP-4]
004011E0 PUSHEAX
004011E1 PUSHESI
004011E2 CALL004034F1
004011E7 ADDESP,0C
004011EA TESTEAX,EAX
004011EC JESHORT 004011FF
004011EE PUSHEAX
004011EF PUSHDWORD PTR SS:[EBP+8]
004011F2 PUSH DWORD PTR SS:[EBP-4]
004011F5 CALL 00403548
004011FA ADDESP,0C
004011FD JMPSHORT 0040120E
004011FF PUSHESI ;系统堆,释放的地址就是一开始申请的地址
00401200 PUSH0 ; Flags = 0
00401202 PUSHDWORD PTR DS:[409CE4] ; hHeap = NULL
00401208 CALLDWORD PTR DS:[<&KERNEL32.HeapFree>] ;直接释放了
0040120E POPESI
0040120F LEAVE
00401210 RETN
可以看出,debug版和release版中CRT堆的操作是不一样的,debug版在申请内存时会额外申请36字节的内存以帮助管理和调试,也因此就造成debug版和release版模块申请和释放内存不能混用;同时也再次提醒我们,在编写代码时一定要在debug模式下进行调试,编译器可以帮助我们发现和定位很多问题,否则在release版本下问题暴露时已经错过了有问题代码所在的位置,徒增了调试的难度。