X86汇编语言程序设计

绪论
什么是汇编语言
机器指令:也被称为硬指令,是,面向机器的,不同的CPU都规定了自己所特有的、一定数量的基本指令,这批指令的全体即为及计算机的指令系统。机器指令由一至多个字节组成,其中包括操作码字段、一个或多个有关操作数地址的字段、操作数等
机器语言:机器指令的集合
机器语言程序:用机器语言编写的程序
汇编语言:用助记符表示机器指令的操作码,用变量代替操作数的存放地址,这种用符号数写的,其操作指令与机器指令基本一致,并遵循一定规则的计算机语言就是汇编语言。汇编语言是为了方便用户而设计的一种符号语言,其编写的源程序不能直接被计算机识别,需要经过汇编程序(即编译器)汇编(即编译)为目标程序(即机器程序)。汇编语言由指令助记符,语句标号、数据变量、伪指令以及他们的使用规则组成。
汇编语言程序举例
1 | .686P |
第一行是处理器选择伪指令,表示在程序中使用的指令集
第二行.model flat
是说明程序采用的存储模型说明的伪指令,说明这是一个采用扁平内存模型的32位段的程序,后面的c
是语言类型,用于说明函数命名,调用和返回的方法
第三行ExitProcess
是Windows系统提供方的API函数,其功能是终止一个程序的运行,返回操作系统,proto stdcall:dword
说明Exitprocess
的语言类型是stdcall
且带一个dword
类型的参数,第四行是使用includelib
包含实现了ExitProcess
的库kernel.lib
。之后的printf
函数声明与相关库的包含与此类似
.data
是数据段定义伪指令,该段中定义的是全局变量
.stack
是堆栈段定义伪指令,其中200
表示堆栈段大小为200个字节
.code
是代码段定义伪指令,main proc...main endp
是子程序,由于使用的是main
,故main
为程序执行入口,end
伪指令表示整个程序结束。
在main
子程序中,mov
jg
add
inc
jmp
属于操作符,操作符是机器指令的助记符,该语句是机器指令语句。invoke
是子程序调用伪指令,lp
和exit
是标号,标号用于表示某一条语句的位置。
Intel中央处理器
Intel公司微处理器简介
Intel公司推出的微处理器很多,但是微处理器的结构主要分为IA-32和IA-64。
在1989年推出Intel80486处理器后,Intel公司开始以IA-32指称该架构,也称x86-32架构。从8086开始之后的产品以80186,80188,80286,i386,i486等代号命名,故被外界称为x86架构,属于复杂指令集架构。
IA-64架构是Intel公式为提高IA-32处理器的运算性能而开发的一种全新处理器架构,与x86不能兼容。
为解决与IA-32兼容问题,Intel公司采用在x86平台上从32位扩充为64位,采用了x86-64结构,也称为Intel64,x64。
Intel x86微处理器架构
32位CPU按其主要功能分为6大部件:总线接口部件、执行部件、指令预取部件
CPU对指令的处理分为四个阶段:提取、解码、执行、写回
32位CPU中的通用寄存器
在32位CPU中,32位通用寄存器有八个,分别是eax
,ebx
,ecx
,edx
,esi
,edi
,esp
,ebp
。其中esp指向堆栈栈顶,ebp指向栈底,一般不用做数据寄存器使用,轻易改变这两个寄存器的值很可能造成程序错误。
上述八个寄存器的低16位有独立的名字,分别是ax
,bx
,cx
,dx
,si
,di
,sp
,bp
,这是16位寄存器,对16位寄存器ax
,bx
,cx
,dx
的高低八位,还可分为H组ah
,bh
,ch
,dh
和L组al
,bl
,cl
,dl
,做8位寄存器使用
标志寄存器
标志寄存器用来保存一条指令指向之后,CPU所处的状态的信息以及运算的结果。对x86微处理器,16位CPU中的标志寄存器 称为flags,32位CPU中的标志寄存器称为eflags,全部标志位向下兼容。32位标志寄存器中的标志位按作用可以分为三类:条件标志位,控制标志位和32位标志寄存器扩充的系统标志位(即16位CPU中不包含系统标志位)
条件标志位
SF:执行一次运算后,若结果最高二进制位为1则SF=1,否则为0
ZF:若运算结果为0,则ZF=1,否则ZF=0
OF:对有符号数,溢出时为1,未溢出时为0
CF:对无符号数,有进位借位时为1,否则为0
AF:置位方法与CF一致,但用于执行字节运算时低半字节向高半字节的进位与借位
PF:奇偶检验位,当结果最低有效字节中1的个数位偶数或0时PF为1,否则为0
方向控制位
DF:用于串操作中对方向的规定,DF为0时是正向操作,为1时是反向操作
IF:IF为1时,说明CPU开中断,此时CPU响应外设的中断请求,为0时关中断
TF:TF为1时CPU处于单步工作方式,即每执行一条指令产生一个类型为1的中断,TF为0时CPU处于连续工作的方式。
系统标志位
IOPL:占两位,指定了要求执行I/O指令的特权级,当CPU当前特权级等于或高于IOPL则可以执行I/O指令,否则会产生一个保护异常。
NT:NT为1时表示当前人物嵌套在另一个任务中,执行完当前任务后要返回原来的任务中,NT置0时则按堆栈中保存的断点返回。
RF:RF为1时下一条指令的所有调试故障将被忽略,否则接受调试故障。
VM:VM为1时说明CPU在虚拟8086方式下工作,否则在保护方式下工作
指令预取部件和指令译码部件
指令预取部件可以通过总线接口部件把要执行的指令从主存中取出送入指令排队机构中排队。指令译码部件用于从指令预取部件中读取指令并译码,再送入译码指令队列排队供执行部件使用。
在读取指令时需要用到指令寄存器用于保存CPU下一条将要执行的指令的偏移地址EA,在16位代码段中,指令指示器也为16位,称为IP,32位中,指令指示器为32为,称为EIP,64位中,指令指示器为64位,称为RIP。指令指示器的内容由硬件自动设置不能供程序直接访问。
分段部件和分页部件
计算机内存管理分段的原因:
- 计算机系统中有多个程序出在运行,为保护正常程序的数据不被另一个程序修改,程序会被放在内存的一个或多个分段,访问时要检查数据是否在本段范围内,若不在则阻止
- 虽然C语言中变量的定义和其他语句是混在一起的,人类也能够分辨两者,但在计算机看来,数据定义与其他语句都是0-1串,因此在执行程序时要将代码与数据分开放在不同的段内。
- 编写程序和编译程序时无法给指令和数据一个固定的物理地址,操作系统在调度该程序运行时会根据当前内存使用情况和调度策略将该程序安排在某一段或几段空间上,编写程序时不需要考虑分段问题,只需给出指令和数据在程序内的相对地址即可,当知道程序被调用时在内存的起始物理地址,由该物理地址和程序内部的相对地址就能够计算出单元的物理地址,而分段部件正是用于将各段二位逻辑地址转换为一维的线性地址。
分段部件有六个16位的段寄存器,分别是代码段寄存器cs,堆栈段寄存器ss,数据段寄存器ds,es,fs,gs
分页部件用于物理存储器的管理,其功能是可选的,如果选择分页功能,则该部件将分段部件产生的线性地址按4kb为一页转换为主存的物理地址,再传给总线,如果不选择分页功能则分段部件产生的线性地址就是物理地址,直接传送给总线。
x86的三种工作方式
从80386开始,Intel公司的CPU提供了三种工作方式:实方式、保护方式、虚拟8086方式。
实方式
实方式下可以使用32位寄存器和操作数以及寻址方式,但是此时32位CPU和16位CPU一样只能寻址1MB的物理存储空间,段基址和偏移地址都是16位的,这样的段也称为16位段
保护方式
在保护方式下,使用32位地址线,寻址4GB的物理存储空间,段基址和段内偏移量都是32位的,并提供支持多任务的硬件结构,分段和分页功能可对各个任务分配不同的虚拟存储空间,实现执行环境的隔离与保护,并对不同的段设立特权级并进行访问权限的检查,对操作系统和各应用程序进行保护。
虚拟8086方式
在保护方式下运行的类似实方式的工作环境同时模拟多个8086处理器。
主存储器以及数据在计算机内的表示形式
主存储器
主存储器也称内存,是用来存储程序和数据的装置,由许多存储位(bit
)组成,每8位组成一个字节(byte
),相邻两个字节组成一个字(word
),相邻象个字组成一个双字(double word
)。为让CPU能够访问到指定内存单元,需要给每个存储单元赋予一个唯一的无歧义的编号,x86的内存按字节编址,即以字节作为最小的寻址单位,也可以一次访问2、4、8个字节的数据。
Intel x86系列采用小端存储的方式,低地址字节中存放数据的低字节,高地址字节中存放数据的最高字节。如双字数据12345678H
在内存中0040FF08H
单元的存放方式如下图
同时注意访问内存中的数据单元时要注意说明访问数据的类型。
数据在计算机内存中的表示形式
数据在内存中以补码的形式存放,求一个数的补码时,首先应将其用规定的字长表示(最高位为符号位,符号位与绝对值之间用0补齐),然后进行求补操作。
计算机中还可是使用BCD码(8421码)表示十进制数,即用四位二进制数表示一位十进制数,如$ (79)2=(01111001){BCD}$,由于计算机内存中能访问的最小单元是字节,一个字节有8位,故BCD码可以以未压缩的形式,即1个字节表示1位,或压缩的形式,即一个字节表示两位的形式存放。
字符数据可用ASCII码表示
数据段定义
数据段以.data
开头,下一个段定义开始时结束。数据段中定义的变量对应于C语言中的全局变量。
数据定义伪指令的格式为[variableName] typePseudoInstruction expression1,[expression2]
,其中变量名不能为汇编语言定义的关键字,数据定义伪指令(即数据类型)有db dw dd dq dt real4 real8
,分别代表无符号的字节、字、双字、四字、十字节的整形变量以及单精度和双精度浮点数,以及整形的全称byte word dword qword tbyte
,以及在整形全称前加上s
表示有符号。
表达式分为数值表达式,ASCII字符串,地址表达式,?
,重复子句。数值表达式即数值常量、符号常量和一些运算符组成的有意义的式子,符号常量是用=
或EQU
定义的常量;地址表达式是程序中出现的标号、定义的子程序名以及地址表达式;?
用于空间占位,表示变量无确定的初值;重复子句为n dup(expression[,expression])
,括号中的语句会重复出现n次。
数据定义和代码段中可以使用汇编地址计数器$
,其表示当前语句或值的地址
主存储器分段管理
内存管理有两种模式,扁平内存模型和分段内存模型。
在扁平内存管理中,代码、数据、堆栈全部放在同一个4GB的空间中,但是是分离的,虽然所有段寄存器会指向相同的内存起始地址,但是代码部分、数据部分在段中的偏移地址不同,由于段寄存器指向地址0,即不同部分的物理地址不同。
在分段存储模型中,每个段寄存器通常指向不同的分段选择器,以便每一个段寄存器指向线性地址空间的不同段。
主存储器物理地址的形成
实方式
8086下地址线有20根,但其只能进行十六位运算,为使其能表示20位地址,采用地址由段寄存器和段内偏移地址组成,内存单元具体地址为该单元所在段的地址左移四位(2进制下左移4位,16进制下左移一位),再加上偏移地址。
保护方式
保护方式下可直接由32位寄存器寻址,所以代码、数据、堆栈可以直接放在4GB空间内,CPU可以直接对他们寻址访问,但是在保护方式下需要注意执行环境的隔离与保护,因此保护方式下依然采用分段技术,将各个分段作为保护对象,用描述符对段进行描述,包括段的位置、大小、类型、特权级、是否被执行过,是否可读写等。
内存中设有描述符表保存描述符,描述符表分为局部描述符表用于对计算机执行的每个程序中各段的有关信息,全局描述符表用于包含系统中所有任务使用的描述符,中断描述符表用于包含中断服务程序的位置的描述符。
在保护方式下,段寄存器不在保存段的首地址,而是指出在该任务描述符表中选择此描述符的方式。在形成物理地址时首先根据段寄存器选择描述符并进行检查,如果合格则将描述符送入对于的描述符高速缓存寄存器,当需要对段中单元进行访问时,取出其段基址与ESP或EIP中的偏移地址相加,此时若不选择分页部件则得到物理地址,若选择分页部件则还要经过分页部件映射得到真是物理地址。
保护方式下形成物理地址最关键的一环在于检查段的使用是否合法,不要过分关注后面的偏移地址加上段基址,基本上段基址就是0
寻址方式
概述
一条指令一般包含以下内容:执行什么操作,操作数在哪,操作数的类型是什么。寻址方式解决的是第二个问题,即如何找到操作数。本章还会介绍基本的指令规则。
x86的寻址方式有6种,分别是立即寻址,寄存器寻址,直接寻址、寄存器间接寻址、变址寻址、基址加变址寻址。其中立即寻址的数的编码在指令中,寄存器寻址的数的指令在寄存器中,剩下的四种寻址方式都属于存储器寻址
x86的一些指令后面无操作数但有固定的操作对象,有些有一个操作数,有些带双操作数。双操作数的格式为操作符 目的操作数的寻址方式 源操作数的寻址方式
,一般有如下规则:
- 源操作数和目的操作数不能同时为存储器寻址方式
- 立即寻址不能作为目的操作数的寻址方式
- 目的操作数和源操作数的必须有一个类型明确,若两者的类型都明确,还要求两者类型相同,可用
TYPE ptr VALUE
来进行强制类型转换
下面介绍六种寻址方式
立即寻址
立即寻址所提供的操作数是紧跟在指令操作码后面的一个可用8位,16位或32位补码表示的有符号数,其是指令的一部分,例如mov eax,1
其中的1
就是立即寻址方式,其操作结果为将1放入寄存器eax
。立即寻址提供的操作数的类型的操作类型是不明确的,由指令中指明的操作数类型决定。当立即寻址操作数的长度超过指明的操作数的长度时,会发生报错
寄存器寻址
寄存器寻址采用某一个寄存器作为操作数的存放地址,操作数在指令指明的寄存器中。如add eax,ebx
中eax
ebx
都属于寄存器寻址,其操作结果为将eax
ebx
的值相加放到eax
中。寄存器的位数是确定的,即其类型是确定的,同注意类型匹配
直接寻址
在直接寻址中,操作数放在寄存器中,操作数的偏移地址紧跟在指令操作码后构成指令的一部分(注意直接寻址中是操作数的偏移地址作为指令的一部分,而立即寻址中是操作数的值作为指令的一部分)。直接寻址的操作数的类型是明确的。
现假设数据段中定义有变量x dd 100
,立即寻址有以下几种格式
1 | ;直接使用变量名或者在变量名外围加上中括号,如: |
注意变量名就是地址,在指令中出现的两个变量名可以相减得到两个变量之间的长度(而不是两个变量值的差,注意这里得到是值),但不允许相加,牢记指令中对变量名加减常数得到的依然是地址,但是在指令执行过程中会通过地址寻找数值,使用offset
操作符或lea
才会将地址真正当作一个数值,不通过他寻址。
虽然说变量名相当于地址,但其包含的信息比单纯的数字物理地址多,加不加中括号对变量无影响,但是对于真正的数字表示的地址,加上中括号代表寻址,不加则只是数值。这一点在寄存器间接寻址中可以看到。
寄存器间接寻址
寄存器间接寻址时数据在存储器中,操作数的偏移地址在寄存器中,通过[寄存器]
访问该操作数。即通过寄存器访问指定地址的变量。其能够访问的原因就是地址加上中括号表示寻址,但是由于变量的地址时程序允许过程中生成的,在程序未运行时我们无法确定其地址,所以有寄存器间接寻址,在程序运行过程中得到地址放入寄存器中再通过中括号访问,类似C语言中的指针间接访问。所以以下三种操作后,ebx
中结果相同
1 | ;第一种,由于mov ebx,x便可以做到,故没人不这么写。 |
寄存器间接寻址的类型是不确定的。
变址寻址
变址寻址的操作数放在存储器中,其偏移地址EA是指令中指定寄存器的内容乘以比例因子之后在于给定的位移量之和。其使用格式为V[R*F]
[R*F+V]
[R*F]+V
,其功能为寄存器R
乘以比例因子F
后再加上给定的位移量V
,注意寄存器必须在方括号内。
变址寻址本质上还是计算地址 R*F+V
,但是还是可以分为两种情况,一是寄存器中是变量地址,则V
必须为常量且F
为1,此时参照寄存器间接寻址理解,操作数类型不确定,例如下面两种方法ebx
中结果等价,也很好理解
1 | ;第一种 |
第二种V
为变量,则此时R*F
可以理解为C语言中访问数组元素时方括号里的数值,此时可以参照直接寻址中的变量[数值表达式]
,操作数类型确定,例如下面两种方法中ebx
中结果相等
1 | ;第一种 |
注意第二种情况下F
只能是1、2、4、8。
基址加变址寻址
基址加变址寻址的操作数放在存储器中,其偏移地址是治疗中指定的基址寄存器BR
中的内容、变址寄存器IR
中的内容与比例因子之间的乘积、位移量三者之和,其格式为V[BR+IR*F]
或[BR+IR*F+V]
或V[BR][IR*F]
基址加变址寻址本质上也是计算地址然后按照地址寻址,注意V
和BR
有且只有一个是地址,F
只能为1,2,4,8,特别注意第三种形式,其实就是对二维数组的访问,而二位数组的访问由可以转化为一维数组的访问,故参照变址寻址理解,不过变址寻址只有一个寄存器而基址加变址有两个。
x86机器指令编码规则
本节简要介绍机器指令生成的规则。
x86的机器指令编码依次由以下部分组成
- 指令前缀(prefix):不是指令编码的必须部分,如果有,用一个字节表示,一般用于限制指令的某些功能、标识指令的类别、强制变换操作数的段等。
- 操作码(opcode):指令编码的必要部分,指明要进行的操作,大多数指令的操作码为1个字节,最多两个字节,FPU和SSE的指令长度为3个字节
- 寻址方式R/M(Mod R/M):必要性由操作码决定。寻址方式占一个字节,其最低三位(R/M)和最高两位(Mod)用于确定一个操作数的寻址方式,中间三位用于指定由操作码确定的操作数使用的寄存器
- 比例因子-变址-基址(SIB):在使用基址加变址寻址时,SIB与寻址方式R/M共同确定操作数的寻址方式,占一个字节
- 地址偏移量(displacement):非必需。占1、2、4个字节,分别对应8、16、32位的偏移量
- 立即数(immediate):非必须,占1、2、4个字节
常用机器指令
机器指令的基本规则前面已经介绍过,即不允许两者都是存储器寻址方式,一般要求类型匹配。
- 数据传送指令
- 一般数据传送
mov opd,ops
- 符号扩展传送指令
movsx opd,ops
注:该指令要求源操作数的位数小于目的操作数的位数,而不是两者类型相同,将符号位向前扩展后传送 - 无符号扩展传送指令
movzx opd,ops
注:使用规则同上,功能为向前扩展0位后传送 - 一般数据交换指令:
xchg opd,ops
- 查表转换指令:
xlat
功能为将[ebx+al]赋给al - 带条件的数据传送指令:
cmov* register,register/memory
。*
可为z/e,c,s,o,p
分别对应ZF,CF,SF,OF,PF
为1时指向传送 - 进栈指令:
push ops
- 出栈指令:
pop opd
- 将通用寄存器按照
eax,ecx,edx,ebx,esp,ebp,esi,edi
顺序入栈pushad
或将对应十六位寄存器顺序入栈pusha
- 顺序出栈
popad
和popa
- 将标志寄存器低8位送入
ah
中lahf
,将ah
传入标志寄存器低8位中sahf
- 16位标志寄存器的进栈
pushf
,出栈popf
,32位标志寄存器进栈pushfd
,出栈popfd
- 地址传送指令
lea opd,ops
- 传送偏移地址及数据段首址
lds/les/lfs/lgs/lss opd,ops
,功能为(ops)->opd,(ops+2)->ds/es/fs/gs/ss
注:在32位扁平内存管理下这些指令没有用
- 一般数据传送
- 算数运算指令
- 加1指令:
inc opd
,减1指令:dec opd
- 加法指令:
add opd,ops
,减法指令:sub opd,ops
注:opd±ops赋给opd - 带进位的加法指令:
adc opd,ops
,带借位的减法指令:sbb opd,ops
注:opd±ops±cf赋给opd - 求补指令:
neg opd
- 比较指令:
cmp opd,ops
注:比较指令本质上是做差,但是不会改变源操作数和目的操作数,只改变标志寄存器 - 单操作数的有符号乘指令:
imul ops
(ops的类型必须确定,根据ops的类型选择ops与al、ax、eax乘,结果放在ax或dx,ax或edx,eax中) - 双操作数的有符号乘指令:
imul opd,ops
(opd*ops赋给opd) - 三操作数有符号乘指令:
imul opd,ops,n
(n为立即数,ops*n赋给opd) - 无符号乘指令:
mul ops
(用法与imul的单操作数用法一致,不过操作数为无符号数) - 无符号除指令
div ops
(ops的类型必须确定,ops作为除数,根据ops是字节、字、双字类型的变量,分别用ax或dx,ax或edx,eax除以ops,将得到的商放在al,ax,eax中,余数放在ah,dx,edx中) - 有符号除指令
idiv ops
(用法与div一致不过操作数为有符号数) - 将字节转换为字、将字转换为双字、将双字转换为四字:
cbw cwd cwde cdq
分别对al有符号扩展到ax,ax有符号扩展到(dx,ax),ax有符号扩展到eax,eax有符号扩展到(edx,eax)
- 加1指令:
- 逻辑运算指令
- 与或非,异或:
and opd,ops
or opd,ops
not opd
xor opd,ops
(都是ops与opd运算后结果放入opd) - 测试:
test opd,ops
(将opdopd,改变标志寄存器,不改变ops和opd)
- 与或非,异或:
- 移位指令
- 算数左移与逻辑左移
sal opd,n
shl opd,n
(直接左移,低位补0,CF为最后移入的位) - 算数右移:
sar opd,n
(右移,最高位填入符号位),逻辑右移:shr opd,n
(右移,最高位填入0)两者的CF都是最后移入位的值 - 循环左移右移:
rol opd,n
ror opd,n
(将目的操作数最高位与最低为连接起来组成一个环来移位,CF是最后移入的位) - 带进位的循环左移右移
rcl opd,n
rcr opd,n
(把进位当最高位循环移动) - 双精度左移右移:
shld opd,ops,n
shrd opd,ops,n
(将ops的高n位移入opd的低n位或将ops的低n位移入opd的高n位,相当于把opd,ops拼接起来移动,但是ops是不变的,CF为opd最后移出的一位)
- 算数左移与逻辑左移
剩余的位操作、字节操作指令、标志位控制指令、I/O指令以及一些杂项指令基本不考。需要注意的是I/O指令在Windows系统中的特权级为2,所以特权级为3的应用程序不能使用I/O指令,实现I/O操作需要通过Widows提供的API实现。
顺序和分支和循环程序设计
程序中的伪指令
常用的伪指令有处理器选择处理器选择伪指令、存储模型选择伪指令、数据定义伪指令,符号定义伪指令,段定义伪指令、程序的模块定义与通信伪指令,宏定义伪指令,条件汇编伪指令,格式控制、列表控制以及其他功能伪指令。其中数据定义、符号定义伪指令前面说过
处理器选择伪指令用于告诉编译器选择合中CPU支持的指令系统,一般使用.686P
(接受全部的部Pentium Pro指令)或.MMX
(接受全部MMX指令)
存储模型伪指令格式为model 存储模型 [,语言类型]
,Win32程序选择flat
。语言类型用于指定函数参数的传递方法与释放参数空间的方法,不是必须的,若不给出则需要在函数定义与函数原型说明中给出语言类型,若给出说明,则函数定义与函数原型中可以默认其类型为语言类型给出的类型(也可以在函数定义或说明中给出其类型,若给出则按给出的类型来,注意函数的定义和说明的类型必须一致)
段定义伪指令即为.data
.stack [堆栈字节数,默认1024]
.code [段名]
等,结束伪指令为end [表达式]
,表达式若有,一般为一个标号,用于说明程序执行的入口点
转移指令
转移指令分有条件转移指令和无条件转移指令。
对有条件转移指令,其使用方式为操作符 标号
,结果汇编后标号被翻译为紧跟转移指令后的一条指令的EIP到转移目的地址之间的字节距离,正数为往下转移,符数为往上转移。有条件转移指令根据标志位进行判断是否执行,每个标志位对应一个j*
一个jn*
,*
为标志位的符号(z s o c
等),分别表示标志位为1的时候执行和标志位为0 的时候执行。
在实际应用中,有条件转移指令常跟在比较指令后,根据比较指令结果,又有两类转移指令,分别是无符号转移指令和有符号转移指令。无符号转移指令根据比较指令中无符号的opd比ops大、小或相等,分别对应a
b
e
,在加上非的符号n
,可以组成ja jb je jna jnb jne jae jbe jnae jnbe
。有符号转移指令根据比较指令中有符号的ops比opd大、小或相等对应g
l
e
,再加上非的符号n
又可以组成jg jl je jng jnl jne jge jle jnge jnle
。
无条件转移指令包括jmp call ret int iret into
,本节介绍jmp
,call ret
用于子程序调用与返回int iret
是中断程序的调用与返回,into
是溢出中断程序。
jmp
指令有两种用法,一是jmp 标号
执行到此处时无条件跳转至标号处,二是jmp opd
,opd
中存放目的地址,无条件转移到目的地址
分支程序设计
可以按照流程图条件安排转移语句实现分支程序设计,在多分支程序设计中善用构造表的方法将多分支转化为无分支(如c语言编译器对switch语句的实现)
可以使用条件控制伪指令来编写分支语句,格式如下:
1 | .if 条件表达式1 |
用法接近于C语言的if语句,条件表达式可以用> < == >= <= != ! && ||
等以及条件标志位
循环程序设计
循环程序设计依旧可以按照流程图安排适当的比较和跳转指令实现。除此之外,还可以使用循环控制指令实现
一般循环转移指令格式为loop 标号
每执行到这里将ecx或cx减一,当不为0时重复标号到loop
之间的流程,不影响标志位
相等循环转移指令格式为loope/loopz 标号
,其继续循环的条件是ecx(cx)不为0且ZF等于1,故在loope
之前可以加上比较指令,当比较指令得到的结果相等时或ecx(cx)为0时结束循环
不等于循环转移指令格式为loopnz/loopnz 标号
,和相等循环转移指令类似,不过在比较指令得到的结果不相等时结束循环
可以使用循环执行伪指令实现循环操作,格式为
1 | .while 条件表达式 |
循环体中可以有.break
和.continue
等,用法与C语言类似
还有重复执行伪指令,等价于do-while循环,格式为
1 | .repeat |
或者
1 | .repeat |
注意.break
和.continue
伪指令除了能执行C语言中break
和continue
的功能,还能接条件表达式,格式为.break .if 条件表达式
和.continue .if 条件表达式
功能为当条件表达式成立执行break
或continue
。
子程序设计
子程序的基本用法
定义
子程序就是C语言中的函数,其基本格式如下:
1 | 子程序名 proc |
子程序应放在代码段中,从语法上看,子程序可以放在代码段的任何位置,但是从程序运行的逻辑上看,一般要将其放在主程序开始之前或之后(main
也是一个特殊的子程序)。
调用
语句格式为call 子程序名
,过程为将call
语句的下一条语句的EIP压栈,然后将子程序名对应的地址送入EIP。
或者call dword ptr opd
,这里的dword ptr opd
就是子程序地址,与jmp
的间接转移用法一致。
注意在分段管理模式中子程序与主程序不在同一代码段内时,在压入断点地址之前要先压入段寄存器值。在32位扁平内存管理模式下只需要压入32位偏移地址入栈。
返回
ret
指令用在子程序中,控制CPU返回主程序断点处继续往下执行。有两种用法,一是直接返回调用子程序处,即从栈顶弹出一个双字(保存的断点地址)送给EIP,用法为ret
,另一种是不仅返回断点地址还将esp加上n个字节(即还原堆栈n个字节的空间,n为正整数且为偶数,用于还原参数空间)用法为ret n
。对于参数空间的释放,可以在子程序中进行也可以在主程序中进行(即在主程序中add esp,n
),对C语言来说,参数空间是在调用子函数的函数中释放的
在子程序间传递参数
常用的参数传递方法有寄存器法,约定单元法和堆栈法,由名字可知三者分别通过寄存器、数据段和堆栈段传递数据。
使用寄存器传递数据时由于寄存器的个数有限,只适合于传递较少的参数,且注意小心使用寄存器以免丢失数据。使用约定单元传递参数需要用到全局变量,模块间的耦合性过高。堆栈法最为常用。
堆栈法通过堆栈传递数据,在调用子程序时,首先将参数压栈,然后执行call指令调用子程序,此时堆栈中的内存分布如下图
显然esp+4
就是最后被压入的参数,可以通过esp来访问参数。当然这只是简单情况,事实上在子程序中esp依然会变化(比如申请局部变量),为方便访问堆栈内存单元,一般会压入ebp,然后令ebp=esp,此时堆栈内存分布如图。
此后而ebp不在变化,可以通过ebp+8
来访问最后压入的参数。
堆栈除了用来传递参数外,还可以用来保护现场,对要用到但又不想让子程序改变其值的寄存器可以先将其值压栈,在子程序执行完毕后再按顺序出栈到对应寄存器。
C语言中函数的运行机理
C语言函数的参数传递同样使用堆栈法,对于有多个参数的函数,参数压栈的顺序由编译器决定。再函数调用完毕后,会在调用处对esp操作以消除参数空间。
进入函数后首先将ebp压栈,然后将esp减去一个值,在堆栈中留出空间分配局部变量,然后保护一些寄存器的值。局部变量的地址就在ebp之下,可以通过[ebp-4]
[ebp-8]
等进行访问。
函数返回时,先恢复之前保护的寄存器的值,函数的返回值放在eax中,然后将esp还原成进入函数时的状态,最后ret
汇编语言中子程序的高级用法
局部变量的定义与使用
紧跟在proc
用local
伪指令说明仅在本函数内使用的局部变量,格式为
1 | local 变量名[[数量]][:类型]{,变量名[[数量]][:类型]} |
如
1 | local u:dword,w:dword |
局部变量在内存中的分布如C语言中一样,所以单个局部变量作为源操作数或目的操作数时的寻址方式时变址寻址方式,一般先定义的局部变量地址大,后定义的局部变量的地址小;局部变量的地址不能用offset
取,用lea
;局部变量的寻址本身包含ebp,故其变址寻址其实是基址加变址寻址。
子程序的原型说明、定义和调用
1 | 函数名 proto [函数类型][语言类型][[参数名]:参数类型....] |
函数类型可选FAR
NEAR
在.model flat
下,应选NEAR
,默认值也是NEAR
语言类型用于说明参数传递和空间释放的规则,C
和stdcall
都是从右往左入栈,C在调用程序中释放空间,stdcall在被调用程序中释放空间。如果主存储模型中说明了语言类型,且本函数类型与其一致,则可省略语言类型,否则要说明。
定义的格式为
1 | 函数名 proc [函数类型][语言类型][使用的寄存器表][参数名[:类型],...] |
函数类型与语言类型与proto用法一致,参数名不能省略,类型省略时默认dword
,16位段中为word
,寄存器表说明了本程序中需要保护的寄存器
调用伪指令为invoke 参数名,[参数]
串处理程序设计
x86提供以下5条串操作指令
- 传送字节、字、双字指令:movs、movsb、movsw、movsd
- 比较字节、字、双字指令:cmps、cmpsb、cmpsw、smpsd
- 搜索字节、字、双字指令:scas、scasb、scasw、scasd
- 存储字节、字、双字指令:stos、stosb、stosw、stosd
- 装载字节、字、双字指令:lods、lodsb、lodsw、lodsd
由于串操作指令一般都是连续多次执行相同的操作,所以串操作智联前面可加重复前缀,可用的重复前缀有:
- rep:无条件重复ecx中指定的次数,每执行一次ecx减一直至ecx=0
- repe/repz:每执行一次ecx减1,ecx为0或ZF为1时停止循环(对比
loope
) - repne/repnz:每执行一次ecx减1,ecx为0或ZF为0时停止循环(对比
loopne
)
串操作指令在使用格式和使用方法上有许多类似的地方,他们呢隐含地使用相同的寄存器、标志位和符号,规定如下:
源串放在ds:esi
中,目的串放在es:edi
中,重复次数放在ecx
中,scas
的搜索值或lods
的目的地址和stos
的源地址放在al/ax/eax
中,当DF为1时(使用指令cld
)esi
、edi
自动增量,DF为0时(使用std
指令),esi
,edi
自动减量
系统规定,源串要在当前数据段中,目的串要在附加数据段中,在32位扁平内存管理模型下,所有段寄存器值相同,所以只需要设置寄存器esi
与edi
串传送指令中movs
需要给出源操作数和目的操作数,但这个操作数只用来告诉指令需要传送的数值的类型,不是将源操作数传送到目的操作数,给出类型后就相当于movsb
movsw
movsd
,将一个字节或一个字或一个双字从ds:esi
传送到ds:edi
,esi
与edi
的增减按照DF标志位进行
串比较指令是将两串相减并设置标志位cmps
与cmpsb cmpsw cmpsd
的区别同上,但是注意串比较指令中是ds:esi
-ds-edi
,是源串减目的串,一般的比较指令是目的操作数减源操作数。
串搜索指令搜索目的串中是否有al/ax/eax
中的字节或字或双字数据,指令会将al/ax/eax
的值减去es:edi
的指向的值,然后设置标志位。scas
只带一个参数,用于指定目的串类型。
向目的串中存储、从源串中取出操作规则同上。
复合数据类型的定义与使用
结构体与结构变量的定义
汇编语言中定义结构体需要用伪指令,格式如下:
1 | 结构体 struct |
例如课程结构course的定义代码如下:
1 | course struct |
结构体中可以包含其他结构体,如
1 | department struct |
包含其他结构体时,用结构体名代替基本类型名,用<>
表示初值
结构变量的定义格式为[变量名] 结构名 <字段赋值表>
,字段赋值表用于给结构变量各字段重新赋值,字段值的排列顺序及类型与结构说明时各字段一致,中间用逗号分隔,如果某个字段采用在结构定义时指定的初始值,可以简单用逗号表示,如果不打算对结构变量重新赋值,可以省区字段赋值表,但是要保留尖括号。局部结构变量的定义为local 变量名[数量]:结构名
结构变量的访问
- 直接使用变量名加字段名访问,如:
mov eax,ke.cid
- 寄存器间接寻址加结构名加字段名,如
mov eax,[ebx].course.cid
- 寄存器间接寻址,相当于把结构体看成一堆定义在一起的变量,计算地址偏移量
结构信息的自动计算
offset
offset
后可接单个变量名、结构名.字段名、结构变量名.字段名,第一个是对全局变量求其在段中的偏移地址,第二个时求一个字段在一个结构中的偏移地址,第三个是求变量的指定字段在段中的偏移地址
type
用法为type 类型名
或type 变量名
,功能为获得数据类型的长度或者变量对应的类型的长度
length
用法为length 变量名
,功能为获得定义该变量时第一个表达式对应的元素个数,当定义语句为n dup(表达式)
时返回n,其他返回1
size/sizeof
当后面接结构名时,相当于type 结构名
,当接变量名时,用于获得变量定义时第一个表达式所占的单元字节数(注意这里时变量的单元字节数而不是type
得到的类型字节数)
结构变量的数据存储
汇编语言中,结构变量相当于在一个变量后面定义一系列变量,即从机器语言的角度看没有结构和结构字段的说法,只有偏移地址。
C语言中存在结构体对齐,结构变量的字段间可能存在对齐的单元导致其存储与汇编语言有所不同。
程序设计的其他方法
多模块程序设计
在汇编多模块设计中,虽然不同文件中的data段定义的都是全局变量,但是一个文件中定义的全局变量要想在其他文件中使用,需要用public 符号[,符号]
说明公有符号(注意这里的public不是用在变量定义里面声明的,这不是面向对象程序设计中的访问类型控制符,二是单独的一个说明语句)
要引用其他文件的公有符号时,需要使用extern 符号:类型[,符号:类型]
,除一般的类型外,若要使用公有符号常量,使用类型ABS
。函数声明语句前面已经说过。
C语言与汇编语言混合使用
C语言中使用汇编语言函数与调用C语言编写的函数没有区别,汇编语言调用C语言函数也只需正确使用proto
伪指令说明即可。需要注意的是由于C++存在换名规则,而汇编语言不会换名,故C++程序中使用汇编函数时需要使用extern "C"
来说明按C语言的规则来解析符号。
在C语言中声明汇编函数时,要注意语言类型,若类型为C不用特殊说明或说明为返回值 __cdecl 函数名
,若类型为stdcall,说明为返回值 __stdcall 函数名
C语言中可以使用内嵌汇编语句,具体的使用方法为
1 | __asm{ |
或
1 | __asm 汇编指令 |
宏功能程序设计
程序中经常将要用到的独立功能程序段设计为子程序,但是子程序的调用需要付出时间和空间上的额外开销,所以当程序中的重复部分只是一组较为简单的语句序列时,一般将其设计为宏指令。宏指令在编译时会展开,相当于把程序中调用宏指令的语句替换为宏体并按参数位置的对应关系进行参数替换,然后编译器会进行语法检查。宏指令的处理是在编译时进行的,编译完成后宏指令就不存在了,而是替换为了宏体,所以执行速度快,由于采用参数的替换来传递参数,使用起来也比子程序简单,但是由于宏是直接在程序中展开的,每使用一次宏,宏体的代码就出现一次,而子程序的目标代码只会出现一次,故一般子程序的目标程序短,占用空间小。
宏定义的格式为
1 | 宏指令名 macro 参数[,参数] |
使用时直接使用宏指令名 参数[,参数]
即可
中断与异常处理
在计算机中,中断是指CPU获知发生了某事件,暂停当前正在执行的程序,转而为临时出现的事件服务。引起中断的事件称为中断源,感知中断的系统称为中断系统,存放中断处理程序入口地址的内存称为中断描述符表。
中断分为由外部硬件设备引起的中断和CPU内部出现的中断。前者称为外部中断或异步中断,可分为不可屏蔽中断和可屏蔽中断,可屏蔽中断根据IF位决定是否响应中断。后者称为同步中断,或异常。
异常分为三类:故障、陷阱和终止。故障一般可以纠正,处理完异常后,引起故障的指令被重新执行,如除法出错、数组访问越界;陷阱如软中断、单步异常等,是指程序在执行到异常处就去执行其他地方的指令,执行完后继续原指令。中止是西永出现严重问题时的异常
在IA-32体系结构中,对每一种中断和异常进行了编号,称为中断向量号,其值在0255之间。每一个中断或异常处理程序都有一个入口地址,称为门,在保护方式下,将各个中断门或陷阱门的描述符按照中断向量的编号的顺序存放在一起,形成的表时中断描述符表。
在实方式下,每一个中断信息处理程序的入口地址是4个字节,包括2个字节的段内偏移和2个字节的段地址,这些信息按中断号的顺序放在一起,形成中断矢量表,中断矢量表在内存的最低端,CPU获得n号中断处理程序入口地址的方式是(0:[n*4])赋给IP,[0:[n*4+2]]赋给CS。
不切换栈时,中断和异常的响应过程为
- 标志寄存器入栈
- CS入栈
- EIP入栈
- 如果有错误编码,错误编码入栈
- 从中断门或陷阱门获得段选择符送入CS,偏移地址送入EIP(实方式下是在中断矢量表中将段内偏移地址和段地址赋给IP和CS)
中断程序最后一句为iret
,其执行过程为
- 弹出EIP
- 弹出CS
- 弹出EFALGS
- 消除参数空间
Win32窗口程序设计
Win32窗口程序设计基础
窗口应用程序最大的特点是事件驱动,没有固定的流程,用户的操作是事件,程序会根据事件进行响应,调用对应的处理函数来完成程序所规定的功能。应用程序的运行高度依赖Windows操作系统,很多功能是由操作系统完成的。
窗口程序运行时,首先要获取应用程序的句柄,句柄时由操作系统管理的资源的唯一编号,用户程序需要先获得资源句柄才能对资源进行操作,程序中使用GetModuleHandle函数获得本应用程序的句柄。
为显示窗口,程序需要注册和创建窗口,注册窗口就是向操作系统报告窗口信息由操作系统登记管理,创建窗口时需要提供窗口信息,创建窗口后,函数返回窗口句柄
为了向用户呈现窗口并获得事件,需要刷新窗口状态并执行消息循环,刷新窗口状态由UpdateWindow函数通过发送WM_PAINT消息来更新,消息循环是一个循环执行的程序段,该程序段的功能时获得消息,翻译消息以及派发消息。消息是指发送给应用程序的各种命令,如键盘鼠标的输入命令以及系统或其他软件产生的命令等。
Win32窗口应用程序的结构
基于窗口的应用程序分为四个部分,主程序、窗口主程序、窗口消息处理程序以及用户处理程序。
主程序一般用于完成初始化工作;窗口主程序(一般命名为WinMain
)首先完成窗口的注册于创建以及资源的加载,然后不断从操作系统获得信息并分发到窗口消息处理程序;窗口消息处理程序(一般命名为WinProc
)用于对消息进行判断完成对应的处理功能,用户处理程序则是用户根据需要自定义的消息响应任务函数,由窗口消息处理程序调用。
这四个部分的关系为:
- 操作系统执行主程序
- 主程序调用窗口主程序
- 窗口主程序创建、注册窗口,不断从程序的消息队列中获得消息、翻译消息和派发消息
- 窗口主程序通过操作系统调用窗口消息处理程序
- 窗口消息处理程序接受操作系统转发的消息,判断消息的种类调用用户处理程序完成相应的功能
在程序简单的情况下,主程序和窗口主程序可以合成一个函数,窗口消息处理程序和用户处理程序可以合成一个函数。
- Title: X86汇编语言程序设计
- Author: Yizumi Konata
- Created at : 2021-05-20 22:20:56
- Updated at : 2024-06-06 23:04:52
- Link: https://zz12138zz.github.io/2021/05/20/X86汇编语言程序设计/
- License: This work is licensed under CC BY-NC-SA 4.0.