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

从零开始学习Linux02:x86中内存[段寻址]的来龙去脉

时间:2023-03-12 19:26:29 科技观察

什么是代码段?什么是数据段?数据类型和长度寻址范围栈实模式和保护模式Linux中的分段策略都是一嘴巴,计算机也是一步步发展的。例如,下面是Intel的CPU型号的发展历程:为了发挥性能越来越强大的计算机的优势,操作系统逐渐变得越来越复杂。为了从最底层学习操作系统的一些基本原理,我们只能抛开操作系统的外衣,从最原始的硬件和编程方法入手,学习一些基础知识。在这篇文章中,我们将继续深入探讨先驱处理器8086是如何使用段机制来寻址内存的。什么是代码段?上一篇:Linux从零开始01:CPU是如何执行一条指令的?前面提到了处理器内部,在执行每条指令代码的时候,CPU是很机械的,很简单的从CS:IP这两个寄存器计算出转换后的物理地址,从这个指向的内存地址读取一定长度的指令物理地址,然后交给算术逻辑单元(ALU)实现。物理地址的计算方法为:CS*16+IP。CPU在读取一条指令时,可以根据指令操作码自动知道这条指令需要读取多少字节。指令被读取后,IP寄存器的内容会递增,指向下一条指令在内存中的地址。比如内存20000H的开头,有2条指令:movax,1122Hmovbx,3344H当第一条指令执行时,CS=2000H,IP=0000H,地址转换后的物理地址为:2000H*16+0000=20000H(乘以16表示十六进制数左移1位):当第一条指令码B82211这3个字节被读取时,IP寄存器中的内容会自动增加3`,从而指向下一条指令:当读取到第2条指令码BB4433的3个字节时,IP寄存器的内容加3,变为0006H。正如上一篇文章所写,CPU只是反复从CS:IP指向的内存地址读取指令代码,执行指令,读取指令代码,再次执行指令。可见,为了完成一项有意义的工作,必须将所有的指令代码聚集在一起,放在内存中的某个地址空间中,以便CPU依次读取和执行。内存中的这段地址空间称为段,由于代码编译出的指令都存放在这个段中,所以也称为代码段。因此,用来寻址代码段的CS和IP这两个寄存器的含义就很明确了:内存中的首地址,或称基地址;IP:指令指针寄存器,表示一条指令的地址,相对于基地址的偏移量,即IP寄存器用来帮助CPU记住:哪些指令已经处理完了,哪些是下一条要处理的指令;什么是数据段?对于一个有意义的程序,仅靠指令是不够的,还必须对数据进行操作。这些数据也应该放在内存中的某个地址空间中。这个地址空间也是一个段,称为数据段。也就是说:代码段和数据段是内存中的两个地址空间,分别存放指令和数据。你可以想象一下:如果指令和数据不是分开存放,而是混合在一起存储,那么当CPU读取一条指令时,肯定会把数据当成一条指令来读取执行,就像下面这样,不出错也就怪不得了!CPU访问内存中数据段的方式与访问代码段类似。它还使用基地址加上偏移量来获得数据段中的某个物理地址。在8086处理中,数据段的段寄存器是DS,也就是说当CPU执行一条需要访问数据段的指令时,会将DS的数据段寄存器中的值移位1位到左边。地址,作为数据段的基地址。遗憾的是,CPU并没有提供类似于IP寄存器的寄存器来表示数据段的偏移地址寄存器。这其实并不是一件坏事,因为程序在处理数据的时候,程序的开发者知道自己需要对数据进行什么样的操作,所以我们可以用更灵活的方式告诉CPU如何计算偏移量的数据地址。就像猴子掰玉米一样,不需要按顺序掰,想掰哪个就掰哪个。同样,程序在操作数据的时候,不管操作的是什么数据,直接给出数据的偏移地址的值就可以了。数据类型和长度但是,在对数据段中的每个数据进行操作时,有一个更重要的概念需要牢记:数据的类型是什么,这个数据在内存中占用多少字节。在高级语言编程中(如:C语言),在定义一个变量的时候,我们必须明确变量的类型。一旦类型确定了,它被加载到内存后占用的空间大小也就确定了。例如下图:假设30000H为数据段的基地址(即DS寄存器中的内容为3000H),那么30000H地址处的数据大小为:11H?2211H?还是44332211H?其中有几个Maybe,因为数据的类型是不确定的!我们知道,在C语言中,如果有一个指针ptr最终指向这里的30000H物理地址(C代码中的ptr是虚拟地址,这里的30000H物理地址是地址转换后执行的地址)。如果ptr定义为:char*ptr;那么可以说ptr指针指向的值是11H。如果ptr定义为:int*prt;可以说ptr指针指向的值是44332211H(假设是littleendian格式)。也就是说,指针ptr指向的数据取决于定义指针变量时的类型。在高级语言中是这样,那么在汇编语言中呢?PS:之前说过文章的主要目的是学习Linux操作系统,但是为了学习一些比较底层的内容,一开始一定要放弃操作系统,到离硬件最近的地方去查看。但是我们应该怎么看呢?我们还需要使用一些原始的手段和工具,那么汇编代码无疑是最好的也是唯一的手段;不过涉及到的汇编代码是最简单的,只是为了说明原理;在汇编语言中,CPU通过指令代码中的相关寄存器来判断运算数据的长度。上一篇文章提到过,相对于寄存器,CPU操作内存的速度非常慢。因此,CPU在处理数据段中的数据时,一般会将原始数据读入通用寄存器(例如:ax、bx、cxdx),然后进行计算。得到计算结果后,将结果写回内存的数据段(如果需要)。然后CPU在读写数据时,根据指令代码中使用的寄存器来决定读写数据的长度。例如:movax,[0]其中[0]表示内存数据段中偏移地址为0的位置。当CPU执行这条指令时,会到30000H的物理地址(假设此时数据段寄存器DS的值为3000H),取出2个字节的数据,放入通用寄存器ax中。此时ax寄存器中的值为2211H。为什么要占用2个字节?因为ax寄存器的长度是16位,也就是2个字节。那么,如果您只想获取1个字节怎么办?16位通用寄存器ax可以拆分为两个8位寄存器:ah和al。move,[0]因为指令码中的al寄存器是8位,所以CPU只在30000H处读取一个字节11,放入al寄存器中。(此时ax寄存器的高8位,即ah中的值不变)如果要取3个字节或4个字节怎么办?作为一个非常古老的处理器,8086CPU是16位的,只能对8位或16位数据进行运算。寻址范围从以上内容可以概括为:代码段和数据段通过[基地址+偏移地址]的方式寻址;基地址放在各自的段寄存器中,CPU会自动设置段寄存器的值,左移1位,作为段的基地址;偏移地址决定了段中每个具体的地址,偏移地址最大为16bit1,即64KB的空间;注意:这里的段寄存器左移1指的是十六进制左移,相当于乘以16,所以段的基地址是16的倍数,我们来看看64这里的KB空间,和20条地址线有什么关系。上一篇文章说:8086处理器有20条地址线,一共可以代表1MB的内存空间。就算给它更大的空间,它也享受不到福气,因为它无法寻址大于1MB的地址空间。!这1MB内存空间可以分成很多段。例如:第一个段的地址范围是:我们来计算最后一个段的空间。段寄存器和偏移地址都取最大值,即FFFF:FFFF,先偏移再相加:FFFF0+FFFF=10FFEF=1M+64K-16Bytes。空间大小超过1MB,但毕竟只有20条地址线,肯定不可能寻址超过1MB的地址空间,所以系统会采用wraparound的方式来定位一个地址空间,类似数学中的模运算。另外,在表示内存地址时,一般不会直接给出物理地址的值(例如:3000A),而是用段地址:偏移地址(例如:3000:000A)的形式来表示。栈也是一种数据空间,只是它的操作方式有点特殊。栈的操作方式是4个字:后进先出。上面引入数据段的时候,我们在指令代码中手动设置了数据的偏移地址,指的是从哪里播放,因为数据放在哪里,是什么意思,怎么用,就看开发者自己了.最清楚。但是堆栈有些不同。虽然它的功能也是用来存储数据的,但是操作栈的方式是通过处理器提供的一些特殊指令来操作的:push和pop。push(入栈):将一条数据放入栈空间;pop(outofthestack):从栈空间弹出一条数据;注意:这里的数据固定为2个字节,即一个字。写过C/C++程序的朋友都知道,调用函数时,有栈操作;当函数返回时,有一个堆栈操作。由于栈也指的是一块内存空间,所以在内存中也表示为一段。既然是段,就必须有一个段寄存器来表示它的基地址。这个栈的段寄存器是SS。另外,由于栈在入栈和出栈时是按连续的地址顺序进行操作的,因此处理器还为栈提供了一个偏移地址寄存器:SP(称为:栈顶指针),它指向栈空间中栈顶指针的位置最顶层的元素。比如下图:栈空间的基地址为1000:0000,SS:SP执行的地址空间为栈顶,栈顶元素为44。当下面两个指令执行:movax,1234Hpushas,栈顶指针寄存器SP中的值先减2变为000A:然后,将寄存器ax中的值1234H放入由指针指向的内存单元SS:SP:out栈的操作顺序是相反的:popbx先将SS:SP指向的内存单元中的数据1234H放入寄存器bx中,然后将栈顶指针寄存器SP中的值加2成为000C:上面描述的是8086处理器中栈操作的执行过程。如果看过其他一些栈相关的介绍书籍,可以看到这里使用的是“满减”的栈操作方式,另外还有:满增、空减、空增。Full:指栈顶指针指向的空间,为有效数据。当有新的数据入栈时,栈顶指针先指向下一个空位置,再将数据放入该位置;空:指栈顶指针指向的空间,是无效数据。当一个新的数据入栈时,先将数据放入这个位置,然后栈顶指针指向下一个空位置;increment:表示数据入栈时,栈顶指针向高地址方向递增;decrement:表示数据入栈时,栈顶指针向低地址方向递减;从上面对内存的寻址方式可以看出实模式和保护模式:只要在可寻址范围内,我们编写的程序在运行中就可以寻址到内存中任意位置的数据。这种寻址模式称为实模式。真实,就是真实、实际、简洁、直接,没有任何波折。人既然写代码,就难免会犯一些低级错误。或者一些恶意的家伙故意去操作内存空间中不该操作也不能操作的代码或数据。为了有效保护内存,从80386开始,引入保护模式对内存进行寻址。有些书会提到IA-32A的概念。IA-32是IntelArchitecture32-bit的缩写,即Intel32位架构,最早是在386中采用的。虽然引入了保护模式,但也有实模式,是向前兼容的。计算机开机后为实模式,BIOS加载主引导记录并设置一些寄存器后进入保护模式。386之后引入的保护模式,地址线数变成了32条,最大寻址空间可以达到4GB。当然,处理器中的寄存器也变成了32位。我们还是采用段基地址+偏移量的方式来计算一个物理地址。假设段寄存器的内容为0,偏移地址的最大长度也为32位,那么一个段所能表示的最大空间为4GB。这就是为什么当今现代处理器中每个进程的最大可寻址空间为4GB(通常称为虚拟地址)。一句话总结:实模式和保护模式最根本的区别在于内存是否被保护。Linux中的分段策略上面介绍的分段机制是x86处理器中提供的一种内存寻址机制,只是一种机制。在x86处理器之上运行Windows、Linux和其他操作系统。我们开发人员是面对操作系统编程的,写好的程序是操作系统接管的,不是x86处理器直接接手的。它相当于操作系统在应用程序和x86处理器之间隔离了一层:因此,如何使用x86提供的分段机制是操作系统需要操心的问题。而操作系统提供什么样的策略供应用程序使用是另一个问题。那么,Linux操作系统是如何封装使用x86提供的段寻址方式的呢?大家是否还记得上一篇文章中的图片:这是Linux2.6版本中主要的四种段描述符,这里不管段描述符是什么,最终都是用来描述内存中的一块空间。在现代操作系统中,分段和分页都是划分和管理内存的方式,在功能上有些冗余。Linux以非常有限的方式使用分段,更喜欢使用分页。上图中一共定义了4个段,每个段的基地址为0x00000000,每个段的Limit为0xFFFFF。从Limit的值可以得到:最大值为2的20次方,只有1MB的空间。但是其中的G字段代表了段的粒度,1表示粒度为4K,所以1MB*4K=4GB,即段的最大空间为4GB。这4个段的基地址和寻址范围是一样的!主要区别在于Type和DPL字段不同。DPL表示优先级,2个用户段(代码段和数据段)的优先级值为3,优先级最低(值越大,优先级越低);2个内核段(代码段和数据段)的优先级level值为0,优先级最高。因此,在Linux系统中可以得出一个重要的结论:逻辑地址和线性地址在数值上是相等的,因为基地址是0x00000000。关于linux内存分段和分页寻址更详细的内容我们后面会讲到。本文转载自微信公众号“IOT物联网小镇”,可通过以下二维码关注。转载本文请联系物联小镇公众号。