1. 保护模式进阶:从内存检测到分页机制
在操作系统开发中,理解并实现保护模式是迈向现代操作系统的关键一步。本章我们将深入探讨如何获取物理内存容量、建立分页机制,并最终加载内核到内存中。这些技术构成了操作系统内存管理的基石。
1.1 物理内存容量检测
在实模式下,我们可以利用BIOS中断来获取系统的物理内存信息。这是操作系统启动初期必须完成的任务,因为后续的内存管理都依赖于对物理内存布局的准确掌握。
1.1.1 BIOS中断0x15子功能0xE820
这是最全面但也最复杂的内存检测方法。它通过多次调用返回多个"地址范围描述符结构"(ARDS),每个描述符描述一段连续的内存区域。
assembly复制;------- int 15h eax = 0000E820h, edx = 534D4150h ('SMAP') 获取内存布局 -------
xor ebx, ebx ; 第一次调用时ebx必须为0
mov edx, 0x534d4150 ; 'SMAP'签名
mov di, ards_buf ; ARDS缓冲区指针
.e820_mem_get_loop:
mov eax, 0x0000e820 ; 功能号
mov ecx, 20 ; ARDS结构大小为20字节
int 0x15
jc .e820_failed_so_try_e801 ; CF=1表示出错
add di, cx ; 移动缓冲区指针
inc word [ards_nr] ; ARDS计数增加
cmp ebx, 0 ; ebx=0表示已是最后一个ARDS
jnz .e820_mem_get_loop ; 继续获取下一个ARDS
每个ARDS结构包含以下字段:
- BaseAddrLow/High:内存区域起始地址
- LengthLow/High:内存区域长度
- Type:内存类型(1表示可用内存)
1.1.2 备用检测方法:0xE801和0x88
当0xE820不可用时,我们可以尝试更简单的0xE801子功能:
assembly复制.e820_failed_so_try_e801:
mov ax,0xe801
int 0x15
jc .e801_failed_so_try88 ; 失败则尝试0x88
; 处理低15MB内存
mov cx,0x400 ; 1024(转换为字节)
mul cx
shl edx,16
and eax,0x0000FFFF
or edx,eax
add edx,0x100000 ; 加上1MB
; 处理16MB以上内存
xor eax,eax
mov ax,bx
mov ecx,0x10000 ; 64KB单位
mul ecx
add edx,eax ; 得到总内存大小
如果所有方法都失败,最后可以尝试最简单的0x88子功能,但它只能检测64MB以内的内存。
1.2 分页机制原理与实现
分段机制虽然提供了基本的内存保护,但存在物理内存必须连续的缺陷。现代操作系统普遍采用分页机制来解决这个问题。
1.2.1 为什么需要分页机制?
分段机制的主要问题包括:
- 物理内存必须连续分配,导致内存碎片
- 内存利用率低,难以实现高效的虚拟内存
- 地址空间隔离性差,多任务管理复杂
分页机制通过将线性地址空间划分为固定大小的页(通常4KB),并将这些页映射到任意物理页框,完美解决了这些问题。
1.2.2 二级页表结构
32位系统通常采用二级页表结构:
- 页目录表(Page Directory):1024个条目,每个条目指向一个页表
- 页表(Page Table):1024个条目,每个条目指向一个物理页框
地址转换过程:
- 高10位定位页目录项(PDE)
- 中间10位定位页表项(PTE)
- 低12位作为页内偏移
页目录项和页表项的结构如下:
code复制31-12位:物理地址的高20位
11-9位:AVL(操作系统可用)
8位:G(全局页)
7位:PS(页大小)
6位:D(脏页)
5位:A(访问位)
4位:PCD(缓存禁止)
3位:PWT(通写)
2位:U/S(用户/超级用户)
1位:R/W(读写权限)
0位:P(存在位)
1.3 分页机制的具体实现
1.3.1 页表初始化代码分析
我们的页表初始化代码主要完成以下工作:
- 清零页目录表空间
- 创建第一个页表并设置前256个页表项(映射低1MB内存)
- 设置页目录项,使虚拟地址0x00000000-0x003fffff和0xc0000000-0xc03fffff都映射到同一物理页表
- 初始化内核空间的其他页目录项
关键代码片段:
assembly复制setup_page:
; 清零页目录表
mov ecx, 4096
mov esi, 0
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS + esi], 0
inc esi
loop .clear_page_dir
; 创建第一个页表(映射低1MB)
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x1000 ; 第一个页表的位置
mov ebx, eax ; ebx = 页表基址
or eax, PG_US_U | PG_RW_W | PG_P ; 设置属性
mov [PAGE_DIR_TABLE_POS + 0x0], eax ; 第0个目录项
mov [PAGE_DIR_TABLE_POS + 0xc00], eax ; 第768个目录项(0xc0000000)
; 创建页表项(PTE)
mov ecx, 256 ; 256个页表项=1MB
mov esi, 0
mov edx, PG_US_U | PG_RW_W | PG_P
.create_pte:
mov [ebx+esi*4], edx ; 每个PTE指向4KB物理页
add edx, 4096 ; 下一个物理页
inc esi
loop .create_pte
; 创建内核其他页表的PDE
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000 ; 第二个页表
or eax, PG_US_U | PG_RW_W | PG_P
mov ebx, PAGE_DIR_TABLE_POS
mov ecx, 254 ; 769-1022目录项
mov esi, 769
.create_kernel_pde:
mov [ebx+esi*4], eax
inc esi
add eax, 0x1000
loop .create_kernel_pde
ret
1.3.2 启用分页机制
在页表初始化完成后,我们需要:
- 将页目录表物理地址加载到CR3寄存器
- 设置CR0的PG位(第31位)来启用分页
assembly复制 mov eax, PAGE_DIR_TABLE_POS
mov cr3, eax ; 加载页目录表地址
mov eax, cr0
or eax, 0x80000000 ; 设置PG位
mov cr0, eax ; 启用分页
1.4 内核加载与ELF格式解析
1.4.1 ELF文件格式概述
ELF(Executable and Linkable Format)是Linux系统下的标准可执行文件格式,由以下几部分组成:
- ELF Header:描述文件的基本信息
- Program Header Table:描述段(Segment)信息
- Section Header Table:描述节(Section)信息
- 实际的数据和代码
关键数据结构:
c复制// ELF Header结构
typedef struct {
unsigned char e_ident[16]; // ELF识别魔数
uint16_t e_type; // 文件类型
uint16_t e_machine; // 目标机器架构
// ... 其他字段
uint32_t e_phoff; // Program Header Table偏移
uint32_t e_shoff; // Section Header Table偏移
// ... 其他字段
} Elf32_Ehdr;
// Program Header结构
typedef struct {
uint32_t p_type; // 段类型
uint32_t p_offset; // 段在文件中的偏移
uint32_t p_vaddr; // 段的虚拟地址
uint32_t p_paddr; // 段的物理地址
uint32_t p_filesz; // 段在文件中的大小
uint32_t p_memsz; // 段在内存中的大小
// ... 其他字段
} Elf32_Phdr;
1.4.2 内核加载流程
内核加载的主要步骤:
- 从磁盘读取ELF头部,验证其有效性
- 遍历Program Header Table,找到所有LOAD类型的段
- 将每个段从文件读取到其指定的虚拟地址
- 跳转到ELF头中指定的入口点执行
关键代码配置:
assembly复制KERNEL_BIN_BASE_ADDR equ 0x70000 ; 内核临时缓冲区
KERNEL_START_SECTOR equ 0x9 ; 内核起始扇区
KERNEL_ENTRY_POINT equ 0xc0001500 ; 内核入口地址
2. 实际操作中的关键问题与解决方案
2.1 内存检测的可靠性问题
在实际操作中,我们发现不同硬件平台对BIOS内存检测中断的支持程度不同。为确保兼容性,我们采用了三级回退策略:
- 首先尝试最全面的0xE820方法
- 失败后尝试较简单的0xE801方法
- 最后使用最基本的0x88方法
经验表明,现代硬件通常都能支持0xE820,但在一些虚拟机环境中可能需要回退到0xE801。
2.2 分页机制建立时的地址映射技巧
在建立分页机制时,我们采用了一个重要技巧:将用户空间和内核空间的低端部分映射到相同的物理页表。具体实现:
assembly复制; 使虚拟地址0x00000000-0x003fffff和0xc0000000-0xc03fffff
; 都指向同一个页表(第一个页表)
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x1000 ; 第一个页表的位置
or eax, PG_US_U | PG_RW_W | PG_P
mov [PAGE_DIR_TABLE_POS + 0x0], eax ; 第0个目录项
mov [PAGE_DIR_TABLE_POS + 0xc00], eax ; 第768个目录项
这样做的原因是:在加载内核前,我们仍在使用低1MB内存运行loader代码。开启分页后,无论通过用户空间地址还是内核空间地址访问,都能正确访问到物理内存。
2.3 内核加载地址的选择
内核加载地址的选择需要考虑以下因素:
- 避开loader使用的内存区域(0x900-0x1500)
- 位于分页机制启用后可直接访问的区域
- 预留足够的空间供内核增长
我们选择0x70000作为临时缓冲区,最终内核将被加载到0xc0001500。这个地址位于内核虚拟地址空间(0xc0000000以上),同时避开了关键的系统数据结构。
3. 调试技巧与常见问题排查
3.1 分页机制启用失败的排查步骤
- 检查CR3设置:确保CR3寄存器正确指向页目录表的物理地址
- 验证页目录项和页表项:特别是P位(存在位)是否设置
- 检查地址映射:确保当前执行的代码所在区域有正确的映射
- 使用Bochs调试器:通过
info tab命令查看当前页表状态
3.2 ELF加载失败的常见原因
- 魔数不匹配:检查ELF头部的e_ident字段是否为0x7F+'ELF'
- 段越界:确保p_vaddr+p_memsz不超过可用内存
- 对齐问题:检查p_align字段,确保段按页对齐(通常4KB)
- 文件读取错误:验证从磁盘读取的扇区数据是否正确
3.3 调试工具的使用技巧
-
Bochs调试命令:
page 0x12345678:查看指定线性地址的页表映射info gdt/info idt:查看描述符表状态creg:查看控制寄存器状态
-
QEMU调试技巧:
-d cpu_reset:跟踪CPU重置状态-S -s:启动调试服务器,配合GDB使用
-
自定义调试输出:
在关键位置插入视频内存输出代码,显示状态信息:
assembly复制mov byte [gs:160], 'V' ; 显示字符表示执行到某位置
4. 性能优化与进阶思考
4.1 大页(4MB)分页的优化
对于内核代码等大块连续内存区域,可以使用4MB大页来减少TLB缺失:
- 设置PDE的PS位(Page Size)为1
- 直接使用PDE指向4MB物理页框
- 跳过页表级转换
优点:
- 减少页表内存占用
- 提高TLB命中率
- 简化地址转换过程
4.2 按需分页与延迟加载
现代操作系统通常采用按需分页策略:
- 初始时只建立必要的页表映射
- 将其他页表项的P位清零
- 在页错误异常处理程序中动态加载所需页面
这种技术可以:
- 减少启动时的内存占用
- 支持内存映射文件
- 实现写时复制(Copy-on-Write)等高级特性
4.3 64位系统的分页扩展
在x86_64架构下,分页机制扩展为四级:
- PML4表(Page Map Level 4)
- 页目录指针表(PDPT)
- 页目录表(PD)
- 页表(PT)
每级使用9位索引,共48位虚拟地址空间。理解32位分页机制为学习64位分页打下了坚实基础。
5. 从理论到实践的完整示例
5.1 完整loader实现的关键部分
assembly复制[bits 32]
p_mode_start:
; 设置段寄存器
mov ax, SELECTOR_DATA
mov ds, ax
mov es, ax
mov ss, ax
mov esp, LOADER_STACK_TOP
; 初始化页表
call setup_page
; 调整GDT地址到内核空间
mov ebx, [gdt_ptr + 2]
or dword [ebx + 0x18 + 4], 0xc0000000 ; 视频段描述符
add dword [gdt_ptr + 2], 0xc0000000 ; GDT基址
add esp, 0xc0000000 ; 栈指针
; 启用分页
mov eax, PAGE_DIR_TABLE_POS
mov cr3, eax
mov eax, cr0
or eax, 0x80000000
mov cr0, eax
; 重新加载GDT
lgdt [gdt_ptr]
; 加载内核
call load_kernel
; 跳转到内核入口
jmp KERNEL_ENTRY_POINT
load_kernel:
; 1. 读取ELF头部
mov eax, KERNEL_START_SECTOR
mov ebx, KERNEL_BIN_BASE_ADDR
mov ecx, 8 ; 读取前8个扇区(足够容纳ELF头)
call rd_disk_m_32
; 2. 验证ELF魔数
cmp dword [KERNEL_BIN_BASE_ADDR], 0x464C457F ; "\x7FELF"
jne .bad_elf
; 3. 遍历Program Header,加载每个段
mov eax, [KERNEL_BIN_BASE_ADDR + 28] ; e_phoff
add eax, KERNEL_BIN_BASE_ADDR ; Program Header Table地址
mov ebx, [KERNEL_BIN_BASE_ADDR + 44] ; e_phnum
.load_segments:
cmp dword [eax], 1 ; p_type == PT_LOAD?
jne .next_segment
; 读取段数据
push eax
mov ecx, [eax + 4] ; p_offset
add ecx, KERNEL_START_SECTOR * 512 ; 转换为扇区号
mov ebx, [eax + 8] ; p_vaddr
mov edx, [eax + 16] ; p_filesz
call rd_disk_m_32
pop eax
.next_segment:
add eax, 32 ; 下一个Program Header
dec ebx
jnz .load_segments
ret
.bad_elf:
; 错误处理
mov byte [gs:320], 'E'
mov byte [gs:322], 'L'
mov byte [gs:324], 'F'
hlt
5.2 从实模式到保护模式再到分页的完整流程
- BIOS加载MBR:从磁盘第一个扇区加载到0x7c00
- MBR加载loader:从磁盘加载loader到0x900
- 实模式内存检测:使用BIOS中断获取内存信息
- 进入保护模式:
- 加载GDT
- 设置CR0.PE位
- 长跳转刷新流水线
- 建立分页机制:
- 初始化页目录表和页表
- 设置CR3寄存器
- 启用CR0.PG位
- 加载内核:
- 解析ELF格式
- 按段加载到指定虚拟地址
- 跳转到内核:执行内核入口点代码
5.3 关键数据结构和宏定义
在boot.inc中定义的关键常量:
assembly复制; 页表相关属性
PG_P equ 1b ; 存在位
PG_RW_R equ 00b ; 只读
PG_RW_W equ 10b ; 可读可写
PG_US_S equ 000b ; 超级用户
PG_US_U equ 100b ; 普通用户
; 内核加载配置
KERNEL_BIN_BASE_ADDR equ 0x70000
KERNEL_START_SECTOR equ 0x9
KERNEL_ENTRY_POINT equ 0xc0001500
; ELF段类型
PT_NULL equ 0
PT_LOAD equ 1
6. 总结与展望
通过本章的学习,我们完成了从实模式到保护模式再到分页机制的完整过渡,为内核加载和执行奠定了坚实基础。关键收获包括:
- 物理内存检测:掌握了多种BIOS中断获取内存信息的方法
- 分页机制:深入理解了二级页表结构及其实现细节
- 内核加载:学习了ELF格式解析和段加载技术
- 地址空间布局:设计了用户空间和内核空间的合理划分
这些技术不仅是操作系统开发的核心,也是理解现代计算机体系结构的重要窗口。在后续开发中,我们将基于这些基础构建更复杂的内存管理功能,如:
- 动态内存分配(malloc/free)
- 虚拟内存和页面置换
- 内存映射文件
- 写时复制技术
- 进程地址空间隔离
操作系统开发是一个循序渐进的过程,每一层抽象都建立在可靠的底层机制之上。通过亲手实现这些基础功能,我们能够获得对计算机系统更深层次的理解,这种理解是单纯理论学习难以替代的。