资讯动态

从实模式到保护模式:操作系统开发的关键跨越与GDT详解

发布时间:2026/8/13 13:51:39 来源:尧图企业网站定制
1. 从实模式到保护模式一次关键的“搬家”如果你是从《操作系统真象还原》这本书的第三章一路跟过来的那么恭喜你你已经成功地在实模式下“裸奔”了一回。我们亲手用汇编和BIOS中断把MBR加载到内存把Loader也加载进来甚至还玩了一把显卡的文本模式在屏幕上打印出了字符。那种感觉就像在一个空旷的、没有任何规则的操场上自由奔跑你可以直接读写任何内存地址调用任何BIOS功能简单直接但也危机四伏。现在第四章“保护模式入门”摆在面前这意味着我们要告别那个“无法无天”的操场搬进一个现代化、有完善安保和物业管理的小区——保护模式。这个“搬家”过程是编写一个真正现代操作系统哪怕是玩具级的所必须跨越的第一道也是最关键的一道门槛。为什么必须搬因为实模式太“原始”了。它诞生于8086那个16位、1MB内存就是天文数字的时代。在实模式下程序可以访问整个1MB的物理地址空间没有任何权限检查一个程序的错误操作比如写错了地址可以直接导致系统崩溃更别提运行多个程序了。这完全不符合我们对现代操作系统“稳定”、“安全”、“多任务”的基本要求。保护模式是Intel从80286开始引入并在80386也就是我们常说的IA-32架构的起点上成熟起来的一套全新的CPU工作模式。它之所以叫“保护”核心就在于它提供了一套硬件级别的内存访问和权限检查机制。在保护模式下程序不再直接操作物理地址而是通过一个叫做“段”的抽象来访问内存。CPU会严格检查每一次内存访问这个程序有没有权限读写这个段它试图执行的代码段是否允许执行这就好比在小区里你不能随便进别人家段保护保安CPU会检查你的门禁卡段选择子和权限。这个“搬家”过程或者说模式切换本身并不复杂但准备工作极其繁琐。你需要为这个新的“小区”建立一套完整的管理规则和基础设施其中最重要的就是全局描述符表。你可以把它想象成小区的“业主花名册”加“房屋产权证”合集。GDT里存放着一个个“段描述符”每个描述符精确地定义了一个内存段它从哪里开始基地址有多大段界限是什么类型的代码段、数据段以及谁有权限访问描述符特权级DPL。CPU在保护模式下所有对内存的访问都必须先查阅这个GDT找到对应的“产权证”验证权限然后才能进行。所以第四章的核心任务非常明确在Loader程序中完成从实模式到保护模式的切换。这需要我们按顺序做好几件“苦力活”准备好GDT打开A20地址线突破实模式1MB内存寻址限制的关键一步设置控制寄存器CR0的PE位保护模式使能位最后执行一个长跳转彻底进入保护模式的新世界。这个过程一旦成功我们的操作系统开发就真正迈入了现代的门槛后续的内存管理、任务切换、用户态/内核态隔离等高级特性才有了赖以实现的硬件基础。接下来我们就一步步拆解这个“搬家”流程里的每一个技术细节和容易踩坑的地方。2. 核心基础设施全局描述符表详解在动手写代码之前我们必须把GDT这个概念吃透。它是保护模式的基石理解不清后面的代码就是空中楼阁。2.1 段描述符内存段的“身份证”在实模式下一个段由“段基址:段内偏移”构成段基址左移4位加上偏移就是物理地址。这种方式简单粗暴但段基址可以任意设置毫无约束。在保护模式下段的概念被抽象和强化了。一个“段”现在由一个8字节64位的段描述符来完整描述。这8个字节里塞满了信息其结构是由Intel手册明确定义的我们必须严格遵守。一个段描述符主要包含以下几类信息段基址32位定义了这个段在4GB线性地址空间中的起始位置。段界限20位定义了这个段的大小范围。这里有个关键点段界限的单位粒度由G位决定。当G0时单位是1字节段最大为1MB当G1时单位是4KB一页段最大可达4GB。这对于管理大内存至关重要。段类型4位用于区分代码段、数据段、系统段如任务状态段TSS、局部描述符表LDT等。例如代码段是可执行的数据段是可读写的。描述符特权级2位取值0~3。0是最高特权级通常给内核3是最低特权级通常给用户程序。DPL决定了访问这个段所需的最低CPU当前特权级。段存在位1位。P1表示该描述符有效对应的段存在于内存中P0则无效访问它会导致异常。这为虚拟内存段被换出到磁盘提供了硬件支持。其他属性如是否可读/可写、是否已访问、默认操作尺寸16位/32位等。把这64位信息按照特定顺序排列在内存中就构成了一个段描述符。CPU在保护模式下通过一个叫做段选择子的16位索引来查找GDT。选择子的高13位是索引号指向GDT中的第几个描述符第2位是TI位Table IndicatorTI0表示查GDTTI1表示查LDT最低2位是RPL请求特权级与CPL、DPL共同决定访问是否合法。2.2 构建我们的第一个GDT对于入门我们不需要一个复杂的GDT。一个最简单的、能让我们进入保护模式并执行一些代码的GDT通常至少包含三个描述符第0个描述符按照Intel规定GDT的第一个描述符必须是空描述符全0。这是一个安全设计防止段选择子未初始化时误访问。代码段描述符用于存放和执行我们的32位保护模式代码。它的类型是“可执行的、依从的代码段”DPL0内核级G1粒度4KBD/B132位段。数据段描述符用于存放数据。它的类型是“可读写的、向上扩展的数据段”DPL0G1D/B1。在汇编中我们需要手动计算并填充这8个字节。这个过程很枯燥但又是基本功。以创建一个基址为0界限为0xFFFFF当G1时表示4GBDPL0的32位代码段为例我们需要根据手册拆分基址、界限和属性然后拼成两个双字4字节写入内存。注意在编写描述符时字节顺序Big-Endian vs Little-Endian和位域的顺序非常容易出错。一个常见的技巧是先使用一个在线的“段描述符计算器”或者写一个简单的C程序来生成描述符的十六进制值然后在汇编中直接使用这些常量。这能避免大量低级错误尤其是在学习阶段。2.3 GDT的加载lgdt指令GDT本身只是一片普通的内存数据。要让CPU知道它我们必须使用lgdt指令来加载全局描述符表寄存器。GDTR是一个48位的寄存器在32位模式下其低16位存储GDT的界限大小-1高32位存储GDT的线性基地址。lgdt的操作数是一个内存地址该地址处存放着一个6字节的数据结构前2字节是界限后4字节是基址。执行lgdt后CPU就知道了GDT在哪里、有多大。但请注意执行lgdt本身并不会让CPU进入保护模式它只是完成了“花名册”的登记工作。3. 切换前的关键准备A20地址线与CR0寄存器有了GDT我们只是准备好了新家的“规章制度”。要真正搬家还得打通道路并按下切换的开关。3.1 打开A20地址线突破1MB的枷锁这是一个历史遗留问题。在最早的IBM PC/AT上为了兼容更老的软件第21根地址线A20默认是被禁用的这导致即使CPU有32位地址线在实模式下也只能访问奇数MB边界上的内存即0x100000以上的地址会回绕到0x0。在保护模式下我们需要完整使用32位地址空间因此必须打开A20。打开A20的方法有多种最常见的是通过键盘控制器8042芯片的端口0x64和0x60来操作。步骤大致是读取0x64端口等待输入缓冲区为空。向0x64端口发送命令0xD1表示要写输出端口。再次等待缓冲区为空。向0x60端口发送数据将A20位第1位置1。这个过程需要严格的等待和状态检查代码写起来有点啰嗦但逻辑是清晰的。现代机器通常都支持更快速的“Fast A20”方式通过端口0x92但为了兼容性书中和很多Bootloader如GRUB依然使用键盘控制器方式。实操心得在QEMU或Bochs这类模拟器中A20默认可能是打开的。但我们的代码绝不能依赖这一点。必须显式地包含打开A20的代码这是一个好习惯能确保你的引导程序在真实硬件或不同模拟器上都能正确工作。忘记打开A20是一个经典错误症状是进入保护模式后在访问超过1MB的内存时出现莫名其妙的问题。3.2 设置CR0寄存器按下保护模式的开关控制寄存器CR0的第0位是保护模式使能位。当PE1时CPU进入保护模式PE0时CPU在实模式。切换的代码非常简单mov eax, cr0 or eax, 0x00000001 ; 将PE位置1 mov cr0, eax执行完mov cr0, eax这条指令的瞬间CPU就进入了保护模式。但是这还没完CPU的指令预取队列和下一条指令的译码可能还是按照实模式进行的。因此紧随其后必须有一条远跳转指令。4. 临门一脚远跳转与保护模式下的初始化4.1 远跳转清空流水线更新CS远跳转指令jmp会同时改变CS和EIP。在实模式下CS是段基址在保护模式下CS以及DSES等变成了段选择子。这个选择子是一个索引指向GDT中我们定义好的代码段描述符。jmp dword SELECTOR_CODE:prot_mode_entry ; SELECTOR_CODE是代码段选择子prot_mode_entry是32位代码标号这条jmp指令做了两件至关重要的事清空流水线强制CPU刷新指令预取队列确保后续指令按照保护模式规则译码。加载新的段描述符将SELECTOR_CODE这个选择子装入CS寄存器CPU会自动用这个选择子去GDT中查找对应的代码段描述符并将其信息基址、界限、属性加载到CS对应的不可见部分描述符高速缓存器。从此以后CS:IP的寻址将基于这个新的描述符进行。4.2 更新数据段寄存器进入保护模式后实模式下设置的DSESSS等段寄存器都失效了。我们必须显式地用新的数据段选择子来加载它们。mov ax, SELECTOR_DATA ; SELECTOR_DATA是数据段选择子 mov ds, ax mov es, ax mov fs, ax mov gs, ax mov ss, axSS也需要加载除非你不打算使用栈。同时需要为栈分配空间并设置ESP。4.3 一个完整的切换流程代码框架将以上所有步骤整合到Loader中代码框架如下使用NASM语法[bits 16] LOADER_BASE_ADDR equ 0x900 ; Loader被加载到的物理地址 LOADER_STACK_TOP equ LOADER_BASE_ADDR ; 栈向下生长 jmp loader_start ; 定义GDT GDT_BASE: dd 0x00000000 ; 第一个描述符必须为空 dd 0x00000000 CODE_DESC: dd 0x0000FFFF ; 段界限低16位段基址低16位 (这里先临时填充后面会修正) dd 0x00CF9A00 ; 段基址高8位段属性段界限高4位及属性 (G1, D/B1, P1, DPL00, TYPE1010) DATA_DESC: dd 0x0000FFFF dd 0x00CF9200 ; TYPE0010 (可读可写数据段) GDT_SIZE equ $ - GDT_BASE GDT_LIMIT equ GDT_SIZE - 1 ; 选择子 SELECTOR_CODE equ CODE_DESC - GDT_BASE SELECTOR_DATA equ DATA_DESC - GDT_BASE ; GDTR临时存储位置 gdt_ptr: dw GDT_LIMIT dd GDT_BASE loader_start: mov ax, cs mov ds, ax mov es, ax mov ss, ax mov sp, LOADER_STACK_TOP ; 1. 打印准备信息实模式 mov si, msg_loading call print_string ; 2. 打开A20地址线 call enable_a20 ; 3. 加载GDT lgdt [gdt_ptr] ; 4. 设置CR0的PE位 mov eax, cr0 or eax, 0x1 mov cr0, eax ; 5. 远跳转进入保护模式并更新CS jmp dword SELECTOR_CODE:prot_mode_begin [bits 32] prot_mode_begin: ; 6. 更新数据段寄存器 mov ax, SELECTOR_DATA mov ds, ax mov es, ax mov fs, ax mov gs, ax mov ss, ax mov esp, LOADER_STACK_TOP ; 重新设置32位栈指针 ; 7. 进入保护模式后的第一件事清屏并打印成功信息 mov byte [gs:0x00], P ; 假设显存起始为0xb8000使用gs段 mov byte [gs:0x01], 0x0A ; 黑底绿字 mov byte [gs:0x02], M mov byte [gs:0x03], 0x0A ; 8. 后续可以在这里进行更多保护模式下的初始化... jmp $ ; 实模式下的子程序 enable_a20: ; ... 具体实现省略 ... ret print_string: ; ... 具体实现省略 ... ret msg_loading db Loading and switching to PM..., 0这段代码清晰地展示了从实模式跳转到保护模式的完整链条。每一个步骤都不可或缺顺序也基本固定。5. 调试与排错保护模式切换的“暗礁”理论很美好但第一次尝试时大概率会失败。屏幕可能卡住、乱码、或者直接重启。这时候系统的调试手段非常有限因为我们的程序是直接跑在裸机上的。以下是几个最常见的坑点和排查思路。5.1 问题一屏幕无任何输出系统挂起或重启这是最令人沮丧的情况。可能的原因和排查方向GDT描述符构造错误这是头号嫌疑犯。仔细检查每个描述符的8个字节。确保基地址、界限、属性位特别是P位、G位、D/B位、TYPE域完全正确。强烈建议将你计算出的描述符值与一个可靠的参考值如书籍示例、在线工具生成的结果进行逐字节比对。lgdt加载的地址或界限错误lgdt指令的操作数指向一个6字节的内存区域。检查这个区域的内容是否正确。界限应该是GDT_SIZE - 1基地址必须是GDT的线性地址。在实模式下如果你把GDT定义在Loader的代码中那么这个地址就是GDT_BASE的偏移地址加上段基址通常是0x9000。计算错误会导致CPU查不到正确的描述符。A20地址线未成功打开虽然很多模拟器默认打开但代码有问题也可能导致失败。确保你的enable_a20子程序包含了完整的等待状态步骤。一个简单的测试方法是在进入保护模式前尝试读写一个高于1MB的地址比如0x10_0000看是否会发生回绕。如果回绕说明A20没开。远跳转指令错误jmp dword是必须的它告诉汇编器这是一个32位远跳转。选择子SELECTOR_CODE必须指向GDT中一个有效的、类型为代码段的描述符。跳转目标prot_mode_begin的地址必须在代码段的界限之内。5.2 问题二进入保护模式后打印字符错乱或位置不对这说明切换基本成功CPU已经在执行32位代码了但内存访问特别是显存访问有问题。数据段选择子未正确加载在32位代码开始处你是否正确加载了DSESGS等寄存器如果你用GS来访问显存0xb8000那么GS必须加载一个有效的、可写的数据段选择子并且该描述符的基址最好为0界限为4GB这样[gs:0xb8000]才能正确映射到物理地址0xb8000。显存地址计算错误在文本模式下每个字符占2字节ASCII码属性。计算屏幕位置是行 * 80 * 2 列 * 2。确保你的计算是正确的。段描述符属性错误数据段描述符的TYPE字段必须是可读可写的对于显存通常用0x92或0x93表示可读写、向上扩展。如果属性是只读那么写操作会引发CPU异常。5.3 问题三使用Bochs进行调试对于操作系统开发Bochs模拟器是比QEMU更强大的调试工具。QEMU速度快但调试信息少。Bochs自带一个调试器可以单步执行、查看寄存器/内存/描述符表、设置断点。当你的Loader卡住时在Bochs中启动Bochs并加载配置文件。在Bochs命令行中使用sreg命令查看段寄存器及其对应的描述符信息。在保护模式切换前后分别执行可以清楚地看到CS、DS等寄存器从实模式的值变成了选择子并且旁边会显示其描述符的详细信息基址、界限、属性。这是最直接的验证手段。使用info gdt命令查看你定义的GDT内容与你代码中定义的是否一致。使用xp /Nx ADDR命令查看物理内存确认你的代码和数据是否被正确加载到了预期位置。单步执行s或n跟踪lgdtmov cr0jmp这几条关键指令。通过Bochs的调试你可以像用GDB调试普通程序一样洞察CPU在每一个时刻的状态绝大部分问题都能被定位。6. 保护模式初体验我们能做什么成功进入保护模式后我们并没有立刻获得什么超能力但世界的基础规则已经改变了。我们现在可以访问完整的4GB线性地址空间不再受1MB限制。理论上我们可以读写任何物理内存前提是有对应的段描述符允许我们这么做。享受最基本的内存保护如果程序试图访问一个段描述符规定范围之外的内存或者试图向一个只读段写入CPU会立刻产生一个异常。这为操作系统捕获程序错误提供了可能。为后续功能铺平道路这是实现分页机制现代操作系统内存管理的核心、特权级切换用户态/内核态隔离、多任务任务状态段TSS的前提。没有保护模式这些高级特性都是无源之水。在第四章的末尾我们通常会在保护模式下做一件有“成就感”的小事比如用32位汇编更高效地清屏或者绘制一个简单的图形来证明我们确实在新的模式下运行了。这不仅仅是演示更是对段机制理解的一次实践。例如我们可以写一个用STOSD指令快速填充显存的循环这比实模式下用INT 0x10一个字符一个字符地打印要快得多也更能体现32位模式的优势。通过这样的实践你会真切地感受到我们虽然还在写汇编但所处的环境已经是一个更强大、更有序的“新世界”了。而这个世界正是我们构建自己操作系统的起点。

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

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

免费获取报价