《汇编程序设计与计算机体系结构: 软件工程师教程》这本书是由Brain R.Hall和Kevin J.Slonka著,由爱飞翔译。中文版是2019年出版的。个人感觉这本书真不错,书中介绍了三种汇编器GAS、NASM、MASM异同,全部示例代码都放在了GitHub上,包括x86和x86_64,并且给出了较多的网络参考资料链接。这里只摘记了NASM和MASM,测试代码仅支持Windows和Linux的x86_64。
4. 基本指令
4.1 简介:
在很多情况下,MASM会根据上下文来推测指令中的操作数是什么类型,有的时候NASM也是这样。在撰写指令的目标操作数时,如果要对变量解引用(dereference),NASM要求你必须指出大小,也就是必须在变量名的前面写上一个表示尺寸的命令,例如用BYTE表示字节、WORD表示字等,比方说像这样:”mov DWORD [test], eax”
4.2 数据的移动与算术运算:
MOV指令有几条具体的要求:
(1). 两个操作数的大小必须相同;
(2). 两个操作数不能全是内存操作数(也就是说要想在两个内存操作数之间移动数据,必须用寄存器做中介);
(3). 指令指针寄存器(ip/eip/rip)不能用作目标操作数。
使用XCHG指令可令两个位置上的数据彼此交换。和MOV指令类似,XCHG指令的两个操作数也不能全都是内存操作数。
加法与减法:INC、DEC、ADD、SUB
INC与DEC指令可以操作内存操作数(变量)或寄存器,这两个指令的目标很简单,就是给操作数加1或减1。
NASM对待变量的方式有点像C++对待指针的方式:如果直接写出变量名本身,那么它会将其视为内存地址。要想引用该地址中的内容(也就是由变量名所指代的那份数据),必须用一对方括号给变量解引用,如:”INC DWORD [sum]”
ADD与SUB指令可以用字面量来做加减法,也可以用内存或寄存器中的值来计算。
NEG指令可以切换操作数的正负号,也就是对操作数的值求补。
乘法与除法:MUL、IMUL、DIV、IDIV
MUL指令用来给无符号的整数执行乘法。该指令只接受一个操作数即乘数(multiplier)。被乘数保存在与乘数尺寸相对应的累加寄存器中。与被乘数一样,积的保存位置也无须手工指定,因为这是由MUL指令自动指定的,如下表所示:M指内存或变量,R指寄存器,L指字面值,被乘数与积的存放地点是根据乘数的尺寸默认指定的。
带符号的整数相乘要通过IMUL指令执行。与MUL一样,该指令也有单操作数的版本,在这种情况下,它所支持的操作数与上表中所列的相同。这条指令与MUL的主要区别在于它还有双操作数及三操作数的版本。双操作数的版本与ADD及SUB指令类似,也就是会把这两个操作数都当成运算的源数据,并且要将其中一个操作数用作运算的目标,以便存放计算结果。因此对于保存运算结果的目标操作数来说,IMUL与ADD及SUB一样都会将其中原有的值覆盖掉。如果不想让IMUL指令把这个值覆盖掉,那么可以使用三操作数的版本。该版本要求开发者指出乘数、被乘数与积的位置。
与MUL类似,除法也分为无符号与带符号两种。DIV指令用来执行无符号整数的除法运算,它会将结果以商与余数的形式分别保存,如下表所示:
使用DIV指令的时候只需要指定除数就可以了。被除数需要提前加载到与除数尺寸相对应的寄存器里。
带符号的整数需要用IDIV指令来相除。与IMUL指令不同,该指令没有双操作数或三操作数的版本,还是只提供一个操作数,即除数。与乘法指令不同,这两种除法指令都不会通过设定状态标志来反映与运算结果有关的信息,也就是说,相关标志的值是未定义(undefined)的。
移位:左移位(shift left)指令SHL与右移位(shift right)指令SHR都可以把内存操作数或寄存器中的值移动一定的位数,这个位数用字面量来指定。SHL和SHR是逻辑移位(logical bit shift),这会令某些二进制位出现在存储范围之外,同时会令空出来的那些数位都填上0.无符号的数据通常是可以做逻辑移位的。如果想通过左移位或右移位的方式给带符号的整数做乘除法,必须使用算术移位(arithmetic bit shift)指令以便保留其符号位。SAL(Shift Arithmetic Left)是算术左移,SAR(Shift Arithmetic Right)是算术右移。SAL与SAR的语法跟对应的逻辑移位指令类似,都是将内存操作数或寄存器中的值移动一定的位数,这个位数也用字面量来指定。
处理负值:4种能够执行符号扩展(sign extension)的指令,也就是CBW(将BYTE转成WORD)、CWD(将WORD转成DWORD)、CDQ(将DWORD转成QWORD),以及CQO(将QWORD转成OCTA),如下表所示:
4.3 数据的寻址与传输:
数据对齐:CPU访问存放在偶数地址上的数据要比访问存放在奇数地址上的数据更快。每种汇编器都有1条或多条命令用来修改位置计数器。MASM用的是ALIGN命令,NASM则依据不同的程序段分别使用ALIGN或ALIGNB命令。这几种命令的参数都必须是整数,而且应该是2的幂。这些命令会推进位置计数器,直到它的值变为该整数的倍数为止。这可以用来确保数据出现在偶数的内存地址上。
数据寻址:直接寻址(direct addressing)是直接访问某个值,而间接寻址(indirect addressing)则是通过值所代表的内存地址来访问另一个值。NASM的变量表示的就是其内存地址,而不是该地址中的值(如果想使用这个值,要用方括号括起来)。在MASM代码中,操作数的地址可以结合MOV指令及OFFSET命令来获取。LEA(Load Effective Address, 加载有效地址)指令在32位与64位模式之下都可以把操作数的地址加载到目标中。由于实际地址要在程序运行的时候才能够知道,因此,涉及这些地址的操作应该用LEA来完成。就获取内存地址而言,MOV与LEA指令之间的重要区别在于,后者的目标操作数必须是寄存器。
数组:汇编语言里的数组也是由类型相同的一系列数据构成的,这些数据在内存中以相等的间隔存放。数组的名称实际上指的是该数组中的首个元素。访问数组元素时,一定要正确算出该元素距离数组开头有多少个字节。汇编器不会自动检查你所做的访问是否与元素之间的边界相合。字符串(也就是由字符所构成的数组)的长度可以通过当前位置计数器(current location counter)来计算。其实不只是字符串,其它数组所占据的字节数也可以用这个办法求出。
用MASM代码来操作数组时的专用命令:TYPE命令返回变量所占据的字节数;LENGTHOF命令返回数组中的元素个数;SIZEOF命令则返回整个数组所占据的总字节数(相当于把TYPE命令与LENGTHOF命令所返回的结果乘起来)。
改变数据的大小及类型:MASM版本的代码需要使用一种新的命令即PTR命令,它会把早前定义的变量尺寸忽略掉,转而将该变量视为一个指向某份数据的指针,这份数据的大小由PTR前面的词决定。
我们在将数值复制到大寄存器的低半区时,应该使用MOVZ/MOVZX(ZX表示Zero eXtend,用0来扩展)与MOVS/MOVSX(SX表示Sign eXtend, 用符合位来扩展)两种指令,因为它们能够在复制的同时,用0位或符合位来填充高半区里的每一个二进制位。
5. 中级指令
5.2 按位执行的布尔运算:
NOT(非):它会分别反转操作数的每一个二进制位。这实际上就是在计算操作数的反码(或对操作数求反)。用来做NOT运算的那条指令也叫做NOT,它带有一个操作数。该指令将会对此数求反并把结果写入原位置。
AND(与):有一种常见的用途是通过AND来判断某值是偶数还是奇数。判断的原理是:检查受测数字的最低有效位(LSB)是0还是1。还有一个任务也可以通过AND操作迅速解决,即将ASCII字符从小写变成大写。大写与小写字母所对应的二进制值,大写与小写是由第5个二进制位(假设最右侧的叫做第0位)来控制的,除此之外,其它那些二进制位是完全相同的。此外,OR或XOR操作也可以用来转换大小写。前者用来将大写转成小写,后者可以实现双向切换:如果原字母是小写就将其改为大写,如果原字母是大写就讲其改写小写。AND操作所对应的指令也叫做AND。
OR(或):如果想实现跟AND运算相反的效果(也就是置1而不是清零),可以用OR指令,对两个操作数的每个二进制位分别执行OR运算。OR还有一个用法,是判断某数为正、为负,还是为0。只要把受测的数与它自身做OR运算,就可以根据结果判明这三种情况了。之所以能够如此,是因为OR与AND指令都会设置处理器中的许多标志位,例如CF、OF、PF、SF及ZF等。
XOR(异或):与OR类似,用来在两个操作数的每一对二进制位之间执行XOR运算。不过,它与OR有个关键的区别在于:只有当两个操作数一个是True一个是False时结果才是True,如果两个操作数均为True或均为False,那么结果是False。由于XOR运算是可逆的(reversible),因此成为很多对称加密(symmetric encryption)算法与数据存储算法中的重要环节。将原值与另一个值(此值称作键,key)连取两次XOR,就可以令运算结果回到原值。
5.3 分支:branch,意思是说程序可以根据开发者所实现的逻辑进入不同的执行路径,甚至可以直接跳过某些指令。分支有两种形式,一种是无条件地进入某路径,另一种是根据条件来做测试,从而依照测试结果进入相应的路径。
无条件跳转:标签只是用来在代码中标明某个位置而已,它本身并不占据内存空间。在汇编代码中使用标签的其中一个原因是要给JMP指令提供跳转目标。这条指令能够直接令程序跳转到某个地方。
有条件跳转:在汇编语言中执行条件测试有多种办法,其中之一是使用TEST指令。该指令会对两个操作数执行AND运算,但并不修改二者的值,而且也不保存计算结果。不过,它会根据计算结果设置处理器的PF、SF及ZF等标志位。在结果中,如果值为1的二进制位是偶数个,PF标志就是1,否则为0,SF标志反映结果的最高有效位,ZF标志反映结果是否为0.CF与OF也会为TEST指令所修改,无论计算结果如何,这两个标志位总是会清零。
还有一种方法也可以做条件测试,就是用CMP指令来比较(compare)两个操作数。这能够实现出与高级语言类似的条件判断逻辑(例如等于、大于、小于等于)。汇编语言的条件跳转至少要用两条指令实现,首先,用一条指令比较两个操作数,然后,用一条或多条指令来处理比较结果。基本的条件测试逻辑可以由CMP指令起头。该指令会用目标操作数减去来源操作数,并根据计算结果修改CPU的相关标志位。与TEST指令类似,CMP指令不会修改这两个操作数,而且也不保存计算结果。比较了两个操作数之后可以用很多种办法来跳转,下表列出了常用的跳转指令以及每条指令所判断的CPU标志位:选择跳转指令的时候一定要注意操作数是无符号数还是带符号数。
5.4 重复执行:重复(repetition)也叫做循环(looping),意思是多次执行预先写好的某一组指令,具体的执行次数通常由某个计数器变量或条件来控制。
用CX/ECX/RCX计数器实现循环:由计数器所控制的循环很容易就能用汇编语言的LOOP指令实现出来。该指令会把某个”C系列的寄存器”(也就是cx/ecx/rcx)当作递减计数器(decrementing counter)来用,每循环一次就将该计数器的值减1.值降为0的时候,结束循环并继续执行LOOP下方的指令。
下面列出了各汇编器的规则:
(1).MASM与NASM代码用$表示当前位置计数器(current location counter)。
(2).MASM及NASM代码要先写目标操作数,后写来源操作数。
(3).NASM的标识符区分大小写。
(4).MASM的标识符默认不区分大小写,但是可以添加”option casemap:none”命令来区分(这一般添加在.MODEL命令之后)。
(5).NASM代码用不加括号的ST0、ST1等写法来表示FPU栈寄存器。
(6).MASM通常用”%st(1)/ST(1)”、”%st(2)/ST(2)”这样带括号的写法来表示FPU栈寄存器。
(7).NASM用EQU命令将表达式的值设定给某个符号;MASM可以用=号,也可以用EQU命令。
(8).MASM和NASM都用单引号或双引号把字符串括起来。
(9).MASM的指令会自动根据某些因素(例如数据的大小)做出相应的处理,因此,有的时候很难看清楚这样一条指令究竟会如何执行。
(10).NASM通常不要求开发者用表示数据大小的命令来修饰源操作数,但如果用了也是可以的,而目标操作数的大小则必须明确,不然就要通过命令来指明。
GitHub:https://github.com/fengbingchun/CUDA_Test