x86汇编基础
这是电子科技大学的汇编程序设计的课程复习,由本人总结,主要资源来自 PPT 和自编教材,少部分辅以网上的博客(会给出参考链接)。课程是基于 x86 汇编,汇编语法和汇编器采用 MASM,程序主要是 flat 模式,语法可能和其他的汇编器不同。这篇文章的主要目的是总结和复习,所以可能较为简洁,读者可以在评论区提问,我会不断完善。如果由不准确或者错误的地方,欢迎指正。
微处理器和寄存器简介
由于汇编需要程序员考虑处理器和寄存器的状态,因此了解基本的内容,有利于理解汇编是如何在 CPU 上工作的。汇编语言与机器语言(字节码)是绑定的,大部分指令直接对应 CPU 的指令,因此汇编不具有可移植性,无法在不同的指令架构下运行。
CPU 执行指令
我们学习 CPU,主要是关注 CPU 内的寄存器、访存方式、输入输出。在计算机组成原理中,我们已经学习过 CPU 的概念图,每次运行:
- 取指令(Instruction Fetch):CPU 发送读取内存的地址,获取指令代码,然后将指令保存在指令寄存器 IR 中。
- 指令译码(Instruction Decode):CPU 将 IR 中的指令中的操作码、操作数、地址等信息解码,然后确定操作数的存储位置、指令要完成的操作以及需要的寄存器等。
- 执行指令(Execute):CPU 会对操作数进行运算或将操作数存储到特定的位置。对于一些需要访问内存或 IO 设备的指令,CPU 会将内存或 IO 设备中的数据读取到 CPU 的寄存器中。(有时候,会把读取操作数单独作为一个步骤,放在执行指令前)。
- 写回结果(Write Back):如果是运算指令,CPU 将结果存储到指定的寄存器中;如果是访存指令,CPU 将结果写回到指定的内存地址中。
- GPRs:通用寄存器(General Purpose Registers),在计算机系统中用于存储操作数、地址和控制信息等临时数据的寄存器,是 CPU 内部存储器中的一部分,具有快速读写的特点。
- MAR:存储器地址寄存器(Memory Address Register),用于存储 CPU 将要访问的存储器地址。
- MDR:存储器数据寄存器(Memory Data Register),用于存储 CPU 从存储器中读取的数据或将要写入存储器的数据。
- IR:指令寄存器(Instruction Register),用于存储 CPU 当前执行的指令。
- ALU:算术逻辑单元(Arithmetic Logic Unit),CPU 中用于执行算术和逻辑运算的部件,它可以对多个操作数进行运算并产生结果。
8086/8088
特点和工作模式
8086/8088 是 Intel 推出的第一款 16 位微处理器,具有以下几个特点:
- 采用并行流水线工作方式:8086/8088 将 CPU 划分成多个功能部分,如指令预取队列、指令译码器、算术逻辑部件(ALU)等,并设置指令预取队列,实现流水线工作。这种工作方式可以提高 CPU 的运算速度,同时也为后续的 CPU 设计提供了基础。
- 对内存空间实行分段管理:8086/8088 采用了分段管理技术,将内存分为多个段并设置4 个段地址寄存器(CS、DS、ES、SS),每个段可以达到 64KB,多段寻址可以实现对 1MB 空间的寻址。通过对不同段进行划分,可以灵活地管理内存空间,同时也为后续的 CPU 设计提供了借鉴。
- 支持多处理器系统:8086/8088 可以通过总线接口支持多处理器系统,可以与其他 8086/8088 或协处理器(Coprocessor)进行通信,实现共享计算资源,提高系统的运算效率。此外,协处理器也可以扩展 CPU 的指令集,增强 CPU 的运算能力。
8088 有两种工作模式:最大模式和最小模式。最大模式是指 CPU通过外部总线与外围设备进行通信,包括访问内存、输入输出、中断响应等。在最大模式下,CPU 需要使用多个芯片来实现外部通信功能,包括地址数据总线转换芯片、系统计时控制芯片、输入输出控制芯片等。
最小模式是指CPU 不通过外部总线与其他设备进行通信,而是通过芯片组内部的接口实现通信。在最小模式下,CPU只需要使用一个称为多路复用器的芯片来实现地址和数据的复用输出,减少了芯片数量和复杂性。
主要引脚
地址引脚:
- AD0-AD7:低 8 位地址和低 8 位数据信号的线。当 CPU 需要从存储器中读取数据时,这些线就是输入数据信号的线;当 CPU 需要将数据写入存储器时,这些线就是输出数据信号的线;当 CPU 需要向存储器发送地址时,这些线就是输出地址信号的线。
- A8-A15:高 8 位地址信号的线。在 8086 中,这些线也用于传输低 8 位数据信号。因为8086 的总线是 16 位的,8088 是 8 位的。
- A16-A19/S3-S6:高 4 位地址信号的线,也与状态信号分时复用。这些线用于传输 CPU 的高 4 位地址信号,用于扩展地址空间。在 8088 中,这些线同时用作状态信号,用于传输一些特殊的控制信息。在 8086 中,这些线不用于状态信号,而是专门用于传输高 4 位地址信号。
控制引脚:
-
RD(Read):读信号,用于从内存或 I/O 设备读取数据。
-
WR(Write):写信号,用于向内存或 I/O 设备写入数据。
-
IO/M(Input/Output Memory):指示当前访问的是(0)内存还是(1)I/O 端口。8086 的信号与 8088(上一句)的相反。
-
DEN(Data Enable):数据使能信号,用于表示当前数据线的数据有效。
-
DT/R(Data Transmit/Receive):数据传输/接收模式选择信号。
-
ALE(Address Latch Enable):地址锁存使能信号。当其为高电平时,表示地址线上的地址被锁存到一个锁存器中,确保 CPU 在进行读写操作时,始终使用的是同一个地址。
-
READY:用于与外部设备同步。READY 信号的作用是为了控制 CPU 的读写速度,因为 CPU 和外部设备的速度不一定相同。当 CPU 向外部设备请求数据时,外部设备可能还没有准备好数据,如果 CPU 继续读取数据,可能会读到错误的数据。通过 READY 信号,外部设备可以告诉 CPU 何时可以读取数据,确保数据的正确性。
-
RESET 是复位信号,当其为高电平时,CPU 会被强制复位,内部寄存器会被清零或者被设为特定的值,以确保 CPU 从初始状态开始执行。复位后的值如下表:
内部寄存器 | 内容 | 内部寄存器 | 内容 |
---|---|---|---|
CS | FFFFH | IP | 0000H |
DS | 0000H | FLAGS | 0000H |
SS | 0000H | 其余寄存器 | 0000H |
ES | 0000H | 指令队列 | 空 |
内部结构和寄存器
大致分为执行单元 EU 和总线接口单元 BIU,重点关注的是通用寄存器和标志寄存器,这些寄存器在汇编中会经常用到。
通用寄存器除了可以作为通用的寄存器,用法习惯上也有一些差别:
- AX:IO 的数据暂存在这里,中间运算结果也存这里。
- BX:内存寻址时地址存这里。
- CX:循环和串操作存这里,因为串操作常常需要循环。
- DX:存放I/O 端口地址还有32 位除法的高 16 位。
- SP:表示堆栈指针,日后会详细学习它的计算方法。
- BP:存放栈基址,比如要访问函数的参数和局部变量。
- DI 和 SI:一般用于串操作,DI 寄存器通常作为目的地址寄存器,SI 寄存器作为源地址寄存器,用于在内存中移动和复制字符串。
段寄存器在保护模式基本用不到,因为整个内存空间是连续的,了解含义即可。
- IP:下一条要执行指令的地址。
下面的段寄存器都是存放对应段的基址,在实模式下都是段基址,一个段 64KB。但是保护模式下一个段最大 4GB,段寄存器存储选择子(16 位)的地址,选择子包括段基地址和段属性信息,由操作系统负责将选择子转换成段基地址。
- CS 寄存器(代码段寄存器)
- DS 寄存器(数据段寄存器)
- ES 寄存器(附加段寄存器)
- SS 寄存器(堆栈段寄存器)
实际汇编的时候,是用到 32 位的 flat 模式,也就是寄存器参考下面的 IA-32,主要是理解 EAX、AX、AH、AL 之间的关系,在小端序下的值如何存储。
IA-32 是 32 位处理器,4GB 物理地址空间,64TB 的虚拟地址寻址空间。支持分段、分页的内存管理方式,有实地址模式、保护模式、虚拟 8086 模式三种工作方式。
标志寄存器
标志寄存器在汇编中非常常用,不能手动赋值。标志位的设置和清除是由 CPU 执行指令时自动完成的,程序员可以通过各种指令来检查这些标志位的状态,并根据需要进行相应的操作。许多指令有不同的修改标志寄存器的规定,并且标志寄存器用于提供控制信息。
- CF(进位标志):最高有效位的进位或借位,CF 为 1,否则为 0。
- PF(奇偶标志):记录结果中 1 的个数的奇偶性。如果结果中1 的个数为偶数,PF 为 1,否则为 0。
- AF(辅助进位标志):记录低四位的进位或借位。如果最后一次操作需要进位或借位,AF 为 1,否则为 0。
- ZF(零标志):记录结果是否为0。如果结果为 0,ZF 为 1,否则为 0。
- SF(符号标志):记录结果的符号。如果结果为负数,SF 为 1,否则为 0。
- OF(溢出标志):记录结果是否溢出。如果结果溢出,OF 为 1,否则为 0。
注意是直接运算来判断标志位,而不是转化成补码后判断标志位。比如溢出标志是根据结果和操作数确定的。以上的标志位需要熟练掌握。
(例题)!!!!!!!!!!!!!!!!!!!!!!!!!!!
还有 3 个不常用的控制标志位,了解即可:
- TF(跟踪标志):用于单步调试。如果 TF 为 1,则 CPU 在执行一条指令后暂停执行并进入单步调试状态,否则为 0。
- IF(中断标志):用于控制可屏蔽中断的开关。如果 IF 为 1,则 CPU 允许可屏蔽中断,否则为 0。
- DF(方向标志):用于指示字符串操作指令的方向。如果 DF 为 1,则字符串操作指令向前移动(由高地址到低地址),否则为 0(向后移动,由低地址到高地址)。
堆栈
物理地址受限于引脚数量,可以知道最大 20 位,1MB。一个字两个字节,地址小的字节的地址作为字的地址。小端序,数字低位低地址,字符串顺序存放。
逻辑地址分成两部分,16 位段地址,16 位段内地址。通过段地址找到段在内存中的起始位置,通过段内地址找到在段内的偏移量,这样就可以定位一个字节。简单的说,段地址+段内地址就是字节所在的地址。但是段地址不是随意的,必须是 16 的倍数,因为规定 16 位为一个小节(Paragraph)。段最大长度 64KB。所以,段地址默认低四位为 0,段寄存器的值需要左移 4 位才是它实际表示的值。所以,根据段寄存器和段内偏移量计算地址时,需要注意。
堆栈的最下端是固定的,叫做栈底。另外一端,叫做栈顶,是最后压栈的元素,SP 就是指向这个元素。SS 指向的是栈所在的存储位置。需要注意:
- 栈底是堆栈最下面的字的地址。
- 堆栈的每个元素都是字为单位,一层 2 个字节。
- 堆栈增长的方向是地址减小的方向,也就是栈底是高地址,栈顶是低地址。
- SP 的值为与栈开始位置(SS 寄存器的值)的距离,字节为单位。
- SP 初始化时在栈底下面两个字节,也就是栈底的段内相对地址+2,此时为最大值,表示栈为空。
- SP 为 0,表示堆栈满了。
上面的栈的示意图,是每一层一个字节。
保护模式的段寻址
主要是学习 IA-32 以后的段寻址的方式,虽然我们主要是使用 flat 32 模式,它是一种特殊的保护模式,不用考虑分段,都是虚拟地址。
在学习段寄存器时提到,保护模式下段寄存器的内容不是段的起始地址,而是段选择子(也叫做段选择器)的地址,段选择器这个数据结构包括了段基地址和段长等属性。还需要了解一些概念:
- 段描述符:每个段对应的元信息,包括段段长、段基地址等信息。
- 描述符表:包含了所有内存段的描述符。它可以被认为是一个数组,每个元素就是段描述符。
- 段选择器:用于访问描述符表,定位到自己需要的段的描述符。这样获取了段的信息之后,就可以访问段所在的内存了。
段选择器的长度就是段寄存器的长度,只有 16 位。
- 0-1:RPL,表示优先级,寻址时基本不用注意。
- 2:TI,关键位,0 表示描述符在 GDT 中,1 表示在 LDT 中。
- 3-15:在描述符表中的索引记住时高 13 位。
1 | 15 3 2 0 |
段描述符为 64 位,下图需要拼起来,第一条是高 32 位,第二条是低 32 位。
比如对于 0x98A46A40 0xAF0FC083,按照上图,段基址应该是 0x9840 AF0F。(顺带吐槽,chatGPT 推理能力真差)。简单的说,高 32 位取头尾字节,低 32 位取头 2 个字节。
属性中的 G
比较特殊表示粒度,G=1 时单位就变成了 4K,段限长取最大时,一个段就是 。否则单位就是 1 个字节。
段描述符表有两个常用的,GDT 是全局描述符表,系统中只有一个。LDT 是局部描述符表,每个进程一个,里面都属局部描述符,是进程使用的段的元信息。可以通过 GDTR 找到 GDT 的位置,但是寻找 LDT 的位置时,需要根据 LGTR 在 GDT 中寻找。因为 GDTR 是 48 位,LGTR 是 16 位。在多任务的 Windows 系统中 LDTR 只有一个,它用来指示当前任务,当前任务切换时需要改变 LDTR 的内容。
寻址时,首先从段寄存器获取 16 位的段选择子,看第 3 位的 TI:
- 为 0,表示段描述符在 GDT 中。从 GDTR 中高 32 位是 GDT 的地址,低 16 位是 GDT 的限长。然后段选择器的高 13 为是索引地址,找到段描述符。段描述符的基址寻址,如上面所示。最后再加上段内偏移量即可。
- 为 1,表示段描述符在 LDT 中。同样先在 GDT 中找到段描述符,LDTR 的高 13 为 作为索引。但是需要注意,这不再是寻找的字节所在的段的段描述符,而是 LDT 的段描述符,需要根据它寻找 LDT 的起始地址。找到 LDT 起始地址之后,用段选择器的 高 13 位作为索引,就找到了真正的段描述符。
(例题)!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
指令系统
CPU 的指令一般由三部分组成:操作码,表示执行何种操作;目标操作数,存储结果;源操作数,存储参与运算的数据。这三部分不一定是严格区分的,比如 ADD AX BX,AX BX 都是源操作数;再比如 TEST AX BX
表示两个数相与,但是目标操作数并不是 AX 或者 BX,而是只修改标志位。
操作数有 3 个来源,第一种是硬编码到指令中的立即数;第二种是寄存器,可以是 8 位(AL、AH)、16 位(AX)、32 位(EAX);第三种存储器,也就是内存,比比如定义数据时的符号地址,或者是寄存器种的地址的值 [AX]
。
寻址方式
-
立即数寻址,和国内不同。简单地说就是操作数硬编码到指令。立即数无法截断,不能超过目的操作数的长度。比如
MOV AL 260
就会报错,MOV AL 1
其中的 1 就会自动拓展到 8 位。 -
寄存器寻址,操作数都在寄存器中。
-
直接寻址。通过内存地址来访问数据。内存操作数的长度必须和另一个操作数相同。
-
寄存器间接寻址。寄存器中是操作数在内存中的地址,然后
[EAX]
就是表示内存中的值。需要注意:- 不能使用 16 位的寄存器存储地址。
- 没有说明长度,默认使用另外另外一个操作数的长度。比如
MOV EAX,1234H, MOV BX, [EAX]
是读取从 1234H 开始的 2 个字节。 - 寄存器都是默认在数据段,只有 EBP 和 ESP 默认在堆栈段。
- 保护模式下不能直接访问具体的存储单元。比如
MOV EAX,1234H, MOV BX, [EAX]
会报错。使用MOV BX [1234H]
和MOVE BX 1234H
是一个效果。
-
寄存器相对寻址。寄存器中的地址,加上一个偏移量来确定地址。它的写法很多,但是偏移量不能放在括号后面,一下几种都是等效的。
MOV AX, [EBX+offset]
MOV AX, offset[EBX]
MOV AX, offset+[EBX]
或者MOV AX, [EBX]+offset
- 但是不能
MOV AX, [EBX]offset
-
基址-变址寻址,简单说就是寄存器里的地址可以乘 2、4、8.
-
隐含寻址。一些指令是默认了操作数的来源,比如
MUL BL
省略的另外一个操作数是 AL,结果默认是写入AX
。
常用指令
数据传送指令
这一类指令不修改标志位!
MOV
MOV dest src
,表示将 src 的值,复制给 dest,注意是底层的字节序列的赋值。
MOV 的两个操作数必须长度相同,但是存在以下的几种自动拓展的情况:
- src 是立即数的话,必须小于等于 dest 长度,并且可以拓展。
- 内存之间不能直接传送。
- CS 寄存器不能被修改,可以作为 src。
- 段寄存器之间不能直接赋值,但是可以通过通用寄存器承接的方式赋值。
- 标志寄存器不参与传送。
- 一个存储单元,必须要有起始地址及类型(长度)。如果在指令中没给出长度,根据另外一个操作数确定。如果给出长度,两个操作数的长度不能矛盾。
例题:
- MOV AL,BX。长度不一致
- MOV [BX][SI],AX。内存寻址必须 32 位。
- MOV DS,1000H。立即数无法给段寄存器赋值。
- MOV [1200],SI。不能直接访问具体的存储单元。
- MOV AX,CS。正确
- MOV DS,CS。段寄存器之间不能直接赋值。
MOVZX/MOVSX
零拓展和符号拓展,可以拓展源操作数,达到目标操作数的长度,然后传送。
堆栈指令
对于 16 位的实模式,堆栈指令的操作数不能是立即数,可以是 16 位的寄存器或者内存中的一个字,而且必须显式指定长度为一个字。最常见的指令为 PUSH OPRD
POP OPRD
,操作数必须满足上面的要求。 PUSHF OPRD
POPF OPRD
会把标志寄存器的值压入堆栈,然后据一定的规则修改在标志寄存器中的值。
由于堆栈是 16 位一层,而且 SP 表示和 SS 的距离,所以每次 PUSH
,SP = SP-2。再次强调,小端序,栈底高地址。反之 POP
造成 SP=SP+2。
对于 32 位保护模式,堆栈元素大小是 32 位,允许立即数入栈。新加入了 PUSHA
将 8 个 16 位通用寄存器按 AX、CX、DX、BX、SP、BP、SI 与 DI 的顺序入栈;PUSHAD
则是将这 8 个 32 位寄存器的值顺序入栈。POPA
则相反,将栈顶的 8 个字依次送入 DI、 SI 、BP、 SP 、 BX 、 DX 、CX 与 AX。POPAD
也是类似的。
同样也有修改标志位的指令,保护模式下的寄存器是 32 为,而实模式下的标志寄存器是 16 为,所以 PUSHFD
POPFD
也是类似的,只是标志寄存器变成了 32 位。
交换指令
交换指令是一种用于交换两个操作数的值的指令,常见的有 XCHG 指令 XCHG destination, source
。这两个操作数必须至少有一个是寄存器,而且不能是段寄存器。
IO 指令
输入输出指令是 CPU 和外设之间进行数据交换的指令,用于完成计算机系统的输入输出操作。输入指令将数据从外设传输到 CPU,输出指令将数据从 CPU 传输到外设。
输入输出指令一般使用 IN 和 OUT 指令来实现。IN 指令用于将数据从端口读入到寄存器中,OUT 指令用于将寄存器中的数据输出到指定的端口中。这些指令需要指定端口号和数据长度。IN acc,PORT
OUT PORT,acc
。
回忆寄存器的作用,AX 放 IO 数据,DX 放 IO 地址。根据数据的大小,可以选择 AL、AX、EAX(保护模式)。PORT
可以使用直接寻址和间接寻址,直接寻址就是根据 8 位 PORT
的值寻址,注意是无符号数,范围是 0-255。间接寻址则是超过 255 时,端口地址只能由 DX 指定。也就是说,DX 都可以用来寻址。
1 | IN AX,80H |
取地址指令
LEA 的全称是 Load Effective Address(加载有效地址),用于内存单元所在的地址写入寄存器。LEA REG,MEM
,源操作数必须是来自内存,目标操作数在保护模式下可以是 32 位寄存器(flat 模式地址都是 32 位)。但是也可以是 16 位寄存器,只保留地址的低 16 位,比如 LEA SI, [EDI]
SI 中只存储了 EDI 的低 16 位
LEA 还可以执行加法,比如 LEA AX, [BX][DI]
是将 BX+DI
送入 AX 中。这是因为 OFFSET
伪指令是不能读取寄存器,所以无法使用寄存器执行加法。
例题:将数据段中首地址为 MEM1 的 50 个字节的数据传送到同一逻辑段首地址为 MEM2 的区域存放。编写相应的程序段 。
分析,50 次循环,每次把一个字节传送,同时更新源操作数地址和目的操作数的地址。串操作一般用 SI 和 DI,计数器用 CX,暂存数据用 AX,注意保护模式用 32 位。
1 | LEA ESI, MEM1 |
标志位操作指令
标志位操作指令是一类用于修改和操作标志寄存器的指令。这些指令可以用于设置、清除或测试标志位的值。LAHF
全称是 Load AH from Flags,用于将当前的标志寄存器的低 8 位拷贝到**AH
寄存器**中。SAHF
, “Store AH into Flags” 则是相反的作用。
算术运算
算术运算指令是用来进行数值运算的汇编指令,大多会影响标志位,特殊的会作特别的说明。
加法指令
加法指令得要求和 MOV 指令基本相同,核心是目标操作数要可以写入,两个操作数不能都是来自内存。
常见的 ADD OPRD1 OPRD2
会影响所有标志位。ADC
指令则会加上 CF 的值,这样实现大整数的相加,因此初始化时应该 CF 置零。例如需要完成 20 个字节的操作数的加法,我们就需要「进位加法」:
1 | LEA ESI, M1 |
INC reg
和 DEC reg
指令在循环中按字节操作时,经常出现,分别是加一和减一。他会影响除了 CF 标志位的所有其他标志位。
减法指令
SUB
指令和 ADD
指令是类似的;SBB
和 ADC
是对应的,全称是 Subtract with Borrow,也就是需要多减去进位(借位)标志,也就是 CF 标志。
NEG OPRD
指令则是对操作数取负数,相当于用 0 减去它。实际操作是按位取反+1。同样的,操作数必须是来自内存或者寄存器。它会影响常用的 6 个标志寄存器,课程只关心 CF 和 OF:
- 只有当操作数为 0 时,CF 才为 0,其他为 1。
- 当操作数为 或者 或者 时,结果时操作数本身,OF 为 1。其他情况 OF 为 0.
CMP OPRD1,OPRD2
指令不更改操作数,只按照 OPRD1-OPRD2
影响所有标志位,它的作用是根据标志位判断大小关系。但是它的操作数和 ADD 指令一样,OPRD1 必须是可以写入的,而且两个操作数不能全来自内存。具体来说
对于无符号数 CMP AX BX
,用 CF、ZF 判断:
-
若 AX > BX,则 CF=0, ZF=0
-
若 AX < BX,则 CF=1, ZF=0
-
若 AX = BX,则 ZF=1
对于有符号数,用 SF、OF、ZF 判断:
- 若 AX > BX,则 OF=SF,ZF=0
- 若 AX < BX,则 OF≠SF,ZF=0
- 若 AX=BX,则 ZF=1
一般的用法,比如在后面接跳转指令。JG(Jump if Greater) 指令表示有符号数“大于”跳转,当 OF=SF,ZF=0 时,跳转。JA(Jump if Above) 指令表示“大于(无符号数)”跳转,当 CF=0, ZF=0 时,跳转。
例题:在 20 个从 BUF 开始的无符号数中,找出最大的数,并将其存放在 MAX 单元中。
1 | LEA EBX MAX |
乘法指令
MUL OPRD
是无符号乘法,其中操作数只能来自寄存器或者内存。表达的意思是,将 OPRD
乘以
- AL,存入 AX
- AX,存入 DX:AX
- EAX,存入 EDX:EAX
也就是说,运算结果是操作数的两倍,隐藏的操作数由 OPRD
决定。标志位的影响也比较特殊,只关注 CF 和 OF 标志,如果隐藏的操作数存不下,需要 AH 或 DX 或者 EDX 存储结果时,CF=1,OF=1。否则 CF=0,ZF=0。
IMUL OPRD
的用法完全一样,只是把操作数当作有符号整数。但是由于带符号数都会进行符号扩展,所以判断有些复杂。
增加判断的例题!!!!!!
32 位下,IMUL
指令增加了操作数的形式。
IMUL DEST, SRC
表示DEST<=(DEST)×(SRC)
IMUL DEST, SRC1, SRC2
表示DEST<=(SRC1) ×(SRC2)
。
除法指令
除法指令中只给出除数,而被除数和商、余数都为隐含。存储的位置和乘法是对应的。因为被除数长度必须是除数长度的两倍,因此常常需要和扩展指令 CBW
CWD
CDQ
配合使用。
被除数 | 除数 | 商 | 余数 |
---|---|---|---|
AX | reg8/mem8 | AL | AH |
DX:AX | reg16/mem16 | AX | DX |
EDX:EAX | reg32/mem32 | EAX | EDX |
拓展指令是零操作数指令,隐含的地址为 AX, DX,EAX,EDX。
CBW(Convert Byte to Word) 表示字节拓展到字,也就是说
- 若 AL 最高位=1,则执行后 AH=FFH
- 若 AL 最高位=0,则执行后 AH=00H
CWD(Convert Word to Doubleword) 也是完全类似的:
- 若 AX 最高位=1,则执行后 DX=FFFFH
- 若 AX 最高位=0,则执行后 DX=0000H
CDQ(Convert Doubleword to Quadword) 表示双字拓展成四字,只是从 DX 变成了 EDX。
BCD 码调整指令
BCD 码调整指令是一组用于将二进制码转换为二进制编码十进制(BCD)码的指令。BCD 码是一种用于表示十进制数的二进制编码形式,使用四位二进制数表示一位十进制数。
BCD 码分为压缩型和非压缩型,压缩型一个字节表示 2 个 BCD 码,比如 0010 0011 表示十进制的 23。
非压缩型只看一个字节的低四位表示的 BCD 码,忽略高 4 位,常为 0000 或 0011。比如 0000 1001 与 0011 1001 都是十进制数 9 的非压缩型 BCD 码
BCD 码的运算过程可以将转化成二进制运算,然后再把二进制的结果转化成 BCD 码。但是这样效率比较低,8086 使用的是直接使用 BCD 码参与二进制运算,然后用指令将结果校正成 BCD 码。
这里我们了解一下即可,记住指令和全称表达的意思。
- DAA(Decimal Adjust AL after Addition):用于在将两个十进制数相加后,调整 AL 中存储的 BCD 码结果。
- AAA(ASCII Adjust AL after Addition):用于在将两个ASCII 码数相加后,调整 AL 中存储的二进制码结果。
- DAS(Decimal Adjust AL after Subtraction):用于在将两个十进制数相减后,调整 AL 中存储的 BCD 码结果。
- AAS(ASCII Adjust AL after Subtraction):用于在将两个 ASCII 码数相减后,调整 AL 中存储的二进制码结果。
- AAM(ASCII Adjust AX after Multiply):用于在将两个 ASCII 码数相乘后,调整 AX 中存储的二进制码结果,将其转换为两个十进制数的积。
- AAD(ASCII Adjust AX before Division):用于在将两个 ASCII 码数相除前,将 AX 中存储的二进制码结果转换为两个十进制数的商和余数。
逻辑运算
NOT
指令不会影响标志位,但是其他逻辑运算指令都会使OF=CF=0,SF ZF PF 根据结果确定。AND
OR
XOR
都是逻辑指令,TEST
则特殊,只是修改标志位不实际写入目标位置。
例如:从地址为 3F8H 端口中读入一个字节数,如果该数 bit1 位为 1,则将 DATA 为首地址的一个字输出到 38FH 端口,否则就不能进行数据传送。
1 | LEA ESI, DATA |
例如:将一个 8 位二进制数 9 变为字符‘9’(57=39H=0011 1001B),9=1001H,所以 OR AL '9'
这样就可以实现。
例如:从地址为 3F8H 的端口中读入一个字节数,当该数的 bit1, bit3, bit5 位同时为 1 时,则从 38FH 端口将 DATA 为首地址的一个字输出,否则就从端口重新输入。
分析可以知道,AND 0010 1010B,如果结果是这个数,那么就符合要求。这就要使用到减法指令中的 CMP
,对应的有符号数大于用 JG
,无符号数 JA
,相等都是 JZ
。另外一种办法是,AND 之后看结果是否 3 个 1 都有,可以用 XOR
实现。
1 | LEA ESI, DATA |
移位指令
移位的标志位一般只考虑 CF,CF 标志是这次操作移出的那一位
移位指令的次数,只能由 CL 或者 8 位立即数指定。SAL OPRD,CL/imm8
(Shift Arithmetic Left)一般用于带符号数,右边补 0,注意左移在小端序中实际是向高地址方向移动,在大端序中是向低地址移动。SHL
(Shift Logical Left)实际上也是相同的。
对应的,右移也分为 SAR
和 SHR
,分别用于带符号数和无符号数。
也有循环移位指令,对标志位修改和其他移位指令一样,但是 ROL
(Rotate Left) ROR
(Rotate Right) 指令是不带进位的循环移位,相当于操作数转圈。RCL
RCR
则是把 CF 也作为数字的一部分,开始移位。比如 0100 1101
,初始 CF=1
1 | 如果是ROR: |
串操作指令
串操作实际上就是循环处理,但是自动修改 ESI 和(或)EDI,使其指向下一个单元。注意,DF=0 则地址增加,DF=1 则地址减小。循环计数的寄存器 ECX 也会对应减少。这样的操作,会通过「重复前缀」实现,前缀的意思就是它放在其他指令的前面,修饰这条指令,在满足条件下重复指令。重复前缀修改 ECX 不修改标志位。
- REP: ECX 不为 0 就重复。
- REPE 和 REPZ:则是增加了条件 ZF=1,才重复。分别用于判断字符串相等和字符串为 0.
MOVS 这类指令可以使用 MOVSB、MOVSW、MOVSD,这样指定长度就不需要操作数,默认了操作数来源。同理,CMPS、SCAS 也是这样
例子:用 MOVS 指令实现将 200 个字节数据从 MEM1 开始的一个内存区送到另一个从 MEM2 开始的区域的程序段
1 | LEA ESI,MEM1 |
例子:比较两组(200 个字节)对应数据,找出第一个不同数据放入 AL,其地址放入 EBX。CMPS
相当于 字符串相减src - dst
,影响标志位。
1 | LEA ESI,MEM1 |
例子:SCAS
(Scan String Byte) 则是根据长度和 AL/AX/EAX - [EDI],32 位下时双字。只影响标志位。在内存块中搜索特定的字节值(在本例中为 0x42)
1 | ; 初始化寄存器 |
LODS
(Load String)指令从[ESI]加载字符串到 AL、AX、EAX,显然LODS 指令加重复前缀无意义。
STOS
(Store String)则是相反的,从 AL、AX、EAX 加载字符串到[EDI]。
例如:将累加器中的值(假设为AL
)存储到目标地址为destination
的字符串中,共存储length
次:
1 | MOV ECX, length ; 设置要存储的次数 |
程序控制指令
转移指令主要由下面几种:
- JMP OPRD:它是无条件转移,从下一条指令开始计算偏移量。实际使用可以
JMP Label
即可。但是如果是JMP EBX
这样 32 位的数据,就是直接跳转到 EBX 的位置。
标志位:
- JC:CF 为 0 跳转。
- JZ:ZF 为 0 跳转。
- JO:OF
- JP:PF
比较类:
- JA、JB、JAE、JBE:是无符号数,接在 CMP 后面。
- JG、JL:是有符号数,也是 CMP 后面。
CX/ECX:
- JCXZ、JECXZ:根据 CX、ECX 跳转。
例题:统计内存数据段中以 TABLE 为首地址的 100 个 8 位有符号数中正数、负数和零的个数。LOOP 是自动 ECX-1 得控制流指令,ECX=0 是退出。
1 | .DATA |
过程调用
flat 模式下过程调用是 EIP 压栈,然后入口送入 EIP 中。子过程执行之后 RET 返回原程序
汇编程序基础
在汇编中,指令的基本格式如下,标号就之前 NEXT
这样的东西,用来表示这个语句的位置,注意不要和保留字冲突。指令助记符就是指令名字,操作数表示可以多次出现,注释也可以多次出现。
除了之前学过的指令还有伪指令,它们不是 CPU 可以直接执行的指令,而是为了方便编写程序的一些指令,编译器或者汇编器会进行处理,按照伪指令的逻辑,生成代码。一些常见的伪指令示例包括:
DB
:定义一个或多个字节的数据。DW
:定义一个或多个字的数据。DD
:定义一个或多个双字的数据。EQU
:为一个值或地址分配一个符号名称。END
:标记程序的结束。
还有多行注释,@
是自定义符号,注释中不能出现,否则会造成矛盾。:
1 | COMMENT @ |
数据定义
汇编中的字面常量,数字一般是十进制,1101B
aef4H
这些根据后缀指定二进制或者十六进制。字符串是用单引号和双引号都可以,字符用单引号,都是 ASCII 码表示。
变量会定义数据的类型,主要是指变量的长度。一般用缩写即可,都是 Define Byte/Word/Doubleword
,对应DB/DW/DD
。变量一般会放在数据区,比如:
1 | .DATA |
变量名表示在数据区(段)内的偏移量,比如 DATA1
表示 0。一个变量也可以表示一个数组,比如 20H,30H
用逗号分隔,表示两个字节元素的数组。如果是 ?
表示不初始化。字符串会自动解析成对应类型。
关于字符串的字面常量,需要注意除了 byte 类型,元素长度必须不大于类型长度,而且内存布局是小端序。如果元素比较短,那么高位自动填充 0。比如:
1 | DATA1 DB "abcdse" |
为了方便创建数组,甚至复杂的数据,提出了 DUP
伪指令,注意重复次数+DUP(元素字面量)
是一组的。
1 | ;基本语法:变量名+类型+重复次数+DUP(需要重复的元素的字面量) |
局部变量在子程序中定义,默认 dword
类型,基本语法local 变量名[元素个数] :类型
1 | Local var1[1024]:byte ;定义了一个1024字节长的局部变量lvar1 |
使用变量时,变量表示地址,但是直接引用变量,是提取地址里面的内容。变量也可以相对寻址,写入特定的位置,比如 MOV DA3[ESI],AL
。特别是在 MOV
指令中,赋值长度一般由寄存器决定。
标号和符号定义
给语句的标号,实际上有一些属性。当使用 ::
而不是 :
时表示全局标号。而且放在指令前面是,是 NEAR
类型标号,只能段内转移,段外要调用,要用 标号名字 NEAR FAR
,而且不用冒号,下一条指令换行写。
1 | SUB1_FAR LABEL FAR |
而且标号可以设置别名,改变变量属性。使用 DATA_BYTE
就可以把下面的 DATA_WORD
变量当作 byte 类型用了。
1 | DATA_BYTE LABEL BYTE |
EQU
伪指令相当于宏定义,编译时直接替换,所以指代必须唯一。
1 | COUNT EQU 5 |
=
类似 EQU,但是只能是一些字面量的别名。
1 | CONT=5 |
表达式和变量修饰符
+、—、*、 / 、MOD、SHL、SHR、[ ]
这些表达式时汇编器自己定义的,也会由汇编器编译成字节码,用于方便编程程序。需要注意,SHR
SHL
是中缀运算符,用于表示常量移位临时的计算结果,结果相当于立即数。[]
则是表示相加,MOV AX,DA_WORD[20H]
相当于 MOV AX,DA_WORD+20H
,但是方括号外的加法是不允许的,比如 ARRAY+EBX[ESI]
。
1 | NUM=11011011B |
NOT、AND、OR、XOR
这些逻辑运算符,除了 NOT
是前缀,其他都是中缀,也是用于常量,返回立即数。
EQ(等于)、NE(不等于)、LT(小于)、 LE(小于等于)、GT(大于)、 GE(大于等于)
这些关系运算符都是中缀,返回值要么全 1 表示真,要么全 0 表示假。
还有一些提取变量信息的运算符,也是相当于返回立即数。
SEG
是16 位下使用的返回变量段标号的,我们基本不会用到。OFFSET
则常用很多,提取变量的地址。OFFSET 无法获取寄存器值,所以 OFFSET ADDR[ESI]是错误的。TYPE
会返回变量的类型的长度。LENGTH
专门用于提取DUP
的重复次数,是最外层的第一个 DUP。如果第一个元素不是 DUP,那么为 1.LENGTHOF
则是变量按照类型长度去数,有多少个元素。注意下面的变量,可以没有变量名。SIZE
则是纯一层 DUP 定义数组的大小,LENGTH 和 TYPE 的乘积。实际编程我们不用它。SIZEOF
则有用很多,表示变量的大小,等于 LENGTHOF 和 TYPE 返回值的乘积
1 | K1 DB 10H DUP(0), 20H |
变量 | TYPE | LENGTH | LENGTHOF | SIZE | SIZEOF |
---|---|---|---|---|---|
K1 | 1 | 10H | 11H | 10H | 11H |
K2 | 1 | 1 | 4 | 1 | 4 |
K3 | 2 | 20H | 80H | 40H | 100H |
K4 | 1 | 1 | 8 | 1 | 8H |
在赋值时,有时候还可以手动指定类型长度 类型+PTR+变量名
,相当于在使用时类型转换了。但是很多时候汇编器会根据寄存器的长度,推理出赋值的长度。
为了拆分一个字的常数,HIGH/LOW
修饰符就可以获得一个字的高 8 位和低 8 位。
1 | CONST EQU ABCDH |
过程定义
主要学习定义子程序的语法,子过程的名字可以用 CALL 或者 INVOKE 调用,之后是可见属性三选一,一个模块类似于一个 C++类。寄存器列表,将会在编译过程增加指令,将这些寄存器自动压栈,ret 前自动出栈。比如PROC USES EAX ECX
,先当于PUSH EAX PUSH ECX POP ECX POP EAX
1 | 子程序名字 PROC [PRIVATE/PUBLIC/EXPORT] [USES 使用的寄存器列表] |
RET
指令实际上从堆栈中弹出一个值,并将程序计数器(PC)设置为该值,从而将执行流程跳转到该地址。RET n
是 RET
指令的一个变体,其中 n
是一个立即数,表示在从子程序返回之后,还需要从堆栈中移除的字节数。这用于清除主程序调用子程序时,压入的参数。
程序结构
.386
指定指令集。.MODEL
指定内存模式,比如 flat 模式,语言模式,比如 API 调用。OPTION
设定语句定义,必须大小写不敏感等等。INCLUDE
是头文件,INCLUDELIB
包含库文件。ExitProcess PROTO, dwExitCode:DWORD
部分程序在.DATA
之前,需要声明用到的 API 函数,格式是函数名字 PROTO, 参数:类型
,但是也可以直接INCLUDE
头文件,就不用手动声明了。.CONST
是常量,只读不可写。之后就是代码的部分,格式基本固定的。
1 | .386 |
可以在代码中调用库函数,invoke 函数名[,参数1][,参数2]……
比如 invoke ExitProcess,NULL
。
$
表示位置计数器,实际上就是当前位置的地址,这就方便计算偏移量,比如某些变量的长度等。ORG 数值表达式
可以设置 $ 的值,这样可以在任意位置写入,也可以保留一部分未使用的内存。
Windows 的输入输出
课程使用的是封装好的 Win32 SDK,叫做 Irvine32.LIB,函数调用不带参数,而是寄存器传参数。
例如:编程实现从键盘输入 16 进制数,然后以二进制形式显示输出。
1 | .386 |
简单了解一下一些函数:
ReadChar
从键盘读取一个字符, 它的 ASCII 码存在 AL,一些特殊键就为 0.ReadDec
从键盘读取 32 bit 无符号十进制整数,存在 EAXReadHex
32bit 十六进制整数ReadInt
32 bit 有符号整数,第一个字符可以是+
-
ReadKey
检查键盘输入缓冲区,如果没有有按键数据则 ZF=1,有则 ZF=0,且存入 ALReadString
从键盘读取一个字符串,直到用户键入回车键。EDX 是存储的变量的地址,ECX 是最大读取长度+1,读取的字符串末尾有 NULL。
输出函数:
WirteBin
EAX 的值二进制打印。WirteBinB
EAX 的值,按照 EBX1,2,4 这样,显示 1,2,4 个字节。WriteChar
WriteDec
WriteHex
注意这个会补前置 0WriteHexB
WriteInt
WriteString
从 EDX 里面的地址开始,一直打印到表示结束的 0
程序设计基础
例题 1:输入学号查学生的数学成绩,成绩按照学号顺序排列,每个字节一个成绩。
1 | .386 |
例题 2:数据段的 ARY 数组中存放有 10 个无符号数,试找出其中最大者送入 MAX 单元。
1 | .386 |
例题 3:设有两个数组 X 和 Y,它们都有 8 个元素,其元素按下标从小到大的顺序存放在数据段中。完成下列计算:Z1=X1+Y1 Z2=X2-Y2 Z3=X3+Y3 , Z4=X4-Y4 Z5=X5-Y5 Z6=X6+Y6, Z7=X7+Y7 Z8=X8-Y8
1 | .386 |
例题 4:编写一程序,将字单元 VARW 中含 1 的个数(含 1 的个数是指用二进制表示时,有多少个 1)统计出来,存入 CONT 单元中。
1 | .386 |
子程序
在前面我们通过例子,学习了一般的顺序、分支、循环结构应该如何处理,这里学习如何使用子程序。最直接的方法就是寄存器传参,直接 call,子程序和写 main
是一样的,但是开始需要 PUSHAD,ret 前需要 POPAD。
比如下面的例子就是将 0-9 中的一位数,转换成二进制。
1 | .386 |
还可以地址传参,就是把需要的变量的地址,全部放在全局变量里,然后把全局变量地址放在 EBX,子程序再从 EBX 获取。
1 | .386 |
堆栈传参稍微复杂一些,在调用子程序之前,需要把对应的参数压栈,需要进入子程序之后,会自动加入偏移量,占一层堆栈。如果不带参数,可以这样写。注意参数压栈的顺序和自动插入的偏移量!有时候会为了恢复现场,在刚进入子程序就把寄存器压栈,ret 前再出栈,这样就重新覆盖了寄存器的值,可以保护现场,这时候也要注意堆栈变化,也要注意 PUSH POP 对应。
1 | .386 |
可以指定保护的寄存器,这样就不用手动恢复寄存器了,寄存器之间用空格隔开。
1 | .386 |
也可以使用 INVOKE 带参数调用,这样就不要在主程序里手动 PUSH 了,也不用手动管理堆栈,这是推荐的方式。
1 | .386 |