Linux 内存-用户空间

2014-09-27 kernel linux

简单介绍下 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,ffff0xffff,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 传统内存布局如下。

memory userspace layout

现在用户空间的内存空间布局如下。

memory userspace layout

从上图可以看到,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 表示,类似如下所示。

memory_process_vma_lists

用户进程的虚拟地址空间包含了若干区域,这些区域的分布方式是特定于体系结构的,不过所有的方式都包含下列成分:

  • 可执行文件的二进制代码,也就是程序的代码段;
  • 存储全局变量的数据段;
  • 用于保存局部变量和实现函数调用的栈;
  • 环境变量和命令行参数;
  • 程序使用的动态库的代码;
  • 用于映射文件内容的区域。

内核中的伙伴系统、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_nextvm_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() 完成:

  1. brk() 是将数据段 (.data) 的最高地址指针 _edata 往高地址推;
  2. mmap() 是在进程的虚拟地址空间中,也就是堆和栈中间 (文件映射区域) 的一块空闲虚拟内存。

如上所述,这两种方式分配的都是虚拟内存,没有分配物理内存,只有在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系。

在标准 C 库中,提供了 malloc() free() 分配释放内存,而这两个函数的底层是由 brk() mmap() munmap() 这些系统调用实现的。

brk() sbrk()

如下是两个函数的声明。

#include <unistd.h>
int brk(void *addr);
void *sbrk(intptr_t increment);

这两个函数都用来改变 “program break” 的位置,如下图所示:

memory userspace layout

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_binprmlinux_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