|
|
|
|
# 35 | 发现和识别内存问题:内存调试实践
|
|
|
|
|
|
|
|
|
|
你好,我是吴咏炜。
|
|
|
|
|
|
|
|
|
|
作为内存相关话题的最后一讲,今天我们来聊一聊内存调试的问题。
|
|
|
|
|
|
|
|
|
|
## 场景
|
|
|
|
|
|
|
|
|
|
首先,目前已经存在一些工具,可以让你在自己不写任何代码的情况下,帮助你来进行内存调试。用好你所在平台的现有工具,已经可以帮你解决很多内存相关问题(在[第 21 讲](https://time.geekbang.org/column/article/187980)中我已经介绍了一些)。
|
|
|
|
|
|
|
|
|
|
不过,前面提到的工具,主要帮你解决的问题是内存泄漏,部分可以帮你解决内存踩踏问题。它们不能帮你解决内存相关的所有问题,比如:
|
|
|
|
|
|
|
|
|
|
* 内存检测工具可能需要使用自己的特殊内存分配器,因此不能和你的特殊内存分配器协作(不使用标准的 `malloc`/`free`)
|
|
|
|
|
* 某些内存调试工具对性能影响太大,无法在实际场景中测试
|
|
|
|
|
* 你需要检查程序各个模块的分别内存占用情况
|
|
|
|
|
* 你需要检查程序各个线程的分别内存占用情况
|
|
|
|
|
* ……
|
|
|
|
|
|
|
|
|
|
总的来说,现成的工具提供了一定的功能,如果它直接能满足你的需求,那当然最好。但如果你有超出它能力的需求,那自己写点代码来帮助调试,也不是一件非常困难的事情——尤其在我们了解了这么多关于内存的底层细节之后。
|
|
|
|
|
|
|
|
|
|
## 内存调试原理
|
|
|
|
|
|
|
|
|
|
内存调试的基本原理,就是在内存分配的时候记录跟分配相关的一些基本信息,然后,在后面的某个时间点,可以通过检查记录下来的信息来检查跟之前分配的匹配情况,如:
|
|
|
|
|
|
|
|
|
|
* 在(测试)程序退出时检查是否有未释放的内存,即有没有产生内存泄漏
|
|
|
|
|
* 在释放内存时检查内存是不是确实是之前分配的
|
|
|
|
|
* 根据记录的信息来对内存的使用进行分门别类的统计
|
|
|
|
|
|
|
|
|
|
根据不同的使用场景,我们需要在分配内存时记录不同的信息,比如:
|
|
|
|
|
|
|
|
|
|
* 需要检查有没有内存泄漏,我们可以只记录总的内存分配和释放次数
|
|
|
|
|
* 需要检查内存泄漏的数量和位置,我们需要在每个内存块里额外记录分配内存的大小,及调用内存分配的代码的位置(文件、行号之类);但这样记录下来的位置不一定是真正有问题的代码的位置
|
|
|
|
|
* 需要检查实际造成内存泄漏的代码的位置,我们最好能够记录内存分配时的完整调用栈(而非分配内存的调用发生的位置);注意这通常是一个平台相关的解决方案
|
|
|
|
|
* 作为一种简单、跨平台的替换方案,我们可以在内存分配时记录一个“上下文”,这样在有内存泄漏时可以缩小错误范围,知道在什么上下文里发生了问题
|
|
|
|
|
* 这个“上下文”里可以包含模块、线程之类的信息,随后我们就可以针对模块或线程来进行统计
|
|
|
|
|
* 我们可以在分配内存时往里安插一个特殊的标识,并在释放时检查并清除这个标识,用来识别是不是释放了不该释放的内存,或者发生了重复释放
|
|
|
|
|
|
|
|
|
|
根据你的实际场景和需要,可能性也是无穷无尽的。
|
|
|
|
|
|
|
|
|
|
下面,我们就来根据“上下文”的思想,来实现一个小小的内存调试工具。基于同样的思想,你也可以对它进行扩充,来满足你的特殊需求。
|
|
|
|
|
|
|
|
|
|
## “上下文”内存调试工具
|
|
|
|
|
|
|
|
|
|
### 预备知识
|
|
|
|
|
|
|
|
|
|
这个内存调试工具说难不难,但它用到了很多我们之前学过的知识。我在下面再列举一下,万一忘记了的话,建议你复习一下再往下看:
|
|
|
|
|
|
|
|
|
|
* RAII([第 1 讲](https://time.geekbang.org/column/article/169225)):我们使用 RAII 来自动产生和销毁一个上下文
|
|
|
|
|
* `deque` 和 `stack`([第 4 讲](https://time.geekbang.org/column/article/173167)):用来存放当前线程的所有上下文
|
|
|
|
|
* `operator new` 和 `operator delete` 的不同形态及其替换([第 31 讲](https://time.geekbang.org/column/article/489409)):我们需要使用布置版本,指定对齐,并截获内存分配和释放操作
|
|
|
|
|
* 分配器([第 32 讲](https://time.geekbang.org/column/article/491227)):我们最后会需要使用一个特殊的分配器来处理一个小细节
|
|
|
|
|
|
|
|
|
|
### 上下文
|
|
|
|
|
|
|
|
|
|
我已经说了好多遍上下文这个词。从代码的角度,它到底是什么呢?
|
|
|
|
|
|
|
|
|
|
它是什么,由你来决定。
|
|
|
|
|
|
|
|
|
|
从内存调试的角度,可能有用的上下文定义有:
|
|
|
|
|
|
|
|
|
|
* 文件名加行号
|
|
|
|
|
* 文件名加函数名
|
|
|
|
|
* 函数名加行号
|
|
|
|
|
* 等等
|
|
|
|
|
|
|
|
|
|
每个人可能有不同的偏好。我目前使用了文件名加函数名这种方式,把上下文定义成:
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
struct context {
|
|
|
|
|
const char* file;
|
|
|
|
|
const char* func;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
要生成上下文,可以利用标准宏或编译器提供的特殊宏。下面的写法适用于任何编译器:
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
context{__FILE__, __func__}
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
如果你使用 GCC 的话,你应该会想用 `__PRETTY_FUNCTION__` 来代替 `__func__` \[1\]。而如果你使用 MSVC 的话,`__FUNCSIG__` 可能会是个更好的选择 \[2\]。
|
|
|
|
|
|
|
|
|
|
### 上下文的产生和销毁
|
|
|
|
|
|
|
|
|
|
我们使用一个类栈的数据结构来存放所有的上下文,并使用后进先出的方式来加入或抛弃上下文。代码非常直白,如下所示:
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
thread_local stack<context>
|
|
|
|
|
context_stack;
|
|
|
|
|
|
|
|
|
|
const context default_ctx{
|
|
|
|
|
"<UNKNOWN>", "<UNKNOWN>"};
|
|
|
|
|
|
|
|
|
|
const context&
|
|
|
|
|
get_current_context()
|
|
|
|
|
{
|
|
|
|
|
if (context_stack.empty()) {
|
|
|
|
|
return default_ctx;
|
|
|
|
|
}
|
|
|
|
|
return context_stack.top();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void restore_context()
|
|
|
|
|
{
|
|
|
|
|
context_stack.pop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void save_context(
|
|
|
|
|
const context& ctx)
|
|
|
|
|
{
|
|
|
|
|
context_stack.push(ctx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
但如果要求你次次小心地手工调用 `restore_context` 和 `save_context` 的话,那也太麻烦、太容易出错了。这时,我们可以写一个小小的 RAII 类,来自动化对这两个函数的调用:
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
class checkpoint {
|
|
|
|
|
public:
|
|
|
|
|
explicit checkpoint(
|
|
|
|
|
const context& ctx)
|
|
|
|
|
{
|
|
|
|
|
save_context(ctx);
|
|
|
|
|
}
|
|
|
|
|
~checkpoint()
|
|
|
|
|
{
|
|
|
|
|
restore_context();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
再加上一个宏会更方便一点:
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
#define MEMORY_CHECKPOINT() \
|
|
|
|
|
checkpoint func_checkpoint{ \
|
|
|
|
|
context{__FILE__, __func__}}
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
然后,你就只需要在自己的函数里加上一行代码,就可以跟踪函数内部的内存使用了:
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
void SomeFunc()
|
|
|
|
|
{
|
|
|
|
|
MEMORY_CHECKPOINT();
|
|
|
|
|
// 函数里的其他操作
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 记录上下文
|
|
|
|
|
|
|
|
|
|
之前的代码只是让我们可以产生在某一特定场景下的上下文。我们要利用这些上下文进行内存调试,就需要把上下文记录下来。我们需要定义一堆额外的布置分配和释放函数。简单起见,我们在这些函数里,简单转发内存分配和释放请求到新的函数:
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
void* operator new(
|
|
|
|
|
size_t size, const context& ctx)
|
|
|
|
|
{
|
|
|
|
|
void* ptr = alloc_mem(size, ctx);
|
|
|
|
|
if (ptr) {
|
|
|
|
|
return ptr;
|
|
|
|
|
} else {
|
|
|
|
|
throw bad_alloc();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
void* operator new[](
|
|
|
|
|
size_t size, const context& ctx)
|
|
|
|
|
{
|
|
|
|
|
// 同上,略
|
|
|
|
|
}
|
|
|
|
|
void* operator new(
|
|
|
|
|
size_t size,
|
|
|
|
|
align_val_t align_val,
|
|
|
|
|
const context& ctx)
|
|
|
|
|
{
|
|
|
|
|
void* ptr = alloc_mem(
|
|
|
|
|
size, ctx, size_t(align_val));
|
|
|
|
|
if (ptr) {
|
|
|
|
|
return ptr;
|
|
|
|
|
} else {
|
|
|
|
|
throw bad_alloc();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
void* operator new[](
|
|
|
|
|
size_t size,
|
|
|
|
|
align_val_t align_val,
|
|
|
|
|
const context& ctx)
|
|
|
|
|
{
|
|
|
|
|
// 同上,略
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void operator delete(
|
|
|
|
|
void* ptr,
|
|
|
|
|
const context&) noexcept
|
|
|
|
|
{
|
|
|
|
|
free_mem(ptr);
|
|
|
|
|
}
|
|
|
|
|
void operator delete[](
|
|
|
|
|
void* ptr,
|
|
|
|
|
const context&) noexcept
|
|
|
|
|
{
|
|
|
|
|
free_mem(ptr);
|
|
|
|
|
}
|
|
|
|
|
void operator delete(
|
|
|
|
|
void* ptr, align_val_t align_val,
|
|
|
|
|
const context&) noexcept
|
|
|
|
|
{
|
|
|
|
|
free_mem(ptr, size_t(align_val));
|
|
|
|
|
}
|
|
|
|
|
void operator delete[](
|
|
|
|
|
void* ptr, align_val_t align_val,
|
|
|
|
|
const context&) noexcept
|
|
|
|
|
{
|
|
|
|
|
free_mem(ptr, size_t(align_val));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
标准的分配和释放函数也类似地只是转发而已,但我们这时就会需要用上前面产生的上下文。代码重复比较多,我就只列举最典型的两个函数了:
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
void* operator new(size_t size)
|
|
|
|
|
{
|
|
|
|
|
return operator new(
|
|
|
|
|
size, get_current_context());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void operator delete(
|
|
|
|
|
void* ptr) noexcept
|
|
|
|
|
{
|
|
|
|
|
free_mem(ptr);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
下面,我们需要把重点放到 `alloc_mem` 和 `free_mem` 两个函数上。我们先写出这两个函数的原型:
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
void* alloc_mem(
|
|
|
|
|
size_t size, const context& ctx,
|
|
|
|
|
size_t alignment =
|
|
|
|
|
__STDCPP_DEFAULT_NEW_ALIGNMENT__);
|
|
|
|
|
|
|
|
|
|
void free_mem(
|
|
|
|
|
void* usr_ptr,
|
|
|
|
|
size_t alignment =
|
|
|
|
|
__STDCPP_DEFAULT_NEW_ALIGNMENT__);
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
`alloc_mem` 接受大小、上下文和对齐值(默认为系统的默认对齐值 `__STDCPP_DEFAULT_NEW_ALIGNMENT__`),进行内存分配,并把上下文记录下来。显然,下面的问题就是:
|
|
|
|
|
|
|
|
|
|
* 我们需要记录多少额外信息?
|
|
|
|
|
* 我们需要把信息记录到哪里?
|
|
|
|
|
|
|
|
|
|
鉴于在释放内存时,通用的接口**只能**拿到一个指针,我们需要通过这个指针找到我们原先记录的信息,因此,我们得出结论,只能把额外的信息记录到分配给用户的内存前面。也就是说,我们需要在分配内存时多分配一点空间,在开头存上我们需要的额外信息,然后把额外信息后的内容返回给用户。这样,在用户释放内存的时候,我们才能简单地找到我们记录的额外信息。
|
|
|
|
|
|
|
|
|
|
我们需要额外存储的信息也不能只是上下文。关键地:
|
|
|
|
|
|
|
|
|
|
* 我们需要记录用户申请的内存大小,并用指针把所有的内存块串起来,以便在需要时报告内存泄漏的数量和大小。
|
|
|
|
|
* 我们需要记录额外信息的大小,因为在不同的对齐值下,额外信息的指针和返回给用户的指针之间的差值会不相同。
|
|
|
|
|
* 我们需要魔术数来辅助校验内存的有效性,帮助检测释放非法内存和重复释放。
|
|
|
|
|
|
|
|
|
|
因此,我们把额外信息的结构体定义如下:
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
struct alloc_list_base {
|
|
|
|
|
alloc_list_base* next;
|
|
|
|
|
alloc_list_base* prev;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
struct alloc_list_t
|
|
|
|
|
: alloc_list_base {
|
|
|
|
|
size_t size;
|
|
|
|
|
context ctx;
|
|
|
|
|
uint32_t head_size;
|
|
|
|
|
uint32_t magic;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
alloc_list_base alloc_list = {
|
|
|
|
|
&alloc_list, // head (next)
|
|
|
|
|
&alloc_list, // tail (prev)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
从 `alloc_list_t` 分出一个 `alloc_list_base` 子类的目的是方便我们统一对链表头尾的操作,不需要特殊处理。`alloc_list` 的 `next` 成员指向链表头,`prev` 成员指向链表尾;把 `alloc_list` 也算进去,整个链表构成一个环形。
|
|
|
|
|
|
|
|
|
|
`alloc_list_t` 内容有效时我们会在 `magic` 中填入一个特殊数值,这里我们随便取一个:
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
constexpr uint32_t CMT_MAGIC =
|
|
|
|
|
0x4D'58'54'43; // "CTXM";
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
在实现 `alloc_mem` 之前,我们先把对齐函数的实现写出来:
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
constexpr uint32_t
|
|
|
|
|
align(size_t alignment, size_t s)
|
|
|
|
|
{
|
|
|
|
|
return static_cast<uint32_t>(
|
|
|
|
|
(s + alignment - 1) &
|
|
|
|
|
~(alignment - 1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
我们这里把一个大小 `s` 调整为 `alignment` 的整数倍。这里 `alignment` 必须是 2 的整数次幂,否则这个算法不对。一种可能更容易理解、但也更低效的计算方法是 `(s + alignment - 1) / alignment * alignment`。
|
|
|
|
|
|
|
|
|
|
这样,我们终于可以写出 `alloc_mem` 的定义了:
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
size_t current_mem_alloc = 0;
|
|
|
|
|
|
|
|
|
|
void* alloc_mem(
|
|
|
|
|
size_t size, const context& ctx,
|
|
|
|
|
size_t alignment =
|
|
|
|
|
__STDCPP_DEFAULT_NEW_ALIGNMENT__)
|
|
|
|
|
{
|
|
|
|
|
uint32_t aligned_list_node_size =
|
|
|
|
|
align(alignment,
|
|
|
|
|
sizeof(alloc_list_t));
|
|
|
|
|
size_t s =
|
|
|
|
|
size + aligned_list_node_size;
|
|
|
|
|
auto ptr =
|
|
|
|
|
static_cast<alloc_list_t*>(
|
|
|
|
|
aligned_alloc(
|
|
|
|
|
alignment,
|
|
|
|
|
align(alignment, s)));
|
|
|
|
|
if (ptr == nullptr) {
|
|
|
|
|
return nullptr;
|
|
|
|
|
}
|
|
|
|
|
auto usr_ptr =
|
|
|
|
|
reinterpret_cast<byte*>(ptr) +
|
|
|
|
|
aligned_list_node_size;
|
|
|
|
|
ptr->ctx = ctx;
|
|
|
|
|
ptr->size = size;
|
|
|
|
|
ptr->head_size =
|
|
|
|
|
aligned_list_node_size;
|
|
|
|
|
ptr->magic = MAGIC;
|
|
|
|
|
ptr->prev = alloc_list.prev;
|
|
|
|
|
ptr->next = &alloc_list;
|
|
|
|
|
alloc_list.prev->next = ptr;
|
|
|
|
|
alloc_list.prev = ptr;
|
|
|
|
|
current_mem_alloc += size;
|
|
|
|
|
return usr_ptr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
简单解释一下:
|
|
|
|
|
|
|
|
|
|
* 我们用一个全局变量 `current_mem_alloc` 跟踪当前已经分配的内存总量。
|
|
|
|
|
* 把额外存储信息的大小和用户请求的内存大小都调为 `alignment` 的整数倍,并使用两者之和作为大小参数来调用 C++17 的 `aligned_alloc`,进行内存分配;注意 `aligned_alloc` 要求分配内存大小必须为对齐值的整数倍。
|
|
|
|
|
* 如果分配内存失败,我们只能返回空指针。
|
|
|
|
|
* 否则,我们在链表结点里填入内容,插入到链表尾部,增加 `current_mem_alloc` 值,并返回链表结点后的部分给用户。
|
|
|
|
|
|
|
|
|
|
### 释放检查
|
|
|
|
|
|
|
|
|
|
释放内存就是一个相反的操作,我们拿到一个用户提供的指针和对齐值,倒推出链表结点的地址,检查其中内容来验证地址的有效性,最后把链表结点从链表中摘除,并释放内存。
|
|
|
|
|
|
|
|
|
|
倒推链表结点地址的代码如下(简化版):
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
alloc_list_t*
|
|
|
|
|
convert_user_ptr(void* usr_ptr,
|
|
|
|
|
size_t alignment)
|
|
|
|
|
{
|
|
|
|
|
auto offset =
|
|
|
|
|
static_cast<byte*>(usr_ptr) -
|
|
|
|
|
static_cast<byte*>(nullptr);
|
|
|
|
|
auto byte_ptr =
|
|
|
|
|
static_cast<byte*>(usr_ptr);
|
|
|
|
|
|
|
|
|
|
if (offset % alignment != 0) {
|
|
|
|
|
return nullptr;
|
|
|
|
|
}
|
|
|
|
|
auto ptr =
|
|
|
|
|
reinterpret_cast<alloc_list_t*>(
|
|
|
|
|
byte_ptr -
|
|
|
|
|
align(alignment,
|
|
|
|
|
sizeof(alloc_list_t)));
|
|
|
|
|
if (ptr->magic != MAGIC) {
|
|
|
|
|
return nullptr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ptr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
我们首先把用户指针 `usr_ptr` 转换成整数值 `offset`,然后检查其是否对齐。不对齐的话,这个指针肯定是错误的,这个函数就直接返回空指针了。否则,我们就把用户指针减去链表结点的对齐大小,并转换为正确的类型。保险起见,我们还需要检查魔术数是否正确,不正确同样用空指针表示失败。魔术数正确的话,那我们才算得到了正确的结果。
|
|
|
|
|
|
|
|
|
|
检查逻辑基本就在上面了。`free_mem` 函数则相当简单:
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
void free_mem(
|
|
|
|
|
void* usr_ptr,
|
|
|
|
|
size_t alignment =
|
|
|
|
|
__STDCPP_DEFAULT_NEW_ALIGNMENT__)
|
|
|
|
|
{
|
|
|
|
|
if (usr_ptr == nullptr) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto ptr = convert_user_ptr(
|
|
|
|
|
usr_ptr, alignment);
|
|
|
|
|
if (ptr == nullptr) {
|
|
|
|
|
puts("Invalid pointer or "
|
|
|
|
|
"double-free");
|
|
|
|
|
abort();
|
|
|
|
|
}
|
|
|
|
|
current_mem_alloc -= ptr->size;
|
|
|
|
|
ptr->magic = 0;
|
|
|
|
|
ptr->prev->next = ptr->next;
|
|
|
|
|
ptr->next->prev = ptr->prev;
|
|
|
|
|
free(ptr);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
对于空指针,我们按 C++ 标准不需要做任何事情,立即返回即可。如果倒推链表结点地址失败的话,我们会输出一行错误信息,并终止程序执行——内存错误是一个很严重的问题,通常继续执行程序已经没有意义,还是早点失败上调试器检查比较好。一切成功的话,我们就在 `current_mem_alloc` 里减去释放内存的大小,清除魔术数(这样才能防止重复释放),从链表中摘除当前结点,最后释放内存。
|
|
|
|
|
|
|
|
|
|
### 退出检查
|
|
|
|
|
|
|
|
|
|
我们在链表结点里存储了上下文信息,这就让我们后续可以做很多调试工作了。一种典型的应用是在程序退出时进行内存泄漏检查。我们可以使用一个全局 RAII 对象来控制调用 `check_leaks`,这可以保证泄漏检查会发生在 `main` 全部执行完成之后(课后思考里有更复杂的方法):
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
class invoke_check_leak_t {
|
|
|
|
|
public:
|
|
|
|
|
~invoke_check_leak_t()
|
|
|
|
|
{
|
|
|
|
|
check_leaks();
|
|
|
|
|
}
|
|
|
|
|
} invoke_check_leak;
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
而 `check_leaks` 所需要做的事情,也就只是遍历内存块的链表而已:
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
int check_leaks()
|
|
|
|
|
{
|
|
|
|
|
int leak_cnt = 0;
|
|
|
|
|
auto ptr =
|
|
|
|
|
static_cast<alloc_list_t*>(
|
|
|
|
|
alloc_list.next);
|
|
|
|
|
|
|
|
|
|
while (ptr != &alloc_list) {
|
|
|
|
|
if (ptr->magic != MAGIC) {
|
|
|
|
|
printf("error: heap data "
|
|
|
|
|
"corrupt near %p\n",
|
|
|
|
|
&ptr->magic);
|
|
|
|
|
abort();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto usr_ptr =
|
|
|
|
|
reinterpret_cast<const byte*>(
|
|
|
|
|
ptr) +
|
|
|
|
|
ptr->head_size;
|
|
|
|
|
printf("Leaked object at %p "
|
|
|
|
|
"(size %zu, ",
|
|
|
|
|
usr_ptr, ptr->size);
|
|
|
|
|
print_context(ptr->ctx);
|
|
|
|
|
printf(")\n");
|
|
|
|
|
|
|
|
|
|
ptr =
|
|
|
|
|
static_cast<alloc_list_t*>(
|
|
|
|
|
ptr->next);
|
|
|
|
|
++leak_cnt;
|
|
|
|
|
}
|
|
|
|
|
if (leak_cnt) {
|
|
|
|
|
printf("*** %d leaks found\n",
|
|
|
|
|
leak_cnt);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return leak_cnt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
我们从链表头(`alloc_list.next`)开始遍历链表,对每个结点首先检查魔术数是否正确,然后根据 `head_size` 算出用户内存所在的位置,并和上下文一起打印出来,然后泄漏计数加一。待链表遍历完成之后(遍历指针重新指向 `alloc_list`),我们打印泄漏内存块的数量,并将其返回。
|
|
|
|
|
|
|
|
|
|
对于下面这样一个简单的 `main` 函数:
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
int main()
|
|
|
|
|
{
|
|
|
|
|
auto ptr1 = new char[10];
|
|
|
|
|
MEMORY_CHECKPOINT();
|
|
|
|
|
auto ptr2 = new char[20];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
这是一种可能的运行结果:
|
|
|
|
|
|
|
|
|
|
> `Leaked object at 0x57930e30 (size 10, context: <UNKNOWN>/<UNKNOWN>)`
|
|
|
|
|
> `Leaked object at 0x57930e70 (size 20, context: test.cpp/main)`
|
|
|
|
|
> `*** 2 leaks found`
|
|
|
|
|
|
|
|
|
|
### 一个小细节
|
|
|
|
|
|
|
|
|
|
有一个小细节我们这里讨论一下:`context_stack` 也需要使用内存,按目前的实现,它的内存占用会计入到“当前”上下文里去,这可能会是一个不必要、且半随机的干扰。为此,我们给它准备一个独立的分配器,使得它占用的内存不被上下文所记录。它的实现如下所示:
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
template <typename T>
|
|
|
|
|
struct malloc_allocator {
|
|
|
|
|
typedef T value_type;
|
|
|
|
|
typedef std::true_type
|
|
|
|
|
is_always_equal;
|
|
|
|
|
typedef std::true_type
|
|
|
|
|
propagate_on_container_move_assignment;
|
|
|
|
|
|
|
|
|
|
malloc_allocator() = default;
|
|
|
|
|
template <typename U>
|
|
|
|
|
malloc_allocator(
|
|
|
|
|
const malloc_allocator<U>&)
|
|
|
|
|
{}
|
|
|
|
|
|
|
|
|
|
template <typename U>
|
|
|
|
|
struct rebind {
|
|
|
|
|
typedef malloc_allocator<U>
|
|
|
|
|
other;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
T* allocate(size_t n)
|
|
|
|
|
{
|
|
|
|
|
return static_cast<T*>(
|
|
|
|
|
malloc(n * sizeof(T)));
|
|
|
|
|
}
|
|
|
|
|
void deallocate(T* p, size_t)
|
|
|
|
|
{
|
|
|
|
|
free(p);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
除了一些常规的固定代码,这个分配器的主要功能体现在它的 `allocate` 和 `deallocate` 的实现上,里面直接调用了 `malloc` 和 `free`,而不是 `operator new` 和 `operator delete`,也非常简单。
|
|
|
|
|
|
|
|
|
|
然后,我们只需要让我们的 `context_stack` 使用这个分配器即可。
|
|
|
|
|
|
|
|
|
|
```cpp
|
|
|
|
|
thread_local stack<
|
|
|
|
|
context,
|
|
|
|
|
deque<context,
|
|
|
|
|
malloc_allocator<context>>>
|
|
|
|
|
context_stack;
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
注意 `stack` 只是一个容器适配器,分配器参数需要写到它的底层容器 `deque` 里去才行。
|
|
|
|
|
|
|
|
|
|
## 内容小结
|
|
|
|
|
|
|
|
|
|
本讲我们综合运用迄今为止学到的多种知识,描述了一个使用栈式上下文对内存使用进行调试的工具。目前这个工具只是简单地记录上下文信息,并在检查内存泄漏时输出未释放的内存块的上下文信息。你可以根据自己的需要对其进行扩展,来满足特定的调试目的。
|
|
|
|
|
|
|
|
|
|
## 课后思考
|
|
|
|
|
|
|
|
|
|
Nvwa 项目提供了一个根据本讲思路实现的完整的内存调试器,请参考其中的 memory\_trace.\* 和 aligned\_memory.\* 文件 \[3\]。它比本讲的介绍更加复杂一些,对跨平台和实际使用场景考虑更多,如:
|
|
|
|
|
|
|
|
|
|
* 多线程加锁保护,并通过自定义的 `fast_mutex` 来规避 MSVC 中的 `std::mutex` 的重入问题
|
|
|
|
|
* 不直接调用标准的 `aligned_alloc` 和 `free` 来分配和释放内存,解决对齐分配的跨平台性问题
|
|
|
|
|
* 对 `new[]` 和 `delete` 的不匹配使用有较好的检测
|
|
|
|
|
* 使用 RAII 计数对象来尽可能延迟对 `check_leaks` 的调用
|
|
|
|
|
* 更多的错误检测和输出
|
|
|
|
|
* ……
|
|
|
|
|
|
|
|
|
|
请自行分析这一实际实现中增加的复杂性。如有任何问题,欢迎留言和我进行讨论。
|
|
|
|
|
|
|
|
|
|
## 参考资料
|
|
|
|
|
|
|
|
|
|
\[1\] GNU, GCC Manual, section “Function Names as Strings”. [https://gcc.gnu.org/onlinedocs/gcc/Function-Names.html](https://gcc.gnu.org/onlinedocs/gcc/Function-Names.html)
|
|
|
|
|
|
|
|
|
|
\[2\] Microsoft, “Predefined macros”. [https://docs.microsoft.com/en-us/cpp/preprocessor/predefined-macros?view=msvc-170](https://docs.microsoft.com/en-us/cpp/preprocessor/predefined-macros?view=msvc-170)
|
|
|
|
|
|
|
|
|
|
\[3\] 吴咏炜, nvwa. [https://github.com/adah1972/nvwa/](https://github.com/adah1972/nvwa/)
|
|
|
|
|
|