资讯详情

保护模式进阶:内存探测、分页与ELF内核加载全解析

📅 2026/10/21 3:23:59 | 华诺云谱 👁 阅读
保护模式进阶:内存探测、分页与ELF内核加载全解析
1. 这一章在做什么从“能打印字符”到“迎接内核”1.1 为什么标题叫“保护模式进阶”《操作系统真象还原》的第五章标题是“保护模式进阶向内核迈进”。我第一次看到这个标题时挺困惑前面的章节明明已经切换到保护模式、也打印过字符了怎么这里还要再来一次“进阶”等真正把这一章走通才反应过来——前几章的保护模式只是“能用”离“能承载内核”还差着十万八千里。第五章要解决的是 Boot Loader 和操作系统内核之间那段最容易被忽略、也最容易翻车的路。你在实模式下完成了最基本的初始化哄着 CPU 进了保护模式但保护模式下的内存、分页、特权级、内核文件格式这些事前面根本没来得及碰。这一章把这些东西全部补上所以叫“进阶”最后真的跳到内核代码里执行所以叫“向内核迈进”。我建议学这一章之前先把前几章的代码过一遍尤其是 GDT 初始化、从实模式跳保护模式的jmp、屏幕打印字符这几个基础动作。操作系统开发是强依赖顺序的任何一环没吃透后面的问题都会成倍放大。1.2 这一章的三个任务和路线图这一章的核心任务可以拆成三块探测物理内存搞清楚机器里到底有多少可用内存、哪些区域能用、哪些是硬件保留区。构造页目录和页表开启分页让 CPU 从“段式翻译”进入“段式加页式翻译”的新阶段。把 ELF 格式的内核文件从磁盘读到内存解析出代码段和数据段然后跳进内核入口。这三件事是层层递进的关系。没有内存地图你就不知道页表该建在哪、内核该加载到哪没有分页机制内核的内存管理模块后面根本没法展开不解析 ELF你可能连内核入口在哪都找不到。所以“向内核迈进”不是一句口号它是有明确交付物的一个可用的内存布局、一组能工作的页表、一个已经加载到内存并准备执行的 C 语言内核。我看到很多初学者会犯一个毛病急着去看后续的进程、文件系统却把这块基本功跳过去。结果就是回到第五章一头扎进三重故障里出不来。这一章值得慢慢磨。2. 保护模式的分段细节容易被忽略的几道坎2.1 段描述符布局写错一个位都可能直接崩第五章虽然不从头讲段描述符但后面所有操作都建立在“你能正确构造段描述符”的基础上。保护模式下一个内存段不是像实模式那样简单地用段寄存器加偏移拼地址而是由一个 8 字节的段描述符定义。描述符里写清楚了段的基地址、段界限、粒度、类型、特权级以及是否存在。标准 32 位段描述符的布局可以这样记字节位置位范围含义00-15段界限低 16 位对应 limit[15:0]116-31段基地址低 16 位对应 base[15:0]20-7段基地址中 8 位对应 base[23:16]28-11段类型比如代码段还是数据段、可读可写等212S 标志系统段还是普通代码/数据段213-14DPL描述符特权级215P段是否存在30-3段界限高 4 位对应 limit[19:16]34AVL软件可用位35L64 位代码段标志32 位环境下置 036D/B操作数大小32 位代码段置 137G粒度0 表示字节粒度1 表示 4KB 粒度38-15段基地址高 8 位对应 base[31:24]很多人第一次写描述符会直接把一个 64 位整数按十进制或者按内存顺序填错。比如想要一个基址为 0、界限为 4GB 的代码段在 32 位保护模式下常见做法是G 位为 1段界限 20 位全用0xFFFFF。因为 G1 后实际段界限等于“20 位界限值 1 后再乘以 4KB”也就是(0xFFFFF 1) * 0x1000 0x100000000正好覆盖整个 4GB 地址空间。如果只把低 16 位界限填了0xFFFF高 4 位没填G 位又没设对CPU 计算出来的段范围就会非常小访问稍微高一点的地址就触发异常。这类错误不会在你刚进入保护模式时立刻暴露等你开始往高地址加载内核、访问页表的时候它就会变成非常难查的三重故障。2.2 GDT、选择子与特权级段描述符不是散落在内存里让 CPU 自己找的而是组织成一张表叫全局描述符表 GDT。CPU 通过 GDTR 寄存器保存 GDT 的基地址和界限用lgdt指令加载。进入保护模式后段寄存器里存的并不是基地址而是一个“选择子”。选择子的结构是低 2 位是请求特权级 RPL第 2 位是 TI 位表示查 GDT 还是 LDT高 13 位是描述符索引。CPU 拿到选择子后去 GDT 中索引对应项再把描述符里的基地址、界限等信息加载到内部的隐藏缓存里。这里有个极易忽略的点段描述符里的 DPL 不是摆设。普通数据段的访问规则可以简单记忆成描述符的 DPL 必须不低于 CPL 和 RPL 中较高的那个。换句话说DPL max(CPL, RPL)。如果内核代码用特权级 0 访问一个 DPL 也是 0 的数据段没问题但如果用户态代码用 CPL3 去访问 DPL0 的内核数据段就会被拒绝。在第五章这个阶段内核还没有用户态全局都跑在特权级 0。所以构造内核代码段和数据段时DPL 一律填 0选择子的 RPL 也填 0。不要自作聪明地去填 3后续一旦接上用户态环境你会发现自己陷入各种特权级检查失败里。2.3 重新加载段寄存器与栈切换进入保护模式那一步虽然已经通过mov cr0, ...把 PE 位置 1但 CS 这个寄存器的隐藏描述符缓存还停留在实模式状态。这时候必须用一条远跳转比如jmp 0x08:flush_32强制重新加载 CS。然后还要挨个把 DS、ES、SS 等数据段寄存器加载成新的选择子。我见过有人跳进保护模式后忘了重新加载 DS结果后续的mov访问内存还是按照实模式段寄存器语义去算整个程序行为完全错乱。排查这种问题最直接的线索是你明明已经开了保护模式但地址表现还像实模式而且越界访问也不报错。另一个经常被忽略的是栈。进入保护模式前你可能还在用 BIOS 提供的栈或者某个临时栈。到了第五章加载内核、调用 C 代码之前必须把栈切到一个明确的位置。建议单独划一块至少 4KB 的连续内存作为内核栈不要继续依赖原来的临时栈。内核里的 C 函数调用、局部变量、后续的中断现场保存全都要压栈。栈太小函数一调深就会往 loader 代码或者页表区域里写结果会是系统性崩溃而不是报一个明确错误。3. 物理内存探测给内核画出可用的地图3.1 为什么必须在进入保护模式前探测很多初学者不理解内存容量有什么好探测的直接假设一个值不就行了吗不行。同一份内核可能跑在只有 32MB 内存的老环境也可能跑在 512MB 的模拟器里。内存池大小、页表要建多少项、内核能不能加载成功全都依赖真实的物理内存布局。更要命的是物理内存不是一整块连续的可用空间。低地址里有 BIOS 数据区、中断向量表、显存映射区中高地址里可能有设备映射、ACPI 表、系统保留区。如果不做内存探测单纯把所有地址都当作可用内存去建页表、写内核很可能会覆盖硬件需要的数据。BIOS 的int 0x15, eax0xE820中断是实模式下最权威的内存探测方式。它返回一段一段的地址范围描述符每段都有起始地址、长度和类型。这个中断依赖实模式 BIOS 服务所以在进入保护模式前必须在 loader 早期调用。一旦进了保护模式你没法轻易再回去调 BIOS 中断。3.2 调用约定与地址范围描述符结构int 0x15, eax0xE820的调用方式不算复杂但寄存器不能填错寄存器输入要求输出结果EAX0x0000E820成功时为签名0x534D4150失败时不清空EBX初始为 0后续传上次返回值下一次要传回的新值等于 0 表示探测结束ES:DI指向缓冲区缓冲区被填充一段描述符ECX缓冲区大小至少 20实际写入的字节数EDX0x534D4150即SMAP保持不变缓冲区里每一段的结构是 20 字节物理起始地址 8 字节长度 8 字节类型 4 字节。类型字段是核心中的核心类型值含义1系统可用内存可以正常分配和使用2保留区不可用3ACPI 可回收内存操作系统在用完后可以回收4ACPI NVS系统休眠相关不要碰5 及以上错误类型直接忽略写代码时我习惯把所有返回结果保存到一个数组里并记录总段数。这个数组需要放在一个“安全且不会被后续步骤覆盖”的地方。一般 loader 自身占不了多少空间但你接下来还要读磁盘、加载内核、建页表如果缓冲区选在那些区域中间探测结果转头就被覆盖那前面做的一切都白费。3.3 基本的探测循环用汇编来写核心循环类似这样mov ebx, 0 ; ebx 初始为 0 mov edx, 0x534d4150 ; SMAP mov di, mem_map_buf ; 指向内存地图缓冲区 xor ecx, ecx ; 先清空计数 .next_entry: mov eax, 0xe820 mov ecx, 20 ; 缓冲区大小 int 0x15 jc .done ; CF1说明出错或探测终止 cmp eax, 0x534d4150 jne .done ; 签名不对不是有效结果 ; 这里对 es:di 指向的 20 字节做处理 add di, 20 inc ecx ; 用 ecx 记录段数 test ebx, ebx jz .done ; ebx0全部完成 jmp .next_entry .done: ; ecx 中就是总段数这一段里最容易写错的是忘记每次循环把ecx重新设为 20。有些资料里的示例把ecx先设一次后面循环里一直没改结果 BIOS 返回的字节数和你实际能处理的缓冲区长度不一致后面解析就是乱套。另外注意调用时es:di指向的缓冲区大小如果只给 20 字节int 0x15返回的数据长度就是 20。当 BIOS 支持 ACPI 3.0 的扩展属性时它可能会写回 24 字节所以缓冲区最好多预留一点避免把循环后面的数据覆盖掉。3.4 实测时的内存布局形态如果你在虚拟机上跑典型结果会是这样0x0000000000000000到0x000000000009FC00类型 1可用0x000000000009FC00到0x00000000000A0000类型 2保留0x00000000000F0000到0x0000000000100000通常是保留0x0000000000100000到若干 MB类型 1可用。也就是说从 0 开始的 640KB 区域并不全是能用的中间有高地址硬件区和大小的 Hole。你如果不做探测直接按“从 0 开始全部可用”来建页表后面很容易出问题。得到地图后第五章后面的内存池初始化、页表位置选择就可以用这些数据来决策了。4. 分页机制地址要开始“翻译”了4.1 分页到底解决了什么问题在只有分段机制时线性地址经过段部件转换成物理地址全程没有页的粒度。操作系统如果直接暴露物理地址各个任务之间没有任何隔离一个进程里写一个野指针可能直接改掉内核代码。分页机制引入后线性地址和物理地址之间多了一层映射关系每个 4KB 页面都能单独控制是否可读、是否可写、是否允许用户态访问。对“向内核迈进”这个目标来说分页更现实的用途是你可以先规划好内核放在哪再决定哪些线性地址映射过去。后续做虚拟内存、进程地址空间隔离时页表会成为绝对核心。第五章的任务不是实现完整的虚拟内存而是先把分页打开让 CPU 习惯用页表翻译地址并且保证开启分页的那一刻当前执行的代码不被打崩。4.2 32 位分页的地址翻译流程32 位地址被分成三段高 10 位页目录索引中间 10 位页表索引低 12 位页内偏移正好对应 4KB 页面。CPU 先读 CR3 拿到页目录的物理地址然后用高 10 位索引去查页目录项 PDE。PDE 里如果存在位 P 是 1就继续拿中间 10 位去查对应的页表项 PTE。PTE 指向真实的物理页框最后把页内偏移加上去就得到了物理地址。PDE 和 PTE 都是 32 位。高 20 位是物理地址的高位低 12 位是控制位。其中最低的 P 位表示页面是否存在第 1 位 RW 表示读写权限第 2 位 US 表示用户态是否可访问。章节练习阶段我给内核页表项填的都是0x03也就是 P1、RW1US0。这样内核自己可以读写用户态将来不能访问内核页。有个太容易被忽略的细节页目录必须物理地址按 4KB 对齐页表也必须 4KB 对齐。你如果随手把页目录定义在一个只按 1 字节对齐的缓冲区里CR3 会把低位非零的地址当作无效值分页开启后行为完全不可预测。4.3 初始页表怎么建恒等映射先跑通开分页最怕的就是分页位一打开下一条取指的线性地址没法映射到物理内存CPU 立刻缺页缺页没处跑变成双重故障再演变成三重故障机器直接复位。所以第一步要做的是建立“恒等映射”。最简单有效的初级方案是把线性地址 0 到 4MB 这一片映射到物理地址 0 到 4MB。因为当前 loader 的代码、栈、数据基本都位于低 1MB 内只要这一段能映射开启分页后当前位置就不会跑飞。建立过程分两步先准备一个页目录再准备一个页表。页目录的第 0 项指向这个页表的物理地址并设置 P/RW。页表中的 1024 项每一项把物理地址设为i * 0x1000同时加0x03也就是直接把 0 到 4MB 映射到自身。页目录的其他项先全部置 0保证没有被映射的地址访问时会产生页错误方便排查。如果用汇编来初始化主体是这样的; 清空页目录 mov ecx, 1024 xor eax, eax mov edi, page_dir_base rep stosd ; 让页目录第 0 项指向 page_table_baseP|RW mov eax, page_table_base or eax, 0x3 mov [page_dir_base], eax ; 填充页表1024 个 PTE每个映射 4KB mov eax, 0x3 ; 物理地址从 0 开始加上 P|RW mov edi, page_table_base mov ecx, 1024 .fill_pte: mov [edi], eax add eax, 0x1000 add edi, 4 loop .fill_pte代码里我用了0x3而不是0x1。如果只填0x1页面存在但不可写后面一旦执行写内存的指令比如往栈里压数据就会触发写保护异常。初学时最容易犯的错就是少写 RW 位。页目录和页表本身占用的物理内存也要避开之前加载内核的缓冲区。我一般会在内存探测结果里找一块低地址连续的可用区域专门留给页表并且把它固定下来后面不要再用作其他用途。4.4 开启分页的指令顺序页表准备好之后开启分页只需要三步mov eax, page_dir_base mov cr3, eax ; 加载页目录基地址 mov eax, cr0 or eax, 0x80000000 ; 设置 PG 位 mov cr0, eax注意顺序一定要先把 CR3 设置好再改 CR0 的 PG 位。如果你先开了分页CPU 会立刻拿着一个还没有初始化的 CR3 去翻译地址大概率当场崩溃。开启分页后旧的旁路转换缓冲 TLB 里可能缓存了之前线性地址到物理地址的映射通常不受影响但为了保险可以用一条短跳转刷新一下流水线。这一步不是必须却是我每次做都顺手加上的习惯。分页开启并不意味着以后就不用管低地址了。内核入口、中断入口、设备映射这些都需要页表项来覆盖。第五章只要把 0 到 4MB 的恒等映射做好就能满足“先跑通再优化”的目标。5. 加载内核并跳转向“C 世界”迈出第一步5.1 为什么内核用 ELF 而不是裸二进制前几章写的汇编代码编译出来可以是一段扁平的二进制直接扔到内存某个位置就能跑。但真正的内核是用 C 语言写的代码里包含多个节区.text、.data、.bss还有符号表。如果直接把裸二进制拷贝过去入口地址、数据段位置、未初始化数据清零这些事情全都要你手工维护极其痛苦。ELF 格式把这些元数据都放进了文件头部。loader 只要解析 ELF Header 和 Program Header就能知道内核的入口地址在哪里哪些代码需要从文件里拷贝到内存哪些未初始化数据需要在加载后清零。所以在“向内核迈进”这一步手写一个最简 ELF 加载器比凑一个裸二进制要合理得多。这个名字起得很形象把 ELF 文件“还原”成内存中的可执行映像正是操作系统加载程序的原始动作。5.2 手写一个“够用就好”的 ELF 加载器ELF Header 长 52 字节。对加载内核来说关键字段是这些偏移大小含义0x004ELF 魔数0x7F 45 4C 460x184入口地址e_entry0x1C4Program Header 表的偏移e_phoff0x2A2每个 Program Header 大小e_phentsize0x2C2Program Header 数量e_phnumProgram Header 才是真正描述“哪段文件数据该放到哪个内存地址”的结构。对 32 位 ELF关键字段是偏移大小含义0x004p_type1 表示可加载段 PT_LOAD0x044p_offset段在文件中的偏移0x084p_vaddr段要加载到的线性地址0x104p_filesz文件中占用的字节数0x144p_memsz内存中占用的字节数加载逻辑非常简单遍历所有 Program Header遇到p_type 1的段就从内核文件缓冲区偏移p_offset处拷贝p_filesz字节到地址p_vaddr。然后如果p_memsz大于p_filesz说明还有未初始化数据需要清零就用memset把多出来的部分写成 0。伪代码如下uint32_t entry *(uint32_t *)(buf 0x18); uint32_t phoff *(uint32_t *)(buf 0x1C); uint16_t phentsize *(uint16_t *)(buf 0x2A); uint16_t phnum *(uint16_t *)(buf 0x2C); for (int i 0; i phnum; i) { Elf32_Phdr *ph (Elf32_Phdr *)(buf phoff i * phentsize); if (ph-p_type ! 1) // 不是 PT_LOAD continue; memcpy((void *)ph-p_vaddr, buf ph-p_offset, ph-p_filesz); uint32_t bss_size ph-p_memsz - ph-p_filesz; if (bss_size 0) memset((void *)(ph-p_vaddr ph-p_filesz), 0, bss_size); }为啥要区分p_filesz和p_memsz因为在 ELF 里未初始化的全局变量不占文件空间只占内存空间。如果你不把这段内存清零C 里未初始化的全局变量可能残留上一任数据传给内核的全局状态就会莫名奇妙。“内核加载到哪个地址”取决于链接脚本。第五章阶段最简单的是让内核的虚拟地址和它实际加载的物理地址保持一致或者落在你已经恒等映射的低 4MB 范围内。这样 loader 拷贝完跳转过去就能直接跑。等后面实现了完整内核页表再改成高地址映射那是后话。5.3 跳转前的最后三件事加载 ELF 完成不代表万事大吉真正跳转之前还必须处理三件事。第一件事是关中断。如果还没建立 IDT任何外部中断都会触发“中断描述符不存在”的异常没有处理程序就会变成双重故障、三重故障。所以跳转内核前执行cli是常规操作。第二件事是把栈设置好。C 语言的函数调用强依赖栈你需要mov esp, kernel_stack_top确保内核一进去就有合法的栈可用。如果这一步不做内核入口第一条call指令就会把返回地址压到一个未知区域。第三件事是把你刚刚探测到的内存地图传给内核。你可以把内存地图数组的地址放在寄存器里然后在 C 入口处接收。最简单的做法是在跳转前把地址push到栈上内核主函数按照 C 调用约定拿参数。最后用远跳转进入内核入口cli mov esp, kernel_stack_top push mem_map_buf ; 传内存地图地址 jmp dword 0x08:entry ; 选择子 0x08 对应内核代码段远跳转的好处是会强制重新加载 CS并把 CPU 预取队列清空避免旧指令缓存干扰新代码段的执行。如果你用mov eax, entry; jmp eax也不是不行但不够严谨。从这一跳开始loader 的汇编代码就“退居二线”了后面的事交给 C 语言内核去处理。6. 常见问题与排查实录6.1 内存探测结果被覆盖或地址算错我最早做内存探测时把结果数组放在了一个临时栈附近结果后面加载内核时内存缓冲区用了同一个区域。内核加载完发现内存地图的段数变成了一些不明意义的数字内存池初始化时直接用了错误数据。后来我统一了做法在 loader 最前面划一块固定的内存区域作为boot_info内存探测结果、内核入口地址、页表相关的物理地址全都在初始化阶段写进去。这个区域只做记录不参与后面的磁盘和内核缓冲。虽然牺牲了一点内存但换来的是极大的确定性。另外解析int 0x15, eax0xE820返回结果时每个地址范围描述符是 20 字节但很多人会在循环里按 16 字节去处理导致下一段解析错位。遇到这种情况内存地图里会出现极其不合理的地址比如 64GB 的机器里冒出一段位于几百太字节的范围。建议每次收到结果后先打印起始地址和长度确认解析正确再往下写。6.2 开启分页后三次复位开启分页后如果模拟器直接复位不要慌这大概率是三重故障。三重故障的前因一般是开启分页后当前 EIP 指向的线性地址没有对应的 PTECPU 缺页缺页异常又因为没有 IDT 导致双重故障双重故障处理也没找到最终 CPU 干脆复位。最常见的几个原因页目录第 0 项没填 P 位或者填成了 0页表项清空时把整个页表都用rep stosd写 0但之后忘了填充 PTE页目录或页表的物理地址没有 4KB 对齐开启 PG 位时CR3 还没加载在开启分页后代码访问的地址超过了映射范围比如 0x80000000 处的东西根本没映射。排查建议是先关掉分页所有逻辑都正常一开分页就复位问题一定出在映射关系上。最笨也最有效的做法是只映射 0 到 4MB然后把 loader 和页表全部放到 1MB 以内开分页后原地执行几行代码验证。6.3 调试小技巧把阶段标记打满屏幕操作系统启动早期没有串口打印、没有文件系统日志唯一无敌的输出手段就是往显存地址0xB8000写字符。我在 loader 的关键节点会连续打出“A、B、C、D、E”几个字母。比如mov byte [0xb8000 160 * 10], A开机后如果屏幕上只到“B”就说明“B”和“C”之间的代码出了问题。这样排查三重故障比去看反汇编日志快得多。尤其是分页开启这种“当场死给你看”的操作在开启前打一个“C”开启后打一个“D”就很容易定位到是分页开关写错了还是分页后的第一条指令访问了非法地址。我个人的经验是这段调试期不用急着加太多功能。先把“不开分页能加载内核、能跳转”跑通再开分页。如果你分成两步验证心理压力会小很多。很多看着玄妙的崩溃拆到最小步骤后原因往往只是一个or指令把 CR0 的 PG 位和高位保留位同时写乱了或者一个页表项漏了 RW 标志。把这些标记字符打得比代码还勤你会少走很多弯路。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑