简单介绍下 Linux 中用户空间的内存管理,包括了内存的布局、内存申请等操作。
内存布局
关于 Linux 的内存分布可以查看内核文档 x86/x86_64/mm.txt,也就是低 128T 为用户空间。
0000000000000000 - 00007fffffffffff (=47 bits) user space, different per mm
在内核中通过 TASK_SIZE_MAX
宏定义,同时还减去了一个页面的大小做为保护。
#define TASK_SIZE_MAX ((1UL << 47) - PAGE_SIZE)
而 0xffff,8000,0000,0000
以上为系统空间地址;注意:该地址的高 16bits 是 0xffff
,这是因为目前实际上只用了 64 位地址中的 48 位,也就是高 16 位没有使用,而从地址 0x0000,7fff,ffff,ffff
到 0xffff,8000,0000,0000
中间是一个巨大的空洞,是为以后的扩展预留的。
而真正的系统空间的起始地址,是从 0xffff,8800,0000,0000
开始的,参见:
#define __PAGE_OFFSET _AC(0xffff,8800,0000,0000, UL)
而 32 位地址时系统空间的起始地址为 0xC000,0000
。
ffff800000000000 - ffff87ffffffffff (=43 bits) guard hole, reserved for hypervisor
ffff880000000000 - ffffc7ffffffffff (=64 TB) direct mapping of all phys. memory
ffffc80000000000 - ffffc8ffffffffff (=40 bits) hole
ffffc90000000000 - ffffe8ffffffffff (=45 bits) vmalloc/ioremap space
布局详解
内核中有两个参数影响了内存的布局,如下。
----- 查看当前配置
$ cat /proc/sys/vm/legacy_va_layout
$ cat /proc/sys/kernel/randomize_va_space
----- 配置参数
# sysctl -w vm.legacy_va_layout=0
# sysctl -w kernel.randomize_va_space=2
----- 可以修改参数后通过如下方式测试
$ cat /proc/self/maps
00400000-0040b000 r-xp 00000000 08:07 786680 /usr/bin/cat
0060b000-0060c000 r--p 0000b000 08:07 786680 /usr/bin/cat
0060c000-0060d000 rw-p 0000c000 08:07 786680 /usr/bin/cat
011ed000-0120e000 rw-p 00000000 00:00 0 [heap]
7f924042c000-7f9246955000 r--p 00000000 08:07 793717 /usr/lib/locale/locale-archive
7f9246955000-7f9246b0c000 r-xp 00000000 08:07 793610 /usr/lib64/libc-2.17.so
7f9246b0c000-7f9246d0b000 ---p 001b7000 08:07 793610 /usr/lib64/libc-2.17.so
7f9246d0b000-7f9246d0f000 r--p 001b6000 08:07 793610 /usr/lib64/libc-2.17.so
7f9246d0f000-7f9246d11000 rw-p 001ba000 08:07 793610 /usr/lib64/libc-2.17.so
7f9246d11000-7f9246d16000 rw-p 00000000 00:00 0
7f9246d16000-7f9246d36000 r-xp 00000000 08:07 793718 /usr/lib64/ld-2.17.so
7f9246f19000-7f9246f1c000 rw-p 00000000 00:00 0
7f9246f34000-7f9246f35000 rw-p 00000000 00:00 0
7f9246f35000-7f9246f36000 r--p 0001f000 08:07 793718 /usr/lib64/ld-2.17.so
7f9246f36000-7f9246f37000 rw-p 00020000 08:07 793718 /usr/lib64/ld-2.17.so
7f9246f37000-7f9246f38000 rw-p 00000000 00:00 0
7ffd158bf000-7ffd158e0000 rw-p 00000000 00:00 0 [stack]
7ffd15945000-7ffd15947000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]
新旧布局
不同之处在于 MMAP 区域的增长方向,新布局导致了栈空间的固定,而堆区域和 MMAP 区域公用一个空间,这在很大程度上增长了堆区域的大小。
Linux 传统内存布局如下。
现在用户空间的内存空间布局如下。
从上图可以看到,mmap 映射区域至顶向下扩展,mmap 映射区域和堆相对扩展,直至耗尽虚拟地址空间中的剩余区域,弥补了经典内存布局方式的不足。
为了使用此新的内存布局,执行命令 sysctl -w vm.legacy_va_layout=0
,然后重新编译运行程序并查看其输出及 maps 文件内容。
内核相关
利用虚拟内存,每个进程相当于占用了全部的内存空间,所有和进程相关的信息都保存在内存描述符 (memory descriptor) 中,也就是 struct mm_struct *mm
;在进程的进程描述符 struct task_struct
中的 mm 域记录该进程使用的内存描述符,也就是说 current->mm
代表当前进程的内存描述符。
struct mm_struct {
struct vm_area_struct *mmap; // 指向内存区域对象,链表形式存放,利于高效地遍历所有元素
struct rb_root mm_rb; // 与mmap表示相同,以红黑树形式存放,适合搜索指定元素
pgd_t *pgd; // 指向页目录
atomic_t mm_count; // 主引用计数,为0时结构体会被撤销
atomic_t mm_users; // 从计数器,代表正在使用该地址的进程数目
int map_count; // vma的数目
struct list_head mmlist; // 所有mm_struct都通过mmlist连接在一个双向链表中,
// 该链表的首元素是init_mm内存描述符,代表init进程的地址空间
};
剩下字段中包含了该进程的代码段、数据段、堆栈、命令行参数、环境变量的起始地址和终止地址;内存描述符在系统中通过 mmlist
组织成了一个双向链表,这个链表的首元素是 init_mm.mmlist
。
其中 mm_count 和 mm_users 用来表示是否还有进程在使用该内存,这两个是比较重要的字段,决定了该进程空间是否仍被使用。
一个进程的内存空间如上图所示,其中各个内存区域通过 vm_area_struct
表示,类似如下所示。
用户进程的虚拟地址空间包含了若干区域,这些区域的分布方式是特定于体系结构的,不过所有的方式都包含下列成分:
- 可执行文件的二进制代码,也就是程序的代码段;
- 存储全局变量的数据段;
- 用于保存局部变量和实现函数调用的栈;
- 环境变量和命令行参数;
- 程序使用的动态库的代码;
- 用于映射文件内容的区域。
内核中的伙伴系统、SLAB 分配器都是尽快响应内核请求,而对于用户空间的请求略有不同。
用户空间动态申请内存时,往往只是获得一块线性地址的使用权,而并没有将这块线性地址区域与实际的物理内存对应上,只有当用户空间真正操作申请的内存时,才会触发一次缺页异常,这时内核才会分配实际的物理内存给用户空间。
结构体
Linux 内核中,关于虚存管理的最基本的管理单元是虚拟内存区域 (Virtual Memory Areas, vma),通过 struct vm_area_struct
表示,它描述了一段连续的、具有相同访问属性 (可读、可写、可执行等等) 的虚存空间,该虚存空间的大小为物理内存页面的整数倍。
struct vm_area_struct { // include/linux/mm_types.h
struct mm_struct * vm_mm; // 反向指向该进程所属的内存描述符
unsigned long vm_start, vm_end; // 虚存空间的首地址,末地址后第一个字节的地址
struct vm_area_struct *vm_next, *vm_prev; // 每个进程的VM空间链表,按地址排序,用于遍历
struct rb_node vm_rb; // 红黑树中对应的节点,用于快速定位
pgprot_t vm_page_prot; // vma的访问控制权限
unsigned long vm_flags; // 保护标志位和属性标志位,共享(0)还是独有(1)
const struct vm_operations_struct *vm_ops; // 该vma上的各种标准操作函数指针集
unsigned long vm_pgoff; // 文件映射偏移,以PAGE_SIZE为单位
struct file * vm_file; // 如果是文件映射,则指向文件描述符
void * vm_private_data; // 设备驱动私有数据,与内存管理无关
};
该结构体描述了 [vm_start, vm_end)
的内存空间,以字节为单位。通常,进程所使用到的虚存空间不连续,且各部分虚存空间的访问属性也可能不同,所以一个进程的虚存空间需要多个 vm_area_struct
结构来描述。
在 vm_area_struct
较少时,各个结构体按照升序排序,通过 vm_next
、vm_prev
以链表的形式组织数据;但当数据较多时,实现了 AVL 树,以提高 vm_area_struct
的搜索速度。
查看进程内存空间
可以通过 cat /proc/<pid>/maps
或者 pmap <pid>
查看。
内存申请
在用户空间中会通过 malloc()
动态申请内存,而实际上,在用户空间中对应了不同的实现方式,包括了 ptmalloc
(glibc)、tcmalloc
(Google) 以及 jemalloc
,接下来简单介绍这三种内存分配方式。
ptmalloc
的早期版本是由 Doug Lea 实现的,它有一个重要问题就是无法保证线程安全,Wolfram Gloger 改进了其实现从而支持多线程;TCMalloc (Thread-Caching Malloc) 是 google 开发的开源工具 google-perftools
之一。
简介
从操作系统角度来看,进程分配内存有两种方式,分别由系统调用 brk() 和 mmap() 完成:
- brk() 是将数据段 (
.data
) 的最高地址指针_edata
往高地址推; - mmap() 是在进程的虚拟地址空间中,也就是堆和栈中间 (文件映射区域) 的一块空闲虚拟内存。
如上所述,这两种方式分配的都是虚拟内存,没有分配物理内存,只有在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系。
在标准 C 库中,提供了 malloc()
free()
分配释放内存,而这两个函数的底层是由 brk()
mmap()
munmap()
这些系统调用实现的。
brk() sbrk()
如下是两个函数的声明。
#include <unistd.h>
int brk(void *addr);
void *sbrk(intptr_t increment);
这两个函数都用来改变 “program break” 的位置,如下图所示:
sbrk/brk 是从堆中分配空间,实际上就是移动一个位置,也就是 Program Break
,这个位置定义了进程数据段的终止处,增大就是分配空间,减小就是释放空间。
sbrk 用相对的整数值确定位置,如果这个整数是正数,会从当前位置向后移若干字节,如果为负数就向前若干字节,为 0 时获取当前位置;而 brk 则使用绝对地址。
#include <stdio.h>
#include <unistd.h>
int main(int argc, char **argv)
{
void *p0 = sbrk(0), *p1, *p2;
printf("P0 %p\n", p0);
brk(p0 + 4); // 分配4字节
p1 = sbrk(0);
printf("P1 %p\n", p1);
p2 = sbrk(4);
printf("P2=%p\n", p2);
return 0;
}
进程加载
execve()
系统调用可用于加载一个可执行文件并代替当前的进程,它在 libc
库中有几个 API 封装:execl()
、execve()
、execlp()
、execvp()
。这几个函数的功能相同,只是参数不同,在内核中统一调用 sys_execve()
。
以 execve()
函数为例,创建一个进程,参考如下。
#include <stdio.h>
#include <unistd.h>
int main(int arg,char **args)
{
char *argv[]={"ls", "-l", "/tmp", NULL};
char *envp[]={0, NULL};
execve("/bin/ls", argv, envp);
return 0;
}
系统为进程运行初始化环境,无非就是完成内存分配和映射以及参数和数据段、代码段和 bss 等的载入,以及对调用 exec 的旧进程进行回收。
其中有两个可执行文件相关的数据结构,分别是 linux_binprm
和 linux_binfmt
,内核中为可执行程序的装入定义了一个数据结构 linux_binprm
,以便将运行一个可执行文件时所需的信息组织在一起。
而 linux_binfmt
用表示每一个加载模块,这个结构在系统中组成了一个链表结构。
struct linux_binprm {
char buf[BINPRM_BUF_SIZE]; // 保存可执行文件的头部
struct page *page[MAX_ARG_PAGES]; // 每个参数最大使用一个物理页来存储,最大为32个页面
struct mm_struct *mm; // 暂时存储新进程的可执行文件名、环境变量等
unsigned long p; // 当前内存的起始地址
int argc, envc; // 参数变量和环境变量的数目
const char * filename; // procps查看到的名称
};
struct linux_binfmt {
struct list_head lh; // 用于形成一个列表
struct module *module; // 定义该函数所属的模块
int (*load_binary)(struct linux_binprm *); // 加载可执行文件
int (*load_shlib)(struct file *); // 加载共享库
int (*core_dump)(struct coredump_params *cprm); // core dump
unsigned long min_coredump;
};
程序的加载主要分为两步:A) 准备阶段,将参数读如内核空间、判断可执行文件的格式、并选择相应的加载器;B) 载入阶段,完成对新进程代码段、数据段、BSS 等信息的载入。
进程创建
如上所述,实际调用的是内核中的 sys_execve()
函数,简单介绍如下。
sys_execve() @ fs/exec.c
|-getname() 将文件名从用户空间复制到内核空间
| |-getname_flags()
| |-__getname() 为文件名分配一个缓冲区
| |-strncpy_from_user()
| |-do_strncpy_from_user()
|-do_execve()
|-do_execve_common()
|-unshare_files()
|-do_open_exec()
| |-do_filp_open()
|-bprm_mm_init() bprm初始化,主要是bprm->mm
| |-mm_alloc()
| |-init_new_context()
| |-__bprm_mm_init()
| |-kmem_cache_zalloc() 分配一个vma
| |-... ... 设置用户空间对应的栈顶STACK_TOP_MAX
| |-insert_vm_struct() 将vma插入mm表示的进程空间结构
|-count() ... ... 计算参数个数、环境变量个数
|-prepare_binprm() 查看权限并加载128(BINPRM_BUF_SIZE)个字节
| |-kernel_read() 读取128字节
|-search_binary_handler() 整个函数的处理核心
|-security_bprm_check() SELinux检查函数
|-fmt->load_binary() 加载二进制文件,不同二进制格式对应了不同回调函数
|-load_elf_binary() elf对应load_elf_binary
|-start_thread() 不同平台如x86会调用不同的函数
|-start_thread_common() 主要是设置寄存器的值
其中有个全局变量 formats
作为链头,可以通过 register_binfmt()
注册一个可执行文件的加载模块,该模块一般在 fs/binfmt_xxx.c
文件中,每次加载可执行文件时只需要遍历 formats 变量即可。
static LIST_HEAD(formats);
void __register_binfmt(struct linux_binfmt * fmt, int insert)
{
BUG_ON(!fmt);
if (WARN_ON(!fmt->load_binary))
return;
write_lock(&binfmt_lock);
insert ? list_add(&fmt->lh, &formats) :
list_add_tail(&fmt->lh, &formats);
write_unlock(&binfmt_lock);
}
static inline void register_binfmt(struct linux_binfmt *fmt)
{
__register_binfmt(fmt, 0);
}
在 Linux 中最常用的是 ELF 为例,启动时通过 core_initcall(init_elf_binfmt)
初始化。
内存复制
在调用 fork()
函数时,会通过 copy_mm()
复制父进程的内存描述符,子进程通过 allcote_mm()
从高速缓存中分配 struct mm_struct
得到。通常,每个进程都有唯一的 struct mm_struct
,即唯一的进程地址空间。
当子进程与父进程是共享地址空间,可调用 clone()
此时不再调用 allcote_mm()
,而是仅仅是将 mm 域指向父进程的 mm ,即 task->mm = current->mm
。