平坦内存模型
现代操作系统一般不会使用过于复杂的分段机制,而是采用平坦内存模型 + 分页模型来管理内存。
平坦内存模型(Flat Memory Model),这是现代操作系统(如 Linux 和 Windows)常用的内存模型。在这种模型中,所有段的段基址都为 0,段界限为 4GB,使得整个内存空间看起来像一个连续的内存块. 因此进程可以访问整个虚拟内存空间。这种模型简化了内存管理,使得代码更加接近于没有段式管理的模型。
在 32 位平坦内存模型中,虽然段的地址范围被设置为 0 到 4GB,但这并不意味着系统实际上必须有 4GB 的物理内存。
汇编定义
[SECTION .gdt]
CODE_DESC: dd 0x0000FFFFdd 0x00CF9800
DATA_STACK_DESC: dd 0x0000FFFFdd 0x00CF9200
代码段
数据段
获取物理内存容量
一般而言有以下办法:
- INT 0x12 仅获取1MB以下的常规内存大小。
- INT 0x15, AH=0x88 可以获取1MB以上的扩展内存大小,但不包括详细的内存布局。
- INT 0x15, EAX=0xE820 是获取物理内存布局的最佳选择,适用于现代计算机。
- 读取CMOS 可以获取扩展内存,但仅限于64MB以下。
- UEFI 提供了更多现代化的服务来获取内存信息,但需要在EFI环境中运行。
现代一般使用 INT 0x15, EAX=0xE820 方法。
INT 0x15, EAX=0xE820 物理内存布局
一次中断只会返回一个 ARDS 也即一种布局,实际可用物理内存需要遍历 所有 Type == 1 的布局,累加并保存到缓冲区(因为保护模式下中断已不可使用)。
输入
EAX = 0xE820
: 指定功能号,表示请求物理内存布局信息。EBX
: 指定调用的偏移量。第一次调用时设置为0
,后续调用时使用上一次调用返回的EBX
继续查询,直到EBX
返回0
表示查询结束。ES:DI
: 指向保存结果的缓冲区的指针,通常为一个 ARDS 结构
struct ARDS /* 20 bits */ {uint64_t base_address; // 内存区域的起始地址uint64_t length; // 内存区域的长度(以字节为单位)uint32_t type; // 内存类型, 操作系统一般只可以使用 type == 1 的内存uint32_t extended; // 扩展属性,通常为 0
};
ECX
: 指定 ARDS 结构的大小,通常为20
(字节数),表示缓冲区的大小。EDX
: 必须为签名'SMAP'
(ASCII),即EDX = 0x534D4150
。
输出
EAX
: 如果成功,则保持为0x534D4150
,即'SMAP'
签名。EBX
: 如果EBX = 0
,表示已返回所有的内存区块信息,否则应将返回的EBX
用于下一次调用。CF
(Carry Flag): 如果 CF = 0 表示成功,如果 CF = 1 表示错误(此时EAX
可能包含错误代码)。ES:DI
: 包含内存区域描述结构 ARDS 的数据。
示例
; -------------------------; Memory; -------------------------xor ebx, ebxmov edx, 0x534D4150 ; "SMAP"mov di, ArdsBuf
.try_e820:mov eax, 0x0000E820mov ecx, 20int 0x15jc .e820_failadd di, cxinc word [ArdsCnt]cmp ebx, 0jnz .try_e820mov cx, word [ArdsCnt]mov ebx, ArdsBufxor edx, edx
.find_usable_memory:mov eax, [ebx]add eax, [ebx + 8]add ebx, 20cmp edx, eaxjge .next_ardmov edx, eax
.next_ard:loop .find_usable_memoryjmp .success_get_memory
.e820_fail:mov ax, Messagemov bx, 0x07mov cx, MessageLengthxor dx, dxcall printstrhalt
.success_get_memory:mov [TotalMemoryBytes], edx
分页机制
对于编译器而言,地址本身是连续的,被称作线性地址。在只分段的情况下,CPU 认为线性地址等同于物理地址,这种传统模型有以下缺陷:
- 分段模式要求每个段的内存是连续的,因此当需要分配大块内存时,可能会因为内存碎片的问题导致无法找到足够大的连续空间。这在程序运行过程中,特别是随着内存分配和释放的频繁进行,会造成内存的碎片化。
- 分段模式无法有效实现进程间的完全隔离。不同的进程共享相同的内存空间模型,如果段描述符配置错误,可能导致进程之间的内存冲突或数据泄露。
分页机制是通过将内存分为固定大小的页来进行管理,每个页在物理内存中的位置可以不连续。分页机制是 CPU 从硬件层面就支持的功能。因此一旦启用分页机制,汇编代码的线性的地址都会被CPU根据页表自动转化为物理地址。
一级页表
CPU 规定一页的长度为 4KB, 于是 4GB 空间被分为 1 M 页,32位的线性地址被分为两部分:高20位为页表索引,低12位为页内偏移。
二级页表
一个页表项为 4个字节,完整的物理内存映射至少需要 4MB 空间以建立一级页表映射。每个进程都需要自己独立的地址空间,因此一级页表方案的内存消耗过大。
x86 默认使用二级页表分页,将1M个页平均放置到1K个页表中,每个页表包含1K个页表项,每个页表项4字节,即二级页表这个大小恰好是4KB大小,即一个页。与一级页表不同的是一级页表必须提前建立,每个进程都需要 4MB 空间进行映射,但是二级页表除了页目录表以外,其二级页表是动态建立的。极大的节约了空间。
传统x86二级分页,将线性空间分为:
- 高10位:用来定位页目录表中的一个页目录项 (PDE)(页目录项中包含页表的物理地址)
- 中间10位:用于在某个页表中定位页表项 (PTE)
- 低12位:页内偏移量
由于 PDE,PTE 的均为 4 字节长,在访问一个线性地址时:
- 用虚拟地址的高10位乘以4,再加上页目录表的物理地址,便是页目录项的物理地址,读取该物理地址处的内容,获得页表的物理地址
- 用虚拟地址的中间10位乘以4,再加上一步获得的页表的物理地址,便是页表项的的物理地址,读取页表项的内容,便可从页表项的数据结构中获取我们需要访问的物理地址
- 将该物理地址再加上虚拟地址的低12位,便是最终我们要访问的物理地址
页表项与页目录项一致:
位位置 | 属性位名称 | 含义 | 常见取值 |
---|---|---|---|
0 | P (Present) | 页是否存在 | 0: 不存在,1: 存在 |
1 | R/W (Read/Write) | 页面读写权限 | 0: 只读,1: 可读写 |
2 | U/S (User/Supervisor) | 用户模式和内核模式的访问权限 | 0: 内核模式,1: 用户模式 |
3 | PWT (Page Write-Through) | 写策略 | 0: 回写(Write-back),1: 写通(Write-through) |
4 | PCD (Page Cache Disable) | 缓存策略 | 0: 允许缓存,1: 禁止缓存 |
5 | A (Accessed) | 该页是否被访问过 | 0: 未访问,1: 已访问 |
6 | D (Dirty) | 该页是否被修改过(仅页表项有效) | 0: 未修改,1: 已修改 |
7 | PS (Page Size) | 页的大小 | 0: 4KB 页,1: 4MB 页 |
8 | G (Global) | 是否为全局页面(TLB切换时不刷新) | 0: 非全局,1: 全局 |
9-11 | AVL (Available) | 保留位,操作系统可用 | 未定义,操作系统自定义使用 |
12-31 | Base Address | 页表或页的物理地址(对齐到4KB,低12位为0) | 页表或物理页的基地址 |
特别的,唯一的页目录物理地址需要提前存放到 CR3 寄存器(页目录基址寄存器):
PCD, PWT 位一般都取0. 因此低12位均为0.
多进程与分页机制
- 页表是多进程操作系统实现虚拟内存的基础,每个进程有独立的页表,保证了内存隔离。
- 操作系统通过按需分配内存、共享内存、分页换页等机制,动态管理进程的虚拟地址空间。
- 虚拟地址空间的划分通常包括用户空间和内核空间,进程通过页表实现虚拟地址到物理地址的映射。
用户进程通常依赖操作系统的系统调用。也即内核空间的代码,操作系统在划分地址空间时,通常会将内核映射到高位地址。所有进程的内核空间都实际上对应同一片物理地址。
示例: 类 Linux 的地址空间映射
startup_page:mov ecx, 4096mov esi, 0
.clear_page_dir:mov byte [PAGE_DIR_TABLE_BASE + esi], 0inc esiloop .clear_page_dir
.create_pde:mov eax, PAGE_DIR_TABLE_BASEadd eax, 0x1000mov ebx, eaxor eax, PG_US_U | PG_RW_W | PG_P; 第 0 PDE 和 第 768 PDE 都指向同一个页表(第0PTE); 第 0 PDE 是为了将 0x00000000 - 0x003FFFFF 映射到 0x00000000 - 0x003FFFFF.; 第 768 PDE 是为了将 0x00000000 - 0x003FFFFF 映射到 0xC0000000 - 0xC03FFFFF. ; 因为我们内核和 loader 位于 低端 4 MB 之内, 而我们规定内核将会映射到虚拟地址的高 3GB 以上 (0xC0000000 - 0xFFFFFFFF); 至于 0 PDE 是为了保证,对于 loader 代码 (0 - 0xfffff) ,线性地址和物理地址是一样的。 mov dword [PAGE_DIR_TABLE_BASE + 0x0], eaxmov dword [PAGE_DIR_TABLE_BASE + 0xc00], eax; 将最后 PDE 设为页目录表的物理地址,这是为了动态操作页表sub eax, 0x1000mov dword [PAGE_DIR_TABLE_BASE + 4092], eax; 创建第 0 PTEmov ecx, 1024 ; map 4MBmov esi, 0mov edx, PG_US_U | PG_RW_W | PG_P
.create_pte:mov dword [ebx + esi * 4], edxadd edx, 4096inc esiloop .create_pte; 创建内核其它 PDEmov eax, PAGE_DIR_TABLE_BASEadd eax, 0x2000or eax, PG_RW_W | PG_US_U | PG_Pmov ebx, PAGE_DIR_TABLE_BASEmov ecx, 254 ; 769 - 1022 PDEmov esi, 769
.create_kernel_pde:mov [ebx + esi * 4], eaxinc esiadd eax, 0x1000loop .create_kernel_pderet
在 Bochs 下的映射
0x00000000-0x003fffff -> 0x000000000000-0x0000003fffff
0xc0000000-0xc03fffff -> 0x000000000000-0x0000003fffff
# 后面三项是由于最后 PDE 设为页目录表的物理地址,bochs 将 PDE 表本身当作了 256 项 PTE 表导致的。
0xffc00000-0xffc00fff -> 0x000000101000-0x000000101fff
0xfff00000-0xffffefff -> 0x000000101000-0x0000001fffff
0xfffff000-0xffffffff -> 0x000000100000-0x000000100fff
- 访问页目录项:0xfffffxxx, 其中 xxx 是页目录项的索引 * 4
内核加载
用 GNU C 套件开发生成的内核本质上是一个 ELF 文件头可执行文件。这意味着:
- 生成内核时,需要手动将可重定向文件链接时指定代码段所在线性地址 (高位 0xc0000000-0xc03fffff)
- Loader 将原始 kernel 映像从磁盘读出,存放到内核可用内存的 (较高处) 内核本身不会触及的位置。
- Loader 根据 ELF 文件格式,将 ELF 中的各个段展开到内核空间。
- Loader 将控制权转移到内核。
内核加载地址的策略
- 内核加载地址可以任意在内核空间选定,但不能破坏 loader (0x900 - 0x1500) 所在区域,扩展 BIOS 数据区 (0x9FC00-0x9FFFF), 原始 kernel 映像所在区域。
- 内核加载完毕后可以选择覆盖原始kernel映像,但还是不能破坏 loader (0x900 - 0x1500) 所在区域,扩展 BIOS 数据区 (0x9FC00-0x9FFFF)
; ---------------------------------------------------
; KERNEL
; ---------------------------------------------------
PAGE_DIR_TABLE_BASE equ 0x500000
KERNEL_ENTRY_POINT equ 0xc0100000
KERNEL_BIN_SECTOR equ 0x9
KERNEL_BIN_ADDR equ 0x300000
KERNEL_STACK_BOTTOM equ 0xc0400000
注意:此处策略与书中不同