掌握汇编语言是学习逆向分析和免杀技术的关键,文章对以往所学习的汇编知识进行了梳理和总结,基于Win32汇编,详尽记录了学习汇编所需基础知识和汇编指令及用法,旨在巩固汇编基础,夯实基本功。
参考书籍 《Win32汇编语言程序设计》
文章目录
一、基础知识储备
(一) 计算机语言
计算机语言分为机器语言、汇编语言、高级语言三大类:
- 机器语言:由0和1组成的指令 (采取01二进制原因是便于底层晶体管逻辑实现),也是CPU唯一能解释执行的命令,可辨度差,难以调试排查故障。
- 汇编语言:机器语言的助记符,由汇编指令、伪指令和其他符号组成,汇编语言经过编译转换成机器语言,汇编指令与机器指令一一对应,伪指令和其他符号帮助编译器将汇编指令转换为机器指令,但本身并不对应产生机器指令。汇编语言增加了可读性,但其依赖于底层硬件,通用性较差,大量助记符的记忆和辨识仍有一定难度。
- 高级语言:语法和结构类似英文,不依赖于底层硬件,多层封装,通用性强,根据应用领域和需求设计有多种高级语言:C、C++、Java、Python、JavaScript等等,功能实现方便,便于记忆和辨识。高级语言分为编译型和解释型两种:编译型语言经过编译、汇编、链接最终形成可执行二进制文件,一次编译就可以脱离开发环境运行,执行效率高,但不同平台通用性差,不便移植;解释型语言由相应解释器逐行解释运行,不同平台只要有解释器即可运行,通用性强,但因为要边解释边运行,导致执行效率低。
(二) CPU架构和指令集
CPU架构即CPU内部硬件架构,也称为微架构,是厂商对某一系列CPU所定义的规范,它规定了CPU内部硬件之间的逻辑运算方式,CPU架构和指令集之间有着对应关系。目前CPU指令集主要分为复杂指令集 (CISC) 和精简指令集 (RISC) 两类:CISC指令数量众多且复杂,每条指令长度不同,运行时间较长,但每条指令可以处理的工作较多;RISC指令精简,每个指令运行时间较短,完成的工作也比较简单,执行性能佳,功耗少。
CISC架构CPU以x86为主,RISC架构CPU以ARM / MIPS / PowerPC为主,如下介绍:
- x86:x86是一个Intel通用计算机系列的标准编号缩写 (8086、80286、80386、80486、80586等),1978年Intel推出了首款基于x86架构的16位处理器:8086CPU (x86-16或IA-16架构),1985年又推出了首款32位的处理器:80386 (IA-32架构),其同样基于x86架构。市场上巨大的成功使得x86架构迅速成为PC业界标准,x86系列采用复杂指令集 (CISC),具有向下兼容的特点,包括后续由AMD先发制人发明的AMD64,或称x86-64架构,可以看成是x86架构的超集。Intel为了与AMD64竞争,发明了不向下兼容x86的IA64架构,但市场经营惨淡,IA64最终退出市场,Intel也妥协于AMD64。
- ARM:ARM指 Advanced RISC Machines,采用精简指令集 (RISC),第一款RISC微处理器由英国Acorn公司设计,1991年Acorn成立为新的ARM公司,与一般商业模式不同,ARM公司既不生产芯片也不销售芯片,它只出售芯片技术授权,ARM架构以其低成本、高性能、低功耗以及兼容16位 (Thumb) / 32位 (ARM) 指令集的优势,迅速占领了移动端和嵌入式产品市场。
- MIPS:MIPS指 Microprocessor without Interlocked Piped Stages,其机制是尽量利用软件办法避免流水线中的数据相关问题,采用精简指令集 (RISC),它最早在80年代初由斯坦福大学Hennessy教授领导的研究小组研制而出,目前占领诸如电视盒子、打印机等一定规模的小众嵌入式产品市场。
- PowerPC:PowerPC指 Performance Optimization With Enhanced RISC – Performance Computing,或简称为PPC,采用精简指令集 (RISC),二十世纪九十年代,由IBM、Apple和Motorola公司共同开发,目前在通信、工控、航天国防等要求高性能和高可靠性的领域得到广泛应用。
(三) 寄存器
CPU主要由运算器、控制器以及多个寄存器组成,寄存器是CPU内部的信息存储单元,不同架构CPU之间寄存器类型有所区别。有很多寄存器如IR、MAR、MDR等是CPU内部使用的,对程序员而言是透明的,我们无法直接对其进行操作,而我们需要关注的是编程可以直接操作的寄存器,这类寄存器主要包括通用寄存器、段寄存器、指令指针寄存器和标志寄存器,这里先以8086寄存器为基础对x86系列寄存器进行介绍。
1.8086 寄存器
8086 CPU是8086系列CPU的基础,它有16根数据线和20根地址线,可寻址空间为1MB,虽然后续出现32、64位的高性能CPU,但其寄存器主要都是在8086寄存器上的扩展,向下兼容。8086 CPU中对程序员可见的寄存器分为通用寄存器、段寄存器、指令指针和标志寄存器4类共14个,均为16位,具体如下:
(1) 通用寄存器 (8个)
通用功能为传送和暂存数据,也可参与算术逻辑运算,并保存运算结果,除此之外每个寄存器还有一些特殊用途。
① 数据寄存器
- AX (Accumulator):累加寄存器,可分为两个独立的8位寄存器,高8位AH,低8位AL。在除法运算中,AX存储被除数,乘法运算中,AX存储被乘数,如果运算数或运算结果是32位,则低16位存在AX中,高16位存在DX中
- BX (Base):基地址寄存器,可分为两个独立的8位寄存器,高8位BH,低8位BL,用于寻址,存放某个存储单元地址的偏移,格式为:
段:[偏移]
,不指明段则默认为DS,如:MOV AH,DS:[BX]
与MOV AH,[BX]
等效 - CX (Count):计数器寄存器,可分为两个独立的8位寄存器,高8位CH,低8位CL,用于循环计数
- DX (Data):数据寄存器,可分为两个独立的8位寄存器,高8位DH,低8位DL。在运算中,如果运算数或运算结果是32位,则低16位存在AX中,高16位存在DX中
② 指针寄存器
- SP (Stack Pointer):堆栈指针寄存器,SS:SP 指向栈顶元素,使用PUSH指令压栈时 SP=SP-1,使用POP指令弹栈时 SP=SP+1 (栈向下增长)
- BP (Base Pointer):基指指针寄存器,用于栈寻址,不指明则使用SS作为默认段基地址
③ 变址寄存器
- SI (Source Index):源变址寄存器,用于寻址,可存放源操作数的偏移地址,不指明则使用DS作为默认段基地址
- DI (Destination Index):目的变址寄存器,用于寻址,可存放目的操作数的偏移地址,不指明则使用DS作为默认段基地址
(2) 段寄存器 (4个)
因为8086 CPU数据总线宽度为16位 (寻址64Kb),但是地址总线宽度为20位 (寻址1Mb),因此采用 [段地址*16+偏移地址] 的方式进行寻址,即段地址左移4位 (空缺补0) 后加上偏移地址,段寄存器的意义就在于为不同的段提供段地址。
- CS (Code Segment):代码段寄存器,存放代码段基地址,CS:IP 指向CPU当前将要读取的指令的地址,当一个可执行文件加载到内存中以后,CS:IP 便指向了这个可执行文件的起始地址
- DS (Data Segment):数据段寄存器,存放数据段基地址,除了BP寄存器外,其余寄存器都默认使用DS寄存器的值作为默认段基地址
- SS (Stack Segment):堆栈段寄存器,存放堆栈段基地址,SS:SP 指向栈顶元素,使用PUSH指令压栈时 SP=SP-1,使用POP指令弹栈时 SP=SP+1
- ES (Extra Segment):附加段寄存器,存附加段基地址
32/64位CPU地址总线和数据总线宽度保持一致,直接使用寄存器就可以寻址整个内存空间,因此 WIN32及之后汇编中不再通过段寄存器将内存进行分段,也无需再使用段寄存器进行寻址。
(3) 指令指针和标志寄存器: (2个)
- IP (Instruction Pointer):指令指针寄存器,CS:IP 指向CPU当前将要读取的指令的地址,当一个可执行文件加载到内存中以后,CS:IP 便指向了这个可执行文件的起始地址
- FLAG:标志寄存器,每一个位都表示不同的状态,存储的信息通常又被称作程序状态字 (PSW)
CF:进位标志,运算结果最高位进位时置1
PF:奇偶标志,运算结果低8位中“1”的个数为偶数时置1
AF:辅助进位标志,运算结果低4位产生进位时置1
ZF:零标志,运算结果为0时置1
SF:符号标志,运算结果为负时置1
TF:跟踪标志,置1时进入单步调试状态
IF:中断标志,置1时开中断,置0时关中断,缺省值为1
DF:方向标志,置1时串操作为减地址方式,置0时为增地址方式,缺省值为0
OF:溢出标志,运算结果超出范围时置1
2.32/64位CPU寄存器
第一款应用于桌面计算机的32位CPU是Intel公司的80386,这也是x86系列中第一个32位CPU,采用IA-32架构,80386除了包含8086所有的寄存器,并把通用寄存器、指令指针寄存器、标志寄存器从16位扩充成32位之外 (名字变为EAX、EBX、ECX…),还增加了两个16位的段寄存器FS和GS:
- FS (Flag Segment):标志段寄存器
- GS (Global Segment):全局段寄存器
32位之后又发展出64位的CPU,第一款应用于桌面计算机的64位CPU是AMD公司的Athlon64,其寄存器在80386基础上把通用寄存器、指令指针寄存器、标志寄存器从32位扩充成64位 (名字变为RAX、RBX、RCX…),同时又增加了8个通用寄存器R8-R15 (因为通用寄存器原先有8个0-7,所以新增加的从编号8开始),x86系列寄存器对比如下图所示:
(四) CPU工作模式
8086是在纯DOS系统下运行的,之后80386+ CPU (IA32) 为向下兼容,让旧程序仍然可以在新的CPU上执行,设置了3种操作模式:实模式、保护模式、虚拟8086模式。纯DOS操作系统运行在实模式,Windows操作系统运行在保护模式,Windows操作系统下运行纯DOS应用程序属于虚拟8086模式,3种操作模式基本区别如下:
1.实模式
实模式下的存储器寻址方式和8086完全一致,采用 [段地址*16+偏移地址] 的方式进行寻址,即使是32位环境,也只能使用低20位,寻址空间为1MB。实模式是纯DOS环境,不支持多任务和优先级,用户程序可以随便修改系统中的数据,相当于工作在特权级ring0。所有操作系统在刚启动时都是实模式,运行自检和载入引导程序 (从BIOS中读取) 后再跳转到保护模式继续接下来的工作。
2.保护模式
Windows环境运行在保护模式下,使用32位地址,寻址空间达4GB,支持多任务和优先级,操作系统运行在ring0级别,用户程序运行在ring3级别 (操作系统只用到ring0和ring3),只有ring0级别才可以修改段寄存器,因此Win32汇编一般不再操作段寄存器。我们编写的汇编指令编译成可执行文件,在操作系统中直接运行就是在保护模式。
3.虚拟8086模式
虚拟8086模式是为了在Windows环境下 (保护模式) 运行纯DOS程序 (8086程序) 而设置的,如果DOS程序中有特权指令,则在Windows环境下运行会产生异常,这些指令可能被模拟实现,也可能被忽略。
(五) 计算机体系结构
篇幅较多,见文章:计算机体系结构简析
二、指令及用法
汇编语言由汇编指令、伪指令和其他符号三部分组成,汇编指令相当于机器指令的助记符,与机器指令一一对应,伪指令和其他符号帮助编译器将汇编指令转换为机器指令,但本身不产生机器指令。
(一) 汇编格式
汇编指令具有两种标准的格式:Windows下一般为Intel格式,Linux/Unix下一般为AT&T格式,一些典型区别如下表所示:
Intel | AT&T | |
---|---|---|
寄存器标识 | ax | %ax |
立即数格式 | 1234h | $0x1234 |
操作数格式 | mov ax,1234h mov al, byte ptr 12h | mov $0x1234, %ax movb $0x12, %al |
寻址方式 | [ebx + 4*eax + section] section[ebx + 4*eax] | section(%ebx, %eax, 4) |
跳转格式 | jmp far section:offset call far section:offset | ljump $section, $offset lcall $section, $offset |
(二) 汇编指令
指令是指编译后能生成机器码的语句,一条指令由4个部分组成:标号: 助记符 操作数 ;注释
,这里基于Win32汇编,介绍指令的助记符和操作数的格式及用法,以Intel格式汇编指令为例,常用指令分类如下:
1.数据传送类指令
指令 | 作用 | 例子 |
---|---|---|
mov dst,src | 数据传送 | mov ax,25 将立即数25传送到寄存器ax |
xchg oprd1,oprd2 | 数据交换 | oprd1/2同时发生变化,oprd可以是寄存器或存储器操作数,但不能是立即数或段寄存器,不能同时是存储器操作数。xhcg [ax],bx ax指向的数据与bx进行交换 |
xlat[b] | 字节查表转换 | 隐含包括两个操作数:ebx和al,将以ebx为字符数组首地址、al值为下标的元素的值传递给al,即al<–[ebx+al] |
push src | 进栈指令 | push ax 将寄存器ax中的数据压栈 |
pusha[d] | 进栈指令 | 依次将[e]ax、[e]cx、[e]dx、[e]bx、[e]sp、[e]bp、[e]si、[e]di等8个通用寄存器的值入栈 |
pop dst | 退栈指令 | pop ax 将栈顶数据传送给寄存器ax |
popa[d] | 退栈指令 | 依次将栈顶数据弹出并传送给8个通用寄存器[e]ax、[e]cx、[e]dx、[e]bx、[e]sp、[e]bp、[e]si、[e]di |
lea reg,mem | 取地址指令 | reg必须是寄存器,mem必须是存储器操作数,指令lea ebx,var 将变量var的偏移地址赋予ebx,相当于mov ebx,offset var ;指令lea bx,[si] 将si指向数据的地址 (也就是si自身) 传送给bx |
lds/les/lfs/lgs/lss reg,mem | 取段地址指令 | reg必须是寄存器,mem必须是存储器操作数,把内存单元的偏移地址传给指定的寄存器,并把段地址传给相应的段寄存器DS、ES、FS、GS、SS |
cld/std | 清0 / 置1方向位 | DF为1时,串操作按减地址方式取字符,为0时按增地址方式取字符,缺省值为0cld DF<–0std DF<–1 |
clc/stc/cmc | 清0 / 置1 / 取反进位位 | cld CF<–0std CF<–1cmc 取反CF位 |
cli/sti | 关中断 / 开中断 | IF值为1时,恢复可屏蔽外部中断响应,为0时不允许可屏蔽中断响应,缺省值为1cli IF<–0sti IF<–1 |
2.整数算数运算指令
指令 | 作用 | 例子 |
---|---|---|
add oprd1,oprd2 | 加法指令 | add bx,ax 令bx=bx+ax,影响flag标志位 |
adc oprd1,oprd2 | 带进位加法指令 | adc bx,ax 令bx=bx+ax+cf |
inc oprd | 自增指令 | inc bx 令bx=bx+1,不影响flag标志位 |
sub oprd1,oprd2 | 减法指令 | sub bx,ax 令bx=bx-ax,影响flag标志位 |
sbb oprd1,oprd2 | 减法指令 | sub bx,ax 令bx=bx-ax-cf |
dec oprd | 自减指令 | dec bx 令bx=bx-1,不影响flag标志位 |
mul / imul oprd | 无符号 / 有符号乘法指令 | 无符号的数据最高位作为数值位,有符号的数据最高位作为符号位。只有乘数在指令中显示,被乘数隐含固定用eax,结果存在edx|eax中。如果是8位或16位,则被乘数改为al或ax,结果存在ax或dx|ax中 |
div / idiv oprd | 无符号 / 有符号除法指令 | 无符号的数据最高位作为数值位,有符号的数据最高位作为符号位。只有除数在指令中显示,被除数隐含固定用edx|eax,商存在eax中,余数放在edx中。如果除数是8位或16位,则被除数改为ax或dx|ax,商存在al或ax中,余数存在ah或dx中 |
cmp dst,src | 比较指令 | 目的操作数减源操作数,按运算结果设置相关标志位,但不保存结果,为其后条件转移或循环指令提供转移用依据。影响标志位 af、cf、of、pf、sf、zf |
3.逻辑运算指令
指令 | 作用 | 例子 |
---|---|---|
and dst,src | 逻辑与 | 两个操作数按位相与,结果存在目的操作数中,受影响标志位包括 cf(0)、of(0)、pf、sf、zfand al,00001111b 如果al是数字字符,则该命令可实现将其转换为数值 |
or dst,src | 逻辑或 | 两个操作数按位相或,结果存在目的操作数中,受影响标志位包括 cf(0)、of(0)、pf、sf、zfor al,00110000b 如果al是数值,则该命令可实现将其转换为数字字符 |
not oprd | 逻辑非 | 将操作数按位取反,不影响任何标志位 |
xor dst,src | 逻辑异或 | 两个操作数按位异或,结果存在目的操作数中,受影响标志位包括 cf(0)、of(0)、pf、sf、zfxor al,11110000b 将al高4位取反 |
test oprd1,oprd2 | 逻辑比较测试 | 两个操作数按位相与,但不保存结果,受影响标志位包括 cf(0)、of(0)、pf、sf、zf,通常其后会紧跟jcc 跳转指令。test ecx,ecx 检测ecx是否为0 |
4.跳转指令
(1) 无条件跳转指令:jmp
(2) 条件跳转指令:根据标志寄存器中一个或多个标志位来决定是否转移,分为三大类:
- 无符号数条件转移
- 有符号数条件转移
- 特殊算数标志位条件转移
跳转指令有很多种,最重要的是记住E
(Equal)、N
(Not Equal)、A
(Above)、B
(Below)、G
(Greater)、L
(Less)这六个字母的含义,条件跳转指令即这些字母的组合,其中A
和B
用于无符号数条件转移,G
和L
用于有符号数条件转移,如下命令:
指令 | 作用 | 检测条件 |
---|---|---|
jmp label | 无条件转移 | 无条件 |
je/jz label | = 等于时转移 | ZF=1 |
jne/jnz label | ≠ 不等于时转移 | ZF=0 |
ja/jnbe label | > 大于时转移 (无符号数比较) | CF=0 && ZF=0 |
jae/jnb label | ≥ 大于等于时转移 (无符号数比较) | CF=0 |
jb/jnae label | < 小于时转移 (无符号数比较) | CF=1 |
jg/jnle label | > 大于时转移 (有符号数比较) | SF=OF && ZF=0 |
jge/jnl label | ≥ 大于等于时转移 (有符号数比较) | SF=OF |
jl/jnge label | < 小于时转移 (有符号数比较) | SF≠OF && ZF=0 |
5.串操作指令
包括移串 (MOVS
)、取串 (LODS
)、存串 (STOS
)、输入串、输出串、串比较、串扫描等7种数据处理指令,任一操作指令前缀重复指令REP
、REPE/REPZ
、REPNE/REPNZ
后可实现对ECX个连续的存储单元按字节、字或双字进行操作,如果没有前缀重复指令,则串操作只执行一次,存储单元地址由ESI或EDI指定,在处理后ESI或EDI地址自动增减1、2或4,具体如下:
- 当DF=0时 (执行
CLD
指令),ESI或EDI数据处理后地址递增1、2或4 - 当DF=1时 (执行
STD
指令),ESI或EDI数据处理后地址递减1、2或4
(1) 重复串操作 REP[E|Z|NE|NZ]
① 无条件重复串操作 REP
指令格式为 REP 串操作指令
,可用于无条件重复执行的串操作指令有移串、取串、存串、输入串、输出串五种,执行逻辑如下:
步骤1:若ECX≠0
步骤2:则ECX=ECX-1,并执行串操作指令,执行后回步骤1
步骤3:否则退出REP循环,顺序执行下一条指令
② 条件重复串操作 REP[N]E / REP[N]Z
判断条件在无条件重复串操作的基础上,多了对标志位ZF的判断,可用于条件重复执行的串操作指令有串扫描和串比较两种,执行逻辑如下:
i) 相等重复前缀指令 REPE / REPZ
步骤1:若ECX≠0且相等 (ZF=0)
步骤2:则ECX=ECX-1,并执行串操作指令,执行后回步骤1
步骤3:否则退出REP循环,顺序执行下一条指令
ii) 不等重复前缀指令REPNE / REPNZ
步骤1:若ECX≠0且不等 (ZF≠0)
步骤2:则ECX=ECX-1,并执行串操作指令,执行后回步骤1
步骤3:否则退出REP循环,顺序执行下一条指令
(2) 串操作指令
指令 | 作用 | 说明 |
---|---|---|
movs dst,src | 移串操作 | 只是告诉编译器数据传递类型 (字节、字、双字),不能决定传送位置,指令movs byte ptr es:[edi], byte ptr ds:[esi] 作用与movb 相同,注意edi在串操作时默认与es段联用 |
movs[b|w|d] | 移字节 / 字 / 双字串操作 | 把以esi值为起始地址的一个字节/字/双字数据传送到以edi值为起始地址的内存空间,并在传送完根据DF值对esi和edi作相应增减,是唯一一条实现直接从存储单元送到存储单元的指令。一次只能传送一个数据,与rep搭配实现传送多个数据。 |
lods src | 取串操作 | 只是告诉编译器数据传递类型 (字节、字、双字),不能决定传送位置 |
lods[b|w|d] | 取串操作 | 把以esi值为起始地址的一个字节/字/双字数据传送到al/ax/eax,并在传送完根据DF值对esi作相应增减。一次只能取一个数据,与LOOP等循环指令结合可取多个数据。与rep指令结合使用没有意义,因为只能保存最后一个数据。 |
stos dst | 存串操作 | 只是告诉编译器数据传递类型 (字节、字、双字),不能决定传送位置 |
stos[b|w|d] | 存串操作 | 把al/ax/eax中的值传送到以edi值为起始地址的存储单元,并在传送完根据DF值对edi作相应增减。一次只能传送一个数据,与rep搭配实现传送多个数据。 |
ins dst | 输入串操作 | 只是告诉编译器数据传递类型 (字节、字、双字),不能决定传送位置 |
ins[b|w|d] dst | 输入串操作 | 从dx指定的端口接收一个字节/字/双字,并存入以edi值为起始地址的内存单元中,并在接收数据后根据DF值对edi作相应增减。一次只能输入一个数据,与rep搭配实现输入多个数据。若当前任务没有执行I/O的权限,则发生异常。 |
outs src | 输出串操作 | 只是告诉编译器数据传递类型 (字节、字、双字),不能决定传送位置 |
outs[b|w|d] | 输出串操作 | 把以edi值为起始地址的内存单元中的一个字节/字/双字数据从dx指定的端口输出,并在输出数据后根据DF值对esi作相应增减。一次只能输出一个数据,与rep搭配实现输出多个数据。若当前任务没有执行I/O的权限,则发生异常。 |
scas dst | 串扫描操作 | 只是告诉编译器数据传递类型 (字节、字、双字),不能决定传送位置 |
scas[b|w|d] | 串扫描操作 | 在目的串中查找指定值的数据,用al/ax/eax的值和以edi值为起始地址的字节/字/双字进行相减,并在运算后根据df值对edi进行增减。受影响标志位包括af、cf、of、pf、sf、zf。该指令执行一次只比较一次,与repe 或repne 结合可以比较多个数据,循环直到结束或比较值相等,与rep结合没有意义,因为只保存最后一次比较结果到标志位。 |
cmps dst,src | 串比较操作 | 只是告诉编译器数据传递类型 (字节、字、双字),不能决定传送位置 |
cmp[b|w|d] | 串扫描操作 | 用以esi值为起始地址的字节/字/双字和以edi值为起始地址的字节/字/双字进行相减,并在运算后根据df值对esi和edi进行增减。受影响标志位包括af、cf、of、pf、sf、zf。该指令执行一次只比较一次,与repe 或repne 结合可以比较多个数据,循环直到结束或比较值相等,与rep结合没有意义,因为只保存最后一次比较结果到标志位。 |
以移串操作为例:
.386
.model flat,stdcall
include kernel32.inc
includelib kernel32.lib
includelib msvcrt.lib
printf PROTO C:DWORD,:vararg
.data
Src db 'Hello world!'
len equ $-Src ; $表示当前地址,当前地址减Src地址得到的是Src串的长度常量
Dst db len dup(0) ; 定义目的串空间
.code
start:
cld ; 复位DF,让串操作由低至高进行
lea esi,Src ; esi指向源串地址
lea edi,Dst ; edi指向目的串地址
mov ecx,len ; 串长度len赋予ecx,要重复操作len次
rep movsb ; 将源串Src开始的len个数据传送到Dst位置
invoke printf,addr Dst ; 输出目的串
invoke ExitProcess,0 ; 退出进程,返回值为0
end start
6.循环指令
循环指令指多种LOOP 标号
指令,指令循环次数保存在CX或ECX中,没执行一次循环CX或ECX自动减1,最后判断CX或ECX是否为0,以决定循环是否退出,使得LOOP
到标号
之间的指令循环执行CX或ECX次。执行LOOP[N][E|Z]
循环指令时,标志位ZF也能决定是否退出循环。
使用LOOP
循环无论CX或ECX初值为多少,循环体至少要执行1次,然后CX或ECX减1再与0作比较,如果CX或ECX初值为0,减1后就会变为循环执行65536 (216) 或4294967296次 (232),这样运算结果就会出错,为此用J[E]CXZ 标号
指令来判断[E]CX是否为0,如果为0则转移到标号处,此命令一般用于循环的开始处。
指令 | 作用 | 说明 |
---|---|---|
loop[w|d] label | 循环指令 | loop 指令自动选择计数器为,16位环境计数器为cx,32位环境为ecx。loopw 计数器为cx,loopd 计数器为ecx |
loop[e|z][w|d] label | 相等或为零循环 | 计数器cx/ecx选择同上,loope 或loopz 指令循环结束的条件除了cx减到0以外,还有zf=0 (两个数值不相等) |
loopn[e|z][w|d] label | 不等或不为零循环 | 计数器cx/ecx选择同上,loopne 或loopnz 指令循环结束的条件除了cx减到0以外,还有zf=1 (两个数值相等) |
j[e]cxz label | [e]cx为零时跳转到标号处 | 在循环开始前判断[e]cx是否为0,如果为0则直接跳到标号处执行,越过循环体 |
如下例计算 1+2+…+n 的和:
.386
.model flat,stdcall
include kernel32.inc
includelib kernel32.lib
includelib msvcrt.lib
scanf PROTO C:DWORD,:vararg
printf PROTO C:DWORD,:vararg
.data
infmt DB '%d',0
outfmt DB '1+2+...+%d=%d',0
n DB ?
.code
start:
invoke scanf,addr infmt,addr n
mov ecx,n
mov eax,0
jecxz done
again:
add eax,ecx
loopd again
done:
invoke printf,addr outfmt,n,eax
invoke ExitProcess,0
end start
7.CPU控制指令
指令 | 作用 | 说明 |
---|---|---|
nop | 空操作指令 | 什么都不做,但同样会产生机器码 |
注意:不能直接对两个存储单元数据进行操作 (包括运算和数据传送),所以经常用寄存器作为临时存储空间实现操作。
(三) 伪指令
1.程序基本结构
主要包括处理器选择、定义程序存储模型、引用头文件和库文件、函数原型声明、变量定义、数据段代码段定义以及标号等涉及程序基本结构的内容,具体如下:
(1) 处理器选择
选择执行指令的CPU,不同型号的CPU可执行的命令不同,32位汇编程序至少要选择80386,如下:
.8086 ; 只接收8086指令
.386 ; 接收除特权指令外的80386指令
.386P ; 接收全部80386指令
(2) 定义程序存储模型
格式为 .model 内存模型[,函数调用方法]
,如下:
.model flat,stdcall
flat
表示内存使用平展模型,使用32位地址,程序代码、数据和堆栈共用一个连续的4GB大小的线性地址空间,与之对应的是分段模型,分段内存模型中存储空间是分段使用的,Win16编程使用的是分段模型,Win32及之后编程使用的一般都是平展模型stdcall
表示函数调用时实参入栈顺序,堆栈恢复是由子程序完成还是由调用者完成,以及是否允许使用VARARG
VARARG
表示实参个数是可变的
不同语言区别如下表所示:
语言类型 | 入栈顺序 | 堆栈恢复者 | 是否可用VARARG |
---|---|---|---|
C | 从右到左 | 调用者 | 是 |
sysCall | 从右到左 | 子程序 | 是 |
stdcall | 从右到左 | 子程序 | 是 |
BASIC | 从左到右 | 子程序 | 否 |
FORTRAN | 从左到右 | 子程序 | 否 |
PASCAL | 从左到右 | 子程序 | 否 |
(3) 引用头文件和库文件
引用头文件和库文件以使用系统定义的函数,引用头文件用include
,引用库文件用includelib
,如下:
include kernel32.inc
includelib kernel32.lib
includelib msvcrt.lib ; C语言定义的库函数大多在msvcrt.lib库中
(4) 函数原型PROTO声明
要调用外部函数,如C函数,就必须对函数进行声明,声明的格式有两种:PROTO声明和EXTRN声明,用PROTO声明的函数,既可以用CALL伪指令调用,也可以用INVOKE伪指令调用,用EXTRN声明的函数,只能用CALL伪指令调用。
PROTO声明格式为 函数名 PROTO [距离] [语言类型] [[参数]:类型]...[,[参数]:类型]
,其中:
- 距离:函数被调用类型,可以是NEAR、FAR、NEAR16、NEAR32等,这些标识应用于分段的内存模型,NEAR指在本段调用函数,FAR指在其他段调用函数,对32位以上CPU而言意义不大,一般省略
- 语言类型:表示参数传递顺序和堆栈恢复方式,可以是stdcall、C、BASIC等,如果忽略则使用头部
.model
定义的值 - 参数:指参数名,一般省略
- 类型:参数类型,Win32中默认DWORD,可省略,最后一个参数类型可以是VARARG,指不确定参数个数。类型名前缀ptr表示指针,因为Win32下指针是32位,所以也可以用DWORD代替指针类型
; 以下两个函数原型声明作用相同
printf PROTO C :ptr sbyte,:vararg
printf PROTO C :DWORD,:vararg
(5) 函数原型EXTRN声明
在不指定参数个数与类型的情况下,可用EXTRN声明,格式为 EXTRN 函数名1:类型1[,函数名2:类型2[,...]]
,其中类型指函数被调用类型,包括NEAR、FAR、NEAR16、NEAR32等,这里不能省略。
EXTRN printf:near,scanf:near
(6) 变量定义
变量包括全局变量、局部变量和形式参数,全局变量在数据段中定义,在伪指令.data
之后,常量在伪指令.const
之后。全局变量定义格式如:变量名 数据类型 数据1,...,数据n
,表示在指定变量名和数据类型条件下,定义了n个数据。其中变量名代表的是变量的首数据,而不是地址,这一点要与标号区分开,数据类型用于决定每个数据要分配的内存字节数,常用数据类型如下:
- 整型数据类型:BYTE (DB)、WORD (DW)、DWORD (DD)、QWORD (DQ)、TBYTE (DT),字节数依次为1、2、4、8、10
- 实型数据类型:REAL4 (DWORD、DD)、REAL8 (QWORD、DQ)、REAL10 (TBYTE、DT),字节数分别为4、8、10
- 字符型数据类型:BYTE (DB),字节数为1
a DWORD 3 ; 相当于int a=3
a DWORD 3,4,5,6
a DWORD ? ; 数值不确定的话可以用?代替
b BYTE 'asdf',0 ; 多个字符型变量既可以用引号把字符串括起来,结尾用加上结束标志0
b DB 'a','s','d','f',0 ; 也可以逐个字符表示
局部变量和形参在函数中定义,局部变量定义需要用到伪指令LOCAL
,意为临时的局部变量,格式为 LOCAL 变量名1[[数量]][:数据类型][,变量名2[[数量]][:数据类型]]...
,如下所示:
LOCAL d[20]:BYTE,n:DWORD,i ; 定义一个20个字节长的变量d (数组),一个双字长的变量n,以及一个未定义类型的i
(7) 段定义
有初值的数据一般定义在 .data 段中 (会增大可执行文件),未初始化数据定义在 .data? 段 (不会增加可执行文件大小),常量定义在 .const 段中,堆栈放在 .stack 段中,而代码只能放在 .code 段,如下例所示:
.data ; 在和.model伪指令联用时,表示已初始化数据段的开始,段名为:_DATA
a BYTE 1
.data? ; 在和.model伪指令联用时,表示未初始化数据段的开始,段名为:_BSS
b DD ?,10 DUP(?) ; 10 DUP(?)表示10个未知数值
.const ; 在和.model伪指令联用时,表示常量数据段的开始,段名为:CONST
PI REAL8 3.14
.stack [[size]] ; 创建一个堆栈,在和.model伪指令联用时,表示栈顶位置,段名为:stack,可选择堆栈大小,默认为1KB
.code [[name]] ; 在和.model伪指令联用时,表示代码段的开始,段名为:_TEXT,可选择为其单独起名字,@code可表示代码段的名称
LABEL: ; 入口点标号
...
invoke ExitProcess,0
end LABEL ; 指明程序入口点
以上段定义的格式为简化版,完整的段定义格式如下:
name SEGMENT [READONLY] [align] [combine] [use] [‘class’]
…
name ENDS
align
为段起始边界的对齐方式,包括:BYTE、WORD、DWORD、PARA、PAGE,默认为PARA;combine
为段组合方式,包括:PUBLIC、STACK、COMMON、MEMORY、AT address、PRIVATE,缺省为PRIVATE;use
为使用模式,包括:USE16、USE32、FLAT,在代码段中可定义默认的操作数大小, 在数据段中可限制该段的数据长度;class
为段类型,包括:‘data’、‘data?’、‘code’、‘const’、‘stack’、‘extra’。
(8) 段分配ASSUME
作用一:用来关联段名与段寄存器,一般与段定义语句一起使用,格式为 ASSUME segreg:name[,segreg:name]
,这种语句主要是针对采用“段地址*16+段偏移”寻址方式的8086 DOS汇编,但是对于80386+ CPU,.model flat模式中,程序代码、数据和堆栈共用一个连续的4GB大小的线性地址空间,32位的寄存器直接可以4GB范围内寻址,MASM默认定义:ASSUME CS:flat, DS:flat, ES:flat, SS:flat, FS:ERROR, GS:ERROR
,因此寄存器可以直接使用,而涉及到FS和GS时,默认定义关联到ERROR,如果不用ASSUME重新声明则会报错。
...
code_seg segment 'code' ; 定义代码段,段名为code_seg
assume cs:code_seg ; 声明code_seg是代码段
assume fs:nothing, gs:nothing ; nothing表示不作任何声明,取消段寄存器和所有段名的关联
code_seg ends
...
作用二:用来指定数据寄存器的类型,格式为 ASSUME datareg:type[,datareg,type]
,如下将edi声明为结构体指针,这样就可以按照[edi].
的方式取数据:
assume edi:ptr NM_TREEVIEW
.if [edi].hdr.code==TVN_BEGINDRAG
...
.endif
(9) 标号
指令由4个部分组成:标号: 助记符 操作数 ;注释
,其中标号是指令所在的地址,用来标明程序转移的目标地址,格式为 标识符:
,如下所示:
mov eax,0
mov ecx,100
again:
add eax,ecx
loop again
标识符可以是@@
,用作@F
和@B
,@F
向前转移到最近的@@:
,@B
向后转移到最近的@@:
,如下所示:
; 实现功能:计算|ecx|+|ecx-1|+...+1
mov eax,0
cmp ecx,0
jg @F
neg ecx
@@:
add eax,ecx
loop @B
2.函数定义及调用
(1) invoke伪指令调用函数
函数声明后就可以调用函数,有两种调用方法:invoke和call,invoke调用函数的格式为 invoke 函数名[,参数1][,参数2][...]
,如下所示:
invoke printf,ADDR fmt,ADDR s ; 以fmt格式输出字符串s
invoke printf,OFFSET fmt,OFFSET s ; 传递的参数如果是地址,可以用ADDR,也可以用OFFSET
注意:用invoke调用的函数只能用PROTO声明,不能用EXTRN声明。
(2) call指令调用函数
call调用函数的格式稍复杂些,因为要借助栈传参,格式如下:
push 参数n
…
push 参数1
call 函数名
add esp,4*n
注意参数入栈顺序,如果是C、syscall、stdCall调用方式,则右边的参数即参数n先入栈,左边的参数即参数1后入栈;如果是C调用,调用完还要由程序调用者 (即调用函数的程序) 用 add esp,压栈距离
来恢复堆栈;而如果是stdCall或syscall调用,则要由函数在其内部恢复堆栈,可以使用命令 add esp,压栈距离
,也可以使用命令ret 压栈距离
命令恢复堆栈,后者相当于在ret
返回的同时又执行了add esp,压栈距离
命令。下面以C调用方式,按fmt格式输出字符串s为例,代码如下:
push OFFSET s
push OFFSET fmt
call printf
add esp,8 ; 压入2个地址,每个地址4字节,所以退栈距离是8
注意:
① 用call调用的函数既可以用PROTO声明,也可以用EXTRN声明;
② 所传递的参数如果是地址,入栈时只能用OFFSET,不能用ADDR;
③ invoke伪指令编译后也是转换为call指令,最好用call指令调用函数。
(3) 函数定义
汇编中的函数又称为子程序或过程,两者本质相同,定义函数的格式如下:
函数名 PROC [距离] [可视区域] [语言类型] [形参:数据类型][,形参:数据类型]…[VARARG]
LOCAL 变量名[[数量]][:数据类型][,变量名[[数量]][:数据类型]]…
… ; 程序体
RET ; 如果有返回值,则存在EAX寄存器或st(0)浮点寄存器中
函数名 ENDP
其中可视区域可以是PRIVATE、PUBLIC、EXPORT,PRIVATE表示只有本模块可见,PUBLIC (缺省值) 表示所有模块可见,EXPORT指导出的函数,如编写DLL时,若要将函数导出,则使用EXPORT。PROC和ENDP标识了函数定义的起始和结束位置,函数定义部分放置在代码段,如下所示:
...
.data
a DWORD 1
b DWORD 2
.code
FunTemp PROC x:DWORD,y:DWORD
mov eax,x
add eax,y
RET
FunTemp ENDP
...
start:
invoke FunTemp,a,b
end start
段内调用CALL NEAR
(NEAR
可省略) 采用近返回RETN
或RET
,段间调用CALL FAR
采用远返回RETF
:
CALL
相当于将调用指令的下一条指令地址压栈,然后跳转到函数位置执行,即:PUSH 下一条指令地址
、MOV EIP,函数位置
,相应地,RETN
或RET
相当于POP EIP
CALL FAR
相当于将段寄存器压栈,然后将调用指令的下一条指令地址压栈,最后再跳转到函数位置执行,即PUSH CS
、PUSH 下一条指令地址
、MOV EIP,函数位置
,相应地,RETF
相当于POP EIP
、POP CS
WIN32及之后汇编中不再通过段寄存器将内存进行分段,因此只有段内调用和段内返回。
若函数有参数或局部变量,程序会在函数开始和结束位置 (RET
指令之前) 自动加上保护主程序堆栈的指令,使堆栈在函数调用前后保持平衡,如下:
push ebp ; 保护主程序基址指针寄存器
mov ebp,esp ; 保护主程序堆栈指针寄存器
mov esp,ebp ; 恢复主程序堆栈指针寄存器
pop ebp ; 恢复主程序基址指针寄存器
3.选择结构伪指令
汇编指令实现分支选择结构,可以使用条件转移指令Jcc
(cc
指各种条件) 和无条件转移指令JMP
实现,也可以使用.IF .ELSEIF .ELSE .ENDIF
伪指令实现,后者比较简单,但并不是最终表现形式,汇编后都要转换成相应的条件转移指令和无条件转移指令。
若在条件表达式中要检测标志位信息,可以使用的符号名有:ZERO?
(ZF==1)、CARRY?
(CF==1)、OVERFLOW?
(OF==1)、PARITY?
(PF==1)、SIGN?
(SF==1)。(标志位?
表示标志位的值,当其为真时即标志位XF==1
)
分支选择结构格式如下所示:
.IF 条件表达式1
指令序列1 ; 满足条件1时执行指令序列1
[.ELSEIF 条件表达式2 ; 满足条件2时执行指令序列2
指令序列2
[…] ; 满足其他条件时执行其他指令序列
[.ELSE
指令序列3] ; 都不满足时执行指令序列3
]
.ENDIF
注意:
① 汇编语言可以使用逻辑运算符&&
、||
、!
,但不能使用运算符&
、|
、~
、^
,要实现运算符功能需要用AND
、OR
、NOT
、XOR
指令;
② 运算符&&
前后要用空格隔开,'A'<=var<='Z'
只能写成var>='A' && var<='Z'
,而不能写成'A'<=var && var<='Z'
。
如下例所示:
.if var>='A' && var<='Z' ; 判断var如果是大写字母则转换成小写
add c,20h
.endif
.if carry? && eax!=ebx ; 检测cf==1且eax≠ebx是否成立
...
.endif
4.循环结构伪指令
汇编指令实现循环结构,可以使用LOOP
和JECXZ
指令实现,也可以使用.while .repeat .break .continue
等伪指令实现,后者比较简单,但并不是最终表现形式,汇编后都要转换成LOOP
和JECXZ
指令,或者条件转移指令实现。
(1) 当循环伪指令.while
先判断后执行,格式为:
.while 条件表达式
指令序列
.endw
(2) 重复伪指令.repeat
先执行后判断,有两种格式,如下所示:
; 格式一
.repeat
指令序列
.until 条件表达式 ; 直到条件表达式的值为真时,退出循环
; 格式二
.repeat
指令序列
.untilcxz [条件表达式] ; 直到ecx(不是cx)值为0,或条件表达式的值为真时,退出循环
两种格式虽然相似,但是本质不同,其中格式一汇编后会转换成相应的条件转移指令,而格式二汇编后会转换成LOOP
指令,用ECX作计数器。
如下两段代码作用相同:
.repeat next:
add eax,ecx -----> add eax,ecx
dec ecx dec ecx
.until ecx==0 jnz next
.repeat next:
add eax,ecx -----> add eax,ecx
.untilcxz loop next
(3) 退出伪指令.break
作用与C语言的break
相同,执行时强制退出循环。如果后接一个测试伪指令.break .if 条件表达式
,则测试条件为真时才执行.break
。
(4) 短路伪指令.continue
作用与C语言的continue
相同,执行时结束当前循环,直接进入下次循环的判断。如果后接一个测试伪指令.continue .if 条件表达式
,则测试条件为真时才执行.continue
。
(四) 寻址方式
分为数据寻址和转移地址寻址两大类,数据寻址用于定位数据,转移地址寻址用于定位转移及调用指令指向的地址。
1.数据寻址
(1) 立即数寻址
操作数直接包含在指令中,例如:
mov ax,41h
mov ah,65
(2) 寄存器寻址
操作数保存在寄存器中,通过寄存器进行寻址,例如:
mov ax,bx
(3) 存储器寻址
通过操作数在存储器中的地址进行寻址,根据地址表示的方式具体分为以下几种:
① 直接寻址
操作数地址为立即数或变量,例如:
mov ax,[1234h]
mov bl,var ; 从var的值对应的地址中取一个字节赋予bl,等效于 mov bl,[var]
mov bl,var+5 ; 从var+5对应的地址中取一个字节赋予bl,等效于 mov bl,[var+5]
mov var,bl ; 将bl的值写入var对应的地址,等效于 mov [var],bl
② 寄存器间接寻址
操作数地址保存在基址寄存器 (BX、BP) 或变址寄存器 (SI、DI) 中,例如:
mov ax,[bx]
③ 寄存器相对寻址
也称为直接变址寻址,操作数地址为基址寄存器 (BX、BP) 或变址寄存器 (SI、DI) 加上一个偏移量,偏移量可以是立即数或变量,例如:
mov ax,8[bx]
mov ax,[bx+8]
mov ax,var[bx]
mov ax,[bx+var]
④ 基址变址寻址
操作数地址为基址寄存器 (BX、BP) 加上变址寄存器 (SI、DI),例如:
mov ax,[si][bx]
mov ax,[bx+si]
⑤ 相对基址变址寻址
通过操作数在存储器中的地址进行寻址,操作数地址为基址寄存器 (BX、BP) 加上变址寄存器 (SI、DI) 的值,例如:
mov al,8[bx][si]
mov al,8[bx+si]
mov al,[8+bx+si]
mov al,var[bx][si]
mov al,var[bx+si]
mov al,[var+bx+si]
⑥ 比例变址寻址
80386及以上寄存器才支持,通过操作数在存储器中的地址进行寻址,操作数地址为 基址寄存器+变址寄存器×比例因子+偏移量
,例如:
mov ax,8[bx][4*si]
mov ax,8[bx+4*si]
mov ax,[bx+4*si+8]
mov ax,var[bx][4*si]
mov ax,var[bx+4*si]
mov ax,[bx+4*si+var]
注意:80386及以上CPU存储器寻址方式中,基址寄存器可以替换为任意通用寄存器,变址寄存器代表除ESP以外的任何一个32位寄存器。
2.转移地址寻址
当转移地址为8位或16位则为段内转移,段寄存器CS不变,指令指针寄存器IP变化;转移地址为32位则为段间转移,转移地址为32位双字,第一个字作为段地址取代CS,第二个地址作为偏移地址IP。进一步分为直接寻址和间接寻址,如下:
(1 ) 直接寻址
转移的目的地址以标号形式给出,例如:
jmp label
jmp short label # 短转移,限定8位地址标签
jmp near ptr label # 近转移,限定16位地址标签,near ptr是缺省值
jmp far ptr label # 长转移,限定32位地址标签
(2) 间接寻址
转移的目的地址以变量、寄存器或两者结合的方式给出,例如:
jmp bx
jmp var
jmp var[bx]
jmp [bx+var]
三、程序调用
(一) 汇编程序调用C函数
用INCLUDE[LIB]
包含必要的库,用PROTO
或EXTRN
定义外部函数,用INVOKE
或CALL
引用,上述伪指令部分已讲,不再赘述。
(二) C程序调用汇编子程序
基本步骤如下:
- 在C程序中用
extern
声明调用的函数 - 在汇编程序中定义该函数,定义时调用方式要与C程序中声明的调用方式一致
- 编译汇编程序生成obj目标文件,将目标文件添加到IDE的工程项目中
- 编译运行
汇编程序和C程序声明方式如下例所示:
汇编程序
.386
.model flat,C
.code
myfunc proc x:dword,y:dword
...
ret
myfunc endp
end
==============================
c程序
#include <stdio.h>
extern int add(int a, int b);
void main(){
...
}