Linux点点滴滴
Linux内存管理
1. 虚拟内存地址与物理内存地址
为了简单,现代操作系统在处理内存地址时,普遍采用虚拟内存地址技术。即在汇编程序(或机器语言)层面,当涉及内存地址时,都是使用虚拟内存地址。采用这种技术时,每个进程仿佛自己独享一片2^N
字节的内存,其中N
是机器位数。
这种虚拟地址空间的作用主要是:简化程序的编写及方便操作系统对进程间内存的隔离管理,真实中的进程不太可能需要(也用不到)如此大的内存空间,实际能用到的内存取决于物理内存大小。
由于在机器语言层面都是采用虚拟地址,当实际的机器码程序涉及到内存操作时,需要根据当前进程运行的实际上下文将虚拟地址转换为物理内存地址,才能实现对真实内存数据的操作。这个转换一般由一个叫MMU(Memory Management Unit)
的硬件完成。
2. 页与地址构成
在现代操作系统中,不论是虚拟内存还是物理内存,都不是以字节为单位进行管理的,而是以页(Page)为单位。一个内存页是一段固定大小的连续内存地址的总称,具体到Linux中,典型的内存页大小为4096
Byte(4K
)。
所以内存地址可以分为页号和页内偏移量。下面以64
位机器,4G
物理内存,4K
页大小为例,虚拟内存地址和物理内存地址的组成如下:
上面是虚拟内存地址,下面是物理内存地址。由于页大小都是4K
,所以页内便宜都是用低12
位表示,而剩下的高地址表示页号。
MMU映射单位并不是字节,而是页,这个映射通过查一个常驻内存的数据结构页表来实现。现在计算机具体的内存地址映射比较复杂,为了加快速度会引入一系列缓存和优化,例如TLB
等机制。例如,经过简化的内存地址翻译示意图:
内存页与磁盘页
一般将内存看做磁盘的的缓存,有时MMU在工作时,会发现页表表明某个内存页不在物理内存中,此时会触发一个缺页异常(Page Fault),此时系统会到磁盘中相应的地方将磁盘页载入到内存中,然后重新执行由于缺页而失败的机器指令。
Linux进程级内存管理
1. 内存排布
Linux 64位操作系统仅使用低47
位,高17
位做扩展(只能是全0
或全1
)。所以,实际用到的地址为空间为0x0000000000000000 ~ 0x00007FFFFFFFFFFF
和0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF
,其中前面为用户空间(User Space),后者为内核空间(Kernel Space)。图示如下:
【注】:Linux virtual memory map
对用户来说,主要关注的空间是User Space,其主要分为如下几段:
Code
:这是整个用户空间的最低地址部分,存放的是指令(也就是程序所编译成的可执行机器码)Data
:存放初始化过的全局变量BSS
:存放未初始化的全局变量Heap
:堆从低地址向高地址增长,brk
相关的系统调用就是从这里分配内存Mapping Area
:与mmap
系统调用相关的区域(大多数实际的malloc
实现会考虑通过mmap
分配较大块的内存区域。此区域自高地址向低地址增长)Stack
:栈区域,从高地址向低地址增长
2. Heap
内存模型
一般来说,malloc
所申请的内存主要从Heap
区域分配(本文不考虑通过mmap
申请大块内存的情况)。
进程所面对的虚拟内存地址空间,只有按页映射到物理内存地址,才能真正使用。受物理存储容量限制,整个堆虚拟内存空间不可能全部映射到实际的物理内存。Linux对堆的管理示意如下:
Linux维护一个break
指针,这个指针指向堆空间的某个地址
- 从堆起始地址到
break
之间的地址空间为映射好的,可以供进程访问。 - 而从
break
往上,是未映射的地址空间,如果访问这段空间则程序会报错。
brk
与sbrk
要增加一个进程实际的可用堆大小,就需要将break
指针向高地址移动。Linux通过brk
和sbrk
系统调用操作break
指针。两个系统调用的原型如下:
int brk(void *addr);
void *sbrk(intptr_t increment);
brk
将break
指针直接设置为某个地址。brk
在执行成功时返回0
,否则返回-1
并设置errno
为ENOMEM
。sbrk
将break
从当前位置移动increment
所指定的增量。sbrk
成功时,返回break
移动之前所指向的地址,否则返回(void *)-1
。
【注】:如果将increment
设置为0
,则可以获得当前break
的地址。
此外,由于Linux是按页进行内存映射的,所以如果break
被设置为没有按页大小对齐,则系统实际上会在最后映射一个完整的页,从而实际已映射的内存空间比break
指向的地方要大一些。但是使用break
之后的地址是很危险的(尽管也许break
之后确实有一小块可用内存地址)。
4. 资源限制与rlimit
系统对每一个进程所分配的资源不是无限的,包括可映射的内存空间,因此每个进程有一个rlimit
表示当前进程可用的资源上限。这个限制可以通过getrlimit
系统调用得到。
获取当前进程虚拟内存空间的rlimit
:
int main() {
struct rlimit *limit = (struct rlimit *)malloc(sizeof(struct rlimit));
getrlimit(RLIMIT_AS, limit);
printf("soft limit: %ld, hard limit: %ld\n", limit->rlim_cur, limit->rlim_max);
}
其中,rlimit
是一个结构体:
struct rlimit {
rlim_t rlim_cur; /* Soft limit */
rlim_t rlim_max; /* Hard limit (ceiling for rlim_cur) */
};
每种资源有软限制和硬限制,并且可以通过setrlimit
对rlimit
进行有条件设置。其中,硬限制作为软限制的上限,非特权进程只能设置软限制,且不能超过硬限制。
实现malloc
1. 采用的数据结构
首先需要确定所采用的数据结构。一个简单可行的方案是:将堆内空间以块(block)的形式组织起来,每个块由meta
区和数据区组成。其中,meta
区记录数据块的meta information(元信息,数据区的大小、空闲标志位、指针等),数据区是真实分配的内存区域,并且数据区的第一个字节地址即为malloc
返回的地址。
定义一个结构体block
:
typedef struct s_block *t_block;
struct s_block {
size_t size; /* 数据区大小 */
t_block next; /* 指向下个块的指针 */
bool is_free; /* 是否是空闲块 */
int padding; /* 填充4字节,保证meta块长度为8的倍数 */
char data[1] /* 这是一个虚拟字段,表示数据块的第一个字节,长度不应计入meta */
};
由于我们只考虑64位机器,为了方便,我们在结构体最后填充一个int
,使得结构体本身的长度为8
的倍数,以便内存对齐。示意图如下:
2. 寻找合适的block
在block
链中查找合适的block
,一般有两种查找算法:
- first fit:从头开始,查找第一个数据区大小大于或者等于要求
size
的块,作为此次分配的块。 - best fit:从头开始,遍历所有块,查找数据区大小大于
size
且差值最小的块,作为此次分配的块。
best fit具有较高的内存使用率(payload较高),而first fit具有更好的运行效率。此处,采用first fit算法:
/* First fit */
t_block find_block(t_block *cur, size_t size) {
t_block b = first_block;
while(b && !(b->is_free && b->size >= size)) {
*cur = b;
b = b->next;
}
return b;
}
find_block()
从frist_block
开始,查找第一个符合要求的block
,并返回block
起始地址。如果找不到,便返回NULL
。在遍历时会更新一个叫cur
的指针,这个指针始终指向当前遍历的block
。cur
的作用是:如果找不到合适的block
,便需要重新开辟新的block
,这时便会用到它。
3. 开辟新的block
如果现有的block
都不能满足size
的要求,则需要在链表的最后开辟一个新的block
。关键是:如何只使用sbrk
创建一个struct
:
#define BLOCK_SIZE 24 /* 由于存在虚拟的data字段,sizeof不能正确计算meta长度,这里手工设置 */
t_block extend_heap(t_block cur, size_t s) {
t_block b;
b = sbrk(0);
if(sbrk(BLOCK_SIZE + s) == (void *)-1) // 如果从heap上分配内存失败,则直接返回NULL
return NULL;
b->size = s;
b->next = NULL;
if(cur) // 在当前指定的block后插入刚分配的block
cur->next = b;
b->free = 0;
return b;
}
分裂block
First fit有一个比较致命的缺点:可能会让很小的size
占据很大的一块block
。此时,为了提高payload,应该在剩余数据区足够大的情况下,将其分裂为一个新的block
。
示意图如下:
实现为:
void split_block(t_block b, size_t s) {
t_block new_block;
new_block = b->data + s;
new_block->size = b->size - s - BLOCK_SIZE ;
new_block->next = b->next;
new_block->free = 1;
b->size = s;
b->next = new_block;
}
malloc
的实现
由于希望malloc
分配的数据区是按8
字节进行对齐,所以当size
不为8
的倍数时,需要将size
调整为大于size
的最小的8
的倍数。
size_t align(size_t s) {
if(s & 0x7 == 0)
return s;
return ((s >> 3) + 1) << 3;
// return ((s + 7) & -8); ((size_t)s + n-1) & -n);
}
malloc
的具体实现:
#define BLOCK_SIZE 24
void *first_block=NULL;
/* other functions... */
void *malloc(size_t size) {
t_block b, last;
size_t s;
/* 对齐地址 */
s = align(size);
if(first_block) {
/* 查找合适的block */
last = first_block;
b = find_block(&last, s);
if(b) {
/* 如果可以,则分裂 */
if ((b->size - s) >= ( BLOCK_SIZE + 8))
split_block(b, s);
b->free = 0;
} else {
/* 没有合适的block,开辟一个新的 */
b = extend_heap(last, s);
if(!b)
return NULL;
}
} else {
b = extend_heap(NULL, s);
if(!b)
return NULL;
first_block = b;
}
return b->data;
}
calloc
的实现
在内存的动态存储区中分配n个长度为size
的连续空间,函数返回一个指向分配起始地址的指针;如果分配不成功,返回NULL
。
实现calloc
的步骤:
malloc
一段内存- 将数据区内容设置为
0
由于数据区是按8
字节对齐的,所以为了提高效率,可以每8
字节一组置0
,而不是一个一个字节设置。可以通过新建一个size_t
指针,将内存区域强制看做size_t
类型来实现。
void *calloc(size_t n, size_t size) {
size_t *new_block;
size_t s, i;
new_block = malloc(n * size);
if(new_block) {
s = align(n * size) >> 3;
for(i = 0; i < s; i++)
new_block[i] = 0;
}
return new_block;
}
free
的实现
free
的实现需要解决两个关键问题:
- 如何验证所传入的地址是有效地址,即确实是通过
malloc
方式分配的数据区首地址 - 如何解决碎片问题
首先要保证传入free
的地址是有效的,这个有效包括两方面:
- 地址应该在
malloc
所分配的区域内,即在first_block
和当前break
指针范围内 - 这个地址是由之前自己实现的
malloc
分配
第一个问题比较好解决,只需要进行地址比较便可,关键是第二个问题。有两种解决方案:
- 在结构体内设置一个
magic number
字段,free
之前通过相对偏移,检查特定位置的值是否为设置的magic number
- 在结构体内增加一个
magic pointer
,此指针指向数据区的第一个字节(也就是在合法时free
时传入的地址),在free
前检查magic pointer
是否指向参数所指地址。
此处采用第二种方案。首先,在结构体中增加magic pointer
(同时需要修改BLOCK_SIZE
):
typedef struct s_block *t_block;
struct s_block {
size_t size; /* 数据区大小 */
t_block next; /* 指向下个块的指针 */
int free; /* 是否是空闲块 */
int padding; /* 填充4字节,保证meta块长度为8的倍数 */
void *ptr; /* Magic pointer,指向data */
char data[1] /* 这是一个虚拟字段,表示数据块的第一个字节,长度不应计入meta */
};
然后,定义检查地址合法性的函数:
t_block get_block(void *p) {
char *tmp;
tmp = p;
return (p = tmp -= BLOCK_SIZE);
}
int valid_addr(void *p) {
if(first_block) {
if(p > first_block && p < sbrk(0)) {
return p == (get_block(p))->ptr;
}
}
return 0;
}
当多次malloc
和free
后,整个内存池可能会产生很多碎片block
,这些block
很小,经常无法使用,甚至出现许多碎片连在一起,虽然总体能满足某些malloc
要求,但是由于分割成了多个小block
而无法fit
,这就是碎片问题。
一个简单的解决方式是:当free
某个block
时,如果发现它相邻的block
也是free
的,则将此block
和相邻block
进行合并。为了满足这个实现,则需要将s_block
改为双向链表。修改后的block
结构如下:
typedef struct s_block *t_block;
struct s_block {
size_t size; /* 数据区大小 */
t_block prev; /* 指向上个块的指针 */
t_block next; /* 指向下个块的指针 */
int free; /* 是否是空闲块 */
int padding; /* 填充4字节,保证meta块长度为8的倍数 */
void *ptr; /* Magic pointer,指向data */
char data[1] /* 这是一个虚拟字段,表示数据块的第一个字节,长度不应计入meta */
};
合并方法为:
t_block fusion(t_block b) {
if (b->next && b->next->free) {
b->size += BLOCK_SIZE + b->next->size;
b->next = b->next->next;
if(b->next)
b->next->prev = b;
}
return b;
}
有了上述方法,free
的实现思路就比较清晰了:首先检查参数地址的合法性,如果不合法,则不做任何事;否则,将此block
的free
设置为1
,并且在可以的情况下与后面的block
进行合并。如果当前是最后一个block
,则回退break
指针释放进程内存,如果当前block
是最后一个block
,则回退break指针并设置first_block为NULL。实现如下: