学习汇编随手记
前言
本笔记是关于王爽汇编的笔记,覆盖不全,到了内中断就完结了,听从学长建议,我跑去学xv6了,x86告辞。
1. 寄存器
1.1 寄存器初步
(A,B,C,D)X
是通用寄存器,通常存放一般性数据,而形似A(H,L)
则是为了向下兼容,而由AX分裂的八位寄存器。
这里值得注意的是,dosbox不支持debug模式直接用r
直接修改AH或者AL,但是可以通过a
来直接进入汇编指令模式,这种情况下是支持的。
字的概念:一个字由两个字节组成,一个字节由8bit组成,而在ax中,分为高位字节和低位字节,对应了我们的兼容性的考虑。
debug模式下,通过输入a
可以直接进入汇编代码模式,我们可以输入mov ah, 15
来改变ax的高八位,这里的15是16进制,无论你输入什么,都会默认是16进制的数字,而你如果在debug模式下,直接在后面加上H,是会报错的,位数多了会报错,但是如果位数少了不会。
如果加法出现溢出,懂得都懂,会无视溢出,该进位进位,如果对AL和BL进行加减,进位不会波及到AH或者BH,因为此时采取的是八位运算。
8086:他的cpu是16位的,而有20位的地址总线,这就产生了木桶效应,而计算机总是神奇的,他给出了两个16位地址合成的方法形成了20位的物理地址,采用的是物理地址=段地址×16(二进制左移四位) + 偏移地址,这是我们在计组中学过的概念,这就使得,即便cpu只能提供16位的地址,但是cpu可以通过提供两个相关的部件来提供两个16位的地址,从而在地址加法器中计算出物理地址,提高寻址能力。ps:原书这里对于段地址+偏移地址的解释非常好,强烈推荐去看看!
段地址:之前有了解过os的同学可能会搞混地址分段和段地址的概念,但是他们是不一样的东西,段地址是具体地址的表示方式,而地址分段我还没深入学,这里就不介绍了,
段寄存器:这里不得不提到段寄存器的概念,我们在dosbox里面输入r
,然后输入a
,可以清晰的发现,汇编代码模式下的左侧num:num
的数字和两个寄存器的数值一模一样!他们就是CS(代码段寄存器)和IP(指令指针寄存器),而我们输入t
的时候,我们可以很轻易的发现,我们的IP会发生偏移,这也就意味着,随着我们IP的一次次偏移,我们输入的指令也会一步一步执行!!读到这里,一直困惑我的一些内容也得到了解决,比如,程序的代码是如何运行?答案显而易见。
一个实例,清晰了解:
CS和IP通过加法器计算出当前应当执行的指令的地址,通过地址总线,发出读命令。
指令通过数据总线被送入CPU。
输入输出控制电路将指令送入指令缓冲器。
读取一条指令后,IP自增,使得可以读取下一条命令。
而在送入指令缓冲器之后,会送入执行控制器,执行这条指令。
此处省略一些步骤,最终ax变成了我们相应的数值。
这样,我们的一条指令就执行完毕了。
那么我们回过头来,我们在编程中可以轻易地改变这些寄存器的数值,从而掌控全局。
但是,事实上,我们的mov指令并不能直接操作CS:IP这两个寄存器,除此之外,我们可以通过jmp CS:IP
这个指令来移动CS:IP的位置,另外,像jmp ax
,就相当于是mov ip, ax
但是实际上这个指令是不存在的,这里仅仅是比喻,另外我们在debug模式中,通过r命令也可以修改CS:IP的数值。
在debug模式中,有这些命令:
r命令,查看cpu相关寄存器,可以修改寄存器值
d命令,查看指定CS:IP的机器码
e命令,修改执行CS:IP的机器码
t命令,执行一条指令
u命令,查看机器码对应的汇编命令
a命令,进入汇编模式,通过这样以汇编的形式讲机器码写入内存
q命令,退出debug模式
1.2 内存访问
字单元:存放一个16位字节的字型数据的内存单元,分高位内存单元和低位内存单元,分别存放数据,以N位起始地址的字单元称为N地址字单元。
我们提过mov和add指令,但是实际上,我们的数据是存储在那里的?这就需要引入DS这个寄存器,存放要访问的段地址,而偏移地址通过[偏移地址]
的格式来表示,但是我们的dosbox不支持直接mov ds, 1000
来修改段寄存器,所以我们需要一个中转的寄存器,比如说
这样,可以讲2000:0000位置的值赋给al,也就是说[...]表示一个偏移地址!而ds寄存器则是我们的段地址。
我们也可以通过mov [0], al
将寄存器的值送入内存,这里还有另一个知识点,就是1000:[1]存储的是字型数据的高八位,而[0]存储的是低八位
sub,add,mov:mov可以通过中间寄存器去操作段寄存器等,但是sub和add均不能操作段寄存器/指令指针寄存器
stack栈:在汇编中也有栈的概念,我们通过pop [to]
和push [from]
来完成栈的操作,他们是通过SS:SP这两个寄存器来管理的,SS是段寄存器,而SP是偏移量,任意时刻指向栈顶元素,甚至,我们可以通过改变栈指针,来实现改变这个栈本身,太恐怖了,同时,在dosbox里面并不能自己检测栈顶是否越界,需要我们手动管理,本质上,pop和push是一种内存传送指令。
此处需要注意,栈是向下增长的,所以弹出元素,SP会增加,推入元素,SP会减少。
2.编写程序
2.1 概述
首先看汇编代码:
虽然我们可以在在dosbox里面使用edit模式来进行写代码,但是感觉不太好用。
步骤:
masm xxx.asm
link xxx.obj
xxx
执行
3. [bx]和loop
3.1 概述
[bx]和[0]有些类似,都表示内存单元,而[0]作为偏移地址可以随便指定,而[bx]也表示偏移地址,但是他的偏移地址就是寄存器bx中的数值,他们的段地址都是ds。
loop,就是循环
如下所示,cx代表循环的次数,每执行一次循环,cx--,直到为0,停止循环。
另外,虽然我们在debug模式下,可以直接使用[num]表示偏移地址,但是在源代码中,我们需要使用段寄存器:[num]
来表示,否则,你的[num]就会被解释成为num,当然,如果直接将num传送到bx寄存器上,然后直接通过[bx]
访问也是可行的。
段前缀(包括cs:[]/ss:[]/ds:[]/es:[])咋用?我们可以通过段地址来实现内存复制,比方说:
我们可以通过额外段地址来存放需要复制的另一块区域,从而便利的实现内存的复制!
4. 包含多个段(segment)的程序
当我们希望能够同时相加多个数字,同时希望能够使用循环的方式,这和我们使用的数组很相似,我们可以通过这样来实现:
根据新引入的start标志,我们可以将程序设计为以下结构
那么,回到我们的主题,如何将代码分段管理?
总体来看,这段代码还是比较简单的,就是将data数据段中的数据压入栈中,然后再弹出来,同时尽管我们设置了堆栈段,但是我们还需要再start中根据段地址来初始化我们的ss和ds寄存器,以实现栈段和数据段匹配!
事实上,某一个段是栈段还是数据段,并不是在创建这个段时决定的,事实上,和他的名字没啥关系,而是和管理这个段的寄存器有关系,我们可以在代码段中将栈段寄存器的指针指向这个段,这样,才算成为一个完整的段。
5. 地址定位
5.1 技巧类
and,都为1才为1,否则为0
or,有一个为1,那么结果就是1
根据这两个,我们可以用已经学过的指令和ASCII码的性质实现字符大小写转换:
同时,为了更灵活的定位,我们的[bx]甚至可以采取[bx + idata]的形式,来实现更灵活的寻址。
另外,si(source index)和di(destination index)寄存器和bx的功能类似,完全可以相互替换使用
在我们需要往字符串后面追加字符串的时候,就可以将这几个寄存器搭配使用,使得效率更高。
我们也可以利用这几个寄存器执行更灵活的寻址操作,这里可以看原书,比如[bx + si + idata]的形式,这里有很多种写法
当我们遇到使用这种寻址方式的时候,也难免会遇见要使用多重循环的情况,但是我们实际上只有一个cx,咋办?我们可以通过dx将这个cx保存起来,比如说:
当然,这种方法并不是通用的,我们可以将其保存到内存之中,需要使用的时候,再从内存中恢复,像这种保存状态,恢复状态,我们肯定会使用栈!
比如:
这里有几个计组的概念:直接寻址/寄存器间接寻址/寄存器相对寻址/基址变址寻址/相对基址变址寻址,他们都是基于之前说的多个索引类寄存器来寻找地址,而事实上,在面对更多维度的数组的时候,寄存器的数量是有限的,我们可以通过计算之前几个维度的长度的乘积来获取索引,也就是说,多维数组实际上是连续的!这也就解释了go语言里面的切片等等的扩容机制为什么会重新开辟新的空间,而不是原地扩容了。
5.2 指令类
有时候,我们需要针对于访问的内存做一些标准,比如mov数字1的时候,我们可能不知道希望mov1个字节单元还是一个字单元,这时候就需要用到:
div:情况有点多...这里看8位和16位就行了
8 位
AX (高8位AH,低8位AL)
8位寄存器/内存
AL
AH
16 位
DX:AX (高16位DX,低16位AX)
16位寄存器/内存
AX
DX
32 位
EDX:EAX (高32位EDX,低32位EAX)
32位寄存器/内存
EAX
EDX
64 位(仅64位模式)
RDX:RAX (高64位RDX,低64位RAX)
64位寄存器/内存
RAX
RDX
事实上,在我们的div中,被除数是由ax和dx联合起来用的
语法为div [寄存器/内存]
但是不能直接给出数字!被除数默认已经确定了。
定义数据的时候,有以下三种:
db,占一个字节
dw,占一个字,两个字节
dd,占两个字,四个字节
但是像之前那样,一次定义很多空间的时候,非要去一个一个输入吗?而且数起来也很麻烦,这个时候,有一个操作符dup,可以实现批量重复数据,开辟空间:db 7 dup (0)
就相当于开辟了7个字节的长度,数据均为0,同时也可以这样定义:db 3 dup ('abc', 'ABC')
来实现重复开辟多个abcABC的空间。
在这里,推荐一下lab7,巩固一下还是很有帮助的。
6. 转移指令
offset是啥?一段代码看懂!
这里主要是讲我们的s中的第一段代码拷贝到了s0中,这就是offset的作用了!
jmp,是一个无条件转移指令,可以修改CS:IP,也可以只修改IP
值得注意的是,jmp short
指令转换成机器码,竟然是不包含目标地址的!但是CPU不是神仙,他是如何找到需要跳转的地址的?答案就是jmp short
转换成机器码,虽然没有直接包含目标地址,但是却包含了目标地址的偏移量,从而节省空间,也就是说——jmp short
的功能是IP ± 偏移量
jmp short:短跳转,后跟标签等,范围128,超出会报错。
jmp near ptr:段内跳转,跟段内地址。
jmp far ptr:长跳转,后跟详细地址
jmp word ptr:类似near,后接字,实现的是段内跳转
jmp dword ptr:类似far,后接两个字,实现的是段间跳转。
jcxz:当cx为0的时候执行跳转,否则不执行
loop:和上面相反,当cx!=0的时候执行跳转,并且cx--,否则不执行任何操作,与此同时,这俩都是实施的短跳转,转换成机器码的时候,后跟的是偏移量。
dec,inc:自增自减,不必多说。
这里书上有一个lab8,奇怪的程序可以分析一下,就是我们之前讲过的jmp的应用。
lab9:
有点意思,做到这里让我想到一句话,计算机的世界里没有魔法。
7. call and ret
ret:利用栈中数据,修改IP的内容,实现近跳转
retf:利用栈中数据实现far跳转,修改CS:IP
可以等价于对CS和IP进行pop
call:call,相信很多人对这个指令很熟悉,但是,call是跳转指令,也是修改CS:IP的,并且需要将相对应的CS:IP压入栈中,call也有几种不同的类型:
call near ptr:将IP压入栈中
call far ptr:CS:IP压入栈中
这里值得注意的是,call并不能实现短转移,并且其转移的原理和jmp相同,但是加了一个把地址压入栈中
这两个指令常用来形成函数,书上叫做子程序,这也是模块化程序设计的基础。
mul:八位乘法,放在ax中,16位乘法,高位DX,低位AX,同时,其中一个乘数固定是ax,只需要给定一个数字即可
当我们需要传入过多数据,寄存器不够用时,我们可以传递一个存放这一堆数据的指针,在传递一个长度,比方说,需要将一串字符串的所有字符转换成大写,传递一个指针,然后loop执行,或者说,在传入的数据末尾,加上一个0,使用jcxz判断数据最后是否为0即可。
但如果想要将一个字符串数组全部转换成大写的,就会使用到两层循环,这个时候如何函数体内使用cx的话,就会导致错误,解决方法就是在进入函数之前,将子程序用到的所有寄存器压入栈中,子程序返回后再恢复状态!
比如:
lab10-1:
剩下两个lab真懒得写了,next
8. 标志寄存器
这是我们将要学习的最后一个寄存器了
作用:
用来存储相关指令的某些执行结果。
用来为CPU 执行相关指令提供行为依据。
用来控制 CPU的相关工作方式。
ZF标志(zero):记录相关指令执行后的结果是否为0,如果是0,那么zf=1,否则zf=1,一般来说,像add,sub,mul等逻辑运算,是会影响zf的,但是mov,push等传送指令不会
PF标志(parity):记录奇偶性,偶1积0
SF标志(sign):符号标志位正0负1.
CF标志(carry):无符号运算的进位或者借位标志位,即便是在两个比较大的数字相加,产生进位导致溢出,这个进位也会存储在CD中,减法当然也一样。
OF标志(overflow):记录有符号运算中中产生的溢出
abc指令:abc是带进位的加法指令,功能为ax = ax + bx + cf
。
sbb指令:带借位的减法指令,实现的是ax = ax - bx - cf
。
cmp指令:相当于减法,但是不保存结果,仅仅根据计算结果对标志位进行设置,通过这样,可以实现各种比较运算,很多人第一直觉是只通过sf来进行比较大小,但是真的假的?如果计算发生溢出,那么就会产生错误,所以还需要判断of溢出位来进行判断。
当然,通过比较之后,我们可以通过条件转移指令来修改IP,除了jcxz,常见的有:
je(jump equal):相等则跳转(检测zf=1)
jne(jump not equal):不相等则跳转(检测zf=0)
jb(jump below):低于则跳转(检测cf=1)
jnb(jump not below):大于等于,即不低于跳转(检测cf=0)
ja(jump above):大于则跳转(cf=0以及zf=0)
jna(jump not above):不大于,小于等于则跳转(cf=1且zf=1)
这样,就比较好记忆了。
DF标志位:在串处理指令中,如果df为1,每次操作后,si和di递减,否则递增。
串传送指令movsb,相当于将源地址(ds:si)指向的字节复制到目标地址(es:di),并且根据df的标志位,让di和si自增或自减,还可以通过movsw来传送一个字,也就是byte和word。
而一般来说,这两个指令都和rep配合进行使用,如rep movsb
相当于
这样就可以快速的实现cx个字符的传送,而我们可以通过cld将df置为0,通过std将df置为1
另外,还有一种指令可以实现快速的保存寄存器,就是popf和pushf他们可以实现快速的将标志寄存器压入栈中,并且一次性弹出。
9. 内中断
cpu内部有什么事情发生的时候,就会产生需要处理的终端信息,有以下几种:
除法错误:0
单步执行:1
执行into指令:4
执行int指令:指令格式为
int n
,n就是提供给cpu的终端类型码
通过不同的类型码,我们cpu可以定位到不同的处理位置,来进行不同的处理,如何根据类型码来定位到相应的CS:IP地址?事实上,cpu是通过中断向量表来定位的,通过这样的方式来找到不同中断类型的不同的处理位置,而中断向量表则存放在内存中。
终端过程是咋样的?
收到中断信息,拿到中断类型码
将标志寄存器的值入栈
设置标志寄存器的第八位的TF和第九位的IF为0
CS内容入栈
IP内容入栈
从内存地址中相应位置的两个字单元读取程序的入口地址,设置CS:IP
可以简单表示为:
随后,便会执行由我们自己编写的中断程序。(我去,真的是执行自己写的中断程序啊,看到这里,总感觉莫名的兴奋,真的是掌控全局!ps:虽然感觉有点麻烦)
这里还需要引入一个iret指令,它通常和硬件自动完成的中断过程配合使用,用于中断返回,用汇编可以描述成下面的样子:
可以发现,出栈入栈顺序相互对应,计算机的世界里没有魔法!!!,iret执行之后,就会回到中断程序前的执行点继续执行程序。
以除法溢出为例,当发生除法溢出时,我们通过在debug模式下编写:
通过不断执行代码,我们会发现最终CS:IP会跳转到另一个位置,我这里是dosbox环境,并没有产生对应的divide overflow的信息,但是此时确确实实时跳转了,我觉得此时应该是还没有放任何中断程序,而需要我们自己去编写。
下面我们将进行伟大的一步——编写自定义中断程序
我们将我们需要编写的这段程序成为do0,毫无疑问,他需要被放在内存中,但是放在哪个位置成为了我们需要考虑的点,尽管我们能够去向操作系统申请内存,但是我们毕竟是汇编er,操作系统?大可不必理会。
书中说,我们将程序放在向量表中就可以了,这样能够简化布局,虽然感觉有点怪怪的,但是还是实践一下吧。
我们需要做的事情:
编写中断程序
将do0送入0000:0200
将do0入口地址存储在中断向量表的0号表项中
大体的代码框架如下,接下来,由我们去完善它!
现在先说一下,之前为什么要把TF位设置为0,因为我们每执行一条指令,我们的cpu如果检测到TF值为1,那么就会产生单步中断,中断类型码为1,也就是我们之前提过的单步执行。
知道了这个,先回想一下,为什么我们在使用Debug命令的时候,输入t命令,能够使得我们的代码一步一步执行,事实上,是不会由任何程序能够让cpu在执行一条指令后停止的,这里Debug是利用了cpu提供的这一中断功能从而实现的t命令展现出来的功能。
而如果我们在进入中断的时候,如果此时的TF=1,那么就会出现无限循环地去中断,那么我们就需要在进入中断的程序之前将TF置为0。而事实上,我们在输入t命令的时候,就会将TF置为1,然后会引发单步中断,但是事实上会在进入中断之前将TF置为0,以此来避免在执行中断处理程序的时候发生单步中断。
这里我其实思考了一下,为什么中断能够暂停程序?为什么有的中断不需要暂停,这个t到底是如何实现的直接把程序暂停了?我觉得,这跟输入输出有关,如果一个中断程序只需要输出,而不需要用户的交互,直接一步一步执行即可,在这种情况下,这个中断实际上也是一个子程序,但是如果需要用户的交互,比如说等待输入字符,那么此时就会产生中断就是在等待的错觉,事实上,他是在等待用户的操作,使得程序能够进一步执行。
最后更新于