计算机的计算,一方面说的是进程、线程对于CPU的使用,另一方面是对于内存的管理.
在Linux中用户态是没有权限直接操作物理内存的,与硬件相关的交互都是通过系统调用由内核来完成操作的。Linux抽象出虚拟内存,用户态操作的只是虚拟内存,真正操作的物理内存由内核内存管理模块管理。本文通篇都在探索三个问题:
- 虚拟地址空间是如何管理的
- 物理地址空间是如何管理的
- 虚拟地址空间和物理地址空间是如何映射的
上述三个问题得到解决之后,我们就可通过一个虚拟地址空间找到对应的物理地址空间。我们首先来看一下Linux虚拟地址空间的管理。
1. 虚拟地址空间的管理
问:是不是用户态使用虚拟内存,内核态直接使用物理内存呢?
答:不是的,内核态和用户态使用的都是虚拟内存。
使用虚拟地址一个核心的问题,需要记录虚拟地址到物理地址的映射,最简单的方式是虚拟地址与物理地址一一对应,这样4G内存光是维护映射关系就需要4G(扯淡)。所以需要其他有效的内存管理方案。通常有两种:分段、分页。
下面我们来一起分析一下这两种管理机制以及在Linux中是如何应用的。
分段
8086升级到80386之后,段寄存器CS、DS、SS、ES从直接存放地址变成高位存放段选择子,低位做段描述符缓存器。由原来的直接使用内存地址变为现在的通过分段机制来使用内存地址。那我们先来看一下内存管理中分段机制的原理。
分段机制下虚拟地址由两部分组成,段选择子和段内偏移量。段选择子中的段号作为段表的索引,通过段号可以在段表找到对应段表项,每一项记录了一段空间:段基址、段的界限、特权级等。用段基址+段内偏移量就可以计算出对应的物理地址。Linux中段表称为段描述符表,放在全局描述符表中,用GDT_ENTRY_INIT函数来初始化表项desc_struct。下面是Linux中段选择子和段表的定义,看一下所有段表项初始化传入的参数中,段基址base都是0,这没有分段。事实上Linux中没有用到全部的分段功能,对于内存管理更倾向于分页机制。
#define GDT_ENTRY_KERNEL32_CS 1 #define GDT_ENTRY_KERNEL_CS 2 #define GDT_ENTRY_KERNEL_DS 3 #define GDT_ENTRY_DEFAULT_USER32_CS 4 #define GDT_ENTRY_DEFAULT_USER_DS 5 #define GDT_ENTRY_DEFAULT_USER_CS 6 DEFINE_PER_CPU_PAGE_ALIGNED(struct gdt_page, gdt_page) = { .gdt = { #ifdef CONFIG_X86_64 [GDT_ENTRY_KERNEL32_CS] = GDT_ENTRY_INIT(0xc09b, 0, 0xfffff), [GDT_ENTRY_KERNEL_CS] = GDT_ENTRY_INIT(0xa09b, 0, 0xfffff), [GDT_ENTRY_KERNEL_DS] = GDT_ENTRY_INIT(0xc093, 0, 0xfffff), [GDT_ENTRY_DEFAULT_USER32_CS] = GDT_ENTRY_INIT(0xc0fb, 0, 0xfffff), [GDT_ENTRY_DEFAULT_USER_DS] = GDT_ENTRY_INIT(0xc0f3, 0, 0xfffff), [GDT_ENTRY_DEFAULT_USER_CS] = GDT_ENTRY_INIT(0xa0fb, 0, 0xfffff), #else [GDT_ENTRY_KERNEL_CS] = GDT_ENTRY_INIT(0xc09a, 0, 0xfffff), [GDT_ENTRY_KERNEL_DS] = GDT_ENTRY_INIT(0xc092, 0, 0xfffff), [GDT_ENTRY_DEFAULT_USER_CS] = GDT_ENTRY_INIT(0xc0fa, 0, 0xfffff), [GDT_ENTRY_DEFAULT_USER_DS] = GDT_ENTRY_INIT(0xc0f2, 0, 0xfffff), ...... #endif } }; EXPORT_PER_CPU_SYMBOL_GPL(gdt_page);
分页
分页机制和分段机制差不多,都是将物理地址分块。不同的是分段一般将内存大段大段的分割且每段大小一般不相同。而分页将物理内存分成一块块大小相同的页,一般大小为4KB。
在分页机制下,虚拟地址有两部分组成(两部分不是严格的两段,比如页号就可以有多级页号):页号、页内偏移量。通过页号找到对应页表项,页表项高位存了物理页号,低位存储了FLAGS。例如:页大小为4KB,只分一级,32位环境中虚拟地址为32位, 可以分1M个页,用20位可以表示页号,12位表示页内偏移。 页表项大小为4B(32位),那么页表大小就是 ,因为每个进程都有自己独立的虚拟地址空间,有100个进程的话光维护页表就需要100MB的空间,这个对于内核来说有点太大了。
问:Linux是如何解决页表太大的问题呢?
答:采用多级分页的策略才能解决页表太大的问题。
32位环境中,一级分页和上边描述的一样,分成1M个4KB的页,由页表维护虚拟页号到物理页号的映射。内核在这次分页之后,又对页表进行分页。页表大小为4MB,我们在按照4KB一页进行分页,4KB包含页表项1K项。所以二级分页就是把页表1M的项按照1K项为一页分了1K页。
二级分页后,虚拟地址就被分成三部分:页目录号、页表内偏移、页内偏移。通过虚拟地址查找物理地址时:
- 通过虚拟地址前10位的页目录号找到对应页目录项,这个页目录项管理了1K个页表项。
- 通过虚拟地址中10位的页表内偏移,从1K个页表项中定位到一个页表项。这个页表项里有物理页号和各种标志位。
- 物理页号+虚拟地址中后12位的页内偏移得到对应物理地址。
问:这样用于维护分页机制的额外空间就是页表(4MB)+ 页表目录(4KB),这不是比一级分页更高了吗?
答:实际不是的,
1.如果使用一级页表,那么每个进程都需要一个页表来维护虚拟地址空间,就是说100个进程需要额外400 MB的空间。
2.如果使用二级页表,每个进程必须的是一个4KB的页目录表。当然并不是每个进程都是用全部4GB内存的。所以4MB的二级页表不会全部使用,用到多少地址就建多少个页表项。所以实际需要额外空间为4KB+使用的页表项数量*4KB。
当然64位的环境中,二级页表就不够了,使用的是四级页表:
全局页目录项 PGD(Page Global Directory)、
上层页目录项 PUD(Page Upper Directory)、
中间页目录项 PMD(Page Middle Directory)、
虽然多级分页解决了页表过大的问题,但是同时也增大了访问延时,由原来的一次访问内存,变为现在访问多次页表之后才能访问目的地址。到目前为止,我们已经知道如何通过一个虚拟地址得到对应的物理地址。
2. 进程的虚拟地址空间
接下来我们再一起看一下进程内的虚拟地址空间是什么样的。Linux中没有进程线程的区别,用struct task_struct表示任务。那么我们可以分析struct task_struct中内存相关变量来分析进程的虚拟内存布局。
struct task_struct { ... struct mm_struct *mm; ... };
struct task_struct里面struct mm_struct来管理内存。首先,既然分析用户态的基本布局,当然要知道用户态和内核态的界限在哪,struct mm_struct里面的task_size变量表示用户态空间的大小。使用系统调用execve加载二进制文件的调用链是do_execve -> do_execveat_common -> bprm_execve -> exec_binprm -> search_binary_handler -> linux_binfmt的load_binary接口。load_binary接口实际是load_elf_binary。load_elf_binary调用setup_new_exec。这个函数中会将task的mm_struct成员变量task_size 设置为TASK_SIZE。32位环境中内核定义如下,TASK_SIZE为0xC0000000,用户空间默认3GB,内核空间1GB。
/* * User space process size: 3GB (default). */ #define IA32_PAGE_OFFSET __PAGE_OFFSET #define TASK_SIZE __PAGE_OFFSET #define TASK_SIZE_LOW TASK_SIZE #define TASK_SIZE_MAX TASK_SIZE #define DEFAULT_MAP_WINDOW TASK_SIZE #define STACK_TOP TASK_SIZE #define STACK_TOP_MAX STACK_TOP
64位环境中虚拟地址只是用了48位,TASK_SIZE为 (1 << 47) 减去一页的大小为0x00007FFFFFFFF000。用户空间大概位128TB,内核空间也是128TB,且用户空间和内核空间之间留有空隙用于隔离。
#define TASK_SIZE_MAX ((_AC(1,UL) << __VIRTUAL_MASK_SHIFT) - PAGE_SIZE) #define DEFAULT_MAP_WINDOW ((1UL << 47) - PAGE_SIZE) /* This decides where the kernel will search for a free chunk of vm * space during mmap's. */ #define IA32_PAGE_OFFSET ((current->personality & ADDR_LIMIT_3GB) ? \ 0xc0000000 : 0xFFFFe000) #define TASK_SIZE_LOW (test_thread_flag(TIF_ADDR32) ? \ IA32_PAGE_OFFSET : DEFAULT_MAP_WINDOW) #define TASK_SIZE (test_thread_flag(TIF_ADDR32) ? \ IA32_PAGE_OFFSET : TASK_SIZE_MAX) #define TASK_SIZE_OF(child) ((test_tsk_thread_flag(child, TIF_ADDR32)) ? \ IA32_PAGE_OFFSET : TASK_SIZE_MAX)
用户态
了解了用户空间和内核空间分界之后,我们先来看下用户空间。用户态虚拟内存布局如下,32位和64位区域和布局差别不大。
- 这些空间里的内容是从哪填充来的呢?
- 没错,是不是感觉和可执行文件的格式有点像。一个进程创建之后所有的内存空间都是复制父进程的,当父进程调用exec加载新的二进制时就会将二进制文件内容加载到进程内存各个模块中,但是不一定是立即加载,有些非必需字段是用时加载。
struct mm_struct结构体中如下参数定义了这些模块的属性和位置。
类型 | 字段名 | 用途 |
struct vm_area_struct * | mmap | 内存中每个区域对应一个mmap,这些区域用链表连接起来 |
struct rb_root | mm_rb | 红黑树,用来辅助操作mmap |
unsigned long | mmap_base | 用于映射的内存起始位置 |
unsigned long | task_size | 用户空间大小 |
unsigned long | total_vm | 总共映射的页数 |
unsigned long | locked_vm | 当内存吃紧,将个别页换到磁盘上,locaked_vm表示被锁定不能换出的页数 |
unsigned long | pinned_vm | 不能换出也不能移动的页数 |
unsigned long | data_vm | 存放数据页数 |
unsigned long | exec_vm | 可执行文件占用的页数 |
unsigned long | stack_vm | 栈占用的页数 |
unsigned long | start_code,end_code, start_data, end_data | 代码段起始和结束位置,数据段起始和结束位置 |
unsigned long | start_brk, brk, start_stack | 堆起始结束位置,栈起始位置(栈结束位置在SP寄存器中) |
unsigned long | arg_start,arg_end, env_start,env_end | 参数列表起始和结束位置,环境变量起始和结束位置 |
函数load_elf_binary负责加载二进制,并且根据可执行文件内容初始化各个区域。
- 调用setup_new_exec设置struct mm_struct的mm_base参数(mmap内存映射区域),并且设置task_size的值。
- 调用setup_arg_pages设置栈的struct vm_area_struct结构,并设置参数列表起始位置arg_start的值,arg_start指向栈低start_stack的位置。
- 调用elf_map将可执行文件中的代码段映射到内存空间。
- 调用set_brk设置堆空间的struct vm_area_struct,并且初始化start_brk=brk(堆为空)。
- 如果有动态库,则调用load_elf_interp映射到内存映射区域。
- 给start_code, end_code, start_data, end_data赋值。
进程的用户态布局就变成下面这样:
内存区域映射完之后,存在一下情况区域会发生变化:
- 用户调用malloc/free申请堆空间,小内存操作调用brk移动堆结束指针,大内存操作调用mmap。
- 创建临时变量或函数调用导致栈指针移动时对应栈区域也会移动。
这里简单看下堆内存操作brk的过程,mmap后边会讲解。
- 入口在SYSCALL_DEFINE1(brk, unsigned long, brk)。参数brk就是新堆顶的位置。
- 将参数堆顶位置brk和进程旧堆顶位置brk关于页对齐,如果对齐后两者相同说明变化量很小可以在同一页里解决。将mm_struct的brk指向新的brk即可。
- 如果两者对齐后不相同,说明操作跨页了,如果新brk小于旧的brk说明是释放内存,就调用__do_munmap将多余的页去掉映射。
- 如果新brk大于旧brk说明是申请内存,就调用find_vma在红黑树中找到下一个struct vm_area_struct的位置,看中间是否还能分配一个完整的页,分配不了就报错。如果能就更新各参数分配。
内核态
内核态的虚拟地址空间和某个进程没关系,所有进程共享同一个内核态虚拟地址空间。并且此时讨论的还是虚拟地址空间。前面分析用户态和内核态分界的时候讲了32位内核态是1GB,64位内核态是128TB。因为空间的数量级就差很大,可想而知布局也会有一定差别,毕竟32位太小了。我们先来分析一下32位内核态的布局。
- 前896M为直接映射区,这部分地址连续,虚拟地址与物理地址映射关系较为简单,内核用了两个宏定义来转换地址#define __va(x),#define __pa(x)实际转换规则就是虚拟地址-PAGE_OFFSET(前面讲过用户空和内核空间分界)得到物理地址,物理地址+PAGE_OFFSET得到虚拟地址。直接映射区前1M空间开机处于实模式时会使用,内核代码从1M开始加载,然后就是全局变量、BSS等,另外内存管理的页表以及进程的内核栈都会放在这个区域。
- 接下来就是8M的空洞,用于捕捉内存越界。其他空洞也是这个原因。
- VMALLOC_START到VMALLOC_END成为动态映射空间,类似进程的堆,内核使用vmalloc进行动态申请内存的区域。
- PKMAP_BASE到FIXADDR_START是持久映射空间,通常为4M,内核使用alloc_pages获得struct page结构,然后调用kmap将其映射到这个区域。
- FIXADDR_START到FIXADDR_TOP为固定映射区域,留作特定用途。
64位的内核态布局就较为简单了,毕竟128TB太大不需要扣内存。
- 内核空间从0xffff800000000000开始,之后有8T空洞。
- 0xFFFF880000000000到0xFFFFC80000000000是直接映射区,同32位。
- 0xFFFFC90000000000到0xFFFFE90000000000是动态映射区,同32位。
- 然后就是存放物理页表,同32位持久映射区域。
至此虚拟地址空间的管理部分讲清楚了,
下一节我们将讲述物理地址空间以及两者之间的映射。