汇编语言学习
基本概念
一般概念
汇编语言能够直接访问计算机硬件
地址总线是单向的,其他的是双向的。
其中地址总线的数量决定了可寻址的存储单元大小,
N
根地址总线,对应寻址> 空间为2的N次方
。数据总线的宽度决定了
CPU
和未接数据传送的速度。控制总线决定了
CPU
对外部器件的控制能力。
汇编器:将程序转换为机器语言
连接器:将汇编器生成的单个文件进行组合为一个可执行程序
调试器:使程序员单步执行程序,并检查寄存器和内存状态
与高级语言区别
高级语言一条语句会被扩展为多条机器语言,而汇编语指令一条语句对应一条机器语言指令
同时高级语言易于移植,编译好的程序几乎能在任何计算机系统中运行,汇编语言不可移植,因为它是为特定处理器系列而设计的。
汇编语言的优势
- 占用内存少,是编写嵌入式程序的理想工具
- 允许程序员精确指定程序可执行代码
- 可以对计算机硬件进行直接访问,对1要求高速度的代码进行手工优化
- 有助于理解计算机硬件、操作系统和应用程序之间的交互
- 一些高级语言对其数据进行了抽象,使其对在执行一些底层任务时不如汇编语言方便,如:位控制
- 便于编写硬件设备驱动程序
内存的读写与地址空间
CPU
对地址的读写需要进行三类信息的交互:
- 存储单元的的地址(地址信息)
- 器件的选择,读或写命令(控制信息)
- 读或写的数据(数据信息)
过程:地址线先发送要读取的地址信息,到内存之中,内存找到对应的地址后CPU
发送控制信息,进行读或写,随后通过数据线进行读或者写。
数据表示
十六进制
与二进制
的加法运算与10进制相似,都是从低位依次相加到高位,大于进制数便进位。需要注意的是可能在加法运算中会发生溢出(最高有效位不足)。
二进制
减法可以采用十进制
的减法进行运算,同时也可以采用一个较为简单的办法,将被减数
的符号位取反,然后两个数相加,忽略掉最高位的进位。
补码(可逆)
二进制数的补码
计算:取反后加一
00000001 -> 11111110 -> 11111111
十六进制数的补码
计算:按位取反(用15减去该位上的数字)后加一
6A3D -> 95C2 + 1 -> 95C3
有符号数进制转换
转为二进制:
将其绝对值转换为二进制数据,如果原来的数据为负,则对该二进制数据求补码
转为十六进制:
将其绝对值转换为十六进制数据,如果原来的数据为负,则对该十六进制数据求补码
通过观察16进制数据的最高位可以判断对应的数据的正负性,如果最高位大于等于8,则该数为负数,最高位小于等于7,则该数为正数
x86架构
一般概念
CPU
包含寄存器、高频时钟、控制单元、算术逻辑单元
CPU
通过控制、地址、数据三种总线与计算机其他部分相联系
计算机从内存读取数据比从内部寄存器读取数据慢,前者大致消耗4个时钟周期
,后者只需要1个时钟周期
指令执行周期:取指 -> 译码 -> 执行
寄存器
EAX是默认使用的乘除指令。它常常被称为扩展累加器寄存器
ECX为CPU默认使用的循环计数器
ESP用于寻址堆栈(一种系统内存结构)数据。它极少用于一般算术运算和数据传输,通常被称为扩展堆栈指针寄存器
ESI和EDI用于高速存储器传输指令,有时也被称为扩展源变址寄存器和扩展目的变址寄存器
EBP在高级语言中用来引用堆栈中的函数参数和局部变量。除了高级编程,它不用于一般算术运算和数据传输。它常常被称为扩展帧指针寄存器。
实地址模式中,16位段寄存器表示的是预先分配的内存区域的基址,这个内存区域称为段。保护模式中,段寄存器中存放的是段描述符表指针。一些段中存放程序指令(代码),其他段存放变量(数据),还有一个堆栈段存放的是局部函数变量和函数参数。
EIP为指令指针寄存器,包含下一条将要执行指令的地址。某些机器指令能控制EIP,使得程序分支转向到一个新位置
FFLAGS寄存器包含了独立的二进制位。用干控制CPU的操作,或是反映一些CPU操作的结果。有些指令可以测试和控制这些单独的处理器标志位
状态标志位状态标志位反映了CPU执行的算术和逻辑操作的结果。其中包括:溢出位、符号位、零标志位、辅助进位标志位、奇偶校验位和进位标志位。
进位标志位(CF),与目标位置相比,无符号算术运算结果太大时,设置该标志位。
溢出标志位(OF),与目标位置相比,有符号算术运算结果太大或太小时,设置该标志位
符号标志位(SF),算术或逻辑操作产生负结果时,设置该标志位。
零标志位(ZF),算术或逻辑操作产生的结果为零时,设置该标志位。
辅助进位标志位(AF),算术操作在8位操作数中产生了位3向位4的进位时,设置该标志位。
奇偶校验标志位(PF),结果的最低有效字节包含偶数个1时,设置该标志位,否则清除该标志位。一般情况下,如果数据有可能被修改或损坏时,该标志位用于进行错误检测。
方向标志位(DF),控制了串操作指令(MOVS,CMPSSCASLODS与STOS)设置DF标志位将使得串操作指令地址自动递减(从高地址向低地址处理串)。清除DF标志位将使得串操作指令自动递增(从低地址向高地址处理串)。
中断允许标志位(IF),控制处理器对干可屏蔽中断的处理,设置该标志位位可使处理器响应可屏蔽中断;清除则禁止响应可屏蔽中断。
跟踪标志位(TF),设置可启用单步运行模式来调试程序,清除则禁用单步运行模式。
计算机组件
主板是微型计算机的心脏,它是一个平面电路板,其上集成了CPU、支持处理器、主存、输入输出接口、电源接口和扩展槽。
BIOS:基本输入输出系统,用于保存系统软件
基于Intel
的系统使用的是几种基础类型内存:
- 只读存储器(ROM):永久烧录在芯片上,并且不能擦除
- 可擦除可编程只读存储器(EPROM):能用紫外线缓慢擦除,并且重新编程
- 动态随机访问存储器(DRAM):通常的内存,在程序运行时保存程序和数据的部件。需要每毫秒进行刷新,以避免丢失数据
- 静态RAM(SRAM):主要用于价格高、速度快的
cache
存储器,不需要刷新 - 图像随机存储器(VRAM):保存视频数据,其为双端口,可以一个端口刷新显示数据,另一个端口将数据写到显示器
- 互补金属氧化物半导体(CMOS)RAM:在系统主板上,保存系统设置信息,由电池供电,因此断电后其中的内容仍能够保留
输入输出系统
与虚拟机概念相似,输入输出是通过不同层次的访问来实现的。库函数在最高层,操作系统是次高层。BIOS(基本输入输出系统)是一组函数,能直接与硬件设备通信。程序也可以直接访问输入输出设备。
汇编语言基础
环境搭建
一般概念
数据标号和符号标号的区别
数据标号表示变量的位置它提供了一种简便的方式操作变量,而符号标号表示程序代码的位置,使用冒号结束,通常用于循环与跳转
大小端序
大端序将最高有效字节放在第一个内存地址中,小端序中最低有效字节放在第一个内存地址中
如存放数据 12345678h 大端序为 12h、34h、56h、78h 小端序为 78h、56h、34h、12h
源文件和列表文件的区别
源文件是ASCII编码的程序源代码, 列表文件包括源文件的副本,行号每条指令的数字地址,每条指令的机器代码字节(十六进制)以及符号表
符号表中包含了程序中所使用的所有标识符名称,段和相关信息
指令的数字地址是相对程序占用的起点而言的,从0000 0000
开始
注释块的编写
1 |
|
DUP 操作符
DUP可以为多个数据项分配存储空间,可以用于初始化或非初始化数据
如: Array DWORD 200 DUP(?) ; 分配两百个双字空间,同时不将其初始化
基本语言元素
整形常量表达式是算术表达式,包括了整数常量、符号常量和算术运算符。优先级
是指当表达式有两个或者更多运算时,隐藏的优先执行顺序
被.code
和.data
等伪指令所包括起来的叫段
有
代码段
和数据段
还有一种叫堆栈
整数常量形式: [{ + | - }] digits [ radix ]
其中radix为对应基数,有如下基数:
h -> 16进制 r -> 编码实数 q/o -> 8进制 t -> 10进制(备用)
d -> 10进制 y -> 2进制(备用) b -> 2进制
实数常量格式: [ sign ] integer. [ integer ] [ exponent ]
符号和指数的格式如下:
sign { +/- }
exponent E[{ +/- }]integer
其中
E
表示十的多少次方: 1E5 = 1 * 10^5 1E-5 = 1*10^-5
保留字、标识符伪指令都是不区分大小写的
保留字包括:指令助记符(如:MOV)、寄存器名称、伪指令(如:.data)、属性(如:BYTE、WORD)、运算符、预定义符号(如:@data)
标识符的一些规则:
- 可以包含 1 - 247 个字符
- 不区分大小写
- 第一个字符必须以字母、下划线、@、?、$。后续字符可以是数字
- 标识符不能和汇编器的保留字相同
伪指令:嵌入源代码中的命令,由汇编器识别和执行,其不在运行时执行,但是可以定义变量、宏和子程序(函数);为内存段分配名称,执行许多其他汇编器相关的日常任务
指令的基本格式: [ label: ] mnemonic [ operands ] [ ;comment ]
指令在程序汇编编译时变得可执行,汇编器将其翻译为机器语言字节,并且在运行时由
CPU
加载和执行
变量定义
定义格式: [ name ] directive initializer [ ,initializer ] (例: A BYTE 100 )
32位汇编程序模板
1 |
|
64位汇编程序模板
1 |
|
数据传送、寻址和算术运算
数据传送指令
操作数有 3 种基本类型:
- 寄存器操作数 —— 使用
CPU
内已命名的寄存器 - 立即数 —— 使用数字文本表达式
- 内存操作数 —— 引用内存位置
MOV
指令将源操作数复制到目的操作数,MOVZX
在传送过程中执行零扩展
,MOVSX
执行符号位扩展(一般来说十六进制第一位大于等于 8 的便是负数,扩展符号位 1 ,便会得到 FFF…)
LAHF
将EFLAGS
寄存器的低字节复制到AH
,被复制的标志位包括:符号标志位、零标志位、辅助进位标志位、奇偶标志位和进位标志位,而SAHF
将AH
的内容复制到EFLAGS
寄存器低字节
XCHG
用于交换两个操作数内容,需要注意两个操作数的字节大小需要相同
通过数组名 + 偏移量
可以取到对应数组在栈中的某个数据,但如果是数组名 + 元素个数 * 类型长度
那么可以得到对应的偏移,如果需要去取对应偏移的值,那么需要加上一个中括号(如 [array+2]
)
加法和减法
INC
和DEC
指令分别对应寄存器或内存操作数加一和减一,需要注意的是这两个指令不会改变进位标志位
ADD
指令将长度相同
的源操作数和目的操作数进行相加,SUB
指令从目的操作数减去源操作数
NEG
(非)指令通过把操作数转换为其二进制补码,将操作数的符号取反,可以将整数转为负数
标志位
-
进位标志意味着无符号整数溢出 (CF/CY)
如果和数超过了目的操作数的存储大小,就可以认为 CF = 1
从较小的无符号整数中减去较大的无符号整数时,减法操作就会将进位标志位置 1
-
溢出标志意味着有符号整数溢出 (OF/OV)
有符号数算术操作结果与目的操作数相比,如果发生上溢或者下溢,则溢出标志位置 1
OF = CF XOR 运算结果最后的二进制最高位
-
零标志位意味着操作结果为 0 (ZF/ZR)
-
符号标志位意味着操作产生的结果为负数 (SF/PL)
有符号数算术操作结果为负数,则符号标志位置 1
-
奇偶进位标志值在一条算术或者布尔运算执行后,立即判断目的操作数最低有效字节(二进制下的后 8 位)中 1 的个数是否为偶数 (PF/PE)
目的操作数最低有效字节(二进制下的后 8 位)中 1 的个数为偶数时置 1
-
辅助进位标志位置 1 ,意味着目的操作数最低有效字节中位 3 有进位 (AF/AC)
主要用于二进制编码的十进制数(BCD)运算
与数据相关的运算符和伪指令
OFFSET
运算符返回的是一个变量与其所在段起始地址之间的距离
PTR
运算符可以重写操作数默认的大小类型
TYPE
运算符返回第是一个操作数或数组中每个元素的大小(按字节计算)
LENGTHOF
运算符返回的是数组中元素的个数
SIZEOF
运算符返回的是数组初始化时使用的字节数
LABEL
伪指令可以插入一个标号,并定义它的大小属性,但是不为这个标号分配存储空间
个人理解:
LABEL
创建了一个对应类型的一个框,不占据任何空间,当有数据被定义时便会放在框内,直到框装满,对应的变量的值便是框内数据
循环
JMP
无条件跳转到一条指令处,LOOP
使用ECX
作为计数器,当ECX
等于 0 时结束循环,再循环过程中每次ECX
会减 1,如果将ECX
初始值设置为 0,在第一次循环后减一会造成溢出,产生FFFFFFFF
,而形成巨大的循环
过程
堆栈操作
在堆栈
中,新值将被添加到栈顶,删除值也在栈顶移除,被称为LIFO
(先进后出)结构,原因是:最后进入堆栈的值也是第一个出堆栈的值
运行时堆栈
是内存数组,CPU
通过ESP
对其进行直接管理,该寄存器也被称为堆栈指针寄存器,ESP
存放的是堆栈中某个位置的 32 位偏移量。ESP
基本不会直接被程序员控制,反之,它是用CALL
、RET
、PUSH
和POP
等指令间接修改
ESP
总是指向最后压入堆栈的数据,运行时堆栈在内存中是向下生长的,即从高地址向低地址扩展。数值在弹出堆栈后,栈顶指针增加(按堆栈元素大小),指向堆栈中下一个最高位置
运行时堆栈的一些用途:
- 当寄存器用于多个目的时,堆栈可以作为寄存器的一个方便的临时保存区。在寄存器被修改后,还可以恢复其初始值
- 执行CALL指令时,CPU在堆栈中保存当前过程的返回地址
- 调用过程时,输入数值也被称为参数,通过将其压入堆栈实现参数传递。堆栈也为过程局部变量提供了临时存储区域
PUSH
指令首先减少ESP
的值,再将源操作数复制到堆栈。
POP
指令先把ESP
指向的堆栈元素内容复制到一个 16 位或 32 位的目的操作数中,再增加ESP
的值
PUSHFD
将 32 位EFLAGS
寄存器内容压入堆栈。POPFD
吧栈顶内容弹出到EFLAGS
寄存器
不能用
MOV
指令将标识寄存器内容复制给一个变量,因此,PUSHFD
是保护标志位的最佳途径
1
2
3
4
5pushfd ; 保存标志寄存器
;
; 任意语句序列
;
popfd ; 恢复标志寄存器采用上述方式使用入栈和出栈指令时,必须要确保程序的指向路径不会跳过
POPFD
指令
PUSHAD
指令按照EAX
、ECX
、EDX
、EBX
、ESP
(执行PUSHAD
之前的值)、EBP
、ESI
和EDI
的顺序,将所有 32 位通用寄存器压入堆栈。POPAD
指令按照相反顺序将同样的寄存器弹出堆栈。与之相似,PUSHA
指令按序(AX
、CX
、DX
、BX
、SP
、BP
、SI
和DI
)将16位通用寄存器压入堆栈。POPA
指令按照相反顺序将同样的寄存器弹出堆栈。
需要注意的是,过程用一个或者多个寄存器返回结构时不应使用PUSHA
和PUSHAD
,这两个指令都保存了对应的保存的寄存器值,若中间有过程用寄存器传递可能会丢失数据
1
2
3
4
5
6
7
8
9
10read PROC
PUSHAD
.
.
mov eax,return_value
.
.
POPAD
ret ; EAX的值被覆盖了
read ENDP上述调用
POPAD
将会覆盖EAX
的返回值,导致返回数据丢失
定义并使用过程
汇编语言中经常使用通用寄存器来传递参数
过程可以非正式地定义为:由返回语句结束的已命名的语句块。过程用PROC
和ENDP
伪指令来定义,并且必须为其分配一个名字(有效标识符)
PORC
和ENDP
伪指令来定义一个过程,当在程序启动过程之外创建一个过程时,就用RET
指令来结束它。RET强制CPU返回到该过程被调用的位置:
1 |
|
默认情况下,标号只在其被定义的过程中可见。解决这个限制可以定义全局标号,即在名字后面加双冒号(::)
1 |
|
CALL
指令执行时将下一条指令的地址压入栈中,再把被调用过程的地址复制到指令指针寄存器中(EIP)
RET
指令执行时先将ESP
的值弹道EIP
中,然后ESP
向高地址移动
与PROC
伪指令一起使用的USES
运算符,列出了过程修改的全部寄存器。汇编器产生代码,在程序开始时将寄存器的内容压人堆栈,并在过程返回前弹出恢复寄存器。
USES
运算符与PROC
伪指令一起使用,让程序员列出在该过程中修改的所有寄存器名。USES
告诉汇编器做两件事情:
第一,在过程开始时生成PUSH
指令,将寄存器保存到堆栈;
第二,在过程结束时生成POP
指令,从堆栈恢复寄存器的值。
USES
运算符紧跟在PROC
之后,其后是位于同一行上的寄存器列表,表项之间用空格符或制表符(不是逗号)分隔。
外部连接库
链接库(.inc
)是一种文件,包含了已经汇编为机器代码的过程(子程序)。链接库开始时是一个或者多个源文件,这些文件再被汇编为目标文件
链接库通过include
进行导入:
1 |
|
通过链接库可以直接调用内部现有的功能函数,Irvine32.inc
中包含如下过程:
条件处理
条件分支
允许作决策的编程语言使程序员可以改变控制流,使用的技术成为条件分支
通过布尔运算可以很方便的更改一个数字的单个位,布尔指令影响对应的零标志位
、进位标志位
、符号标志位
、溢出标志位
和奇偶标志位
AND
指令将两个数进行与运算
,要求对应的两个数的大小相等,操作数可以是8、16、32、64位。
1 and 1 = 1
0 and 1 = 0
0 and 0 = 0
两个数同时为 1 的时候运算结果才为 1 ,反之则为 0
OR
指令将两个数进行或运算
,同样要求两个数的大小相等,操作数可以是8、16、32、64位
1 or 1 = 1
0 or 1 = 1
0 or 0 = 0
两个数中有一个数字为 1 那么运算的结果便为 1
XOR
指令将两个数进行异或运算
,操作数组合和大小与AND
和OR
指令相同
0 xor 0 = 0
1 xor 1 = 0
0 xor 1 = 1
两个数字不同时进行
xor
运算的结果为 1 ,两个数字相同时xor
结果为 0 ,同时xor
具备有可逆性有如下性质:A XOR B = C C XOR B = A C XOR A = B
NOT
指令将操作数的所有位进行翻转(0 -> 1 , 1 -> 0),其结果为反码,需要注意的时NOT
指令不会影响标志位
TEST
指令将两个操作数对应位之间进行AND
操作,根据其运算结果设置对应的符号标志位
、零标志位
和奇偶标志位
,TEST
指令不改变对应的目标操作数,可以用于发现操作数中单个位是否置位(某个位上是否为 1)
TEST
指令总是清除溢出
和进位标志位
,其修改符号标志位
、零标志位
和奇偶标志位
的方式与AND
指令相同
CMP
用于比较两个操作数,内部隐含一个减法操作,并且不会修改任何操作数,常常用于创建条件分支
一些置位方式
零标志位
1 |
|
符号标志位
1 |
|
进位标志位
1 |
|
溢出标志位置 1 ,就把两个正数相加使其产生负的和数;若要清除溢出标志位,则将操作数和 0 进行OR
操作
条件跳转
第一步先使用CMP
、AND
或SUB
操作改变CPU
的状态标志位,随后使用条件跳转指令
来对标志位
进行测试,而产生新的分支
条件循环指令
LOOPZ
(为零跳转)指令的工作和LOOP
指令相同,只是有一个附加条件:为零控制转向目的标号,零标志位必须置 1。LOOPE
(相等跳转)指令相当于LOOPZ
两者有相同的操作码,执行以下任务:
1 |
|
两者均不改变对应的状态标志位,如果处于 64 位下便采用rcx
作为对应循环计数器
LOOPNZ
(非零跳转)指令与LOOPZ
(为零跳转)相对应。当ECX
中无符号数值大于零(减 1 操作之后)且零标志位等于零时,继续循环。LOOPNE
(不等跳转)指令相当于LOOPNZ
,它们有相同的操作码,执行以下任务:
1 |
|
条件循环指令与普通的循环指令相似,不同的是需要额外判断对应CPU
中的状态标志位,当两者(ECX != 0 和 条件标志位满足)同时成立时,便发生跳转
条件分支与循环伪指令
条件分支(IF语句)
1 |
|
在condition
中可以采用与高级语言同样的关系和逻辑运算符,同时也存在一些特殊运算符:
运算符 | 说明 |
---|---|
OVERFLOW? | 若溢出标志置 1 ,则返回“真” |
CARRY? | 若进位标志置 1 ,则返回“真” |
PARITY? | 若奇偶标志置 1 ,则返回“真” |
SIGN? | 若符号标志置 1 ,则返回“真” |
ZERO? | 若零标志置 1 ,则返回“真” |
循环语句
do-while
1 |
|
while
1 |
|
整数运算
移位和循环移位指令
溢出:当一个有符号数进行循环移位时发生了符号位的取反( 0 -> 1 或 1 -> 0 )时,溢出标志位置 1
逻辑移位:在移位过程中空出来的高位用 0 进行填充
算术移位:在移位过程中空出来的高位用符号位进行填充
SHL
指令将目的操作数逻辑左移移位,最低位用 0 进行填充,其中最高位会移入进位标志位上,每向左移位一次相当于乘以 2
SHR
指令将目的操作数逻辑右移移位,最高位用 0 进行填充,其中最后一位被移除的会置于进位标志位上,每向右移位一次相当于除以 2
SAL
(算术左移)指令与SHL
相同,每次移动时将最低为用 0 进行填充,其中最高位会移入进位标志位上
SAR
进行算术右移,SAL
与SAR
两者的操作数类型与SHL
和SHR
完全相同
ROL
以循环方式来进行移位,该指令将数的一段移动到另一端,并不会丢弃位,当循环计数次数大于 1 时,进位标志位保存的是最后循环位移出MSB
的位
可以利用该方式将一个数字的高 4 位与低 4 位进行交换
1
2mov al,26h
rol al,4 ; al = 62h
ROR
(循环右移)指令把所有位都向右移,最低位复制到进位标志位和最高位。该指令格式与SHL指令相同
与之相似的是当循环计数值大于1时,进位标志位保存的是最后循环移出LSB的位
RCL
(带进位循环左移)指令把每一位都向左移,进位标志位复制到LSB,而MSB复制到进位标志位
此处的
带进位
指的是进位标志位也参与到移位中
RCR
(带进位循环右移)指令把每一位都向右移,进位标志位复制到MSB
,而LSB
复制到进位标志位
SHLD
(双精度左移)指令将目的操作数向左移动指定位数。移动形成的空位由源操作数的高位填充。源操作数不变,但是符号标志位、零标志位、辅助进位标志位、奇偶标志位和进位标志位会受影响,格式为:
1 |
|
SHRD
(双精度右移)指令将目的操作数向右移动指定位数。移动形成的空位由源操作数的低位格式为:
1 |
|
一般为了在屏幕上进行图像的重定向而将位元组左右移动的时候,可以用
SHLD
和SHRD
来处理位映射图像。或者在数据加密时可以用到两种移位方式。
乘法与除法指令
32 位模式下,MUL
(无符号数乘法)指令有三种类型:第一种执行 8 位操作数与AL
寄存器的乘法;第二种执行 16 位操作数与AX
寄存器的乘法;第三种执行 32 位操作数与EAX
寄存器的乘法。乘数和被乘数的大小必须保持一致,乘积的大小则是它们的一倍。这三种类型都可以使用寄存器和内存操作数,但不能使用立即数
往往来说两个数相乘可能会产生比其长度大一倍的数字,对此引入了两个寄存器(或者一个)分别保存结果的高位和地位
被乘数 | 乘数 | 乘积 |
---|---|---|
AL | reg/mem8 | AX (AH为高 4 位,AL为低 4 位) |
AX | reg/mem16 | DX:AX (DX为高 8 位,AX为低 8 位) |
EAX | reg/mem32 | EDX:EAX (EDX为高 16 位,EAX为低 16 位) |
IMUL
(有符号数乘法)指令执行有符号整数乘法。与MUL
指令不同,IMUL
会保留乘积的符号,实现的方法是,将乘积低半部分的最高位符号扩展到高半部分
x86
指令集支持三种格式的IMUL
指令:单操作数、双操作数和三操作数
一般来说,在单操作数的情况下默认与
AL
(AX
、EAX
)相乘三操作数时将成绩保存在第一个操作数中,第二个与第三个操作数进行乘法运算
1
imul A,B,C == A = B * C
和
MUL
指令一样,其乘积的存储大小使得溢出不会发生。同时,如果乘积的高半部分不是其低半部分的符号扩展,则进位标志位(CF)和溢出标志位(OF)置 1利用这个特点可以决定是否忽略乘积的高半部分。即不是符号位扩展(OF = 1 ):不可以忽略乘积的高半部分
32 位模式下,DIV
(无符号除法)指令执行 8 位、16 位和 32 位无符号数除法。
被除数 | 除数 | 商 | 余数 |
---|---|---|---|
AX | reg/mem8 | AL | AH |
DX:AX | reg/mem16 | AX | DX |
EDX:EAX | reg/mem32 | EAX | EDX |
IDIV
(有符号除法)指令执行有符号整数除法,其操作数与DIV
指令相同。执行 8 位除法之前,被除数(AX
)必须完成符号扩展。余数的符号总是与被除数相同。
符号扩展指令:
CBW
(字节转字)指令将AL
的符号位扩展到AH
,保留了数据的符号
CWD
(字转双字)指令将AX
的符号位扩展到DX
CDQ
(双字转四字)指令将EAX
的符号位扩展到EDX
需要注意的是执行DIV
和IDIV
后所有的算术运算状态标志位的值都不确定
异常:如果除法运算结果的商目的操作数存储不下时会引发溢出异常,同时如果除以 0 时会引发除零异常机制
对于溢出异常可以使用 32 位除数和 64 位被除数来减少出现除法溢出条件的可能性
被除数 / 除数 = 商 … 余数
扩展加减法
ADC
(带进位加法)指令将源操作数和进位标志位的值都与目的操作数相加
1 |
|
ADC
指令相当于在普通的ADD
指令的基础上加上了一个进位标志位(CF
),在运行加法运算时是使用高位进行运算
SBB
(带借位减法)指令从目的操作数中减去源操作数和进位标志位的值
1 |
|
SBB
指令也是相当于SUB
指令的基础上减去了一个进位标志位(CF
),运行减法运算时是使用高位进行运算
高级过程
调用程序向子程序传递的数值被称为实际参数
。而被调用的子程序要接收的数值被称为形式参数
。
堆栈帧
堆栈帧
是一块堆栈保留区域,用于存放被传递的实际参数、子程序的返回值、局部变量以及被保存的寄存器。
堆栈帧的创建步骤如下:
- 被传递的实际参数。如果有,则压入堆栈
- 当子程序被调用时,使该子程序的返回值压入堆栈
- 子程序开始执行时,
EBP
被压入堆栈 - 设置
EBP
等于ESP
。从这时开始,EBP就变成了该子程序所有参数的引用基址 - 如果有局部变量,修改
ESP
以便在堆栈中为这些变量预留空间 - 如果需要保存寄存器,就将它们压入堆栈
在过程调用之前,任何存放参数的寄存器需要首先入栈,然后向其分配过程参数,再返回后再恢复起始值,同时当一个参数通过数值传递时,该值的副本会被压入堆栈,对于一个函数而言,其参数入栈的顺序是从右向左进行的。
一般而言,当一个过程的参数是一个数组时,我们常常将其所对应的地址进行压入堆栈中,节省操作的时间
访问堆栈中的数据我们可以使用基址 —— 偏移量寻址
,用[EBP+X]
获取到对应压入栈中的数据,需要注意的是在子程序返回的时候需要将参数从栈中进行删除,否则可能会造成内存泄漏,堆栈就会被破坏。
调用规范:
C 调用规范
程序调用子程序时,在CALL指令的后面紧跟一条语句使堆栈指针(
ESP
)加上一个数,该数的值即为子程序参数所占堆栈空间的总和。一般而言一个参数占用的空间是4 字节
1add esp,X ; X 为 4 的倍数
STDCALL调用规范
STDCALL
规范给RET
指令添加了一个整数参数,这使得程序在返回到调用过程时,ESP会加上该数值。同时这个添加的整数必须与被调用过程参数占用的堆栈空间字节数相等
1ret X ; X 为 4 的倍数
上面代码等价于
1
2add esp,X
ret要说明的是,
STDCALL
与C
相似,参数是按逆序入栈的。通过在RET
指令中添加参数,STDCALL
不仅减少了子程序调用产生的代码量(减少了一条指令),还保证了调用程序永远不会忘记清除堆栈。另一方面,C
调用规范则允许子程序声明不同数量的参数,主调程序可以决定传递多少个参数。
C语言
的printf
函数就是一个例子,它的参数数量取决于初始字符串参数中的格式说明符的个数。
STDCALL
与C语言
调用规范差异:
c stdcall 在被调用函数 ( Callee
) 返回后,由调用方 (Caller
) 调整堆栈。 1.调用方的函数调用 2.被调用函数的执行 3.被调用函数的结果返回 4.调用方清除调整堆栈在被调用函数 ( Callee
) 返回前,由被调用函数 (Callee
) 调整堆栈。图示: 1.调用方的函数调用 2.被调用函数的执行 3.被调用函数清除调整堆栈 4.被调用函数的结果返回因为每个调用的地方都需要生成一段调整堆栈的代码,所以最后生成的文件较大。 因为调整堆栈的代码只存在在一个地方(被调用函数的代码内),所以最后生成的文件较小。 函数的参数个数可变(就像 printf
函数一样),因为只有调用者才知道它传给被调用函数几个参数,才能在调用结束时适当地调整堆栈。函数的参数个数不能是可变的。
通常子程序在修改寄存器之前就要将它们的值压入栈中进行保存(PUSH
),在子程序返回后恢复原寄存器的值(POP
)
在子过程中,局部变量一般创建于运行时堆栈,通常位于基址指针(EBP
)下,我们对其需要将ESP
向下开辟对应字节的空间,从子程序退出前也需要将局部变量从堆栈中进行删除
LEA
指令返回简介操作数的地址,比如创建了一个局部变量时,不能采用mov esi,OFFSET [EBP-X]
的方式将对应的局部变量空间地址赋予给ESI
,因为OFFSET
只适用于编译时已知的地址。LEA
的作用也便是将新开辟的地址空间进行复制操作。
ENTER
指令为被调用过程自动创建堆栈帧。它为局部变量保留堆栈空间,把EBP
入栈。具体来说,它执行三个操作:
- 把EBP入栈(
push ebp
) - 把EBP设置为堆栈帧的基址(
mov ebp,esp
) - 为局部变量保留空间(
sub esp, numbytes
)
对应结构为:
1 |
|
这两个操作数都是立即数。Numbytes
总是向上舍入为 4 的倍数,以便ESP
对齐双字边界。Nestinglevel
确定了从主调过程堆栈帧复制到当前帧的堆栈帧指针的个数。
有如下等价关系
1enter X,0
等价于
1
2
3push ebp
mov ebp,esp
sub esp,X
一般使用ENTER
指令时都需要使用LEAVE
进行配合使用,LEAVE
指令结束一个过程的堆栈帧,反转了ENTER
的指令操作,恢复了ESP
和EBP
的值
有如下等价关系
1leave
等价于
1
2mov esp,ebp
pop ebp
POP EBP
是先把ESP
指向的堆栈元素内容复制到EBP
中,再增加ESP
的值,此时的ESP
对应的地址便是返回地址
LOCAL
伪指令在过程内部声明一个或多个局部变量,它必须紧跟在PROC
伪指令的后面。与全局变量相比,局部变量有独特优势:
- 对局部变量名和内容的访问可以被限制在包含它的过程之内。局部变量对程序调试也有帮助,因为只有少数几条程序语句才能修改它们。
- 局部变量的生命周期受限于包含它的过程的执行范围。局部变量能有效利用内存,因为同样的存储空间还可以被其他变量使用。
- 同一个变量名可以被多个过程使用,而不会发生命名冲突。
- 递归过程可以用局部变量在堆栈中保存数值。如果使用的是全局变量,那么每次过程调用自身时,这些数值就会被覆盖。
递归
递归子程序
是指直接或简介的调用自身的子程序
一个简单的递归也会占用大量的堆栈空间,在每次过程调用发生时最占用 4 字节的堆栈空间,因为要把返回地址保存到堆栈(对应CALL
语句的下一条语句地址)
假设完成同样的任务,递归子程序所使用的内存空间通常大于非递归子程序
INVOKE 和 PROC 指令
INVOKE
伪指令(仅限32位模式)能代替CALL
指令,它的功能更加强大,可以传递多个参数。用INVOKE
伪指令定义过程时,ADDR
运算符可以传递指针。
ADDR
指令用于传递指针,同时只能和INVOKE
同时使用,传递给ADDR
的参数必须是汇编时常数不能使用[ebp+X]
的形式
PROC
伪指令在声明过程名的同时可以带上已命名参数列表。PROTO
伪指令为现有过程创建原型,原型声明过程的名称和参数列表。
PROC
相当于声明形参,只后会被MASM
翻译为对应的汇编语句来开辟对应的栈空间
伪指令的调用先后为:
1 |
|
字符串和数组
X86
指令集有五组指令用于处理字节、字和双字数组,其被称为字符串原语
,其能够高效执行,因为它们会自动重复并增加数组索引
就其自身而言,字符串基本指令只能处理一个或一对内存数值。如果加上重复前缀,指令就可以使用ECX
寄存器作为计数器重复执行。重复前缀使得单条指令能够处理整个数组
使用方式如下:
1
REP/PEPZ/... MOVSB/CMPSB/...
格式为:
重复前缀 + 字符串基本指令
需要注意的是,再重复指令时,我们需要将方向标志位进行置 1 或者置 0 ,通过不同的置位来告诉对应的ESI
和EDI
是增加还是减少
1 |
|
当方向标志位清零的时候,程序中对应寄存器代表的的地址是是正向增长,当方向标志位置 1 的时候,对应的地址是逐渐减少。
二维数组
高级语言在内存中有两种方式存放数组的行和列:行主序
和列主序
行主序
最常用,第一行的数据存放在内存块开始的位置,第一行存放结束后后面紧跟的是第二行的第一个元素,列主序
则是第一列的数据存放在内存块开始的位置,在第一列数据结束后后面紧跟的数据是第二列的第一个元素
对于二维数组的访问我们常常将其抽象为一个一维数组,通过基址 + 变址操作数
或基址 + 变址 + 偏移量操作数
基址 + 变址操作数
[ base + index ]
通过将两个寄存器相加来产生一个偏移地址
基址 + 变址 + 偏移量操作数
[ base + index + displacement ]
displacement[ base + index ]
采用的是一个偏移量、一个基址寄存器和一个可选的比例因子来生成有效地址
两种方式都类似于高级语言中已经寻找到了一个数组的首地址,通过一个单字节的指针来进行寻找每一位的数据,每次指针的增加量便是对应的偏移量,如
int
类型,占用了 4 字节,那么每次偏移往下找数据时增加的量便为 4