目录
5.6.2 非连续内存区数据结构 struct vm_struct
1 Linux内存管理概述
1.1 内存的层次结构
出于性能和价格综合考虑,计算机一般都是构建层次化的存储体系
说明1:尽管内存比外存速度快很多,但是还是无法与CPU的速度匹配,因此CPU内部就需要更快的存储设备,也就是高速缓存(cache),对cache处理的目标就是提高命中率,避免过多低速的访问内存操作
说明2:通过lscpu命令可以查看CPU的体系结构,其中包含Cache的层次结构
以当前实验环境的虚拟机为例,L1 cache区分为dcache和icache,L2 & L3 cache则是命令与数据混用,构成如下图所示的cache层次
1.2 虚拟内存概述
1.2.1 虚拟内存基本思想
在计算机中运行的程序,其代码、数据、堆和栈的总量可以超过实际内存的大小,操作系统只将当前使用的程序块保存在内存中,其余的程序块则保留在磁盘上。必要时,操作系统负责在磁盘和内存之间交换程序块
说明:这种思想基于程序的局部性原理
1.2.2 进程虚拟地址空间
程序一旦被运行就称为一个进程,内核就会为每个运行的进程提供大小相同的虚拟地址空间,这使得多个进程可以同时运行又不会相互干扰
说明1:从进程角度,每个进程拥有4GB虚拟地址空间
32位体系结构中提供4GB的进程虚拟地址空间,其中,
① 0 ~ 3GB:用户空间,每个进程私有,其他进程不可见
② 3 ~ 4GB:内核空间,由系统中的所有进程共享
说明2:从CPU角度,同一时刻只有一个虚拟地址空间
任意一个时刻,在一个CPU上只有一个进程在运行。当进程发生切换时,虚拟地址空间也会随之切换(通过将进程页表的pgd物理地址写入cr3控制寄存器)
说明3:最终在物理地址空间运行
程序经编译链接后形成的地址空间是虚拟地址空间,最终需要转换为物理地址并加载到物理内存中才能运行,这个映射关系需要通过页表完成
如果给出的页表不同,CPU将某一虚拟地址空间中的地址转换成的物理地址就会不同。所以我们为每个进程都建立页表,将每个进程的虚拟地址空间根据自己的需要映射到物理地址空间
1.3 内核空间到物理空间的映射
1.3.1 内核空间的线性映射
虽然内核空间占据每个进程虚拟地址空间中的最高1GB,但是进程内核空间映射到物理内存却是从最低的地址开始。X86中为0x00000000,在其他体系结构中,最低物理地址不一定是0x00000000(e.g. ARM)
之所以这么规定是为了在内核空间与物理地址之间建立起简单的线性映射关系,其中3GB(0xC0000000)就是物理地址与虚拟地址之间的位移量,在内核中使用PAGE_OFFSET宏标识
从图示中可见,1GB的内核空间并未全部进行线性映射,其中从0x00000000到ZONE_NORMAL末端进行了线性映射在这个区域内,可以通过__pa & __va进行虚拟地址和物理地址的相互"计算"
1.3.2 内核镜像的物理存储
在X86体系结构中,系统启动时将Linux内核镜像装入物理地址的0x00100000(1MB)处,具体如下图所示,
内核镜像只占用从0x00100000到start_mem的区域,从start_mem到end_mem这段区域称作动态内存,是用户程序和数据使用的内存区说明:之所以空出低端1MB内存,是历史遗留问题,与低端1MB的memory map有关(其中包含内存、外设内存和BIOS),可参考如下《03. 虚拟机的安装和使用》的chapter 1.2.2
1.4 虚拟内存实现机制
Linux虚拟内存的实现需要多种机制的支持,他们之间的关系如下图所示,
-
首先内核通过地址映射机制把进程从磁盘映射到虚拟地址空间(注意,这里只是映射到虚拟地址空间,并不分配物理内存)
-
当进程执行时,如果发现要访问的页没有在物理内存时,就发出页请求(如图中①)
-
如果有空闲的内存可供分配,就请求分配内存(如图中②);并把正在使用的页记录在页缓存中(如图中③)
-
如果没有足够的内存可供分配,就调用交换机制,腾出一部分内存(如图中④⑤)
-
在地址映射机制中要通过TLB来加速物理页的寻找(如图中⑧)
-
交换机制中也要用到交换缓存(如图中⑥)
-
把物理页内容交换到交换文件后,也要通过修改页表来映射文件地址(如图中⑦)
说明:上面的最后2点场景,尚未理解清晰
2 进程用户空间管理
2.1 进程用户空间布局
-
每个程序编译链接后形成的ELF文件中包含代码段(Text)和数据段(Date和BSS),其中代码段在下,数据段在上
-
链接器和函数库也有自己的代码段(Text)和数据段(Data和BSS)
-
运行程序生成进程时,每个进程还有独占的堆(Heap)和栈(Stack)空间,注意二者的生长方向
-
进程要映射的文件(e.g. 动态库、普通文件)被映射到内存映射区(Memory Mapping Region)
说明:查看进程用户空间布局
使用下面的命令,可以查看指定进程的内存布局
cat /proc/进程号/map
实现测试用例如下,最终调用pause系统调用是为了让进程处于TASK_INTERRUPTIBLE状态,便于通过其他终端观察该进程的状态
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int a = 1; // data段
int b; // bss段
int main(void)
{
char *buff = NULL; // 栈
buff = (char *)malloc(1024); // 堆
printf("address of a is %p\n", &a);
printf("address of b is %p\n", &b);
printf("address of buff is %p\n", &buff);
printf("address of malloc memory is %p\n", buff);
printf("pid is: %d\n", getpid());
pause();
return 0;
}
该程序打印如下,
使用cat /proc/进程号/map命令查看一下该进程的用户空间布局,
可见程序中各变量地址所在的区域和预期是符合的,其中.data段和.bss段都在数据段部分
说明:/proc/进程号/map字段说明
① 地址:虚拟内存区的起始和终止地址(注意这里引入了虚拟内存区的概念)
② 权限:虚拟内存区的访问权限,r=read / w=write / x=execute / s=shared / p=private(copy on write)
③ 偏移(offset):虚拟内存区在被映射文件中的偏移量(注意这里引入了虚拟内存区与文件的映射关系)
④ 设备号(devno):文件所在设备的主次设备号
⑤ inode号:文件inode节点编号
⑥ 文件名:被映射文件的文件名,如果该虚拟存储区和磁盘文件无关则为空
对上述输出的完整理解,就需要到内核态一探究竟了~
2.2 进程用户空间的内核描述
2.2.1 概述
Linux把进程的用户空间划分为若干个区间进行管理,这些区间称为虚拟内存区(简称vma)
进程的用户地址空间主要由 mm_struct 和 vm_area_struct 结构来描述,其中,
mm_struct:描述进程整个用户空间
vm_area_struct:描述用户空间中的各个虚拟内存区
mm_struct 和 vm_area_struct 的关系如下图所示
可见 mm_struct 结构包含在 task_struct 结构中,用于描述一个进程的整个用户空间;而mm_struct 中包含一系列 vm_area_struct 结构,用于描述每个虚拟内存区
2.2.2 mm_struct 结构
mm_struct定义在include/linux/sched.h中,主要字段含义如下,
说明1:虚拟存储区的组织
一个进程的用户空间可能包含多个虚拟存储区,当vma较少时,使用单链表管理(按虚拟地址升序排列),由mmap字段索引;当vma较多时,使用红黑树管理,由mm_rb字段索引
说明2:mm_struct 对用户地址空间的整体描述
mm_struct中存储了一系列地址值,可以从整体上描述进程用户空间的范围
说明3:mm_users和mm_count字段
① mm_user是一个thread level的计数值,表示正在引用该地址空间的thread数目
例如在调用do_fork时如果带有CLONE_VM标志,则不会生成新的mm_struct,而只是将父进程mm_struct的mm_users字段加1,这种情况一般是调用vfork创建进程或调用clone创建子线程
顺便剧透一下,共享mm_struct和拷贝mm_struct是两回事儿,让各位先看一眼吧,下图中就是调用fork流程时的拷贝父进程mm_struct的操作
当有其他线程在查看该mm_struct的内容时,比如cat /proc/进程号/maps,也会增加mm_users计数,以确保在此过程中mm_struct不被释放
② mm_count的使用涉及内核线程和用户线程的区别。在Linux中,用户线程和内核线程都是task_struct的实例,区别是内核线程没有用户空间,所以内核线程的mm字段为NULL
在schedule函数中进行上下文切换时,会根据mm字段判断即将调度的是用户线程还是内核线程。内核线程不用访问用户空间,所以可以借用将被换出的用户线程的用户空间
其实mm_count字段的设置还是为了避免在mm_struct在使用过程中被释放,只有当mm_struct的mm_count字段为0时才会释放mm_struct结构,该过程在mmdrop函数中完成
而上图列表中描述在调用clone生成线程时会增加mm_count计数是错误的,如上文分析,此时增加的是mm_users计数
③ 如果mm_users字段为0时会怎样 ?
当mm_users字段为0时,会释放所有虚拟存储区,并将该mm_struct从mmlist链表中删除
2.2.3 vm_area_struct 结构
mm_struct定义在include/linux/mm.h中,主要字段含义如下,
说明1:将用户空间划分为虚拟存储区的原因
因为每个虚拟存储区可能来源不同,例如可执行镜像、共享库、动态分配的内存区等,对于不同的区间可能有不同的权限和不同的操作
vm_ops字段用来抽象对不同虚拟存储区的处理方法,其中的nopage函数是用于处理缺页异常的函数
说明2:vma如何映射到地址空间
可见栈、堆和BSS段的映射是匿名的,关于匿名映射,后文还有说明
说明3:数据结构关系图
理解时时刻记住,mm_struct结构是描述用户空间的
2.2.4 实例:内核态打印虚存区信息
cat /proc/进程号/maps打印的就是mm_struct中各个vm_area_struct的信息,我们在内核态也打印虚存区信息进行比对
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/sched.h>
static int pid;
module_param(pid, int, 0644);
static int __init hello_init(void)
{
struct task_struct *p = NULL;
struct vm_area_struct *temp = NULL;
// 根据PID查找task_struct
//p = find_task_by_vpid(pid);
p = pid_task(find_vpid(pid), PIDTYPE_PID);
temp = p->mm->mmap;
printk("mm_struct info:\n");
printk("start_code = %lx\n", p->mm->start_code);
printk("end_code = %lx\n", p->mm->end_code);
printk("start_data = %lx\n", p->mm->start_data);
printk("end_data = %lx\n", p->mm->end_data);
printk("start_brk = %lx\n", p->mm->start_brk);
printk("brk = %lx\n", p->mm->brk);
printk("mmap_base = %lx\n", p->mm->mmap_base);
printk("start_stack = %lx\n", p->mm->start_stack);
printk("The virtual memory area(VMA) are:\n");
// 遍历vm_area_struct,打印起止地址
while (temp) {
printk("start:%p\tend:%p\n", (unsigned int *)temp->vm_start,
(unsigned int *)temp->vm_end);
temp = temp->vm_next;
}
return 0;
}
static void __exit hello_exit(void)
{
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
内核态打印结果如下图所示,
对照之前cat /proc/进程号/maps的结果,可见task_struct的地址信息在vm_area_struct的范围之内,同时增补如下说明,
-
start_code & end_code 在进程代码段 vm_area_struct 范围内,但不足4KB(虚拟内存区的分配均以页为单位)
-
start_data & end_data 跨越了进程数据段的 2 个 vm_area_struct,其中一部分为只读,一部分为可读写。因为权限不同,所以需要组织在不同的 vm_area_struct中
-
start_brk & brk 之间的范围是堆(Heap),大小为132KB
-
进程中使用到的2个动态库(libc.so & ld.so)均映射到Memory Mapping Region,而task_struct中的mmap_base指向MMR区域的最高地址处
-
栈区vm_area_struct指向的范围为132KB
说明1:通过PID查找task_struct
在低于2.6.24版本中,使用find_task_by_pid函数查找task_struct
在2.6.24 ~ 2.6.31版本中,使用find_task_by_vpid函数查找task_struct
在后续的版本中不再包含上述2个函数,我们使用pid_task宏查找task_struct
说明2:malloc函数实现方式验证
malloc函数内部实际调用了brk和mmap两个系统调用,当所要分配的内存空间较少时,使用brk实现;当所要分配的内存空间较大时,使用mmap方式实现,而我们现在知道mmap函数在内核态会建立新的虚拟存储区。
下面我们就通过实验来验证下上面的说法,以及在内核态实际产生的效果
① 用户空间不再调用malloc函数分配内存,查看进程空间虚拟缓冲区布局
可见此时进程用户空间中是没有heap的;mm_struct中的start_brk & brk也是指向相同的位置,说明这段空间为0
可见之前的malloc(1024)内部是通过sbrk & brk系统调用实现的。
② 使用sbrk & brk实现与malloc类似的功能
进程可以通过增加堆的大小来分配内存,所谓堆是一段长度可变的连续虚拟内存,始于进程的未初始化数据段末尾(即BSS段),随着内存的分配和释放而增减。通常将堆的当前内存边界称为program break
所需头文件 | #include <unistd.h> |
函数原型 | void *sbrk(intptr_t increment); |
函数参数 | increment:将program break在原有地址上增加从参数increment传入的大小 sbrk(0)将返回当前program break的位置,对其不做改变 |
函数返回值 | 成功返回之前program break的位置;错误返回-1 |
从验证结果可知,
-
malloc中调用sbrk & brk的方式比示例代码复杂很多,但示例代码更能体现这2个函数的功能本质(虽然实际编程中几乎不会直接使用)
-
sbrk(0)返回的是heap区域的低端地址,位置在进程的bss段之上,但并非紧挨着
-
使用brk分配1KB内存,虚拟内存区是按页对齐分配了4KB(0x9515000 ~ 0x9516000)
-
mm_struct中的start_brk & brk覆盖范围为1KB,与brk的调用是一致的(注意,在调用malloc时,mm_struct & 虚拟内存区的heap范围是一致的)
③ 调用2次malloc
可见内核分配的heap区为132KB
④ 使用malloc分配较大内存
使用malloc默认分配的heap的大小均为132KB,这应该不是一个巧合,我们使用malloc分配133KB进行验证
可见此时新建了虚拟内存区,并进行了匿名映射,而不是使用heap来满足内存分配的需求
⑤ 累计超过132KB
可见累计超过132KB时,也使用mmap实现,但是!!!如果按如下方式分配,则是部分用heap,部分用mmap
内部具体的实现方式由malloc函数控制
同时需要注意,malloc函数返回的地址在内核分配地址的基础上增加了8B偏移
补充:根据后续课程,malloc函数内部以128KB为调用mmap和brk的阈值
上文说明了mm_struct结构和vm_area_struct结构如何描述进程用户空间,那么这种映射关系是什么时候建立的呢?
3 创建进程用户空间
3.1 调用fork创建用户空间
3.1.1 概述
-
fork系统调用在创建新进程时,也为该进程创建完整的用户空间
-
新进程的用户空间是通过拷贝或共享父进程的用户空间来实现的
-
上述过程通过调用 copy_mm 函数实现,在该函数中可以明显区分出是拷贝还是共享
3.1.2 copy_mm 函数分析
先来看下 copy_mm 函数的调用关系,
do_fork
--> copy_process
--> copy_mm
可见无论是新建用户进程(fork & vfork)、用户线程(clone)还是内核线程(kernel_thread),都会调用到 copy_mm
// copy_mm函数被copy_process函数调用
// clone_flags:创建进程时的拷贝参数
// tsk:指向新进程的task_struct
static int copy_mm(unsigned long clone_flags, struct task_struct * tsk)
{
struct mm_struct * mm, *oldmm;
int retval;
// 初始化task_struct的相关计数
// 这里主要是swap和context switch信息
tsk->min_flt = tsk->maj_flt = 0;
tsk->nvcsw = tsk->nivcsw = 0;
// 新进程的用户空间初始化为空
tsk->mm = NULL;
tsk->active_mm = NULL;
/*
* Are we cloning a kernel thread?
*
* We need to steal a active VM for that..
*/
// 内核线程没有用户空间,直接返回0
// 在调度内核线程时会借用上一个用户进程的用户空间
// 内核线程的父进程一定是内核线程,所以都没有用户空间
oldmm = current->mm;
if (!oldmm)
return 0;
// 如果拷贝标志中包含CLONE_VM,则与父进程共享用户空间
// 此时不会分配mm_struct,只是增加父进程mm_struct的mm_users计数
// 这种情况一般是调用clone创建用户线程或调用kernel_thread创建内核线程
// 但是根据之前的分析,如果是创建内核线程,在上一个判断中就直接返回了
if (clone_flags & CLONE_VM) {
atomic_inc(&oldmm->mm_users);
mm = oldmm;
/*
* There are cases where the PTL is held to ensure no
* new threads start up in user mode using an mm, which
* allows optimizing out ipis; the tlb_gather_mmu code
* is an example.
*/
spin_unlock_wait(&oldmm->page_table_lock);
goto good_mm;
}
retval = -ENOMEM;
// 从slab分配mm_struct
mm = allocate_mm();
if (!mm)
goto fail_nomem;
/* Copy the current MM stuff.. */
// 这里就是拷贝父进程用户空间
memcpy(mm, oldmm, sizeof(*mm));
// mm_init函数
// 1. 初始化各种计数器
// 2. 初始化锁
// 3. 分配并初始化页表,内核页表直接拷贝,用户页表清零
if (!mm_init(mm))
goto fail_nomem;
// 设置mm_struct中与进程上下文切换相关字段
if (init_new_context(tsk,mm))
goto fail_nocontext;
// 拷贝父进程的虚拟内存区
// 写时拷贝就是在这个函数中实现
retval = dup_mmap(mm, oldmm);
if (retval)
goto free_pt;
mm->hiwater_rss = mm->rss;
mm->hiwater_vm = mm->total_vm;
good_mm:
tsk->mm = mm;
tsk->active_mm = mm;
return 0;
free_pt:
mmput(mm);
fail_nomem:
return retval;
fail_nocontext:
/*
* If init_new_context() failed, we cannot use mmput() to free the mm
* because it calls destroy_context()
*/
mm_free_pgd(mm);
free_mm(mm);
return retval;
}
说明1:写时拷贝的实现
dup_mmap
--> copy_page_range
--> copy_pud_range
--> copy_pmd_range
--> copy_pte_range
--> copy_one_pte
在copy_one_pte函数中,如果拷贝的页可写,则将父(src_pte)子(pte)进程中的这个pte表项均设置为只读
这样当父子进程中任何一个进程要进行写入时,就会触发异常,在异常中可识别出需要写时拷贝,则该物理页面被复制一份
说明2:fork为什么能快速创建进程
从上面的分析可知,进程用户空间的创建主要依赖于父进程,而在创建进程的过程中,所做的工作仅仅是mm_struct结构的建立、vm_area_struct结构的建立以及页目录和页表的建立,并没有真正地复制一个物理页面
3.2 调用exec进行虚存映射
3.2.1 虚存映射的含义
虚存映射就是把文件从磁盘映射到进程的用户空间,将对文件的访问转化为对虚存区的访问
3.2.2 虚存映射时机
- 当调用exec系统调用开始执行一个进程时,进程的可执行镜像(包括代码段、数据段和堆等)必须装入到进程的用户地址空间;如果该进程用到了任何一个共享库,则共享库也必须装入到进程的用户空间
- 由此可见,Linux并不将镜像装入物理内存,可执行文件只是被映射到进程的用户空间中
- 将可执行镜像和共享库加载到用户空间,就是将这些文件和用户空间的vma建立关联(当然,还存在和文件无关的匿名映射,比如heap区)
3.2.3 新建vma的方法
-
在用户空间可以通过mmap系统调用获取do_mmap。
-
在内核空间可以直接调用do_mmap创建一个新的虚存区,do_execve在加载elf文件时就会调用do_mmap创建新的虚存区。当可执行镜像映射到进程的用户空间时,将产生一组vm_area_struct 结构来描述各虚存区,而每个虚存区代表可执行镜像的一部分
3.2.4 虚存映射种类
共享映射
有多个进程共享这一映射,如果一个进程对共享的虚存区进行了写操作,其他进程都能感知到,而且会修改磁盘上对应的文件,文件的共享就可以通过这种方式实现(但是需要用户自己进程文件操作的同步与互斥)
私有映射
进程创建这种映射只是为了读文件而不是写文件,因此对虚存区的写操作不会修改磁盘上的文件,因此私有映射的效率比共享映射高,之前验证中查看的虚存区类型均为私有的,权限中的p就是private的含义
匿名映射
与文件无关的映射,比如上文介绍的分配较大内存的malloc调用,就是用匿名映射的方式实现的
教材中推断cat /proc/进程号/maps中的匿名映射对应BSS段(后续根据代码验证)
3.3 与用户空间相关的主要系统调用
说明:mmap函数
所需头文件 | #include <sys/mman.h> |
函数原型 | void *mmap(void *addr, size_t len, int prot, int flag, int fd, off_t off); |
函数参数 | addr:映射到用户空间的起始地址,通常将其设置为0,表示由系统选择该映射区的起始地址,该函数成功时返回的就是该映射区的起始地址 len:映射的字节数 prot:映射区的保护模式,可选项如下, PROT_READ:映射区可读 PROT_WRITE:映射区可写 PROT_EXEC:映射区可执行 PROT_NONE:映射区不可访问 flag:映射区的属性,可选项如下, MAP_SHARED:指定映射区可共享,存储操作修改映射文件 MAP_PRIVATE:对映射区的存储操作导致创建该映射文件的一个私有副本,不会修改映射的文件 MAP_LOCKED:锁定这个虚存区,不能交换 MAP_ANONYMOUS:匿名区,与文件无关 其中MAP_SHARED & MAP_PRIVATE必须指定且只指定一个 fd:要被映射的文件描述符 off:要映射字节在文件中的起始偏移量 说明:如果是匿名映射,fd & off参数被忽略,但是有些实现要求fd为-1 |
函数返回值 | 成功返回映射区的起始地址;若出错,返回MAP_FAILED |
使用mmap映射文件的操作比较常见,此处重点关注下匿名映射的实现。从上文中可知,匿名映射是和共享映射 & 私有映射并列的一种映射类型。但是根据mmap函数的说明,匿名映射更像是附加在共享映射 & 私有映射上的一种属性,即与文件无关,下面对此进行验证
#include <stdio.h>
#include <sys/mman.h>
int main(void)
{
int fd = -1;
int pid = -1;
int *map = NULL;
map = (int *)mmap(0, 4, PROT_READ|PROT_WRITE,
MAP_SHARED|MAP_ANONYMOUS, 0, 0); // 共享且匿名
if (map == MAP_FAILED) {
printf("mmap error\n");
return -1;
}
*map = 100;
pid = fork();
if (pid == 0) {
// 子进程修改
*map = 200;
} else if (pid > 0) {
// 父进程读取
wait(0);
printf("value = %d\n", *map);
} else {
printf("fork error\n");
return -1;
}
return 0;
}
示例中以共享且匿名的方式建立映射区,根据结果,子进程的修改父进程可感知
如果将映射方式修改为私有且匿名,则子进程的修改父进程无法感知,这也就体现了匿名映射是一种附加属性
map = (int *)mmap(0, 4, PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS, 0, 0); // 私有且匿名
mmap调用流程如下:
4 请页机制概述
4.1 请页机制的目的
-
do_mmap建立了文件到虚存区的映射,但是并没有建立虚拟页面到物理页面的映射,这种映射是通过请页机制动态建立的
-
Linux采用请页机制来节约物理内存,在运行程序时,Linux仅将当前要使用的用户空间中的少量页面装入内存,需要时再通过请页机制将特定页面调入内存
说明:请页机制是有系统开销的,但是由于以下原因,可以认为缺页异常是一种稀有事件,
① 进程开始运行时并不访问其地址空间中的全部地址,事实上,有一部分地址也许进程永远不会使用(e.g. 进程只使用libc库中很小一部分函数)
② 局部性原理保证了在程序执行的每个阶段,真正使用的进程页只有一小部分,因此暂时不用的页没必要调入内存
4.2 页故障原因
当要访问的页不在内存时,处理器将向Linux报告一个页故障及对应的页故障原因,页故障的产生有如下三种原因,
程序出现错误
虚拟地址无效(例如要访问的虚拟地址在PAGE_OFFSET[3GB]之外),则该地址无效,Linux将向进程发送一个信号并终止进程的运行
缺页异常
虚拟地址有效,但其所对应的页当前不在物理内存中,即发生缺页异常。此时操作系统必须从磁盘或交换文件(此页被换出)中将其装入物理内存
保护错误
要访问的虚拟地址被写保护,即保护错误,此时操作系统需要判断出如下2种情况,
① 如果是某个用户进程正在写当前进程的地址空间,则发送一个信号并终止进程的运行
② 如果错误发生在一个旧的共享页上,则要对这一共享页进行复制,也就是写时拷贝
4.3 缺页异常处理
4.3.1 总体方案
- 当一个进程运行时,如果CPU访问了一个有效的虚拟地址,但是这个地址对应的页没有在内存中,则CPU产生一个缺页异常,同时将这个虚拟地址存入CR2寄存器
- Linux的缺页异常处理首先要对产生缺页的原因进行区分:是由编程错误引起的异常,还是由访问进程用户空间的页尚未分配物理页面所引起的异常
4.3.2 处理流程
- 缺页异常由 do_page_fault 函数处理,该函数首先从 CR2 寄存器读取引起缺页的虚拟地址,如果没有找到,则说明访问了非法虚拟地址,Linux会发送信号终止进程,否则,检查缺页类型,如果是非法类型(越界错误、段权限错误等)同样会发送信号终止进程。
- 缺页异常肯定是发生在内核态,如果发生在用户态则必定是错误,于是把相关信息保存在进程的PCB中。
- 对有效的虚拟地址,如果是缺页异常,Linux必须区分页所在的位置,即判断页是在交换文件中,还是在可执行镜像中。Linux可以通过页表项进行判断,如果页表项非空,但对应的页不在内存,则说明该页处于交换文件中,操作系统要从交换文件装入页。
下图是笨叔给出的32位操作系统用户空间发送缺页异常处理流程:
4.4 请页函数概述
请页函数的核心作用就是得到一个物理页的页描述符(struct page),用于和引起缺页的线性地址建立映射关系
根据vm_ops的nopage字段是否为空,分为如下2种情况,
-
nopage字段不为空,说明该虚存区映射了一个磁盘文件,nopage字段指向从磁盘进行读入的函数(这种情况涉及磁盘文件的底层操作)
-
nopage字段为空,说明该虚存区没有映射磁盘文件,也就是说他是一个匿名映射。因此do_no_page调用 do_anonymous_page 函数获得一个新的页面
在获取到页面(new_page)之后,内核就会据此填充进程页表,注意其中pte_offset_map和pte_unmap的配对使用
5. 物理内存分配与回收
5.1 物理内存管理机制
基于物理内存在内核空间中的映射原理,物理内存的管理机制主要有以下4种,
伙伴算法
负责大块连续物理内存的分配和释放,以页框为基本单位,该机制可避免外部碎片
per-CPU页框高速缓存
内核经常请求和释放单个页框,该缓存包含预先分配的页框,用于满足本地CPU发出的单一页框请求
slab缓存
负责小块连续物理内存的分配,并且他也作为高速缓存,主要针对内核中经常分配并释放的对象
vmalloc机制
vmalloc机制使得内核可以通过连续的线性地址来访问非连续的物理页框,这样可以最大限度的使用高端物理内存
5.2 物理内存的组织
5.2.1 UMA和NUMA
目前有两种类型的计算机,分别以不同的方法管理物理内存,
NUMA计算机(non-uniform memory access)
一种多处理器计算机,每个CPU拥有各自的本地内存。这样的划分使每个CPU都能以较快的速度访问本地内存,各个CPU之间通过总线连接起来,这样也可以访问其他CPU的本地内存,只是速度较慢
UMA计算机(uniform memory access)
将可用内存以连续方式组织起来
5.2.2 Linux物理内存组织
-
为了兼容NUMA模型,Linux内核引入了内存节点的概念,每个节点关联一个CPU。各个节点又被划分为几个内存区,每个内存区中又包含若干个页框
-
因此物理内存在逻辑上被划分为三级结构,分别使用 pg_data_t(节点)、zone(区)和page(页框)这三种数据结构加以描述
说明:NUMA & UMA中的内存节点
NUMA计算机中每个CPU的物理内存称为一个内存节点,内核通过 pg_data_t 数据结构来描述,系统内的所有节点形成一个双向链表
UMA模型下的物理内存只对应一个节点,也就是说整个物理内存形成一个节点
5.2.3 内存管理区(zone)
各个节点划分为若干个区,通过下面几个宏来标记物理内存不同的区,
ZONE_DMA:标记适合DMA的内存区
ZONE_NORMAL:可以直接映射到内核空间的物理内存
ZONE_HIGHMEM:高端物理内存
这里再强调一下,ZONE管理的是物理内存
说明:64位操作系统不再有高端内存的概念,可以支持大于4GB的内存寻址,ZONE_NORMAL空间扩展到64GB或128GB
5.2.4 页描述符 struct page
内核使用struct page结构表示系统中的每个物理页,也称作页描述符
字段 | 说明 |
flags | 页的状态(e.g. 页是否是脏的,是否被锁定在内存中),这些标志定义在<linux/page_flags.h>文件中 |
_count | 页的引用计数,当计数值变为0时,说明当前内核并没有引用这一页,于是在新的分配中就可以使用该页(使用page_count宏检查该字段的值) |
mapping | 用于管理文件(struct inode)映射到内存的页面 |
private | 页的私有数据 |
virtual | 页的虚拟地址 |
lru | 将page链入LRU链表,用于页面回收 |
说明1:80386的页标志
比如后面要用到的SetPageReserved宏,就是设置页的PG_reserved标志
#define SetPageReserved(page) set_bit(PG_reserved, &(page)->flags)
说明2:内核使用struct page结构来管理系统中所有的页,因为内核需要知道一个页是否空闲;如果页已经被分配,内核还需要知道谁拥有这个页
要管理系统中这么多的物理页,可以采用最简单的数组结构,
struct page *mem_map;
这是一个全局变量,系统在初始化时建立该数组
5.4 伙伴(Buddy)算法
5.4.1 伙伴算法概述
从mem_map数组中频繁地请求和释放不同大小的连续页面,必然导致外碎片问题,因此 Linux 引入了伙伴算法
伙伴算法将所有的空闲页面分为(MAX_ORDER + 1)个块链表,每个链表中的一个块含有2的幂次个页面,我们把这种块简称为"页块"或"块"
例如第0个链表中的块的大小都是2^0(1个页面),第9个链表块中块的大小都是2^9(512个页面),MAX_ORDER 的默认值为11(即最大的页块为2048个页面)
Linux中使用 struct free_area 结构管理伙伴系统
struct zone {
// 其他成员
struct free_area free_area[MAX_ORDER];
};
struct free_area {
struct list_head free_list; // 大小为2^k页的空闲块对应的页描述符
unsigned long nr_free; // 大小为2^k的空闲块的个数
};
当 nr_free 为 0 且 fre_list 为空时,说明没有大小为 2^k 页的空闲页块。
5.4.2 伙伴算法的分配原理
在伙伴算法中,大小相同且物理地址连续的两个页块被称为伙伴
如果要分配阶为n的页块,则先从第n个页框链表中查找是否存在空闲页块,如果有则分配;否则在第(n + 1)个页框链表中继续查找,直到找到为止
示例:如果申请大小为8的页块(分配阶为3),但却在页块大小为32的链表中找到空闲块,则先将这32个页面等分,前一半作为分配使用,另一半作为新元素插入下级大小为16的链表中;继续将前一半大小为16的页块等分,一半分配,另一半插入大小为8的链表中
以上过程的逆过程就是页块的释放过程,伙伴算法把满足伙伴条件的页块合并为一个块,如果合并后的块还可以跟相邻的块进行合并,则继续合并
5.4.3 伙伴算法核心函数分析
/*
* zone:指定分配的内存区
* order:指定要分配页块的阶数
*/
static struct page *__rmqueue(struct zone *zone, unsigned int order)
{
struct free_area * area;
unsigned int current_order;
struct page *page;
// 从指定阶数遍历到最大阶数
for (current_order = order; current_order < MAX_ORDER; ++current_order) {
area = zone->free_area + current_order;
// 如果当前阶数无空闲页块则继续查找更高一阶
if (list_empty(&area->free_list))
continue;
page = list_entry(area->free_list.next, struct page, lru);
// 将待分配页块移出链表,该页块可能超过请求的大小
list_del(&page->lru);
rmv_page_order(page);
// 减少该阶页块个数
area->nr_free--;
// 减少内存区空闲页个数
// 注意:减少空闲页使用的是指定要分配的阶数order,而非当前阶数
// 因为页块中多余的页还会交还伙伴系统
zone->free_pages -= 1UL << order;
// 如果所得到的页块大于所请求的页块,则按照伙伴算法的分配原理将
// 大的页块分裂成小的页块分配
return expand(zone, page, order, current_order, area);
}
return NULL;
}
说明1:rmv_page_order函数实现
static inline void rmv_page_order(struct page *page)
{
__ClearPagePrivate(page);
page->private = 0;
}
rmv_page_order 函数清除页块首个页面的 private 字段,该字段原先记录了该页块的阶数(后文可见)
说明2:expand函数实现
// 使用struct page的private字段记录页块阶数
static inline void set_page_order(struct page *page, int order)
{
page->private = order;
__SetPagePrivate(page);
}
/*
* zone:指定分配的内存区
* page:查找到的可供分配的页块首个页面指针
* low:要分配页块的阶数
* high:可供分配的页块的阶数(>=low)
* area:对应high阶数的链表数组成员地址
*/
static inline struct page * expand(struct zone *zone, struct page *page,
int low, int high, struct free_area *area)
{
unsigned long size = 1 << high; // 可供分配的页块页面数
while (high > low) {
area--;
high--;
size >>= 1; // 将可供分配的页框一分为二
BUG_ON(bad_range(zone, &page[size]));
// 将折半的页块插入
list_add(&page[size].lru, &area->free_list);
area->nr_free++;
// 设置分裂后页块的阶数
set_page_order(&page[size], high);
}
return page;
}
说明3:struct page的lru字段
当struct page结构中的lru字段不用于组织LRU链表时,可用于其他用途,比如此处用于组织页块
说明4:分区页框管理器分为两大部分:前端的管理区分配器和伙伴系统
管理区分配器负责搜索一个能满足请求页块大小的管理区,在每个管理区中,具体的页框分配工作由伙伴系统负责。为了达到更好的系统性能,单个页框的申请工作直接通过per-CPU页框高速缓存完成。
5.4.4 物理页面的分配与回收
内核中有6个稍有差别的函数和宏来请求页框,但最终都是调用 alloc_pages 来获取连续的物理页框
说明1:get_page类函数
上图中4个绿色的get_page类函数返回的都是物理页面对应的虚拟地址,以__get_free_pages函数为例,
alloc_pages 函数返回的是页描述符,然后通过 page_address 函数获取物理页面对应的虚拟地址,如果分配的物理页面在内核的线性映射区域,则直接计算出对应的虚拟地址
static inline void *lowmem_page_address(struct page *page)
{
return __va(page_to_pfn(page) << PAGE_SHIFT);
}
如果不需要得到物理页面对应的虚拟地址,而只是需要物理页面的描述符,则可以调用alloc_pages函数
说明2:释放物理页面时有2种参数,
① 物理页面对应的虚拟地址
使用free_page & free_pages函数释放,其中free_page函数调用free_pages函数,实现释放1个页面
② 物理页面的页描述符
使用__free_page & __free_pages函数释放,其中__free_page函数调用__free_pages函数,实现释放1个页面
上述函数最终都是调用__free_pages函数实现释放
5.5 Slab分配机制
5.5.1 Slab机制概述
-
上文介绍的伙伴算法负责大块连续物理内存的分配和释放,以页框为单位,从而解决了外部碎片问题
-
如果要分配小块内存,则需要解决内部碎片的问题。Slab机制的提出,最初就是为了解决物理内存的内部碎片问题
-
Slab将内核中常用的数据结构看作对象,并为每一种对象建立高速缓存,内核对象的分配和释放均在这块高速缓存中进行,进而减少了对伙伴算法的调用次数
-
减少对伙伴算法的调用次数,又可以避免弄脏硬件高速缓存,进而减少对内存的平均访问次数,提高了内存访问效率
5.5.2 Slab的组成
-
每种对象的高速缓存由若干个slab组成
-
每个slab由若干个物理页框组成(页框通过伙伴算法分配)
-
每个slab包含若干个同种类型的对象,这些对象或已被分配,或空闲
说明:尽管Slab高速缓存在英文中使用了Cache这个词,但实际上是内存中的区域,而不是指硬件高速缓存
但是通过在一个slab中组织多个对象,可以提高硬件高速缓存的命中率
个人:简单来说,Slab从伙伴算法那里批发物理页框,然后零售给对象
5.5.3 Slab专用缓冲区
5.5.3.1 适用场景
专用缓冲区主要用于频繁使用的数据结构,如 task_struct、mm_struct、vm_area_struct、file、dentry 和 inode 等
5.5.3.2 缓冲区的建立和撤销
kmem_cache_t * kmem_cache_create(const char *name, size_t size, size_t offset,
unsigned long flags, void (*ctor)(void*, kmem_cache_t *, unsigned long),
void (*dtor)(void*, kmem_cache_t *, unsigned long));
参数 | 含义 |
name | 缓冲区名称,在/proc/slabinfo中用作标识 |
size | 对象大小 |
offset | 在缓冲区内第一个对象的偏移,用来确定在页内进行对齐的位置,缺省为0,表示标准对齐 |
flags | 对缓冲区设置的标志, SLAB_HWCACHE_ALIGN:第一个缓冲区中的缓冲行边界对齐(16或32字节) SLAB_NO_REAP:不允许系统回收内存 SLAB_CACHE_DMA:使用DMA内存 |
ctor | 分配对象时的构造函数,一般为NULL |
dtor | 释放对象时的析构函数,一般为NULL |
说明:撤销缓冲区使用kmem_cache_destroy函数实现
5.5.3.3 对象的分配和释放
kmem_cache_create 函数创建的缓冲区中并不包含任何slab,因此也没有空闲对象,只有以下两个条件均为真时,才给缓冲区分配slab
-
已发出一个分配新对象的请求
-
缓冲区不包含任何空闲对象
对象的分配和释放使用下面2个函数实现,
void *kmem_cache_alloc(kmem_cache_t *cachep, int flags);
void kmem_cache_free(kmem_cache_t *cachep, void *objp);
说明:kmem_cache_alloc函数的flags参数
如果缓冲区中所有的slab中都没有空闲的对象,那么slab必须调用__get_free_pages获取新的页面,flags是传递给该函数的参数,一般应该是GFP_KERNEL或GFP_ATOMIC
5.5.3.4 实例:task_struct的分配
说明:如果要频繁创建很多相同类型的对象,就应该考虑使用Slab专用缓冲区,不要自己去实现空闲链表
5.5.4 Slab通用缓冲区
5.5.4.1 适用场景
-
在内核中初始化开销不大的数据结构可以合用一个通用缓冲区
-
通用缓冲区最小为32B,然后依次为64B、128B直至128KB(一般kmalloc分配的内存上限)
-
通用缓冲区一般用于使用不频繁的数据结构(对于频繁使用的数据结构,使用专用Slab是可以从硬件高速缓存中获益的)
说明1:无论是Slab专用缓冲区还是通用缓冲区,都是用于小块物理内存分配的,他们本质上都受制于伙伴系统最大页块的限制(e.g. MAX_ORDER为11时,最大的页块为8MB)
如果需要分配大块连续的物理内存,需要使用Linux中的其他内存分配机制(e.g. memreserve)
说明2:通过Slab通用缓冲区分配内存是会造成内碎片的,但是由于通用缓冲区针对不频繁使用的数据结构,所以对性能影响不大
5.5.4.2 缓冲区的分配和释放
Slab通用缓冲区的分配和释放使用大家熟悉的kmalloc和kfree函数实现,此处不再赘述
void *kmalloc(size_t size, int flags); void kfree(const void *ptr);
说明:kfree用于释放由 kmalloc 函数分配的内存块,如果要释放的内存不是由kmalloc分配的,或者double free,都会导致严重后果,与用户空间类似,kfree(NULL)是安全的
5.6 内核空间非连续内存区的分配
5.6.1 非连续内存区
-
非连续内存处于VMALLOC_START到VMALLOC_END之间
-
非连续内存区前后与非内存区之间插入的区间为安全区,用于"捕获"对非连续内存的非法访问
-
内核使用vmalloc接口分配虚拟内存中连续但物理内存不一定连续的内存
5.6.2 非连续内存区数据结构 struct vm_struct
Linux内核使用struct vm_struct结构来描述非连续内存区,
字段 | 说明 |
addr | 非连续内存区的起始地址(虚拟地址) |
size | 非连续内存区的大小 + 4KB(安全区的大小) |
flags | 非连续内存区标志位 |
pages | 非连续内存区页指针数组首地址(详见vmalloc函数分析) |
nr_pages | 非连续内存区页数 |
phys_addr | 代码中实际未使用 |
next | 非连续内存区组成一个单链表(按虚拟地址升序排列) |
说明:从struct vm_struct结构的flags字段可知,vmalloc & ioremap机制都是基于非连续内存区实现的,只不过ioremap映射的物理地址是连续的
5.6.3 vmalloc函数分析
下面先给出vmalloc函数的核心调用关系,
vmalloc
--> __vmalloc
--> get_vm_area // 获取vm_struct结构
--> alloc_page // 逐页获取物理页面
--> map_vm_area // 修改内核页表,映射非连续内存区
下面则逐个步骤进行分析,
5.6.3.1 vmalloc
_GFP_HIGHMEM:从高端内存分配物理页面
PAGE_KERNEL:建立页表项时的权限,PAGE_KERNEL对应的页表项权限如下,
#define _PAGE_KERNEL \
(_PAGE_PRESENT | _PAGE_RW | _PAGE_DIRTY | _PAGE_ACCESSED | _PAGE_NX)
5.6.3.2 __vmalloc
__vmalloc函数中最重要的就是上面4个步骤,get_vm_area & map_vm_area下面单独介绍,此处说明中间2个步骤,
分配页描述符指针数组
首先分配用于存储页描述符指针的数据pages,如果数组大小小于PAGE_SIZE则使用kmalloc分配;如果大于PAGE_SIZE则递归调用__vmalloc,此时会形成非连续内存区逐级存放的效果
以32位处理器4KB页为例,最多容纳4KB / 4B = 1KB个页描述符指针,也就是对应4MB内存,所以这种递归调用的层次是非常有限的
假设递归调用的第2级正好需要1个页来存储页描述符指针,即页描述符指针数组为4MB,则对应的内存为4MB / 4 * 4KB = 4GB,然后谁没鸟事用vmalloc分配4GB的内存
逐个分配物理页面,并将页描述符存储在数组中
逐个分配物理页面,也是导致物理地址可能不连续的原因
5.6.3.3 get_vm_area
__get_vm_area函数中使用 kmalloc 分配 struct vm_struct 结构,并将其加入vmlist单链表
5.6.3.4 map_vm_area
在map_vm_area函数中,会从pgd --> pud --> pmd --> pt逐级分配页目录和页表,并逐页进行映射
5.6.4 vmalloc 与 kmalloc 辨析
-
vmalloc和kmalloc都可用于内核空间分配内存
-
kmalloc分配的物理内存处于3GB ~ high_memory之间,这段内核空间与物理内存形成线性映射
-
vmalloc分配的物理内存在VMALLOC_START ~ VMALLOC_END之间,这段非连续内存区映射到物理内存也可能是非连续的
-
vmalloc分配的物理地址无需连续,而kmalloc确保页在物理上是连续的
说明1:尽管仅仅在某些情况下才需要物理连续的内存块,但是很多内核代码都调用kmalloc而不是vmalloc获取内存。这主要是出于性能的考虑,因为vmalloc函数为了把物理上不连续的页面映射到连续的虚拟地址,需要专门建立页表,并逐页进行映射
说明2:各种内存申请函数关系如下图所示
根据上文分析,vmalloc函数已经分配了物理页面并进行了映射,为何还会触发缺页异常呢?细看vmalloc的实现,可见此时仅修改了init_mm标识的内核页表,并未同步到用户态进程页表,所以当用户态进程若进入内核态访问vmalloc分配的内存,依然会触发缺页异常,此时主要的行为就是更新进程页表(这也是将内核页表同步到进程页表的时机)。
5.6.5 内存分配实例
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/init_task.h>
#include <linux/sched.h>
#include <linux/list.h>
#include <linux/slab.h>
#include <linux/mm.h>
#include <linux/vmalloc.h>
#include <asm/page.h>
unsigned long pagemem = 0UL;
unsigned char *kmallocmem = NULL;
unsigned char *vmallocmem = NULL;
static int __init hello_init(void)
{
pagemem = __get_free_page(GFP_KERNEL);
if (!pagemem) {
printk("__get_free_page error\n");
goto err1;
}
printk("pagemem = 0x%lx\n", pagemem);
kmallocmem = kmalloc(100, GFP_KERNEL);
if (!kmallocmem) {
printk("kmalloc error\n");
goto err2;
}
printk("kmallocmem = 0x%p\n", kmallocmem);
vmallocmem = vmalloc(1000000);
if (!vmallocmem) {
printk("vmalloc error\n");
goto err3;
}
printk("vmallocmem = 0x%p\n", vmallocmem);
return 0;
err3:
kfree(kmallocmem);
err2:
free_page(pagemem);
err1:
return -1;
}
static void __exit hello_exit(void)
{
vfree(vmallocmem);
kfree(kmallocmem);
free_page(pagemem);
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("xxx");
MODULE_DESCRIPTION("A simple Hello World Module");
MODULE_ALIAS("a simplest module");
可见分配的内存的虚拟地址是符合预期的,__get_fre_page 和 kmalloc 分配的内存在线性映射区,vmalloc 分配的内存在非连续内存区
6. 交换机制概述
6.1 交换的基本原理
- 当空闲内存数量少于某个阈值时,Linux内核需要释放部分物理内存页面,将内存的内容存储到一个专用的磁盘空间(swap分区),是一种以时间换空间的策略
- 交换的单位为页面(最早的Unix交换以进程为单位)
- 页面的交换有很大的时间开销,是不得已而为之,他会使进程的执行在时间上有较大的不确定性
- 页面交换由内核守护进程kswapd进行
- 可以通过命令或系统调用开启或关闭交换机制,关闭方式可参考如下blog:《Linux 关闭交换内存》
6.2 交换的主要问题
6.2.1 哪种页面要换出
-
交换的最终目的是页面的回收
-
并非内存中所有页面都可以交换出去,只有与用户空间建立了映射关系的物理页面才会被交换出去,而内核空间中内核所占的页面则常驻内存
说明1:用户空间不同区域的交换策略
① 进程镜像中的代码段、数据段、堆、栈
代码段、数据段所占内存可以被换入换出,但堆和栈的页面一般不被换出,目的是简化内核的设计
② 通过系统调用mmap把文件的内容映射到用户空间
这些页面的交换区就是被映射的文件本身
③ 进程间共享内存区
这些页面的换入换出比较复杂
说明2:内核页面的释放
内核中动态分配(kmalloc / vmalloc / __get_free_pages)的页面在使用完成后会释放到空闲页面中,但是有些页面虽然使用完毕,但是其内容仍有保存价值,因此不立即释放,而是进入一个LRU队列,经过一段时间的缓冲让其老化,如果在此期间又要用到其中的内容,即可直接投入使用
这种特殊页面包括如下几种,
① 文件系统中用来缓冲存储一些文件目录结构 dentry 的空间
② 文件系统中用来缓冲存储一些索引节点 inode 的空间
③ 用于文件系统读写操作的缓冲区
free命令中的 cache 和 buffer 就是这些文件系统的缓冲
6.2.2 如何在交换区存放页面
-
交换分区也被划分为块,每个块的大小正好等于一页,交换区中的一块称作一个页插槽(Page Slot)
-
当进行交换时,内核尽可能把换出的页放在相邻的插槽中,从而减少访问交换区时磁盘的寻道时间
-
如果系统使用多个交换区,快速交换区(即放在快速磁盘中的交换区)可以获得比较高的优先级,应当优先使用。当使用优先级相同的多个交换区时,应平衡负载
6.2.3 如何选择被交换出的页面
此处循序渐进说明交换策略的选择,以简述设计原理,
6.2.3.1 需要时才交换
- 当发现没有空闲页面可分配时进行交换,这是一种被动交换策略
- 这种策略虽然简单,但是系统需要在分配内存的重要时刻花费相当多的时间进行交换
6.2.3.2 系统空闲时交换
- 在系统空闲时预先换出一些内存页面,维持一定数量的空闲页面,这是一种主动交换策略
- 一般使用LRU队列确定要换出的页面
6.2.3.3 换出但不立即释放
- 当系统选出要交换的页面时,将相应页面写入磁盘交换区,并修改相应页表项将Preset标志置为0,但是并不立即释放,而是将该struct page结构留在一个缓冲(Cache)队列中,使其从活跃(Active)状态转为不活跃(Inactive)状态
- 这些页面的最后释放要推迟到必要时才进行,这样如果一个页面在释放后又立即被访问,就可以从缓冲队列找到相应页面,不需要磁盘读入
6.2.3.4 把页面换出推迟到不能再推迟
- 对换出但不立即释放策略的改进
- 换出的页面不一定要写入磁盘(e.g. 一个页面从读入后没有被写过,是"干净的"页面)
7. 内存管理实例
7.1 实现功能
- 通过访问用户空间的内存达到读取内核数据的目的,这样就可以进行内核空间到用户空间的大规模信息传送,从而应用于高速数据采集等性能要求高的场合。
- 由于内核内存是受保护的,因此将数据从内核空间拷贝到用户空间的通常方法是使用系统调用,但是系统调用的缺点是速度慢,这会成为数据高速处理的瓶颈。
- 本实例利用内存映射功能,将内核中的一部分虚拟内存映射到用户空间,使得访问用户空间等同于访问被映射的内核地址空间,从而不再需要数据拷贝操作。
7.2 背景知识
7.2.1 mmap系统调用流程
mmap系统调用的执行流程如下,
SYSCALL_DEFINE(mmap_pgoff, ...)
--> sys_mmap_pgoff
--> SYSCALL_DEFINE6(mmap_pgoff, ...)
--> vm_mmap_pgoff
--> do_mmap_pgoff
--> mmap_region(2.6.11版本没有这级)
-->(call_mmap)file->f_op->mmap[自定义操作函数:mapdrv_mmap]
-->__mm_populate
-->populate_vma_page_range
-->__get_user_pages
-->__handle_mm_fault
-->__do_fault
-->vma->vm_ops->fault(vmf);[定义操作函数:map_fault]
在调用file->f_op->mmap之前,会找到对应的vm_area_struct结构,如果是首次调用,会从专用Slab缓冲区分配vm_area_struct结构,该结构将作为参数传递给file->f_op->mmap函数
mmap并不分配物理内存,他所做的最重要的工作就是为进程虚拟内存区的虚拟地址建立页表项
7.2.2 缺页异常处理函数
在file->f_op->mmap函数中,需要为vm_area_struct结构中的虚拟地址建立相应的页表项,建立页表项有2种方法,
使用remap_pfn_range函数
在f_op->mmap函数中一次性为vm_area_struct中的线性地址建立页表项,但是这就要求这些页表所要映射的物理地址是连续的,但是vmalloc分配的内存物理上可能是不连续的
通过缺页异常
如上文所述,vm_operations_struct 结构中的 nopage 函数(新版内核为fault函数)用于处理缺页异常
调用过mmap之后,进程访问的这段用户空间虚拟地址已经是合法的,只是尚未装载物理内存,所以触发了缺页异常。我们就是要在这个缺页异常中分配物理页面,并建立映射关系(即填充页表项)
7.3 实现思路
- 使用 vmalloc 函数在内核态分配一段内存,vmalloc 返回的是内核空间的虚拟地址,用户进程是无法直接访问的,所以需要再映射到用户进程空间中,也就是一个vm_area_struct结构中
- 使用 mmap 系统调用新建 struct vm_area_struct 结构,设置缺页异常处理函数,在该函数中动态逐页地建立映射关系
7.4 实现分析
kernel版本:4.14.87
7.4.1 模块初始化函数
#define MAP_PAGE_COUNT 10
#define MAPLEN (PAGE_SIZE * MAP_PAGE_COUNT) // 分配10页内存
#define MAP_DEV_MAJOR 240 // 主设备号
#define MAP_DEV_NAME "mapnopage" // 字符设备名称
static char *vmalloc_area;
static int __init mapdrv_init(void)
{
int result = -1;
unsigned long virt_addr = 0;
int i = 0;
// 注册字符设备,这是后续调用mmap系统调用的基础
result = register_chrdev(MAP_DEV_MAJOR, MAP_DEV_NAME, &mapdrv_fops);
if (result < 0) {
printk("register_chrdev error\n");
return result;
}
// 分配虚拟地址连续物理地址不连续的内存
vmalloc_area = vmalloc(MAPLEN);
// 锁定分配的页面不被换出
for (virt_addr = (unsigned long)vmalloc_area;
virt_addr < (unsigned long)vmalloc_area + MAPLEN;
virt_addr += PAGE_SIZE) {
SetPageReserved(vmalloc_to_page((void *)virt_addr));
// 在每页的起始写入字符串,供用户态验证
sprintf((char *)virt_addr, "test %d", i++);
}
printk("vmalloc_area = 0x%p\n", vmalloc_area);
return 0;
}
说明1:vmalloc_to_page函数分析
调用SetPageReserved函数锁定页面时需要struct page结构,vmalloc_to_page函数用于从vmalloc分配的虚拟地址找到对应的页描述符
参考之前对vmalloc函数实现的分析,vmalloc时是从内核页表的pgd开始逐层生成并建立页表,vmalloc_to_page就是对应的逆过程,先找到虚拟地址对应的页表项pte,就能通过pte_pfn计算出页号,再通过pfn_to_page就可以得到对应的页描述符
即pte --> pfn --> page
此处注意pte_offset_map和pte_unmap的配对使用
说明2:模块注销函数
static void __exit mapdrv_exit(void)
{
unsigned long virt_addr = 0;
// 解除页面锁定
for (virt_addr = (unsigned long)vmalloc_area;
virt_addr < (unsigned long)vmalloc_area + MAPLEN;
virt_addr += PAGE_SIZE) {
ClearPageReserved(vmalloc_to_page((void *)virt_addr));
}
// 释放内存
if (vmalloc_area) {
vfree(vmalloc_area);
vmalloc_area = NULL;
}
// 注销字符设备
unregister_chrdev(MAP_DEV_MAJOR, MAP_DEV_NAME);
}
7.4.2 mmap函数
static struct vm_operations_struct map_vm_ops = {
.open = map_vopen,
.close = map_vclose,
.fault = map_fault,
};
int mapdrv_mmap(struct file *file, struct vm_area_struct *vma)
{
unsigned long offset = vma->vm_pgoff << PAGE_SHIFT;
unsigned long size = vma->vm_end - vma->vm_start;
if (size > MAPLEN) {
printk("map size too big\n");
return -ENXIO;
}
vma->vm_flags |= VM_LOCKED;
if (!offset) {
// 设置vma操作函数,其中核心为缺页处理函数fault
vma->vm_ops = &map_vm_ops;
} else {
printk("offset out of range\n");
return -ENXIO;
}
return 0;
}
说明:vma->vm_pgoff 标识的偏移量是以页为单位的,所以是page offset(pgoff)
7.4.3 fault函数
static int map_fault(struct vm_area_struct *vma, struct vm_fault *vmf)
{
struct page *page = NULL;
unsigned long offset = 0;
unsigned long virt_start = 0;
unsigned long pfn_start = 0;
printk("vmf->pgoff = %ld\n", vmf->pgoff);
// 发生缺页的偏移量
offset = (unsigned long)(vmf->pgoff << PAGE_SHIFT);
// 对应偏移量的vmalloc分配内存地址
virt_start = (unsigned long)vmalloc_area + offset;
// vmalloc地址对应的页号,可用于计算物理地址
pfn_start = (unsigned long)vmalloc_to_pfn((void *)virt_start);
if (!vma || !vmalloc_area) {
printk("return VM_FAULT_SIGBUS\n");
return VM_FAULT_SIGBUS;
}
if (offset >= MAPLEN) {
printk("return VM_FAULT_SIGBUS\n");
return VM_FAULT_SIGBUS;
}
page = vmalloc_to_page((void *)virt_start);
get_page(page); // 增加页引用计数
// 缺页异常处理函数的核心就是得到一个物理页的页描述符
// 供用户态线性地址建立映射关系
vmf->page = page;
printk("%s: map 0x%lx (0x%016lx) to 0x%lx, size: 0x%lx, page:%ld \n",
__func__, virt_start, pfn_start << PAGE_SHIFT,
(unsigned long)vmf->virtual_address, PAGE_SIZE, vmf->pgoff);
return 0;
}
说明:vmalloc_to_pfn函数分析
结合上文分析,该函数就很好理解了
7.4.4 测试用例
#define LEN (10*4096)
int main(void)
{
int fd = 0;
int loop = 0;
char *vadr = NULL;
if ((fd = open("/dev/mapnopage", O_RDWR)) < 0) {
perror("open error");
return -1;
}
vadr = mmap(0, LEN, PROT_READ, MAP_PRIVATE | MAP_LOCKED, fd, 0);
for(loop = 0; loop < 10; loop++) {
printf("[%-10s----%lx]\n", vadr + 4096 * loop,
(unsigned long)(vadr + 4096 * loop));
}
pause(); // 便于查看进程状态
}
说明:实验现象
# 建立设备节点
sudo mknod /dev/mapnopage c 260 0
# 运行测试程序
sudo ./map_read
① 用户态打印
可见读取的内容与内核态写入的内容相同
② 内核态打印
③ 进程用户空间布局
arm环境测试:
memor_map.c
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/init_task.h>
#include <linux/sched.h>
#include <linux/list.h>
#include <asm/atomic.h>
#include <linux/semaphore.h>
#include <linux/cdev.h>
#include <linux/fs.h>
#include <linux/string.h>
#include <linux/errno.h>
#include <linux/mm.h>
#include <linux/vmalloc.h>
#include <linux/slab.h>
#include <linux/sched.h>
#include <asm/io.h>
#include <linux/mman.h>
#define MAP_PAGE_COUNT 10
#define MAPLEN (PAGE_SIZE * MAP_PAGE_COUNT)
#define MAP_DEV_MAJOR 260
#define MAP_DEV_NAME "mapnopage"
static char *vmalloc_area;
// vm_ops
static void map_vopen(struct vm_area_struct *vma)
{
printk("mapping vma is opened\n");
}
static void map_vclose(struct vm_area_struct *vma)
{
printk("mapping vma is closed\n");
}
static int map_fault(struct vm_fault *vmf)
{
struct page *page = NULL;
unsigned long offset = 0;
unsigned long virt_start = 0;
unsigned long pfn_start = 0;
printk("vmf->pgoff = %ld\n", vmf->pgoff);
offset = (unsigned long)(vmf->pgoff << PAGE_SHIFT);
virt_start = (unsigned long)vmalloc_area + offset;
pfn_start = (unsigned long)vmalloc_to_pfn((void *)virt_start);
if (!vmf->vma || !vmalloc_area) {
printk("return VM_FAULT_SIGBUS\n");
return VM_FAULT_SIGBUS;
}
if (offset >= MAPLEN) {
printk("return VM_FAULT_SIGBUS\n");
return VM_FAULT_SIGBUS;
}
page = vmalloc_to_page((void *)virt_start);
get_page(page); // 增加页引用计数
vmf->page = page;
printk("%s: map 0x%lx (0x%016lx) to 0x%lx , size: 0x%lx, page:%ld \n", __func__,
virt_start, pfn_start << PAGE_SHIFT,
(unsigned long)vmf->address, PAGE_SIZE, vmf->pgoff);
return 0;
}
static struct vm_operations_struct map_vm_ops = {
.open = map_vopen,
.close = map_vclose,
.fault = map_fault,
};
// file_operations
int mapdrv_open(struct inode *inode, struct file *file)
{
printk("process: %s (%d)\n", current->comm, current->pid);
return 0;
}
int mapdrv_release(struct inode *inode, struct file *file)
{
return 0;
}
int mapdrv_mmap(struct file *file, struct vm_area_struct *vma)
{
unsigned long offset = vma->vm_pgoff << PAGE_SHIFT;
unsigned long size = vma->vm_end - vma->vm_start;
if (size > MAPLEN) {
printk("map size too big\n");
return -ENXIO;
}
vma->vm_flags |= VM_LOCKED;
if (!offset)
vma->vm_ops = &map_vm_ops;
else {
printk("offset out of range\n");
return -ENXIO;
}
return 0;
}
static struct file_operations mapdrv_fops = {
.owner = THIS_MODULE,
.mmap = mapdrv_mmap,
.open = mapdrv_open,
.release = mapdrv_release,
};
static int __init mapdrv_init(void)
{
int result = -1;
unsigned long virt_addr = 0;
int i = 0;
result = register_chrdev(MAP_DEV_MAJOR, MAP_DEV_NAME, &mapdrv_fops);
if (result < 0) {
printk("#### register_chrdev error\n");
return result;
}
vmalloc_area = vmalloc(MAPLEN);
for (virt_addr = (unsigned long)vmalloc_area;
virt_addr < (unsigned long)vmalloc_area + MAPLEN;
virt_addr += PAGE_SIZE) {
SetPageReserved(vmalloc_to_page((void *)virt_addr));
sprintf((char *)virt_addr, "test %d", i++);
}
printk("vmalloc_area = 0x%p\n", vmalloc_area);
return 0;
}
static void __exit mapdrv_exit(void)
{
unsigned long virt_addr = 0;
for (virt_addr = (unsigned long)vmalloc_area;
virt_addr < (unsigned long)vmalloc_area + MAPLEN;
virt_addr += PAGE_SIZE) {
ClearPageReserved(vmalloc_to_page((void *)virt_addr));
}
if (vmalloc_area) {
vfree(vmalloc_area);
vmalloc_area = NULL;
}
unregister_chrdev(MAP_DEV_MAJOR, MAP_DEV_NAME);
}
module_init(mapdrv_init);
module_exit(mapdrv_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("xxxx");
map_read.c
#include <stdio.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <fcntl.h>
#include <stdlib.h>
#define LEN (10*4096)
int main(void)
{
int fd = 0;
int loop = 0;
char *vadr = NULL;
if ((fd = open("/dev/mapnopage", O_RDWR)) < 0) {
perror("open error");
return -1;
}
vadr = mmap(0, LEN, PROT_READ, MAP_PRIVATE | MAP_LOCKED, fd, 0);
for(loop = 0; loop < 10; loop++){
printf("[%-10s----%lx]\n", vadr + 4096 * loop,
(unsigned long)(vadr + 4096 * loop));
}
pause();
}
7.5 连续内存映射示例
如上文所述,vmalloc分配的内存虚拟地址连续但物理地址不连续,所以只能在缺页异常中逐页建立映射
下面给出使用kmalloc分配内存,并在mmap函数中一次性建立映射的示例
7.5.1 模块初始化函数
#define MAP_PAGE_COUNT 10
#define MAPLEN (PAGE_SIZE * MAP_PAGE_COUNT)
#define MAP_DEV_MAJOR 240
#define MAP_DEV_NAME "mapnopage"
static char *kmalloc_area;
static int __init mapdrv_init(void)
{
int result = -1;
unsigned long virt_addr = 0;
int i = 0;
result = register_chrdev(MAP_DEV_MAJOR, MAP_DEV_NAME, &mapdrv_fops);
if (result < 0) {
printk("register_chrdev error\n");
return result;
}
// 使用kmalloc分配物理连续的内存
kmalloc_area = kmalloc(MAPLEN, GFP_KERNEL);
for (virt_addr = (unsigned long)kmalloc_area;
virt_addr < (unsigned long)kmalloc_area + MAPLEN;
virt_addr += PAGE_SIZE) {
SetPageReserved(virt_to_page((void *)virt_addr));
sprintf((char *)virt_addr, "test %d", i++);
}
printk("kmalloc_area = 0x%p\n", kmalloc_area);
return 0;
}
说明:由于使用kmalloc函数分配物理连续的内存,此处直接用virt_to_page宏获得虚拟地址对应的页描述符
7.5.2 mmap函数
int mapdrv_mmap(struct file *file, struct vm_area_struct *vma)
{
unsigned long offset = vma->vm_pgoff << PAGE_SHIFT;
unsigned long size = vma->vm_end - vma->vm_start;
int ret = 0;
if (size > MAPLEN) {
printk("map size too big\n");
return -ENXIO;
}
vma->vm_flags |= VM_LOCKED;
if (!offset) {
// 一次性建立映射
ret = remap_pfn_range(vma, vma->vm_start,
__pa(kmalloc_area) >> PAGE_SHIFT, // 直接使用__pa计算物理地址
MAPLEN, vma->vm_page_prot);
} else {
printk("offset out of range\n");
return -ENXIO;
}
printk("%s: map from 0x%lx (0x%lx) to 0x%lx, size: 0x%lx\n", __func__,
(unsigned long)kmalloc_area, (unsigned long)__pa(kmalloc_area),
vma->vm_start, MAPLEN);
return 0;
}
说明1:remap_pfn_range函数分析
remap_pfn_range函数的参数中,
from:映射起始的用户空间虚拟地址
pfn:映射起始的物理页号
size:映射区域长度
之后便逐级填充用户页表
说明2:实验结果
① 用户态打印
② 内核态打印
③ 进程用户空间布局
memory_map_kmalloc.c
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/init_task.h>
#include <linux/sched.h>
#include <linux/list.h>
#include <asm/atomic.h>
#include <linux/semaphore.h>
#include <linux/cdev.h>
#include <linux/fs.h>
#include <linux/string.h>
#include <linux/errno.h>
#include <linux/mm.h>
#include <linux/vmalloc.h>
#include <linux/slab.h>
#include <linux/sched.h>
#include <asm/io.h>
#include <linux/mman.h>
#define MAP_PAGE_COUNT 10
#define MAPLEN (PAGE_SIZE * MAP_PAGE_COUNT)
#define MAP_DEV_MAJOR 240
#define MAP_DEV_NAME "mapnopage"
static char *kmalloc_area;
// file_operations
int mapdrv_open(struct inode *inode, struct file *file){
printk("process: %s (%d)\n", current->comm, current->pid);
return 0;
}
int mapdrv_release(struct inode *inode, struct file *file){
return 0;
}
int mapdrv_mmap(struct file *file, struct vm_area_struct *vma){
unsigned long offset = vma->vm_pgoff << PAGE_SHIFT;
unsigned long size = vma->vm_end - vma->vm_start;
int ret = 0;
if (size > MAPLEN) {
printk("map size too big\n");
return -ENXIO;
}
vma->vm_flags |= VM_LOCKED;
if (!offset) {
ret = remap_pfn_range(vma, vma->vm_start,
__pa(kmalloc_area) >> PAGE_SHIFT,
MAPLEN, vma->vm_page_prot);
}
else {
printk("offset out of range\n");
return -ENXIO;
}
printk("%s: map from 0x%lx (0x%lx) to 0x%lx, size: 0x%lx\n", __func__,
(unsigned long)kmalloc_area, (unsigned long)__pa(kmalloc_area), vma->vm_start, MAPLEN);
return 0;
}
static struct file_operations mapdrv_fops = {
.owner = THIS_MODULE,
.mmap = mapdrv_mmap,
.open = mapdrv_open,
.release = mapdrv_release,
};
static int __init mapdrv_init(void){
int result = -1;
unsigned long virt_addr = 0;
int i = 0;
result = register_chrdev(MAP_DEV_MAJOR, MAP_DEV_NAME, &mapdrv_fops);
if (result < 0) {
printk("register_chrdev error\n");
return result;
}
kmalloc_area = kmalloc(MAPLEN, GFP_KERNEL);
for (virt_addr = (unsigned long)kmalloc_area;
virt_addr < (unsigned long)kmalloc_area + MAPLEN;
virt_addr += PAGE_SIZE) {
SetPageReserved(virt_to_page((void *)virt_addr));
sprintf((char *)virt_addr, "test %d", i++);
}
printk("kmalloc_area = 0x%p\n", kmalloc_area);
return 0;
}
static void __exit mapdrv_exit(void){
unsigned long virt_addr = 0;
for (virt_addr = (unsigned long)kmalloc_area;
virt_addr < (unsigned long)kmalloc_area + MAPLEN;
virt_addr += PAGE_SIZE) {
ClearPageReserved(virt_to_page((void *)virt_addr));
}
if (kmalloc_area) {
kfree(kmalloc_area);
kmalloc_area = NULL;
}
unregister_chrdev(MAP_DEV_MAJOR, MAP_DEV_NAME);
}
module_init(mapdrv_init);
module_exit(mapdrv_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("xxx");