极客时间已完结课程限时免费阅读

35 | 发现和识别内存问题:内存调试实践

35 | 发现和识别内存问题:内存调试实践-极客时间

35 | 发现和识别内存问题:内存调试实践

讲述:吴咏炜

时长16:25大小15.00M

你好,我是吴咏炜。
作为内存相关话题的最后一讲,今天我们来聊一聊内存调试的问题。

场景

首先,目前已经存在一些工具,可以让你在自己不写任何代码的情况下,帮助你来进行内存调试。用好你所在平台的现有工具,已经可以帮你解决很多内存相关问题(在第 21 讲中我已经介绍了一些)。
不过,前面提到的工具,主要帮你解决的问题是内存泄漏,部分可以帮你解决内存踩踏问题。它们不能帮你解决内存相关的所有问题,比如:
内存检测工具可能需要使用自己的特殊内存分配器,因此不能和你的特殊内存分配器协作(不使用标准的 malloc/free
某些内存调试工具对性能影响太大,无法在实际场景中测试
你需要检查程序各个模块的分别内存占用情况
你需要检查程序各个线程的分别内存占用情况
……
总的来说,现成的工具提供了一定的功能,如果它直接能满足你的需求,那当然最好。但如果你有超出它能力的需求,那自己写点代码来帮助调试,也不是一件非常困难的事情——尤其在我们了解了这么多关于内存的底层细节之后。

内存调试原理

内存调试的基本原理,就是在内存分配的时候记录跟分配相关的一些基本信息,然后,在后面的某个时间点,可以通过检查记录下来的信息来检查跟之前分配的匹配情况,如:
在(测试)程序退出时检查是否有未释放的内存,即有没有产生内存泄漏
在释放内存时检查内存是不是确实是之前分配的
根据记录的信息来对内存的使用进行分门别类的统计
根据不同的使用场景,我们需要在分配内存时记录不同的信息,比如:
需要检查有没有内存泄漏,我们可以只记录总的内存分配和释放次数
需要检查内存泄漏的数量和位置,我们需要在每个内存块里额外记录分配内存的大小,及调用内存分配的代码的位置(文件、行号之类);但这样记录下来的位置不一定是真正有问题的代码的位置
需要检查实际造成内存泄漏的代码的位置,我们最好能够记录内存分配时的完整调用栈(而非分配内存的调用发生的位置);注意这通常是一个平台相关的解决方案
作为一种简单、跨平台的替换方案,我们可以在内存分配时记录一个“上下文”,这样在有内存泄漏时可以缩小错误范围,知道在什么上下文里发生了问题
这个“上下文”里可以包含模块、线程之类的信息,随后我们就可以针对模块或线程来进行统计
我们可以在分配内存时往里安插一个特殊的标识,并在释放时检查并清除这个标识,用来识别是不是释放了不该释放的内存,或者发生了重复释放
根据你的实际场景和需要,可能性也是无穷无尽的。
下面,我们就来根据“上下文”的思想,来实现一个小小的内存调试工具。基于同样的思想,你也可以对它进行扩充,来满足你的特殊需求。

“上下文”内存调试工具

预备知识

这个内存调试工具说难不难,但它用到了很多我们之前学过的知识。我在下面再列举一下,万一忘记了的话,建议你复习一下再往下看:
RAII(第 1 讲):我们使用 RAII 来自动产生和销毁一个上下文
dequestack第 4 讲):用来存放当前线程的所有上下文
operator newoperator delete 的不同形态及其替换(第 31 讲):我们需要使用布置版本,指定对齐,并截获内存分配和释放操作
分配器(第 32 讲):我们最后会需要使用一个特殊的分配器来处理一个小细节

上下文

我已经说了好多遍上下文这个词。从代码的角度,它到底是什么呢?
它是什么,由你来决定。
从内存调试的角度,可能有用的上下文定义有:
文件名加行号
文件名加函数名
函数名加行号
等等
每个人可能有不同的偏好。我目前使用了文件名加函数名这种方式,把上下文定义成:
struct context {
const char* file;
const char* func;
};
要生成上下文,可以利用标准宏或编译器提供的特殊宏。下面的写法适用于任何编译器:
context{__FILE__, __func__}
如果你使用 GCC 的话,你应该会想用 __PRETTY_FUNCTION__ 来代替 __func__ [1]。而如果你使用 MSVC 的话,__FUNCSIG__ 可能会是个更好的选择 [2]

上下文的产生和销毁

我们使用一个类栈的数据结构来存放所有的上下文,并使用后进先出的方式来加入或抛弃上下文。代码非常直白,如下所示:
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_contextsave_context 的话,那也太麻烦、太容易出错了。这时,我们可以写一个小小的 RAII 类,来自动化对这两个函数的调用:
class checkpoint {
public:
explicit checkpoint(
const context& ctx)
{
save_context(ctx);
}
~checkpoint()
{
restore_context();
}
};
再加上一个宏会更方便一点:
#define MEMORY_CHECKPOINT() \
checkpoint func_checkpoint{ \
context{__FILE__, __func__}}
然后,你就只需要在自己的函数里加上一行代码,就可以跟踪函数内部的内存使用了:
void SomeFunc()
{
MEMORY_CHECKPOINT();
// 函数里的其他操作
}

记录上下文

之前的代码只是让我们可以产生在某一特定场景下的上下文。我们要利用这些上下文进行内存调试,就需要把上下文记录下来。我们需要定义一堆额外的布置分配和释放函数。简单起见,我们在这些函数里,简单转发内存分配和释放请求到新的函数:
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));
}
标准的分配和释放函数也类似地只是转发而已,但我们这时就会需要用上前面产生的上下文。代码重复比较多,我就只列举最典型的两个函数了:
void* operator new(size_t size)
{
return operator new(
size, get_current_context());
}
void operator delete(
void* ptr) noexcept
{
free_mem(ptr);
}
下面,我们需要把重点放到 alloc_memfree_mem 两个函数上。我们先写出这两个函数的原型:
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__),进行内存分配,并把上下文记录下来。显然,下面的问题就是:
我们需要记录多少额外信息?
我们需要把信息记录到哪里?
鉴于在释放内存时,通用的接口只能拿到一个指针,我们需要通过这个指针找到我们原先记录的信息,因此,我们得出结论,只能把额外的信息记录到分配给用户的内存前面。也就是说,我们需要在分配内存时多分配一点空间,在开头存上我们需要的额外信息,然后把额外信息后的内容返回给用户。这样,在用户释放内存的时候,我们才能简单地找到我们记录的额外信息。
我们需要额外存储的信息也不能只是上下文。关键地:
我们需要记录用户申请的内存大小,并用指针把所有的内存块串起来,以便在需要时报告内存泄漏的数量和大小。
我们需要记录额外信息的大小,因为在不同的对齐值下,额外信息的指针和返回给用户的指针之间的差值会不相同。
我们需要魔术数来辅助校验内存的有效性,帮助检测释放非法内存和重复释放。
因此,我们把额外信息的结构体定义如下:
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_listnext 成员指向链表头,prev 成员指向链表尾;把 alloc_list 也算进去,整个链表构成一个环形。
alloc_list_t 内容有效时我们会在 magic 中填入一个特殊数值,这里我们随便取一个:
constexpr uint32_t CMT_MAGIC =
0x4D'58'54'43; // "CTXM";
在实现 alloc_mem 之前,我们先把对齐函数的实现写出来:
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 的定义了:
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 值,并返回链表结点后的部分给用户。

释放检查

释放内存就是一个相反的操作,我们拿到一个用户提供的指针和对齐值,倒推出链表结点的地址,检查其中内容来验证地址的有效性,最后把链表结点从链表中摘除,并释放内存。
倒推链表结点地址的代码如下(简化版):
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 函数则相当简单:
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 全部执行完成之后(课后思考里有更复杂的方法):
class invoke_check_leak_t {
public:
~invoke_check_leak_t()
{
check_leaks();
}
} invoke_check_leak;
check_leaks 所需要做的事情,也就只是遍历内存块的链表而已:
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 函数:
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 也需要使用内存,按目前的实现,它的内存占用会计入到“当前”上下文里去,这可能会是一个不必要、且半随机的干扰。为此,我们给它准备一个独立的分配器,使得它占用的内存不被上下文所记录。它的实现如下所示:
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);
}
};
除了一些常规的固定代码,这个分配器的主要功能体现在它的 allocatedeallocate 的实现上,里面直接调用了 mallocfree,而不是 operator newoperator delete,也非常简单。
然后,我们只需要让我们的 context_stack 使用这个分配器即可。
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_allocfree 来分配和释放内存,解决对齐分配的跨平台性问题
new[]delete 的不匹配使用有较好的检测
使用 RAII 计数对象来尽可能延迟对 check_leaks 的调用
更多的错误检测和输出
……
请自行分析这一实际实现中增加的复杂性。如有任何问题,欢迎留言和我进行讨论。

参考资料

[1] GNU, GCC Manual, section “Function Names as Strings”. https://gcc.gnu.org/onlinedocs/gcc/Function-Names.html
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 2

提建议

上一篇
34|快速分配和释放内存:内存池
下一篇
36|访问对象的代理对象:视图类型
 写留言

精选留言(3)

  • H X
    2022-05-25
    请问 1 .valgrind的内存检测工具 memcheck 和massif 这两个,能否根据我自己的项目 对其源码进行修改。最小影响的 检测我的项目中内存泄漏问题。2、cpp中没有jvm的GC。我用valgrind memcheck查出很多 still reachable。我理解这是内存碎片(vector和string频繁增删数据导致的)针对still reachable 有好的办法解决吗?多谢老师

    作者回复: Valgrind 使用 GPL v2 发布,显然你可以自己去修改它的源代码。 still unreachable 我怀疑是真正的泄漏而不是内存碎片。你可以换用其他的内存检测工具试一下。C++ 理论上现在应该不容易产生内存泄漏了,如果你不用有所有权的裸指针的话——确实很少再有理由用了。

    1
  • awmthink
    2023-02-15 来自广东
    吴老师,请问:memory_trace里 “使用 RAII 计数对象来尽可能延迟对 check_leaks 的调用” 怎么理解呢?我看代码时,是把check_leaks调用放在了一个static对象的析构里,和全局对象invoke_check_leak相比,行为有什么不同吗?(防止有其他编译模块定义的全局对象生命周期比invoke_check_leak更长吗?)

    作者回复: 不同翻译单元里的全局/静态对象的析构顺序没有保证。文中的技巧可以保证析构发生在所有包含了这个头文件的源文件里的全局/静态对象的析构之后。

  • ericaaa
    2022-05-27
    老师您好,我对free_mem最后一行free(ptr)有一些不大理解。这里的ptr类型是alloc_list_t*,这样是否只释放了head所占的memory而没有释放user object所占的memory呢?

    作者回复: free 的参数类型是 void*,非强类型。它不管你内存是怎么使用的:只要你给 free 的是 malloc 得到的指针并之前没有释放过,操作就应该成功——把整个 malloc 分配的内存块释放掉。