《操作系统真象还原》前面五章折腾完,你的内核已经能在屏幕上打印字符、能跳进保护模式、能开分页了。但你心里清楚,这只是个“花架子”——它没有任何自主管理资源的能力。第六章“完善内核”就是这道分水岭:这一章做完,内核才算真正“站起来”。
这章内容看起来很杂,字符串、位图、内存池、页表调整、分配释放,但把它们串起来的逻辑只有一条:让内核具备“管理物理内存”的能力。字符串函数是工具,位图是记账本,内存池是仓库,页表是地址翻译器。四者配合,内核才能说出“这块内存归我管、那块内存给你用”这种话。
我建议所有读这本书的人,第六章要放慢速度啃。前面几章偏重汇编和硬件交互,这一章开始大量写C语言代码,而且这些代码是一个极简内存管理子系统的骨架,后面所有章节都会反复用到。这里我把当年在这一章踩过的一些坑和调试心得整理出来,给后面的人做个参考。
1. 第六章在整本书里的位置:内核正式从“能跑”走向“能用”
1.1 前五章的积累与第六章的跳跃
前三章基本在跟硬件打交道。MBR启动引导、实模式下调用BIOS中断、把loader加载到内存,然后进入保护模式,配置GDT、打开A20地址线、设置CR0的PE位。第五章更进一步,把页表的demo建起来,开启分页机制,把内核装载到内存的高地址区域。
但到了这一步,内核的运行环境还是“裸奔”的。它虽然有页表和GDT,可一旦涉及稍微真实一点的任务,比如动态创建一块内存、记录哪些页被占用、多任务切换时给每个任务划分独立地址空间,现有代码完全不够用。原因很简单:没有内存管理,没有数据结构基础设施,甚至没有一个可用的字符串函数库。第六章就是把这些基础设施逐一补齐,让内核从一个“演示程序”变成一个“系统雏形”。
1.2 为什么“完善”这章比你想的更关键
很多人读这本书有个错觉,以为第六章只是把一些零碎工具凑一凑。实际不是,这一章是全书思维模式转变的分水岭。前五章的代码是“顺着CPU的行为走”,每条指令做什么、每个寄存器怎么设置,思路是线性的;从第六章开始,你写的是“子系统”——内存管理子系统,里面有数据结构和算法,有“分配”和“回收”两个相对立的操作路径。这个转变对初学者来说是一次微型的“从码农到系统开发者”的跨越。
我可以用一个装修的类比:前五章是在盖毛坯房、立承重墙,第六章是给房间布水电管线。如果你水电管线没铺好,后面刷漆、装柜子都会出问题。对应到操作系统,第六章的内存管理如果没写稳,后面第七章的中断、第八章的多任务、第九章的用户进程,全都会在“内存”这个地基上摔跟头。所以这一章值得你反复读、反复调,甚至读完之后再回头重新审视一遍第五章的页表初始化。
2. 字符串操作函数:先把内核的“工具箱”备齐
2.1 为什么内核不能直接调用C库函数
按常理说,memset、memcpy、strcpy这种东西写起来太基础了,但凡学过几天C的人都能随手写。但内核里为什么要自己造一套轮子?一个最直接的原因是:内核态代码不能依赖任何用户态库,也没有运行时环境帮你在程序启动前把库函数准备好。进入内核main函数的时候,连栈都是刚切好的,更别说什么libc了。
还有一个更微妙的考量:内核自定义这些函数,行为是“可控的”。比如我自己的实现里,memset参数顺序是(目的地址, 值, 大小),memcpy是(目的地址, 源地址, 大小),这套命名风格是从Linux内核里学来的。虽然标准C库的memcpy参数顺序也是(目的, 源, 大小),但memset的标准签名是(void*, int, size_t),而内核里为了统一,通常也是(void*, uint8_t, uint32_t)。自己实现一遍,就能把参数语义牢牢锁死在代码里,不受外部ABI变化影响。
2.2 五个核心函数逐个拆解
先看memset和memcpy,这两个是内存级操作的基础:
void memset(void* dst_, uint8_t value, uint32_t size) { uint8_t* dst = (uint8_t*)dst_; while (size-- > 0) *dst++ = value; } void memcpy(void* dst_, const void* src_, uint32_t size) { uint8_t* dst = (uint8_t*)dst_; const uint8_t* src = (const uint8_t*)src_; while (size-- > 0) *dst++ = *src++; }这两个实现朴素到了极点,但有几个细节值得注意。第一,为什么要转成uint8_t才能做自增运算?因为void在C标准里不保证能做指针算术,虽然在GCC里能编译,但转成具体类型指针是规范做法。第二,memset的value参数是uint8_t不是int,避免调用方误传一个大于255的值导致截断逻辑混乱。第三,这两个函数都假设目标地址和源地址是合法可访问的,没有做任何边界检查——这在内核里是合理的,因为调用方必须保证地址有效,检查开销在这种基础函数里是不可接受的。
接下来是三个字符串函数:
char* strcpy(char* dst_, const char* src_) { char* r = dst_; while ((*dst_++ = *src_++)) ; return r; } uint32_t strlen(const char* str) { const char* p = str; while (*p++) ; return (p - str - 1); } int8_t strcmp(const char* a, const char* b) { while (*a && *a == *b) { a++; b++; } return *a - *b; }strcpy这个写法很经典,*dst_++ = *src_++作为循环条件,既完成复制又完成对src当前字符是否为0的判断。注意它返回的是目的地址dst_的原始值,这是为了支持链式调用,比如strcpy(a, strcpy(b, "hello"))。实际调试中发现,有个需要特别留意的点:如果源字符串没有以'\0'结尾,这个循环会一路读下去,直到访问到非法地址触发缺页异常。所以内核里使用strcpy前,你要自己保证源字符串的合法性,最好在更高层封装里做长度约束。
strlen的实现通过p最终指向的位置减去起始地址再减1,就得到了不含结束符的字符串长度。这个写法比用一个计数器变量累加要聪明,也更容易被编译器优化成更高效的指令序列。strcmp的返回值注意别以为是-1或1,它实际上是两个不相等字符的ASCII码差值。比如'a'和'b'比较返回-1,但'g'和'a'比较返回6。所以调用方判断相等时用== 0,判断大小关系时直接和0比大小就行。
我把这几个函数的设计要点整理成了表,方便对照:
| 函数 | 核心逻辑 | 常见误用点 | 边界风险 |
|---|---|---|---|
| memset(dst, value, size) | 逐字节写入指定值 | 参数顺序写成(value, dst, size) | size过大导致越过缓冲区 |
| memcpy(dst, src, size) | 逐字节复制 | 源和目的重叠时行为未定义 | 目的缓冲区小于size会踩内存 |
| strcpy(dst, src) | 复制到'\0'为止 | 忘记src必须以'\0'结尾 | 源字符串无结束符会越界读 |
| strlen(str) | 扫描到'\0'计数 | 对NULL直接调用直接崩 | 非字符串数据可能导致死循环 |
| strcmp(a, b) | 按字符比较ASCII差值 | 返回值被当成相等/不等的布尔值 | 两个字符串都很长时逐字符比较较慢 |
3. 位图:用一比特管理一页内存
3.1 位图的数据结构和映射数学关系
内存管理要回答的第一个问题很简单:哪块物理页空闲?哪块被占用?最直观的想法是给每个物理页建一个结构体,记录状态、引用计数、所有者。但在一个极简内核里这太奢侈了——一个结构体至少好几字节,4MB内存按4KB一页就是1024个页,光状态记录就要好几KB,而且管理这些结构体本身也需要内存,这就形成了“先有鸡还是先有蛋”的问题。
位图的优势是空间开销极小:一个物理页对应一个bit,1024个页只需要128字节。而且映射关系非常直接:位图的第N个bit对应物理地址空间里的第N页。虽然它只能表达“占用/空闲”这两种状态,对初期内核来说完全够用。第六章的定义是这样:
struct bitmap { uint32_t bytes; // 位图缓冲区一共占用的字节数 uint8_t* bits; // 位图缓冲区起始地址 };注意这里又把一个“鸡生蛋”问题暴露出来了:位图本身存在哪里?答案很朴素:在编译阶段就预留一块静态内存作为位图缓冲区,等内存管理器初始化时直接绑定上去。初期内核没有动态内存可用,所以一切都要提前规划。比如定义物理页数量的宏、计算需要的字节数,再用一个全局数组做位图缓冲区,这些都是在startup阶段已经安排好的。
3.2 四个位图操作函数的实现与细节
位图的标准操作四个:初始化、测试某一位、扫描连续空闲位、设置某一位。
void bitmap_init(struct bitmap* bmp) { memset(bmp->bits, 0, bmp->bytes); } bool bitmap_scan_test(struct bitmap* bmp, uint32_t bit_idx) { uint32_t byte_idx = bit_idx / 8; uint32_t bit_odd = bit_idx % 8; return (bmp->bits[byte_idx] & (BITMAP_MASK << bit_odd)); } void bitmap_set(struct bitmap* bmp, uint32_t bit_idx, int8_t value) { uint32_t byte_idx = bit_idx / 8; uint32_t bit_odd = bit_idx % 8; if (value) { bmp->bits[byte_idx] |= (BITMAP_MASK << bit_odd); } else { bmp->bits[byte_idx] &= ~(BITMAP_MASK << bit_odd); } }bitmap_init一眼就能看懂,就是整块清零。bitmap_scan_test和bitmap_set的形式很像,都是先除8得字节下标,再模8得字节内的位偏移。BITMAP_MASK通常是0x01,左移bit_odd位后就定位到了目标位。测试函数返回的是那个bit是否为1,设置函数则按value为1或0来置位或清位。
这里的一个关键隐蔽点是:位图的字节数bytes必须提前精确计算。如果bytes算小了,后面访问bit_idx时会发生数组越界,而且是在位图缓冲区之外的内存上写数据,这种Bug很难复现,一般会表现为“某个不相关的变量突然变了”。我当时在这个问题上栽过跟头,后面细说。
还有一个容易被忽略的边界:如果位图末尾有冗余bit(比如总位数不是8的整数倍),这些bit在初始化时被清零,但在分配时也可能被扫描到。要处理就很麻烦,所以我一般会要求物理页数量刚好是8的倍数,或者在扫描函数里限定最多扫描位数。
3.3 bitmap_scan:连续空闲页分配的核心算法
这个函数是位图里最复杂的一个,也是内存分配器能否工作的关键。它的任务是找到连续cnt个值为0的bit,返回起始bit位下标,找不到就返回-1。
int bitmap_scan(struct bitmap* bmp, uint32_t cnt) { uint32_t idx_byte = 0; while ((idx_byte < bmp->bytes) && (0xff == bmp->bits[idx_byte])) { idx_byte++; } if (idx_byte == bmp->bytes) return -1; int idx_bit = 0; while ((uint8_t)(BITMAP_MASK << idx_bit) & bmp->bits[idx_byte]) { idx_bit++; } int bit_idx_start = idx_byte * 8 + idx_bit; if (cnt == 1) return bit_idx_start; uint32_t bit_left = bmp->bytes * 8 - bit_idx_start; uint32_t next_bit = bit_idx_start + 1; uint32_t count = 1; bit_idx_start = -1; while (bit_left > 0) { if (!bitmap_scan_test(bmp, next_bit)) { count++; if (count == cnt) { bit_idx_start = next_bit - cnt + 1; break; } } else { count = 0; } next_bit++; bit_left--; } return bit_idx_start; }这个算法的思路分两步。第一步,从第0字节开始逐字节检查,找到第一个不是0xff的字节。一个字节是0xff意味着这8个bit全被占用,全是1,不可能在这里开启一段连续空闲位。找到非0xff字节后,再在字节内逐位扫描,找到第一个为0的bit位作为起始候选。
第二步,从候选位置的下一位开始,逐个往高位检查。遇到空闲位就累加计数器,一旦计数器等于cnt,就返回“当前位- cnt + 1”作为起始下标;遇到被占用的bit就重置计数器为0,继续往后扫。这个逻辑其实就是在模拟手工查找一段连续空隙的过程。
实现时有个容易出错的细节:bit_left = bmp->bytes * 8 - bit_idx_start计算的是从候选位到总位数之间还剩多少bit。循环条件用bit_left > 0能防止越界扫描到缓冲区之外的虚假内存。我当时第一次写的时候没用bit_left,结果扫描时越过了末尾的合法bit,读到了位图后面的数据,导致随机性的分配失败。
4. 内存池与地址池:内核的“房地产公司”开张
4.1 物理内存为什么要分成内核池和用户池
位图解决了“如何记账”的问题,但要真正管理内存,还得先把内存划分出“地盘”。第六章引入的结构是两个物理内存池:内核物理内存池和用户物理内存池。每个池都是一段连续的物理地址范围,各自配一个位图记录池内页的分配情况。
为什么要分池而不是用一个总池子?因为要防止用户态程序把内核内存耗光。内核内存是系统运转的地基,必须预先隔离出一块来只能由内核自己分配。分池之后,即使某个用户程序疯狂申请内存,它也只能在用户池里折腾,动不了内核池。这个隔离思想在后面实现用户进程时尤其重要——用户进程可没有任何“自觉性”,它不会关心系统还留了多少内存给内核。
在我的实现中,内存池的定义大概是这样的:
struct pool { struct bitmap pool_bitmap; // 这个池使用的位图 uint32_t phy_addr_start; // 池起始物理地址 uint32_t pool_size; // 池大小(字节) }; struct mem_pool { struct pool kernel_pool; // 内核物理内存池 struct pool user_pool; // 用户物理内存池 };初始化时,要决定哪段物理内存归内核池、哪段归用户池。第六章的做法通常是把可用物理内存的高端部分给内核,低端给用户,中间做好地址边界。这个划分不是随便定的:内核代码本身已经加载到内存的某个地址,内存池的起始地址必须避开这些已经被占用的区域。
4.2 虚拟地址池:和物理池配套的另一套账本
物理池解决了物理页管理,但分页机制下,程序使用的是虚拟地址,所以还得管“哪些虚拟地址还没被占用”。第六章为此设计了一个虚拟地址池:
struct virtual_pool { struct bitmap vaddr_bitmap; // 虚拟地址位图 uint32_t vaddr_start; // 虚拟地址池起始地址 };这里最关键的一点是:虚拟地址池和物理地址池是两个维度的账本。同一个物理页可以被多个虚拟地址映射,但虚拟地址池管的是“这段虚拟地址空间里哪些范围还没被映射出去”,物理池管的是“这些物理页还没被分配出去”。所以在分配内存的流程中,必须两边同时协调:先从虚拟地址池里取一段虚拟地址,再从物理池里取物理页,最后把二者的映射写进页表。
页表操作是这一章的重点,因为分页机制需要你懂得如何把虚拟地址拆成三部分:高10位是页目录索引(PDE索引)、中间10位是页表索引(PTE索引)、低12位是页内偏移。用表格表示就是:
| 虚拟地址位 | 长度 | 用途 | 对应表项 |
|---|---|---|---|
| bit31 - bit22 | 10位 | 页目录索引 | PDE |
| bit21 - bit12 | 10位 | 页表索引 | PTE |
| bit11 - bit0 | 12位 | 页内偏移 | 物理页内偏移量 |
书中采用了一种页表自映射的布局,把页目录表也映射到虚拟地址空间的高端,这样就能直接通过虚拟地址读写PDE和PTE。这种布局下,定位PDE和PTE的公式大致是:
#define PDE_ADDR(vaddr) (0xfffff000 + ((vaddr & 0xffc00000) >> 20)) #define PTE_ADDR(vaddr) (0xffc00000 + ((vaddr & 0xffc00000) >> 20) + ((vaddr & 0x003ff000) >> 10))第一次看到这种地址计算的人可能会懵:0xfffff000和0xffc00000都是负数(因为超过0x7fffffff的有符号整数最高位为1)。所以在内核代码里,这些地址要用uint32_t类型来存,不能定义成int。我当时在这里踩过坑,用int变量去算PDE地址,结果算出来成了负数,写进页表乱七八糟。
4.3 页表写入:最容易写崩的地方
页表操作的完整流程是:先检查PDE是否存在,不存在就先分配一个页表页,初始化后挂到PDE上;然后再填充PTE,最后还要刷新TLB,让CPU的地址翻译缓存失效,否则旧的映射还会被Cache命中。
void page_table_add(void* _vaddr, uint32_t _paddr) { uint32_t vaddr = (uint32_t)_vaddr; uint32_t paddr = (uint32_t)_paddr; uint32_t* pde = (uint32_t*)PDE_ADDR(vaddr); if (!(*pde & 0x00000001)) { uint32_t page_table_phy = (uint32_t)paddr_alloc(&kernel_pool); *pde = page_table_phy | 0x7; // P | RW | US memset((void*)PTE_ADDR(vaddr), 0, 1024); } uint32_t* pte = (uint32_t*)PTE_ADDR(vaddr); *pte = paddr | 0x7; }这段代码有两个关键顺序不能颠倒。第一,写PDE之后才能用PTE_ADDR去访问新的页表页。因为PTE_ADDR的计算依赖PDE的映射,如果PDE还是空的,访问PTE_ADDR会触发缺页异常。第二,memset要在写PDE之后执行,因为要清空新页表的所有表项,得先让这个页表可访问。
属性位0x7对应着P位(存在位)、RW位(读写)、US位(用户/超级用户),前两者是必须的。如果这块内存是要给用户进程用的,US位要置1,否则用户态一访问就触发保护异常。这个细节在后面的用户进程章节会反复用到。
5. 内存分配与释放的完整实现
5.1 分配一条龙:get_kernel_pages的调用链
第六章把内存分配的复杂流程封装成了几个层次,最终的对外接口是get_kernel_pages和get_user_pages。我当时花了很长时间才把整条调用链理清楚,这里直接给你画出来:
- get_kernel_pages(cnt) 是入口,负责调用 malloc_page(PF_KERNEL, cnt) 并初始化页。
- malloc_page 是总调度,依次执行三个子步骤:vaddr_get 在虚拟地址池里取连续cnt个bit对应的虚拟地址;paddr_alloc 在物理池里取cnt个物理页;page_table_add 把两者映射写入页表。
- 最后把分配到的虚拟地址返回给调用者。
核心代码如下:
void* malloc_page(enum pool_flags pf, uint32_t pg_cnt) { void* vaddr = vaddr_get(pf, pg_cnt); if (vaddr == NULL) return NULL; uint32_t paddr = paddr_alloc(pf, pg_cnt); if (paddr == -1) return NULL; page_table_add((void*)vaddr, paddr); return vaddr; }这里有个重要原则:先取虚拟地址,再取物理地址。为什么?因为如果先分配物理页,却找不到连续的虚拟地址映射,物理页就“悬空”了,还得回滚释放,增加了复杂度。先取虚拟地址占好位置,再去找物理页,就算找不到也只需要释放虚拟地址的位图位置,逻辑简单很多。
vaddr_get的实现,本质上就是调用bitmap_scan扫描虚拟地址池,找到连续的vaddr_cnt个空闲bit,然后调用bitmap_set把这些位置1,最后返回起始虚拟地址。paddr_alloc类似,扫描物理池的位图,分配物理页并返回物理地址。
这里有一个性能上的小问题:每次分配一页都去扫描位图,最坏情况是O(n)。但初期内核分配频率不高,完全够用。如果你玩到后面觉得慢,再换成buddy分配器之类的算法也不迟,前提是先把位图这套基础逻辑吃透。
5.2 释放流程:反推回滚的边界问题
内存释放是分配过程的反向操作,流程是:拿到虚拟地址后,先根据地址算出PDE/PTE索引,找到对应的物理地址;然后清掉PTE里的映射,释放物理页(把位图对应位清零);最后释放虚拟地址位图上的占用位。
看起来简单,实际有两个坑。
第一个坑:物理页释放之后,页表项里面的残留映射必须清零。如果只把物理位图清0,页表项里还留着旧映射,等这个物理页被重新分配给别的虚拟地址时,新旧两个虚拟地址的页表项可能都指向同一个物理页,写入数据就会互相污染。这就是经典的“别名”问题,表现出来是莫名其妙的数据错乱。
第二个坑:多级页表回收。如果释放的是一整张页表里的所有页表项,这张页表本身(作为物理页)要不要也释放掉?第六章里的极简方案是:不回收页表页。这样做的理由很实在,页表页只有4KB,占用不大,释放后如果以后又要用这个虚拟地址范围,还得重新创建页表,额外开销更大。等到系统规模真正变大,再考虑写一个page_table_recycle的优化版本。
释放函数大致长这样:
void free_pages(uint32_t vaddr, uint32_t pg_cnt) { uint32_t pde_idx = (vaddr & 0xffc00000) >> 20; uint32_t pte_idx = (vaddr & 0x003ff000) >> 12; uint32_t vaddr_start = vaddr & 0xfffff000; for (uint32_t i = 0; i < pg_cnt; i++) { uint32_t* pte = (uint32_t*)PTE_ADDR(vaddr_start); uint32_t phy_addr = *pte & 0xfffff000; if (phy_addr) { paddr_free(phy_addr); } *pte = 0; vaddr_start += 0x1000; } vaddr_remove(pde_idx, pte_idx, pg_cnt); }这里的vaddr_remove是释放虚拟地址位图占用的位。注意我在循环里先取物理地址,再清页表项,顺序不能反,否则物理地址信息就丢了。
6. 实操调试:那些年我踩过的坑
6.1 故障表现与排查思路
第六章的代码跑起来不像前面几章那么直观,前面代码出问题最多是屏幕不打印或打印乱码,内存管理出问题往往表现为“系统跑着跑着突然崩了”或者“某些变量莫名其妙被改写”。我把自己遇到过的典型故障整理成了一张对照表:
| 故障表现 | 最可能的原因 | 排查方法 |
|---|---|---|
| 分配内存后一写入就触发异常 | 页表映射没建立或PTE未写入 | 查看返回地址对应PTE内容是否为0 |
| 数据随机被覆盖 | 物理页释放后页表项未清空,产生别名映射 | 释放后检查PTE是否被正确清零 |
| bitmap_scan死循环 | 位图越界访问或字节数计算错误 | 在bitmap_scan入口打印bmp->bytes和当前idx |
| GDT更新后CS段异常 | 重新加载GDT后未刷新段寄存器缓存 | 用远跳转强制刷新CS,重新加载SS/DS等 |
| 函数调用第一次正常第二次崩 | 栈指针未切到内核栈,栈空间不足 | 检查进入C函数前的esp值是否落在内核栈区间 |
| 访问高地址时指针变负数 | 把0xfffffxxx等地址放进int变量 | 使用uint32_t类型存储地址常量 |
这里面我想特别讲一下“物理页释放后页表项未清空”那个问题。我当时写free_pages时偷懒,只把物理位图清0就return了。结果运行到用户进程相关代码时,发现一个进程free掉的内存被另一个进程继续使用,数据完全错乱。光看代码很难发现,后来是在Bochs里用查询PTE命令,发现已经释放的物理页在页表里居然还挂着映射,才恍然大悟。
再补一个所有读者都会遇到的“隐性坑”:在进入内存管理实验后,如果你的内核里用了调试打印函数,一定要确保打印函数的调用不依赖堆上的内存。我当时的一个打印函数内部用了自己的缓冲区和memcpy,结果在内存管理器还没初始化时就被调用了,直接死循环。于是我把打印函数的缓冲区改成了静态数组,不依赖任何动态内存,才解决问题。
6.2 Bochs调试内存管理的几个实用技巧
Bochs是这本书配套的虚拟机,调试内核比在真机上方便太多。我这里分享几个对第六章特别有用的技巧。
第一,用info pte查看某个虚拟地址的页表映射。比如你分配了一块虚拟地址0xc0001000,想确认它对应的PTE是否写对了,直接在Bochs调试窗口执行info pte 0xc0001000,它会显示PDE和PTE的索引和属性位。这是验证page_table_add是否成功最直接的方法。
第二,用物理地址查看内存内容。内存管理代码经常要和物理内存打交道,虚拟地址可能误导你,但物理地址是确定的。在Bochs里,x /8bx 0x100000可以查看物理地址0x100000处的前8个字节。如果你想确认某个物理地址上的数据是否被错误写入,用这个命令比看虚拟地址更准。
第三,断点不要只下在一个位置。内存分配器的Bug往往在调用链的深处,我建议在vaddr_get、paddr_alloc、page_table_add三个函数入口都下断点,甚至可以在memset、memcpy这种基础函数里临时下断点,配合单步执行观察参数值。有一次我在Bochs里跟踪了一段分配内存的过程,发现vaddr_get返回的虚拟地址正确,paddr_alloc返回的物理地址正确,但page_table_add里计算PDE_ADDR时发生了负数溢出,导致写页表写了错误地址。用单步执行很容易定位到这种问题。
第四,善用info registers和info cpus。当系统崩溃时,看看CPU的CR0、CR3、EIP和栈顶ESP的值,基本能判断是分页开启状态异常还是栈指针错位。我遇到过一次CR3寄存器指向的页目录地址根本不在合法内存范围,就是靠这个命令查出来的。
还有一个土办法,在代码里加“进度标记”。在内存管理初始化流程的关键步骤后,往显存地址或串口写一个不同字符,崩溃时就看你停在哪个标记处,能很快锁定崩溃发生在哪一步。这个方法虽然土,但在没有复杂调试工具的初期非常有效。
写在最后的几点体会
第六章的内容学完之后,我最大的感受是“内存管理是一个系统工程”这句话的分量。表面上看,这一章只写了几个结构体和几十个函数,但每个函数的逻辑都和其他函数紧密咬合。改一个位图的边界条件,可能让整个分配器在压力测试下崩掉;动一下页表的属性位,可能让后续所有用户态代码的权限检查全部失效。
我建议读者在读这章时,不要急着往后翻。先把内存池每个函数的调用链画出来,理解清楚vaddr_get、paddr_alloc、page_table_add三者的配合关系,再动手去改代码。我当时读完第六章,自己动手实现了一遍,又故意在几个边界条件上制造Bug来观察故障现象,折腾了整整一周,最后才把整章代码跑到稳定状态。这个功夫不白费,后面的中断章节、多任务章节涉及的内存操作越来越复杂,正因为第六章的地基打得牢,我才能一路顺下去。
最后再分享一个小技巧:每次内存分配后,把虚拟地址和物理地址的映射关系打印出来,再手动查一下页表确认映射一致。这个习惯帮我提前发现了不少映射漏配、配错的Bug,也让我彻底理解了“虚拟地址到物理地址的翻译”在页表视角下到底是什么样子。等你哪天能在调试器里看着页表自映射区的PDE、PTE数字,一眼想明白它映射到哪块内存的时候,第六章就算真正吃透了。