9.6 内存管理与虚拟化
内存管理是操作系统内核最为基础且复杂的子系统之一。它既要为内核核心结构、设备驱动及用户空间应用按需分配物理存储资源,更承担着构建虚拟地址空间隔离、防范非法跨界访问、保障内核特权空间绝对安全的防御重任。
在 x86 体系结构下,处理器硬件层面先后提供了两类内存管理机制:
- 分段机制(Segmentation):源于早期 x86 处理器的特权保护与地址扩展模型;
- 分页机制(Paging):现代操作系统实现虚拟内存管理、按需调页与多任务地址隔离的标准基石。
值得注意的是,在 x86_64 长模式下,分段寻址机制已被硬件大幅弱化(绝大多数段基址被强制归零,段界限检查被旁路),而多级分页机制成为长模式下强制启用的核心机制。
1. 分段机制与平面内存模型
分段机制将处理器的逻辑地址(由段选择子与段内偏移构成)转换为线性地址。其原始设计初衷是允许软件按逻辑功能划分代码段、数据段、堆栈段等独立区域,并赋予各自不同的界限与访问权限。
但在实际操作系统演进中,纯分段管理存在显著缺陷:由于段的长度不固定,连续动态分配与回收极易在物理内存中产生大量难以利用的外部碎片;同时,段的搬移和紧缩开销巨大。
因此,现代操作系统普遍采纳平面内存模型(Flat Memory Model):在初始化全局描述符表(GDT)时,将所有常规代码段和数据段的物理基地址均配置为 0x00000000,段界限设为最大寻址上限(4 GiB)。在这种配置下,段内偏移量在数值上与线性地址完全等同,分段机制退化为仅负责维护特权级(DPL/CPL)的薄层壳体,真正的地址转换与保护隔离职责全部让渡给分页单元。
1.1 全局描述符表(GDT)
在保护模式下,段寄存器中存放的不再是物理基地址,而是指向全局描述符表(Global Descriptor Table,简称 GDT)或局部描述符表(LDT)的段选择子(Segment Selector)。
一个段选择子占 16 位:
- 第 15~3 位(Index):对应描述符在 GDT 表中的表项索引;
- 第 2 位(TI):表指示位,0 代表查询全局描述符表 GDT,1 代表查询局部描述符表 LDT;
- 第 1~0 位(RPL):请求特权级(Requested Privilege Level),取值为 0(内核最高特权)至 3(用户态)。
1.1.1 GDT 段描述符结构
GDT 中的每个段描述符固定占用 8 个字节(64 位),其字段布局为了兼容早期的 80286 处理器呈现出高度交错的历史形态:
- 段基地址(Base Address,32 位):被分割存放在描述符的 Byte 2~4(低 24 位)以及 Byte 7(高 8 位)中;
- 段界限(Segment Limit,20 位):被分割存放在描述符的 Byte 0~1(低 16 位)以及 Byte 6 的低 4 位(高 4 位)中;
- 段属性与标志(Attributes & Flags,12 位):
- Type 字段:指示该段为代码段还是数据段,以及是否可读、可写、可执行;
- S 位(Descriptor Type):0 表示系统段(如 TSS、门描述符),1 表示代码段或数据段;
- DPL(Descriptor Privilege Level,2 位):描述符特权级,界定了访问该段所需的最低特权级别;
- P 位(Present):指示该段是否已加载至物理内存;
- G 位(Granularity,粒度标志):为 0 时段界限单位为 1 字节(最大 1 MiB),为 1 时段界限单位为 4 KiB(最大 4 GiB)。
1.1.2 段寄存器重载与特权流水线刷新
使用 lgdt 指令将包含 GDT 基址与限长的伪描述符加载到 GDTR 寄存器后,必须显式重载各段寄存器,使硬件影子寄存器(Shadow Registers)同步缓存最新的描述符属性:
- 数据段寄存器
ds、es、fs、gs与栈段寄存器ss,可以直接通过mov指令写入对应的数据段选择子(如0x10); - 代码段寄存器
cs的值无法通过常规mov指令修改。为了将新的代码段选择子(如0x08)装入cs,必须执行一条远跳转指令(ljmp/jmp far)或远返回指令(retf),以此强制清空并刷新 CPU 的预取指令流水线。
2. 分页式虚拟内存管理
硬件分页机制将虚拟地址空间与物理地址空间解构为尺寸固定、互不干扰的离散单元。在典型的 32 位 x86 系统中,标准页面的大小为 4 KiB(即
在此模型下,程序执行期间发出的 32 位虚拟地址(线性地址)由内存管理单元(MMU)进行透明翻译,地址被划分为三个逻辑部分:
- 高 10 位(位 31~22):页目录索引(Page Directory Index);
- 中 10 位(位 21~12):页表项索引(Page Table Index);
- 低 12 位(位 11~0):页内偏移(Page Offset)。
翻译时,CPU 的控制寄存器 CR3 存放当前活动页目录的物理基地址。MMU 首先依据高 10 位索引在页目录中定位一个页目录项(PDE),从中读取下一级页表的物理基地址;随后以中 10 位为索引在该页表中定位具体的页表项(PTE),提取目标物理页框的基地址;最终将该物理基地址与虚拟地址的低 12 位页内偏移直接拼合,形成最终访问物理 RAM 的物理地址。
虚拟地址的结构布局如下图所示:

2.1 页表项属性与恒等映射
无论是页目录项(PDE)还是页表项(PTE),其本身均占 4 字节。由于物理页框与页表本身都必须严格按照 4 KiB 边界对齐,其物理基地址的低 12 位恒为 0。硬件巧妙地复用了这些低位,用来存储精细的内存访问权限与状态控制标志:
- P 位(Present,位 0):指示目标页面是否在物理内存中。若访问 P=0 的页面,CPU 会立即触发 14 号缺页异常(Page Fault);
- R/W 位(Read/Write,位 1):0 表示只读,1 表示可读可写;
- U/S 位(User/Supervisor,位 2):0 表示仅特权模式(Ring 0~2)可访问,1 表示用户态代码(Ring 3)亦可访问;
- A 位(Accessed,位 5)与 D 位(Dirty,位 6):由 MMU 硬件在发生读写时自动置位,为内核的换页算法提供统计依据。
恒等映射(Identity Mapping)的工程意义
恒等映射是指将一段虚拟地址空间与其对应的物理内存地址设置为一比一相等(即虚拟地址直接映射到物理地址 )。在操作系统刚启用 CR0 寄存器的分页使能位(CR0.PG)的一瞬间,CPU 指令指针(EIP)中装载的仍是物理寻址阶段的地址。如果内核代码所在的区域未进行恒等映射,硬件在执行下一条指令走查页表时便会立即引发不可恢复的缺页崩溃。此外,在外设的 MMIO 地址映射中,恒等映射或固定偏置的高半核映射也是驱动与外设控制器正常通信的基石。
2.2 物理页框分配器(PMM)
在分页系统之上,内核必须首先建立物理内存管理器(Physical Memory Manager,简称 PMM)。物理内存管理器的职责在于跟踪整机物理 RAM 的哪些页框(4 KiB 块)已被占用、哪些处于空闲可用状态。
常见的物理页框管理模型包括:
- 位图分配器(Bitmap Allocator):以 1 个比特位代表 1 个 4 KiB 物理页框,置 1 表示占用,清 0 表示空闲。这种方案实现简洁、内存占用极其紧凑(管理 4 GiB 内存仅需 128 KiB 的位图空间),非常适合作为自制系统的初始页分配器;
- 空闲链表与伙伴系统(Buddy System):通过在空闲页框内部嵌入链表节点或维护多级块幂次,实现更大尺寸连续页框的高效分配。
页框并非只能以 4 KiB 为单位。也可以把页大小设为 4 MB 甚至 1 GB,但那样单页太大,一般只用于 64 位系统管理更大的内存;本书的首要任务还是让这套分页式内存管理先跑通。
3. 动态内核堆内存管理
物理页框分配器与分页机制以 4 KiB 为粒度划分内存,然而内核内部的数据结构(如进程描述符、文件句柄、缓存缓冲区)往往只需几十到几百字节。若直接为每一个小对象分配 4 KiB 页框,将造成灾难性的内部碎片。
因此,内核必须在页分配器之上构筑动态堆内存管理子系统(Kernel Heap):
kmalloc(size_t size):在内核堆地址空间中寻觅并切分出满足指定尺寸的连续内存块,维护块头信息以记录分配尺寸与空闲状态;kfree(void *ptr):根据内存块头部元数据将该区间标记为空闲,并适时执行相邻空闲块合并(Coalescing),防止小碎片积聚导致大内存申请失败。
4. 习题
GDT 的段描述符是 8 个字节,段选择子 16 位:第 15~3 位是描述符下标,第 2 位是 TI(0 表示 GDT,1 表示 LDT),第 1~0 位是请求特权级 RPL。
(1) 选择子 0x08、0x10、0x0F、0x1B 各自指向哪个表、第几个描述符、RPL 是多少?
(2) 给出下面三个描述符的段基址、段界限(原始 20 位)、访问字节与标志位,判断是代码段还是数据段、DPL、粒度是字节还是 4 KiB,并算出这段线性地址的末端:
FF FF 00 00 00 9A CF 00
FF 7F 00 80 0B 92 00 00
FF FF 00 00 00 FA CF 002
3
(3) 第一个与第二个描述符的段基址分别是多少?第二个描述符按 0xB8000 起的显存来描述时,段界限(32 KiB)够不够覆盖 80×25 的全屏?如果解码第一个描述符时漏掉 G 位、把 0xFFFFF 当成字节数,会算出多长的段?
(4) 写 uint32_t gdt_base(const uint8_t d[8]) 与 uint32_t gdt_limit(const uint8_t d[8]),把上面三个描述符解出来打印,再打印每个描述符所描述地址范围的上界。用自己手算的结果对照,三个都一致才算通过。
正文 1.1.2 说加载 GDT 后 ds、es、fs、gs、ss 都要写 0x10,cs 不能直接 mov。
(1) 写一个 struct gdt_ptr { uint16_t limit; uint32_t base; } __attribute__((packed));,填入一个含 3 个描述符的 GDT(空、内核代码、内核数据,基址 0、界限 4 GiB、DPL 0),写出 lgdt 的内联汇编与三条段寄存器加载语句。
(2) 为什么 mov cs, ax 汇编不过?写出用远跳转重载 cs 的两种写法(ljmp 与 retf),并说明跳转目标的选择子与偏移各是什么。
(3) 用主机程序把这段 GDT 的 24 个字节打印成十六进制,与手工编码逐个核对。验收:空描述符全为 0;代码段那 8 字节为 FF FF 00 00 00 9A CF 00;数据段为 FF FF 00 00 00 92 CF 00。
(4) 如果 GDT 的基址没有按 8 字节对齐,正文说会影响访问效率。查 Intel 手册确认这是建议还是要求,写一句话结论。再把 (1) 的 GDT 改成把基址放在奇数地址上,在模拟器(QEMU)里观察是否仍然工作。
按正文 2 节的两级页表做地址翻译,32 位、4 KiB 页。
(1) 把 0x00401234、0x00000FFF、0x7FFFFF、0xC0000000 拆成页目录下标、页表下标、页内偏移,列表写出。
(2) 给定页目录项 1 指向页表 0x00101000,该页表第 1 项为 0x00102003,页目录项 0 为 0。翻译 0x00401234 与 0x00000FFF,写出物理地址或说明为什么触发页错误。0x00102003 的低 3 位各是什么含义?
(3) 写一个主机程序 paging_walk.c:用 1024 个 uint32_t 的数组模拟页目录与两级页表,map(pd, va, pa, flags) 负责建表并填项,walk(pd, va, uint32_t *pa_out) 返回 0/1 并置 *pa_out。测试:映射 0x00400000→0x00200000 与 0xC0000000→0x00300000,然后对 (1) 的四个地址逐个 walk 并打印。验收:0xC0000000 与 0x00401234 成功,0x00000FFF 与 0x7FFFFF 失败(未映射)。
(4) 说明为什么页目录自身也必须在恒等映射的区域里,以及正文那句「页表所在区域的线性地址与物理地址必须一一对应」在 walk 程序里对应哪一步。如果把页目录所在页映射到别处,会发生什么?
code/项目/mdrOS/kheap.c 的首次适应分配器能跑,但下面五处都要处理。先读代码,再动手。
(1) memory_usage() 里 uint32_t size; 没有初值就做 size += curr->s.size。用下面程序在本机复现:
#include <stdint.h>
#include <stdio.h>
uint32_t f(uint32_t x) {
uint32_t size;
size += x;
return size;
}
int main(void) {
printf("%u\n", f(10));
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
用 gcc -std=c17 -Wall -Wextra 编译,贴出编译器警告;再分别用 -O0 与 -O2 运行并贴出结果。在 MinGW gcc 13.2 上实测两者分别是 11 与 10,换一台机器数字可能不同。解释两次为什么不同,以及为什么这正是未定义行为,而不是「碰巧对」。给出正确写法,并说明 -fsanitize=undefined 为什么查不出这一类问题(它检查的是另一类问题;本机 MinGW 还缺 libubsan,加了也链接不过)。
(2) get_free_block() 找到的空闲块被 alloc() 直接整块返回,不做切分。写出「分配 4096 字节 → 释放 → 分配 12 字节」这一串操作后块链表的样子(块的 size、is_free、相对堆起点的偏移),指出浪费了多少字节,以及为什么下一次 alloc(3000) 仍然会从 ksbrk 拿新空间。把 alloc() 改成能切分:命中块比请求大出 sizeof(header_t) + 8 以上时,在原块尾部切出余量并挂进链表;余量不够时整块给出。改完重跑同一串操作,打印链表与浪费量。
(3) kfree() 把块还给分配器时,只把该块标记为空闲,不合并相邻空闲块。构造一个产生 4 个相邻空闲块的操作序列,写出链表与 ksbrk 的位置。再说明为什么不合并会让 ksbrk 一路长到 program_break_end:明明空闲总量够用,alloc() 却在后面对较大的请求开始返回 NULL。
(4) ksbrk() 的判断写作 program_break + incr >= program_break_end,incr 为负时这个判断不成立,但 program_break + incr 可能落到堆区之前。写出修正判断,并说明 kfree() 传负值收缩堆时为什么要先确认这个块确实在堆顶。
(5) 在主机上写测试:用一个 64 KiB 的静态数组代替堆,把 ksbrk 换成一个操作数组下标的版本。跑 1000 轮「随机大小分配 / 随机释放」,每轮后遍历链表检查:所有块首尾相接、没有重叠、空闲块不超过块数、已用总量加空闲总量等于 program_break - 堆起点。验收:输出每轮检查通过,并打印修复前后 memory_usage() 在已全部释放时的返回值(修复后应为 0)。