资讯动态

从实模式到长模式:手写操作系统CPU模式切换实战

发布时间:2026/10/24 7:56:28 来源:尧图企业网站定制
1. 从零开发操作系统三种CPU模式到底在折腾什么很多人第一次翻操作系统源码或者看芯片手册的时候都会被实模式、保护模式、长模式这三个词砸得有点懵。明明就是让CPU跑代码为什么还要分这么多模式直接一个模式跑到底不行吗我当初也是这么想的直到自己动手写引导扇区、切GDT、开分页踩了一堆重启和黑屏的坑之后才真正理解这三种模式存在的意义。简单来说这三种模式代表了CPU在不同历史阶段对内存管理、权限控制和寻址能力的三种设计思路。实模式是早期CPU的工作方式寻址范围只有1MB没有权限概念任何代码都能访问任何内存保护模式引入了分段和分页机制让操作系统可以隔离不同进程的内存空间同时支持多任务和特权级切换长模式则是64位时代的产物取消了传统的段式内存管理改用平坦模型加多级分页寻址空间直接拉到64位。这篇文章适合谁看如果你正在跟着教程写一个玩具操作系统或者对底层原理有好奇心想搞清楚从按下电源键到进入64位内核之间CPU到底经历了什么那这篇内容就是为你准备的。我会从实模式的启动流程讲起一步步拆解怎么切到保护模式再从保护模式跳到长模式每一步都附上我实际写过的代码和踩过的坑。整个过程不依赖任何特定硬件平台你在模拟器里就能完整复现。2. 三种模式的核心差异与设计逻辑2.1 实模式一切开始的地方实模式是CPU上电后的默认状态。这个模式下CPU的寻址方式非常原始用段寄存器左移4位加上偏移量得到一个20位的物理地址。比如CS0x07C0IP0x0000实际执行的物理地址就是0x07C0乘以16加0等于0x7C00。这也是为什么BIOS把引导扇区加载到0x7C00这个地址的原因。实模式最大的限制是寻址范围只有1MB。段寄存器是16位左移4位后最大是0xFFFF0加上16位偏移量最大0xFFFF总共能访问到0x10FFEF稍微超过1MB一点点但实际可用的常规内存就是640KB加上384KB的上位内存区域。这个限制在早期是够用的但随着程序越来越大1MB的寻址空间很快就成了瓶颈。另一个关键问题是实模式没有权限级别。任何程序都可以执行任何指令包括直接操作硬件端口、修改中断向量表。这意味着一个错误的指针就能让整个系统崩溃。我在写引导代码的时候有一次不小心写错了段寄存器直接导致模拟器重启调试了半天才发现是段地址算错了。2.2 保护模式现代操作系统的基石保护模式的核心改进有三个更大的寻址空间、内存保护和多任务支持。寻址空间从1MB扩展到4GB这是通过分段机制实现的。每个段用一个8字节的描述符来表示描述符里包含段的基地址、限长和访问权限。所有描述符放在一张全局描述符表GDT里CPU通过GDTR寄存器找到GDT的位置。分段机制带来的第一个好处是内存隔离。每个进程可以有自己独立的段段描述符里的DPL字段控制访问权限CPL表示当前特权级。当程序试图访问一个权限不够的段时CPU会抛出通用保护异常。这就防止了用户程序直接操作内核数据。第二个好处是分页机制。分页把线性地址转换成物理地址页表可以做到按需分配和页面置换。分页和分段可以组合使用也可以只用分页不用分段。现代操作系统通常采用平坦模型把所有段的基地址设为0限长设为4GB然后完全依赖分页来做内存管理。从实模式切到保护模式需要做几件事建立GDT、设置CR0寄存器的PE位、执行远跳转刷新CS。这个过程中最容易出错的地方是GDT的格式和远跳转的目标地址。我见过不少人GDT写对了但远跳转的段选择子算错了结果跳到了一个错误的地址直接跑飞。2.3 长模式64位时代的必然选择长模式是x86-64架构引入的新模式它和传统保护模式有本质区别。在长模式下传统的段式内存管理基本被废弃CS、DS、ES、SS的基地址都被强制为0限长被忽略。内存管理完全依赖分页而且分页从两级变成了四级甚至五级。长模式的另一个重要变化是寄存器扩展。通用寄存器从32位扩展到64位新增了R8到R15这8个寄存器。指令指针也扩展到64位理论上可以寻址16EB的空间目前实际实现通常是48位或57位物理地址。从保护模式切到长模式需要满足几个条件先开启物理地址扩展PAE设置CR4的PAE位建立四级页表把PML4的物理地址写入CR3设置EFER寄存器的LME位最后开启CR0的PG位。这个过程比切保护模式复杂得多因为页表结构变了而且必须在开启分页之前就建立好有效的页表。我当初在写这段代码的时候最大的坑是页表的映射关系。在保护模式下我习惯用恒等映射虚拟地址等于物理地址。但到了长模式如果PML4的第一项没有正确指向PDPCPU在开启分页的瞬间就会触发页错误然后三重故障重启。调试这个问题花了我整整一个晚上最后用模拟器的调试模式单步跟踪才找到原因。3. 从实模式到保护模式的完整实操3.1 引导扇区的编写与加载一切从引导扇区开始。BIOS会把硬盘的第一个扇区加载到0x7C00然后跳过去执行。这个扇区必须是512字节最后两个字节是0x55和0xAA。我习惯用汇编来写引导代码因为对内存布局的控制最直接。[bits 16] [org 0x7C00] start: cli xor ax, ax mov ds, ax mov es, ax mov ss, ax mov sp, 0x7C00 sti mov [boot_drive], dl call load_kernel jmp switch_to_pm这段代码做了几件事关中断、清零段寄存器、设置栈指针、保存启动盘号、加载内核、跳转到保护模式切换代码。栈指针设在0x7C00是因为引导扇区本身就在这个位置栈向下增长不会覆盖到自己的代码。加载内核我用的是最简单的LBA方式直接读硬盘的第二个扇区开始的内容。这里要注意不同BIOS的磁盘读取中断参数可能略有差异我一般用INT 13h的扩展读功能AH0x42配合DAP结构体。DAP结构体里要填好目标缓冲区地址、起始扇区号和扇区数量。注意在实模式下读磁盘时目标缓冲区不能跨越64KB边界否则DMA传输会出错。我一般把内核加载到0x10000以上的地址避开低端内存的碎片区域。3.2 GDT的建立与加载GDT是保护模式的门票没有它CPU根本不知道段描述符长什么样。一个最小的GDT至少要有三个条目空描述符、代码段描述符、数据段描述符。空描述符全零是CPU要求的占位符。代码段描述符的访问字节通常是0x9A表示可执行、可读、非一致码段。数据段描述符的访问字节是0x92表示可读写数据段。gdt_start: dq 0x0000000000000000 gdt_code: dw 0xFFFF dw 0x0000 db 0x00 db 0x9A db 0xCF db 0x00 gdt_data: dw 0xFFFF dw 0x0000 db 0x00 db 0x92 db 0xCF db 0x00 gdt_end: gdt_descriptor: dw gdt_end - gdt_start - 1 dd gdt_start这里限长设为0xFFFF粒度位设为1实际限长就是0xFFFF乘以4KB等于4GB。基地址设为0这就是平坦模型。代码段选择子是0x08数据段选择子是0x10因为每个描述符8字节空描述符占了0到7代码段从8开始数据段从16开始。加载GDT用lgdt指令操作数是6字节的伪描述符前2字节是限长后4字节是基地址。执行lgdt之后GDT就生效了但此时CPU还在实模式需要设置CR0的PE位才能真正进入保护模式。3.3 切换到保护模式的关键步骤切换保护模式的核心代码就几行但顺序不能错switch_to_pm: cli lgdt [gdt_descriptor] mov eax, cr0 or eax, 0x1 mov cr0, eax jmp 0x08:protected_mode_entry [bits 32] protected_mode_entry: mov ax, 0x10 mov ds, ax mov es, ax mov fs, ax mov gs, ax mov ss, ax mov esp, 0x90000 jmp kernel_main这里有几个关键点。第一设置PE位之后必须立即执行远跳转因为流水线里可能还有实模式的指令远跳转会清空流水线并刷新CS。第二远跳转的目标段选择子必须是代码段的选择子偏移量是保护模式入口的地址。第三进入保护模式后要重新加载所有数据段寄存器因为实模式下加载的段寄存器值在保护模式下是无效的。我踩过的一个坑是在设置PE位之前没有关中断。如果此时有中断发生CPU会尝试用实模式的中断向量表来处理但此时已经处于保护模式结果就是三重故障重启。所以cli指令一定要放在设置PE位之前。另一个坑是GDT的基地址。在实模式下lgdt的操作数是一个线性地址但此时分页还没开启线性地址就是物理地址。如果GDT定义在0x7C00附近基地址就是0x7C00加上gdt_start的偏移。我一般用org伪指令来简化地址计算但要注意org只影响汇编器生成的地址不影响实际加载地址。4. 从保护模式到长模式的进阶操作4.1 开启PAE与建立四级页表从保护模式切到长模式第一步是开启PAE。PAE是物理地址扩展它把页表项从32位扩展到64位支持超过4GB的物理内存。开启PAE只需要设置CR4寄存器的第5位mov eax, cr4 or eax, 1 5 mov cr4, eax接下来要建立四级页表。长模式下的页表结构是PML4、PDP、PD、PT四级每级512个条目每个条目8字节。PML4的物理地址要写入CR3。我一般先建立恒等映射把前1GB的物理内存映射到相同的虚拟地址这样切换过程中代码不会跑飞。pml4: dq pdpt 0x3 times 511 dq 0 pdpt: dq pd 0x3 times 511 dq 0 pd: dq pt 0x3 times 511 dq 0 pt: %assign i 0 %rep 512 dq (i 12) | 0x3 %assign i i1 %endrep这里每个条目的低12位是标志位0x3表示存在且可写。PML4的第一项指向PDPPDP的第一项指向PDPD的第一项指向PTPT的512个条目映射前2MB内存。如果要映射更多内存需要更多的PD和PT。注意页表本身必须放在16字节对齐的地址上而且PML4的物理地址要写入CR3。如果页表地址不对齐CPU在开启分页时会直接报错。4.2 设置EFER与开启分页EFER是扩展功能启用寄存器通过MSR访问。长模式使能位是EFER的第8位叫LME。设置LME之后CPU并不会立即进入长模式还需要开启分页。开启分页是设置CR0的PG位但此时CPU会检查LME和PAE是否都已设置如果都设置了就进入长模式。mov ecx, 0xC0000080 rdmsr or eax, 1 8 wrmsr mov eax, cr0 or eax, 1 31 mov cr0, eax执行完这几条指令后CPU就进入了长模式。但此时CS还是保护模式的代码段描述符需要执行一个远跳转来刷新CS加载长模式的代码段描述符。长模式的代码段描述符和保护模式类似但L位要设为1表示64位代码段。jmp 0x08:long_mode_entry [bits 64] long_mode_entry: mov ax, 0x10 mov ds, ax mov es, ax mov fs, ax mov gs, ax mov ss, ax jmp kernel_main_64这里有个细节长模式下数据段寄存器的基地址被强制为0所以数据段描述符的基地址字段实际上被忽略了。但代码段描述符的L位必须正确设置否则CPU会按32位模式解码指令导致不可预期的行为。4.3 长模式下的内存管理与中断处理进入长模式后内存管理完全依赖分页。传统的段式内存管理基本被废弃所有段的基地址都是0限长被忽略。这意味着你不能再用段来隔离内存必须用页表来做权限控制。页表项的权限位包括存在位、读写位、用户超级用户位、执行禁用位等。中断处理在长模式下也有变化。IDT的条目从8字节变成16字节中断处理函数的地址是64位的。中断描述符的格式也变了需要设置IST字段来指定中断栈表。我一般给每个中断分配一个独立的栈避免中断处理过程中栈溢出。idt_entry: dw isr_handler 0xFFFF dw 0x08 db 0x00 db 0x8E dw (isr_handler 16) 0xFFFF dd (isr_handler 32) 0xFFFFFFFF dd 0x00000000这里0x8E表示中断门DPL为0表示只有内核态可以触发。如果要在用户态触发系统调用DPL要设为3访问字节变成0xEE。长模式下的系统调用通常用syscall指令而不是传统的int 0x80。syscall指令会跳转到MSR寄存器指定的入口地址同时把返回地址保存在RCX标志寄存器保存在R11。这种方式比中断门快得多因为不需要查IDT和切换栈。5. 常见问题与排查技巧实录5.1 切换模式时系统重启或黑屏这是最常见的问题原因通常有几个。第一GDT没有正确加载lgdt的伪描述符地址算错了。检查方法是把GDT的基地址和限长打印出来确认和预期一致。第二远跳转的段选择子不对比如用了0x08但代码段描述符实际在0x10。第三设置PE位之前没有关中断导致中断处理时三重故障。排查这类问题我一般用模拟器的调试模式在关键指令处下断点单步执行观察寄存器的变化。特别是CR0、CR3、CR4这几个控制寄存器它们的值直接决定了CPU的工作模式。5.2 页表映射错误导致页错误长模式下开启分页的瞬间如果页表映射不正确CPU会触发页错误。页错误的错误码会告诉你是什么原因是页不存在还是权限不够还是保留位被设置。我遇到最多的是页表项的存在位没有设置或者页表地址没有对齐。排查页表问题我一般先检查CR3的值是否正确然后逐级检查PML4、PDP、PD、PT的条目。可以用模拟器的内存查看功能直接看页表所在的内存区域确认每个条目的值是否符合预期。5.3 中断处理函数跑飞中断处理函数跑飞通常是因为IDT条目格式不对或者中断处理函数的地址算错了。长模式下IDT条目是16字节如果按保护模式的8字节格式来填CPU会读到错误的中断处理函数地址。另外中断处理函数必须用iretq返回不能用iret因为长模式下栈上的返回地址是64位的。还有一个容易忽略的点是中断栈。长模式下可以给每个中断指定独立的栈通过IST字段。如果IST字段设为0CPU会使用当前栈。如果当前栈已经溢出或者被破坏中断处理就会出错。我一般给关键中断分配独立的栈确保中断处理不会因为栈问题而失败。5.4 常见问题速查表问题现象可能原因排查方法切换保护模式后重启GDT加载失败或远跳转错误检查lgdt操作数和段选择子开启分页后页错误页表项存在位未设置或地址未对齐检查CR3和各级页表条目长模式中断跑飞IDT条目格式错误或返回指令不对确认IDT条目为16字节用iretq返回系统调用无响应syscall入口地址未设置或EFER未配置检查MSR寄存器和EFER的SCE位三重故障重启中断处理时栈溢出或IDT未加载用模拟器调试模式单步跟踪提示三重故障是CPU的最后一道防线当它发生时CPU会直接重启不会给出任何错误信息。所以调试时一定要在关键步骤之前关中断避免中断处理过程中的错误导致三重故障。6. 实操心得与避坑建议写操作系统最折磨人的地方不是代码有多复杂而是出错的时候没有任何提示。一个错误的地址、一个没设置的标志位结果就是黑屏或者重启你只能靠经验和调试工具一点点排查。我在这上面花的时间比写代码本身多得多。第一个心得是每次只改一个东西。不要一次性把实模式到长模式的代码全写完再调试那样出了问题你根本不知道是哪一步错了。我的做法是先把实模式到保护模式跑通确认能正常打印字符再在此基础上加长模式的代码。每加一个功能就测试一次确保每一步都是可验证的。第二个心得是善用模拟器的调试功能。我用的模拟器支持单步执行、断点、内存查看和寄存器查看。在切换模式的关键指令处下断点单步执行观察寄存器和内存的变化比盲目猜测高效得多。特别是页表映射直接看内存里的页表条目比在代码里加打印语句快得多。第三个心得是文档和手册是最好的老师。芯片手册里对每个寄存器的每个位都有详细说明遇到不确定的地方翻手册比在网上搜答案靠谱。我刚开始写的时候总觉得手册太厚看不完后来发现其实只需要看和当前任务相关的章节用到什么查什么效率反而更高。第四个心得是代码要模块化。引导扇区、GDT、页表、中断处理这些都应该分开写用汇编的include或者链接器来组织。这样调试的时候可以单独测试每个模块出了问题也容易定位。我见过有人把所有代码写在一个文件里几千行汇编出了问题根本没法查。最后分享一个调试技巧如果系统在切换模式后没有任何输出可以在切换之前往显存里写一个字符确认代码至少执行到了那一步。显存的地址是0xB8000在实模式和保护模式下都可以直接访问。写一个字符进去如果屏幕上出现了说明代码执行到了这里如果没有说明问题出在更早的地方。这个技巧虽然简单但在没有调试器的情况下非常管用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑