首先,程序不是一定要分段才能运行的,分段只是为了使程序更加优美。就像用饭盒装饭菜一样,完全可以将很多菜和米饭混合在一起,或者搅拌成一体,哈哈,但这样可能就没什么胃口啦。如果饭盒中有好多小格子,方便将不同的菜和饭区分存放,这样会让我们胃口大开增加食欲。
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
mov ds,ax
mov ax,[var]
label:
jmp label
var dw 0x99
本示例一共就5行,简单纯粹为演示。将其编译为二进制文件,程序内容是:
- 8E D8 A1 07 00 EB FE 99 00
就这9个字节的内容,有没有觉得一阵晕炫。如果没有,目测读者兄弟的技术水平远在我之上,请略过本书。
其实这9个字节的内容就是机器码。为了让大家理解得更清晰,给大家列个机器码和源码对照表,见表0-1。
表0-1 机器码和源码对照表
地 址 | 机 器 码 | 源 码 |
00000000 | 8ED8 | mov ds,ax |
00000002 | A10700 | mov ax,[0x7] |
00000005 | EBFE | jmp short 0x5 |
00000007 |
| var dw 0x99 |
00000008 |
|
表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跳过数据区。
比如这样的汇编代码:
- jmp start ;跳转到第三行的start,这是CPU直接执行的指令
- var dd 1 ;定义变量var并赋值为1。分配变量不是CPU的工作
- ;汇编器负责分配空间并为变量编址
- start: ;标号名为start,会被汇编器翻译为某个地址
- mov ax,0 ;将ax赋值为0