# 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_stack; const context default_ctx{ "", ""}; 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( (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( aligned_alloc( alignment, align(alignment, s))); if (ptr == nullptr) { return nullptr; } auto usr_ptr = reinterpret_cast(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(usr_ptr) - static_cast(nullptr); auto byte_ptr = static_cast(usr_ptr); if (offset % alignment != 0) { return nullptr; } auto ptr = reinterpret_cast( 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.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( ptr) + ptr->head_size; printf("Leaked object at %p " "(size %zu, ", usr_ptr, ptr->size); print_context(ptr->ctx); printf(")\n"); ptr = static_cast( 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: /)` > `Leaked object at 0x57930e70 (size 20, context: test.cpp/main)` > `*** 2 leaks found` ### 一个小细节 有一个小细节我们这里讨论一下:`context_stack` 也需要使用内存,按目前的实现,它的内存占用会计入到“当前”上下文里去,这可能会是一个不必要、且半随机的干扰。为此,我们给它准备一个独立的分配器,使得它占用的内存不被上下文所记录。它的实现如下所示: ```cpp template 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 malloc_allocator( const malloc_allocator&) {} template struct rebind { typedef malloc_allocator other; }; T* allocate(size_t n) { return static_cast( 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_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/)