8086汇编语言学习(三) 8086中的段和栈
1. 8086汇编中的段
段地址
8086对内存寻址的方式是通过段地址*16+偏移地址的方式实现的,而在16位的8086CPU下,段地址和偏移地址也都是16位的。这意味着,对于任意一个段,段的起始地址必定为16的倍数(段地址*16)。
对于同一个内存地址,存在多种不同的内存寻址方式:
例如:段地址1000H+偏移地址2345H,与段地址1234H+偏移地址0005H都可以对内存地址12345H进行寻址(段地址1204H+偏移地址0305H等等也可行,非常自由)。
段的最大空间为2^16bit=64KB(例如:将段地址设为1000H,将段的大小设置为64KB,则段的内存范围为10000H-1FFFFH)。
段的最小空间为2^4bit=16bit (例如:将段地址设为1234H,将段的大小设置为16bit,则段的内存范围为12340H-1234FH)。
段的逻辑意义
需要注意,内存段的概念并不是内存硬件所固有的,而是从CPU寻址的角度出发,将内存中的物理连续区域逻辑上分隔为不同的区域。内存段这一概念的提出有利于在复杂程序、多程序系统中对同一程序的不同逻辑部分以及不同程序的内存进行更好的访问和管理。
段寄存器
内存段的存在能够划分同一程序中的不同逻辑部分,进行更有效的管理和更简单的访问。汇编程序的内存通常可以被划分为三部分:代码、栈以及数据。
通常为了避免混淆,会将这三种不同的逻辑部分分别存放在三个不同的内存段中,便于理解(当然也能将同一部分的内存存放在不同的段中,这主要取决于程序的复杂程度)。
8086CPU提供了对代码、栈、数据三种内存段访问的段寄存器,分别是代码段寄存器CS、栈段寄存器SS、数据段寄存器DS以及附加段寄存器ES。
代码段寄存器CS
代码段寄存器CS在前面的博客中已经有过介绍,CPU在运行时会将CS:IP指向的内存中的数据当作指令来执行。
栈段寄存器SS
执行栈相关的指令时,CPU通过SS:SP获得当前栈顶指针。通过设置寄存器SS的值,可以将某一段内存当作逻辑上的栈来使用。有关栈的内容,会在博客的后半段进行介绍。
数据段寄存器DS
内存寻址时,可以通过段地址:偏移地址的方式进行指定内存的访问(例如:mov cx 1000H:2345H)。
但由于大多数时候程序中对内存的访问都主要集中在某一逻辑段中,8086汇编提供了另一种更加简单的内存寻址方式:只需要指定偏移地址就能进行访问。(例如:mov cx [2345H];其中2345H为偏移地址)
但事实上,8086CPU依然是通过段地址:偏移地址的方式来寻址的,那么CPU是如何知道[address]指令形式的段地址的呢?答案是通过数据段寄存器DS来获取。当使用[address]这种仅指定了偏移地址的寻址方式时,CPU默认从寄存器DS中获取当前的段地址。
想要将地址12345H内存数据送入寄存器CX,[address]形式的指令序列为:mov ax 1000H; mov DS AX; mov cx [2345H]。
这里之所以需要通过通用寄存器AX间接的将段地址1000H送入DS,是因为8086CPU不允许直接对段寄存器进行赋值,具体的原因可以参考: https://www.zhihu.com/question/43608287。
附加段寄存器ES
附加段寄存器也可以视为额外的数据段寄存器,用于指定附加数据段的段地址。当同时需要操作两个段内存中的数据时,ES的存在可以避免反复的修改寄存器DS的,有效的简化代码和提高机器执行效率。
一段内存,既可以是程序的存储空间,可以是数据的存储空间,也可以作为栈空间来使用,甚至可以什么都不是。这里的关键在于CPU中相关寄存器CS/IP,SS/SP以及DS、ES的值。
2. 8086汇编中的栈
栈这个概念在计算机相关的领域内十分常见,其所具有的的后进先出的特性被很多程序广泛利用。
在汇编语言程序开发的过程中,在内存中数据的交换、子程序跳转进行变量保存等场景下,开发人员希望能够将一段内存当作逻辑上后进先出的栈来使用,比起通过每次都指明特定的内存地址去操作存储器,使用栈操作一段连续内存能让程序更加简单明了。
通过软件来实现一个栈,机器效率是十分低下的。为此,8086CPU的设计者提供了硬件层面的栈机制:通过特定的指令,CPU能够像对待栈一样来访问指定的一段内存。
栈有两种独特的操作,一是入栈(PUSH),二是出栈(POP),一个栈只有栈顶指针指向的元素才可以被访问。入栈和出栈时,栈顶指针也会相应的移动以指向新的栈顶处。
8086汇编提供了对应的指令PUSH和POP来对内存进行入栈、出栈操作。同时8086CPU是16位的,因此POP和PUSH所操作的栈中元素大小为两个字节。
栈相关指令
PUSH [寄存器],将指定寄存器中的值传送到栈顶指针处,栈顶指针上移
PUSH [内存单元],将指定内存单元处的值传送到栈顶指针处,栈顶指针上移
POP [寄存器],将栈顶指针处的内存数据传送到指定寄存器中,栈顶指针下移
POP [内存单元],将栈顶指针处的内存数据传送到指定内存单元处,栈顶指针下移
可以这么理解,PUSH是入栈操作,那么指令中的寄存器或是内存单元自然就是指向入栈数据所处的位置。而POP是出栈操作,指令中的寄存器或是内存单元则是用来承载出栈数据的。
栈顶指针
随之而来的问题是,CPU该如何知道在栈操作指令中没有明确声明的栈顶指针呢?
前面提到过,CPU是通过CS:IP来获得当前所应该执行的指令;类似的,CPU通过另外两个寄存器SS(stack-segment 栈段寄存器)和SP(stack-point 堆栈指针寄存器)来确定栈顶指针。在执行PUSH/POP指令时SS:SP所指向的内存单元就是栈顶指针所在的位置,SS标识栈内存段,而SP标识内存段中栈顶指针的内存地址。8086的一段连续栈内存地址中,将高位内存视为栈底,低位视为栈顶。
PUSH入栈:SS不变而SP自减2(空出两个字节共16位的内存空间),标识新的栈顶,随后将数据送入栈顶
POP出栈:先将栈顶元素送入目的地(寄存器/内存),随后SS不变而将SP自增2(缩小栈),标识新的栈顶
栈的越界问题
这里只提到了栈的顶部指针,而没有提到栈底指针。如果不断的出栈导致sp指针越过栈的底部会怎样?不断的入栈导致SP越过了栈的顶部又会发生什么呢?
汇编语言给了程序员难以言喻的自由,将几乎一切都交给了开发者自己控制,因此上述的栈访问越界问题必须由程序员自己避免。
在出栈入栈过程中,栈段寄存器SS是不变的,这意味着8086能直接支持的最大栈就是64KB(段的最大值),而SP在极端情况下会发生栈的上溢或者下溢;或者一个物理段被分成了许多的逻辑段,对栈顶指针的控制出现失误,会导致不同逻辑段间的数据被覆盖。
由于栈指针访问越界造成的问题在编程时不易察觉,却会带来严重后果,需要加以小心。