调用malloc时发生了什么
这或许是老生常谈的问题,也是面试中经常碰到的问题,有人简单的几句话就回答完了,有人却能大谈特谈。
疑问
- 进程的堆栈结构
- malloc是否会占用内存
- malloc对应的系统调用
- malloc返回的地址
- free函数干了什么
一般,我们在需要申请内存的时候,需要执行malloc(),分配内存,需要注意的是,malloc()是glibc函数,其实际对应的系统调用是brk()函数(实际上是syscall 1)。glibc对brk系统调用进行封装,然后抽象出malloc函数,提供给linux开发者使用。
glibc中,malloc具体的实现是由__libc_malloc函数实现,其核心就是调用_int_malloc函数,glibc具体实现不细说了,内存管理相当复杂,主要是内存头的实现,可有一定程度上探测到踩内存/重复释放之类的常见错误。
brk与sbrk函数
要了解brk函数,需要了解进程的地址空间。网上很容易找到这么一张图:
brk函数对应的就是“堆”的操作,这个很好理解,教科书上,malloc返回的内存也经常被称之为堆内存。
堆 用一对 brk_start 和 brk_end ,变量描述其大小,只有[ brk_start ,brk_end ]范围内的内存,才可以读写。
接着,我们通过man 命令看看 brk具体作用:
DESCRIPTION
brk() and sbrk() change the location of the program break, which defines the end of the process's data segment (i.e., the
program break is the first location after the end of the uninitialized data segment). Increasing the program break has the
effect of allocating memory to the process; decreasing the break deallocates memory.
brk() sets the end of the data segment to the value specified by addr, when that value is reasonable, the system has enough
memory, and the process does not exceed its maximum data size (see setrlimit(2)).
sbrk() increments the program's data space by increment bytes. Calling sbrk() with an increment of 0 can be used to find
the current location of the program break.
简而言之,brk的入参就是指定堆的结束地址,换句话说,假设当前堆指针curbrk = 0x1000,如果我入参是0x2000,那么堆指针就被我设置成了curbrk = 0x2000。
堆指针向上移动了0x1000(实际上还会有对齐)。
brk()返回0表示成功,返回-1表示失败,一般指的是没有内存了。
sbrk的入参与返回值都和brk不一样,brk的入参是一个绝对地址,表示自己想要设置的brk_end 值,而sbrk的入参是相对地址,sbrk的返回是新设置的brk_end。
举个例子1就能明白了:
#include <unistd.h>
#include <stdio.h>
void main()
{
char *brk_end = NULL;
/*表示自己想扩展堆大小0字节
*由于sbrk返回的是新的brk_end,所以sbrk(0)就能获取到当前
*的brk_end
*/
char *p = sbrk(0);
printf("current brk end:%p\n",p);
/*brk的入参是绝对地址,表示自己想要拓展brk_end至p+4096*/
brk(p+4096);
/*再次尝试获取当前的brk_end*/
brk_end = sbrk(0);
printf("current brk end:%p\n",brk_end);
}
输出
current brk end:0x98e000
current brk end:0x98f000
代码的注释说的很清楚,其中一个技巧就是通过sbrk(0)获取当前的堆的结束地址。然后使用brk拓展堆大小。这样程序地址中,0x98e000到0x98f000之前的内存就可以被读写了。
例子2:
#include <unistd.h>
#include <stdio.h>
void main()
{
char *brk_end = NULL;
/*表示自己想扩展堆大小0字节*/
char *p = sbrk(0);
brk(p+100);
p[0] = 1;/*正常*/
p[99] = 1;/*正常*/
p[100] = 1;/*正常*/
p[4095] = 1;/*正常*/
p[4096] = 1;/*segment fault*/
}
理论上,堆扩大了100字节,只有p[0, 99]才可以写,但是为什么p[100]没事,p[4095]没事,p[4096]就不不行了?后面我们会讲到,地址映射是以PAGE_SIZE为单位去映射的。别看调用了brk(p+100),起底层实现创建VMA时,执行了PAGE_ALIGN(brk)。详细的后面再说。
到这里,应该清楚了sbrk()和brk()的作用了,那么很容易实现malloc与free函数了:
例子3
#include <unistd.h>
static void *_malloc(int len)
{
unsigned char *brk_end = sbrk(0);
if (-1 == brk(brk_end + len +sizeof(unsigned long)))
return NULL;
*(unsigned long*)brk_end = len;
return brk_end +sizeof(unsigned long);
}
static void _free(void *p)
{
unsigned long *ptr = p;
ptr -= 1;
unsigned long len = *ptr;
brk((char*)ptr-len-sizeof(unsigned long));
}
int main()
{
char *p = _malloc(100);
p[1] = 1;
_free(p);
}
实现malloc(len)很容易,理论上sbrk就行了,这里多用了一个brk,演示用。
实现free(p),需要考虑这么一个情况,free(p)时必然需要使用brk函数,brk函数
的入参,肯定是p - len,使得进程堆回退,但是free的入参只有指针p,所以为了使得p包含内存长度信息,使用了内存头的方法,这也是非常常见的方法,这里内存头只是简单的一个unsigned long 类型,复杂的程序里面往往是一个struct。
上面示例程序其实有个问题,那就是如果我执行如下操作:
p1 = _malloc(0x10);//p1 = 0x100 brk_end = 0x118
p2 = _malloc(0x10);//p2 = 0x118 brk_end = 0x130
_free(p1)
在_free(p1)时,brk_end 就被设置成了0x100 ,这样,p2的内存即使没有调用_free,也被无情的释放了。即使使用sbrk进行_free也无济于事。
所以设计malloc非常复杂,性能与功能就想鱼和熊掌。