分页机制是现代操作系统内存管理的基石,而Linux内核在32位系统下的实现充分体现了其高效性与灵活性。本文将从分页机制的基本原理出发,详细讲解Linux内核如何在32位x86架构下构建页目录(Page Directory)和页表(Page Table),如何为它们赋值,并深入探讨一个关键问题:当进程分配到物理内存的高地址(如最高1GB)时,如何正确赋值页表而不干扰内核空间的映射。
第一部分:32位系统分页机制基础
1.1 分页机制的基本原理
分页机制的核心思想是将内存划分为固定大小的块,称为页面(Page)。在32位系统中,页面大小通常为4KB(2^12字节),这是因为低12位用于表示页面内的偏移量,而高20位用于索引页面。这种设计源于硬件和操作系统的折中,既能高效利用内存,又便于管理。
- 虚拟地址(Virtual Address):每个进程拥有独立的4GB(2^32字节)虚拟地址空间。
- 物理地址(Physical Address):实际的硬件内存地址,通过分页机制映射而来。
- 页面与页面框架:虚拟地址空间被划分为页面,物理内存被划分为页面框架(Page Frame),两者通过页表建立映射关系。
分页机制的优势包括:
- 进程隔离:每个进程的虚拟地址空间独立,互不干扰。
- 内存保护:通过页表属性(如读写权限)限制访问。
- 按需分页(Demand Paging):只加载需要的页面,节省物理内存。
- 高效分配:固定大小的页面避免外部碎片,提升内存利用率。
分页机制由硬件和软件协同实现:
- 硬件:MMU(Memory Management Unit)负责地址转换,TLB(Translation Lookaside Buffer)缓存常用映射。
- 软件:操作系统(如Linux)维护页表,处理页面错误。
1.2 虚拟地址的结构
在32位x86架构中,虚拟地址采用两级分页结构,分为三部分:
- 页面目录索引(PGD Index):高10位(31-22),索引页面目录中的条目。
- 页面表索引(PT Index):次10位(21-12),索引页面表中的条目。
- 页面内偏移量(Offset):低12位(11-0),指定页面内的字节位置。
以虚拟地址0x12345678
为例:
| 31 22 | 21 12 | 11 0 |
| 0001 0010 00 | 11 0100 0101 | 0110 0111 1000 |
| PGD索引: 72 | PT索引: 837 | 偏移量: 1656 |
- 页面目录:包含1024个条目(2^10),每个条目指向一个页面表。
- 页面表:也包含1024个条目,每个条目映射一个4KB物理页面。
- 总容量:1024 × 1024 × 4KB = 4GB,覆盖整个32位地址空间。
这种两级结构是对存储空间和性能的权衡:
- 一级页表需要4GB / 4KB = 1M个条目,占用4MB内存,太大。
- 两级页表将管理分为两步,页面目录和页面表各4KB,总开销更小。
1.3 两级页表结构详解
页面目录(Page Directory)
- 大小:4KB,包含1024个32位条目(Page Directory Entry, PDE)。
- 结构:
| 31 12 | 11 9 | 8 7 6 5 4 3 2 1 0 |
| 页面表基地址 | 保留 | G PAT D A PCD PWT U W P |
- 页面表基地址:高20位,指向页面表的物理地址(4KB对齐)。
- 属性位:
- P(Present):1表示页面表存在。
- W(Write):1表示可写。
- U(User):1表示用户态可访问。
- PCD/PWT:缓存策略。
- 存储:物理地址由CR3寄存器指定。
页面表(Page Table)
- 大小:4KB,包含1024个32位条目(Page Table Entry, PTE)。
- 结构:
| 31 12 | 11 9 | 8 7 6 5 4 3 2 1 0 |
| 物理页面基地址 | 保留 | G PAT D A PCD PWT U W P |
- 物理页面基地址:高20位,指向4KB物理页面。
- 属性位:与页面目录类似。
地址转换流程
- 从CR3获取页面目录的物理基地址。
- 用PGD索引(高10位)查找页面目录项,得到页面表地址。
- 用PT索引(次10位)查找页面表项,得到物理页面地址。
- 加上偏移量(低12位),计算最终物理地址。
例如,虚拟地址0x12345678
:
- PGD索引 = 72,假设
pgd[72] = 0x50000000 | PRESENT
。 - PT索引 = 837,假设
pte[837] = 0x60000000 | PRESENT
。 - 物理地址 =
0x60000000 + 1656 = 0x60000678
。
硬件支持
- MMU:解析页表,执行转换。
- TLB:高速缓存,存储最近使用的映射。若TLB未命中,则触发页表查询。
- 控制寄存器:
- CR0:PG位启用分页。
- CR3:存储页面目录地址。
第二部分:Linux内核中的页目录和页表构建
2.1 内核启动时的页表初始化
Linux内核从启动到运行经历了从实模式到保护模式的转换,页表初始化是这一过程的关键。
2.1.1 临时页表
- 背景:Linux启动时由引导加载程序(如GRUB)加载到物理地址
0x100000
(1MB),运行在实模式下。 - 目的:启用分页后,内核需要访问虚拟地址(如0xC0000000)。
- 实现:
- 在
arch/x86/boot/head_32.S
中静态定义:boot_pg_dir: // 页面目录
.long 0x00000000 + 0x007 // 身份映射:0x0 -> 0x0
.fill 767, 4, 0 // 填充0
.long 0x50000000 + 0x007 // 内核映射:0xC0000000 -> 0x0
.fill 255, 4, 0
boot_page_table: // 页面表
.long 0x00000000 + 0x007 // 0x0 -> 0x0
.long 0x00001000 + 0x007 // 0x1000 -> 0x1000
...
- 身份映射:物理
0x0 - 1MB
映射到虚拟0x0 - 1MB
,确保分页启用前后的连续性。 - 内核映射:物理
0x0 - 1MB
映射到虚拟0xC0000000 - 0xC0100000
。
- 在
- 启用分页:
- 将
boot_pg_dir
的物理地址写入CR3。 - 设置CR0的PG位:
movl %cr0, %eax
orl $0x80000000, %eax
movl %eax, %cr0
- 将
2.1.2 永久页表初始化
- 入口:进入C代码后,
start_kernel()
调用paging_init()
(arch/x86/kernel/setup.c
)。 - 任务:
- 分配永久页面目录:
- 使用
pgd_alloc()
分配4KB内存。
- 使用
- 映射内核空间:
init_mem_mapping()
设置线性映射。
- 分配永久页面目录:
- 代码示例:
void __init paging_init(void) {
init_mem_mapping(0, max_low_pfn << PAGE_SHIFT);
load_cr3(swapper_pg_dir);
}
- 直接映射区:物理
0x0 - 0xFFFFFFF
映射到0xC0000000 - 0xCFFFFFFF
。
2.2 内核空间的页表构建
- 地址范围:0xC0000000 - 0xFFFFFFFF(1GB)。
- 实现细节:
- 文件:
arch/x86/mm/init_32.c
。 - 函数:
init_mem_mapping()
。
- 文件:
- 步骤:
- 分配页面目录:
swapper_pg_dir
是内核的全局页面目录。
- 填充映射:
- 线性映射:虚拟地址 = 物理地址 +
PAGE_OFFSET
。 - 示例:物理
0x100000
→ 虚拟0xC0100000
。
- 线性映射:虚拟地址 = 物理地址 +
- 设置页面表:
- 分配页面表并填充条目。
- 分配页面目录:
- 代码:
static void __init init_mem_mapping(unsigned long start, unsigned long end) {
unsigned long pfn;
pgd_t *pgd = swapper_pg_dir + pgd_index(start);
pte_t *pte;
for (pfn = start >> PAGE_SHIFT; pfn < end >> PAGE_SHIFT; pfn++) {
if (pgd_none(*pgd)) {
pte = (pte_t *)get_zeroed_page(GFP_KERNEL);
set_pgd(pgd, __pgd(__pa(pte) | _PAGE_TABLE));
}
pte = pte_offset_kernel(pgd, pfn << PAGE_SHIFT);
set_pte(pte, pfn_pte(pfn, PAGE_KERNEL));
pgd++;
}
}
2.3 用户进程的页表构建
进程创建:
fork()
调用copy_mm()
:int copy_mm(unsigned long clone_flags, struct task_struct *tsk) {
struct mm_struct *mm = allocate_mm();
mm->pgd = pgd_alloc(mm);
copy_user_pgd(mm->pgd, current->mm->pgd);
return 0;
}
- 用户空间采用写时复制(Copy-on-Write),内核空间共享。
按需分页:
- 页面错误处理:
do_page_fault()
(arch/x86/mm/fault.c
)。 - 流程:
- 检查虚拟地址。
- 分配物理页面。
- 更新页表。
- 代码:
void do_page_fault(struct pt_regs *regs, unsigned long error_code) {
unsigned long address = read_cr2();
struct vm_area_struct *vma = find_vma(current->mm, address);
if (!vma) {
handle_mm_fault(vma, address, FAULT_FLAG_WRITE);
}
}
- 页面错误处理:
第三部分:页目录和页表的赋值过程
3.1 赋值的基本原理
- 页面目录赋值:将页面表的物理地址写入PGD项。
- 页面表赋值:将物理页面地址写入PTE项。
- 工具:
set_pgd(pgd_t *pgdp, pgd_t pgdval)
。set_pte(pte_t *ptep, pte_t pteval)
。
3.2 内核初始化时的赋值
- 场景:映射物理
0x0 - 0x100000
到虚拟0xC0000000 - 0xC0100000
。 - 步骤:
- 设置页面目录:
pgd_t *pgd = swapper_pg_dir;
pgd[768] = (pgd_t)(0x50000000 | PGDIR_PRESENT | PGDIR_RW);
- 设置页面表:
pte_t *pte = (pte_t *)__va(0x50000000);
for (int i = 0; i < 256; i++) {
pte[i] = (pte_t)((i << 12) | PTE_PRESENT | PTE_RW);
}
- 设置页面目录:
3.3 页面错误时的赋值
- 场景:虚拟地址
0x08048000
映射到物理0xC0000000
。 - 步骤:
- 检查页面目录:
pgd_t *pgd = current->mm->pgd;
if (pgd_none(pgd[2])) {
pte_t *pt = (pte_t *)get_zeroed_page(GFP_KERNEL);
set_pgd(&pgd[2], (pgd_t)(__pa(pt) | PGDIR_PRESENT | PGDIR_USER));
}
- 设置页面表:
pte_t *pte = pte_offset_map(&pgd[2], 0x08048000);
set_pte(&pte[33], (pte_t)(0xC0000000 | PTE_PRESENT | PTE_USER));
pte_unmap(pte);
- 检查页面目录:
第四部分:虚拟地址与物理地址的映射关系
4.1 内核空间的1GB布局
- 虚拟地址:0xC0000000 - 0xFFFFFFFF。
- 映射目标:通常是物理低端内存。
4.2 物理地址的加载位置
- 默认位置:物理
0x100000
(1MB)。 - 原因:BIOS占用低端内存,内核从1MB开始加载。
4.3 高内存的处理
- 定义:物理地址超出1GB的部分。
- 实现:通过
kmap
或临时页表访问。
第五部分:物理高地址分配时的页表赋值
5.1 问题提出
假设进程分配到物理地址0xC0000000
,映射到虚拟0x08048000
,如何赋值?
5.2 赋值流程
- 索引计算:
- PGD索引:
0x08048000 >> 22 = 2
。 - PT索引:
(0x08048000 >> 12) & 0x3FF = 33
。
- PGD索引:
- 赋值:
pgd_t *pgd = current->mm->pgd;
pte_t *pte = pte_offset_map(&pgd[2], 0x08048000);
set_pte(&pte[33], (pte_t)(0xC0000000 | PTE_PRESENT | PTE_USER));
5.3 隔离机制
- 页表隔离:用户空间和内核空间由不同的PGD条目管理。
- 内核保护:
pgd[768-1023]
共享全局映射。
第六部分:总结与扩展
Linux的分页机制通过两级页表和按需分页,实现了高效的内存管理。物理高地址的分配不会干扰内核空间,体现了设计的优雅性。读者可进一步探索源码或64位系统的四级页表。