一个程序是如何运行的?这看起来是个简单的问题,实际上涉及到了许多复杂的内容:
参考书籍:
- 《x86汇编语言-从实模式到保护模式》作为入门
- 《汇编语言》 王爽, 作为x86-16的详细学习
- 《计算机组成原理》
此处我们主要考虑的是8086这一经典的CPU型号,以及80386包含了分页功能的CPU型号。
为了从原理角度理解CPU的运行,首先以8086为例进行说明,而先不考虑更加高级的分页内存管理方式。(此处可以参考王爽《汇编语言》)
在逻辑角度上来看,CPU要想进行计算,需要具备什么样的功能呢?
i) 首先需要有计算能力(当然该能力首先就默认CPU具有数据存储的能力)。
ii) 第二应该具有数据传输能力,用于和内存之间进行数据的传递;
iii) 还应该有控制线,即控制下一步做些什么操作;
基于以上三点需求,满足后就可以从最简单的角度上允许CPU进行计算。
那么CPU是如何逻辑划分的呢?从硬件角度上又包含什么呢?
首先引入一个概念:寄存器。
寄存器是CPU中的存储单元。其逻辑结构都相同,如8086CPU中所有寄存器都是16位的,
显然,该寄存器一次能存放一个16Bit的数据,即2^16=65536。
按照上面的需求,寄存器被分为如下几种类型:
指令寄存器能够执行的功能:
mov
add
jmp
那么有了以上几种类型的寄存器,CPU是如何执行一段内存中的指令的呢?
首先需要知道CPU访问内存地址的方式,才能将指令和数据从内存读取到CPU中。
如上面所说,CPU与内存之间有三大总线:地址总线(用于传输地址)、数据总线(用于传输数据)、控制总线(用于传输指令)。而对于CPU来说,只有知道了内存的物理地址才能进行实际的操作。
对于8086CPU来说,其采用的是分段式处理方式。CPU中的相关部件提供了两个16位的地址,一个是段地址,一个时段内偏移地址。将段地址和偏移进行移位相加(*16),可以获得实际的物理地址。
其实这是一种通用的寻址方法。即:
物理地址 = 基础地址 + 偏移地址
问题:段地址如何给出?
分段的意义?
无论数据还是指令,都是一些数字。而区分开来便有了意义。
对于指令,从人为定义的角度上规定好了某些数字的含义,这便是指令集。
CPU中寄存器的数目与种类?
从硬件角度上来看,CPU是什么样子的?
问:为什么设计者将寄存器进行分类,而不和内存一样采取通用的存储方式?
答:可能是角色不同:CPU需要运算,而运算的时候,其步骤可以被整理为通用的步骤,因此将其进行预先定义,有利于实现。而内存只是为了存储数据,因此无需(也不能)进行预先定义某个内存地址的作用。
是通过总线来实现的。分为三种类型的总线:
在x86-16体系中,为了解决16位寄存器对20位地址线的寻址问题,引入了分段式内存管理。
而CPU则使用CS,DS,ES,SS等寄存器来保存程序的段首地址。当CPU执行指令需要访问内存时,只会送出段内的偏移地址,而通过指令的类型类确定访问那一个段寄存器。
在x86-32体系中,为了向上兼容,保留了分段内存管理。
CS:IP结构:
分段内存管理的优势在于内存共享和安全控制,而分页内存管理的优势在于提高内利用率。他们之间并不是相互对立的竞争关系,而是可以相互补充的。也就是可以把2种方式结合起来,也就是目前计算机中最普遍采用的段页式内存管理。段页式管理的核心就是对内存进行分段,对每个段进行分页。这样在拥有了分段的优势的同时,可以更加合理的使用内存的物理页。
地址转换的过程。实际上就是我们前面介绍的分段和分页地址转换的结合。
随着计算机CPU的发展, 以及更多的进程需要更多, 更复杂的内存需求, 当一个程序没有空间可用就不好了, 另外内存还很容易被破坏, 从而造成很多不可预知的, 难以定位的问题. 因此操作系统发展出了一种对主存的抽象概念, 叫做虚拟内存.
虚拟内存是硬件异常, 硬件地址翻译, 主存, 磁盘文件, 和内核软件的完美交互. 它为每个进程提供了一个大的, 一致的, 和私有的地址空间.
这样, 多个进程间相互不受影响(当然也引入了进程间通信的一个课题)从而不会破坏其他进程的内存.
虚拟内存还将主存看成一个存储在磁盘上的地址空间的高速缓存, 在主存中只保存活动区域, 并根据需要在磁盘和主存之间来回传送数据, 通过这种方式, 可以高效地使用主存.
内存由M个连续的单个字节大小的单元组成的数组, 每个字节都有一个唯一的物理地址(Physical Address), 为0,1,2,…,M-1依次排布.
可以理解, 从本质上来讲, CPU只有获得内存的物理地址,才能通过寄存器与CPU进行数据交换. (下图参考[1] p560)
早期PC都是使用物理寻址.
使用虚拟寻址的话, CPU通过生成一个虚拟地址(Virtual Address)来访问主存, 这个虚拟地址在被送到内存之前先转换为对应的物理地址, 这个过程被称为地址翻译(Address Translation). 地址翻译这一过程通过一个被称为内存管理单元(Memory Management Unit)的专用硬件完成, 利用存放在主存中的查询表(即页表)
来动态翻译虚拟地址. 该表的内容由操作系统管理… (下图参考[1] p560)
对于虚拟内存来说, 虚拟内存被分割为大小固定的页(Page), 物理内存也被分割为大小固定的块(被称为页框(PageFrame)).从而两者之间可以有三种关系:
此处理解得再深入一点, 所谓的"虚拟内存" 实际上就是指磁盘, 因为它被虚拟化为内存.
参考 [1],p563
PTE: Page Table Entry.
每次地址翻译硬件将一个虚拟地址转换为一个物理地址时,都会读取页表.
此处所讲的"命中" 实际上就是缺页的本质: 当命中的时候, 便意味着已经将磁盘中的某个页框读到了内存中, 即物理内存中缓存了这个磁盘页框, 从而使缓存命中.
[1] p564
此处以DRAM缓存命中和缓存不命中(缺页)两个可能性来分析MMU的工作过程.
如上图所示, 假设CPU的读请求可以缓存命中, 则其流程是这样的:
页面命中完全由硬件处理. 与之不同的是, 处理缺页要求硬件和操作系统内核协作完成.如b)所示:
4… PTE中的有效位是0, 所以MMU触发一次异常, 传递CPU中的控制到操作系统内核中的缺页异常处理程序;
5… 缺页处理程序确定出物理内存中的牺牲页, 如果这个页面已经被修改了, 则把它换出到磁盘.
6… 缺页处理程序页面调入新的页面, 并更新内存中的PTE;
7… 缺页处理程序返回到原来的进程, 再次执行导致缺页的命令. CPU将之前引起缺页的虚拟地址重新发送给MMU. 因为虚拟页面现在缓存在物理内存中, 所以就会命中. 在MMU执行了图9-13b中的步骤后, 主存就会将所请求的字返回给CPU.
为什么需要多级页表:
之前的一级页表设计中, 有一个缺陷: 对于给定的内存规格, 一开始就需要全部申请好资源用于管理. 比如一个32位的地址空间, 4KB的页面和一个4字节的PTE, 那么即使我们只使用了其地址空间中的一小部分, 我们也需要整个4MB大小的页表占用内存资源.(计算方式: 2 ^ 32 B / 4KB * 4B ).
这还是只针对32位来说的. 针对64位的则更加复杂. 因此可以想到的方法就是建立多级页表, 从粗粒度到细粒度来多级协同管理.
此处的方法是建立一个二级页表. 第一级页表中的每个PTE负责映射虚拟地址空间中的一个4MB的片(chunk), 这里每一片都是有1024个连续的页面组成的.假设地址空间是4GB, 那么1024个PTE已经足够覆盖整个空间了.
而细粒度的二级页表中的每个PTE则负责映射一个4KB的虚拟内存页面.
这种方法减少了内存浪费: 加入一级页表中的某个PTE是空的, 则其二级页表就不存在, 也就不会占用内存资源.
寄存器是CPU的重要组成部分,用于在CPU中存储数据。
按照CPU位数的不同,一个寄存器能够存放的数据量也不同。以8086为例,寄存器为16位,因此一次只能存放16bit的数据。(因此对于20bit的物理内存地址寻址需求的话,就需要进行合并操作)。
按照需求,寄存器有很多中类型。
CPU是怎样从内存获得数据、存储数据、计算数据、输出数据到内存呢?搞明白了这一点,就明白了需要什么样的寄存器。
众所周知,内存是一种线性结构。先不考虑高级的分页、虚拟内存等设计,简单地按照分段内存来考虑。一个内存地址能够表示一个字节的数据,CPU需要知道物理内存地址才能通过总线(具体什么总线?)根据物理地址将该物理地址中的数据传入到CPU中。
那么内存中是如何存储数据的呢?
代码和数据等不同类型的数据是否有区分?答案是否定的,内存被设计为一种通用的存储介质,只负责在给定的物理地址存取数据,而不会区分其是代码还是数据,还是其他的一些东西。
这些东西只有在载入CPU相应寄存器的时候才有了意义。也就是内存中相同的物理地址的数据,被载入到数据的寄存器中,他就是一个数字,被载入到指令寄存器中,他就是一个指令。
此时需要引出寄存器的种类和具体操作了。
CPU要与内存进行数据交互,首先需要解决的问题就是内存的寻址问题。众所周知CPU需要内存的物理地址,才能找到需要读写内存的哪个区域的数据。此时需要一个寄存器来保存物理内存地址。8086通过段地址和偏移地址来描述物理地址。可以看到通过段地址和偏移地址的组合,可以实现不同段地址也能获得相同物理地址的效果。
其物理地址将被存储在相应的寄存器中。
例如对于数据段,将其段地址存放在DS
中。用mov/add/sub
等访问内存单元的指令时,CPU就将我们定义的数据段中的内容当做数据来访问;
对于代码段,将其段地址存放在CS
中,将端中第一条指令的偏移地址放在IP
中,这样CPU就能执行我们定义的代码段中的指令;
对于栈段,将其段地址存放在SS
中,将栈顶的单元偏移地址存放在SP
中,这样CPU在需要进行栈操作的时候(push/pop
),就将我们定义的栈段当做栈空间来使用。
由此可见,无论我们怎样安排,CPU将内存中某段内容当做什么,都取决于我们让什么寄存器保存了该段的数据。
如下图所示,表示了几种不同的寻址方式【1】p114。寻址是整个程序运行的基础。
解决了寻址问题后,意味着可以找到想要读写的内存区域了,那么就需要一个寄存器来保存将要从内存中读取的数据。(待续)
代码要想运行,无非是需要控制和数据两个条件。即:我要对什么东西,做什么操作。下面需要考虑控制是如何收发的。
CPU的控制包括了逻辑运算、跳转操作。(待续)
。。。
栈实际上是一种通用的数据结构,并非只在CPU中使用。在解决实际问题的时候,我们经常需要来描述先进后出的情况,这就是栈的存在意义。
再思考得深入一点,为什么我们需要栈结构呢?
我们都知道,数据是存储在内存中(硬盘中的数据要想使用也必须先读取到内存中),而内存的寻址方式一般都是基础地址+偏移
的方式。对于一个结构体来说,其指针都是指向结构体的开始位置,要想访问内部的变量,需要将该指针进行移动。这是一种灵活的设计方式,可以随心所欲地进行访问,但要想描述先进后出的情况,这样的方式还是太灵活了一点。
想一想,如果有多个数据A、B、C依次被存储,然后按照C、B、A的顺序进行读取。
这里实际上涉及到了两点,一点是这个结构有了记忆功能,后面存储的值没有覆盖前面存储的值;另一点就是取出的顺序刚好和放入的顺序相反。
如果按照直接存储的方式的话,如下图所示:
| A | B | C |
创建的时候我们分别有了指向ABC起始地址的引用。要想描述ABC的位置,我们分别需要ABC三个变量的引用(我们不能保证ABC能够在内存中连续存储,否则也能按照偏移来获得其地址)。那么要想实现栈的效果,我们就需要同时使用ABC三个变量来描述。
这还是三个变量,若有一万个,也得这么做。
我们只想实现一个先进后出的效果,我们希望通过一个变量就能对栈顶元素进行插入或删除,这就是栈的来源:我们将实现隐藏到内部,对外可以通过一个变量进行实现。
回到CPU这里。
8086使用SS:SP两个寄存器来指向栈顶元素。每当Push和Pop的时候,通过SS:SP的指针来移动指向不同内存,从而实现栈的效果。
需要使用一个新的寄存器AX来存放数据,而不能直接与内存进行数据交换。
8086CPU中,入栈时,栈顶从高地址向低地址方向增长。
push、pop实际上是一种内存传送指令
在编程时,可以将一组内存单元定义为一个段(这只是一种安排,CPU并不会由于这种安排,就在执行Push、Pop等栈操作指令是就自动将我们定义的栈段当做栈空间来使用,而是需要将SS:SP指向我们定义的栈段才可以)。
内存的段是逻辑的,并非物理的。
我们可以将一段内存定义为一个段,用一个段地址表示段,用偏移地址来表示段内的单元。
一般来说,我们将一个段划分为如下几个部分:
用于存放数据。将其段地址放在DS中,用mov/add/sub等访问内存单元的指令时,CPU就将我们定义的数据段中的内容当做数据来访问。
将其段地址放在CS中,将断种第一条指令的偏移地址放在IP中,这样CPU就将执行我们定义的代码段中的指令。
将其段地址放在SS中,将栈顶单元的偏移地址放在SP中,这样CPU在执行栈操作的时候如Push/pop,就将我们定义的栈段当做栈空间来使用。
不管我们怎样安排,CPU将内存中的数据当做数据/指令、栈,全部是我们自己定义的,内存本身只是一种通用的存储介质,并不能做类型的区分。
CPU将内存中的某段内容当做代码,是因为CS:IP指向了哪里;CPU将某段内存当做栈,是因为SS:SP指向了那里。CPU将内存中的某段内容当做数据,是因为DS指向了那里。
到后面详细学习进程相关的时候就可以发现在现代的操作系统中,一个进程地址空间中将分为进程控制块、数据段、代码段、堆栈段。后面详细讨论。
编辑 -> .asm -> 编译(masm) -> .obj -> 连接(Link) -> .exe -> 加载(Command) -> 内存中的程序 -> 运行(CPU)
任何操作系统都需要对外提供一个称为shell(外壳)的程序,用户使用这个程序来操作计算机进行工作。
如果用户要执行一个程序,则输入该程序的可执行文件的名称。shell会首先根据文件名找到可执行文件,然后将这个可执行文件加入内存,设置CS:IP指向程序的入口。此后,shell会暂停运行,CPU运行程序。程序运行结束后,返回到shell中。
当然以上是简单一讲,具体还要涉及到中断等操作,将在后面逐步深化。
是由可执行文件中的描述信息指明的。可执行文件由描述信息和程序组成。程序来自于源程序中的汇编指令和定义的数据,描述信息则主要是编译、连接程序对源程序中相关伪指令进行处理所得到的信息。
我们用伪指令end
描述了程序的结束和城市的入口。在编译、连接后,由end start
指明的程序入口,被转化为一个入口地址,存储在可执行文件的描述信息中。当程序被加载入内存之后,加载者从程序的可执行文件的描述信息中读到程序的入口地址,设置CS:IP,这样CPU就从我们希望的地址中开始执行。
如下图所示:
可通过手动创建数据的方式来实现申请内存的效果,然后将其当做一个栈空间。
疑问:程序如何自动使用栈?
通过汇编来定义不同的段,使用不同的段名做区分。而通过段名就可以获得段地址,从而可以对其进行引用。段内位置即指定即可。
而对于数据、代码和栈的定义只是逻辑定义,此处写出并非CPU就当其为相应的数据结构了,而是通过指定其地址到相应寄存器来实现的。
##### 指令的转移:call和return
进程需要解决什么问题
List item
一种通用CPU都需要具有的能力,可以在执行完当前正在执行的指令之后,检测到从CPU外部发送过来的或内部产生的一种特殊信息,并且立即对所接收到的信息进行处理。这种特殊的信息被称为中断信息。
在中断产生之前,CPU就是按照程序中所编号的代码来执行的。将程序载入内存,设置相关寄存器指向代码,依次执行。
按照产生的起源,中断分为内中断与外中断。
既然类型有很多种,就需要一张表来表示。此处引入中断向量表
的概念。
中断向量表就是中断向量的列表。什么是中断向量呢?实际上就是中断处理程序的入口地址。可以通过该地址来找到中断处理程序。
中断向量表在内存的固定位置保存(8086在0000:0000到0000:03FF保存),存放了256个中断源所对应的中断处理程序的入口(即起始地址)。CPU只需知道中断码,就能通过中断向量表的相应表项来得到中断处理程序。
TODO:
中断向量表图示:
此处便是指:如何收到中断信息。
如上文所述,CPU之所以能够知道内存中哪一部分数据为程序,是因为我们让CS:IP寄存器指向了保存程序的内存地址。同理,要实现中断,实际上我们要做的也是将CS:IP指向中断处理程序的入口。一旦这样,CPU就开始执行中断处理程序。
因为在处理完中断程序后我们还需要还原之前的程序,因此我们需要将中断前的现场保留下来。需要保存什么才能还原一个程序的现场呢?实际上就是各种寄存器指针。
我们通过栈来保存。(终于用上CPU的栈了,详情请见上文)
具体流程如下所示:
[1] 深入理解计算机系统(第三版)