当前位置: 首页 > 科技观察

吃透操作系统:CPU和实模式

时间:2023-03-13 20:21:10 科技观察

对于人类来说,我们不喜欢拐弯抹角,我们喜欢更直接的东西,“直说”、“没有中间商赚取差价”、“简单设计”"等等,但对于计算机,尤其是内存管理,情况恰恰相反。在这里,“干净”的设计往往不是好的设计。这是什么意思?我们在前面的一篇文章中提到,内存从本质上会很简单,你可以把它想象成一个小盒子,每个小盒子可以存储1或者0,每8个小盒子组成一个字节(8位),每个字节都有一个唯一的地址,通过它我们可以从相应的一组小盒子中取出位。剩下的都没了。看,内存本身其实很简单,但是程序员和程序使用内存的方式却让这个问题变得更加复杂。分析任何复杂问题,都要抓住重点,抓住核心问题。那么这里的重点和核心是什么?没什么秘密,这里的核心在于两个词:addressing,Addressing。一切都围绕寻址展开。寻址,最重要的是寻址什么是寻址Addressing?所谓寻址就是在内存中找到我们需要的某个数据的方法。即使我们平时去储物柜取东西,也有很多“寻址”的方法:直接告诉我们一个号码,我们拿到号码就可以找到,就像下图,我们需要在里面找东西15号1号储物柜,那么我们可以根据15的地址找到15号储物柜。当然我们也可以将储物柜划分区域。以刚才的储物柜为例,我们可以将其分为3个区域。当我们需要找东西的时候,告诉我们它在储物柜的哪个区域,就在这个区域。什么是“抵消”。下图就是一个例子。我们需要的东西在第二区,区内偏移量为6(本区第6个储物柜)。其实第一种更像是“绝对寻址”。这是什么意思?这意味着寻找特定的储物柜是基于非常严格的“硬编码”。第二种更像是Relativeaddressing,稍微灵活一些。怎么样,是不是觉得这两种方式其实没有什么区别?确实,对于找储物柜的例子,这两种方法没有什么区别,但是对于记忆来说就不一样了。Rigidvsflexible我们知道程序和程序使用的数据都是编译后存放在磁盘上,运行时加载到内存中,所以这里也有一个寻址问题:我们需要根据机器指令和数据来查找到内存地址。接下来,假设有一个只有8字节内存和2字节机器指令的程序(无需关心实际含义):这2字节的代码很简单,实际上就是一个无意义的while循环,pay注意这里的jmp指令,我们直接跳转到内存地址2,这是一个硬编码的内存地址,也就是说我们必须把程序加载到内存地址为2的位置:否则这条指令根本不存在有没办法运行,比如我们把这段代码加载到内存地址6:那么我们在执行jmp2的时候就没办法跳转到add指令了,有的同学可能会觉得无所谓,是不是因为内存地址被写死?好像没什么大不了的。如果您一次只能运行一个程序,那很好,但它根本不适用于操作系统的核心功能之一:多任务处理,或一次运行多个程序的能力。在这种方案下,你几乎没有办法同时运行多个程序,除非你在运行前定义程序运行的区域,比如你要运行两个程序A和B,A占用0区~3内存;B占用4~6块内存。对于现代程序员来说,你能想象程序运行前需要划定区域吗?显然,这是非常麻烦且容易出错的。如果你在1960年代和70年代写过代码,你可能会遇到这样的情况。其实,这个问题的核心在于搬迁。程序使用的地址不能绑定到内存区域。它需要足够灵活。我们需要在不修改代码的情况下加载程序在任意内存区域运行!想想如何解决这个问题。作为一名程序员,您一定与文件路径打过交道。如果能理解绝对路径和相对路径,就可以解决重定位问题。绝对路径和相对路径想想绝对地址有什么问题?这个问题就好比当你在程序中读取一个绝对地址:/user/xiaofeng/doc/a.c如果是自己的电脑是没有问题的,但是如果这个程序运行在别人的电脑上就没有必要了,因为别人的电脑不一定有这个路径。这个时候怎么办?聪明的你一定知道,那就不要用绝对路径,而要用相对路径就是这样:./a.c其中./表示程序运行的路径。此时无论程序运行在哪个路径下,都可以找到a.c这个文件,此时它所在的目录就成为基准。解决搬迁问题也是如此。在编程生成可执行程序时,不再使用绝对内存地址,而是使用相对地址。如何使用相对地址?相对于谁?很简单,相对于程序加载到内存的起始地址。这时候我们的jmp命令就不再是内存的绝对地址了,而是相对地址:0,但是毕竟对内存发出读写命令的时候必须要用到内存地址,那么当CPU执行jmp0内存地址呢?很简单,因为这个程序加载到内存起始地址2,所以只需要相对地址加上起始地址就可以得到真正的物理内存地址:物理地址=起始地址+相对地址,很简单,所以不管这个程序加载到哪个内存区,只要知道起始地址,我们总能计算出真正的物理内存地址,这样就可以解决重定位问题。实际上,您会发现解决此储物柜的第二种方法也没有任何区别。分段内存管理我们知道,程序的内存可以分为存放机器指令的代码区、存放全局变量的数据区、存放函数运行时信息的堆栈区等,显然我们可以将程序进行划分根据这个进行分段管理,在段中使用相对地址,这样无论这些段被加载到内存的哪个区域,我们都可以很方便的计算出正确的物理内存地址。我们把内存中每个段的起始地址放到一个专用的寄存器中。X86CPU中有几个段寄存器,CS、DS、SS、ES。这些寄存器有什么用?这些寄存器是用来存放内存中各个段的起始地址的(先理解这个,后面你会发现这些寄存器的真正用途):存放机器指令的区域,这个区域就是我们所说的代码段(CodeSegment),所以我们可以用一个寄存器来具体指向代码段,这就是CS寄存器的作用,CS也是CodeSegment的缩写。同理,程序运行后,有一个专门的区域用来存放数据,所以必须有一个专门的寄存器指向数据段(DataSegment),这就是DS寄存器的作用,DS是数据段。程序运行后,有一个运行时栈(StackSegment),所以SS寄存器可以用来指向程序员的运行时栈。SS是StackSegment的缩写。还有ES寄存器,ExtraSegment,用作临时段寄存器。除了内存分段管理之外,我们的程序可以对任意内存区域进行读写,可能有些同学并不关心,那又如何呢?没有内存保护会怎样?到目前为止,这个问题仍然困扰着多线程编程的程序员,因为同一个进程中的线程共享同一个地址空间,这意味着你的线程可以修改地址空间中的任何可写区域,包括栈区和堆区。当然,这也意味着其他线程可以修改你线程使用的数据,这就是多线程中一大类bug的根源。而当内存地址没有被保护时这个问题就更严重了,因为它不是一个进程而是包括操作系统在内的多个进程共享同一个物理内存地址,任何进程都可以修改内存中的任何位置,你的进程就可以破坏其他进程使用的内存,它可以破坏操作系统使用的内存,如果破坏了其他进程,重启这个进程是大不了的,但是如果破坏了操作系统,就没有办法了。这时候只能重启电脑了。如果CPU不提供内存保护机制,那么操作系统连自己都保护不了,更别说保护其他进程了。没想到,看似简单直接的内存读写,竟然出现这么多问题。RealMode好了,我们先总结一下。绝对内存地址不容易使用。这些地址必须将程序加载到内存中的特定位置。为了解决这个问题,使用了相对地址。在x86中,每个程序区都配备了一个专门的寄存器来存放段在内存中的起始地址,这样就可以根据基地址加上偏移量计算出物理内存地址。注意这里计算的是真正的物理内存地址。内存读写没有任何保护,程序可以读写内存的任意区域。其实这就是以前的内存管理方式,很直接很原始。x86CPU把这种原始的内存管理方式称为realmode,realmode,这种模式也叫realaddressmode。顾名思义,我们在程序中看到的是真实的物理内存地址。事实证明,早期的x86CPU可以访问的最大内存限制为1MB(2^20字节)。您可能认为可用内存太少。对于今天的程序员或者用户来说,1MB几乎什么都做不了。连一首歌都存不下,但在80年代,1MB的内存是一个极其广阔的空间,以至于80年代的比尔·盖茨说:640k应该对任何人都足够了,对大多数人来说640K内存就足够了。另外,更难的是早期的x86CPU寄存器只有16位。16位寄存器无法访问整个1MB内存。16位寄存器最多可以访问64K的内存。访问1MB内存,内存地址需要20位,而寄存器本身是16位,根本放不下,怎么办?很简单,一个寄存器不够我们就用两个寄存器,第一个寄存器叫selector,说白了其实就是存放lockerareaNumber,所以也叫segmentregister,segmentregister,不管是不是称为区域或段本质上是相同的。第二个寄存器称为偏移量。说白了就是区域内的数或者区域内的偏移量。这样,真正的内存地址由两部分组成:selector:offset。此时内存地址的计算方法如下:16?selector+offset此时给定一个段寄存器和一个偏移量,我们可以直接在内存中找到需要的数据:所以这里计算出的内存地址就是物理内存地址。另外,在实模式下,CPU不提供内存保护机制,程序可以随意读写任意内存区域,甚至操作系统所在区域的其他程序也可以读写。现在我们可以总结一下早期x86处理器的特点:寻址空间有限,只有1MB使用selector:offset使用两个16位寄存器寻址1MB内存,没有内存保护机制,当然也没有内存保护机制。优点是内存读写速度更快,原因是不需要经过虚拟内存地址到物理内存地址的转换,也不需要进行任何检查(这可能是实模式下的唯一优势)。在80286之前,所有的x86CPU都运行在实模式下,并且为了向后兼容,即使是现代的x86在复位(power-on)后也会先进入实模式,然后跳转到保护模式(protectedmode),关于保护我们会解释后续文章中的模式。实模式与操作系统实模式是x86系列处理器最早的内存管理模式。这一时期的操作系统只能运行在这种模式下。早期的DOS系统和早期的MicrosoftWindows操作系统都是以实模式运行的。实模式虽然容易理解,但这种模式的主要问题是物理内存暴露给程序,没有内存保护机制。两者结合的结果是程序不受限制。程序员知道我们写的代码充满了错误。在现代操作系统中,程序很容易挂掉自己,但在早期的操作系统中,程序很容易挂掉整个系统。为了解决这个问题,x86CPU开始引入80286.mode保护,后续文章会详细讲解。虽然现代操作系统(Windows、Linux)等早已不再以实模式运行,但实模式仍然保留了下来。你可能会疑惑为什么x86CPU还需要保留实模式?我们都知道代码有座屎山,其实历史悠久的x86也有类似的问题。CPU硬件和软件也在不断进化,从16位实模式到32位保护模式再到现代64位处理器,但是早期程序员围绕16位实模式x86CPU写了很多软件,当CPU发展起来的时候到32位保护模式,基于16位实模式写的软件怎么办?不支持吗?如果不支持,只有两种可能:1)用户不再购买不兼容16位软件的CPU2)改写代码,很可能程序员不会改写,intel也是非常懂时事,所以实模式在后来的32位乃至现代的64位处理器上仍然保留着,x86系列处理器在复位时,会先进入实模式。对于不使用实模式的现代操作系统,它会在简单的初始化工作后跳转到保护模式。因此,我们可以看到,真实的模式仍然存在,就像最初的进化基因一样,就像动物胚胎有鳃一样,只是过程一闪而过。实模式在计算机启动阶段也会快速闪烁。这种古老的记忆管理方式至今仍留下印记。总结实模式是一种非常古老的内存管理方式。这种方式,程序员直接面对物理内存,处理器不提供内存读写机制。程序员可以读写任何内存区域。其实实模式对于现代操作系统来说几乎没什么用,但是如果你写一个针对x86CPU的操作系统,实模式是必须了解的,但是对于其他的CPU,就没有这样的历史包袱了,所以有很多操作系统的教学资料开始基于非X86平台进行讲解,这样可以更快速地讲解操作系统,而不是一开始就在各种内存模式中打转。注意,本文中提到的实模式仅适用于x86系列处理器。对于大多数上层应用程序的程序员来说,根本不需要关心实模式。然而,技术就像生物学一样不断发展。只有了解过去才能更好地了解现在和未来。