进程虚拟内存
- 内存管理
- 2023-08-26
- 201热度
- 0评论
进程虚拟地址空间
Executable and Linkable Format(ELF)
上图是可执行文件的内容结构图,由ELF header、program headers、各section、sections headers组成。
- ELF header:描述整个文件的基本属性,如文件版本号、目标机器型号、程序入口地址等。
- program headers:描述ELF文件该如何被操作系统映射到进程的虚拟地址空间,对于LOAD类型的Segment,每个Segment对应一个VMA。对于操作系统来说,并不关心各个section所包含的内容,它只关心跟装载相关的问题,最主要的是section的权限(可读,可写,可执行),所以对于相同类型的section,将会被合并成要给Segment进行映射,如init/text/rodata,这些都是可读可执行所以合并成一个Segment来描述。对于.o文件是没有program heades的。
- sections:代码经过编译之后,将会分类链接多个section,如init/text/data/bss。
- sections headers:用于ELF文件中各sections的。
ELF header
描述ELF header的结构体
typedef struct {
unsigned char e_ident[EI_NIDENT]; /* 16 bytes */
Elf64_Half e_type; /* File type */
....
Elf64_Addr e_entry; /* Entry point virtual address */
Elf64_Off e_phoff; /* Prog headers file offset */
Elf64_Off e_shoff; /* Sec headers file offset */
....
Elf64_Half e_phentsize; /* Prog headers entry size */
Elf64_Half e_phnum; /* Prog headers entry count */
Elf64_Half e_shentsize; /* Sec headers entry size */
Elf64_Half e_shnum; /* Sec headers entry count */
Elf64_Half e_shstrndx; /* Sec string table index */
} Elf64_Ehdr;
可以通过readelf -h 来获取ELF的header信息。
$ readelf -h wifi_daemon
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: AArch64
Version: 0x1
Entry point address: 0x401c00
Start of program headers: 64 (bytes into file)
Start of section headers: 44568 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 9
Size of section headers: 64 (bytes)
Number of section headers: 29
Section header string table index: 28
program headers
描述Program header的结构体。
// Program header for ELF64.
typedef struct {
Elf64_Word p_type; // Type of segment
Elf64_Word p_flags; // Segment flags
Elf64_Off p_offset; // File offset where segment is located, in bytes
Elf64_Addr p_vaddr; // Virtual address of beginning of segment
Elf64_Addr p_paddr; // Physical addr of beginning of segment (OS-specific)
Elf64_Xword p_filesz; // Num. of bytes in file image of segment (may be zero)
Elf64_Xword p_memsz; // Num. of bytes in mem image of segment (may be zero)
Elf64_Xword p_align; // Segment alignment constraint
} Elf64_Phdr;
可以通过readelf -l []来获取ELF的pragram header信息。
$ readelf -l wifi_daemon
Elf file type is EXEC (Executable file)
Entry point 0x401c00
There are 9 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000400040 0x0000000000400040
0x00000000000001f8 0x00000000000001f8 R 8
INTERP 0x0000000000000238 0x0000000000400238 0x0000000000400238
0x000000000000001b 0x000000000000001b R 1
[Requesting program interpreter: /lib/ld-linux-aarch64.so.1]
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000
0x000000000000899c 0x000000000000899c R E 10000
LOAD 0x0000000000008b60 0x0000000000418b60 0x0000000000418b60
0x00000000000005b4 0x00000000000005e0 RW 10000
DYNAMIC 0x0000000000008b68 0x0000000000418b68 0x0000000000418b68
0x0000000000000270 0x0000000000000270 RW 8
NOTE 0x0000000000000254 0x0000000000400254 0x0000000000400254
0x0000000000000020 0x0000000000000020 R 4
GNU_EH_FRAME 0x0000000000008628 0x0000000000408628 0x0000000000408628
0x000000000000008c 0x000000000000008c R 4
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 10
GNU_RELRO 0x0000000000008b60 0x0000000000418b60 0x0000000000418b60
0x00000000000004a0 0x00000000000004a0 R 1
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.ABI-tag .hash .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .text .fini .rodata .eh_frame_hdr .eh_frame
03 .init_array .fini_array .data.rel.ro .dynamic .got .data .bss
04 .dynamic
05 .note.ABI-tag
06 .eh_frame_hdr
07
08 .init_array .fini_array .data.rel.ro .dynamic .got
从上可以看出一共有9各segment,与前面elf header信息中的Number of program headers:9对应。这里我们重点关注02和03的segment即可,因为这两个segment的类型是LOAD类型,每个segment就对应一个VMA,与我们后面关于虚拟地址到物理地址的映射有着非常重要的联系。
操作系统只关心段的权限(可读、可写、可执行),所以对于相同类型权限段可以合并到一起当作一个段来映射,所以通常的分类有一下三种:
- 可读可执行:代码块为代表
- 可读可写:data块和BSS块为代表
- 只读:rodata块为代表
sections
下面是描述sections headers的结构体。
// Section header.
struct Elf32_Shdr {
Elf32_Word sh_name; // Section name (index into string table)
Elf32_Word sh_type; // Section type (SHT_*)
Elf32_Word sh_flags; // Section flags (SHF_*)
Elf32_Addr sh_addr; // Address where section is to be loaded
Elf32_Off sh_offset; // File offset of section data, in bytes
Elf32_Word sh_size; // Size of section, in bytes
Elf32_Word sh_link; // Section type-specific header table index link
Elf32_Word sh_info; // Section type-specific extra information
Elf32_Word sh_addralign; // Section address alignment
Elf32_Word sh_entsize; // Size of records contained within the section
};
可以通过readelf -S []来读取ELF的section header的信息。
$ readelf -S wifi_daemon
There are 29 section headers, starting at offset 0xae18:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .interp PROGBITS 0000000000400238 00000238
000000000000001b 0000000000000000 A 0 0 1
[ 2] .note.ABI-tag NOTE 0000000000400254 00000254
0000000000000020 0000000000000000 A 0 0 4
[ 3] .hash HASH 0000000000400278 00000278
00000000000001a0 0000000000000004 A 5 0 8
[ 4] .gnu.hash GNU_HASH 0000000000400418 00000418
0000000000000024 0000000000000000 A 5 0 8
[ 5] .dynsym DYNSYM 0000000000400440 00000440
0000000000000618 0000000000000018 A 6 1 8
[ 6] .dynstr STRTAB 0000000000400a58 00000a58
00000000000003b8 0000000000000000 A 0 0 1
[ 7] .gnu.version VERSYM 0000000000400e10 00000e10
0000000000000082 0000000000000002 A 5 0 2
[ 8] .gnu.version_r VERNEED 0000000000400e98 00000e98
0000000000000060 0000000000000000 A 6 3 8
[ 9] .rela.dyn RELA 0000000000400ef8 00000ef8
0000000000000030 0000000000000018 A 5 0 8
[10] .rela.plt RELA 0000000000400f28 00000f28
00000000000005e8 0000000000000018 AI 5 22 8
[11] .init PROGBITS 0000000000401510 00001510
0000000000000018 0000000000000000 AX 0 0 4
[12] .plt PROGBITS 0000000000401530 00001530
0000000000000410 0000000000000000 AX 0 0 16
[13] .text PROGBITS 0000000000401940 00001940
00000000000038e4 0000000000000000 AX 0 0 64
[14] .fini PROGBITS 0000000000405224 00005224
0000000000000014 0000000000000000 AX 0 0 4
[15] .rodata PROGBITS 0000000000405238 00005238
00000000000033f0 0000000000000000 A 0 0 8
[16] .eh_frame_hdr PROGBITS 0000000000408628 00008628
000000000000008c 0000000000000000 A 0 0 4
[17] .eh_frame PROGBITS 00000000004086b8 000086b8
00000000000002e4 0000000000000000 A 0 0 8
[18] .init_array INIT_ARRAY 0000000000418b60 00008b60
0000000000000000 0000000000000008 WA 0 0 1
[19] .fini_array FINI_ARRAY 0000000000418b60 00008b60
0000000000000000 0000000000000008 WA 0 0 1
[20] .data.rel.ro PROGBITS 0000000000418b60 00008b60
0000000000000008 0000000000000000 WA 0 0 8
[21] .dynamic DYNAMIC 0000000000418b68 00008b68
0000000000000270 0000000000000010 WA 6 0 8
[22] .got PROGBITS 0000000000418dd8 00008dd8
0000000000000228 0000000000000008 WA 0 0 8
[23] .data PROGBITS 0000000000419000 00009000
0000000000000114 0000000000000000 WA 0 0 8
[24] .bss NOBITS 0000000000419118 00009114
0000000000000028 0000000000000000 WA 0 0 8
[25] .comment PROGBITS 0000000000000000 00009114
0000000000000033 0000000000000001 MS 0 0 1
[26] .symtab SYMTAB 0000000000000000 00009148
00000000000011a0 0000000000000018 27 103 8
[27] .strtab STRTAB 0000000000000000 0000a2e8
0000000000000a38 0000000000000000 0 0 1
[28] .shstrtab STRTAB 0000000000000000 0000ad20
00000000000000f4 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings)
I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
可以看出wifi_daemon中一共有29个section,与ELF header描述内容中的Number of section headers:29是匹配的。
进程虚拟地址空间布局
系统对虚拟地址空间进行布局,在2.3章节中描述了内核空间的划分,同样针对用户空间也有进行了划分。可以分为4类:
- 栈:用于维护函数调用的上下文,栈在用户空间的最高地址处分配,地址是向下增长。
- 堆:用户程序动态分配内存的区域,当使用malloc分配内存时,虚拟地址空间将在这个范围,地址向上增长。
- MMAP:在栈和堆空间有一个MMAP区域,主要用于mmap系统调用的映射。包括文件映射(包含动态库、文件IO)和匿名映射。
- 可执行文件映像:存储可执行文件在内存的映像,装载器会将ELF内容读取或映射到这里。
可执行文件映像可再进行分类,前面章节描述了,程序最终编译临界成各个sections,如text,data,bss等等,但对于系统来说关注的是加载的方式,如读写执行权限,因此可执行文件映像的分类是按照权限划分的。如上分为只读权限(对应init,text等section),读写权限(对应.data,bss等)。
虚拟地址空间描述
Linux系统操作的是虚拟地址,访问实际内存需要将虚拟地址通过MMU查询页表,转化为物理地址。用户空间的虚拟地址可以按照如上图进行分为几个segment,每个segment都对应一个VMA。虚拟地址到物理地址的映射,是每个VMA到物理地址的映射。
VMA作为进程地址空间一块连续区域,使用struct vm_area_struct结构体进行描述。该结构体中描述了连续区域的起始地址和地址。
VMA之间通过双向链接连接在一起,在struct vm_area_struct中vm_next,vm_prev分别指向下一个VMA和上一个VMA,使用双向链表来组织VMA,便于进程虚拟地址对VMA的插入。
VMA同时又被加入到一棵红黑树中,在struct vm_area_struct中的vm_rb描述红黑树的节点,根节点在struct mm_struct mm_rb来描述,既然VMA通过链表串一起了,为什么再使用红黑树来组织,主要是使用红黑树能够加快进程搜索VMA的速度。
mm_struct数据结构中的pgd指向了该进程的页表页目录,每个进程都有自己一份独立的页表,当CPU第一次访问虚拟地址空间时,如果查询页表找不到对应的物理页,将会发生缺页异常,在缺页异常中,进行分配物理页面,当然如果页表没有创建,需要先申请物理页面创建页表,最后将物理页面填充到页表中,完成虚拟地址到物理地址的映射关系。
上图中数据结构的层级关系struct task_struct->struct mm_struct->struct vm_area_struct。
可以通过节点/proc/[pid]/maps来查看内存mappings,下面例子中每一行都表示一个VMA。可以man proc来查看各项参数意义。
address perms offset dev inode pathname
00400000-00452000 r-xp 00000000 08:02 173521 /usr/bin/dbus-daemon
00651000-00652000 r--p 00051000 08:02 173521 /usr/bin/dbus-daemon
00652000-00655000 rw-p 00052000 08:02 173521 /usr/bin/dbus-daemon
00e03000-00e24000 rw-p 00000000 00:00 0 [heap]
...
35b1800000-35b1820000 r-xp 00000000 08:02 135522 /usr/lib64/ld-2.15.so
35b1a1f000-35b1a20000 r--p 0001f000 08:02 135522 /usr/lib64/ld-2.15.so
35b1a20000-35b1a21000 rw-p 00020000 08:02 135522 /usr/lib64/ld-2.15.so
35b1a21000-35b1a22000 rw-p 00000000 00:00 0
35b1c00000-35b1dac000 r-xp 00000000 08:02 135870 /usr/lib64/libc-2.15.so
35b1dac000-35b1fac000 ---p 001ac000 08:02 135870 /usr/lib64/libc-2.15.so
35b1fac000-35b1fb0000 r--p 001ac000 08:02 135870 /usr/lib64/libc-2.15.so
35b1fb0000-35b1fb2000 rw-p 001b0000 08:02 135870 /usr/lib64/libc-2.15.so
..
7fffb2c0d000-7fffb2c2e000 rw-p 00000000 00:00 0 [stack]
- address: VMA对应的起始地址,对应struct vm_area_struct中的vm_start,vm_end。
- perms:VMA的权限,r=read,w=write,x=execute,s=shared,p=private。s和p二选一,主要是判断当前的地址空间是进程私有,还是共享。
- offset: 文件映射,表示此段虚拟内存起始地址在文件中以页为单位的偏移,匿名映射为0。
- dev:所映射文件所属的设备号,匿名映射为0。
- inode:映射文所属节点节点号,匿名映射为0。
- pathname:文件映射,对应的就是映射的文件名。匿名映射,是此段虚拟内存在进程的角色,如heap,stack等。
是否可以查看进程虚拟内存都被谁占用了,对应的VMA的大小是否有增大趋势,可以用于判断内存泄露?
操作系统角度看可执行文件的装载运行
从操作系统角度看,一个进程最关键的特征是它拥有独立的虚拟地址空间,这使得它跟别的进程有差别。下面来看一个程序被执行比较通用的情形,流程如下:
- 创建一个独立的虚拟地址空间。
- 建立执行程序虚拟空间与可执行文件的映射关系。
- 将CPU的指令寄存器设置成可执行文件的入口函数,启动运行。
- 运行过程中,通过缺页异常将指令、数据装载进内存。
(1)创建独立的虚拟地址空间
系统访问使用的是虚拟地址,虚拟地址通过查询页表找到对应的物理内存,因此最开始是创建好虚拟地址空间,而创建虚拟地址空间实际上并不是创建空间直接建立好跟物理内存的连续,而是先创建映射函数所需要的相应数据结构,比如task_struct,mm_struct等。对应页表的创建,实际上只分配了一个页目录就可以了,不需要设置页的映射关系,等实际程序访问的时候通过缺页异常才进行设置。
(2)建立执行程序虚拟空间与可执行文件的映射关系
上一个步骤建立了虚拟地址空间,这一步所做的建立虚拟空间与可执行文件的映射关系,因为程序执行时发生缺页异常,系统会从物理内存分配一块内存,然后将缺页从磁盘读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序就可以运行了。因此,当程序发生缺页异常时,需要知道程序当前的页在磁盘的那一个位置。
Linux系统将进程虚拟地址空间分配成多个段,这个段叫做虚拟内存区域(VMA,Virtual Memory Area)。如系统创建进程后,会设置一个.text段(原则上应该时多个sections的合并)的VMA。
(3)将CPU的指令寄存器设置可执行程序文件的入口函数
这一步就是将ELF文件头中保存的入口地址赋值为PC,然后启动运行。
(4)缺页异常
在上述步骤执行后,实际可执行文件的指令和数据都还没有装入到内存中,操作系统只是通过该可执行文件头部信息建立起可执行文件和进程虚拟内存空间直接的映射关系,当程序启动运行时,执行相关的虚拟地址,当发现这虚拟地址对应的物理页面为空,将会发生缺页异常,缺页异常会分配一块内存,然后将可执行文件的指令和数据从磁盘加载到内存中,后续就可以直接访问内存进行读写访问执行了。
VMA的操作函数
VMA查找
VMA插入
VMA合并与拆分
malloc函数
用户空间分配内存,不会每次申请都向linux内核做一个系统调用进行分配获取内存,而是在用户空间维持着一个缓冲池,这个缓冲池有自己的内存管理算法。当用户进行malloc时候,如果缓存池中有内存,就直接获取返回,如果缓存池中没有内存,就会下陷做系统调用到linux内核中获取内存。
用户空间通过系统调用向内核获取内存时分为两种情况:当分配的内存小于M_MMAP_THRESHOLD阈值时会使用brk系统调用来扩展堆空间,当分配内存大于M_MMAP_THRESHOLD阈值时,会使用mmap进行映射分配。M_MMAP_THRESHOLD通常为128K,用户可以通过调用mallopt函数来修改该阈值。
malloc通过brk方式申请的内存,free释放内存时,并不会归还给系统,而是缓存到malloc内存池中,待下次使用。
malloc通过mmap申请到的内存,free释放内存时,会把内存归还给系统,内存得到真正释放。
与malloc相关的函数
malloc系统调用流程
待补充
mmap函数
基本概念
mmap用于内存映射,将一段区域映射到自己的进程地址空间中。这段区域可以是文件页属性也可以是物理页属性,所以分为两种:
- 文件映射:将文件映射到进程空间,文件存放在存储设备上(文件内容会以page cache缓存到物理内存中)。
- 匿名映射:没有文件对应的区域,内容在物理内存上。
mmap用于文件映射能提高读写效率,主要的差异点是常规的文件操作需要从磁盘到页缓存拷贝,然后内核空间到用户空间还有拷贝,有两次数据拷贝动作;而mmap操控文件,只需要从磁盘到用户主存一次拷贝,后续的读写直接对主存读写,相当于少了依次内核到用户空间的拷贝。
mmap针对进程是否可见,有分为两种:
- 私有映射:数据源拷贝一次副本,进程之间互不影响。
- 共享映射:共享的进程都能看到。
根据排列组合就有4中映射情况:
- 私有匿名映射:可以用于分配大的内存,如malloc堆空间。
- 共享匿名隐射:可用于父子进程间通信,在内存文件系统中创建/dev/zero设备。
- 私有文件映射:常用于动态库的加载,如代码段,数据段等。
- 共享文件映射:非父子之间的进程间通信,文件读写等。
实现原理
do_mmap函数调用流程待补充.....
举例mmap文件内存映射的实现过程,可以分为三个阶段:
- 1.创建mmap虚拟地址空间VMA
如果mmap没有指定虚拟地址空间区域,则搜索一段空闲的连续虚拟地址空间,并分配一个vm_area_struct实例添加到红黑树和链表中。
- 2.建立VMA与文件物理地址(在磁盘那个位置)的映射关系
通过虚拟文件系统inode模块定位到文件在磁盘的位置,建立VMA与文件的联系。 call_mmap->file->f_op->mmap?
- 3.访问文件对应的虚拟地址,引发缺页异常,将文件内容加载到内存
前面阶段,并没有将文件的数据拷贝到内存中,真正的文件读取是进程发起读写操作时。进程在读货写操作访问映射的虚拟地址空间,通过查询页表,发现这一段地址并不再物理页面上,引发缺页异常,于是先从磁盘加载数据到内存中。后续堆文件的读写,直接就对对应的物理内存读写,当改变了内容,系统会自动将脏页面写回到磁盘上,当前也可以强行同步(msync)
缺页异常
Linux系统有一个重要的特性就是用户“欺骗性”,如通过malloc申请了内存,但是实际上并没有分配内存给你,等实际访问内存的时候才会分配给你。用户很多重要的初始化操作只是针对虚拟内存的,虚拟内存实际对应的物理内存空间并没有分配,等实际需要访问的时候才会进行分配,因此当进程访问虚拟地址空间,发现虚拟地址空间没有与物理内存建立映射关系,处理器就会自动触发缺页异常(缺页中断)。下面是触发缺页异常的一些场景情形:
缺页异常会执行到对应的中断函数,会跟实际的处理器架构有些关系,在实际中缺页异常最终会调用到do_page_fault(arch/arm64/mm/fault.c)函数,接下来将会从这个函数进行重点分析。
do_page_fault
匿名页面
发生匿名页缺页异常,一般是①malloc/mmap分配进程地址空间区域,没有对应的物理内存将会触发分配。② 用户栈不够时,进行栈区的扩大处理。
匿名页面分配时,会判断页面是否可写,如果是只读权限,那么系统会分配一个zero page。Zero page是一个特殊的物理页(实际没有使用物理内存空间),里面值全部为0,zero page针对匿名页场景专门进行的优化,主要是节省内存和对性能的一定优化。当malloc或者mmap分配内存仅仅是进程地址空间中的虚拟内存,如果用程序需要读这个malloc分配的虚拟内存,那么系统会返回全0的数据,因此linux内核不必为这种情况单独分配物理内存,而是使用系统零页,当程序需要写入这个页面时就会触发一个缺页异常,于是缺页异常变成写时复制的缺页异常。malloc分配虚拟内存,有以下几种情况。
- malloc分配后,直接读内存,这时缺页异常,分配到的是zero page,PTE的属性是read only。
- malloc分配后,先读后写,先读的时候缺页异常分配的是系统零页,再写再触发缺页异常触发写时复制。
- malloc分配后,直接写内存,触发缺页异常,使用alloc_zerod_user_highpage_movable分配新页面。
文件页面
文件页面异常分为读文件异常,写私有文件异常,写共享文件异常。总体的思路是没有分配物理页面,就进行分配物理页面,然后将内容从文件中拷贝到物理页面中,再建立好页表。
- 读文件异常:会尝试多映射数据周围的内容,因为周围数据再次被命中的概率比较高,这样减少缺页异常次数。
- 写私有文件:写私有文件会发生写时复制,会先分配一块物理页面cow_page,先将文件内容读取文件缓存页(page cache),然后再将其内容复制到cow_page中。
- 写共享文件:写共享文件不会发生写时复制,如果mkwrite函数不为空,将会通知进程页面变成可写。同时会将页设置为脏页。
swap页面换入
当内存不足时,会把页面交换到磁盘swap分区中,当再次访问这块内存时会发生缺页异常,大致的流程就是搜索swap cache看页面是否在内存中,如果不在说明被交换出去了,那就需要从磁盘里面读出来,然后重新刷新页表,重新建立虚拟地址和物理页面的映射关系。
页面写时复制COW
通常有以下两种情况会触发写时复制(Copy on write,CoW)。
(1)父进程创建一个子进程,为了避免复制物理页,子进程和父进程以只读方式共享的匿名页和文件页,当有一个进程需要写只读页时,将会触发页错误异常,进程会拷贝一份新的物理页进行写。
(2)进程创建了一个私有文件映射,当进行读访问时,缺页异常将文件内容读取到page cache中,并将以只读方式跟虚拟页建立映射关系。当进程再对改内容进行写时,缺页异常会触发写时复制,为page cache创建一个副本,新建一个虚拟页与复制的物理页建立联系。
vm_normal_page从页表项得到页帧号,如果返回值为NULL,表示这是一个特殊的页映射,这种特殊的页映射只有页帧号,没有对应实际的物理页,具体是什么用途,需要再研究?
如上图,写时复制一共有四种情形。
- 写时复制:wp_page_copy
- 可写且共享的特殊映射页面:wp_pfn_shared
- 可写且共享的普通页面:wp_page_shared
- KSM匿名页面(复用的页面):wp_page_reuse
下面重点看看wp_page_copy的流程:
1.为什么会产生page fault?
Page fault是硬件提供的特性,由硬件触发,触发条件为CPU访问某线性地址时,如果没有找到对应可访问页表项,则由硬件直接触发page fault。
2.发生缺页的地址是否可以位于内核态地址空间?
有可能,内核地址空间发生缺页异常仅可能在vmalloc区,线性映射区域对应的页表在内核初始化就已经建立好了,所以这部分内存对应的虚拟地址空间不可能产生page fault。
RMAP
反向映射是物理页面page可以寻找到其对应的虚拟地址空间VMA。当进行页面回收的时候,就需要利用反向映射技术找到其对应的进程VMA,然后将VMA与当前页面断开映射关系,即可进行回收当前页面。
一个物理页面是可以同时被多个进程的虚拟页面映射的,但是一个虚拟页面只能映射一个物理页面。不同虚拟页面映射到同一物理页面的场景主要有子进程复制了父进程的VMA以及KSM机制的存在。
关键数据结构
基本原理
映射到一个进程的反向映射
static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
......
__anon_vma_prepare(vma)
page = alloc_zeroed_user_highpage_movable(vma, vmf->address);
page_add_new_anon_rmap(page, vma, vmf->address, false);
......
}
以匿名映射为例说明:
在缺页异常分配物理页时,会调用两个函数做RAMP相关的处理:__anon_vma_prepare,以及page_add_new_anon_rmap。
创建过程:
(1)分配页面的时候,为每个VMA创建一个AVC,然后再创建一个AV。
(2)AVC->vma指向VMA,AVC->AV指向AV。
(3)将AVC添加到VMA->anon_vma_chain链表中。
(4)将AVC添加到AV->rb_root红黑树中。
(5)将page->mapping指向av。
反向映射过程:
(1)通过page->mapping找到av。
(2)在av->rb_root红黑树中从根节点进行遍历avc。
(3)从avc中avc->vma找到vma。
void page_add_new_anon_rmap(struct page *page,
struct vm_area_struct *vma, unsigned long address, bool compound)
{
int nr = compound ? thp_nr_pages(page) : 1;
VM_BUG_ON_VMA(address < vma->vm_start || address >= vma->vm_end, vma);
__SetPageSwapBacked(page);
设置page的标志位位PG_swapbacked,表示页面可以交换到磁盘。
if (compound) {
VM_BUG_ON_PAGE(!PageTransHuge(page), page);
/* increment count (starts at -1) */
atomic_set(compound_mapcount_ptr(page), 0);
if (hpage_pincount_available(page))
atomic_set(compound_pincount_ptr(page), 0);
__mod_lruvec_page_state(page, NR_ANON_THPS, nr);
} else {
/* Anon THP always mapped first with PMD */
VM_BUG_ON_PAGE(PageTransCompound(page), page);
/* increment count (starts at -1) */
atomic_set(&page->_mapcount, 0);
}
__mod_lruvec_page_state(page, NR_ANON_MAPPED, nr);
__page_set_anon_rmap(page, vma, address, 1);
设置页面位匿名映射
}
static void __page_set_anon_rmap(struct page *page,
struct vm_area_struct *vma, unsigned long address, int exclusive)
{
struct anon_vma *anon_vma = vma->anon_vma;
anon_vma = (void *) anon_vma + PAGE_MAPPING_ANON;
WRITE_ONCE(page->mapping, (struct address_space *) anon_vma);
Page中的mapping指向anon_vma
page->index = linear_page_index(vma, address);
}
映射到多个进程的反向映射
未发生写时复制
父进程创建子进程时,如果没有还没有进行写操作只有读操作,为了节省内存,父子是共享物理页面的,只有当父子进程需要修改页面内容是,才会发生写时复制一份。
- 遍历父进程的VMA,子进程进行复制一份VMA。
- 每复制一份VMA,就创建一个AVC(下图的AVC_c),用于建立父子之间的桥梁联系。AVC_c(AVC_c->anon_vma)指向父AV并添加到父AV(AV->rb_root)红黑树中,同时AVC_c(AVC_c->vma)又指向子进程的VMA。这样在遍历父AV的红黑树,就能通过AVC_c找到子进程的VMA。
- 子进程创建属于自己的AVC/AV,并建立VMA,AVC,AV的联系。
- 子进程复制了父进程的pte,所以子进程的vma对应虚拟页面也父进程的虚拟页面同时指向物理页面page(二对一的情况)。
物理页面page寻找虚拟页面过程:
- 通过page->mapping找到父进程的AV,如上图AV0。
- 遍历AV0的红黑树,这里找到两个AVC节点,分别是父AVC0节点以及父子进程桥梁节点AVC_c。
- 通过父AVC0找到父进程的VMA0(AVC0->vma指向父VMA0)。
- 通过桥梁AVC_c找到子进程的VMA1(AVC_c-vma指向子VMA1)
经过上述过程,物理页面page就可以找到与其对应的两个虚拟页面了。
发生写时复制
当父子进程的某一方出现写页面操作时,将会触发写时复制复制一份自己的物理页面,如上图,VMA1的pte指向复制的新物理页,同时物理页page->mapping指向AV1,这样copy page就能够通过AV1遍历其红黑树找到AVC1,进而通过AVC1找到VMA1。
疑问:上图中AVC_c依旧在AV0的红黑树中,所以父进程的物理页page依旧通过AV0找到子进程的VMA1,但是子进程实际的pte已经指向copy page了,跟父进程的page没有关系了。网上看到的说法是,为了解决这个问题,即使page找到了对应的VMA,会检查vma的页表是否确实映射到了此页,进而解决这个问题,需要进一步研究代码求证。
反向映射的应用
反向映射主要的应用场景如下:
- Kswapd回收页面是,需要断开映射到物理页面的PTE。
- 页面迁移时,需要断开所有映射到页面的PTE。