嗨~你好呀!
我是一名初二学生,热爱计算机,码龄两年。最近开始学习汇编,希望通过 Blog 的形式记录下自己的学习过程,也和更多人分享。
上篇系列文章链接:【汇编】三、寄存器(一只 Assember 的成长史)
这篇文章主要讲解内存访问。
话不多说~我们开始吧!
⭐ 注:本系列文章基于 8086 CPU,16 位汇编,参考书《汇编语言》。
本系列旨在为 32 位汇编的学习以及汇编的实际使用打下基础。
目录
四 0. 本文中用到的汇编指令及其功能
四 1. 字在内存中的存储
四 2. DS 寄存器和 [address]
1. ds 寄存器
2. [address]
四 3. 栈简介和内存提供的栈机制
1. 栈简介
2. 内存提供的栈机制
四 4. PUSH/POP 指令、SS/SP 寄存器和栈顶超界问题
1. push 和 pop 指令
2. ss 和 sp 寄存器
3. 栈顶超界问题
汇编指令 | 指令功能 | C++ 语法描述 |
sub X,Y | 将 X - Y 的结果存储在 X 中(注意,计算机并不区分正负,即 FFFFH 既可以表示正数 65535,也可以表示负数 -1,正负是由开发者决定的~) | X -= Y 或 X = X-Y |
push X | 将 X 中的数据入栈 | stackname.push(X) |
pop X | 用 X 接收出栈的数据 | X = stackname.pop() |
mov X, Y | 将 Y 中的数据送至 X 中 | X = Y |
add X, Y | 将 X,Y 相加,并将结果存储在 X 中 | X += Y 或 X = X+Y |
jmp X:Y | 将 cs 的值设置为 X,将 ip 的值设置为 Y(后面会详细讲到) | cs = X; ip = Y |
jmp 某一合法寄存器名 | 将 ip 的值设置为该合法寄存器中存储的值(后面会详细讲到) | ip = 该寄存器中存储的值 |
注:除前三条指令(标蓝指令)外,其它指令在前几篇文章中均有讲解~
注 2:push 和 pop 的使用有一些限制,但涉及到更多知识,容易打乱思路和学习主线,所以会在遇到这些限制的时候再进行说明~
上一篇文章讲过,一个字相当于两个字节,它分为高位字节和低位字节。
在内存中存储一个字,需要两个地址连续的内存单元,这个字的低位字节存储在低地址单元中,高位字节存储在高地址单元中,比如我们在内存中存储十进制数 4896。
4896(10) = 1320(16)。这个字的高位字节是 13h,低位字节是 20h。
用 DOSBox 看一下吧~
设置 ds 为 0520,向 ds:0000 和 ds:0001 中写入数据(一个字是两个字节,所以占用两个内存单元)。
指令执行完毕后,看看 0520:0000 和 0520:0001 中的内容吧~
emmm 是的没错,它是 “反过来的”,因为 20h 是 1320h 中的低位字节,所以会存储在低地址单元中。
这个要记住o~以后还会用到的~
上篇文章中讲过,ds 属于数据段寄存器,通常用于存放要访问的数据的段的地址。
比如下面这段代码:
mov bx, 1000h
mov ds, bx
mov al, [0]
那个 [0] 的含义是该内存单元的偏移地址为 0。
诶?那段地址呢?
没错,段地址默认存储在 ds 寄存器(data segment register,数据段寄存器)中,也就是说,mov al, [0] 的实际含义是 mov al, ds:[0]。这个内存单元的物理地址 = ds * 16 + 0000。
其实 [0] 前面的段寄存器可以自定义,比如 cs:[0]、ss:[0] 等等。但如果没有显式地写出,都默认在 ds 中。
我们可以通过设置 ds 来定义一个数据段,在这个段中存储程序所需的所有数据,当需要使用这个段中的数据时,仅需给出数据所在的内存单元的偏移地址。
[address] 的一个典型的例子就是上面代码中的 [0],[address] 代表一个内存单元,[ ] 中的内容可以是一个自然数(比如 0),可以是 bx,可以是 si, di,后两种表示方法会在后续文章中讲到,但无论哪种表示,[address] 都表示一个内存单元的偏移地址。
“Stack is a linear data structure that follows a particular order in which the operations are performed. The order may be LIFO(Last In First Out) or FILO(First In Last Out). LIFO implies that the element that is inserted last, comes out first and FILO implies that the element that is inserted first, comes out last. There are many real-life examples of a stack. Consider an example of plates stacked over one another in the canteen. The plate which is at the top is the first one to be removed, i.e. the plate which has been placed at the bottommost position remains in the stack for the longest period of time. So, it can be simply seen to follow LIFO(Last In First Out)/FILO(First In Last Out) order.”(最近似乎爱上了英语嘿嘿嘿~)
栈是一种线性的、LIFO(先进后出)的数据结构。栈的工作机制类似于往箱子里放书,最先放进去的书在最下面,而取书的时候是从最上面开始取的,所以最先放进去的书最后才能被取出来。如下图所示。
栈有两个基本操作:入栈(push)和出栈(pop)。入栈就是将一个新元素放到栈顶,出栈就是从栈顶取出一个元素。
栈是一种基础的数据结构,c++ 中可以使用 STL 实现,不过不止 c++ 了这种机制,cpu 本身也提供了栈机制哦!(其实所有语言的指令的执行都是基于 cpu 的机制...上面那句话只是我为了 “引出下文” 用的 emmm.)
cpu 提供相关的指令来以栈的方式(LIFO)访问内存空间,这意味着我们在编程的时候可以将一段内存当作栈来使用,让最先进入这段内存的数据最后出去(不过这并不意味着这段内存在 “硬件层面” 很特殊,它和其它内存空间没有任何区别,只是我们 “人为规定” 它作为栈空间)。
在内存中,栈是从高位到低位分配的,也就是说栈底的元素(最先进栈的元素)的物理地址最大(物理地址在上一篇中讲过),下图说明了这一点。
上图中,箭头指向的是栈顶,最下面的那个内存单元是栈底,它的物理地址最大。由图可知越靠近栈顶的的内存单元的物理地址越小(偏移地址越小),即栈是从高位到低位分配的。
下图的内存模型也说明了这一点(这个内存模型是一个程序运行时占用内存空间的通用模型,其中堆 heap 是从低位向高位分配,处理动态的内存占用请求;栈 stack 是从高位向低位分配,处理函数运行时临时占用的内存)。
诶?可是把一段内存当作栈空间使用,那栈底的地址、栈顶的地址和栈的长度如何确定?又用什么指令进行出入栈操作呢......?
别着急呀,汇编提供了它的方法哦~
其实第 0 部分说过,push 指令用于入栈,pop 指令用于出栈。比如以下四条指令:
push ax ; 将 ax 中的数据入栈
push bx
pop ax ; 弹出栈顶元素至 ax
pop bx
前两条指令用于入栈,后两条指令用于出栈。
诶,想想栈的性质(LIFO),这四条指令大致实现了一个什么功能?
——交换 ax, bx 寄存器中的数据。
因为栈是 LIFO 的,所以最后入栈的数据(原 bx 寄存器中的数据)会首先出栈,被写入到 ax 寄存器中,相当于交换了两个寄存器中的数据。
不过这两条指令的使用是有限制的——操作数只能是寄存器、段寄存器或内存单元。
即,下列指令中前三条是合法的,第四条是非法的。
1. push ax(操作数是寄存器)
2. pop ds(操作数是段寄存器)
3. push [10](操作数是内存单元)
4. push 52218(操作数是自然数)
注:第 3 条指令的操作数部分只给出了内存单元的偏移地址,此时的段地址默认在 ds 中~
emm,但是,好像还有一个问题没有解决——push 到哪里?又从哪里 pop?
栈是被开发者规定的一段空间,它实际上只是一堆内存单元,和其它内存空间在物理层面没有任何不同。所以对栈进行操作其实也就是对内存单元进行操作,也需要这个内存单元的段地址和偏移地址。
对于栈,有这样两个专门的寄存器——ss 和 sp。
ss 是栈段寄存器,存储栈段的段地址;sp 是地址指针寄存器,存储栈段中的内存单元的偏移地址。
任何时刻,ss:sp 指向栈顶。
让我们通过分析指令来更深入地理解这两句话......
以下这几句指令实现了一个最简单的栈并演示了各个指令的功能:
mov ax, 0520h
mov ss, ax ; 设置栈段段地址为 0520h
mov ax, 0218h
mov sp, ax ; 设置栈顶内存单元偏移地址为 0218h
mov ax, 1102h
mov bx, 1320h
mov cx, 99h
push ax ; 将 ax 中的内容(1102h)写入栈顶内存单元
push bx ; 将 bx 中的内容(1320h)写入栈顶内存单元
pop ax ; 将栈顶内存单元中的内容弹出至 ax 寄存器中
pop cx ; 将栈顶内存单元中的内容弹出至 cx 寄存器中
用 DOSBox 跟踪调试一下......
来看看进行栈操作前各个寄存器中的值。
进行入栈操作时,注意观察 sp 的值的变化(也就是栈顶的内存单元的偏移地址的变化)。
由图可知,sp 的值不断减小(栈从高位向低位分配),每次减小 2(因为 ax 等寄存器中存储的一个字,即两个字节)。
比如,执行 push ax,相当于:
sub sp, 2 ; 先将 sp 减 2,以当前栈顶上面的单元为新的栈顶,ss:sp 指向新的栈顶
mov ss:sp, ax ; 将 ax 中的数据入栈
注意,这只是两行伪代码,用来解释 push ax 的意思,这两行代码并不能真正执行。
也就是说,入栈时,sp 的值先减小 2,再将数据入栈。
入栈完毕后,栈看起来是这个样子:
再来看看出栈,注意观察 sp 的值的变化和 ax、cx 寄存器的值的变化。
由图可知,sp 的值不断增大,每次增大 2。
比如,执行 pop cx,相当于:
mov cx, ss:sp ; 将当前栈顶的元素写入 cx 寄存器
sub sp, 2 ; 将 sp 的值减 2,以当前栈顶下面的单元为新栈顶,ss:sp 指向该栈顶
注意,这只是两行伪代码,用来解释 pop cx 的意思,这两行代码并不能真正执行。
也就是说,出栈时,先将数据出栈,再将 sp 的值增大 2。
注意,将一个元素出栈并不等于让它在内存中 “消失”,它依然在内存中,只是不在被划定的栈空间中,而当下一次执行 push 等入栈指令时,它将被覆盖。
总结一下叭~:
· 栈从高位向低位分配
· 任意时刻,ss:sp 指向栈顶元素
· 入栈时,sp 先减小 2,再将数据入栈
· 出站时,先将数据出栈,sp 再增大 2
诶,不过......ss 和 sp 的值不受到限制吗?或者说,栈可以有多大?它的 “边界” 在哪里?
前面说过,栈只是被划定的一块内存空间,和别的内存空间其实没有区别。也就是说,cpu 和内存可不知道这块空间被当作栈使用,它们并不会 “特别关照” 这块空间。
这意味着,可能开发者本来仅仅想把 10010H ~ 1001FH 这段空间当作栈来使用,但一不小心多写了一个 push,导致一个数据被写到了栈外。。。然后或许栈外的那个地方存放着重要的系统数据。。。
嗯,然后就一个字,爽((
如何避免这种情况?cpu 有办法保证栈顶不会超出栈空间吗?比如一个栈顶上限寄存器什么的?
cpu:“很抱歉地通知您,这样的寄存器并不存在。”
是的,8086 cpu 只知道栈顶在何处(由 ss:sp 指示),而不知道开发者安排的栈空间有多大,开发者只能自己操心栈顶超界的问题。(类似于它只知道当前要执行的指令在 cs:ip 指向的内存单元,但不知道究竟有多少指令。)
在 8086 cpu 中,一个栈段最大仅能为 64kb,否则 ss 的值无法确定。如果栈段的大小超过 64kb,则会形成一个 “闭环”,新写入的数据将覆盖原先栈段中的从栈底开始的数据。类似 c++ 中,无符号整数 0 减 1 后会变成 65535。
诶不过写到这里,我突发奇想,或许可以利用一下 es 寄存器(extra segment register,额外段寄存器,它似乎没有专门的功能...)来避免出现栈顶超界的问题。我们可以把我们希望的栈顶上限的内存单元的偏移地址(也就是 sp 的最小值)记录在 es 寄存器中,每次进行入栈操作前先比较 sp 寄存器中的值和 es 寄存器中的值,如果两者相等,说明栈已满,此时提示开发者,不能继续进行 push 操作了。
but,以上这些问题在保护模式中很少会遇到,或者说即使栈顶超界了也不会 “危及系统”,因为想在保护模式中对系统下手,咱得先拿到 ring 0 权限。
等哪个周末我搞个 ring 0 权限来玩玩......
栈在以后还会经常用到,很重要,一定要理解呀~~
以上就是本文的全部内容啦~感谢你看到这里~
作者只是一名初学者,有任何错误或解释不当之处欢迎指出呀~一起加油!
那,我们下一篇再见咯~
2023-03-11
By Geeker · LStar
❤️