本节书摘来自异步社区《操作系统真象还原》一书中的第0章,第0.8节,作者:郑钢著,更多章节内容可以访问云栖社区“异步社区”公众号查看
0.8 代码中为什么分为代码段、数据段?这和内存访问机制中的段是一回事吗
首先,程序不是一定要分段才能运行的,分段只是为了使程序更加优美。就像用饭盒装饭菜一样,完全可以将很多菜和米饭混合在一起,或者搅拌成一体,哈哈,但这样可能就没什么胃口啦。如果饭盒中有好多小格子,方便将不同的菜和饭区分存放,这样会让我们胃口大开增加食欲。
x86平台的处理器是必须要用分段机制访问内存的,正因为如此,处理器才提供了段寄存器,用来指定待访问的内存段起始地址。我们这里讨论的程序代码中的段(用section或segment来定义的段,不同汇编编译器提供的关键字有所区别,功能是一样的)和内存访问机制中的段本质上是一回事。在硬件的内存访问机制中,处理器要用硬件——段寄存器,指向软件——程序代码中用section或segment以软件形式所定义的内存段。
分段是必然的,只是在平坦模型下,硬件段寄存器中指向的内存段为最大的4GB,而在多段模式下编程,硬件段寄存器中指向的内存段大小不一。
对于在代码中的分段,有的是操作系统做的,有的是程序员自己划分的。如果是在多段模型下编程,我们必然会在源码中定义多个段,然后需要不断地切换段寄存器所指向的段,这样才能访问到不同段中的数据,所以说,在多段模型下的程序分段是程序员人为划分的。如果是在平坦模型下编程,操作系统将整个4GB内存都放在同一个段中,我们就不需要来回切换段寄存器所指向的段。对于代码中是否要分段,这取决于操作系统是否在平坦模型下。
一般的高级语言不允许程序员自己将代码分成各种各样的段,这是因为其所用的编译器是针对某个操作系统编写的,该操作系统采用的是平坦模型,所以该编译器要编译出适合此操作系统加载运行的程序。由于处理器支持了具有分页机制的虚拟内存,操作系统也采用了分页模型,因此编译器会将程序按内容划分成代码段和数据段,如编译器gcc会把C语言写出的程序划分成代码段、数据段、栈段、.bss段、堆等部分。这会由操作系统将编译器编译出来的用户程序中的各个段分配到不同的物理内存上。对于目前咱们用高级语言编码来说,我们之所以不用关心如何将程序分段,正是由于编译器按平坦模型编译,而程序所依赖的操作系统又采用了虚拟内存管理,即处理器的分页机制。像汇编这种低级语言允许程序员为自己的程序分段,能够灵活地编排布局,这就属于人为将程序分成段了,也就是采用多段模型编程。
这么说似乎不是很清楚,一会再用例子和大伙儿解释就明白了。在这之前,先和大家明确一件事。
CPU是个自动化程度极高的芯片,就像心脏一样,给它一个初始的收缩,它将永远地跳下去。突然想到Intel的广告词:给你一颗奔腾的心。
只要给出CPU第一个指令的起始地址,CPU在它执行本指令的同时,它会自动获取下一条的地址,然后重复上述过程,继续执行,继续取址。假如执行的每条指令都正确,没有异常发生的话,我想它可以运行到世界的尽头,能让它停下来的唯一条件就是断电。
它为什么能够取得下一条指令地址?也就是说为什么知道下一条指令在哪里。这是因为程序中的指令都是挨着的,彼此之间无空隙。有同学可能会问,程序中不是有对齐这回事吗?为了对齐,编译器在程序中塞了好多0。是的,对齐确实是让程序中出现了好多空隙,但这些空隙是数据间的空隙,指令间不存在空隙,下一条指令的地址是按照前面指令的尺寸大小排下来的,这就是Intel处理器的程序计数器cs:eip能够自动获得下一条指令的原理,即将当前eip中的地址加上当前指令机器码的大小便是内存中下一条指令的起始地址。即使指令间有空隙或其他非指令的数据,这也仅仅是在物理上将其断开了,依然可以用jmp指令将非指令部分跳过以保持指令在逻辑上连续,我们在后面会通过实例验证这一原理。
为了让程序内指令接连不断地执行,要把指令全部排在一起,形成一片连续的指令区域,这就是代码段。这样CPU肯定能接连不断地执行下去。指令是由操作码和操作数组成的,这对于数据也一样,程序运行不仅要有操作码,也得有操作数,操作数就是指程序中的数据。把数据连续地并排在一起存储形成的段落,就称为数据段。
指令大小是由实际指令的操作码决定的,也就是说CPU在译码阶段拿到了操作码后,就知道实际指令所占的大小。其实说来说去,本质上就是在解释地址是怎么来的。这部分在第3章中的“什么是地址”节中有详解。
给大家演示个小例子,代码没有实际意义,是我随便写的,只是为方便大家理解指令的地址,代码如下。
code_seg.S
1 mov ds,ax
2 mov ax,[var]
3 label:
4 jmp label
5 var dw 0x99
本示例一共就5行,简单纯粹为演示。将其编译为二进制文件,程序内容是:
8E D8 A1 07 00 EB FE 99 00
就这9个字节的内容,有没有觉得一阵晕炫。如果没有,目测读者兄弟的技术水平远在我之上,请略过本书。
其实这9个字节的内容就是机器码。为了让大家理解得更清晰,给大家列个机器码和源码对照表,见表0-1。
表0-1第1行,地址0处的指令是“mov ds,ax”,其机器码是8ED8,这是十六进制表示,可见其大小是2字节。前面说过,下一条指令的地址是按照前面指令的尺寸排下来的,那第2行指令的起始地址是0+2=2。在第2行的地址列中,地址确实是2。这不是我故意写上去的,编译器真的就是这样编排的。第2列的指令是“mov ax,[0x7]”(0x7是变量var经过编译后的地址),其机器码是A10700,这是3字节大小。所以第3条指令的地址是2+3=5。后面的指令地址也是这样推算的。程序虽然很短,但麻雀虽小,五脏俱全,完美展示了程序中代码紧凑无隙的布局。
现在大伙儿明白为什么CPU能源源不断获取到指令了吧,如前所述,原因首先是指令是连续紧凑的,其次是通过指令机器码能够判断当前指令长度,当前指令地址+当前指令长度=下一条指令地址。
上面给出的例子,其指令在物理上是连续的,其实在CPU眼里,只要指令逻辑上是连续的就可以,没必要一定得是物理上连续。所以,明确一点,即使数据和代码在物理上混在一起,程序也是可以运行的,这并不意味着指令被数据“断开”了。只要程序中有指令能够跨过这些数据就行啦,最典型的就是用jmp跳过数据区。
比如这样的汇编代码:
1 jmp start ;跳转到第三行的start,这是CPU直接执行的指令
2 var dd 1 ;定义变量var并赋值为1。分配变量不是CPU的工作
;汇编器负责分配空间并为变量编址
3 start: ;标号名为start,会被汇编器翻译为某个地址
4 mov ax,0 ;将ax赋值为0
这几行代码没有实际意义,只是为了解释清楚问题,咱们只要关注在第2行的定义变量var之前为什么要jmp start。如果将上面的汇编代码按纯二进制编译,如果不加第1行的jmp,CPU也许会发出异常,显示无效指令,也许不知道执行到哪里去了。因为CPU只会执行cs:ip中的指令,这两个寄存器记录的是下一条待执行指令的地址,下一个地址var处的值为1,显然我们从定义中看出这只是数据,但指令和数据都是二进制数字,CPU可分不出这是指令,还是数据。保不准某些“数据”误打误撞恰恰是某种指令也说不定。既然var是我们定义的数据,那么必须加上jmp start跳过这个var所占的空间才可以。
加个jmp指令,这样做一点都不影响运行,只不过这样写出来的程序,其中引用的地址大部分是不连续的,也就是程序在取地址时会显得跳来跳去。就美观层面上看,这样的结构显得很凌乱,不利于程序员阅读与维护。如果把第2行的var换到第1行,数据和代码就分开了,没有混在一起,标号都不用了,代码简洁多了,如下。
var dd 1
mov ax,0
做过开发的同学都清楚,尽量把同一属性的数据放在一起,这样易于维护。这一点类似于MVC,在程序逻辑中把模型、视图、控制这三部分分开,这样更新各部分时,不会影响到其他模块。
将数据和代码分开的好处有三点。
第一,可以为它们赋予不同的属性。
例如数据本身是需要修改的,所以数据就需要有可写的属性,不让数据段可写,那程序根本就无法执行啦。程序中的代码是不能被更改的,这样就要求代码段具备只读的属性。真要是在运行过程中程序的下一条指令被修改了,谁知道会产生什么样的灾难。
第二,为了提高CPU内部缓存的命中率。
大伙儿知道,缓存起作用的原因是程序的局部性原理。在CPU内部也有缓存机制,将程序中的指令和数据分离,这有利于增强程序的局部性。CPU内部有针对数据和针对指令的两种缓存机制,因此,将数据和代码分开存储将使程序运行得更快。
第三,节省内存。
程序中存在一些只读的部分,比如代码,当一个程序的多个副本同时运行时(比如同时执行多个ls命令时),没必要在内存中同时存在多个相同的代码段,这将浪费有限的物理内存资源,只要把这一个代码段共享就可以了。
后两点较容易理解,咱们深入讨论下第一点,不知您有没有想过,数据段或代码段的属性是谁给添加上的呢,是谁又去根据属性保护程序的呢,是程序员吗?是编译器吗?是操作系统吗?还是CPU一级的硬件支持?
首先肯定不是程序员,人家操作系统设计人员为了让程序员编写程序更加容易,肯定不会让他们分心去处理这些与业务逻辑无关的事。看看编译器为我们做了什么,它将程序中那些只读的代码编译出来后,放在一片连续的区域,这个区域叫代码段。将那些已经初始化的数据也放在一片连续的区域,这个区域叫数据段,那些具有全局属性的但又未初始化的数据放在bss段。总之,程序中段的类型可多了,用“readelf –e elf”命令便可以看到很多段的类型,感兴趣的读者请自行查阅。好了,编译器的工作到此就完事了,显然,数据段和代码段的属性到现在还没有体现出来。
先看CPU为我们提供了哪些原生的支持。在保护模式下,有这样一个数据结构,它叫全局描述符表(Global Descriptor Table,GDT),这个表中的每一项称为段描述符。先递归学习一下,什么是描述符?描述符就是描述某种数据的数据结构,是元信息,属于数据的数据。就像人们的身份证,上面有写性别、出生日期、地址等描述个人情况的信息。在段描述符中有段的属性位,在以后的章节中可以看到,其实是有2个,一个是S字段,占1bit大小,另外一个是占4bit大小的TYPE字段,这两个字段配合在一起使用就能组合出各种属性,如只读、向下扩展、只执行等。提供归提供,可得有人去填写这张表啊,谁来做这事呢,有请操作系统登场。
接着看操作系统为我们做了什么。
操作系统在让CPU进入保护模式之前,首先要准备好GDT,也就是要设置好GDT的相关项,填写好段描述符。段描述符填写成什么样,段具备什么样的属性,这完全取决于操作系统了,在这里大家只要知道,段描述符中的S字段和TYPE字段负责该段的属性,也就是该属性与安全相关。
说到这里,答案似乎浮出水面了。
(1)编译器负责挑选出数据具备的属性,从而根据属性将程序片段分类,比如,划分出了只读属性的代码段和可写属性的数据段。再补充一下,编译器并没有让段具备某种属性,对于代码段,编译器所做的只是将代码归类到一起而已,也就是将程序中的有关代码的多个section合并成一个大的segment(这就是我们所说的代码段),它并没有为代码段添加额外的信息。
(2)操作系统通过设置GDT全局描述符表来构建段描述符,在段描述符中指定段的位置、大小及属性(包括S字段和TYPE字段)。也就是说,操作系统认为代码应该是只读的,所以给用来指向代码段的那个段描述符设置了只读的属性,这才是真正给段添加属性的地方。
(3)CPU中的段寄存器提前被操作系统赋予相应的选择子(后面章节会讲什么是选择子,暂时将其理解为相当于段基址),从而确定了指向的段。在执行指令时,会根据该段的属性来判断指令的行为,若有返回则发出异常。
总之,编译器、操作系统、CPU三个配合在一起才能对程序保护,检测出指令中的违规行为。如果GDT中的代码段描述符具备可写的属性,那编译器再怎么划分代码段都没有用,有判断权利的只有CPU。
好,现在大家对GDT有个感性认识,随着以后章节中讲GDT的时候,大家就会有深刻的理解了。
以上说明了程序按内容分段的原因,那么编译器编译出来的段和内存访问中的段是一回事吗?
其实算一回事,也不算一回事。怎么说呢,我觉得当初Intel公司在设计CPU时,其采用分段机制访问内存的原因,肯定不是为了上层软件的优美,毕竟那只是逻辑上的东西。那为什么也算一回事呢?
分析一下,编译出来的代码段是指一片连续的内存区域。这个段有自己的起始地址,也有自己的大小范围。用户进程中的段,只是为了便于管理,而编译器或程序员在“美学方面”做出的规划,本质上它并不是CPU用于内存访问的段,但它们都是描述了一段内存,而且程序中的段,其起始地址和大小可以理解为CPU访问内存分段策略中的“段基址:段内偏移地址”,这么说来,至少它们很接近了,让我们更近一步:程序是可以被人为划分成段的,并且可以将划分出来的段地址加载到段寄存器中,见下面的代码0-1。
代码0-1 程序分段
1 section my_code vstart=0
2 ;通过远跳转的方式给代码段寄存器CS赋值0x90
3 jmp 0x90:start
4 start: ;标号start只是为了jmp跳到下一条指令
5
6 ;初始化数据段寄存器DS
7 mov ax,section.my_data.start
8 add ax,0x900 ;加0x900是因为本程序会被mbr加载到内存0x900处
9 shr ax,4 ;提前右移4位,因为段基址会被CPU段部件左移4位
10 mov ds,ax
11
12 ;初始化栈段寄存器SS
13 mov ax,section.my_stack.start
14 add ax,0x900 ;加0x900是因为本程序会被mbr加载到内存0x900处
15 shr ax,4 ;提前右移4位,因为段基址会被CPU段部件左移4位
16 mov ss,ax
17 mov sp,stack_top ;初始化栈指针
18
19 ;此时CS、DS、SS段寄存器已经初始化完成,下面开始正式工作
20 push word [var2] ;变量名var2编译后变成0x4
21 jmp $
22
23 ;自定义的数据段
24 section my_data align=16 vstart=0
25 var1 dd 0x1
26 var2 dd 0x6
27
28 ;自定义的栈段
29 section my_stack align=16 vstart=0
30 times 128 db 0
31 stack_top: ;此处用于栈顶,标号作用域是当前section,
;以当前section的vstart为基数
32
代码0-1是实模式下运行的程序,其中自定义了三个段,为了和标准的段名(.code、.data等)有所区别,这里代码段取名为my_code,数据段取名为my_data,栈段取名为my_stack。这段代码是由MBR加载到物理内存地址0x900后,mbr通过“jmp 0x900”跳过来的,我们的想法是让各段寄存器左移4位后的段基址与程序中各分段实际内存位置相同,所以对于代码段,希望其基址是0x900,故代码段CS的值为0x90(在实模式下,由CPU的段部件将其左移4位后变成0x900,所以要初始化成左移4位前的值)。但没有办法直接为CS寄存器赋值,所以在代码0-1开头,用“jmp 0x90:0”初始化了程序计数器CS和IP。这样段寄存器CS就是程序中咱们自己划分的代码段了。
在此提醒一下,各section中的定义都有align=16和vstart=0,这是用来指定各section按16位对齐的,各section的起始地址是16的整数倍,即用十六进制表示的话,最后一位是0。所以右移操作如第9行的shr ax,4,结果才是正确的,只是把0移出去了。否则不加align=16的话,section的地址不能保证是16的整数倍,右移4位可能会丢数据。vstart=0是指定各section内数据或指令的地址以0为起始编号,这样做为段内偏移地址时更方便。具体vstart内容请参阅本书相应章节。
第6~10行是初始化数据段寄存器DS,是用程序中自已划分的段my_data的地址来初始化的。由于代码0-1本身是脱离操作系统的程序,是MBR将其加载到0x900后通过跳转指令“jmp 0x900”跳入执行的,所以要将my_data在文件内的地址section.my_data.start加上0x900才是最终在内存中的真实地址。右移4位的原因同代码段相同,都是CPU的段部件会自动将段基址左移4位,故提前右移4位。此地址作为段基址赋值给DS,这样段寄存器DS中的值是程序中咱们自己划分的数据段了。
第12~17行是初始化栈段寄存器,原理和数据段差不多,唯一区别是栈段初始化多了个针指针SP,为它初始化的值stack_top是最后一行,因为栈指针在使用过程中指向的地址越来越低,所以初始化时一定得是栈段的最高地址。
经过代码段、数据段、栈段的初始化,CPU中的段寄存器CS、DS、SS都是指向程序中咱们自己划分的段地址,之后CPU的内存分段机制“段基址:段内偏移地址”,段基址就是程序中咱们自己划分的段,段内偏移地址都是各自定义段内的指令和数据地址,由于在section中有vstart=0限制,地址都是从0开始编号的。所以,程序中的分段和CPU内存访问的分段又是一回事。
让我们对此感到疑惑的原因,可能是我们一般都是用高级语言开发程序,在高级语言中,程序分段这种工作不由我们控制,是由编译器在编译阶段完成的。而且现代操作系统都是在平坦模型(整个4GB空间为1个段)下工作,编译器也是按照平坦模型为程序布局,程序中的代码和数据都在同一个段中整齐排列。大家可以用readelf –e /bin/ls查看一下ls命令,结果太长,就不截图啦。咱们主要关注三段内容。
Section Headers:列出了程序中所有的section,这些section是gcc编译器帮忙划分的。
Program Headers:列出了程序中的段,即segment,这是程序中section合并后的结果。
Section to Segment mapping:列出了一个segment中包含了哪些section。
有关section和segment的内容请参见本书相关章节。
在Section Headers和Program Headers中您会发现,这些分段都是按照地址由低到高在4GB空间中连续整洁地分布的,在平坦模型下和谐融洽。
显然,不用程序员手工分段,并且采用平坦模型,这种操作上的“隔离”固然让我们更加方便,但也让我们更加感到进程空间布局的神秘。如果程序分段像代码0-1那样地直白、亲民,大家肯定不会感到迷惑了。其实我想说的是无论是否为平坦模型,程序中的分段和CPU中的内存分段机制,它们属于物品与容器的关系。
举个例子,程序中划分的段相当于各种水果,比如代码段相当于香蕉,数据段相当于葡萄,栈段相当于西瓜。CPU内存分段策略中的段寄存器相当于盛水果的盘子。可以用一个大盘子将各种水果都放进来,但依然是分门别类地摆放,不能失去美感混成一锅粥,这就是段大小为4GB的平坦模型。也可以把每种水果分别放在一个小盘子里一块儿端上来,这就是普通的分段模型,如图0-4所示。
总结一下,程序中的段只是逻辑上的划分,用于不同数据的归类,但是可以用CPU中的段寄存器直接指向它们,然后用内存分段机制去访问程序中的段,在这一点上看,它们很像相片和相框的关系:程序中的段是内存中的内容,相当于相片,属于被展示的内容,而内存分段机制则是访问内存的手段,相当于相框,有了相框,照片才能有地摆放。
我想大家应该已经搞清楚了内存分段和程序分段的关系,其实就是一回事,内存分段指的是处理器为访问内存而采用的机制,称之为内存分段机制,程序分段是软件中人为逻辑划分的内存区域,它本身也是内存,所以处理器在访问该区域时,也会采用内存分段机制,用段寄存器指向该区域的起始地址。