当前位置: 首页 > news >正文

从实模式到保护模式

从实模式到保护模式

我们经常听说实模式与保护模式,那么到底什么是实模式,什么是保护模式呢?

在前面的代码编写过程中,我们实现了将文件从硬盘当中加载到内存里,然后我们在内存里还进行了跳转指令等等,像这种我们直接操作物理内存的情况,就是处于实模式之中。

那么说到底,什么是实模式呢?在早期x86架构下,使用的运行模式很简单,当时使用的是16位的寄存器,也就是16位环境,在内存寻址的时候是直接在物理地址上进行寻址的,最大的寻址空间是1MB(1MB是2的20次方,需要20位的地址总线,所以是由两个寄存器来表示的,具体后面会说);我们都知道内核运行之后是各个程序运行在内存当中,这时候每个程序都是平级的,每个程序都可以删除或者更改某个地址上的内容,或者执行某个地址上的内容,这种情况下很容易出现冲突和错误;而且此时发生的中断内核并不多做处理,而是依赖BIOS提供的服务。

显然这种方式是不合理的,尤其是技术发展随着程序环境越来越复杂的情况,所以就设计了一种更安全的运行模式,为了与原本模式区分开来,参考各模式特点取名为了保护模式和实模式。

保护模式是32位或64位x86架构的核心运行模式,提供了分段、分页、特权级保护、多任务支持等等现代操作系统所需要的功能,此时的最大寻址空间也达到了4GB(32位地址总线)或者更大(64位),并且通过内存保护和虚拟内存的增强系统稳定性。

从实模式到保护模式的切换通常在操作系统加载引导程序或者内核初始化的过程就完成了,也就是我们下一步要完成的内容。

首先介绍下两种模式的差别

首先最大的差别就是寻址方式的变化:

在实模式下,寻址是有段+偏移实现的,首先有段基址寄存器存储段基址的位置,然后由寄存器存储偏移地址,将段基址左移四位后加上偏移地址,就能够表达实际的地址:物理地址=段基址×16+偏移地址

因为寄存器大小为16位,即偏移地址大小最大为2的16次方,即64KB,所以每个段大小也是64KB。

每个段基址有四位,与偏移地址一起能够寻址1MB的空间。

在保护模式下,寻址方式变得复杂了一些:
段寄存器不再直接存储段基址了,而是存储段选择子

段选择子是一个16位的二进制数,其包含索引、TI标志和RPL三部分。

其中索引指向全局描述符表(GDT)或局部描述符表(LDT)中的段描述符。(后面讲什么是GDT和LDT)

TI标志指示使用的是GDT还是LDT,为1则指向GDT。

RPL用于请求特权级(包含0-3四种)(还是后面说)

逻辑地址(段选择子+偏移)通过GDT或者LDT转换为线性地址:线性地址=段基地址(从段描述符获取)+偏移地址

偏移地址会经过检查(怎么实现计组有讲过,这里不赘述)防止越界访问。

如果没有启动分页机制,那么线性地址就是物理地址,

如果启动了分页,线性地址通过分页机制映射到物理地址:

线性地址分为:页目录索引、页面索引和页面内偏移

通过页目录表(PDE)和页面表(PTE)查找对应的物理页面,物理地址就等于页面基地址(从页面表获取)+页面内偏移

其次是内存管理方面,体现了“保护”

在实模式下,每个程序都能操纵任意地址,这十分的不安全

在保护模式下,具有分段机制,分页机制特权级保护和多任务支持

每个段由段描述符定义,包含基地址、界限和属性。段可以为代码段、数据段、系统段等等,支持不同的特权级访问,并且由段界限检查防止越界访问,增强了安全性。

而且可以可选的实现分页机制,将内存分为固定大小的页面,通过页表实现线性地址到物理地址的映射(前面有说),可以实现虚拟内存、内存隔离等等,并且可以为每个页定义单独的属性提供额外的保护。

保护模式下通过任务状态段和中断机制实现多任务切换,每个任务都有独立的段和页表,实现了彼此的隔离。

还有就是中断处理的变化:

在实模式下中断处理依赖于BIOS提供的服务,在内存中0x0000到0x003FF包含256个中断向量,一个4字节,每个向量存储中断处理程序的段基址和偏移地址,通过int进行使用,比如前面用过的int 0x10用于显示。

存储中断向量的叫做中断向量表(IVT),他是可以在实模式下被任意程序修改的,这也体现了不安全。

在保护模式下,中断操作由OS接手,OS自己实现中断逻辑,取代BIOS的功能。

而且有中断向量符表(IDT)取代IVT,这里面每一个描述符包含8个字节,其中包括中断处理程序的段选择子和偏移地址以及特权级(DPL)和门类型(中断门、陷阱门、任务门)。

中断处理程序运行在指定的特权级,通常是ring 0 内核态,低特权级别的代码不能够调用高特权级的中断,由中断门提供保护。

保护模式还引入了多种的异常处理,比如页面错误,段错误等,由CPU自动触发,异常处理程序可以用于内存管理或者恢复错误。

最后就是寄存器与指令集的变化

在保护模式下寄存器大小扩展到32位,段寄存器存储选择子,并且新增了控制寄存器(比如CR0、CR2和CR3)和调试寄存器(DR0-DR7)

保护模式支持新的指令用于系统管理,并且将特权指令与非特权指令区分,防止用户态滥用。

然后就是怎么实现从实模式切换到保护模式

步骤一般是禁用中断-设置GDT-启用保护模式-执行远跳转

具体实现(setup):

; 0柱面0磁道2扇區
[ORG  0x500][SECTION .text]
[BITS 16]
global _start
_start:cli ;首先禁用中断,防止切换为保护模式过程中中断干扰mov     ax, 0mov     ss, axmov     ds, axmov     es, axmov     fs, axmov     gs, axmov     si, ax; 启用A20地址线call enable_a20; 加载GDT表lgdt [gdt_descriptor]; 设置CR0的PE位置,标记为保护模式mov eax,cr0or eax,1 ; 设置PE位(第0位)mov cr0,eax; 执行远跳转指令,更新CS并切换到32位代码jmp   CODE_SEG:protected_mode_entry; 启用A20地址线的函数:通过键盘控制器
enable_a20:in al,0x92or al,2out 0x92,alret; 定义GDT表
gdt_start:; 空描述符dd 0x00000000dd 0x00000000;代码段描述符;基地址=0,界限=0xFFFF(4GB),粒度=4KB;类型:代码,可执行,可读,存在,DPL = 0(内核态)gdt_code:dw 0xFFFF ; 界限低16位dw 0x0000 ; 界地址低16位db 0x00 ;基地址中8位db 0x9A ;访问字节:存在=1,DPL=00,类型=1010(代码,可执行,可读)db 0xCF ;界限高4位+标志:G=1(4KB粒度),D/B = 1(32位),界限=0xFdb 0x00 ;基地址高八位; 数据段描述符; 基地址=0,界限=0xFFFFF(4GB),粒度=4KB; 类型:数据,可读写,存在,DPL=0(内核态)gdt_data:dw 0xFFFF       ; 界限低16位dw 0x0000       ; 基地址低16位db 0x00         ; 基地址中8位db 0x92         ; 访问字节:存在=1,DPL=00,类型=0010(数据,可读写)db 0xCF         ; 界限高4位+标志:G=1(4KB粒度),D/B=1(32位),界限=0xFdb 0x00         ; 基地址高8位gdt_end:; GDT描述符(GDTR寄存器内容)
gdt_descriptor:dw gdt_end - gdt_start - 1  ; GDT大小(字节数-1)dd gdt_start                ; GDT起始地址; 段选择子定义
CODE_SEG equ gdt_code - gdt_start   ; 代码段选择子(0x08)
DATA_SEG equ gdt_data - gdt_start   ; 数据段选择子(0x10); 切换到32位保护模式
[bits 32]
protected_mode_entry:; 步骤5:设置数据段寄存器mov ax, DATA_SEGmov ds, axmov es, axmov fs, axmov gs, axmov ss, ax; 步骤6:设置堆栈mov esp, 0x90000    ; 堆栈指针(任意高地址,确保不覆盖代码); 步骤7:进入主循环(示例)mov ebx, msg_protectedcall print_string_pm; 死循环jmp $; 32位打印字符串函数
print_string_pm:mov edx, 0xB8000    ; 显存地址(VGA文本模式)mov ah, 0x0F        ; 属性:白色字符,黑色背景
.loop:mov al, [ebx]       ; 读取字符cmp al, 0           ; 检查字符串结束je .donemov [edx], ax       ; 写入显存(字符+属性)add ebx, 1          ; 下一个字符add edx, 2          ; 显存下一个位置jmp .loop
.done:ret; 数据
msg_protected db "Entered Protected Mode!", 0

注意解释一下吧:

关中断

前面的cli就是关闭BIOS中断也就是IVT那个表的中断,这个操作就是所谓的关中断,首先关掉它是因为我们后面由OS代码自己写中断逻辑接管中断,他就用不上了,而且后面万一不小心调用就是徒增烦恼,所以我们在一开始就把它关掉。

然后一连串的mov操作就是设置寄存器初值,也就是初始化,因为刚刚启动没有人知道寄存器是什么初始值,我们初始化也是为了后面操作的安全。

打开A20

然后调用了enable_a20这个函数,这个名字是我们自己定义的函数名,后面有它的实现逻辑,我们一起分析下逻辑:

首先说目的,这个函数是为了打开A20,什么是A20呢?

我们知道在早期处理器实模式下寻址是段地址+偏移地址,使用的都是16位的寄存器,最大值位216-1=65535

所以在理论上段地址左移四位最大是0xFFFF0,0xFFFF0+0xFFFF=0x10FFEF(建议自己算一下)

(为什么段地址最高位没有丢失呢?因为是在计算逻辑模块中进行的移位和相加,这里是能处理大于16位的逻辑的,然后传输到地址总线上,因为早期地址总线长度原因高位数据自动丢失了

​ 实模式的地址线有20根,为A0-A19)

这个得到的0x10FFEF是略大于1MB的,所以如果我们最后的计算结果大于了1MB,比如说达到了0x100000,最高位识别不到,会被认为是0x00000,这种现象叫做地址回卷。

但是Intel 8086出现后引入了保护模式,处理器能够访问超过1MB的范围了,但是为了兼容原本的实模式,所以新引入的地址线增加了一个硬件机制可以选择开启或者不开启第21根地址线A20,所以实模式下我们禁用A20,切换到保护模式下要打开A20。

那么怎么打开A20呢?

我们使用的是通过键盘控制器的特定I/O端口交互实现,我们这里只要知道怎么做就好了,细节内容属于微机端口一课,这里讲属于是超纲了,而且我也不知道,暂时也没有了解的计划。

讲解下enable_a20代码逻辑:

in al,0x92 是从I/O端口0x92读取一个字节到AL寄存器

or al,2 这里2切换二进制是0000_0010充当掩码,可以不改变其他位置而将第1位置为1

然后通过out 0x92,al写回去,最后通过ret返回就完成了

设置GDT

前面讲过保护模式下寻址段寄存器里面不再存储基地址了,而是存储一个叫做段选择子的16位值,这个段选择子是一个指向存储在GDT或者LDT中的一个8字节的段描述符。

段描述符里面包含了这个段的物理基地址、界限和属性。

基地址没什么好说的,能看见这篇文章你一定非常熟悉了,界限就是段的大小。

而属性包含段的类型,访问权限、特权级和存在位等内容。

CPU会利用这个段描述符来计算最终的线性地址并检查访问权限。

GDT的核心作用就是定义系统中所有任务都可以访问的全局段的属性。

通过前面说的取址方式的变化,就提供了强大的内存保护能力。

接下来讲解GDT的结构和段描述符的规范

GDT本质上就是一个由8字节的段描述符组成的数组,而且设计上要求GDT的第一个条目(索引0)必须是一个特殊的空描述符,即全0的描述符,填充GDT索引为0的位置;这种情况下所有尝试使用索引为0的段选择子都会触发通用保护错误,这是处理器强制要求的规范。

段描述符的8个字节(共64二进制位)各有信息布局

其中0-1两个字节(0-15位)表示段界限的低16位;

2-3两个字节(16-31位)表示段基地址的低16位;

4字节(32-39)表示段基地址的中间8位;

5字节(40-47)表示访问字节;

6字节(48-55)中,高四位是标志位,低四位是段界限的高4位。

7字节(56-63)表示段基地址的高8位。

然后在访问字节和标志位部分:
访问字节中8个字节其中:

第7位(Present/P):为1表示段在内存中可用,0表示段不再内存中,访问的话会导致段不存在异常。这一功能用于虚拟内存的实现。

第6-5位(DPL):特权级描述符,从00-11,定义了访问该段所需要的最低特权级别。

第4位(S):描述符类型:为1表示是代码段或数据段描述符(非系统段),为0表示是系统段描述符。

第3-0位(Type):这四位与S位结合定义了段的具体类型和权限。当S等于1时:Bit3(Executable/E)为1表示代码段,为0表示数据/堆栈段;

如果E=1,即为代码段时:Bit2(Conforming/C)为0表示非一致代码段,1表示一致代码段;

​ Bit1(Readable-R)为1表示可读,0表示不可读;

​ Bit0在硬件首次访问时设为1。

如果E=0,即为数据段时:Bit2(Expansion Direction -ED)为0表示向上扩展,为1表示向下扩展(用于堆栈段)

​ Bit1(Writable-W)为1表示可写,为0表示不可写。

​ Bit0在硬件首次访问时设为1。

标志位的前四位:

Bit7:粒度,为1 表示段界限是以4KB为单位,为0表示段界限是以字节为单位。

BIt6:默认操作大小。S=E=1时,1表示32位代码段0,0表示16位代码段。

Bit5:64位代码段,为1表示是64位的代码段。在32位保护模式下必为0。

参照这部分内容可以理解段描述符部分代码。

GDTR与lgdt指令

前面讲述了GDT的含义与内容,但是想要得到使用还需要将GDT在内存中的位置和代码告诉处理器。这依靠GDTR寄存器实现。

GDTR是一个48位的寄存器,其中低16位存放GDT的界限,也就是GDT的总字节数-1。

高32位存储GDT的基地址。

lgdt 指令用于将操作数加载到GDTR寄存器当中。

关于内存分段的设计常有两种模型,文章中代码采用的是平坦内存模型,即定义一个或者几个段,基地址都是0x00000000,界限都设置为最大值(32位模式下配合4KB粒度为4GB)。此时不同的段之间区别只有属性的不同。

优点是设计简单(而且或许内存管理和指针使用也变得简单),与分页结合更加方便,现代内核常用。

另一种情况是多段内存模型或者说叫做分级模型,各段的基地址、界限和属性都各不相同,此时可能出现一个程序可以有专门的代码段,数据段,堆栈段等,他们在物理内存中可能并不连续。

优点是提供了强大的隔离和保护,而且可能支持一些古早的特殊的应用等。但是首先是设计更复杂,而且性能开销会变大。

现在实现的只是进去保护模式的最小需求,后续还可以在GDT中实现TSS段、LDT段、门描述符等。

设置CR0的PE位

前面讲过进入保护模式后新增了控制寄存器,CR0就是其中一个32位的控制寄存器,其上包含了多个控制处理器行为的标志位。PE位是CR0上的最低位(第0位),表示当前工作模式处于实模式或者保护模式。

为0表示处于实模式,为1表示处于保护模式。所以在切换时我们需要将其置为1。这部分代码很简单,不做赘述。

执行远跳转

刚进入保护模式时,CS寄存器还是在实模式下的信息,所以执行远跳转指令同时更新CS寄存器为段选择子以及新的偏移地址到EIP/IP寄存器。

进入protected_mode_entry之后,第一步为设置数据段寄存器

也就是将数据段的选择子加载到ds,es,ss等一系列寄存器当中,因为在代码中会显示或者隐式的使用这些寄存器,如果不更新为新的段选择子的话,其原本内容为实模式下数据或者为0会导致报错。

所以总结一下就是说这一段是将数据段选择子加载到所有数据相关的段寄存器和堆栈段寄存器SS。

加载之后再使用DS等相关的指令,比如mov eax,[ebx] 就会基于数据段的描述符来计算线性地址和检查权限了。

后面设置堆栈的指令就是更改堆栈指针的位置,这里设置应当为任意高地址的位置,防止会覆盖代码,而使用的0x90000是一个比较常用的选择。

相关文章:

  • 每日算法刷题Day8 5.15:leetcode滑动窗口4道题,用时1h
  • 使用Python实现简单的人工智能聊天机器人
  • 【基础】Windows开发设置入门6:Scoop开发者完全指南(AI整理)
  • AXI-LITE slave读写时序
  • MySQL 与 FastAPI 交互教程
  • 589. N叉树的前序遍历迭代法:null指针与栈的巧妙配合
  • Crowdfund Insider聚焦:CertiK联创顾荣辉解析Web3.0创新与安全平衡之术
  • 职坐标AIoT技能培训课程实战解析
  • base64加密为何可以直接找三方网站解密
  • Unity:场景管理系统 —— SceneManagement 模块
  • Reactive与Ref的故事
  • day22-数据结构之 栈队列
  • RAGFlow升级到最新0.18.0新手指南
  • APIfox参数化配置
  • AI 赋能 Copula 建模:大语言模型驱动的相关性分析革新
  • 操作系统-锁/内存/中断/IO
  • c++20引入的三路比较操作符<=>
  • 保姆教程-----安装MySQL全过程
  • DiT中的 Adaptive Layer Normalization (adaLN) 讲解
  • 【Android构建系统】如何在Camera Hal的Android.bp中选择性引用某个模块
  • 美国关税压力下,日本经济一年来首次萎缩
  • 全国人大常委会今年将初次审议检察公益诉讼法
  • 崔登荣任国家游泳队总教练
  • 孙卫东会见巴基斯坦驻华大使:支持巴印两国实现全面持久停火
  • 字母哥动了离开的心思,他和雄鹿队的缘分早就到了头
  • 基因编辑技术让蜘蛛吐彩丝