汇编学习笔记-1
汇编语言与机器码的对应表:https://wenku.baidu.com/view/45f10e2b50ea551810a6f524ccbff121dc36c544.html
学习视频:https://www.bilibili.com/video/BV1Rs411c7HG
如果还有其他资料可能以后会在加进这里来
有一部分图是从小甲鱼的课件上和王爽的那本书上截下来的
1.基础
1.1汇编语言
机器语言:是“机器指令”的集合,机器指令是一串二进制数字,可以由计算机转换为一列高低电平并执行
每一种cpu,都有自己的指令集。也就是不同cpu因为设计不同,指令集也是不同的
但由二进制数组成的机器语言显然不易于阅读,记忆,所以也就有了汇编语言
汇编语言实际上是机器语言的助记符,编些完后通过编译器,将汇编代码替换成机器能执行的二进制串
汇编代码由这三部分组成:
- 汇编指令:机器码助记符,也就是它有对应的机器码
- 伪指令:没有对应机器码,由编译器执行(在编译过程中)
- 其他符号:比如 \(+,-,*,/\),由编译器识别
当然,汇编的主要部分是汇编指令,后两种都是写给编译器看的(其实算是一类,主要是有符号和指令的区别)
1.2存储器和其它有关存储、数据等
存储器用来存储数据、指令
它被划分为一些存储单元(编号从 \(0\) 开始),一个单元存储一个 Byte,也就是八个二进制位
主要的存储器是内存,不同与磁盘,cpu只能从内存里读取数据、指令,不能从磁盘读取。因此,任何磁盘中的指令或数据,想要被cpu使用,都必须先加载到内存
一般如果要画图表示某一段内存中的数据的时候,习惯将低地址放在上,高地址在下,也就是从 \(0\) 开始从上往下来写
这也意味着,从这种图里读一个二进制数的时候,要从下向上读
上面提到了数据和指令,其实指令就是以前说的机器码,和数据一样,在存储器上看,都是二进制串,没有什么不同
只是cpu在处理时,将他们区分开,分别作为指令来执行,作为数据来处理
cpu对存储器的读写以及三种总线
上面说存储单元有一个“编号”,这个编号就被称为某一段数据的地址
如果cpu想在某段存储单元读或写,就要先给出它的地址,有了地址以后,还要知道操作是读还是写,以及读出的或要写入的数据是什么
所以,cpu会和对应的芯片进行三种信息的交互:地址,读或写的数据,以及读或写的命令(称为控制信息)
那么就要用到下面说到的三种总线了
地址总线
用来指定读写的存储单元
cpu把地址发送给内存进行寻址(内存收到以后会自动定位过去),如下图,每一根地址总线能发送一个二进制位(零或一)
当然,cpu在发送地址之前,要在内部先行成这个地址。不同cpu行成地址的这个方式可能不同,后面会讲到 8086cpu 行成地址的方式
如果一个cpu有 \(n\) 条地址总线,那么它可以寻找到 \(2^n\) 个内存单元,而这个 \(n\) 条称之为地址总线的“宽度”
所以说,地址总线的宽度决定了cpu的寻址能力
比如,一个cpu有 \(13\) 根地址总线,那么它的寻址能力就是 \(2^{13} Byte=8 KB\)
因此,大于 \(8KB\) 处的内存无法被访问到
由此也注意到,寻址能力决定的是访问范围,与速度无关
数据总线
用来在cpu和其它器件之间传送数据
一根数据总线,每次可以传送的数据是一个二进制位。
例如,\(16\) 根数据总线的cpu每次可以传送两个字节。而 \(8\) 根总线的每次就只能传一个字节,如果这个cpu想传两个字节的数据,就需要传送两次
因此,数据总线的数量(或者说宽度)决定了cpu和外部传送数据的速度
传多次时,是要先传低位,再传高位,比如 89D8 这个十六进制数(两字节),在 \(8\) 根数据总线的cpu上传送过程是这样的(记得读数据从下往上)
控制总线
cpu用控制总线来对外部发送命令,控制外部器件
有多少根控制总线,cpu就能提供对外部器件的多少种控制,控制总线宽度决定cpu对外部的控制能力
例如,“读信号输出”和“写信号输出”的控制总线,cpu向他们输出低电平(就是二进制 \(0\),相应的,高电平就是二进制 \(1\)),就分别代表向外发出读和写的信号
(小甲鱼的视频控制总线这里讲的和书上不大一样,应该是有些问题)
这也可以一定程度上说明cpu如何区分一段二进制串是数据还是指令(上面提到的),就是看他是从那种总线传送过来的
有了这些,我们也就可以知道,当cpu想读取(或写入)一段内存时,它先通过地址总线寻址,内存定位到相应地址,然后cpu发出读(或写)的指令,对应的数据被读取传回cpu(或从cpu传到内存来写入)
这里先穿插一点零碎的概念再继续
主板:主板上有一些核心器件和主要器件,这些器件通过总线相连,有cpu,存储器,外围芯片组,扩展插槽等。扩展插槽上一般有RAM内存条和各类接口卡
接口卡:对于外部设备,cpu不能直接控制他们。他们的工作受到对应接口卡的控制,而这些接口卡通过总线和cpu相连,cpu向他们发送指令等信息控制它们
比如显卡就是一个接口卡,cpu通过修改显存来控制显卡,显卡再控制显示器
各类存储器以及内存地址相关
各类存储器基本可以分为随机存储器(RAM)和只读存储器(ROM)
RAM 可读可写,一旦断电,数据变丢失;ROM 只能读,断电数据不丢失
有不同功能:
- 随机存储器,在主板上的 RAM 和插在扩展插槽上的 RAM 组成,用于存放cpu的指令、数据等
- 装有 BIOS 的 ROM,上面是对对应器件做基本输入输出的程序
- 接口卡上的 RAM,当然是给接口卡存放数据的地方,比如显卡的 RAM 叫显存
这些存储器,都和cpu通过总线相连,cpu要对他们读写时,会通过控制总线发出指令
虽然在物理上,这些存储器都是独立的,但在cpu看来,它们都是内存
cpu把他们看作由一些存储单元组成的逻辑存储器,也就是内存地址空间
它们组成的是一个一维的线性空间,每一个内存单元在这个线性空间里都有唯一的地址,成为物理地址
每一个物理上的存储器,在这个逻辑存储器中,占有一个地址段,也就是一段内存地址空间
所以说我们在逻辑存储器中修改这段地址的数据时,修改的也就是对应的物理存储器里的数据
因此在面向硬件编程时,要知道系统中内存地址分配情况
比如要修改显存地址,才能向显示屏输出,因此我们需要知道逻辑存储器中哪一段地址对应的是显卡的 RAM,然后修改这一段内存中的数据就好了
2.寄存器及cpu、内存访问原理等
2.1cpu概述
cpu由运算器(信息处理)、寄存器(信息存储)、控制器(控制各种器件工作)等组成
这些部分由内部总线连接(相应的,外部总线就连接xpu和主板上其它器件)
不同cpu在寄存器的结构和数量上会有不同,例如 8086cpu 就有 \(14\) 个寄存器(当然这些寄存器也分为了不同的类型)
后面的例子和代码的编写大都是针对 8086cpu 的,8086cpu 是 X86 架构的鼻祖(现在很多 cpu 也是用这种架构),有 \(20\) 根地址总线(也就是寻址能力为 \(1M\))
架构、寻址方式(尤其是段地址和偏移地址的那些内容)等与现在的cpu大都相同,区别也只是在于位数增加,寄存器数量增加等,所以就以这个cpu为例子了
它是 \(16\) 位结构的,也就是:运算器一次可以处理 \(16\) 位的数据,寄存器(下面会提到)宽度为 \(16\) 位,寄存器和运算器之间的通路是 \(16\) 位
所以说一次能处理、传送、暂时存储的数据最大 \(16\) 位
2.2通用寄存器
8086cpu中,ax,bx,cx,dx 这四个寄存器,用来存储一般信息,成为通用寄存器
这些都是16位寄存器,当然也有与之相应的更多位的寄存器。比如 eax 是32位寄存器(还有 ebx,ecx 等),rax 是64位寄存器
显然,16位寄存器能存储的最大数据是 \(2^{16}-1\)(不考虑符号时)
8086cpu是第一代 16 位cpu,之前的cpu都是 8 位的,为了满足向下兼容的要求,这四个寄存器每个又可以分为两个(高位和低位)来使用
比如 ax 分为 al 和 ah,分别代表低 8 位(由 0-7 位构成)和高 8 位(8-15 位)
像这样,这张图是没有把里面存储的数据画上(标的数字是位数),如果有的话,左边是高位,所以应该由左往右读数据
8 位是一个字节,那么 16 位 cpu 就带来了一个新的概念,16 位称为一个“字”
一个字由两个字节(分别是它的高位字节和低位字节)组成
2.3几个汇编指令
所以终于有代码了
mov
:mov ax,18
将 \(18\) 送入 ax 寄存器,mov ax,bx
将 bx 中的值送入 ax 中
add
:add ax,18
将 ax 中的值加上 \(18\)(存在 ax 中),add ax,bx
将 ax 的值加上 bx 的值(存在 ax 中)
注意,如果在执行指令中,比如 al 发生了溢出,并不会溢出到 ah(但cpu也不会把它丢弃,具体会怎样在后面会有)
也就是,虽然 al 和 ah 物理上是连续的,但它们在分别被使用时,都是独立的
2.4 8086cpu给出物理地址的方法
上面说过,8086cpu 有 \(20\) 根地址总线,但cpu的内部总线只有 \(16\) 根,一次只能传 \(16\) 位的信息
所以,cpu在确定地址的时候,如果直接按一次传送结果来寻址,寻址能力就只有 \(2^{16}=64\ KB\) 了
这时候就需要“段地址”和“偏移地址”了
这两种地址,都分别是一个 16 位的数,确定地址的过程是这样的:
- cpu相关部件先把这两个地址提供给“地址加法器”,默认应该是先传送段地址,再传偏移地址(通过cpu的内部总线)
- 地址加法器将这两个 16 位地址合成位一个20位地址
- 地址加法器再通过内部总线把这个 20 位地址送入“输入输出控制电路”
- 输入输出控制电路通过地址总线把这个地址传给内存
再说这个地址加法器如何工作
其实很简单,就是 段地址*16+偏移地址,就得到了20位物理地址,如下图
可以更深入的理解为,这种寻址方式,是用一个“基础地址”(就是段地址乘十六),加上一个偏移地址得出物理地址,来解决cpu内的数据宽度和地址总线宽度不对应的问题
也可以说,基础地址等于段地址乘十六,是这种寻址方式(物理地址=基础地址+偏移地址)的一种具体实现
2.5段
前面已经说过“段地址”这个概念,那是不是内存就是被分成一个一个的“段”?
其实不是。内存没有分段,但 8086cpu 用的是“物理地址=基础地址+偏移地址”来给出物理地址,所以,这也让我们用“分段的方式”来管理内存
也就是说,内存都是连续的,但由于cpu使用段地址和偏移地址来确定物理地址,所以在编程中,我们可以指定一段连续的,大小为 \(N(N\le 64\ KB)\) 的内存作为一个“段”
为这个指定的段确定一个基础地址(这个段内存地址的起始地址),然后当我们想要对这个段内的某个内存单元寻址时,就用给出基础地址和偏移地址的方式来进行
为什么大小一定要小于等于 64KB?因为偏移地址是 16 位,寻址能力只有 64KB
基础地址有什么要求?因为 8086cpu 对基础地址(一个段的起始位置)的确定方式是“段地址*16”,所以,段的起始位置一定是 16 的倍数
比如,可以定义 10000H-100FFH 为一个段,但不能定义 10001H-100FFH 或 10000H-FFFFFH,因为它们分别出现基础地址不是 16 的倍数和段长度过长的问题
其实可以这样理解,也如下面所说,一段内存既可以分成一个段,也可以分成多个;一个物理地址也可以通过多组段地址、偏移地址表示,所以段看起来似乎是“并不存在”的,而内存实际上也是连续的,所以对于段的划分,更多的是偏向于“逻辑上的”
但由于是因为cpu内部数据宽度导致的需要这种划分,所以也是说段的划分实际上来自于 cpu
比如说 10000H-100FFH 这一段内存,它既可以向上面说的那样分成这一个段,也可以分成 10000H-1007FH,10080H-100FFH 这两个段,只是在于分的方式不同
而且,一个确定的物理地址,可以由多组不同的段地址和偏移地址来组成,如下图:
对于8086cpu,在说一个物理地址时,一般有两种表述:内存 1000:FF 单元;或者内存 1000H 段中的 FFH 单元。其实一般常用的是前者
2.6段寄存器以及 cs 和 ip
段寄存器,用来存放段地址,把段地址送入地址加法器合成物理地址
8086cpu有四个段寄存器,cs(code segment),ds(data segment),ss(stack segment),es(extra segment),分别是代码段,数据段,堆栈段,附加段
下面介绍 cs 与 ip
分别是代码段寄存器,指令指针寄存器,指示cpu当前要读取指令的地址
cs 存的是这个地址的段地址,ip存的是偏移地址,由这两个确定当前要读取指令的物理地址(cs:ip)
更详细的读取方式:
- cs 和 ip 里的数据被送入地址加法器
- 通过地址加法器合成指令的物理地址,通过输入输出控制电路和地址总线(20 位),送入内存
- 内存定位到指令的地址,指令通过数据总线送入 cpu,被输入输出控制电路送入“指令缓冲器”
- ip的值自增,增加的值为加指令长度大小。刚看到这里的时候也有疑问,就是cpu如何获取指令长度(因为指令的长度不都是相同的,所以说 ip 每次自增的值也不一样),后来知道其实指令的长度已经包含在指令信息中了,可以通过指令信息来获取
- 指令被“执行控制器”执行,然后再回到最初去获取、执行下一条指令
8086cpu 在cpu刚开始工作时,会将 cs 设为 FFFFH,ip 设为 0000H
如何修改 cs 和 ip 的值
cs 和 ip 也是寄存器,但 ip 不可以直接用 mov
指令修改,8086cpu提供了另一种指令 jmp
具体格式是:jmp 段地址:偏移地址
,比如 jmp 2A33:13
就和 mov cs,2A33H , mov ip,0013H
相似(当然不能用后者)
还有一种用法:jmp 某一合法寄存器
,这种方法只修改 ip,例如 jmp ax
就类似于 mov ip,ax
(当然还是不能用后者)
其实 cs 也是可以用 mov
来改的,但 mov cs,XXX
中对这个 XXX 的类型有一些要求,具体在下面会说
代码段
在 8086cpu 上编程时,根据需要,可以将一个长度小于等于 \(64KB\) 的,连续的,起始地址为 \(16\) 倍数的内存空间设为一个代码段,用来存放代码
但我们把这一个段设为一个代码段,这只是一个我们自己的安排,cpu 并不知道这种安排,所以我们要通过修改 cs 和 ip 的值让它们指向这个段的第一条指令,才能使这个代码段中的代码开始执行
显然,想要一个代码段执行,只要把 cs 设为它的段地址,ip 设为 \(0\) 即可
也因此,cpu只是把 cs:ip 指向的内存的内容当作指令,如果用 cs:ip 指向了一串数据,cpu也会把这串数据当成指令
这也是一种数据、指令的区分,就和前面说的“数据总线传来的是数据,控制总线传来的是指令”相似
更深入的说,就是cpu只认“数据”,不同东西指向的“数据”,被“解读”为相应的类型:cs:ip 指向的,就认为他是代码;ds:[address](这是种寻址方式,后面讲)指向的,就被认为是数据;栈顶指针指向的,就认为是栈空间......
2.7使用debug调试、编写程序
可以尝试打开 cmd 执行以下 debug 看行不行,反正我这 win10 是没有,没有的话自己去搜一搜下一个就行了
我看小甲鱼的视频里用的是 winXP,可以使用,但我这下载下来也不能执行,会报错
所以还需要一个 dosbox,可以去下一个,使用起来也很简单,挂载什么的有一些基础应该就能整明白,当然网上也有各种教程
debug 的一部分常用操作是这样的:
更具体地:
https://www.cnblogs.com/tiger2soft/p/5094917.html
http://staff.ustc.edu.cn/~zhoudf/2014summer/debug.pdf
https://www.jianshu.com/p/843661407333
注意,在debug里直接往内存写代码,和用文件写代码在编译,对指令的处理是有区别的,后面会讲到这点
主板的 ROM 上有一个生产日期,在内存 FFF00H-FFFFFH 中的某几个单元,可以这样查看,我也不知道为啥会是92年:
左边的部分是内存信息,右边是将内存的值按照ASCII码翻译后的结果,红圈的部分即为生产日期
然后尝试用 e
来修改一下,发现修改完以后再查看内存值没变,因为这是ROM
还有一个比较有意思的实验,就是往 B810:0000 中写一些数据,发现屏幕上会出现一些字符,因为这其实是显存的地址
另外,写的数据是 16 进制,查询一下对应的ASCII码,就可以确定他会在屏幕上显示的数(其实还并不完全是这样,因为还有控制颜色等的位置上的数据,后面到编写程序时会更深入)
2.8内存中的字
一个内存单元存放一个字节的数据,那一个字就需要两个内存单元存放
长度为一个字的数据称之为“字型数据”
这时,就出现了大端和小端的区分
大端模式,是指数据的高字节保存在内存的低地址中,而数据的低字节保存在内存的高地址中,这样的存储模式有点儿类似于把数据当作字符串顺序处理:地址由小向大增加,而数据从高位往低位放;这和我们的阅读习惯一致。
小端模式,是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中,这种存储模式将地址的高低和数据位权有效地结合起来,高地址部分权值高,低地址部分权值低。
上面是一个例子,对于小端的那个,我说“4000H 内存中存放的字型数据”,那么指的就是把 4001H 中数据作为高位,4000H 中数据作为低位,组成的 16 为数据,也就是 5678H
而我说“4000H 内存中存放的字节数据,指的就是 4000H 中的这个 8 位数据,也就是 78H
但是在大端中,”4000H 内存中的字型数据“就是 1234H
下面的整篇博客都以小端存储为前提(现在大部分机子好像都是小端吧,其实主要是小甲鱼的教程也是小端),所以想下面我们说地址 \(N\) 处的字型数据,说的就是 \(N\) 和 \(N+1\) 这两个内存单元的数据,按小端存储模式下的数据
如何判断自己的机器是大端或小端
其实很简单,大体思路是这样的,我们先用 c++ 的 short int
创建一个短整形数据,但 c++ 的 short int
说的好像只是“不比 int
类型的数据长”,所以不一定是两个字节的,应该先用 sizeof(short int)
来看看,不过我这的长度是两个字节
这样以后,我们将它的指针强转成 char
类型的指针,就变成了两个指针(因为 char
是一个字节,如果前面 short int
是 4 字节,就搞四个指针就好),分别存下来高位、低位两个指针指向的数,并输出
int main(){
short int x=0x1122;
char x0,x1;
x0=((char*)&x)[0];
x1=((char*)&x)[1];
printf("x0 : %d\nx1 : %d",(int)x0,(int)x1);
return 0;
}
我机器上的运行结果(Intel 处理器,win10 系统):
x0 : 34
x1 : 17
x0
是指向低地址,对应的数是 34,也就是 22H,所以低地址上的数是低位,我这是小端
反之,如果 x0
是 17,那就是大端
Intel的80x86系列芯片是唯一还在坚持使用小端的芯片,ARM芯片默认采用小端,但可以切换为大端;而MIPS等芯片要么采用全部大端的方式储存,要么提供选项支持大端——可以在大小端之间切换。另外,对于大小端的处理也和编译器的实现有关,在C语言中,默认是小端(但在一些对于单片机的实现中却是基于大端,比如Keil 51C),Java是平台无关的,默认是大端。在网络上传输数据普遍采用的都是大端。
——百度百科
关于字型数据的传输:由于 8086cpu 是 16 位的,有 16 根数据线,一次传输就是 16 位数据,可以直接用 mov
一个 16 位寄存器来传输
当然,如果用 al
这种 8 位寄存器当然就是传 8 位了
2.9 ds 以及 [address]
数据段
和之前说的代码段类似,就是一个这样的用来存储数据的段
ds 是数据段的段地址,[address] 是他的偏移地址
cpu在访问内存数据时,要先给出内存单元的地址,而在 8086cpu 中,这个地址由段地址和偏移地址组成
前面说过,ds 是 data segment,数据段寄存器,所以一般是用来存放当前要访问数据的段地址
一个例子,比如要访问 \(10000H\) 的数据,要先把 ds 指向它对应的段地址 \(1000H\),用如下代码
mov bx,1000H
mov ds,bx
注意,这里不能直接用 mov ds,1000H
这种语法,想往 ds 里送入数据时,由于不能直接送立即数(就是一般来说的常数)进入段寄存器,要先送入寄存器,再进段寄存器,这是8086cpu硬件决定的
然后要读取 \(1000:0\) 的数据,就是:
mov al,[0]
这就读取了 \(10000H\) 的字节数据,当我们使用指令 mov 合法寄存器,[address]
时,是从 ds:[address] 中取数据,所以用之前要先设置 ds 的值(段地址),这个 address 也就是偏移地址
当然,如果是 mov ax,[0]
就是送入 ax 10000H 处的字型数据
同理,也可以使用 mov [0],al
类似的指令
\(2.9\frac{1}{2}\) 目前学过指令的整理
现在 mov
的用法已经有了这样的几种
- mov 寄存器,立即数:
mov ax,8
- mov 寄存器,寄存器:
mov ax,bx
- mov 寄存器,内存单元:
mov ax,[0]
- mov 内存单元,寄存器:
mov [0],ax
- mov 段寄存器,寄存器:
mov ds,ax
那么推测下面几种情况是否可以使用
- mov 寄存器,段寄存器:就是把段寄存器中的内容送入寄存器,比如
mov ax,ds
- mov 内存单元,段寄存器:
mov [0],ds
- mov 段寄存器,内存单元:
mov ds,[0]
经过在 debug 中的测试,发现上面这三种都是可以的
但 mov 段寄存器,段寄存器
,mov 内存单元,立即数
,mov 内存单元,内存单元
是不行的(比如 mov cs,ds
和 mov [0],1234H
,mov [0],[1]
)
从硬件方面说一下为什么两个内存单元不能直接 mov,cpu从内存取数据处理,处理的方式就是内部的寄存器之间倒数据,所以内存之间无法直接不通过cpu拷贝数据
而且,cpu对内存读和写是两种状态,显然不能同时进行
再说 add
和 sub
指令,它们格式都是一样的,就是一个是加一个是减而已(结果都存在第一个里面)
同样,也不可以用 add 段寄存器,立即数
这种指令
也不可以用 add 段寄存器,寄存器
,add 寄存器,段寄存器
, add 段寄存器,内存单元
,add 内存单元,段寄存器
,add 内存单元,立即数
,add 内存单元,内存单元
,这种,这些在 debug 种尝试编写是都会直接报错
可以用的和 mov
的有些类似,直接截图了
2.10栈
栈是一种有特殊访问方式的存储空间,这个特殊访问方式就是,先进去的,会后出来,也就是 FILO(first in,last out)
在高级语言中,定义的局部变量,调用的函数等,底层也都是通过栈来实现的
与之相对的先进去的先出来是队列
可以想象一个杯子或盒子,反正就是一个底部有底,上部没盖的容器
先往里放入第一个元素,再放第二个,等等,直到放到第 \(n\) 个(入栈,或者说压栈)
这个最上面的元素,在这里是第 \(n\) 个元素,称之为栈顶元素
然后这个第 \(n\) 个,放在了最上面,而第一个被压在了最底下,所以出栈时,先出第 \(n\) 个,然后 \(n-1\) 个,最后出第二个,第一个(出栈也叫弹栈)
cpu提供的栈的机制
先说站段,和上面的代码段数据段一样,在内存里安排一段空间(符合段的要求)当作栈空间
有这样两个寄存器:ss
存的是栈段的段地址(stack segment),sp
是栈顶的偏移地址
sp:ss 任意时刻始终指向栈顶
有这样两种指令:
- push 寄存器,比如
push ax
就是把 ax 的值送入栈中,具体执行过程:
1. sp 变成 sp-2
2. ax 送入 ss:sp(送入一个字型数据,由 sp,sp+1 组成) - pop 寄存器,比如
pop ax
就是把 栈顶的值送入 ax,执行过程:
1. 将 ss:sp 处字型数据,送入 ax 中(也就是 sp,sp+1 组成的)
2. sp 变成 sp+2
就像上面加粗内容,8086cpu 的这两种指令,都是针对字型数据,也就是 16 位(当然如果是位数更多的cpu,就是针对更多位的数据),也就是说,push al
这种指令是不合法的
同样不合法的,还有 push
或 pop
一个立即数;但如果是内存单元或段寄存器,是合法的
根据 sp 的变化,也可以发现,栈空间是从高地址往低地址用的,如下图:
还有就是,出栈时,只是改了栈顶指针,并送出数据,并没有完全的把栈顶数据抹掉,所以栈顶数据还在内存里(直到被修改)
同样原理,这也是为什么有时格式化磁盘后还能复原,因为只是改了索引等,真正的内容还留在里面,如果想“完全”清除这些东西,还要不断的往里写入来覆盖掉(下面那张图只是没有把已经弹栈的数据写上而已)
当栈为空时
前面说了,ss:sp 永远指向栈顶元素,但当栈为空时,没有栈顶元素,那 sp 应该赋为何值?
应该让 ss:sp 指向栈段中第一个(地址最高的)内存单元,的高一位地址
也很容易理解,因为第一放入栈中的字型数据,是比地址最高的内存单元低一位地址,那在第一次 push 时,sp 加二得到这个地址,所以自然应该初始时把 sp 赋为这个地址减二,也就是刚才描述的那个地址
举个例子,将 \(10000H-1000FH\) 当作一个栈段,ss(段地址)是 \(1000H\),那么栈空时,sp 应该为 \(10H\)(最高地址的偏移地址是 \(FH\),再高一位是 \(10H\))
栈顶越界
ss:sp 指向栈顶,但 cpu 并不知道这一段栈空间有多大,从哪个内存单元到哪个内存单元,因此也就不会为我们检查栈顶是否越界
因为 8086cpu 中没有寄存器里会存这个栈段的起始、终止地址(别的 cpu 应该也没有)
这就像是 cs:ip 指向要执行的指令,但 cpu 并不知道指令一共有多少,如果 cs:ip 越界了,出了当前代码段,cpu 也不会检查(也没法检查)
那如果越界了会怎么样?
比如我不断 push,直到有一次 push,sp 减了二,这时 ss:ip 指向的地址到了当前栈段的外面,但cpu还是会把相应数据送入内存,也就抹掉了内存上其它地方的元素
不断 pop,直到 ss:ip 指向了栈段外面,但 cpu 还是会把相应内存里的值发送到寄存器(或内存单元)中
所以说,栈顶越界是危险的
由于栈空间也只是内存的一部分,它只是一段可以通过特殊方式访问的内存,所以内存中在这个栈段旁边,很有可能就是一些有用的代码、数据等,但因为越界就把它们更改了(或者把错误的数据读入了进来),引发错误
这也就是栈顶越界攻击的原理
2.11关于段的总结
什么样的内存空间(就是什么起始地址和长度的要求)可以安排它作为一个段上面已经说过了,不重复了
重点是要清楚,我们可以用一个段存放数据(称之为“数据段”,存放代码、栈同理),我们这样“安排”了,如果想要cpu按照我们这样的安排来访问这些段,就要用段寄存器等来指向它们
比如对于数据段,要用 ds 指向它的段地址,然后用 [address] 确定偏移地址,这样再通过一些指令让cpu访问对应内存单元
而代码段和栈段,就分别用 cs:ip 和 ss:sp 指向对应内存单元,来进行访问并执行相关指令
就是说,当 cs:ip 指向一个内存单元,它被当成代码;当 ds:[address] 指向它,它被当成数据;ss:sp 指向它,它就是栈顶元素
所以,一段内存空间,既可以当作代码段,也当作数据段,还可以当作栈段(也可以啥也不是)
它们的区别关键在于是什么指向它们
总的来说,就是这些代码、数据、栈段,都是我们安排的,而读取 ds:[address] 的数据、执行 cs:ip 的指令,处理 ss:sp 的栈元素,这些是cpu执行的