详细了解操作系统分页机制和实践
1. 引言
经过一系列的讲解,我们从启动扇区一直加载到了分段。
分段让操作系统具备了对内存的保护能力,通过描述符表、选择子的多级跳转,让每一段内存都增加了一系列属性,从而可以实现读、写、执行等权限以及为不同程序赋予不同特权的保护功能。 在此前的文章中,我们已经提到,通过 LDT 来解决进程间内存独立的问题,其代价是寄存器的反复加载,这对于 CPU 来说是一件较为耗时的操作,于是,80386 开始,Intel 引入了内存分页功能,相比于 LDT,更为灵活高效,因此 LDT 已经基本不会被使用了。 那么,分页究竟是一种什么样的机制,又是如何实现的呢?本文我们就来一探究竟。
2. 分页机制
随着 80286 保护机制的引入,让多个程序共用 CPU、内存来执行成为了可能,虽然 CPU 可以通过反复的保存现场并切换完成多个进程的并发执行,但昂贵而又容量有限的内存成为了最大的限制,虽然 32 位的地址总线提供了 4GB 内存的寻址能力,但程序的运行受限于实际的内存容量,同时,系统在启动时又很难预先定义每个进程究竟要分配多大的段空间来满足每一个应用程序的需要。 此时,操作系统迫切需要一种类似 CPU 任务切换的机制来对内存进行切换,可以想到,这需要从两个方面来进行考虑:
- 离散化 — 如同 CPU 时间片,将内存尽量切碎,从而在一个任务中,非当前使用的内存切片可以被临时放置在辅助存储器上,让出内存供其他任务使用
- 虚拟化 — 离散化的解决方案引入了一个新的问题: 同一个物理地址在不同的时间可能载入不同任务所对应的内存,同一个任务在不同时间使用的相同变量又可能位于不同的物理地址中,要解决这些问题就必须要通过虚拟化的方式,隐藏物理地址,通过任务所使用的虚拟地址映射到内存的物理地址上,从而不同时刻同一虚拟地址可以映射到不同的物理地址上
经过上述的离散化与虚拟化,分页机制就这样诞生了。 从 80386 开始,内存被分为 4KB 固定大小的“页”,他们在需要使用时载入内存,不需要使用时可以被置换到磁盘上,由分页机制将程序持有的固定的线性地址动态映射到物理地址上。
操作系统的内存管理
3. 页目录表与页表
如图所示,在 80X86 的软硬件设计中,实现了两级页表。 第一级页表 Root page table 被称为“页目录表”,总计占用 4KB 空间,每个表项占用 4 字节,共计 1024 个表项,因此通过线性地址的最高 10bits 可以索引每一个表项,每个表项简称“PDE”(Page Directory Entry) 第二级页表是直接保存物理页基地址的列表,他同样有 1024 个表项,每个表项 4 字节,通过线性地址的中间 10bits 来进行索引。 线性地址剩余的 12bits 用来索引最终指向的页面的 4KB 内存。
3.1. 页目录表项 PDE 与页表项 PTE 的结构
PDE 与 PTE 的结构非常相似:
- P 位 — 存在位,表示当前条目是否在物理内存中
- R/W 位 — 读写权限位,为 0 表示只读,为 1 表示可读写
- U/S 位 — 页或一组页的特权级,为 0 表示系统级,对应 CPL 0、1、2,为 1 表示用户级,对应 CPL 3,下文进行详细介绍
- PWT — 页表缓冲写入机制,为 0 表示 write-back 模式,更新页表缓冲区时,只标记为已更新,不同步写内存,只有被新进入的数据取代时才更新到内存,为 1 表示 write-through 模式,更新页表缓冲区时,同步写内存,保证缓冲区与内存一致
- PCD — 是否拒绝被缓冲,为 0 表示可以被缓冲,为 1 表示不可以被缓冲
- A 位 — 是否被访问,CPU 会在访问到页面时将该位置 1,但不会清除,只有软件可以将 A 位复位
- D 位 — 是否被写入,CPU 会在写入页面时将该位置 1,但不会清除,只有软件可以将 D 位复位
- PS — 页大小位,为 0 表示页大小为 4KB,且 PDE 指向页表,为 1 表示页大小为 4MB,且 PDE 指向 4MB 的整块内存
- PAT — 奔腾3以后的 CPU 引入的页属性表标识位,为 1 开启页属性表后,通过一系列专用寄存器(MBR)为每个页提供了详细的属性设置
- G 位 — 全局位,如果该位与 CR4 寄存器的 PGE 位同时被置为 1,则该页或页目录项将不会在 TLB 中被逐出
- 20bits 基地址 — PDE 与 PTE 的高 20bits 都是下级页基址,无论是页目录表还是页表还是在内存中的页,他们都是 4KB 对齐的,也就是说他们的首地址低12位均为0,这样,只需要通过 20bits 的基地址 * 12 就可以得到计算后的 32 位物理地址了
3.2. cr0 寄存器
此前,我们介绍了 CPU 的控制寄存器。 进军保护模式
硬件控制开关寄存器 cr0 的部分字段如下图所示:
这里重点介绍 PG 位、WP 位与 CD 位:
3.2.1. PG 位
PG 位就是是否开启分页的标志,当 PG 位被置为 1,则开启分页模式,上述一系列机制开始生效。
3.2.2. WP 位
WP 位是内核写保护位,当 WP 位为 0,那么当 CPL 为 0、1、2(系统级)的程序去访问 U/S 位为 1 的内存页时,不再校验页的 R/W 位,系统级程序对所有用户级页面均具有读写权限。 如果 WP 位为 1,那么系统级程序访问用户级内存时,仍然要校验用户级内存的 R/W 位是只读还是读写权限。
3.2.3. CD 位与 TLB
CD 位是页表缓冲位,用来标识是否开启 CPU 页表缓冲。 所谓的“页表缓冲”简称为“TLB”(translation lookaside buffer),指的是 CPU 内的一块缓冲区,用来缓存经常访问的页目录和页表项,从而加快访问页目录与页表的时间。 如果 CD 位为 0,即不开启 CPU 页表缓冲,那么 PDE 与 PTE 中的 PWT 位、PCD 位与 G 位也将不起作用。
4. 实战开启内存分页
接下来我们就来实战开启内存分页机制。 经过上述讲解,我们已经对分页机制了解的十分清楚了,那么,如何在我们已有的分段代码基础上实现分页机制呢? 为了以最快速度上手实战,我们这里不考虑线性地址与物理地址的映射关系,直接取线性地址 = 物理地址,同时假设内存地址空间足够容纳所有页面,并且页、页表在内存中均连续。
- 创建页目录段
- 创建页表段
- 填充 PDE
- 填充 PTE
- 设置 CR3 寄存器,指向页目录表首地址
- 设置 CR0 寄存器 PG 位,启动分页机制
- 执行程序
- 退出实地址模式时复位 PG 位
4.1. 创建页目录段
4.1.1. 在 GDT 中创建描述符
PageDirBase equ 200000h ; 页目录开始地址: 2M
LABEL_DESC_PAGE_DIR: Descriptor PageDirBase, 4095, 92h ; Page Directory,可读写
这里我们直接选择内存 2M 地址作为页目录表的起始地址,且页目录表大小为 4KB。
4.1.2. 创建页目录段选择子
SelectorPageDir equ LABEL_DESC_PAGE_DIR - LABEL_GDT
4.2. 创建页表段
PageTblBase equ 201000h ; 页表开始地址: 2M+4K
LABEL_DESC_PAGE_TBL: Descriptor PageTblBase, 1023, 8092h ; Page Tables,段界限为 1023 * 4096 字节
页表位于页目录起始地址后 4KB 位置,同样为可读写数据段,但此处,我们置位了 GDT 描述符的 G 位,表示段界限的单位为 4096 字节。
4.2.1. 创建页表选择子
SelectorPageTbl equ LABEL_DESC_PAGE_TBL - LABEL_GDT
4.3. 填充 PDE
接下来我们就要填充上面定义的 4096 字节的页目录中的每一个表项 PDE。
; 初始化页目录
mov ax, SelectorPageDir ; 此段首地址为 PageDirBase
mov es, ax
mov ecx, 1024 ; 共 1K 个表项
xor edi, edi
xor eax, eax
mov eax, PageTblBase | 7 ; 用户级、存在于内存、可读写
.filter_pde:
stosd
add eax, 4096 ; 为了简化, 所有页表在内存中是连续的.
loop .filter_pde
这里通过 stosd 指令与 loop 指令,将 eax 的内容循环填充到 4KB 大小的页目录表内。
4.3.1. stosd 指令与 loop 指令
这两个指令我们之前已经有过很多使用:
- stosd 指令将 32 位的 eax 的内容复制到 es:edi 指向的内存空间,并自动将 edi 寄存器内容加 4,类似的有复制 2 字节 ax 寄存器的 stosw 以及复制 1 字节 al 寄存器的 stosb 命令
- loop 指令先判断 ecx 是否为 0,如为 0 则跳出循环,否则对 ecx 寄存器内容减 1 并跳转到其参数 label 处,16 位模式下,则判断 cx 是否为 0,无论在任何模式下,loopw 指令均使用 CX 寄存器,loopd 指令均判断 ecx 寄存器
4.4. 填充 PTE
; 初始化所有页表 (1K 个, 4M 内存空间)
mov ax, SelectorPageTbl ; 此段首地址为 PageTblBase
mov es, ax
mov ecx, 1024 * 1024 ; 共 1M 个页表项, 也即有 1M 个页
xor edi, edi
xor eax, eax
mov eax, 7 ; 用户级、存在于内存、可读写
.filter_pte:
stosd
add eax, 4096 ; 每一页指向 4K 的空间
loop .filter_pte
4.5. 设置 CR3 寄存器
; 设置页目录表起始地址
mov eax, PageDirBase
mov cr3, eax
4.6. 开启分页机制
; 开启分页机制
mov eax, cr0
or eax, 80000000h
mov cr0, eax
4.7. 退出实地址模式时复位 PG 位
mov eax, cr0
and eax, 7FFFFFFEh ; PE=0, PG=0
mov cr0, eax
5. 执行结果
7.1 附录 -- 专题目录
计算机是如何启动的?一文教你自制操作系统
如何调试操作系统源码
一文详解 32 位保护模式与内存分段机制
进军保护模式
保护模式进阶 -- 再回实模式
实战局部描述符表 LDT
实战特权级间的跳转 -- 原理篇
利用调用门实现特权级间跳转 -- 实战篇
操作系统的内存管理
7.2 附录 — 完整代码
; ---------------- 内存段描述符宏 -------------
; usage: Descriptor Base, Limit, Attr
; Base: dd
; Limit: dd (low 20 bits available)
; Attr: dw (lower 4 bits of higher byte are always 0)
%macro Descriptor 3
dw %2 & 0FFFFh ; 段界限1
dw %1 & 0FFFFh ; 段基址1
db (%1 >> 16) & 0FFh ; 段基址2
dw ((%2 >> 8) & 0F00h) | (%3 & 0F0FFh) ; 属性1 + 段界限2 + 属性2
db (%1 >> 24) & 0FFh ; 段基址3
%endmacro
PageDirBase equ 200000h ; 页目录开始地址: 2M
PageTblBase equ 201000h ; 页表开始地址: 2M+4K
; ------------ DOS 加载初始内存地址 -----------
org 0100h
jmp LABEL_BEGIN
; ------------------- GDT ---------------------
[SECTION .gdt]
; GDT
; 段基址, 段界限, 属性
LABEL_GDT: Descriptor 0, 0, 0 ; 空描述符
LABEL_DESC_NORMAL: Descriptor 0, 0ffffh, 92h ; Normal 描述符
LABEL_DESC_PAGE_DIR: Descriptor PageDirBase, 4095, 92h ; Page Directory,可读写
LABEL_DESC_PAGE_TBL: Descriptor PageTblBase, 1023, 8092h ; Page Tables,段界限为 1023 * 4096 字节
LABEL_DESC_CODE32: Descriptor 0, SegCode32Len - 1, 4098h ; 非一致代码段
LABEL_DESC_CODE16: Descriptor 0, 0ffffh, 98h ; 非一致代码段, 用于跳回 16 BITS 模式
LABEL_DESC_DATA: Descriptor 0, DataLen-1, 92h ; 可读写数据段,界限 64KB
LABEL_DESC_STACK: Descriptor 0, TopOfStack, 4093h ; 32 位全局堆栈段,可读写数据段,且栈指针默认使用 esp 寄存器
LABEL_DESC_VIDEO: Descriptor 0B8000h, 0ffffh, 92h ; 显存首地址
; ------------------ END OF GDT ----------------
GdtLen equ $ - LABEL_GDT ; GDT长度
GdtPtr dw GdtLen - 1 ; GDT界限
dd 0 ; GDT基地址
; ------------------ GDT 选择子 -----------------
SelectorNormal equ LABEL_DESC_NORMAL - LABEL_GDT
SelectorPageDir equ LABEL_DESC_PAGE_DIR - LABEL_GDT
SelectorPageTbl equ LABEL_DESC_PAGE_TBL - LABEL_GDT
SelectorCode32 equ LABEL_DESC_CODE32 - LABEL_GDT
SelectorCode16 equ LABEL_DESC_CODE16 - LABEL_GDT
SelectorData equ LABEL_DESC_DATA - LABEL_GDT
SelectorStack equ LABEL_DESC_STACK - LABEL_GDT
SelectorVideo equ LABEL_DESC_VIDEO - LABEL_GDT
; --------------- END OF 段选择子 ----------------
[SECTION .data1] ; 数据段
ALIGN 32
[BITS 32]
LABEL_DATA:
SPValueInRealMode dw 0
BootMessage: db "Hello World my OS, techlog.cn!", 0
OffsetBootMessage equ BootMessage - $$
DataLen equ $ - LABEL_DATA
; 全局堆栈段
[SECTION .gs]
ALIGN 32
[BITS 32]
LABEL_STACK:
times 512 db 0
TopOfStack equ $ - LABEL_STACK - 1
[SECTION .s16]
[BITS 16]
LABEL_BEGIN:
; 初始化段基址寄存器
mov ax, cs
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0100h
mov [LABEL_GO_BACK_TO_REAL+3], ax
mov [SPValueInRealMode], sp
; 初始化 16 位代码段描述符
mov ax, cs
movzx eax, ax
shl eax, 4
add eax, LABEL_SEG_CODE16
mov word [LABEL_DESC_CODE16 + 2], ax
shr eax, 16
mov byte [LABEL_DESC_CODE16 + 4], al
mov byte [LABEL_DESC_CODE16 + 7], ah
; 初始化非一致代码段描述符
xor eax, eax
mov ax, cs
shl eax, 4
add eax, LABEL_SEG_CODE32 ; 计算非一致代码段基地址物理地址
mov word [LABEL_DESC_CODE32 + 2], ax
shr eax, 16
mov byte [LABEL_DESC_CODE32 + 4], al
mov byte [LABEL_DESC_CODE32 + 7], ah
; 初始化数据段描述符
xor eax, eax
mov ax, ds
shl eax, 4
add eax, LABEL_DATA
mov word [LABEL_DESC_DATA + 2], ax
shr eax, 16
mov byte [LABEL_DESC_DATA + 4], al
mov byte [LABEL_DESC_DATA + 7], ah
; 初始化堆栈段描述符
xor eax, eax
mov ax, ds
shl eax, 4
add eax, LABEL_STACK
mov word [LABEL_DESC_STACK + 2], ax
shr eax, 16
mov byte [LABEL_DESC_STACK + 4], al
mov byte [LABEL_DESC_STACK + 7], ah
; 准备加载 GDTR
xor eax, eax ; 清空 eax 寄存器
mov ax, ds
shl eax, 4
add eax, LABEL_GDT ; 计算出 GDT 基地址的物理地址
mov dword [GdtPtr + 2], eax ; [GdtPtr + 2] <- gdt 基地址
; 加载 GDTR
lgdt [GdtPtr]
; 关闭硬件中断
cli
; 打开 A20 地址总线
in al, 92h
or al, 00000010b
out 92h, al
; 置位 PE 标志位,打开保护模式
mov eax, cr0
or eax, 1
mov cr0, eax
; 跳转进入保护模式
jmp dword SelectorCode32:0 ; 执行这一句会把 SelectorCode32 装入 cs,
; 并跳转到 Code32Selector:0 处
; 从保护模式跳回到实模式
LABEL_REAL_ENTRY:
mov ax, cs
mov ds, ax
mov es, ax
mov ss, ax
mov sp, [SPValueInRealMode]
; 关闭 A20 地址线
in al, 92h
and al, 0fdh
out 92h, al
; 打开硬件中断
sti
; 触发 BIOS int 21h 中断,回到实地址模式
mov ax, 4c00h
int 21h
[SECTION .s32] ; 32 位代码段. 由实模式跳入.
[BITS 32]
LABEL_SEG_CODE32:
call SetupPaging
mov ax, SelectorData
mov ds, ax ; 数据段选择子
mov ax, SelectorVideo
mov gs, ax ; 赋值视频段选择子
mov ax, SelectorStack
mov ss, ax ; 堆栈段选择子
mov esp, TopOfStack
xor edi, edi
mov edi, 80 * 2 * 2 ; 屏幕第 2 行, 第 0 列
xor esi, esi
mov esi, OffsetBootMessage
call DisplayString
jmp SelectorCode16:0
; ---------------------- 分页机制启动 ---------------------------
SetupPaging:
; 为简化处理, 所有线性地址对应相等的物理地址.
; 初始化页目录
mov ax, SelectorPageDir ; 此段首地址为 PageDirBase
mov es, ax
mov ecx, 1024 ; 共 1K 个表项
xor edi, edi
xor eax, eax
mov eax, PageTblBase | 7 ; 用户级、存在于内存、可读写
.filter_pde:
stosd
add eax, 4096 ; 为了简化, 所有页表在内存中是连续的.
loop .filter_pde
; 初始化所有页表 (1K 个, 4M 内存空间)
mov ax, SelectorPageTbl ; 此段首地址为 PageTblBase
mov es, ax
mov ecx, 1024 * 1024 ; 共 1M 个页表项, 也即有 1M 个页
xor edi, edi
xor eax, eax
mov eax, 7 ; 用户级、存在于内存、可读写
.filter_pte:
stosd
add eax, 4096 ; 每一页指向 4K 的空间
loop .filter_pte
; 设置页目录表起始地址
mov eax, PageDirBase
mov cr3, eax
; 开启分页机制
mov eax, cr0
or eax, 80000000h
mov cr0, eax
ret
; ------------------------- 打印字符串 -------------------------
DisplayString:
push eax
mov ah, 8Ch ; 0000: 黑底 1100: 红字
cld
.loop_label:
lodsb
test al, al
jz .over_print
mov [gs:edi], ax
add edi, 2
jmp .loop_label
.over_print:
pop eax
ret
SegCode32Len equ $ - LABEL_SEG_CODE32
; 16 位代码段. 由 32 位代码段跳入, 跳出后到实模式
[SECTION .s16code]
ALIGN 32
[BITS 16]
LABEL_SEG_CODE16:
; 跳回实模式:
mov ax, SelectorNormal
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
mov eax, cr0
and eax, 7FFFFFFEh ; PE=0, PG=0
mov cr0, eax
LABEL_GO_BACK_TO_REAL:
jmp word 0:LABEL_REAL_ENTRY ; 段地址会在程序开始处被设置成正确的值
Code16Len equ $ - LABEL_SEG_CODE16
; END of [SECTION .s16code]
8. 参考资料
https://en.wikipedia.org/wiki/Paging。 https://slide-finder.com/view/IA32-Paging-Scheme-Introduction.268455.html。 https://en.wikipedia.org/wiki/Translation\_lookaside\_buffer。 https://en.wikipedia.org/wiki/Memory\_type\_range\_register。 https://en.wikipedia.org/wiki/Control\_register。
推荐阅读
-
【2022新手指南】Java编程进阶之路 - 六、技术架构篇 ### MySQL索引底层解析与优化实战 - 你会讲解MySQL索引的数据结构吗?性能调优技巧知多少? - Redis深度揭秘:你知道多少?从基础到哨兵、主从复制全梳理 - Redis持久化及哨兵模式详解,还有集群搭建和Leader选举黑箱打开 - Zookeeper是个啥?特性和应用场景大公开 - ZooKeeper集群搭建攻略及 Leader选举、读写一致性、共享锁实现细节 - 探究ZooKeeper中的Leader选举机制及其在分布式环境中的作用 - Zab协议深入剖析:原理、功能与在Zookeeper中的核心地位 - RabbitMQ全方位解读:工作模式、消费限流、可靠投递与配置策略 - 设计者视角:RabbitMQ过期时间、死信队列与延时队列实践指南 - RocketMQ特性和应用场景揭示:理解其精髓与差异化优势 - Kafka详细介绍:特性及广泛应用于实时数据处理的场景解析 - ElasticSearch实力揭秘:特性概述与作为搜索引擎的广泛应用 - MongoDB认知升级:非关系型数据库的优势阐述,安装与使用实战教学 - BIO/NIO/AIO网络模型对比:掌握它们的区别与在网络编程中的实际应用 - Netty带你飞:理解其超快速度背后的秘密,包括线程模型分析 - 网络通信黑科技:Netty编解码原理与常用编解码器的应用,Protostuff实战演示 - 解密Netty粘包与拆包现象,怎样有效应对这一常见问题 - 自定义Netty心跳检测机制,轻松调整检测间隔时间的艺术 - Dubbo轻骑兵介绍:核心特性概览,服务降级实战与其实现益处 - Dubbo三大神器解读:本地存根与本地伪装的实战运用与优势呈现 ----------------------- 七、结语与回顾
-
详细了解操作系统分页机制和实践
-
Java 类加载器的作用 - 简介:类加载器是 Java™ 中一个非常重要的概念。类加载器负责将 Java 类的字节码加载到 Java 虚拟机中。本文首先详细介绍了 Java 类加载器的基本概念,包括代理模型、加载类的具体过程和线程上下文类加载器等。然后介绍了如何开发自己的类加载器,最后介绍了类加载器在 Web 容器和 OSGi™ 中的应用。 类加载器是 Java 语言的一项创新,也是 Java 语言广受欢迎的重要原因之一。它允许将 Java 类动态加载到 Java 虚拟机中并执行。类加载器从 JDK 1.0 开始出现,最初是为了满足 Java Applets 的需求而开发的,Java Applets 需要从远程位置下载 Java 类文件并在浏览器中执行。现在,类加载器已广泛应用于网络容器和 OSGi。一般来说,Java 应用程序的开发人员不需要直接与类加载器交互;Java 虚拟机的默认行为足以应对大多数情况。但是,如果遇到需要与类加载器交互的情况,而您又不太了解类加载器的机制,就很容易花费大量时间调试异常,如 ClassNotFoundException 和 NoClassDefFoundError。本文将详细介绍 Java 的类加载器,帮助读者深入理解 Java 语言中的这一重要概念。下面先介绍一些基本概念。 类加载器的基本概念 顾名思义,类加载器用于将 Java 类加载到 Java 虚拟机中。一般来说,Java 虚拟机以如下方式使用 Java 类:Java 源程序(.java 文件)经 Java 编译器编译后转换为 Java 字节代码(.class 文件)。类加载器负责读取 Java 字节代码并将其转换为 java.lang 实例。每个实例都用来表示一个 Java 类。通过该实例的 newInstance 方法创建该类的对象。实际情况可能更加复杂,例如,Java 字节代码可能是由工具动态生成或通过网络下载的。 基本上,所有类加载器都是 java.lang.ClassLoader 类的实例。下面将详细介绍这个 Java 类。 java.lang.ClassLoader 类简介 java.lang.ClassLoader 类的基本职责是根据给定类的名称为其查找或生成相应的字节码,然后根据这些字节码定义一个 Java 类,即 java.lang.Class 类的实例。除此之外,ClassLoader 还负责加载 Java 应用程序所需的资源,如图像文件和配置文件。不过,本文只讨论它加载类的功能。为了履行加载类的职责,ClassLoader 提供了许多方法,其中比较重要的方法如表 1 所示。下文将详细介绍这些方法。 表 1.与加载类相关的 ClassLoader 方法