4 - Linux Memory Issues - Linux 内存问题
Linux Memory Issues - Linux 内存问题
常见的内存问题
使用 C 语言编程逃不掉下面的内存问题:
- 不正确的内存访问
- 使用未经初始化的变量
- 界外内存访问
- 释放后使用/返回后使用问题
- 双重释放
- 内存泄露
- 未定义行为
- 数据竞争
- 碎片化问题
- 内部
- 外部
在编译本章的例程时(无论使用 GCC 还是使用 Clang 编译器),可以看到编译器报了很多的警告,这是因为本章的例程中包含很多的问题。我们可以使用下面的命令将编译警告输出到文件中:
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
test case 14 : out-of-bounds : write overflow [on global memory] [update]
-h | --help : show this help screen
可以看到上面的例子中,提到了对于写溢出与读溢出都有两种情况:一个是编译时内存,一个是动态分配内存。区分这两种情况是十分重要的。
不正确地内存访问
访问/使用未初始化的变量
测试案例 1:访问未初始化的内存
一个经典的案例:定义了局部变量,但是没有初始化局部变量(不像全局变量会被初始化为 0):
/* 测试案例 1 */
static void uninit_var()
{
int x; /* static mem */
if (x)
printf("true case: x = %d\r\n", x);
else
printf("false case\r\n");
}
在不使用优化的情况下编译程序:
gcc -Wall -o membugs -O0 membugs.c ../common.c ../common.h
在运行时,变量 x
是未被初始化的,它的内容是随机的,现在我们可以运行生成的这个可执行文件:
$ ./membugs 1
true case: x=32695
$ ./membugs 1
true case: x=32654
$ ./membugs 1
true case: x=32641
$ ./membugs 1
true case: x=32747
我们应该感谢现在的编译器,在编译时会给我们报告关于这一问题的提示:
membugs.c: In function ‘uninit_var’:
membugs.c:298:5: warning: ‘x’ is used uninitialized in this function [-Wuninitialized]
298 | if (x)
| ^
界外内存访问
这个问题也是常见的内存问题,可以分为下面几类:
- 写上溢:尝试写合法访问界限后面的内存
- 写下溢:尝试写合法访问界限前面的内存
- 读下溢:尝试读合法访问界限前面的内存
- 读上溢:尝试读合法访问界限后面的内存
测试案例 2
我们先看一下对编译时分配内存的上溢情况:
/* 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; /* i == 5 时 arr 溢出,将会写 tmp 的内存 */
}
}
有趣的是这段程序无论是在编译时还是在运行时候,都无法检测到这个问题:
$ ./membugs 2
$
如果我们把这段程序修改为下面的语句:
for (i = 0; i <= 5; i++) {
arr[i] = 100;
}
重新编译并运行这个程序:
$ ./membugs 2
*** stack smashing detected ***: terminated
Aborted (core dumped)
足够大的溢出才能够让我们发现这个问题存在。
测试案例 3
下面是动态分配内存的内存上溢代码:
/* write overflow on dynamic memory */
static void write_overflow_dynmem(void)
{
char *dest, src[] = "abcd56789";
dest = malloc(8);
if(!dest)
FATAL("malloc failed\r\n");
strcpy(test, src); /* Bug: write overflow */
free(dest);
}
如果使用 gcc
的 -O0
优化,这段程序在编译、执行时也没有提示并可以正常运行:
gcc -o membugs -O0 membugs.c ../common.c ../common.h
$ ./membugs 3
$
使用 gcc
的 -O2
优化,将会不同:
$ gcc -o test -O2 test.c
In file included from /usr/include/string.h:495,
from test.c:2:
In function ‘strcpy’,
inlined from ‘func’ at test.c:12:2,
inlined from ‘main’ at test.c:18:2:
/usr/include/x86_64-linux-gnu/bits/string_fortified.h:90:10: warning: ‘__builtin___memcpy_chk’ writing 10 bytes into a region of size 8 overflows the destination [-Wstringop-overflow=]
90 | return __builtin___strcpy_chk (__dest, __src, __bos (__dest));
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$ ./test
*** buffer overflow detected ***: terminated
Aborted (core dumped)
测试案例 4
下面是动态分配内存写下溢的代码:
/* write underflow */
static void write_underflow(void)
{
char *p = malloc(8);
if (!p)
FATAL("malloc failed\r\n");
p--;
strncpy(p, "abcd5678", 8); /* Bug: write underflow */
free(++p);
}
在这个例子中,编译器编译时并不能发现存在问题,如果不进行编译优化,在执行时程序会崩溃:
$ ./test
double free or corruption (out)
Aborted (core dumped)
测试案例 5
下面是编译时动态分配内存读上溢的代码:
/* 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\r\n", arr); /* read overflow */
}
这个例子中,我们估计没有给第一个 buffer 的字串一个结束符,因此 printf
会在打印完 arr
之后继续进入第二个 buffer 打印 tmp
,如果 tmp
中有不想为人知的秘密会怎样呢?这个例子中,编译器并不能发现有问题存在,这个例子执行结果如下:
$ ./membugs 5
arr = aaaaattttttt
我们把 tmp
打印出来了(当然基于编译器版本,这个执行结果可能不同)。
测试案例 6
下面是动态分配内存的读上溢代码:
/* read overflow on dynamic memory */
static void read_overflow_dynmem(void)
{
char *arr;
arr = malloc(5);
if (!arr)
FATAL("malloc failed\r\n");
memset(arr, 'a', 5);
/* bug 1 */
arr[5] = 'S'; arr[6] = 'e'; arr[7] = 'c';
arr[8] = 'r'; arr[9] = 'e'; arr[10] = 'T';
printf("arr = %s\r\n", arr);
/* bug 2 */
printf("*(arr+100) = %d\r\n", *(arr+100));
printf("*(arr+10000) = %d\r\n", *(arr+10000));
free(arr);
}
这个测试程序与上一个测试程序类似:
$ ./membugs 6
arr = aaaaaSecreT
*(arr+100)=0
*(arr+10000)=0
这段程序甚至没有崩溃,这个程序似乎没有害处,但却是十分危险的问题,著名的 OpenSSL 安全问题 CVE-2014-0160 就是这类问题。
测试案例 7
/* read underflow */
static void read_underflow(int cond)
{
char *dest, src[] = "abcd56789", *orig;
printf("%s(): cond %d\r\n", __FUNCTION__, cond);
dest = malloc(25);
if (!dest)
FATAL("malloc failed\r\n");
orig = dest;
strncpy(dest, src, strlen(src));
if (cond) {
*(orig - 1) = 'x';
dest--;
}
printf(" dest: %s\r\n", dest);
free(orig);
}
这个程序会制造下溢:
5$ ./membugs 7
read_underflow(): cond 0
dest: abcd56789
read_underflow(): cond 1
dest: xabcd56789
double free or corruption (out)
Aborted (core dumped)
glibc
能够检测到有问题存在。
释放后/返回后使用内存的错误
释放后使用内存与返回后使用内存是十分危险的行为,这个问题很难定位。
测试案例 8
释放后的内存使用,这种运行情况的行为是未知的,释放后使用的指针称为野指针:
/* 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\r\n");
memset(arr, 'a', n);
arr[n-1] = '\0';
printf("%s(): %d: arr = %p: %.*s\r\n", __FUNCTION__, __LINE__, arr, 32, arr);
next = malloc(n);
if (!next) {
free(arr);
FATAL("malloc failed\r\n");
}
free(arr);
strncpy(arr, name, strlen(name)); /* bug */
printf("%s(): %d: arr = %p:%.*s\r\n", __FUNCTION__, __LINE__, arr, 32, arr);
free(next);
}
这里编译器与运行时都没有报出错误:
$ ./membugs 8
uaf():170: arr = 0x5558a288a2a0:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
uaf():182: arr = 0x5558a288a2a0:Hands-on Linux Sys Prgaaaaaaaaaa
这个例子中的 %.*s
打印字串的指定长度。
测试案例 9
在函数返回之后使用这个函数中临时的内存空间,是另一个经典问题:
/* use-after-return test case */
static void * uar(void)
{
char name[32];
memset(name, 0, 32);
strncpy(name, "Hand-on Linux Sys Prg", 22);
return name;
}
...
case 9:
res = uar();
printf("res: %s\r\n", (char *)res);
break;
...
显然,一旦从 uar
函数返回,name
变量不再可用范围内了,指针无效,运行这一程序:
$ ./membugs 9
res: (null)
万幸这个问题编译器是可以帮助我们发现的:
gcc -o membugs_dbg membugs_dbg.o common_dbg.o
clang -g -ggdb -gdwarf-4 -O0 -Wall -Wextra -DDEBUG -fsanitize=address -c membugs.c -o membugs_dbg_asan.o
membugs.c:155:9: warning: address of stack memory associated with local variable 'name' returned [-Wreturn-stack-address]
return name;
^~~~
测试案例 10
双重释放,一旦分配的空间被释放,就不再允许使用这个指针,若没有利用这个指针分配新的空间,尝试再次释放相同的指针就是双重释放问题。下面是这样的例子:
/* double-free test case */
static void doublefree(void)
{
char *ptr;
char name[] = "Hand-on Linux Sys Prg";
int n = 512;
printf("%s(): cond %d\r\n", __FUNCTION__, cond);
ptr = malloc(n);
if (!ptr)
FATAL("malloc failed\r\n");
strncpy(ptr, name, strlen(name));
free(ptr);
if (cond) {
bogus = malloc(-1UL); /* will fail */
if (!bogus) {
fprintf(stderr, "%s:%s:%d: malloc failed\r\n", __FILE__, __FUNCTION__, __LINE__);
free(ptr); /*bug: double-free */
exit(EXIT_FAILURE);
}
}
}
下面是执行情况:
$ ./membugs 10
doublefree(): cond 0
doublefree(): cond 1
membugs.c:doublefree:140: malloc failed
free(): double free detected in tcache 2
Aborted (core dumped)
编译器能够检测到分配内存时的错误,但是不能检测到双重释放的错误:
membugs.c: In function ‘doublefree’:
membugs.c:137:11: warning: argument 1 value ‘18446744073709551615’ exceeds maximum object size 9223372036854775807 [-Walloc-size-larger-than=]
137 | bogus = malloc(-1UL); /* will fail! */
| ^~~~~~~~~~~~
In file included from membugs.c:26:
/usr/include/stdlib.h:539:14: note: in a call to allocation function ‘malloc’ declared here
539 | extern void *malloc (size_t __size) __THROW __attribute_malloc__
| ^~~~~~
泄露
使用动态内存的黄金法则是一定要释放自己分配的内存。如果没有释放自己分配的内存,这样的情景就被称作内存泄露。如果我们认为释放了的内存实际上没能被释放,那么这就出现了问题,进程与系统都不能再使用这块内存空间。
测试案例 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\r\n", mem);
/* 使用这块分配的内存做些操作,否则编译器将会优化这一部分逻辑 */
memset(ptr, 0, mem);
/* 这里我们不释放内存 */
}
/* memory leak test case 1: simple leak */
static void leakage_case1(size_t size)
{
printf("%s(): will now leak %zu bytes (%ld MB)\r\n",
__FUNCTION__, size, size/(1024*1024));
smleaky(size);
}
...
case 11:
leakage_case1(32);
leakage_case1(BLK_1MB);
break;
...
这个程序无论是在编译时还是在执行时,都没有什么警告,下面是执行情况:
$ ./membugs 11
leakage_case1(): will now leak 32 bytes (0 MB)
leakage_case1(): will now leak 1048576 bytes (1 MB)
测试案例 12
内存泄露测试代码 2,这里我们泄露更多的内存:
/* 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]\r\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)\r\n", __FUNCTION__, i, size);
amleaky(size);
}
if (mem_leaked >= threshold)
system("free | grep \"^Mem:\"");
printf("\r\n");
}
...
case 12:
leakage_case2(32, 100000);
leakage_case2(BLK_1MB, 12);
break;
...
我们希望确定内存是否真的泄露了,受限我们可以使用 free
小工具看一下当前内存:
$ free
total used free shared buff/cache available
Mem: 32601720 11352432 13590368 273676 7658920 20506976
Swap: 8388604 0 8388604
free
命令可以以 KB 为单位展示当前内存情况,总内存,已使用内存,可用内存等。以这种方式来探测内存泄露是不精确的。这里我们可以看到总可用的内存是 32GB,当前可用的内存约为 12GB,下面我们执行内存泄露程序(这里我们用未优化的编译版本):
$ ./membugs_dbg 12
leakage_case2(): will now leak a total of 3200000 bytes (3.05 MB) [32 bytes * 100000 loops]
Mem: 32601720 9226184 20451188 264788 2924348 22637116
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: 32601720 9230216 20447156 264788 2924348 22633084
leakage_case2(): will now leak a total of 12582912 bytes (12.00 MB) [1048576 bytes * 12 loops]
Mem: 32601720 9230216 20447156 264788 2924348 22633084
leakage_case2(): 0:malloc(1048576)
Mem: 32601720 9242620 20434752 264788 2924348 22620688
可以看到 20457188 - 20447156 = 4032 KB,约为 4.5MB,而 20447156 - 20434752 =12400 约为 12 MB。
测试案例 13
我们现在已经知道,在调用 malloc
之后要记得调用 free
。有时候第三方库 API 在内部执行了内存分配,并期望用户来释放这些内存,这些第三方库一般都会在支持文档中记录这一特性,但是很多人是不读文档的,这就造成了内存泄露。
测试案例 13.1
/* 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\r\n");
strcpy(*ptr, getenv("PATH"));
if (!*ptr)
FATAL("getenv failed\r\n");
}
static void leakage_case3(int cond)
{
char *mypath = NULL;
printf("\r\n## Leakage test: case 3: \"lib\" API"
": runtime cond = %d\r\n", cond);
silly_getpath(&mypath);
printf("mypath = %s\r\n", mypath);
if (cond) /* if cond == 0, then we have a leak */
free(mypath);
}
...
case 13:
leakage_case3(0);
leakage_case3(1);
break;
...
这样的错误在编译与运行时都没有爆出问题:
$ ./membugs 13
## Leakage test: case 3: "lib" API: runtime cond = 0
mypath = /home/arv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
## Leakage test: case 3: "lib" API: runtime cond = 1
mypath = /home/arv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
这样的 bug 显然是十分危险的。
测试案例 13.2
Motif
库是 X Window 系统的一部分,用来开发类 UNIX 系统的 GUI。这里我们会关注它的一个 API:XmStringCreateLocalized
,GUI 开发者使用这个函数来创建 Motif
中称作复合字符串的元素:
#include <Xm/Xm.h>
XmString XmStringCreateLocalized(char *text);
假设开发者用它生成了复合字符串,就有可能发生内存泄露。下面是这个接口的 man 页面介绍:
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
接口必须牢记使用 XmStringFree
释放这个复合字符串。如果没有这么做,就会导致内存泄露。
碎片化
碎片化问题通常是内存分配引擎会面临的问题,对于应用开发者而言并不是典型问题。碎片化通常可以分为两种,一种内部一种外部。
外部碎片化通常发生在程序运行了一段时间后,即便系统中有足量的自由内存,比如有 100MB 的自由内存,但实际上最大的连续内存可能都不足 1MB,进程分配、释放内存使得内存块变得碎片化。
内部碎片化通常发生在使用不够高效的内存分配策略情况下导致的内存浪费。
如果要在一个大型的工程中查内存碎片化问题,可以尝试使用一些工具来展示进程运行时的内存映射(在 Linux 系统中,可以查看 /proc/<PID>/maps
做为辅助)。