Linux虚拟内存管理

本文主要目的是尽可能分析linux虚拟内存管理部分的源码。

linux虚拟地址空间的划分

在64位机上,虚拟地址的结构为:全局页目录项(9位)+ 上层页目录项(9位)+ 中间页目录项(9位)+ 页表项(9位)+ 页内偏移(12位)。共 48 位组成的虚拟内存地址。64位地址从0x0000 0000 0000 0000到0xFFFF FFFF FFFF FFFF,其中0x0000 0000 0000 0000-0x0000 7FFF FFFF FFFF是用户虚拟地址空间,0xFFF 8000 0000 0000-0xFFFF FFFF FFFF FFFF是内核虚拟地址空间。这占用了48位的地址位数,剩下的区域称为 canonical address 空洞。不难发现,用户空间的高十六位地址为全0,内核空间的高16位地址为全1。

用户虚拟地址空间的划分

1.png
在 ./include/linux/sched.h 中定义了task_struct,在这之中包含了一个mm_struct

1
2
3
4
5
struct task_struct {
......
struct mm_struct *mm; <- 定义了用户虚拟地址空间的全部信息
......
}

每个进程都有唯一的 mm_struct 结构体,即每个进程的虚拟地址空间都是独立,互不干扰。mm_struct定义在./include/linux/mm_types.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct mm_struct {
......

unsigned long mmap_base; /* base of mmap area ,内存映射区的起始地址,保存了进程运行时所依赖的动态链接库中的代码段,数据段,BSS 段以及调用 mmap 映射出来的一段虚拟内存空间*/

unsigned long task_size; /* size of task vm space */
unsigned long start_code, end_code, start_data, end_data;/*代码段、数据段*/
unsigned long start_brk, brk, start_stack;/*堆栈起始位置,使用 malloc 申请小块内存时(低于 128K),就是通过改变 brk 位置调整堆大小实现的。*/
unsigned long arg_start, arg_end, env_start, env_end;/*参数列表起止,环境变量起止*/

unsigned long total_vm; /* 进程虚拟内存空间中总共与物理内存映射的页的总数 */
unsigned long locked_vm; /* 被锁定不能换出的内存页总数 */
atomic64_t pinned_vm; /* 既不能换出,也不能移动的内存页总数 */
unsigned long data_vm; /* 数据段中映射的内存页数目 */
unsigned long exec_vm; /* 代码段中存放可执行文件的内存页数目 */
unsigned long stack_vm; /* 栈中所映射的内存页数目 */
......
}

这些字段定义了代码段、数据段的起始位置和结束位置,以及堆栈的起始位置。kernel/fork.c中, pid_t kernel_clone(struct kernel_clone_args *args) 负责进行fork操作,其中 p = copy_process(NULL, trace, NUMA_NO_NODE, args);会给子进程拷贝父进程的一些信息,这个函数又会调用 retval = copy_mm(clone_flags, p); 其函数实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
struct mm_struct *mm, *oldmm;

tsk->min_flt = tsk->maj_flt = 0;
tsk->nvcsw = tsk->nivcsw = 0;
#ifdef CONFIG_DETECT_HUNG_TASK
tsk->last_switch_count = tsk->nvcsw + tsk->nivcsw;
tsk->last_switch_time = 0;
#endif

tsk->mm = NULL;
tsk->active_mm = NULL;

/*
* Are we cloning a kernel thread?
*
* We need to steal a active VM for that..
*/
oldmm = current->mm;
if (!oldmm)
return 0;

if (clone_flags & CLONE_VM) {
mmget(oldmm); <-----增加父进程虚拟地址空间的引用计数
mm = oldmm; <-----两个指针指向同一片内存区域,父进程与子进程共享mm_struct,此时子进程就是线程(通过vfork clone创建)
} else {
mm = dup_mm(tsk, current->mm); <-----父进程的虚拟内存空间以及相关页表拷贝到子进程的 mm_struct
if (!mm)
return -ENOMEM;
}

tsk->mm = mm;
tsk->active_mm = mm;
sched_mm_cid_fork(tsk);
return 0;
}

4.png

用户虚拟地址空间和内核虚拟地址空间如何进行分隔

这两段地址是怎么在代码层面进行划分的呢?这由task_size字段决定。x86系统中,这个值被赋值为TASK_SIZE。在64位中其定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define TASK_SIZE		(test_thread_flag(TIF_ADDR32) ? \   <-----arch/x86/include/asm/page_64_types.h
IA32_PAGE_OFFSET : TASK_SIZE_MAX)


static __always_inline unsigned long task_size_max(void) <-----arch/x86/include/asm/page_64.h
{
unsigned long ret;

alternative_io("movq %[small],%0","movq %[large],%0",
X86_FEATURE_LA57,
"=r" (ret),
[small] "i" ((1ul << 47)-PAGE_SIZE), <-----0x00007FFFFFFFF000 标准用户空间
[large] "i" ((1ul << 56)-PAGE_SIZE)); <-----扩展用户空间

return ret;
}

可以看出,task_size的值设置成了用户虚拟内存空间的边界值,这样就和内核的虚拟地址空间划分开了。同时,内核对task_size有一系列的检查措施防止进程访问到它不该访问的区域。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// arch/x86/mm/fault.c
static void sanitize_error_code(unsigned long address,
unsigned long *error_code)
{
/*
* To avoid leaking information about the kernel page
* table layout, pretend that user-mode accesses to
* kernel addresses are always protection faults.
*
* NB: This means that failed vsyscalls with vsyscall=none
* will have the PROT bit. This doesn't leak any
* information and does not appear to cause any problems.
*/
if (address >= TASK_SIZE_MAX) <-----检查访问的地址是否大于最大进程虚拟地址空间
*error_code |= X86_PF_PROT;
}

以及x86的页表有一个U/S位,U/S = 1为用户页,U/S = 0为内核页。用户态运行在ring3,当访问到U/S = 0的内核页表时cpu就会检测到这种问题,就会出现页错误,交付内核来处理。关于缺页处理,后续会单独写一下。

内核对用户虚拟地址空间的管理

vm_area_struct

每个虚拟内存区域(如代码段、数据段等)由一个struct vm_area_struct 进行管理。首先明确一下这个结构体和mm_struct的区别和联系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
区别
表示的概念不同:
mm_struct:表示整个进程的内存管理信息,每个进程只有一个
vm_area_struct:表示进程地址空间中的一个连续区域,一个进程通常有多个
包含的信息不同:
mm_struct 包含进程整体内存信息:页表根目录、内存使用统计、内存策略等
vm_area_struct 包含特定内存区域的属性:起止地址、访问权限、映射文件等
粒度不同:
mm_struct 是粗粒度的,管理整个进程的地址空间
vm_area_struct 是细粒度的,管理特定区域(如代码段、数据段、堆、栈等)
联系
从属关系:
vm_area_struct 通过 vm_mm 字段指向所属的 mm_struct
mm_struct 通过 mm_mt(maple tree)组织和管理所有的 vm_area_struct
协同工作:
mm_struct 维护进程整体内存视图和页表
vm_area_struct 定义各区域的具体属性和行为
生命周期:
mm_struct 在进程创建时创建,进程结束时销毁
vm_area_struct 在内存映射(如 mmap)创建时创建,取消映射时销毁
内存操作:
当发生页错误时,系统先通过 mm_struct 找到对应的 vm_area_struct
然后根据 vm_area_struct 的属性决定如何处理(如加载文件、分配匿名页等)
简而言之,mm_struct 是进程级的内存管理器,而 vm_area_struct 是区域级的内存描述符,两者共同协作完成进程的虚拟内存管理。(这些是claude说的)

这个结构体比较重要的字段如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct vm_area_struct {

 unsigned long vm_start;  /* Our start address within vm_mm. */
 unsigned long vm_end;  /* The first byte after our end address
        within vm_mm. */ <-----描述的是 [vm_start,vm_end) 这样一段左闭右开的虚拟内存区域
 /*
  * Access permissions of this VMA.
  */
 pgprot_t vm_page_prot;
 unsigned long vm_flags; 
struct mm_struct *vm_mm;

 struct anon_vma *anon_vma; /* Serialized by page_table_lock */
    struct file * vm_file;  /* File we map to (can be NULL). */
 unsigned long vm_pgoff;  /* Offset (within vm_file) in PAGE_SIZE
        units */ 
 void * vm_private_data;  /* was vm_pte (shared mem) */
 /* Function pointers to deal with this struct. */
 const struct vm_operations_struct *vm_ops;
}

vm_start和vm_end描述的是 [vm_start,vm_end) 这样一段左闭右开的虚拟内存区域,也就如下图所示,可见每段虚拟内存空间对应一个vm_area_struct。
2.png
vm_page_prot和vm_flags与权限有关,简单来说vm_flags偏向于整个虚拟内存区域的访问权限和规范,而vm_page_prot偏向于页表中关于内存页的访问权限。虚拟内存区域也是由很多页表组成的,设置vm_flags可以对整个虚拟内存区域的权限值进行配置,即对应的所有虚拟页都会被设置为相应的权限位。常见的权限如下图所示:
3.png
anon_vma,vm_file,vm_pgoff三个属性和虚拟内存映射相关。虚拟内存区域既可以映射到物理内存上,也可以映射到文件上,映射到物理内存成为匿名映射,映射到文件上称为文件映射。申请malloc来分配内存时,如果申请的内存小于128KB则会使用do_brk() 系统调用调整brk指针的位置来分配和回收堆的内存。如果大于128KB,则会调用mmap系统调用从文件与匿名映射区中创建出一块 VMA 匿名映射内存区域。这块匿名映射区域就用 struct anon_vma 结构表示。当调用 mmap 进行文件映射时,vm_file 属性就用来关联被映射的文件。这样一来虚拟内存区域就与映射文件关联了起来。vm_pgoff 则表示映射进虚拟内存中的文件内容,在文件中的偏移。

匿名映射和文件映射

文件映射允许将一个文件或者文件的一部分映射到进程的虚拟地址空间,当进程对这部分内存区域进行读写等操作时,系统会自动把更改写回磁盘文件。文件映射允许进程像访问普通内存一样对文件进行操作而不需要使用read,write等系统调用对文件进行操作,这样的好处是可以提高文件操作的效率。

匿名映射是仅仅将物理内存区域映射到进程虚拟地址空间中,通常用于进程间的共享内存,方便进程间的通信。

一个文件可能被多个进程通过mmap映射后访问并修改,根据所做的修改是否对其他进程可见,mmap可分为共享映射和私有映射两种。共享映射则允许多个进程映射同一片物理内存,使得多个进程能够共享同一份数据。共享映射下的数据,如果被一个进程修改,其他进程也可以看到修改后的结果。这种映射方式主要用于实现共享内存、文件映射等功能。私有映射是指进程的修改对其他进程不可见。初始时多个进程映射同一片内存区域,当一个进程进行修改操作时会触发cow(写时复制)机制来进行处理。

虚拟内存区域的相关操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
./include/linux/mm.h
/*
* These are the virtual MM functions - opening of an area, closing and
* unmapping it (needed to keep files on disk up-to-date etc), pointer
* to the functions called when a no-page or a wp-page exception occurs.
*/
struct vm_operations_struct {
void (*open)(struct vm_area_struct * area);
/**
* @close: Called when the VMA is being removed from the MM.
* Context: User context. May sleep. Caller holds mmap_lock.
*/
void (*close)(struct vm_area_struct * area);
/* Called any time before splitting to check if it's allowed */
int (*may_split)(struct vm_area_struct *area, unsigned long addr);
int (*mremap)(struct vm_area_struct *area);
/*
* Called by mprotect() to make driver-specific permission
* checks before mprotect() is finalised. The VMA must not
* be modified. Returns 0 if mprotect() can proceed.
*/
int (*mprotect)(struct vm_area_struct *vma, unsigned long start,
unsigned long end, unsigned long newflags);
vm_fault_t (*fault)(struct vm_fault *vmf);
vm_fault_t (*huge_fault)(struct vm_fault *vmf, unsigned int order);
vm_fault_t (*map_pages)(struct vm_fault *vmf,
pgoff_t start_pgoff, pgoff_t end_pgoff);
unsigned long (*pagesize)(struct vm_area_struct * area);

/* notification that a previously read-only page is about to become
* writable, if an error is returned it will cause a SIGBUS */
vm_fault_t (*page_mkwrite)(struct vm_fault *vmf);

/* same as page_mkwrite when using VM_PFNMAP|VM_MIXEDMAP */
vm_fault_t (*pfn_mkwrite)(struct vm_fault *vmf);

/* called by access_process_vm when get_user_pages() fails, typically
* for use by special VMAs. See also generic_access_phys() for a generic
* implementation useful for any iomem mapping.
*/
int (*access)(struct vm_area_struct *vma, unsigned long addr,
void *buf, int len, int write);

/* Called by the /proc/PID/maps code to ask the vma whether it
* has a special name. Returning non-NULL will also cause this
* vma to be dumped unconditionally. */
const char *(*name)(struct vm_area_struct *vma);

#ifdef CONFIG_NUMA
/*
* set_policy() op must add a reference to any non-NULL @new mempolicy
* to hold the policy upon return. Caller should pass NULL @new to
* remove a policy and fall back to surrounding context--i.e. do not
* install a MPOL_DEFAULT policy, nor the task or system default
* mempolicy.
*/
int (*set_policy)(struct vm_area_struct *vma, struct mempolicy *new);

/*
* get_policy() op must add reference [mpol_get()] to any policy at
* (vma,addr) marked as MPOL_SHARED. The shared policy infrastructure
* in mm/mempolicy.c will do this automatically.
* get_policy() must NOT add a ref if the policy at (vma,addr) is not
* marked as MPOL_SHARED. vma policies are protected by the mmap_lock.
* If no [shared/vma] mempolicy exists at the addr, get_policy() op
* must return NULL--i.e., do not "fallback" to task or system default
* policy.
*/
struct mempolicy *(*get_policy)(struct vm_area_struct *vma,
unsigned long addr, pgoff_t *ilx);
#endif
/*
* Called by vm_normal_page() for special PTEs to find the
* page for @addr. This is useful if the default behavior
* (using pte_page()) would not find the correct page.
*/
struct page *(*find_special_page)(struct vm_area_struct *vma,
unsigned long addr);
};

对虚拟内存区域的操作使用了一系列函数指针来进行,struct vm_operations_struct 结构中定义的都是对虚拟内存区域 VMA 的相关操作函数指针。当指定的虚拟内存区域被加入到进程虚拟内存空间中时,open 函数会被调用。当虚拟内存区域 VMA 从进程虚拟内存空间中被删除时,close 函数会被调用。当进程访问虚拟内存时,访问的页面不在物理内存中,可能是未分配物理内存也可能是被置换到磁盘中,这时就会产生缺页异常,fault 函数就会被调用。当一个只读的页面将要变为可写时,page_mkwrite 函数会被调用。对虚拟内存区域进行的操作都是通过这里进行的。

虚拟地址空间的组织

以前内核对虚拟内存区域是使用双向链表和rbtree来进行管理的,但是现在的内核用了maple tree这样一个新的数据结构来进行管理:

1
2
3
4
5
6
7
8
struct maple_tree {
union {
spinlock_t ma_lock;
lockdep_map_p ma_external_lock;
};
void __rcu *ma_root;
unsigned int ma_flags;
};

对于这个数据结构不是(目前的)研究重点,以后再看一下。

二进制文件的加载

程序编译之后会创建一个elf二进制文件,程序运行之前会加载到内存,elf会被加载到内存中,然后Section会被映射到相应的Segment中。执行加载的函数是 load_elf_binary 。这个函数的用处很大,可以加载内核,启动init进程,调用exec运行二进制程序。它可以解析elf文件的格式以及建立内存映射。关于这个函数在未来会单独分析。所以这个函数把elf程序加载到内存中,用户就可以运行这个elf程序了。

内核虚拟地址空间

内核同样也运行在虚拟地址空间中。内核态的虚拟地址空间是所有进程共享的,也就是说进入内核态的程序看到的虚拟地址空间都是一样的,但是用户虚拟地址空间由于隔离性,不同的进程访问相同的虚拟地址看到的数据也是不同的。内核虚拟地址空间布局如下:
5.png