引言
汇编语言虽然是低级语言,但具有不可替代的地位。汇编指令与硬件密切相关,因此学习汇编语言可以很好地了解计算机的结构。
机器指令、汇编指令
计算机中的电路只有两种状态:高电平和低电平,因此只能用二进制来表示各种信息。
机器指令是有特殊作用的二进制数,会触发计算机执行特定的操作,如加、减、乘、除等算术运算。机器指令由操作码和操作数两部分组成:
- 操作码:对应着某一操作,决定了指令的作用,比如加法
- 操作数:操作的对象,如加法运算中的一个加数
下图展示了 4 条机器指令,它们的作用都是修改寄存器的值。前 2 条是修改寄存器 AX 的值;后 2 条是修改寄存器 BX 的值。都是先修改为全 0,再修改为全 1
通过对比,不难发现,它们都有相同的部分 1011
,表示要修改寄存器的值。前两列就是操作码,因为前 2 条和后 2 条对应不同的寄存器,所以它们的操作码不同。后面 4 列是操作数。
显然,一连串的 0 和 1,对于人类来说,难以记忆和辨别。为此,人们就用一些文字(称为助记符)来代替每条机器指令中固定不变的部分,汇编语言由此诞生。
例如,上述 4 条机器指令对应的汇编指令如下:
MOV AX, 0000H
MOV AX, FFFFH
MOV BX, 0000H
MOV BX, FFFFH
这样的写法与自然语言接近,便于阅读和记忆。
汇编指令与机器指令之间具有一一对应的关系。但汇编指令在交由计算机执行前,还是要先转换成机器指令,才能被计算机识别和运行,这个转换过程称为编译,完成这项工作的程序称为编译器。
8086 CPU
机器指令的集合称为指令集。目前被广泛采用的指令集是 Intel 研发的 x86。不同的 CPU 可能采用不同的指令集,相应的汇编指令也不同。因此汇编语言是不能跨平台的。
Intel CPU 的主要发展过程是:4004/8008、8080、8086/8088、80286、80386、80486、Pentium、Pentium Pro、Pentium 2、Pentium 3、Pentium 4、Pentium D、Core 2
8086 是具有代表性的 16 位 CPU,是第一个采用 x86 指令集的 CPU。Intel 后续推出的各型 CPU 都与其兼容。学习 8086/8088 CPU 是进一步学习和应用更先进 CPU 的基础。
几条汇编指令
8086 CPU 有 14 个寄存器,并且都是 16 位的,也就是可以放得下一个 16 位的二进制数。有 4 个是通用寄存器,用来存放一般性的数,在汇编指令中分别用 AX、BX、CX、DX 表示。
下面介绍 2 条最基本的汇编指令:
MOV
指令可用于将某个值传送到指定寄存器。下面指令将数值 5 传送到寄存器 AX 中,执行后,寄存器 AX 的值就变成 5
MOV AX, 5H
ADD
指令可用于把指定寄存器的值加上另一个值。下面指令将寄存器 AX 的值加上数值 3,执行后,寄存器 AX 的值就变成 8
ADD AX, 3H
SUB
指令可用于把指定寄存器的值减去另一个值。下面指令将寄存器 AX 的值减去数值 2,执行后,寄存器 AX 的值就变成 6
SUB AX, 2H
INC
指令可用于将指定寄存器的值加 1,下面指令相当于 ADD AX, 1
INC AX
通用寄存器又可以看作是两个相互独立的 8 位寄存器组成:
- AX 分成 AH 和 AL
- BX 分成 BH 和 BL
- CX 分成 CH 和 CL
- DX 分成 DH 和 DL
下面指令先把数值 4EH 传送到寄存器 DH,再把数值 20H 传送到寄存器 DL,相当于把 4E20H 传送到寄存器 DX
MOV DH, 4EH
MOV DL, 20H
Debug
Debug 是一个调试工具,可以直接查看和修改各个寄存器的值和内存的值,可以在机器指令的层面跟踪程序的运行。
Debug 是一个 16 位的命令行工具。DOS 和 16 位或 32 位的 Windows 都已内置 Debug,按 Win+R,输入 debug
,回车即可打开。但 64 位 Windows 移除了 Debug,即使有,也无法直接运行。有两个解决方案:
- 安装 DOS 模拟器 DOSBox,以模拟 DOS 环境
- 安装虚拟机软件 VirtualBox,以虚拟 16 位或 32 位的 Windows
不管是那种情况,启动 Debug 的方法都是一样的:打开命令提示符窗口,输入 debug
,然后按下 Enter,此时命令提示符变成 -
这个时候就可以使用 Debug 提供的命令了。要使用这些命令,同样是先输入命令,再按下回车便可执行。下面介绍一些常用的命令:
在 debug 中,所有数值都以 16 进制表示,输入时结尾不需要
H
d
命令用来查看内存中的值。每次输出 8 行,每行 16 个字节,共输出 128 个字节。第一列是每行第一个字节的地址。右侧的字符是将内存中的值视为 ASCII 编码而得到的。如果没有对应可显示的字符,就用 .
代替。
u
命令将内存中的值视为机器指令,并显示对应的汇编指令,也就是反汇编。
r
命令用来查看寄存器的值。
r
命令还可以跟上某个寄存器的名称,以修改寄存器的值。Debug 会先显示寄存器的当前值,再等待输入新的值,按回车即可完成修改。
执行 q
命令,可退出 debug
段地址、偏移地址
8086 CPU 的地址线有 20 根,即地址是 20 位的二进制数。20 位的完整地址称为物理地址。但最大的寄存器也就只有 16 位,不足以保存 20 位的物理地址。为此,8086 CPU 需要两个寄存器相互配合,才能完整地表示一个地址。具体方法是:
把物理地址看成两个数的和。这两个数中,一个只有 16 位,刚好可以放在一个 16 位的寄存器中。这 16 位称为 偏移地址,保存偏移地址的寄存器称为偏移地址寄存器;另一个有 20 位,但最低 4 位都是 0,因此也只需一个 16 位的寄存器来保存它的高 16 位即可。这 16 位称为 段地址,保存段地址的寄存器称为段寄存器。
显然,段地址要先左移 4 位(相当于 × 16),再加上偏移地址,才是真正的物理地址。例如,段地址是 F492H,偏移地址是 3H,则物理地址 = F492H × 16 + 3H
在 debug 中,d
命令用来查看内存中的值。可以指定要查看的内存空间,只需在 d
后面跟上用冒号隔开的段地址和偏移地址即可。
e
命令用来修改内存的值,使用时要在 e
后面跟上用冒号隔开的段地址和偏移地址。在输入命令并按下回车后,就进入内存编辑模式。此时,debug 会从地址对应的字节开始,每次输出一个字节的当前值,并等待输入新的值,按空格,可接着修改下一个字节,按回车则结束命令。
e
命令还可以很方便地在内存中填入字符的 ASCII 编码,只需将字符放在双引号中,跟在命令后面。
a
命令和 e
命令一样,也是用来修改内存的值。不过,a
命令等待输入的是汇编指令,debug 会将输入的汇编指令转换成机器指令,再写入指定的内存空间。
代码段、数据段
CPU 通过段地址和偏移地址的组合,得到某个内存单元的地址,可看作是把内存分段,先由段地址给出某一段的起始地址,再由偏移地址指出这一段中的其中一个单元。
根据用途,段可以分为代码段和数据段。如果保存的是一系列指令,就称这一段为代码段。
要执行这些指令,只需让程序计数器指向代码段的第一个单元即可。
如果保存的是数据,就称这一段为数据段。
CS:IP 程序计数器
8086 CPU 的段寄存器有 DS, ES, SS, CS,偏移地址寄存器有 SP, BP, SI, DI, IP 以及 BX
其中 CS 为代码段寄存器,IP 为指令指针寄存器,它们共同组成程序计数器,保存下一条指令的地址。在 debug 中使用 r
命令时,debug 会同时把下一条指令显示出来。
执行 t
命令,就可以执行下一条指令。
通过修改程序计数器的值,就可以改变指令的执行顺序。在 debug 中可以用 r
命令直接修改程序计数器的值。
转移指令
专门用来修改程序计数器的汇编指令是 JMP
。下面指令将程序计数器的值修改为 0730:0100
,执行后,这个地址上的指令就成为下一条指令。
JMP 0730:0100
也可以只修改 IP 寄存器:JMP
命令支持把另一个寄存器的值传送给 IP 寄存器。
MOV AX, 0100
JMP AX
这类用于修改程序计数器的汇编指令称为转移指令。
有两个比较特殊的转移指令 CALL
和 RET
,这两者的搭配,可以实现「回过头」的效果:先用 CALL
跳转到别处,之后,又可以用 RET
跳转回原来的位置。
DS:[BX] 读写内存
DS 为数据段寄存器。CPU 要读写某个内存单元时,段地址写入 DS,偏移地址作为指令的一部分,放在中括号中。下面指令的作用是将 2000:0100
单元的值传送到 AL 寄存器。
MOV DX, 2000
MOV DS, DX
MOV AL, [0100]
当然,也可以反过来,从寄存器传送到内存单元。下面指令的作用是将 AL 寄存器的值传送到 2400:0200
单元。
MOV DX, 2400
MOV DS, DX
MOV [0200], AL
偏移地址可以先放在 BX 寄存器中,然后在中括号里写上 BX。下面指令的作用是将 0730:0100
单元的值传送到 AH 寄存器。
MOV DX, 0730
MOV DS, DX
MOV BX, 0100
MOV AH, [BX]
8086 CPU 的数据总线有 16 位,可以一次性传送 16 位数据,即两个字节。通常,两个字节称为一个字。
字和字节一样,都是容量的单位。8 个二进制位,称为 1 字节;而 16 个二进制位,则称为 1 字,并把低 8 位称为低位字节、高 8 位称为高位字节。
只要指令中包含 16 位寄存器,就会连续传送两个字节。
MOV DX, 0730
MOV DS, DX
MOV AX, [0100]
上面指令的作用是将 0730:0100
单元的值传送到 AX 寄存器的低 8 位;将 0730:0101
单元的值传送到 AX 寄存器的高 8 位,如下图所示:
SS:SP 堆栈指针
栈是一段特殊的内存空间,称为栈段。它的特殊之处在于:越后写入,越先读出,这种规则称为后入先出(Last In First Out,LIFO)
段寄存器 SS
和偏移地址寄存器 SP
指向这段内存。通过 PUSH
指令可以将某个寄存器的值写入栈,同时 SP
寄存器减 2,称为 入栈
SS:SP
指向的内存单元,称为 栈顶。通过 POP
指令可以将栈顶的值传送到某个寄存器,同时 SP
寄存器加 2,称为 出栈
在执行 CALL
指令时,发生了两个动作:先把下一条指令的偏移地址入栈,再把 IP 寄存器的值改为指定值。RET
指令则把栈顶的值取出,传送给 IP 寄存器。
第一个程序
一个完整的汇编程序的源代码如下:
ASSUME CS:CODE_SEGMENT
CODE_SEGMENT SEGMENT
MOV AX, 2
ADD AX, 2
ADD AX, 2
MOV AX, 4C00H
INT 21H
CODE_SEGMENT ENDS
END
汇编程序的源代码包含两种指令:汇编指令和伪指令。汇编指令会编译成机器指令,最终被 CPU 执行。伪指令相当于 C 语言中的预处理器,编译器根据伪指令来进行编译工作。
源代码中一般还会包含一些标号。一个标号代表一个地址。放在指令前面时,就代表这个指令的地址。标号最终都会被处理成一个地址。
段的概念(代码段、数据段、栈段等)在汇编程序中得到体现。一个汇编程序由多个段组成,至少有一个代码段。
段通过伪指令 SEGMENT
和 ENDS
定义。它们总是成对使用:SEGMENT
标识段的开始;ENDS
标识段的结束。使用时,它们前面必须有一个标号,作为段名。
下面代码定义了一个名为 CODE_SEGMENT
的段,中间是一系列指令,因此是一个代码段。段名实际上就是这个代码段第一条指令的地址。
CODE_SEGMENT SEGMENT
.
.
.
CODE_SEGMENT ENDS
伪指令 ASSUME
用于说明某个段与某个段寄存器存在联系,比如说明上面定义的代码段 CODE_SEGMENT
与代码段寄存器 CS
存在联系。
ASSUME CS:CODE_SEGMENT
编译器在编译过程中,如果碰到 END
指令就结束编译。
END
在 DOS 中,要运行一个程序 P1,必须有另一个程序 P2 把它加载到内存,把 CPU 的控制权交给 P1,P1 才得以运行。P1 的最后,还要把 CPU 的控制权交还给 P2,称为程序返回。代码段中的以下两条指令就是用来完成程序返回的。
MOV AX, 4C00H
INT 21H
现阶段只要知道,在程序末尾使用两条指令就可以实现程序返回。
编译、连接、跟踪运行
从源代码到可执行文件,要经过两个步骤:编译和连接。编译通过调用编译器完成;连接通过调用连接器完成。编译器可采用 MASM 5.0,连接器可采用 Overlay Linker 3.6
假设前面分析的源代码保存在 test.asm 中,用 MASM 5.0 编译,得到目标文件 test.obj
> masm test.asm;
用 Overlay Linker 3.6 编译,得到可执行文件 test.exe
> masm test.obj;
现在可以直接输入 test
来运行该程序,但那样不能到观察程序的运行过程。可以使用 debug 来跟踪程序的运行。只要在 debug
命令后跟上一个程序的文件名,debug 就会把这个程序加载到内存,并将程序计数器指向其第一条指令。
到了 INT 21H
,要用 p
命令来执行。
网友评论