Linux 内存调试

作者:Petr Sorfa

所有程序都会使用内存,即使是那些什么都不做的程序。内存误用会导致很大一部分致命的程序错误,例如程序终止和意外行为。

内存是处理信息的设备。程序内存通常与计算机拥有的物理内存量相关联,但当不使用时,也可以驻留在辅助存储器上,例如磁盘驱动器。用户内存由两个设备管理:内核本身和实际程序,程序通过调用内存函数(如 malloc())来使用内存。

内核内存

操作系统内核管理特定程序或程序实例的所有内存需求(因为操作系统可以同时执行程序的多个实例)。当用户执行程序时,内核会为该程序分配一块内存区域。然后,该程序通过将其划分为多个区域来管理该内存区域

  • 文本—仅存储程序的只读部分。这通常是程序的实际指令代码。同一程序的多个实例可以共享此内存区域。

  • 静态数据—预先分配内存的区域。这通常用于全局变量和静态 C++ 类成员。操作系统为程序的每个实例分配此内存区域的副本。

  • 内存竞技场(也称为中断空间)—存储动态运行时内存的区域。内存竞技场由堆和未使用的内存组成。堆是所有用户分配内存的所在地。堆从较低的内存地址向上增长到较高的内存地址。

  • 栈—每当程序进行函数调用时,都需要将当前函数的状态保存到栈中。栈从较高的内存地址向下增长到较低的内存地址。程序的每个实例都存在唯一的内存竞技场和栈。

Debugging Memory on Linux

图 1. 与程序实例关联的内存

用户内存

用户可分配内存位于内存竞技场的堆中。内存竞技场由例程 malloc()、realloc()、free() 和 calloc() 管理。它们是 libc 的一部分。但是,可以替换这些函数为另一种实现,这种实现可能会为特定用途提供更好的性能。有关备用内存函数列表,请参见侧边栏。

备用内存函数

在 Linux 系统上,程序以预先计算的增量(通常为一个内存页大小或与边界对齐)扩展内存竞技场的大小。一旦堆需要的内存超过内存竞技场中可用的内存,内存例程将调用 brk() 系统调用,该调用从内核请求额外的内存。实际增量大小可以通过 sbrk() 调用设置。

要查看任何进程的当前栈和内存竞技场,请查看特定进程的 /proc/<pid>/maps 的内容,其中 pid 是进程 ID(参见列表 1)。

列表 1. 来自 /proc/<pid>/maps 的输出

结构

每次使用 malloc() 分配新内存时,获得的内存都会比请求的多一点。内存例程使用这些额外的内存进行维护。要获得为用户操作分配的实际内存量,请使用函数调用 malloc_usable_space()。实际内存块通常大八个字节。

内存块的结构在块的开头和结尾都预先添加了块的大小(参见图 2)。大小值还具有一个位标志,指示内存管理系统是否维护紧接在当前内存块之前的内存块。

Debugging Memory on Linux

图 2. 内存块结构

GNU libc 中的内存例程使用 bin 来存储大小相似的内存块,以帮助提高性能并防止内存碎片区域,即在整个内存竞技场中存在未使用的内存间隙。这些内存例程也是线程安全的。尽管这些例程快速且稳定,但可能存在可以改进的领域,例如速度和内存覆盖率。

调试

内存可能会导致错误,并且通常会导致不必要的内存行为。一种方式是使用已释放的内存,即使用程序已释放的内存块。尽管这不一定会立即导致问题,但一旦新的内存分配接管同一内存区域,就会出现问题。结果,同一内存区域用于两个不同的目的,这会导致意外的值,如果内存区域包含指针值或偏移量,则可能导致程序核心转储。

另一个问题是覆盖内存块的前导码。如果程序覆盖了内存块的前导码,则内存管理系统在遇到损坏的内存块时可能会失败或表现异常。

有时,覆盖会发生在相邻的内存块上,这可能会损坏数据。用户可能稍后在程序执行期间才发现这种类型的错误,表现为奇怪的值和程序行为。

同样,如果已释放内存块的管理信息因覆盖或不当使用而损坏,则内存管理系统很可能导致错误。

使用内存竞技场中未分配的空间也可能产生影响。可能可以使用堆外部但在内存竞技场内的内存。这通常不会导致错误,直到新分配的内存使用其中一些空间。此错误可能非常难以检测,因为后续的内存操作可能仍保留在堆空间内。

最明显和最直接的错误是当程序尝试使用内存竞技场和程序内存范围之外的内存时。这会导致 SIGSEGV(段违例故障),程序将自动转储核心。

最具破坏性和最难调试的内存错误是当程序的栈被损坏时。程序将来自先前帧的局部变量、参数和寄存器以及最重要的返回地址存储在栈中。因此,如果栈被损坏,程序可能变得无法使用传统的调试器进行调试,因为栈帧本身已变得无用。调试栈内存问题仅限于少数开源(例如,libsafe)和专有内存调试器,因为需要更改或增强程序执行以检测栈内存违例。

有几种方法可以尝试捕获和查找内存误用。不幸的是,有些方法具有副作用,例如降低程序执行速度和增加内存使用量,因此,它们可能无法在内存密集型程序中使用。

以下内存调试器使用的错误程序示例可以在列表 2、3 和 4 中看到。

列表 2. mytest00.c 示例程序

列表 3. mytest01.c 示例程序

列表 4. mytest02.c 示例程序

默认情况下,有一个环境变量 MALLOC_CHECK_,可以设置它以使用默认的 malloc 启用基本的调试。MALLOC_CHECK_ 可以设置为 1,以便提供一些错误报告,或者设置为 2,以便在发生任何 malloc 错误时中止程序。输出可能很隐晦,因为调试模式将问题区域报告为地址而不是可读符号。因此,最好手头有一个调试器来确定程序中发生这些错误的位置。以下是使用默认内存调试的示例

<home>$ MALLOC_CHECK_=1 ./mytest00
malloc: using debugging hooks
hello Linux users
free(): invalid pointer 0x80496d0
hello again
free(): invalid pointer 0x80496d0
realloc(): invalid pointer 0x80496d0
malloc: top chunk is corrupt
hello there

输出指示 mytest00.c 第 8 行(列表 2)中的问题,其中 strcpy() 函数溢出并损坏了 msg 指向的内存块。随后的调试消息是由于此损坏造成的。

有几个优秀的开源内存工具可用(有关列表,请参见侧边栏)。每种实现在内存错误覆盖范围、输出和交互方面都不同。

开源内存工具

Electric Fence 是一种易于使用的工具。该库执行多项内存检查,并在遇到错误时停止程序。这通常会导致核心转储,然后用户可以使用调试器进行调查。Electric Fence 在调试器(例如 GNU 调试器 (GDB))中使用时最有用。当 Electric Fence 停止程序时,GDB 会在程序中发生错误的确切位置重新获得控制权(参见列表 5)。

列表 5. 在 GDB 中使用 Electric Fence 进行内存调试

此示例输出显示了使用 Electric Fence 库构建并在 GDB 下执行的测试。mytest00.c 第 8 行的第一个违例导致 SIGSEGV。当检查 GDB 提供的堆栈跟踪时,用户可以识别问题位置。

libsafe 用于检查许多可能的栈帧边界违例,这些违例仅限于少数 C 函数(strcpy、strcat、getwd、gets、scanf、vscanf、fscanf、realpath、sprintf 和 vsprintf)。

libsafe 示例输出简洁。一旦发生栈错误,libsafe 就会显示错误并终止程序。但是,libsafe 会将实际错误的详细信息发送给各个电子邮件收件人。诚然,这是一种迂回的错误报告方式,但用户主要使用 libsafe 来检测利用缓冲区溢出进行的未遂安全漏洞。通过稍微编辑一下,开发人员可以增强 libsafe 代码以报告更具信息性的消息。另一种选择是在 GDB 中执行程序,并在 _libsafe_die() 上设置断点,一旦 libsafe 检测到栈违例,就会命中该断点。在以下示例中,libsafe 检测到由 mytest01.c 第 8 行(列表 3)中的 strcpy() 引起的栈覆盖

<home>$ LD_PRELOAD=/lib/libsafe.so.1.3  ./mytest01
Detected an attempt to write across stack boundary.
Terminating mytest01.
Null message body; hope that's ok
# Email is the sent with the following subject header
libsafe violation for /tmp/mytest01, pid=27265;
overflow caused by strcpy()

debauch 将其输出限制为包含地址而不是符号,这使得它必须与调试器一起使用。debauch 具有用户可以专门为 GDB 使用而激活的特殊功能。这些功能允许更好地跟踪内存分配和释放调用。debauch 非常彻底,可以检测并从许多内存错误中恢复(参见列表 6)。

列表 6. 使用 debauch 进行内存调试

memprof 的主要功能是 GUI 界面,这使得它易于理解并查看内存泄漏发生的位置。由于它利用了 GDB 使用的功能通过二进制文件描述符 (BFD) 库控制进程,因此它具有相当强大的功能。图 3 显示 memprof 已检测到 mytest02.c 中 alloc_two() 函数中的泄漏。

Debugging Memory on Linux

图 3. 使用 memprof 进行内存调试

除了开源内存工具外,还有几种专有工具可用,它们提供图形用户界面和比开源版本更彻底的检查(有关专有内存工具的列表,请参见侧边栏)。

专有内存工具

最后一种可能的选择是编写您自己的内存处理函数。这可能有助于熟悉内存管理或根据您的特定需求(例如,快速分配和释放大型内存区域)提供性能增强。

调试内存问题不仅对于程序稳定性很重要,而且对于安全性也很重要。有几种可用于 Linux 的内存调试器,每种调试器都有其自己特定的一组功能和使用标准。最好的方法是使用多个内存调试器以及调试器(如 GDB)来测试程序,因为组合的力量可能会检测到更广泛的内存问题。

Debugging Memory on Linux
Petr Sorfa (petrs@sco.com) 是 Santa Cruz Operation 开发系统组的成员,在那里他是 cscope 和 Sar3D 开源项目的维护者。他拥有开普敦大学的理学学士学位和罗德斯大学的理学荣誉学士学位。他的兴趣包括开源项目、计算机图形、开发系统和连环画(漫画)。
加载 Disqus 评论