汇编语言入门学习 | 2 - 汇编语言代码基本结构
从一个例子开始
根据个人习惯,我更愿意从一个实例开始某种语言的学习。
这里以一个 16 位汇编程序为例:
我们在 xp 虚拟机中新建文件 hello.asm,用记事本编辑:
1 data segment
2 abc db "hello, world!", 0Dh, 0Ah, "$"
3 data ends
4 ;这是一条注释
5 code segment
6 assume cs:code, ds:data
7 main:
8 mov ax, data
9 mov ds, ax
10 mov ah, 9
11 mov dx, offset abc
12 int 21h
13 mov ah, 4Ch
14 int 21h
15 code ends
16 end main
将其放在 \Masm 目录中。该目录中同时包含了 LINK.EXE 以及 MASM.EXE。我们在 command 中进入对应目录,输入指令:
masm hello;
link hello;
hello
我们通过运行 masm.exe 来编译 hello[.asm],然后通过 link.exe 来连接 hello[.obj],最后运行 hello[.exe]。结果显示: hello, world! 。
下面,我们对代码进行逐句解析:
-
段
-
对于 8086PC 机,在编程时可以根据需要将一组内存单元定义为一个段(机器语言代码也存储在内存中)。例如第 1 行和第 5 行就分别定义了名为 data 的段和名为 code 的段。第 3 行和第 15 行分别是这两个段的结束。
-
8086CPU 要求每个段的容量不能超过 64KB。这与计算机的寻址方式有关:
- 计算机对内存的编码是线性的。例如内存为 256M,则地址就应该为 0~(256M-1) 。这个地址称为 物理地址 或 绝对地址 。
- 8086CPU 可以传送 20 位的地址 0~(1M-1),但是由于 8086CPU 是 16 位结构的,因此如果采用简单的方法传递物理地址,那么它的寻址能力只有 0~(64K-1)。因此 8086CPU 采用两个 16 位地址合成的方法来形成一个 20 位的物理地址。比如 12ABh:34DEh 就是一个地址(其中 12ABh 表示 16 进制下的 12AB。汇编语言中用末尾的一个 h 来表示 16 进制数。汇编语言中数字的表示不区分大小写。如果一个 16 进制数是字母开头的,则需要在它前面增写一个 0,如 ABCDh 应写为 0ABCDh,因为字母开头的字符串表示的是变量的名称),它由两个 16 位的地址组成,分别称为 段地址 (Segment) 和 偏移地址 (Offset)。这样表示的地址称为 逻辑地址 。
- 地址加法器采用 物理地址 = 段地址 * 16 + 偏移地址 的方法合成物理地址。
- 需要说明的是,同一个物理地址可以表示成多个逻辑地址。如 123BC = 123B:000C = 122A:011C。
- 因此,我们如果要用 段地址:偏移地址 的方式寻址,偏移地址的范围为 0h~FFFFh,即 10000h 个字节,即 64KB。
-
伪指令
- 在汇编语言源程序中,包含 2 种指令,一种是汇编指令,一种是伪指令。汇编指令是与机器码一一对应的,而伪指令由编译器来执行,编译器会进行相关的编译工作。
- 例如,segment 和 ends 就是一对成对使用的伪指令。
-
-
定义数组
-
第 2 行
abc db "hello, world!", 0Dh, 0Ah, "$"
定义了一个字节类型的变量(db: define byte,byte 类型实际上等价于 C 语言中的 char 类型),名为 abc,内容为"hello, world!", 0Dh, 0Ah, "$"
,相当于 C 语言中的char abc[] = "hello, world!\x0D\x0A$";
,即逗号隔开的内容会被连接成一个变量。其中 0Dh, 0Ah 分别是回车(光标回到行首)和换行(光标向下移动一行)的 ASCII 码。在汇编语言中,$ 是字符串结束的标志。 -
我们可以通过
ans db 100 dup(0)
定义一个定长的数组,相当于 C 语言中的char ans[100] = {0};
。dup 是 duplicate 的简写,表示重复。我们可以通过这种方法来取得内存空间存放数据。 -
汇编语言将所有的变量定义放在一起,即 data segment 区域中。
-
类似地,我们可以用 dw(define word) 定义字型数据(16位)。
-
-
注释
- 如第 4 行,汇编语言源代码中,可以用分号 ; 表示本行中后面的内容均为注释。这与 C 中的 // 类似。
-
寄存器
-
CPU 本身只负责运算,不负责存储数据。数据一般存放在存储器中,CPU 需要使用数据时就会去存储器中调用数据。然而,CPU 的运算速度远高于内存的读写速度,因此为了提高效率,CPU 自带缓存和 寄存器 (register)。缓存可以看做读写速度较快的内存,而寄存器是 "fastest, smallest and most expensive" 的,用来存储最常用的数据。
-
寄存器不依靠地址区分数据,而依靠名称。每一个寄存器都有自己的名称。
8086CPU 有 14 个寄存器,分别为 AX, BX, CX, DX, SI, DI, SP, BP, IP, CS, SS, DS, ES, PSW,它们都是 16 位的。 -
本文中,我们用加括号的寄存器名称来表示寄存器中存储的数据。例如, (ax) 表示 ax 寄存器中存储的数据。
-
AX, BX, CX, DX 这 4 个寄存器通常用来存放一般性数据,称为 通用寄存器 。为了与上一代 CPU 兼容,每个通用寄存器都可以拆成两个 8 位寄存器独立使用,如 AX 可拆分为 AH 和 AL,BX 拆分为 BH 和 BL 等。H 和 L 分别表示高 8 位和低 8 位。
计算机存储信息的基本单位是一个 二进制位(bit),一位可存储一个二进制数 0 或 1。每 8 位组成一个 字节(Byte)。每两个字节组成一个 字(word),这两个字节分别称为高位字节和低位字节。 -
代码段寄存器 CS(code segment) 和指令指针寄存器 IP(Instruction Pointer) 是 8086CPU 中最关键的两个寄存器。它们分别用来提供当前指令的段地址和偏移地址。即任意时刻,8086CPU 将 CS:IP 指向的内容当做命令执行。每条指令进入指令缓冲器后、执行前,IP += 所读取指令的长度,从而指向下一条指令。
-
其余寄存器将在用到时再做记录。
-
-
伪指令 assume
- 第 6 行
assume cs:code, ds:data
将段寄存器和段名建立了关系。即 assume 使得段寄存器储存了对应段的段地址。
- 第 6 行
-
标号
- 第 7 行
main:
是一个标号。标号在程序中的主要用途是方便跳转语句的执行。跳转语句将在后面再做学习。
- 第 7 行
-
传送指令 mov
- 传送指令 mov 的一般格式为 mov A, B ,用于将 B 的内容赋给 A(如果合法)。
- 传送指令在本文文末专门记录。
-
offset
- 操作符 offset 的功能是取得标号的偏移地址。第 11 行
mov dx, offset abc
的作用就是将 abc 的偏移地址赋给 dx。
- 操作符 offset 的功能是取得标号的偏移地址。第 11 行
-
中断
- 第 12 和 14 行的
int 21h
调用了中断。中断在本文文末专门记录。 - 在这里,
mov ah, 9
;mov dx, offset abc
;int 21h
调用了中断 21h 的 09h 号功能,实现了对字符串 abc 的输出。中断 21h 的 09h 号功能实现的是:将自 ds:dx 开始、到 '$' 为止的字符串输出到标准输出设备上。 - 程序返回:每个可执行文件的类型都来自于某一个正在运行的程序的调用。可执行文件运行完毕后,它要将 CPU 的控制权交还给调用它的程序,这个过程称为程序返回。
mov ah, 4Ch
int 21h
完成的就是这个过程。
- 第 12 和 14 行的
-
end
- end 指令用于通知编译器:程序运行结束了。
- 在 end 后面加上一个标号,如 end main,则在起到上述效果的同时还会通知编译器程序的入口在什么地方(即偏移地址)。即,程序的入口由 end 指出。在本代码中,程序自标号 main 开始运行。
mov 指令(传送指令)
将数据直接送入寄存器
指令 mov ax, 4E20h
表示将 4E20h 送入寄存器 AX。等价于高级语言中的 AX = 4E20h;
(此后文中会大量使用高级语言的语法描述汇编指令)。
将一个寄存器中的内容送入另一个寄存器
类似地, mov ax, bx
表示 AX = BX;
。
将一个内存单元中的内容送入一个寄存器
之前我们提到,8086CPU 中的地址由段地址和偏移地址组成。8086CPU 中有一个 DS 寄存器(段寄存器),用来存放要访问数据的段地址。
例如,我们要读取 10000h 单元的内容,可以用以下的程序段进行:
mov bx, 1000h
mov ds, bx
mov al, [0]
上面的三条指令将 10000h (1000:0) 中的数据读到了 al 中。可见,我们可以通过 mov register, [ address ] 的方式来将内存中 DS:address 的数据读到合法的寄存器 register 中。
值得注意的是,我们通过 1,2 两行将 1000h 放入了 DS,这是因为 8086CPU 不支持将数据直接送入段寄存器(ds, ss, cs, es)的操作。
我们还可以显式地规定我们调用的内存地址的段地址,如我们可以用 ds:[0] 来表示我们调用的内存单元为 ds:0。这允许了我们使用 ds 以外的段地址。这样的 "ds:" "cs:" 等被称为 段前缀。逻辑地址中的偏移地址可以用常数表示,但是段地址必须用段寄存器表示。
另外,我们可以通过 [bx] 表示内存单元 ds:bx,即段地址由 ds 提供,偏移地址由 bx 提供。
需要注意的是,8086CPU 中只有 bx, si, di, bp 这四个寄存器可以用来在 [] 中进行内存单元的寻址,其他寄存器进行这样的操作都是非法的。
同时,在 [] 中,这四个寄存器只能单独出现或以 bx, si / bx, di / bp, si / bp, di 的组合出现,如 [bx+si+1] 是合法的,而 [bx+bp] 就是非法的。两个寄存器只能相加,不能相减
只要在 [] 中用到寄存器 bp,而指令中没有显式给出段地址,那么段地址就默认在 ss 中而不是 ds 中。
由于 8086CPU 是 16 位结构,因此可以一次性传送 16 位数据。比如:
内存情况:
10000H 11
10001H 22
指令:
mov ax, 1000h
mov ds, ax
mov ax, [0]
结果:
ax = 2211H
这是因为,我们将 1000:0 处存放的字数据(由两个字节组成)送入 ax 时,1000:0 处存放的是字数据的低 8 位,即 11;1000:1 处存放的数字数据的高 8 位,即 22。(小端规则:对于 8 位以上的变量,先存放低位,再存放高位,即低位的内存地址低于高位的内存地址。)执行 mov 时,字数据的低 8 位送入 al,高 8 位送入 ah,因此 ax = 2211H。
这也说明,mov 操作的内存单元的长度由其他操作对象(寄存器)指出。但是,两个不同长度的寄存器之间的传递是非法的,如 al 和 bx。
另外,下面的代码反映了一种常见的错误:
data segment
xyz dw 1234h, 0ABCDh
data ends
...... ;略去
code segment
...... ;略去
mov ax, xyz[1]
...... ;略去
在 C 语言的理解中,short int xyz[2] = {1234h, 0ABCDh};
定义出的数组,xyz[1] 的值应该为 0ABCDh。而实际上在汇编语言中,xyz[1](即 [xyz + 1])指向的就是 xyz 物理地址 +1 的地址。假设 xyz 地址为 10000H,那么内存情况为(小端规则):
10000h 10001h 10002h 10003h
34 12 CD AB
实际上 xyz + 1 即 10001h,那么实际上程序认为 mov ax, xyz[1]
调用了以 10001h 为低八位的 16 个字节,根据小端规则,ax 被赋值为 0CD12h。
因此,如果希望引用 0ABCDh,实际上要写的是 mov ax, xyz[2]
。这是需要特别注意的。