You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

20 KiB

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

你好,我是吴咏炜。

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

场景

首先,目前已经存在一些工具,可以让你在自己不写任何代码的情况下,帮助你来进行内存调试。用好你所在平台的现有工具,已经可以帮你解决很多内存相关问题(在第 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
2
3