8086体系实模式地址映射
8086cpu有16根数据线和20根地址线。
寄存器:
- 段寄存器:描述内存分段时,保存段的信息。8086 内部有4 个段寄存器。其中,CS 是代码段寄存器,DS 是数据 段寄存器,ES 是附加段(Extra Segment)寄存器,IP 指令指针寄存器。
- 物理地址:对应内存条上的实际地址。
- 偏移地址: 段内存储单元相对于短地址的距离。
- 段地址: 段的起始地址(对应着一个物理地址)。
- 逻辑地址: 短地址+偏移地址。
我们把一个程序分为数据段与代码段,在数据段寄存器(DS)中存储数据段的段地址,代码段中存储代码段的段地址。
物理地址 = 段基址(DS/CS)+偏移地址/逻辑地址即段上的偏移量(IP寄存器)
这种获取到的物理地址也称为实模式。实模式下的分段。
CPU上电强制进入实模式,寻址空间是2^20 = 1M. 内核image 从0x100000(1M)开始加载,之前属于实模式的地址空间
当处理器访问内存时,它把指令中指定的 内存地址看成是段内的偏移地址,而不是物理地址。这样,一旦处理器 遇到一条访问内存的指令,它将把DS 中的数据段起始地址和指令中提供的段内偏移相加,来得到访问内存所需要的物理地址。
当一段代码开始执行时, CS 指向代码段的起始地址,IP 则指向段内偏移。这样,由CS 和IP 共同 形成逻辑地址,并由总线接口部件变换成物理地址来取得指令。然后, 处理器会自动根据当前指令的长度来改变IP 的值,使它指向下一条指令。
8086的处理器地址引线:20根,那么逻辑地址就是20位。而我们的寄存器只有16位,为了解决这个问题:段地址实际上也是20位,将段寄存器中的值左移4位(每个分段必须加载到地址最低位为0的位置,相对于16进制表示而言)。偏移地址仍然是16位,也就意味着每个段的最大长度为65536个字节。
8086 处理器的逻辑分段,起始地址都是16 的倍数,这称为是 按16 字节对齐的。
同样在不允许段之间重叠的情况下,每个段的最大长度是64KB,因 为偏移地址也是16 位的,从0000H 到FFFFH。在这种情况下,1MB 的 内存,最多只能划分成16 个段,每段长64KB,段地址分别是0000H、1000H、2000H、3000H,…,一直到F000H。
同一段内存,多种分段方案如下:
10000H到100FFH组成一个段,起始地址( 基础地址,向左移4位) 为10000H,段地址为1000H, 偏移的大小为100H。当这段内存分成两个段后,起始地址( 基础地址 )为10000H和10080H,段地址为1000H和1008H, 大小均为80H。
我们知道当段地址向左移4位,实际上就是乘以16,换句话说一个段的起始地址也一定是16的倍数。
偏移地址为16位,16 位地址的寻址能力为64K,所以一个段的长度最大为64K。
现在有一个物理地址为21F60H,段地址为2000H,把段地址向左移4位后就是20000H,再用物理地址减去段地址,那么就很容易推出偏移地址为1F60H
在8086PC机中存储单元地址是怎样表示的呢?如果数据在21F60H内存单元中,段地址是2000H,地址表示如下:
数据存在内存2000:1F60单元中
数据存在内存的2000H段中的1F60H单元中
另外,段地址和数据都是重要的,因此处理器至少需要提供两个段寄存器,分别是代码段寄存器(Code Segment,CS)和数据段寄存器(Data Segment,DS)。对CS内容的改变将导致处理器从新的代码段开始执行。同样的,在开始访问内存中的数据之前,也必须让DS寄存器指向数据段。
通常情况下,段地址的选择取决于内存中哪些区域是空闲的,例如从物理地址00000H开始,到82215H之间的内存都被其他程序占用了,那么可以从82215H后面的空闲的内存区域开始加载程序。
保护模式下的内存分段的地址映射
所谓工作模式,是指CPU的寻址方式、寄存器大小、指令用法和内存布局等。
实模式
段基址:段内偏移地址”产生的逻辑地址就是物理地址,即程序员可见的地址完全是真实的内存地址。
保护模式
在保护模式中,内存的管理模式分为两种——段模式和页模式。其中页模式也是基于段模式的。也就是说,保护模式的内存管理模式事实上是:纯段模式和段页式。进一步说,段模式是必不可少的,而页模式则是可选的——如果使用页模式,则是段页式,否则这是纯段模式。
保护模式下的段寄存器 由 16位的选择器 与 64位的段描述符寄存器 构成
- 段描述符寄存器: 存储段描述符
- 选择器:存储段描述符的索引
原先实模式下的各个段寄存器在保护模式下的作用是段选择器,仅仅是每个位表示的作用不同于实模式(实模式下是段基址)。
请求特权级(RPL)则代表选择子的特权级,共有4个特权级(0级、1级、2级、3级)。00 表示最高级别 11 表示最低级别
高13位表示在段描述符表 中的索引号。
关于特权级的说明:任务中的每一个段都有一个特定的级别。每当一个程序试图访问某一个段时,就将该程序所拥有的特权级与要访问的特权级进行比较,以决定能否访问该段。系统约定,CPU只能访问同一特权级或级别较低特权级的段。
全局描述符表GDT
全局描述符表GDT(Global Descriptor Table)在整个系统中,全局描述符表GDT只有一张,GDT可以被放在内存的任何位置,但CPU必须知道GDT的入口,也就是基地址放在哪里,Intel的设计者提供了一个寄存器GDTR用来存放GDT的入口地址,程序员将GDT设定在内存中某个位置之后,可以通过LGDT指令将GDT的入口地址装入此寄存器,从此以后,CPU就根据此寄存器中的内容作为GDT的入口来访问GDT了。GDTR中存放的是GDT在内存中的基地址和其表长界限。
全局描述符表在系统中只能有一个,且可以被每一个任务所共享.任何描述符都可以放在GDT中,但中断门和陷阱门放在GDT中是不会起作用的.能被多个任务共享的内存区就是通过GDT完成的,
GDTR寄存器中的基地址指定GDT表在内存中的起始地址,表长度指明GDT表的字节长度值。
指令LGDT和SGDT分别用于加载和保存GDTR寄存器的内容。在机器刚加电或处理器复位后,基地址被默认地设置为0,而长度值被设置成0xFFFF。在保护模式初始化过程中必须给GDTR加载一个新值。
在8086中,段寄存器中存储内存的段基址(上图中的内存的起始地址),在80386中保护模式下,内存的起始地址存放在GDT中,段寄存器 的作用是选择子。
GDT共有2^13,8192个,因为占用的是段寄存器的高13位
在linux内核源码中:arch/i386/kernel/head.S
/*
* The boot_gdt_table must mirror the equivalent in setup.S and is
* used only by the trampoline for booting other CPUs
*/
.align L1_CACHE_BYTES
ENTRY(boot_gdt_table)
.fill GDT_ENTRY_BOOT_CS,8,0
.quad 0x00cf9a000000ffff /* kernel 4GB code at 0x00000000 */
.quad 0x00cf92000000ffff /* kernel 4GB data at 0x00000000 */
#endif
.align L1_CACHE_BYTES
ENTRY(cpu_gdt_table)
.quad 0x0000000000000000 /* NULL descriptor */
.quad 0x0000000000000000 /* 0x0b reserved */
.quad 0x0000000000000000 /* 0x13 reserved */
.quad 0x0000000000000000 /* 0x1b reserved */
.quad 0x0000000000000000 /* 0x20 unused */
.quad 0x0000000000000000 /* 0x28 unused */
.quad 0x0000000000000000 /* 0x33 TLS entry 1 */
.quad 0x0000000000000000 /* 0x3b TLS entry 2 */
.quad 0x0000000000000000 /* 0x43 TLS entry 3 */
.quad 0x0000000000000000 /* 0x4b reserved */
.quad 0x0000000000000000 /* 0x53 reserved */
.quad 0x0000000000000000 /* 0x5b reserved */
.quad 0x00cf9a000000ffff /* 0x60 kernel 4GB code at 0x00000000 */
.quad 0x00cf92000000ffff /* 0x68 kernel 4GB data at 0x00000000 */
.quad 0x00cffa000000ffff /* 0x73 user 4GB code at 0x00000000 */
.quad 0x00cff2000000ffff /* 0x7b user 4GB data at 0x00000000 */
.quad 0x0000000000000000 /* 0x80 TSS descriptor */
.quad 0x0000000000000000 /* 0x88 LDT descriptor */
.......
在其中定义了系统预先占用的全局描述符表项,剩下的可以使用。每一项段描述符:
.quad 0x 00 cf 9a 00 00 00 ff ff 占8个字节
内存的起始地址:B0-B15 ,B16-B23,B24-B31 一共32位
内存的段大小:L0-L15,L16-L19, 一共20位 2^20=1M 长度
G:表示长度的单位 0:字节 1:页(4K),因此段大小有可能是1*1M = 1M byte 或4K * 1M = 4G byte,32位linux内核给每个进程都会分配一个虚拟地址空间 4G ,3G用户空间,1G内核空间
保护模式下的内存分段的地址映射
如DS 段寄存器:
首先左移三位得到index,在GDT中获取内存的起始地址,
然后 IP寄存器相加得到线性地址;
检查是否开启分页机制,如果没有则线性地址就是物理地址。
如果开启分页机制,线性地址经过多级的页表映射得到物理地址
局部描述符表LDT
局部描述符表LDT(Local Descriptor Table)局部描述符表可以有若干张,每个任务可以有一张。我们可以这样理解GDT和LDT:GDT为一级描述符表,LDT为二级描述符表。如图
局部描述符表在系统中可以有多个,通常情况下是与任务的数量保持对等,但任务可以没有局部描述符表.任务间不相干的部分也是通过LDT实现的.这里涉及到地址映射的问题.和GDT一样,中断门和陷阱门放在LDT中是不会起作用的.
LDT和GDT从本质上说是相同的,只是LDT嵌套在GDT之中。
LDTR记录局部描述符表的起始位置,与GDTR不同,LDTR的内容是一个段选择子。
由于LDT本身同样是一段内存,也是一个段,所以它也有个描述符描述它,这个描述符就存储在GDT中,对应这个表述符也会有一个选择子,LDTR装载的就是这样一个选择子。LDTR可以在程序中随时改变,通过使用lldt指令。如上图,如果装载的是Selector 2则LDTR指向的是表LDT2。举个例子:如果我们想在表LDT2中选择第三个描述符所描述的段的地址12345678h。
- 首先需要装载LDTR使它指向LDT2 使用指令lldt将Select2装载到LDTR
- 通过逻辑地址(SEL:OFFSET)访问时SEL的index=3代表选择第三个描述符;TI=1代表选择子是在LDT选择,此时LDTR指向的是LDT2,所以是在LDT2中选择,此时的SEL值为1Ch(二进制为11 1 00b)。OFFSET=12345678h。逻辑地址为1C:12345678h
- 由SEL选择出描述符,由描述符中的基址(Base)加上OFFSET可得到线性地址,例如基址是11111111h,则线性地址=11111111h+12345678h=23456789h
- 此时若再想访问LDT1中的第三个描述符,只要使用lldt指令将选择子Selector 1装入再执行2、3两步就可以了(因为此时LDTR又指向了LDT1)
由于每个进程都有自己的一套程序段、数据段、堆栈段,有了局部描述符表则可以将每个进程的程序段、数据段、堆栈段封装在一起,只要改变LDTR就可以实现对不同进程的段进行访问。
当进行任务切换时,处理器会把新任务LDT的段选择符和段描述符自动地加载进LDTR中。在机器加电或处理器复位后,段选择符和基地址被默认地设置为0,而段长度被设置成0xFFFF。
GDT表只有一个,是固定的;而LDT表每个任务就可以有一个,因此有多个,并且由于任务的个数在不断变化其数量也在不断变化。如果只有一个LDTR寄存器显然不能满足多个LDT的要求。因此INTEL的做法是把它放在放在GDT中。
段选择子
在保护模式下,段寄存器的内容已不是段值,而称其为选择子.该选择子指示描述符在上面这三个表中的位置,所以说选择子即是索引值。
当我们把段选择子装入寄存器时不仅使该寄存器值,同时CPU将该选择子所对应的GDT或LDT中的描述符装入了不可见部分。这样只要我们不进行代码切换(不重新装入新的选择子)CPU就会不会对不可见部分存储的描述符进行更新,可以直接进行访问,加快了访问速度。一旦寄存器被重新赋值,不可见部分也将被重新赋值。
段选择子包括三部分:描述符索引(index)、TI、请求特权级(RPL)。
index(描述符索引)部分表示所需要的段的描述符在描述符表的位置,由这个位置再根据在GDTR中存储的描述符表基址就可以找到相应的描述符。
然后用描述符表中的段基址加上逻辑地址(SEL:OFFSET)的OFFSET就可以转换成线性地址,
段选择子中的TI值只有一位0或1,0代表选择子是在GDT选择,1代表选择子是在LDT选择。
例如给出逻辑地址:21h:12345678h转换为线性地址
a. 选择子SEL=21h=0000000000100 0 01b 他代表的意思是:选择子的index=0000000000100 也就是4即选择GDT中的第4个描述符;TI=0代表选择子是在GDT选择;左后的01b代表特权级RPL=1
b. OFFSET=12345678h若此时GDT第四个描述符中描述的段基址(Base)为11111111h,则线性地址=11111111h+12345678h=23456789h
任务寄存器TR
TR用于寻址一个特殊的任务状态段(Task State Segment,TSS)。TSS中包含着当前执行任务的重要信息。
TR寄存器用于存放当前任务TSS段的16位段选择符、32位基地址、16位段长度和描述符属性值。它引用GDT表中的一个TSS类型的描述符。指令LTR和STR分别用于加载和保存TR寄存器的段选择符部分。当使用LTR指令把选择符加载进任务寄存器时,TSS描述符中的段基地址、段限长度以及描述符属性会被自动加载到任务寄存器中。当执行任务切换时,处理器会把新任务的TSS的段选择符和段描述符自动加载进任务寄存器TR中。
实例
1:访问GDT
当TI=0时表示段描述符在GDT中,如上图所示:
①先从GDTR寄存器中获得GDT基址。
②然后再GDT中以段选择器高13位位置索引值得到段描述符。
③段描述符符包含段的基址、限长、优先级等各种属性,这就得到了段的起始地址(基址),再以基址加上偏移地址yyyyyyyy才得到最后的线性地址。
2:访问LDT
当TI=1时表示段描述符在LDT中,如上图所示:
①还是先从GDTR寄存器中获得GDT基址。
②从LDTR寄存器中获取LDT所在段的位置索引(LDTR高13位)。
③以这个位置索引在GDT中得到LDT段描述符从而得到LDT段基址。
④用段选择器高13位位置索引值从LDT段中得到段描述符。
⑤段描述符符包含段的基址、限长、优先级等各种属性,这就得到了段的起始地址(基址),再以基址加上偏移地址yyyyyyyy才得到最后的线性地址。
分页模式:线性地址到物理地址的映射
控制寄存器
控制寄存器用于控制和确定CPU的操作模式。控制寄存器有Cr0
、Cr1
、Cr2
、Cr3
、Cr4
。Cr1
被保留了,Cr3
用于页目录表基址,其他的将继续详细讲解。
Cr0
Cr0
是一个十分重要的寄存器,可以说它是总开关的集合体。如下图所示:
PE
位是启用保护模式(Protection Enable)标志。若PE = 1
是开启保护模式,反之为实地址模式。这个标志仅开启段级保护,而并没有启用分页机制。若要启用分页机制,那么PE
和PG
标志都要置位。
PG
位是启用分页机制。在开启这个标志之前必须已经或者同时开启PE
标志。PG = 0
且PE = 0
,处理器工作在实地址模式下。PG = 0
且PE = 1
,处理器工作在没有开启分页机制的保护模式下。PG = 1
且PE = 0
,在PE
没有开启的情况下无法开启PG
。PG = 1
且PE = 1
,处理器工作在开启了分页机制的保护模式下。
WP
位对于Intel 80486
或以上的CPU
,是写保护(Write Proctect)标志。当设置该标志时,处理器会禁止超级用户程序(例如特权级0的程序)向用户级只读页面执行写操作;当CPL < 3
的时候,如果WP = 0
可以读写任意用户级物理页,只要线性地址有效。如果WP = 1
可以读取任意用户级物理页,但对于只读的物理页,则不能写。
Cr2
当CPU访问某个无效页面时,会产生缺页异常,此时,CPU会将引起异常的线性地址存放在CR2中,如下图所示:
Cr4
Cr4
的结构如下图所示:
VME
用于虚拟8086模式。PAE
用于确认是哪个分页,PAE = 1
,是2-9-9-12
分页,PAE = 0
是10-10-12
分页。PSE
是大页是否开启的总开关,如果置0,就算PDE
中设置了大页你也得是普通的页
当Cr0中PG = 1
且PE = 1
,处理器工作在开启了分页机制的保护模式下。
从线性地址到物理地址的映射过程为:
(1)从CR3寄存器中获取页面目录(Page Directory)的基地址;
(2)以线性地址的dir位段为下标,在目录中取得相应页面表(Page Table)的基地址;
(3)以线性地址中的page位段为下标,在所得到的页面表中获得相应的页面描述项;
(4)将页面描述项中给出的页面基地址与线性地址中的offset位段相加得到物理地址。
CR3寄存器的值从哪里来的?每个进程都会有自己的地址空间,页面目录也在内存不同的位置上,这样不同进程就有不同的CR3寄存器的值。CR3寄存器的值一般都保存在进程控制块中,如linux中的task_struct数据结构中。
页目录项一项占四字节,一共是4*2^10=4K,同理页表大小也是4K,都是4K对齐,从地址0开始,每一个页面(大小4k)的起始地址都是4K的整数倍
分页模式,4K对齐,也就是低12位为0,页目录中一项的大小为32位,低12位用于权限等标志,仅仅用高20位存储页表基址,同理有项表项
在进程切换时,需要切换进程的目录的起始地址放入cr3寄存器中
在linux内核启动时,会检测当前内存的大小,将内存分成一个个page(大小4k),用于内存分配
如果页表项中:
- 高20位是0,最低位也是0,表示物理页面还没有分配过,会触发缺页异常
- 高20位不是0,最低位是0,表示物理页面在交换分区中,此时页表项中存储物理页在swap分区中的位置
- 高20位不是0,最低位不是0,表示物理页面正常使用
在页表项中,存储着物理内存页的起始地址,由于4k对齐,实际上只需要使用20位表示2^20=1M大小的范围,即 0 , 1, 2 ......2^20-1,因此也称为框号。
线性地址转化为物理地址的计算过程,32位系统下两级页表的过程中在MMU中计算完成。
虚拟内存的好处:
1 安全:每个进程的地址空间是相互隔离(0-4G),不受其他影响的
2 虚拟地址连续,物理地址可以不连续,物理内存可以得到充分使用,使用LRU算法置换内存页面,将不经常使用的页面置换到磁盘。
问题:
1 能不能在用户空间定义一个指针指向内核空间的内存:不能,权限问题
2 能不能在内核空间定义一个指针指向用户空间的内存:不能
内核空间的地址映射和上面的地址映射(用户空间)不同;内核空间的地址映射
_pa 把内核的虚拟地址转为物理地址,也就是x - 3G
_va 把内核的物理地址转为虚拟地址,也就是x+ 3G,
也就是内核的虚拟地址空间在3G-4G,内核启动放置镜像从0x100000 1M的位置(之前的位置放置实模式下的东西),映射到虚拟内存的0xc0100000
bochs使用 查看分段分页
安装后:
拷贝资源中的mylinux到安装目录下:
打开:
依次点击:
看到界面:
调试命令类似gdb:
c:继续
进入mycode 目录下
执行a.out 程序进入死循环
因为data不可能为0,所以程序不可能停止运行。如何停止呢?
data的虚拟地址是0x3fffef4,如果能找到物理地址,修改物理地址中的内容为0,那么while就可以停止运行。
data是局部变量,在栈上,在ss 栈内存中,输入命令sreg
ss在0x0017 h,转换成二进制 0000 0000 0001 0111 B;
根据上面段选择子内容的介绍:
段选择子包括三部分:描述符索引(index)、TI、请求特权级(RPL)。
index(描述符索引)部分表示所需要的段的描述符在描述符表的位置,由这个位置再根据在GDTR中存储的描述符表基址就可以找到相应的描述符。
然后用描述符表中的段基址加上逻辑地址(SEL:OFFSET)的OFFSET就可以转换成线性地址,
段选择子中的TI值只有一位0或1,0代表选择子是在GDT选择,1代表选择子是在LDT选择。
在 0000 0000 0001 0111 B;中
第1,2位11 表示用户空间,
第3位1 表示LDT
剩下的表示index = 2,这块内存的信息存放在LDT[2]中。
如何找到LDT?
ldtr:0x0068h 即 0000 0000 0110 1(index=13)0(GDT)00(内核权限)
LDT存储在GDT中index=13的位置中
一个GDT表项占8字节,
输入: xp/2w 0x0000000000005cb8+13*8 xp 查看字节偏移,w表示一个字节 2w表示两个字节,因为一个段描述符是两个字节8位, 0x0000000000005cb8是gdtr的基地址,偏移13(index)*8(段描述符8bit)
小端模式:低地址存低字节
因此是:
0x000082fa
0xd2d00068
对照:
0x000082fa : 0xe2d00068 对应的二进制为:
0000
82fa
d2d0
0068
那么:
内存的起始地址:B0-B15 ,B16-B23,B24-B31 一共32位 红色标出
内存的段大小:L0-L15,L16-L19, 一共20位 2^20=1M 长度 绿色标出
G:表示长度的单位 0:字节 1:页(4K),因此段大小有可能是1*1M = 1M byte 或4K * 1M = 4G byte,32位linux内核给每个进程都会分配一个虚拟地址空间 4G ,3G用户空间,1G内核空间
内存起始地址: 0x 00fa d2d0
内存的段大小: 0x 0 0068
G:0 表示字节
data存储在ldt[2]中,即 0x 00fa d2d0 +2*8 ,查看 xp/2w 0x00fad2d0+2*8
查看这64位段描述符的意义:
0x00003fff 0x10c0f300
10c0
f300
0000
3fff
起始地址是1000 0000H
偏移量即逻辑地址 0x3fffef4
线性地址= 起始地址+偏移量= 1000 0000+ 3ff fef4 = 0x13ff fef4
分段已经完成,得到线性地址,现在查看是否分页:
PG
位是启用分页机制。在开启这个标志之前必须已经或者同时开启PE
标志。PG = 0
且PE = 0
,处理器工作在实地址模式下。PG = 0
且PE = 1
,处理器工作在没有开启分页机制的保护模式下。PG = 1
且PE = 0
,在PE
没有开启的情况下无法开启PG
。PG = 1
且PE = 1
,处理器工作在开启了分页机制的保护模式下。
现在CR0=0x8000001b: PG=1,PE=1 开启了分页模式。
0x13ff fef4 对应的二进制
二进制 0001 0011 1111 1111 111 1110 1111 0100
十进制 79 1023 3828
页目录的基地址在cr3寄存器中:
页目录基地址为0
找到页目录的79项,一个项占四个字节 :
页目录项内容的高20位表示页表项地址 :
页表项地址 = 00fb1000 + 1023*4:
最后的内容是a,因为data=10,也就是16进制的a; 物理地址是0x0000000000f95ef4
setpmem 0x0000000000f95ef4 4 0 设置物理地址0x0000000000f95ef4 起始的四字节内容为0
输出 c 继续执行程序,发现程序结束!
mm_struct
如上图所示,当进程需要访问一个虚拟地址,如0x00FFF213时:
首先通过mm_struct 中的 mmp,mmp是一个红黑树,这样查找速度可以达到logn,加快虚拟地址的查找速度。mmp将进程的虚拟地址空间用一个个vm_area_struct组织起来,vm_area_struct中表示了它维持的虚拟地址空间区域的起始位置结束位置,读写权限等,属于哪个段;
当查找mmp时找不到该地址所在的地址区间,则报错 segment fault (段错误)导致程序crash.
当从mmp中找到后0x00FFF213所在的虚拟地址空间存在且合法后:
访问pgd,页目录表起始地址,通过分段和分页最终找到物理地址。当访问pgd时找不到相应物理页面,则会触发缺页异常。
关于分段分页可以参考:Linux内核网桥注释v0.11 :
第六章 引导启动程序 6.4 head.s 在:
在进入保护模式首先创建一个内存页目录表(4k)。0.11 内核是所有进程公用一个页目录表(唯一)。然后创建四个页表供内核代码使用;创建全局描述符表gdt.
5.3 Linux内核对内存的管理与使用
13章内存管理
缺页异常:do_page_fault
区别:如中断指令(系统调用)执行完中断处理函数,执行下一条指令;
异常:如缺页异常,执行完do_page_fault后执行执行当前指令。
关于缺页异常:在linux0.11 内核完全注释中,13章内存管理 对写时拷贝和按需加载有详细的描述:
linux内核2.6 中:
do_page_fault 是页面异常的异常服务函数:
在这个函数中,通过handle_mm_fault处理:
dotraplinkage void __kprobes
do_page_fault(struct pt_regs *regs, unsigned long error_code)
{
struct vm_area_struct *vma;
struct task_struct *tsk;
unsigned long address;
struct mm_struct *mm;
int fault;
int write = error_code & PF_WRITE;
unsigned int flags = FAULT_FLAG_ALLOW_RETRY | FAULT_FLAG_KILLABLE |
(write ? FAULT_FLAG_WRITE : 0);
tsk = current;//当前CPU正在执行的进程
mm = tsk->mm;//当前进程的mm_struct
/* Get the faulting address: */
//获取出错地址,cr2 寄存器放置缺页异常时的地址
address = read_cr2();
......
//查找mm_struct中address所在的vma
vma = find_vma(mm, address);
if (unlikely(!vma)) {//没有找到 段错误 cause a SIGSEGV
bad_area(regs, error_code, address);
return;
}//找到该区间(除了栈区域,其他段都是从低地址到高地址增长)
if (likely(vma->vm_start <= address))
goto good_area;
//判断是不是栈区域
if (unlikely(!(vma->vm_flags & VM_GROWSDOWN))) {
bad_area(regs, error_code, address);
return;
}
if (error_code & PF_USER) {
/*
* Accessing the stack below %sp is always a bug.
* The large cushion allows instructions like enter
* and pusha to work. ("enter $65535, $31" pushes
* 32 pointers and then decrements %sp by 65535.)
*/
if (unlikely(address + 65536 + 32 * sizeof(unsigned long) < regs->sp)) {
bad_area(regs, error_code, address);
return;
}
}
//判断在不在栈的增加区域,不踩到其他区域
if (unlikely(expand_stack(vma, address))) {
bad_area(regs, error_code, address);
return;
}
/*
* Ok, we have a good vm_area for this memory access, so
* we can handle it..
*/
// address是合法的vma区域,解决该异常:
good_area:
//判断当前指令的操作和vma节点描述的内存区域的内存属性是否一致
//如 在只读的vma属性中执行写操作
if (unlikely(access_error(error_code, vma))) {
bad_area_access_error(regs, error_code, address);
return;
}
/*
* If for any reason at all we couldn't handle the fault,
* make sure we exit gracefully rather than endlessly redo
* the fault:
*/
//核心处理函数 pgd-> 页目录 ->页表 -> 物理地址
fault = handle_mm_fault(mm, vma, address, flags);
......
}
__handle_mm_fault
/*
* By the time we get here, we already hold the mm semaphore
*/
int __handle_mm_fault(struct mm_struct *mm, struct vm_area_struct *vma,
unsigned long address, int write_access)
{
pgd_t *pgd;
pud_t *pud;
pmd_t *pmd;
pte_t *pte;
__set_current_state(TASK_RUNNING);
count_vm_event(PGFAULT);
if (unlikely(is_vm_hugetlb_page(vma)))
return hugetlb_fault(mm, vma, address, write_access);
//查找页目录项pgd :page dir
pgd = pgd_offset(mm, address);
pud = pud_alloc(mm, pgd, address);
if (!pud)
return VM_FAULT_OOM;
pmd = pmd_alloc(mm, pud, address);
if (!pmd)
return VM_FAULT_OOM;
//创建 pgd 指向的页表如果不存在,创建 pgd 指向的页表 :page table entry
pte = pte_alloc_map(mm, pmd, address);
if (!pte)
return VM_FAULT_OOM;
return handle_pte_fault(mm, vma, address, pte, pmd, write_access);
}
handle_pte_fault
static inline int handle_pte_fault(struct mm_struct *mm,
struct vm_area_struct *vma, unsigned long address,
pte_t *pte, pmd_t *pmd, int write_access)
{
pte_t entry;
spinlock_t *ptl;
//entry 记录了页表项的内容 8字节描述符
entry = *pte;
//当前页表项还没有记录物理页面的有效信息:没有分配物理页面(按需加载)
if (!pte_present(entry)) {
//1 页表项是空的,没有记录物理页面信息->分配物理页面
if (pte_none(entry)) {
if (vma->vm_ops) {
if (vma->vm_ops->nopage)
//分配物理页面
return do_no_page(mm, vma, address,
pte, pmd,
write_access);
if (unlikely(vma->vm_ops->nopfn))
return do_no_pfn(mm, vma, address, pte,
pmd, write_access);
}
return do_anonymous_page(mm, vma, address,
pte, pmd, write_access);
}
//2 页表项是空的,页表项指向一个文件的内容:
// mmap file文件映射到内存:共享内存
if (pte_file(entry))
return do_file_page(mm, vma, address,
pte, pmd, write_access, entry);
//3 做页面的交换:页表项并不空,但是已经不在物理内存中
//在swap交换分区中,重新加载到物理内存中,更新pte的内容
return do_swap_page(mm, vma, address,
pte, pmd, write_access, entry);
}
ptl = pte_lockptr(mm, pmd);
spin_lock(ptl);
if (unlikely(!pte_same(*pte, entry)))
goto unlock;
// 页表项是正常的,当前的指令需要写操作
if (write_access) {
//没有写权限
if (!pte_write(entry))
//wp(write on page) 写时拷贝:
//父进程创建子进程时,复制页目录项,页表,此时共用
//物理内存,共用的物理内存双方只读,不能写,如果需要写
//则需要给子进程分配单独的物理页面,并且修改标志位可读
return do_wp_page(mm, vma, address,
pte, pmd, ptl, entry);
entry = pte_mkdirty(entry);
}
//LRU 最近最久未使用算法,更新最新访问的页面
entry = pte_mkyoung(entry);
//更新物理page对应的pte
if (ptep_set_access_flags(vma, address, pte, entry, write_access)) {
update_mmu_cache(vma, address, entry);
lazy_mmu_prot_update(entry);
} else {
/*
* This is needed only for protection faults but the arch code
* is not yet telling us if this is a protection fault or not.
* This still avoids useless tlb flushes for .text page faults
* with threads.
*/
if (write_access)
flush_tlb_page(vma, address);
}
unlock:
pte_unmap_unlock(pte, ptl);
return VM_FAULT_MINOR;
}