【发布时间】:2021-02-01 17:09:50
【问题描述】:
当我在调试模式下使用 VisualStudio 编译程序运行时,有时我会得到
调试断言失败!表达式:
_CrtIsValidHeapPointer(block)
或
调试断言失败!表达式:
is_block_type_valid(header->_block_use)
(或两个都在一个之后)断言。
这是什么意思?如何找到并解决此类问题的根源?
【问题讨论】:
标签: c++ c debugging visual-c++
当我在调试模式下使用 VisualStudio 编译程序运行时,有时我会得到
调试断言失败!表达式:
_CrtIsValidHeapPointer(block)
或
调试断言失败!表达式:
is_block_type_valid(header->_block_use)
(或两个都在一个之后)断言。
这是什么意思?如何找到并解决此类问题的根源?
【问题讨论】:
标签: c++ c debugging visual-c++
这些断言表明,应该释放的指针无效(或不再)有效(_CrtIsValidHeapPointer-assertion),或者堆在程序运行期间的某个时间点损坏(is_block_type_valid(header->_block_use)-断言在早期版本中又称为_Block_Type_Is_Valid (pHead->nBlockUse)-assertion)。
在从堆中获取内存时,函数malloc/free不直接与操作系统通信,而是与内存管理器通信,内存管理器通常由相应的C运行时提供。 VisualStudio/Windows SDK 为调试构建提供了一个特殊的堆内存管理器,它在运行时执行额外的健全性检查。
_CrtIsValidHeapPointer 只是一种启发式方法,但有足够多的无效指针情况,此函数可以报告问题。
1. _CrtIsValidHeapPointer-assertion 什么时候触发?
有一些最常见的场景:
A.指针未指向堆中的内存:
char *mem = "not on the heap!";
free(mem);
这里的文字没有存储在堆上,因此可以/不应该被释放。
B.指针的值不是malloc/calloc返回的原始地址:
unsigned char *mem = (unsigned char*)malloc(100);
mem++;
free(mem); // mem has wrong address!
由于mem 的值在递增后不再是 64 字节对齐,因此完整性检查可以很容易地看出它不能是堆指针!
一个稍微复杂但并不罕见的 C++ 示例(不匹配 new[] 和 delete):
struct A {
int a = 0;
~A() {// destructor is not trivial!
std::cout << a << "\n";
}
};
A *mem = new A[10];
delete mem;
当new A[n]被调用时,实际上sizeof(size_t)+n*sizeof(A)bytes内存是通过malloc分配的(当A类的析构函数不平凡时),数组的元素个数保存在数组的开头分配的内存和返回的指针mem 指向的不是malloc 返回的原始地址,而是地址+偏移量(sizeof(size_t))。然而,delete 对这个偏移量一无所知,并试图删除地址错误的指针(delete [] 会做正确的事情)。
C.双免:
unsigned char *mem = (unsigned char*)malloc(10);
free(mem);
free(mem); # the pointer is already freed
在 C++ 中一个非常常见的原因是 rule of three/five 没有被遵守,例如:
struct A {// bad: doesn't adhere to rule of three
int* ptr;
A(int i): ptr(new int(i)){}
~A() { delete ptr; }
};
{
A a(0);
A b = a; // a and b share pointer: a.ptr == b.ptr
} // here destructors of b and a called => problem
// at first b.ptr gets deleted
// deleting (already deleted) a.ptr leads now to UB/error.
D.来自另一个运行时/内存管理器的指针
Windows 程序能够同时使用多个运行时:每个使用的 dll 都可能有自己的运行时/内存管理器/堆,因为它是静态链接的或者因为它们具有不同的版本。因此,在一个 dll 中分配的内存在另一个 dll 中释放时可能会失败,该 dll 使用不同的堆(例如,参见 SO-question 或 SO-question)。
2。 is_block_type_valid(header->_block_use)-assertion 什么时候触发?
在上述情况 A. 和 B. 中,除了 is_block_type_valid(header->_block_use) 也会触发。在_CrtIsValidHeapPointer-assertion 之后,free-函数(更精确的free_dbg_nolock)在块头(debug-heap 使用的特殊数据结构,稍后会详细介绍)中查找信息并检查块类型有效。但是,由于指针完全是伪造的,所以内存中 nBlockUse 的预期位置是某个随机值。
但是,在某些情况下,is_block_type_valid(header->_block_use) 在没有先前的_CrtIsValidHeapPointer-assertion 的情况下触发。
A. _CrtIsValidHeapPointer 没有检测到无效指针
这是一个例子:
unsigned char *mem = (unsigned char*)malloc(100);
mem+=64;
free(mem);
因为调试堆用0xCD填充分配的内存,我们可以确定访问nBlockUse会产生错误的类型,从而导致上述断言。
B.堆损坏
大多数时候,当is_block_type_valid(header->_block_use) 在没有_CrtIsValidHeapPointer 的情况下触发时,这意味着堆由于一些超出范围的写入而损坏。
所以如果我们“精致”(并且不要覆盖“无人区”——稍后会详细介绍):
unsigned char *mem = (unsigned char*)malloc(100);
*(mem-17)=64; // thrashes _block_use.
free(mem);
只导致is_block_type_valid(header->_block_use)。
在上述所有情况下,都可以通过跟踪内存分配来找到根本问题,但更多地了解调试堆的结构会有很大帮助。
可以找到关于调试堆的概述,例如in documentation,或者所有实现的细节都可以在相应的 Windows Kit 中找到(例如C:\Program Files (x86)\Windows Kits\10\Source\10.0.16299.0\ucrt\heap\debug_heap.cpp)。
简而言之:当在调试堆上分配内存时,分配的内存比需要的多,因此可以将诸如“无人区”之类的附加结构和诸如_block_use之类的附加信息存储在“真实”的记忆。实际的内存布局是:
------------------------------------------------------------------------
| header of the block + no man's land | "real" memory | no man's land |
----------------------------------------------------------------------
| 32 bytes + 4bytes | ? bytes | 4 bytes |
------------------------------------------------------------------------
“无人区”中末尾和开头的每个字节都设置为一个特殊值(0xFD),因此一旦它被覆盖,我们就可以注册越界写访问(只要它们最多 4 个字节)。
例如在new[]-delete-mismatch的情况下,我们可以在指针之前分析内存,看看这是否是无人区(这里是代码,但通常在调试器中完成):
A *mem = new A[10];
...
// instead of
//delete mem;
// investigate memory:
unsigned char* ch = reinterpret_cast<unsigned char*>(mem);
for (int i = 0; i < 16; i++) {
std::cout << (int)(*(ch - i)) << " ";
}
我们得到:
0 0 0 0 0 0 0 0 10 253 253 253 253 0 0 52
即前 8 个字节用于元素的数量(10),然后我们看到“无人区”(0xFD=253)以及其他信息。很容易看出,出了什么问题 - 如果指针正确,则前 4 个值位于 253。
当调试堆释放内存时,它会用一个特殊的字节值覆盖它:0xDD,即221。还可以通过设置标志_CRTDBG_DELAY_FREE_MEM_DF 来限制对曾经使用和释放的内存的重用,因此内存不仅在free 调用之后直接被标记,而且在程序的整个运行期间都保持标记。因此,当我们再次尝试释放同一个指针时,调试堆可以看到,内存已经被释放一次并触发断言。
因此,通过分析指针周围的值,也很容易看出问题是双重释放的:
unsigned char *mem = (unsigned char*)malloc(10);
free(mem);
for (int i = 0; i < 16; i++) {
printf("%d ", (int)(*(mem - i)));
}
free(mem); //second free
打印
221 221 221 221 221 221 221 221 221 221 221 221 221 221 221 221
内存,即内存已被释放一次。
关于堆损坏的检测:
无人区的目的是检测超出范围的写入,但这仅适用于在任一方向关闭 4 个字节,例如:
unsigned char *mem = (unsigned char*)malloc(100);
*(mem-1)=64; // thrashes no-man's land
free(mem);
导致
HEAP CORRUPTION DETECTED: before Normal block (#13266) at 0x0000025C6CC21050.
CRT detected that the application wrote to memory before start of heap buffer.
查找堆损坏的一个好方法是使用_CrtSetDbgFlag(_CRTDBG_CHECK_ALWAYS_DF) 或ASSERT(_CrtCheckMemory());(请参阅此SO-post)。然而,这有点间接 - 这是使用 gflags 的更直接方式,正如 SO-post 中所解释的那样(gflags 需要大约 30 倍以上的内存并且速度大约慢 10 倍,这并不罕见)。
顺便说一句,_CrtMemBlockHeader 的定义随着时间的推移而改变,不再是online-help 中显示的定义,而是:
struct _CrtMemBlockHeader
{
_CrtMemBlockHeader* _block_header_next;
_CrtMemBlockHeader* _block_header_prev;
char const* _file_name;
int _line_number;
int _block_use;
size_t _data_size;
long _request_number;
unsigned char _gap[no_mans_land_size];
// Followed by:
// unsigned char _data[_data_size];
// unsigned char _another_gap[no_mans_land_size];
};
【讨论】: