保护模式汇编系列

保护模式汇编系列之一 - 初探保护模式

为了后面学习操作系统的需要,从今天开始我要研究x86的汇编了。所以我决定开始总结并连载x86的汇编系列,这是第一篇——初探保护模式。

我假定读者接触过16位的汇编语言,并理解汇编语言的基本概念、熟悉8086处理器采用的“段寄存器 * 16 + 偏移地址”的寻址方法。

我们从80386处理器入手。首先,到了80386时代,CPU有了三种运行模式,即实模式、保护模式和虚拟8086模式。

实模式指的是8086CPU的运行模式,不过这是后来提出的概念,在8086时代只有当时的运行模式,自然也就没有“实模式”这么个提法。如果世界上只有一种性别的人,也就没有男人,女人这种名称了。8086的汇编中,我们对于实模式的各种机制应该算是比较了解了,其大致包括实模式1MB的线性地址空间、内存寻址方法、寄存器、端口读写以及中断处理方法等内容。

不过到了80386时代,引进了一种沿用至今的CPU运行机制——保护模式(Protected Mode)。保护模式有一些新的特色,用来增强多工和系统稳定度,比如内存保护,分页系统,以及硬件支持的虚拟内存等。大部分现今基于 x86的操作系统都在保护模式下运行,包括Linux、FreeBSD、以及 微软 Windows 2.0 和之后版本 [都指32位操作系统] 。

虚拟8086模式用于在保护模式下运行原来实模式下的16位程序,我们不关心。

事实上,现在的64位处理器,拥有三种基本模式(保护模式、实模式、系统管理模式)和一种扩展模式(IA-32e模式(又分兼容模式和64位模式)) 详见这里

我们先来研究保护模式,学校目前基本还处于只讲8086实模式的时代。至于现代CPU的模式……我们有精力再来研究吧。声明下,我不是在吐槽我们的大学教育,真的。

80386首先扩展了8086的处理器(其实中间有个80286,不过这玩意感觉就是个过渡产品,我们不提了),原先的AX,BX,CX,DX,SI,DI,SP,BP从16位扩展(Extend)到了32位,并改名EAX,EBX,ECX,EDX,ESI,EDI,ESP,EBP,E就是Extend的意思。当然,保留了原先的16位寄存器的使用习惯,就像在8086下能用AH和AL访问AX的高低部分一样,不过EAX的低位部分能使用AX直接访问,高位却没有直接的方法,只能通过数据右移16位之后再访问。另外,CS,DS,ES,SS这几个16位段寄存器保留,再增加FS,GS两个段寄存器。另外还有其它很多新增加的寄存器,我们本着实用原则,到时候用到了我们再说。

我们知道,对CPU来讲,系统中的所有储存器中的储存单元都处于一个统一的逻辑储存器中,它的容量受CPU寻址能力的限制。这个逻辑储存器就是我们所说的线性地址空间。8086有20位地址线,拥有1MB的线性地址空间。而80386有32位地址线,拥有4GB的线性地址空间。但是80386依旧保留了8086采用的地址分段的方式,只是增加了一个折中的方案,即只分一个段,段基址0x00000000,段长0xFFFFFFFF(4GB),这样的话整个线性空间可以看作就一个段。这就是所谓的平坦模型(Flat Mode)。

我们以前就知道,线性地址不仅仅是内存地址,还有其它的存储器编址在里面。对于80386,在保护模式下如果开启分页,内存物理地址的访问不一定就是线性地址了,而是需要根据页映射转换到实际的物理地址去。我们暂时还谈不到分页,所以我们目前计算出的线性地址就是物理地址。

我们先来看保护模式下的内存是如何分段管理的。为了便于理解,我们从一个设计者的角度来研究这个问题,顺便试图按我的理解对一些机制的设计原因做一些阐释。

首先是对内存分段中每一个段的描述,内模式对于内存段并没有访问控制,任意的程序可以修改任意地址的变量,而保护模式需要对内存段的性质和允许的操作给出定义,以实现对特定内存段的访问检测和数据保护。考虑到各种属性和需要设置的操作,32位保护模式下对一个内存段的描述需要8个字节,其称之为段描述符(Segment Descriptor)。段描述符分为数据段描述符、指令段描述符和系统段描述符三种,大致相同,个体差异。

我们现在看一张这数据段8个字节的分解图吧,至于为什么是这样,以及每一个细节的含义请读者自行查阅Intel文档,毕竟我写的不是文档…

显然,寄存器不足以存放N多个内存段的描述符集合,所以这些描述符的集合(称之为描述符表)被放置在内存里了。在很多描述符表中,最重要的就是所谓的全局描述符表(Global Descriptor Table,GDT),它为整个软硬件系统服务。

一个问题解决了,但是又引出了的其他问题。问题一、这些描述符表放置在内存哪里?答案是没有固定的说法,可以任由程序员安排在任意合适的位置。那么问题二、既然没有指定固定位置,CPU如何知道全局描述符表在哪?答案是Intel干脆设置了一个48位的专用的全局描述符表寄存器(GDTR)来保存全局描述符表的信息。那这48位怎么分配呢?如图所示,0-15位表示GDT的边界位置(数值为表的长度-1,因为从0计算),16-47位这32位存放的就是GDT的基地址(恰似数组的首地址)。

既然用16位来表示表的长度,那么2的16次方就是65536字节,除以每一个描述符的8字节,那么最多能创建8192个描述符。

貌似说了这么多,我们一直还没提CPU的默认工作方式。80386CPU加电的时候自动进入实模式(实际上不是实模式,刚加电的时刻是一个奇葩的混沌模式,具体说明详见我的另外一篇文章《基于Intel 80×86 CPU的IBM PC及其兼容计算机的启动流程》)。既然CPU加电后就一直工作在实模式下了。那怎么进入保护模式呢?说来也简单,80386CPU内部有5个32位的控制寄存器(Control Register,CR),分别是CR0到CR3,以及CR8。用来表示CPU的一些状态,其中的CR0寄存器的PE位(Protection Enable,保护模式允许位),0号位,就表示了CPU的运行状态,0为实模式,1为保护模式。通过修改这个位就可以立即改变CPU的工作模式。

不过需要注意的是,一旦CR0寄存器的PE位被修改,CPU就立即按照保护模式去寻址了,所以这就要求我们必须在进入保护模式之前就在内存里放置好GDT,然后设置好GDTR寄存器。我们知道实模式下只有1MB的寻址空间,所以GDT就等于被限制在了这里。即便是再不乐意我们也没有办法,只得委屈就全的先安排在这里。不过进入保护模式之后我们就可以在4G的空间里设置并修改原来的GDTR了。

OK,现在有了描述符的数组了,也有了“数组指针”(GDTR)了,怎么表示我们要访问哪个段呢?还记得8086时代的段寄存器吧?不过此时它们改名字了,叫段选择器(段选择子)。此时的CS等寄存器不再保存段基址了,而是保存其指向段的索引信息,CPU会根据这些信息在内存中获取到段信息。

我们上一张图看看整个寻找和合成地址的过程吧:

大致的寻址我们就先说到这里,其实有很多细节我们先做了隐藏处理。那么在接下来的第二篇里面,我们会对从实模式到保护模式时候的细节再次进行阐述,并给出相关的汇编代码实现。

保护模式汇编系列之二 - 中断和异常处理

如果你对中断是什么都不清楚的话,还是先Google一下中断的定义和基本概念吧,这里给出一个链接: http://zh.wikipedia.org/zh/%E4%B8%AD%E6%96%B7

好了,看完了这个链接,我想你已经大致明白了什么是中断,还有中断的作用了吧?我们再来总结下,其实简单说中断就是一种通知机制罢了。我们知道操作系统的一个核心任务就是和连接在主板上的所有的硬件设备进行通信,但是CPU和这些外设的速率根本就不在一个数量级上,倘若CPU向某一个设备发出一个请求并且一直等待反馈结果的话,这样带来的性能损失是不可接受的。而且CPU在运行期间需要得知外设所发生的事件,轮询显然是不可取的,那么就迫切需要一种机制来帮助我们解决这个问题。

肩负着这一伟大使命,中断应运而生。当中断发生时,典型的处理方式就是打断CPU目前正在做的事情,CPU会保留当前的执行现场,转移到该中断事先安排好的中断处理函数去执行,执行结束之后再回来恢复之前的执行现场去执行。

从物理学的角度看,中断其实就是一种电信号,一般由硬件设备生成并送入中断控制器统一协调(当然需要一个“协调机构”了,试想所有设备不区分轻重缓急的和CPU发送中断信号的恐怖场景…)。中断控制器就是个简单的电子芯片,其作用就是将汇集的多路中断管线,采用复用技术只通过一条中断线和CPU相连接。既然中断控制器这里只有一条线和CPU相链接,那么为了区分各个设备,中断自然就有编号了。

补充一下,其实CPU的中断管脚并非只有一根,其实是有NMI和INTR两个管脚,因为从严重性上来看,中断是分为两类的,首先NMI管脚触发的中断是需要无条件立即处理的,这种类型的中断是不会被阻塞和屏蔽的,所以叫做非屏蔽中断(Non Maskable Interrupt, NMI)。事实上一旦产生了NMI中断,就意味着CPU遇到了不可挽回的错误,一般不会进行处理,只是给出一个错误信息。而我们之前所说的中断控制器连接的管脚叫做INTR,这类中断有两个特点,分别是数量多和可屏蔽。而我们主要关注的正是INTR中断。

我举一个通俗的例子,假设你就是CPU,你正在看书(执行任务),突然间你的鼻涕流下来了(一个NMI中断),这个自然是不可以屏蔽的,不然会流到嘴里的…(好恶心),你现在把书反着扣在桌子上避免找不到页码(保留当前执行现场),取出纸巾…(此处省略几十个字),OK,你处理完后把书拿起来继续看(恢复之前的执行现场)。这就是一个中断的处理过程,其实很简单是不是?这是不可屏蔽中断,那么可屏蔽的呢?还是刚刚那个场景,你在看书,手机响了(一个INTR中断),但是你在学习期间不想被打扰,就无视它了…这就是可屏蔽中断了。

通俗的例子举完了,我们还是专业一点好了。在x86PC中,我们熟知的中断控制芯片就是8259了,它就是我们说的中断控制器了。Intel的处理器允许256个中断,中断号范围是0~255。8259芯片负责15个,但是并不固定中断号,允许通过IO端口设置以避免冲突。所以,它的全称是可编程中断控制器(Programmable Interrupt Controller,PIC)。关于8259的资料网上铺天盖地的,至于8259的结构,如何屏蔽中断什么的我就不多说了,请读者自行了解。

其实从上面的描述中我们基本上能理解中断的概念了。再简单说就是硬件发生了某个事件后告诉中断控制器,中断控制器汇报给CPU,CPU从中断控制器处得知了中断号,根据这个中断号找到对应的中断处理程序并转移过去执行,完成后重新回到之前的执行流程去。

至于实模式下的中断处理,我简单说下吧。既然Intel支持256个中断,理论上就需要256段对应的中断处理程序了,至于它们放在哪里并不重要,重要的是如何找到入口。实模式下很简单,实模式下一个地址由段地址+偏移地址构成,一个函数入口地址正好就是4字节,256个函数的地址数组就是1KB了。CPU要求直接把中断处理函数的地址从0~255按顺序放置在物理内存地址的0x00000~0x003ff,占据了内存最前面的1KB。就这么简单,CPU可以简单的通过一个中断号码乘以4找到相应的处理函数的地址并执行了。

我们之前一直说的都是硬件中断,其实除了硬件中断之外还有软件中断,也就是软件系统也可以利用中断机制来完成一些任务,比如有些OS的系统调用的实现就采用了中断的方式。

我们的重点是保护模式下的中断处理。中断处理程序是运行在ring0层的,这就意味着中断处理程序拥有着系统的全部权限,那么我们就不能简单的像实模式下类似函数指针数组这样的方式了。仿照内存段描述符表的思路,Intel设置了一个叫做中断描述符表(IDT, Interrupt Descriptor Table)的东西,和段描述符表一样放置在主存中,类似地,也有一个中断描述符表寄存器(IDTR)记录这个表的起始地址。我们给出一张IA32平台上的中断编号的定义表。

这是0号到19号中断,20~31号中断Intel保留了,32~255号中断留给了用户去定义和使用。在Linux系统下我们可以查阅当前的中断定义和映射表,方法是查看/proc/interrupts 文件即可,如图所示:

至于保护模式下的中断描述符的结构,分类等等细节问题,我就不多说了。本文的定位就是对中断概念的科普,写到这里也算是完成使命了。接下来怎么继续研究呢?我推荐Intel的CPU开发第三卷的中断章节,《深入理解计算机系统》第8章—异常控制流,《x86汇编—实模式到保护模式》最后一章供大家参考。

相信有了本文的简单介绍,对大家研究上面基本书的相关章节会有所帮助。而且你会发现本文为了通俗易懂,简化了很多的东西,甚至有的地方严格说是有问题的。不管怎么样,本文权当抛砖引玉,路还很长,我们一起走吧。

 

保护模式汇编系列之三 - 段页式内存管理(一)

这是本系列第三篇了,我们这次来谈谈x86的段页式内存管理。这篇文章的定位是阐述分段分页的来历和要解决的问题。需要阐述细节的地方,我会贴出相关的文档和代码。

首先,如果我这个标题让你觉得段页式是一种方式而且密不可分的话,那我先说声抱歉了。其实分段和分页没什么必然联系。只不过Intel从8086开始,其制造的CPU就以段地址+偏移地址的方式来访问内存。后来要兼容以前的CPU,Intel不得不一直保留着这个传统。分段可以说是Intel的CPU一直保持着的一种机制,而分页只是保护模式下的一种内存管理策略。不过想开启分页机制,CPU就必须工作在保护模式,而工作在保护模式时候可以不开启分页。

关于保护模式的段机制我们在系列一里面已经谈过不少,而且我们也谈过“绕过”分段的平坦模式。那么,我们下文的重点就是谈谈在设置平坦模式的环境之后,进行内存分页管理的问题了。光说不练是假把式,这次我们就贴上来一些代码具体感受一下吧。

首先是设置全局段描述符表。我们给出全局段描述符表和全局描述符表寄存器的结构体定义:

注意结构体定义后面的那个 __attribute__((packed)) 很重要,这是GCC的扩展,用来设置该结构体不进行字节对齐。什么?你不知道什么是字节对齐?那么你先去谷歌一下再回来接着看吧。

好了,为了方便和Intel的文档比对,我们贴出相关的定义参照着看吧。

我们再贴出GDTR的定义:

这样对比着结构体的定义很清楚吧?需要注意的是我们把段描述符表的部分以二进制位来表示的设置信息合并到了相应的字节里,这里按照位域去定义不是不可以,但是太过于臃肿了,而且等我贴出设置一个段描述符的函数时,你就觉得其实这样做更清晰。

我们给出全局描述符表的定义以及设置一项描述符的函数实现:

怎么样?几张图片对比着看很容易就理解了吧?那么具体的初始化函数呢?别急,接下来就是:

这里唯一麻烦的就是需要对照着Intel文档的说明,去为每一个段描述符计算权限位的数值了。不过细心的你肯定发现了最后有一个加载全局描述附表的函数,这个函数用汇编来实现了。代码如下:

因为对具体寄存器的操作超过了C语言的能力范围,与其内联汇编还不如直接用用汇编实现简单(我们采用的汇编编译器是nasm)。

我想这个汇编函数中唯一需要解释的就是jmp跳转那一句了,首先0x08是我们跳转目标段的段选择子(这个不陌生吧?),其对应段描述符第2项。后面的跳转目标标号可能会让你诧异,因为它就是下一行代码。这是为何?当然有深意了,第一,Intel不允许直接修改段寄存器cs的值,我们只好这样通过这种方式更新cs段寄存器;第二,x86以后CPU所增加的指令流水线和高速缓存可能会在新的全局描述符表加载之后依旧保持之前的缓存,那么修改GDTR之后最安全的做法就是立即清空流水线和更新高速缓存。说的这么牛逼的样子,其实只要一句jmp跳转就能强迫CPU自动更新了,很简单吧?

到这里段描述符表的创建就告一段落了,其实我们完全可以直接计算出这些段具体的表示数值然后硬编码进去,但是出于学习的目的,我们还是写了这些函数进行处理。当然了,我们没有谈及一些具体的描述符细节问题,因为Intel文档的描述都很详细。

接下来我们来聊分页吧。我们先给出CPU在保护模式下分页未开启和分页开启的不同状态时,MMU组件处理地址的流程。

如果没有开启分页:

逻辑地址->段机制处理->线性地址=物理地址

如果开启分页:

逻辑地址->段机制处理->线性地址->页机制处理->物理地址

因为我们采用了平坦模式,所以给出的访问地址实际上已经是线性地址了(段基址为0),那么剩下的问题就是所谓的页机制处理了。

时间关系,页机制的细节我们下次再说。如果你已经迫不及待想知道了,那就先去谷歌看看吧。我们下期再见~

保护模式汇编系列之四 - 段页式内存管理(二)

我们这次接着内存分页继续说。稍微插一句,虽然本系列的名字叫做保护模式汇编,可是到现在颇有些挂羊头卖狗肉的意味。我们只是在一个劲的谈理论,就连仅有的一点代码也是用C语言描述的,而不是汇编。不过我觉得这不是关键,我觉得只要我们掌握了理论就好,至于用什么语言描述都是次要的,你说呢?

言归正传,我们开始说分页机制。长时间以来,随着计算机技术的发展,存储器的容量在不断的高速增加着。但是说起内存(这里指RAM,下同)这个东西,它有一个很奇葩的特性,就是无论它有多大,都总是不够用(P.S.厨房的垃圾桶也一样)。现在我们看似拥有着以前的程序员想都不敢想的“天文数字”的内存,动辄就是几G十几G的。但是相信我,历史总是嘲弄人的。就像当年程序员们质疑32位地址线带来的4GB空间太大没有意义似的,我们也会有一天抱怨现在的内存太小的。

那么,既然内存总是不够用的,那内存不够用了怎么办?还有,使用过程中出现的内存碎片怎么办?假设我们有4GB的物理内存,现在有1、2、3、4一共4个程序分别各占据连续的1G内存,然后2、4退出,此时我们拥有着空闲的两段内存,却连一个稍大于1GB的程序都无法载入了。

当然了,这只是一个例子。不过按照一般的思路,在内存释放之后,我们如何回收呢?做碎片整理吗?即便我们不在乎整理过程带来的效率损失,光是程序加载时候的地址逐一重定位就是及其麻烦的。那怎么办?当然了,解决的办法是有的,聪明的计算机工程师们想到了采用分页的方式来管理物理内存。他们在逻辑上把内存划分为定长的物理页,同时将一个程序执行时候的线性地址地址空间划分为逻辑页,在分页机制工作的前提下,给硬件提供一组数据结构来保存这种映射关系。也就是说,线性地址是连续的,但是其实际指向的物理地址就不见得是连续的了。别忘了,RAM是随机存储器,读取任意一个地址的理论时间都是一样的(暂时让我们忘了cache吧…)。我们让CPU在寻址的时候,自动的去查找线性地址到物理地址的映射关系,从而找到实际的数据就好。严格说地址翻译是由MMU组件来进行的,但是现在MMU一般都是CPU的一个组成部分了,所以我们也不严格区分了。

是不是文字读的有点混乱?我们来张图看看(网上找不到顺心的,用WPS表格画了一张,顺便推荐WPS for Linux,真心不错)。

一图胜千言,我们看到了固定大小的物理页、虚拟页、甚至还有磁盘页。我觉得这张图片很能说明问题了,我相信聪明的你从这里都悟出来了虚拟内存的实现原理了。没错,虚拟内存实质上就是把物理内存中暂时用不到的内容暂时换出到外存里,空出内存放置现阶段需要的数据。至于替换的策略当然有相应的算法了,比如最先换入原则,最少使用原则等等方法可以使用。

相信通过上文的描述,我们对分页已经建立了初步的理解了。那么接下来的问题是,怎么表示和存储这个映射关系。这里描述起来简单,但是代码就不是那么直观了,原因很简单,因为我们需要一组数据结构来管理内存,但是这组数据结构本身也得放在内存里。所以牵扯到一个自己管理自己的问题。而且,开启分页模式之后,CPU立即就会按照分页模式的规则去解释线性地址了。所以,这意味着我们必须先建立好地址映射的数据结构,才能开启分页,而且我们必须保证之前的代码地址和数据地址都能映射正确。

其实这篇我是不准备贴实际的代码的,因为怕把大家绕晕了。本篇虽然一直都是围绕着x86在说,但是截至目前我们的描述都是不针对某一个具体CPU而言的,所以上述概念是通用的。

下面我们来说说x86下的一种简单的做法吧。我们以32位为例。为什么不是64位?一是因为复杂;二呢?我暂时还不懂64位…哈哈。

在32位操作系统下我们使用32位地址总线(暂时原谅我在这里错误的描述吧,其实还有PAE这个东西),所以我们的寻址空间有2^32,也就是4GB。一定要注意,我们强调了很多次了,这个空间里,有一些断断续续的地址实际上是指向了其它的外设,不过大部分还是指向RAM的。我们采取的分页大小可以有多种选择,但是过于小的分页会造成管理结构太大,过于大的分页又浪费内存。现在较为常见的分页是4KB一个页,也就是4096字节一个页。我们简单计算下,4GB的内存分成4KB一个的页,那就是1MB个页,没错吧?每个虚拟页到物理页的映射需要4个字节来存储的话(别忘了前提是32位环境下),整个4GB空间的映射需要4MB的数据结构来存储。

目前看起来一切都很好,4MB似乎也不是很大。但是,这只是一个虚拟地址空间的映射啊,别忘了每个进程都有自己的映射,而且操作系统中通常有N个进程在运行。这样的话,假如有100个进程在运行,就需要400MB的内存来存储管理信息!这…太浪费了…

怎么办?聪明的工程师们提出了分级页表的实现策略,他们提出了页目录,页表的概念。以32位的地址来说,分为3段来寻址,分别是地址的低12位,中间10位和高10位。高10位表示当前地址项在页目录中的偏移,最终偏移处指向对应的页表,中间10位是当前地址在该页表中的偏移,我们按照这个偏移就能查出来最终指向的物理页了,最低的12位表示当前地址在该物理页中的偏移。就这样,我们就实现了分级页表。我们来张图看看:

也许你已经算出来了,这样做的话映射4GB地址空间需要4MB+4KB的内存。我们这是搬起石头砸了自己的脚吗?当然不是,因为在一个进程中,实际使用到的内存大都远没有4GB这么大,所以通过两级页表的映射,我们就可以只映射需要的地址就可以了,是不是节省了内存呢?概念我们暂时就说到这里,更专业的描述和规范请参阅Intel文档,也就是上面那个图的出处。

好了,也许这是本系列最后一篇了。可能你会说保护模式的东西还多着啊,这才哪到哪啊。没错,这仅仅是保护模式的冰山一脚。结束本系列的原因是有新的系列要开始了,而且我觉得这个新的系列听起来更酷更有意思,也更有实践性。什么系列呢?这个系列我们讨论自己写一个小的“操作系统”。怎么样,听起来是不是很酷很好玩?话是这么说没错,但是我们所谓的“操作系统”可能仅仅只是一个Demo,一个x86硬件原理演示性质的东西罢了。但是,它却能帮助我们理解和掌握更多深层次的东西。我们不仅仅满足虚拟机上自娱自乐,最后我们还要让它跑在物理机器上来展示最终的效果。

你是不是已经迫不及待了呢?别着急,容我整理下思路先…

不会太久的,敬请期待。

 

posted @ 2021-10-31 18:45  CharyGao  阅读(45)  评论(0)    收藏  举报