Skip to content

Latest commit

 

History

History
1015 lines (745 loc) · 44.2 KB

File metadata and controls

1015 lines (745 loc) · 44.2 KB

五、Linux 内存问题

一个简单的真理是:记忆问题是存在的。 我们用 C(和 C++)等语言编程的事实本身就隐含地引起了无限类型的问题! 在某种程度上,人们意识到(可能有点悲观),使用托管内存安全语言谨慎编程最终是(唯一的?)。 完全避免内存问题的现实方法。

然而,我们现在正在使用我们选择的强大工具:卓越而古老的 C 编程语言! 因此,我们可以做些什么来缓解(如果不是消除)常见的内存问题,这是本章的主题。 归根结底,我们的目标是实现真正的内存安全;嗯,说起来容易做起来难!

然而,我们将尝试让开发人员通过阐明他们可能面临的常见内存问题来成功地完成这项任务。 在接下来的一章中,我们将研究一些功能强大的内存调试工具如何在这方面提供极大的帮助。

在本章中,开发人员将了解到,尽管动态内存管理 API(在第 4 章动态内存分配中介绍)很少,但当使用不当时,它们可能会造成似乎无穷无尽的麻烦和错误!

具体地说,本章将重点介绍导致现场软件中难以检测的错误的常见内存问题:

  • 不正确的内存访问问题(其中有几种类型)
  • 内存泄漏
  • 未定义的行为

常见内存问题

如果要将其归类为细粒度内存错误(通常是通过 C 或 C++编程引起的),就会遇到困难--存在数百种类型! 相反,让我们保持讨论的可控性,看看我们可怜的 C 程序员会遇到什么典型或常见的内存错误:

  • 错误的内存访问
    • 使用未初始化的变量
    • 内存访问越界(读/写下溢/溢出错误)
    • 释放后使用/返回后使用(超出范围)错误
    • 双重免费
  • 渗漏 / 泄密 / 泄漏
  • 未定义的行为(UB)
  • 数据竞赛数据竞赛
  • 碎片化问题(内部实施)
    • 内部的 / 内政的 / 里面的 / 体内的
    • 外面的 / 外部的 / 外国的

All these common memory issues (except fragmentation) are classified as UB; still, we keep UB as a separate entry as we will explore it more deeply. Also, though the word bug is colloquially used, one should really (and more correctly) think of it as defect.

We do not cover Data Races in this chapter (please hang on until Chapter 15, Multithreading with Pthreads Part II - Synchronization).

为了帮助测试这些内存问题,membugs程序是针对每个内存问题的小型测试用例的集合。

边栏::Clang 编译器

LLVM/Clang 是一个针对 C 的开源编译器。我们确实使用了 Clang 编译器,特别是在本章和下一章中,特别是针对杀菌器和编译器插装工具集(将在下一章中介绍)。 在整本书中它仍然很有用(事实上我们的许多 Makefile 中都使用了它),因此在您的 Linux 开发系统上安装 Clang 将是一个好主意! 同样,这并不是完全必要的,你也可以坚持使用熟悉的 GCC-只要你愿意编辑 Makefile,在需要的地方切换回 GCC!

在 Ubuntu 18.04 LTS 桌面上安装 Clang 很容易:sudo apt install clang

CLANG 文档可在https://clang.llvm.org/docs/index.html上找到。

When the membugs program is compiled (using both GCC for the normal case as well as the Clang compiler for the sanitizer variants), you will see a lot of compiler warnings being emitted! This is expected; after all, its code is filled with bugs. Relax, and continue reading.

Also, we remind you that the purpose of this chapter is to understand (and classify) typical Linux memory issues; identifying and fixing them using powerful tools is the subject matter of the next chapter. Both are required, so please read on.

构建的一些示例输出如下所示(为了可读性,输出被裁剪)。 现在,我们不会尝试分析它;这将在我们浏览本章时发生*(记住,您还需要安装 Clang!):*

$ make
gcc -Wall -c ../common.c -o common.o
gcc -Wall -c membugs.c -o membugs.o
membugs.c: In function ‘uar’:
membugs.c:143:9: warning: function returns address of local variable [-Wreturn-local-addr]
 return name;
 ^~~~
 [...]

gcc -Wall -o membugs membugs.o common.o

[...]
clang -g -ggdb -gdwarf-4 -O0 -Wall -Wextra -fsanitize=address -c membugs.c -o membugs_dbg_asan.o
membugs.c:143:9: warning: address of stack memory associated with local variable 'name' returned [-Wreturn-stack-address]
 return name;
 ^~~~

gcc -g -ggdb -gdwarf-4 -O0 -Wall -Wextra -o membugs_dbg membugs_dbg.o common_dbg.o
[...]
$ 

我们还强调了这样一个事实,在我们将要运行的所有测试用例中,我们使用了 GCC 生成的Membugs和二进制可执行文件(而不是 Clang;我们稍后将使用 Clang 和杀毒工具)。

During the build, one can capture all the output in to a file like so: make >build.txt 2>&1

使用--help开关运行membugs测试程序,查看所有可用的测试用例:

$ ./membugs --help

Usage: ./membugs test_case [ -h | --help]
 test case  1 : uninitialized var test case
 test case  2 : out-of-bounds : write overflow [on compile-time memory]
 test case  3 : out-of-bounds : write overflow [on dynamic memory]
 test case  4 : out-of-bounds : write underflow
 test case  5 : out-of-bounds : read overflow [on compile-time memory]
 test case  6 : out-of-bounds : read overflow [on dynamic memory]
 test case  7 : out-of-bounds : read underflow
 test case  8 : UAF (use-after-free) test case
 test case  9 : UAR (use-after-return) test case
 test case 10 : double-free test case
 test case 11 : memory leak test case 1: simple leak
 test case 12 : memory leak test case 2: leak more (in a loop)
 test case 13 : memory leak test case 3: "lib" API leak
-h | --help : show this help screen
$ 

您将注意到,写溢出和读溢出各有两个测试用例:一个在编译时内存上,另一个在动态分配的内存上。 区分不同的情况很重要,因为不同的工具可以检测到哪些类型的缺陷。

错误的内存访问

通常,这个类中的 bug 和问题是如此普遍,以至于被轻而易举地忽视了! 当心,它们仍然非常危险;注意找到、理解和修复它们。

All classes of overflow and underflow bugs on memory buffers are carefully documented and tracked via the Common Vulnerabilities and Exposures (CVE) and the Common Weakness Enumeration (CWE) websites. Relevant to what we are discussing, CWE-119 is the Improper Restriction of Operations within the Bounds of a Memory Buffer (https://cwe.mitre.org/data/definitions/119.html).

访问和/或使用未初始化的变量

为了让读者了解这些内存问题的严重性,我们编写了一个测试程序:membugs.c。 该测试程序允许用户测试各种常见的内存错误,这将帮助他们更好地了解潜在问题。

每个内存错误测试用例都有一个测试用例编号。 为了方便读者理解源代码和解释材料,我们还指定了测试用例,如下所示。

测试用例 1:未初始化的内存访问

这些错误也称为未初始化内存读取(UMR)错误。 经典情况:根据定义,局部(或自动)变量是未初始化的(与全局变量不同,全局变量总是预设为零*)*:

/* test case 1 : uninitialized var test case */
static void uninit_var()
{
   int x; /* static mem */

    if (x)
        printf("true case: x=%d\n", x);
    else
        printf("false case\n");
}

在前面的代码中,未定义在运行时会发生什么,因为x未初始化,因此将具有随机内容。 现在,我们按如下方式运行此测试用例:

$ ./membugs 1
true case: x=32604
$ ./membugs 1
true case: x=32611
$ ./membugs 1
true case: x=32627
$ ./membugs 1
true case: x=32709
$ 

值得庆幸的是,现代版本的编译器(gccclang)将发出有关此问题的警告:

$ make 
[...]
gcc -Wall -c membugs.c -o membugs.o
[...]
membugs.c: In function ‘uninit_var’:
membugs.c:272:5: warning: ‘x’ is used uninitialized in this function [-Wuninitialized]
 if (x) 
 ^ 

[...]
clang -g -ggdb -gdwarf-4 -O0 -Wall -Wextra -fsanitize=address -c membugs.c -o membugs_dbg_asan.o
[...]
membugs.c:272:6: warning: variable 'x' is uninitialized when used here [-Wuninitialized]
 if (x)
 ^
membugs.c:270:7: note: initialize the variable 'x' to silence this warning
 int x; /* static mem */
 ^
 = 0
[...]

越界内存访问

这个类也是比较常见但致命的内存访问错误之一。 它们可以被归类为不同种类的错误:

  • 写入溢出:在内存缓冲区最后一个合法访问位置之后尝试写入内存缓冲区的错误
  • 写入下溢:在第一个可合法访问的位置之前尝试写入内存缓冲区
  • 读取下溢:在内存缓冲区的第一个合法访问位置之前尝试读取
  • 读取溢出:在内存缓冲区的第一个合法可访问位置之后尝试读取

让我们通过我们的membugs.c程序的源代码来查看这些内容。

测试案例 2

编译时分配的内存上的写入或缓冲区溢出。 请按如下方式查看代码片段:

/* test case 2 : out-of-bounds : write overflow [on compile-time memory] */
static void write_overflow_compilemem(void)
{
    int i, arr[5], tmp[8];
    for (i=0; i<=5; i++) {
       arr[i] = 100;  /* Bug: 'arr' overflows on i==5,
                         overwriting part of the 'tmp' variable
                         - a stack overflow! */
    }
}

这导致了堆栈溢出(也称为堆栈崩溃或缓冲区溢出(BOF))错误;这是攻击者多次成功利用的严重漏洞类别,从 1988 年的 Morris 蠕虫病毒开始! 有关 GitHub 存储库中此漏洞的更多信息,请参阅进一步阅读部分中的资源。

非常有趣的是,在我们的Fedora 28工作站 Linux 机器上编译和运行这部分代码(通过传递适当的参数)显示,默认情况下既没有编译时也没有运行时检测到这个(和其他类似的)危险错误(稍后将详细介绍!):

$ ./membugs 2
$ ./membugs_dbg 2
$ 

这些错误有时也称为 Off-by-one 错误。

不过(和往常一样)还有更多,让我们快速做个实验。 在第一个membugs.c:write_overflow_compilemem()函数*中,*将我们循环的次数从 5 次更改为 50 次:

 for (i = 0; i <= 50; i++) {
    arr[i] = 100;
}

重新构建并重试;现在可以在Ubuntu 18.04 LTSDesktop Linux 系统(也在 Fedora 上,但使用普通内核)上查看输出:

$ ./membugs 2
*** stack smashing detected ***: <unknown> terminated
Aborted
$ 

事实是,现代编译器使用堆栈保护器功能来检测堆栈溢出错误,更重要的是检测攻击。 使用足够大的值时,可以检测到溢出;但是使用默认值时,错误不会被检测到! 我们将在下一章中强调使用工具(包括编译器)检测这些隐藏错误的重要性。

测试案例 3

在动态分配的内存上写入或 BOF。 请按如下方式查看代码片段:

/* test case 3 : out-of-bounds : write overflow [on dynamic memory] */
static void write_overflow_dynmem(void)
{
    char *dest, src[] = "abcd56789";

    dest = malloc(8);
    if (!dest) 

    FATAL("malloc failed\n");

    strcpy(dest, src); /* Bug: write overflow */
    free(dest);
}

同样,没有编译或运行时检测到该错误:

$ ./membugs 3
$ ./membugs 3           *<< try once more >>*
$ 

Unfortunately, BOF-related bugs and vulnerabilities tend to be quite common in the industry. The root cause is poorly understood, and thus results in poorly written, code; this is where we, as developers, must step up our game!

For real-world examples of security vulnerabilities, please see this table of 52 documented security vulnerabilities (due to various kinds of BOF bugs) on Linux in 2017: https://www.cvedetails.com/vulnerability-list/vendor_id-33/year-2017/opov-1/Linux.html.

测试用例 4

写下溢。 我们使用malloc(3)动态分配缓冲区,递减指针,然后写入该内存位置-写入或缓冲区下溢错误:

/* test case 4 : out-of-bounds : write underflow */
static void write_underflow(void)
{
    char *p = malloc(8);
    if (!p)
        FATAL("malloc failed\n");
    p--;
    strncpy(p, "abcd5678", 8); /* Bug: write underflow */
    free(++p);
}

在此测试用例中,我们不希望free(3)失败,因此我们确保传递给它的指针是正确的。 编译器在这里没有检测到任何 bug;但在运行时,它确实会崩溃,现代的 glibc 无法检测错误(在本例中,是内存损坏):

$ ./membugs 4
double free or corruption (out)
Aborted
$

测试案例 5

在编译时分配的内存上读取溢出。 我们尝试在编译时分配的内存缓冲区的最后一个合法可访问位置之后对其进行读取:

/* test case 5 : out-of-bounds : read overflow [on compile-time memory] */
static void read_overflow_compilemem(void)
{
    char arr[5], tmp[8];

    memset(arr, 'a', 5);
    memset(tmp, 't', 8);
    tmp[7] = '\0';

    printf("arr = %s\n", arr); /* Bug: read buffer overflow */
}

按照此测试用例的设计方式,我们在内存中按顺序排列了两个缓冲区。 错误:我们故意不空终止第一个缓冲区(但在第二个缓冲区上这样做),因此,将在arr发出的数据printf(3)将继续读取第二个缓冲区tmp。如果tmp数据缓冲区包含秘密怎么办?

当然,关键是编译器无法捕捉到这个看似明显的错误。 另外,请注意,我们在这里编写的是小型、简单、易读的测试用例;在一个只有几百万行代码的实际项目中,这样的缺陷很容易遗漏。

以下是示例输出:

$ ./membugs 2>&1 | grep -w 5
 option =  5 : out-of-bounds : read overflow [on compile-time memory]
$ ./membugs 5
arr = aaaaattttttt
$ 

嘿,我们得读出tmp的秘密记忆。

事实上,诸如地址消毒器(地址消毒器,见下一章)等工具将此漏洞归类为堆栈缓冲区溢出。

顺便说一句,在我们的Fedora 28工作站上,在此测试用例中,我们只从第二个缓冲区获得垃圾文件:

$ ./membugs 5
arr = aaaaa0<5=�
$ ./membugs 5
arr = aaaaa�:��
$ 

这向我们表明,根据编译器版本、glibc 版本和机器硬件的不同,这些 bug 可能会以不同的方式暴露出来。

An always useful testing technique is to try to run your test cases on as many hardware/software variants as possible. Hidden bugs may be exposed! Think of instances such as endianness issues, compiler optimization (padding, packing), and platform-specific alignments.

测试案例 6

读取溢出,在动态分配的内存上。 我们再次尝试读取;这一次是在动态分配的内存缓冲区上,在其最后一个合法可访问位置之后:

/* test case 6 : out-of-bounds : read overflow [on dynamic memory] */
static void read_overflow_dynmem(void)
{
    char *arr;

    arr = malloc(5);
    if (!arr)
        FATAL("malloc failed\n",);
    memset(arr, 'a', 5);

    /* Bug 1: Steal secrets via a buffer overread.
     * Ensure the next few bytes are _not_ NULL.
     * Ideally, this should be caught as a bug by the compiler,
     * but isn't! (Tools do; seen later).
     */
    arr[5] = 'S'; arr[6] = 'e'; arr[7] = 'c';
    arr[8] = 'r'; arr[9] = 'e'; arr[10] = 'T';
    printf("arr = %s\n", arr);

    /* Bug 2, 3: more read buffer overflows */
    printf("*(arr+100)=%d\n", *(arr+100));
    printf("*(arr+10000)=%d\n", *(arr+10000));

    free(arr);
}

测试用例与前面的用例(编译时内存*上的读取溢出)*基本相同,不同之处在于我们动态分配了内存缓冲区,并插入了更多错误:

$ ./membugs 2>&1 |grep -w 6
 option =  6 : out-of-bounds : read overflow [on dynamic memory]
$ ./membugs 6
arr = aaaaaSecreT
*(arr+100)=0
*(arr+10000)=0
$  

嘿,妈妈,快看! 我们知道秘密了!

它甚至不会造成撞车。 乍一看,像这样的 bug 可能看起来相当无害--但事实是,这是一个非常危险的 bug!

The well known OpenSSL Heartbleed security bug (CVE-2014-0160) is a great example of exploiting a read overflow, or as it's often called, a buffer over-read, vulnerability. 

In a nutshell, the bug allowed a rogue client process to make a seemingly correct request to the OpenSSL server process; in reality, it could request and receive much more memory than it should have been allowed to, because of a buffer over-read vulnerability.In effect, this bug made it possible for attackers to bypass security easily and steal secrets [http://heartbleed.com].

If interested, find more in the Further reading section on the GitHub repository.

测试案例 7

阅读下溢。 我们在动态分配的内存缓冲区的第一个合法可访问位置之前尝试读取:

/* test case 7 : out-of-bounds : read underflow */
static void read_underflow(int cond)
{
    char *dest, src[] = "abcd56789", *orig;

    printf("%s(): cond %d\n", __FUNCTION__, cond);
    dest = malloc(25);
    if (!dest)
        FATAL("malloc failed\n",);
    orig = dest;

    strncpy(dest, src, strlen(src));
    if (cond) {
 *(orig-1) = 'x';
 dest --;
 }
    printf(" dest: %s\n", dest);

    free(orig);
}

测试用例是使用运行时条件设计的;我们通过两种方式对其进行测试:

 case 7:
     read_underflow(0);
     read_underflow(1);
     break;

如果条件计算为真,则缓冲区指针递减,从而导致后续printf上的读取缓冲区下溢:

$ ./membugs 7
read_underflow(): cond 0
 dest: abcd56789
read_underflow(): cond 1
 dest: xabcd56789
double free or corruption (out)
Aborted (core dumped)
$ 

再一次,Glibc 帮助了我们,它向我们展示了双重释放或腐败的发生-在这种情况下,这是内存腐败。

释放后使用/返回后使用错误

**Use-**After-UAF(UAF)和Use-After-Return(UAR)是危险的、难以发现的错误。 请查看其中每一个的以下测试用例。

测试案例 8

免费后使用(UAF)。 在释放内存指针后对其进行操作显然是一个错误,会导致 UB。 指针有时被称为悬挂式指针。 下面是一个快速测试案例:

/* test case 8 : UAF (use-after-free) test case */
static void uaf(void)
{
    char *arr, *next;
    char name[]="Hands-on Linux Sys Prg";
    int n=512;

    arr = malloc(n);
    if (!arr)
        FATAL("malloc failed\n");
    memset(arr, 'a', n);
    arr[n-1]='\0';
    printf("%s():%d: arr = %p:%.*s\n", __FUNCTION__, __LINE__, arr,
                32, arr);

    next = malloc(n);
    if (!next) {
        free(arr);
        FATAL("malloc failed\n");
    }
    free(arr);
    strncpy(arr, name, strlen(name));  /* Bug: UAF */ 
    printf("%s():%d: arr = %p:%.*s\n", __FUNCTION__, __LINE__, arr,
                32, arr);
    free(next);
}

同样,无论是在编译时还是在运行时,都不会检测到 UAF 错误,也不会导致崩溃:

$ ./membugs 2>&1 |grep -w 8
 option =  8 : UAF (use-after-free) test case
$ ./membugs 8
uaf():158: arr = 0x558012280260:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
uaf():166: arr = 0x558012280260:Hands-on Linux Sys Prgaaaaaaaaaa
$  

Did you notice the neat printf(3) format specifier, %.*s, trick? This format is used to print a string of a specific length (no terminating null required!). First, specify the length in bytes to print, and then the pointer to string.

测试案例 9

返回(UAR)后使用。 另一个经典错误,这个错误涉及向调用函数返回一个存储项(或指向它的指针)。 问题是存储是本地的或自动的,这意味着一旦返回受到影响,存储对象现在就不在作用域之外。

经典示例如下所示:我们将32字节分配给局部变量*,*对其进行初始化,然后将其返回给调用方:

/* test case 9 : UAR (use-after-return) test case */
static void * uar(void)
{
    char name[32];

    memset(name, 0, 32);
    strncpy(name, "Hands-on Linux Sys Prg", 22);

    return name;
}

以下是调用方调用前面的 Buggy 函数的方式:

[...]
    case 9:
            res = uar();
            printf("res: %s\n", (char *)res);
            break;
[...]

当然,一旦uar()函数中的return语句生效,name变量就会自动超出作用域! 因此,指向它的指针无效,并且在运行时失败:

$ ./membugs 2>&1 |grep -w 9
 option = 9 : UAR (use-after-return) test case
$ ./membugs 9
res: (null)
$ 

不过,值得庆幸的是,现代的 GCC(我们使用的是 GCC 7.3.0 版)警告我们这个常见的漏洞:

$ make membugs
gcc -Wall -c membugs.c -o membugs.o
membugs.c: In function ‘uar’:
membugs.c:143:9: warning: function returns address of local variable [-Wreturn-local-addr]
 return name;
 ^~~~
[...]

正如前面提到的(但它总是值得重复的),请注意并修复所有警告!

实际上,有些时候这个 bug 没有被注意到--它看起来运行良好,而且没有 bug。 这是因为不能保证堆栈内存帧在函数返回时立即销毁-内存和编译器优化可能会保留该帧(通常是为了重用)。 然而,这是一个危险的错误,必须修复!

In the next chapter, we'll cover some memory debug tools. As a matter of fact, neither Valgrind nor the Sanitizer tools catch this possibly deadly bug. But, using the ASan toolset appropriately does catch the UAR! Read on.

测试用例 10

双自由**。** 一旦释放了一个malloc系列缓冲区,就根本不允许使用该指针。 尝试再次释放同一指针(不再通过malloc系列 API 之一为其分配内存)是一个错误:不是双重释放。 它会导致堆损坏;这样的错误经常被攻击者利用来引起拒绝服务攻击(DoS)或更糟的攻击(权限提升)。

下面是一个简单的测试案例:

/* test case 10 : double-free test case */
static void doublefree(int cond)
{
    char *ptr;
    char name[]="Hands-on Linux Sys Prg";
    int n=512;

    printf("%s(): cond %d\n", __FUNCTION__, cond);
    ptr = malloc(n);
    if (!ptr)
        FATAL("malloc failed\n");
    strncpy(ptr, name, strlen(name));
    free(ptr);

    if (cond) {
        bogus = malloc(-1UL); /* will fail! */
        if (!bogus) {
            fprintf(stderr, "%s:%s:%d: malloc failed\n",
                       __FILE__, __FUNCTION__, __LINE__);
            free(ptr); /* Bug: double-free */
            exit(EXIT_FAILURE);
        }
    }
}

在前面的测试用例中,我们模拟了一个有趣且相当现实的场景:运行时条件(通过cond参数模拟)导致程序执行调用,比方说,调用失败-malloc(-1UL)几乎保证了这一点。

为什么? 因为,在 64 位操作系统上,-1UL = 0xffffffffffffffff = 18446744073709551615 bytes = 16 EB。 这是 64 位虚拟地址空间的全部范围。

回到正题:在我们的 malloc 错误处理代码中,出现了一个错误的双重释放--先前释放的指针ptr的双重释放--导致了双重释放错误。

真正的问题是,作为开发人员,我们通常不为错误处理代码路径编写(否定的)测试用例;这样缺陷就会不知不觉地逃到现场:

$ ./membugs 10
doublefree(): cond 0
doublefree(): cond 1
membugs.c:doublefree:56: malloc failed
$ 

有趣的是,编译器确实警告我们第二个 malloc 有故障(读错误)(但不是关于双重释放!);请参见以下内容:

$ make
[...]
membugs.c: In function ‘doublefree’:
membugs.c:125:9: warning: argument 1 value ‘18446744073709551615’ exceeds maximum object size 9223372036854775807 [-Walloc-size-larger-than=]
 bogus = malloc(-1UL); /* will fail! */
 ~~~~~~^~~~~~~~~~~~~~
In file included from membugs.c:18:0:
/usr/include/stdlib.h:539:14: note: in a call to allocation function ‘malloc’ declared here
 extern void *malloc (size_t __size) __THROW __attribute_malloc__ __wur;
 ^~~~~~
[...]

To help emphasize the importance of detecting and fixing such bugs—and remember, this is just one example— we show as follows some information from the National Vulnerability Database (NVD) on double free bugs within the last 3 years (at the time of this writing): https://nvd.nist.gov/vuln/search/results?adv_search=false&form_type=basic&results_type=overview&search_type=last3years&query=double+free

在*国家漏洞数据库**和(NVD)*上执行的关于过去 3 年(撰写本文时)双自由漏洞的搜索结果的部分屏幕截图如下:

The complete screenshot has not been shown here.

渗漏 / 泄密 / 泄漏

动态内存的黄金法则是释放您分配的内存。

内存泄漏是用来描述未能做到这一点的情况的术语。 程序员还认为,内存区确实被释放了。 但事实并非如此--这就是问题所在。 因此,这使得要释放的内存区域对进程和系统不可用;实际上,它是不可用的,即使它本应可用。

据说内存泄露了。 那么,为什么程序员不能在代码的其他地方通过调用这个内存指针上的空闲函数来解决这个问题呢? 这确实是问题的症结所在:在典型情况下,由于代码的实现方式,基本上不可能重新访问泄漏的内存指针。

一个快速测试案例将证明这一点。

函数被故意编写为在每次调用时泄漏mem字节的内存(它的参数)。

测试用例 11

内存泄漏-案例 1:一个简单的内存泄漏测试案例。 请参见以下代码片段:

static const size_t BLK_1MB = 1024*1024;
[...]
static void amleaky(size_t mem)
{
    char *ptr;

    ptr = malloc(mem);
    if (!ptr)
        FATAL("malloc(%zu) failed\n", mem);

    /* Do something with the memory region; else, the compiler
     * might just optimize the whole thing away!
     * ... and we won't 'see' the leak.
     */
    memset(ptr, 0, mem);

    /* Bug: no free, leakage */
}

[...]
/* test case 11 : memory leak test case 1: simple leak */
static void leakage_case1(size_t size)
{
 printf("%s(): will now leak %zu bytes (%ld MB)\n",
     __FUNCTION__, size, size/(1024*1024));
 amleaky(size);
}

[...]

 case 11:
     leakage_case1(32);
     leakage_case1(BLK_1MB);
     break;
[...]

可以清楚地看到,在amleaky函数中,ptr的内存指针是一个局部变量,因此一旦我们从有错误的函数返回,它就会丢失;这使得以后不可能释放它。 另请注意-注释解释了这一点-我们如何要求memset命令强制编译器为内存区域生成代码并使用该内存区域。

快速构建和执行前面的测试用例将再次显示,没有明显的编译时或运行时检测到泄漏:

$ ./membugs 2>&1 | grep "memory leak"
 option = 11 : memory leak test case 1: simple leak
 option = 12 : memory leak test case 2: leak more (in a loop)
 option = 13 : memory leak test case 3: lib API leak
$ ./membugs 11
leakage_case1(): will now leak 32 bytes (0 MB)
leakage_case1(): will now leak 1048576 bytes (1 MB)
$ 

测试用例 12

内存泄漏情况 2-泄漏更多(在循环中)。通常情况下,有缺陷的泄漏代码本身可能只泄漏少量内存,几个字节。 问题是,如果在流程执行期间,这个泄漏函数在一个循环中被调用数百次或数千次,该怎么办? 现在泄漏是很严重的,但不幸的是,并不是马上就能显现出来。

为了准确地模拟这一点和更多,我们执行两个测试用例(对于选项 12):

  • 我们分配并泄漏了极少量的内存(32 字节),但是在一个循环中发生了 100,000 次(所以,是的,我们最终泄漏了超过 3MB)
  • 我们在一个循环中分配和泄漏了 12 次大量内存(1MB)(因此,我们最终泄漏了 12MB)。

相关代码如下:

[...]

/* test case 12 : memory leak test case 2: leak in a loop */
static void leakage_case2(size_t size, unsigned int reps)
{
    unsigned int i, threshold = 3*BLK_1MB;
    double mem_leaked;

    if (reps == 0)
        reps = 1;
    mem_leaked = size * reps;
    printf("%s(): will now leak a total of %.0f bytes (%.2f MB)"
            " [%zu bytes * %u loops]\n",
            __FUNCTION__, mem_leaked, mem_leaked/(1024*1024),
            size, reps);

    if (mem_leaked >= threshold)
        system("free|grep \"^Mem:\"");

    for (i=0; i<reps; i++) {
        if (i%10000 == 0)
            printf("%s():%6d:malloc(%zu)\n", __FUNCTION__, i, size);
        amleaky(size);
    }

    if (mem_leaked >= threshold)
       system("free|grep \"^Mem:\""); printf("\n");
}

[...]

  case 12:
 leakage_case2(32, 100000);
 leakage_case2(BLK_1MB, 12);
 break;
[...]

该逻辑确保泄漏循环内的printf(3)仅在每 10,000 次循环迭代时显示。

此外,我们还想看看内存是否真的泄露了。 为此,我们使用free实用程序(尽管方式大致如此):

$ free
 total     used       free    shared   buff/cache  available
Mem:  16305508   5906672   348744   1171944   10050092   10248116
Swap:  8000508         0  8000508
$ 

free(1)实用程序以千字节为单位显示整个系统中当前已用、可用和可用的(近似)内存量。 它进一步在共享、缓冲/页面缓存之间划分使用的内存;它还显示Swap分区统计信息。 我们还应该注意到,这种使用free(1)检测内存泄漏的方法被认为不是非常准确;充其量是一种粗略的方法。 操作系统报告的正在使用、可用、已缓存等内存可能会显示各种变化。 就我们的目的而言,这是可以的。

我们的兴趣点是Mem行和free列的交集;因此,我们可以看到,在总共 16 GB 的可用内存中,当前空闲的内存大约是 348744 KB~=340MB。

用户可以快速试用一行脚本来仅显示感兴趣的区域-Mem行:

$ free | grep "^Mem:"
Mem:  16305508   5922772   336436   1165960   10046300   10237452
$ 

Mem之后的第三列是free内存(有趣的是,它已经比前一个输出减少了;这无关紧要)。

回到程序;我们使用system(3)库 API 在 C 程序中运行前面的管道 shell 命令(我们将在第 10 章进程创建中构建我们自己的system(3)命令 API 的小型仿真):

if (mem_leaked >= threshold) system("free|grep \"^Mem:\");

if语句确保仅当>=3MB 的阈值泄漏时才会出现此输出。

以下是执行时的输出:

$ ./membugs 12
leakage_case2(): will now leak a total of 3200000 bytes (3.05 MB) 
 [32 bytes * 100000 loops]
Mem:   16305508     5982408   297708   1149648   10025392   10194628
leakage_case2():     0:malloc(32)
leakage_case2(): 10000:malloc(32)
leakage_case2(): 20000:malloc(32)
leakage_case2(): 30000:malloc(32)
leakage_case2(): 40000:malloc(32)
leakage_case2(): 50000:malloc(32)
leakage_case2(): 60000:malloc(32)
leakage_case2(): 70000:malloc(32)
leakage_case2(): 80000:malloc(32)
leakage_case2(): 90000:malloc(32)
Mem:   16305508     5986996   293120   1149648   10025392   10190040

leakage_case2(): will now leak a total of 12582912 bytes (12.00 MB) 
 [1048576 bytes * 12 loops]
Mem:   16305508     5987500   292616   1149648   10025392   10189536
leakage_case2():     0:malloc(1048576)
Mem:   16305508     5999124   280992   1149648   10025392   10177912
$ 

我们看到两个场景正在执行;检查free列的值。 我们应该减去它们,以查看泄露的内存:

  • 我们分配并泄漏了极少量的内存(32 字节),但是在一个循环中分配和泄漏了 100,000 次:Leaked memory = 297708 - 293120 = 4588 KB ~= 4.5 MB
  • 我们在循环中分配和泄漏大量内存(1MB)12 次:Leaked memory = 292616 - 280992 = 11624 KB ~= 11.4 MB

当然,一定要认识到,一旦进程终止,它的所有内存都会被释放回系统。 这就是为什么我们在进程中执行一行脚本的原因,因为它还活着。

测试用例 13

复杂的案例包装 API。 有时,如果你认为所有程序员都受过教育,这是情有可原的:在调用 malloc(或 calloc,realloc)之后,调用 free。 这能有多难呢? 如果是这样的话,为什么会有这么多偷偷摸摸的泄漏虫呢?

泄漏缺陷可能发生且难以确定的一个关键原因是,某些 API-通常是第三方库 API-可能会在内部执行动态内存分配,并期望调用者释放内存。 API 将(希望)记录这一重要事实;但是谁(半开玩笑地)阅读文档呢?

这确实是现实世界软件问题的症结所在;它很复杂,我们从事的是大型、复杂的项目。 确实很容易忽略一个事实,即底层 API 分配内存,调用者负责释放内存。 确切地说,这种情况经常发生。

还有另一种情况:在复杂的代码库(尤其是那些包含意大利面条代码的代码库)上,很多深度嵌套的层缠绕着代码,在每种可能的错误情况下执行所需的清理(包括释放内存)可能会变得特别困难。

The Linux kernel community offers a clean, though fairly controversial, way to keep cleanup code paths clean and working well, that is, the use of the local go to perform centralized error-handling! It helps indeed. Interested in learning more? Check out section 7,* Centralized exiting of functions* at https://www.kernel.org/doc/Documentation/process/coding-style.rst.

测试用例 13.1

这里有一个简单的例子。 让我们用以下测试用例代码进行模拟:

/* 
 * A demo: this function allocates memory internally; the caller
 * is responsible for freeing it!
 */
static void silly_getpath(char **ptr)
{
#include <linux/limits.h>
    *ptr = malloc(PATH_MAX);
    if (!ptr)
        FATAL("malloc failed\n");

    strcpy(*ptr, getenv("PATH"));
    if (!*ptr)
        FATAL("getenv failed\n");
}

/* test case 13 : memory leak test case 3: "lib" API leak */
static void leakage_case3(int cond)
{
    char *mypath=NULL;

    printf("\n## Leakage test: case 3: \"lib\" API"
        ": runtime cond = %d\n", cond);

    /* Use C's illusory 'pass-by-reference' model */
    silly_getpath(&mypath);
    printf("mypath = %s\n", mypath);

    if (cond) /* Bug: if cond==0 then we have a leak! */
        free(mypath);
}

我们将其调用为:

[...]
case 13:
     leakage_case3(0);
     leakage_case3(1);
     break;

与往常一样,不会产生编译器或运行时警告。 下面是输出(认识到第一次调用是有错误的情况,因为cond的值是 00,因此不会调用free(3)):

$ ./membugs 13

## Leakage test: case 3: "lib" API: runtime cond = 0
mypath = /usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/sbin:/usr/sbin:/usr/local/sbin:/home/kai/Mentorimg/Sourcery_CodeBench_Lite_for_ARM_GNU_Linux/bin/:/mnt/big/scratchpad/buildroot-2017.08.1/output/host/bin/:/sbin:/usr/sbin:/usr/local/sbin

## Leakage test: case 3: "lib" API: runtime cond = 1
mypath = /usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/sbin:/usr/sbin:/usr/local/sbin:/home/kai/Mentorimg/Sourcery_CodeBench_Lite_for_ARM_GNU_Linux/bin/:/mnt/big/scratchpad/buildroot-2017.08.1/output/host/bin/:/sbin:/usr/sbin:/usr/local/sbin
$ 

通过查看输出没有明显的错误-这就是这些错误如此危险的部分原因!

这个案例对于开发人员和测试人员来说至关重要;它需要检查几个真实世界的示例。

测试用例 13.2

示例-Motif库**。**Motif是旧库,是 X Window System 的一部分;它被用来(也许现在仍然是)为 Unix(和类 Unix)系统开发 GUI。

出于本例的目的,我们将重点介绍它的一个 API:XmStringCreateLocalized(3)。 GUI 开发人员使用此函数来创建 Motif 所称的“复合字符串”-本质上,它只是一个在特定地区保存文本的字符串(出于 I18N 国际化的目的)。 这是它的签名:

#include <Xm/Xm.h>
XmString XmStringCreateLocalized(char *text);

因此,假设开发人员使用它来生成复合字符串(用于各种目的;通常用于标签或按钮小部件的标签)。

那么,有什么问题呢? 泄漏! 多么?。 阅读XmStringCreateLocalized(3)上手册页(https://linux.die.net/man/3/xmstringcreatelocalized)中的文档:

[...]

The function will allocate space to hold the returned compound string. The application is responsible for managing the allocated space. The application can recover the allocated space by calling XmStringFree. 
[...]

显然,开发人员不仅必须调用XmStringCreateLocalized(3),还必须记住通过调用XmStringFree(3)释放其内部为复合字符串分配的内存!

如果不这样做,将导致泄漏。 我对这种情况有亲身体验-一个有缺陷的应用调用了XmStringCreateLocalized(3),而没有调用它的对应程序XmStringFree(3)。 不仅如此,这段代码还经常运行,因为它是作为外部循环主体的一部分被调用的! 因此,泄漏量成倍增加。

测试用例 13.3

示例-北电网络移植项目。 有一个关于 Nortel(加拿大的一家大型电信和网络设备跨国公司)的开发人员很难调试结果是内存泄漏问题的故事(参见下面的信息框)。 问题的症结在于:在将 Unix 应用移植到 VxWorks 时,他们在测试时注意到出现了一个 18 字节的小泄漏,这最终会导致应用崩溃。 找到泄漏源是一场噩梦--无休止地检查代码没有提供任何线索。 最后,事实证明,游戏规则改变者使用了泄漏检测工具(我们将在即将到来的第 6 章内存问题调试工具中介绍这一点)。 在几分钟内,他们就发现了泄漏的根本原因:一个看起来无辜的 APIinet_ntoa(3)(请参阅信息框),它在 Unix 和 VxWorks 上都以通常的方式工作。 问题是:在 VxWorks 实现中,它在幕后分配内存-调用者负责释放内存! 这一事实被记录在案,但它是一个移植项目! 一旦这一事实被认识到,它很快就被解决了。

**Article: The ten secrets of embedded debugging, Schneider and Fraleigh: https://www.embedded.com/design/prototyping-and-development/4025015/The-ten-secrets-of-embedded-debugging The man page entry on inet_ntoa(3) states: The inet_ntoa() function converts the Internet host address in, given in network byte order, to a string in IPv4 dotted-decimal notation. The string is returned in a statically allocated buffer, which subsequent calls will overwrite.

对存在泄漏错误的程序的一些观察:

  • 该程序在很长很长一段时间内都运行正常;比方说,在正常运行一个月后,它突然崩溃。
  • 根泄漏可能非常小-一次只有几个字节;但可能经常被调用。
  • 试图通过仔细匹配malloc(3)实例和free(3)实例来查找泄漏错误是行不通的;库 API 包装器通常会在幕后分配内存,并期望调用者释放内存。
  • 泄漏通常不会被注意到,因为它们本身很难在大型代码库中被发现,一旦进程死亡,泄漏的内存就会被释放回系统。

底线是:

  • 不要假设任何事情
  • 请仔细阅读 API 文档
  • 使用工具(在即将到来的第 6 章,以及调试工具中介绍内存问题)

使用工具检测内存错误的重要性怎么强调都不为过!

未定义的行为

我们已经讨论了相当多的内容,并且看到了相当多常见的内存错误,其中包括:

  • 错误的内存访问
    • 使用未初始化的变量
    • 内存访问越界(读/写下溢/溢出错误)
    • 释放后使用/返回后使用(超出范围)错误
    • 双重免费
  • 渗漏 / 泄密 / 泄漏
  • 数据竞赛*(*详情见下一章)

正如前面提到的,所有这些都归入一个通用的分类-UB。正如这句话所暗示的,一旦这些错误中的任何一个被击中,进程(或线程)的行为就是未定义的。 更糟糕的是,它们中的许多都没有表现出任何直接明显的副作用;但这个过程是不稳定的,最终会崩溃。 尤其是泄漏错误,在这一点上是主要的破坏因素:在真正发生崩溃之前,泄漏可能会存在很长一段时间。 不仅如此,留下的痕迹(开发人员会气喘吁吁地追逐)可能经常是转移注意力的事情-无关紧要的事情,与 bug 的根本原因没有真正关系的事情。 当然,所有这些都使得调试 UB 成为我们大多数人都希望避免的经历!

好消息是,UB 是可以避免的,只要开发人员了解 UB 的根本原因(我们在前面的小节中已经介绍过),当然还有使用强大的工具发现并修复这些错误的能力,这是我们下一个主题领域。

For a deeper look at the many, many possible kinds of UB bugs, please check out:Appendix J.2: Undefined behavior: a nonnormative, non-exhaustive list of undefined behaviors in C: http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1548.pdf#page=571. From the in-depth C Programming Language standards—the ISO/IEC 9899:201x Committee Draft dated 02 Dec 2010.

Along similar lines, please see CWE VIEW: Weaknesses in Software Written in C:https://cwe.mitre.org/data/definitions/658.html.

破碎 / 分裂 / 存储残片 / 分段存储

碎片问题通常指的是内存分配引擎本身的内部实现主要面临的问题,而不是典型的应用开发人员所面临的问题。 碎片问题通常有两种类型:内部和外部。

外部内存碎片通常指的是这样一种情况,即在正常运行几天后,即使系统上的空闲内存为(比方说)100MB,物理上连续的空闲内存可能不到 1 兆字节。 因此,随着进程获取和释放各种大小的内存块,内存变得支离破碎。

内部碎片通常指的是使用低效分配策略导致的内存浪费;不过,这通常是无能为力的,因为浪费往往是许多基于堆的分配器的副作用。 现代的 glibc 引擎使用内存池,这极大地减少了内部碎片。

在这本书中,我们不会试图深入探讨支离破碎的问题。

Suffice it to say that, if in a large project you suspect fragmentation issues, you should try using a tool that displays your process runtime memory map (on Linux, check out /proc/<PID>/maps as a starting point). Interpreting it, you could possibly look at redesigning your application to avoid said fragmentation.

混杂的 / 各种各样的 / 多才多艺的

此外,一定要认识到,除非内存已经分配,否则尝试只使用指针访问内存是错误的。 请记住,指针没有内存;必须为它们分配内存(无论是在编译时静态分配还是在运行时动态分配)。

例如,编写一个使用参数作为返回值的 C 函数-这是一个常见的 C 编程技巧(通常称为值-结果或输入-输出参数):

unsigned long *uptr; 
[...] 
    my_awesome_func(uptr); // bug! value to be returned in 'uptr'
[...]

这是一个错误;参数uptr变量只是一个指针-它没有内存。 解决此问题的一种方法如下:

unsigned long *uptr; 
[...]
    uptr = malloc(sizeof(unsigned long));
    if (!uptr) {
        [...handle the error...]
    }
    my_awesome_func(uptr); // value returned in 'uptr'
    [...]
    free(uptr);

或者,更简单的是,为什么不在这样的情况下使用编译时内存:

unsigned long uptr; // compile-time allocated memory
[...] 
    my_awesome_func(&uptr); // value returned in 'uptr'
[...]

简略的 / 概括的 / 简易判罪的 / 简易的

在本章中,我们深入研究了一个关键领域:看似简单的动态内存管理 API 可能会在实际系统中导致严重且难以检测的错误。

讨论了常见的内存错误类别,如未初始化内存使用(UMR)、越界访问(读取|写入下溢|溢出错误)和双重释放。 内存泄漏是一种常见而危险的内存缺陷-我们研究了三种不同的情况。

提供的membugs程序帮助读者实际看到并尝试通过小测试用例覆盖的各种内存错误。在下一章中,我们将深入研究如何使用工具来帮助识别这些危险的缺陷。**