在开始之前,我们约定一种写法,(reg)表示寄存器reg中的内容。
汇编语言基础
程序返回
在程序末尾添加返回程序段的代码为:
mov ax, 4c00H
int 21H
这里的int 21H
表示21号中断,并不是整型变量的意思。
几个表示结束的(伪)指令:
- 通知编译器一个段结束:
段名 ends
- 通知编译器程序结束:
end
- 程序返回:见上汇编指令
程序执行过程的跟踪
Debug可以用来监视程序的执行。
使用R命令查看各个寄存器的内容
Debug命令可以将汇编程序装入内存,就R命令而言,它为我们查看指定内存中的内容提供了机会;但是,我们应该查看哪里的内容?要回答这个问题,我们需要明白程序究竟被装入了内存的哪个地方。
使用-r命令后,我们会发现DS(数据段)与CS(代码段)指向的地址并不相同。更准确地说,CS总是比DS大了16(比如DS=075C,CS=076C),下面简单说一下原因:
- 程序被装入内存,内存中会有一段起始地址为SA:0000(起始地址的偏移地址为0)的容量足够的空闲内存区;
- 在这段内存区的前256个字节中,会创建一个PSP的数据区,此处不需要深入理解;
- 从这段内存区的256字节处开始(即PSP数据区后面)装入程序,程序的地址被设为SA+10H: 0。
- 将该内存区的段地址存入DS中(操作系统将程序作为数据加载到内存中),初始化其他相关寄存器,设置CS: IP指向程序的入口。
使用T命令单步执行程序中的每一条指令并观察执行结果
当到了结束指令int 21
时,改用P命令执行。
[BX]与Loop指令
[BX]
[bx]是内存单元。要完整地描述一个内存单元,需要两种信息:
- 内存单元的地址
- 内存单元的长度
比如,我们用[0]表示一个内存单元时,0表示单元的偏移地址,段地址默认在ds(数据段)中,单元长度可以由具体指令中的其他操作对象(如前面的寄存器)指出。
mov ax, [2]; 将以DS为基地址,2为偏移地址中的内容移送给寄存器ax
mov [2], ax; 将ax中的内容移送到以DS为基地址,2为偏移地址之中
但是在指令mov ax [0]
中,编译器并不是将0偏移地址单元传入ax,而是将0传入ax;如果要实现前者效果,应该先将偏移量传入bx,再将[bx]传入:
mov bx, 0
mov ax, [bx]
上面的bx中存放的数据作为一个偏移地址EA,段地址SA默认在DS中,将SA: EA处存放的数据移送到寄存器ax中。
在MASM编译器中,对比以下指令:
mov al, [0]
表示将0移送到almov al, ds: [0]
表示将DS段中,偏移地址为0的内容移送到almov al, [bx]
表示将DS段中,偏移地址为bx的内容移送到al中mov al, ds: [bx]
和上一个一致
段前缀
再次回到指令mov ax, [bx]
,内存单元的偏移地址由bx给出,而段地址默认在ds(数据段寄存器)中。
此外,我们也可以在访问内存单元的指令中,显式地给出内存单元的段地址所在的段寄存器,比如我们要访问内存单元,显式地指明内存单元的段地址“ds”、“cs”、“ss”、“es”,这些在汇编语言中被称为段前缀。
循环Loop指令
loop指令有两个注意点:
- 循环次数存放在寄存器cx中,注意尽量让该操作靠近循环程序段,否则很有可能不小心改变了cx中的内容;
- loop指令后面跟的标号所标识的地址要在loop指令之前
循环程序框架为:
mov cx, 循环次数
s:
循环执行的程序段
loop s; 循环执行程序段s
_例题_ 计算ffff: 0006单元中的数,将其乘以3,结果存放在dx中,编程计算。
assume CS: code
code SEGMENT
start:
mov ax, 0ffffH
mov ds, ax
mov bx, 0006H
mov al, [bx]
mov ah, 0
mov dx, 0; dx要事先清零
mov cx, 2
s: add dx, ax
loop s
mov ax, 4100H
int 21H
code ends
end start
程序简析:ffff: 0006单元中,ffff为基地址,0006为偏移地址,前者送入数据段寄存器DS,后者送入BX;[bx]即表示以DS为基地址、bx为偏移地址处的内容。另外注意dx事先要清零。
相关debug命令
之前我们已经学习过,使用r命令查看当前各个寄存器的内容、使用d命令查看指定内存中的内容(格式:-d 段基址:偏移地址
),以及使用t命令单步执行汇编语句。
那么,现在学过了循环指令loop之后,更多时候使用t命令似乎不太现实了,毕竟循环语句的单步执行意义不大。对此,我们需要掌握另外两个重要的debug命令:
- -u命令:查看当前每一条代码的存放地址,以及其内容
- -g命令:跳转到指定的地址,执行代码,格式:
-g 段基址:偏移地址
,需要说明都是,往往先使用-u命令查看当前所有代码语句的所在地址,然后使用-g跳转到目标地址,直接执行那个地方存放的代码。
两者结合使用的栗子
_例题_ 计算ffff:0 ~ ffff:b单元中的数据之和,并将结果存储在dx中。
分析:将内存单元中的8位数据赋值到一个16位寄存器ax中,再将ax中的数据加到dx上,从而使两个运算对象的类型匹配并且结果不会超界。思路如下(X是偏移地址量):
(al) = ((ds)*16 + X)
(ah) = 0
(dx) = (dx) + (ax)
总结
在实际编程中,经常会遇到,用同一种方法处理地址连续的内存单元中的数据的问题。此时,就需要用到循环来解决这类问题,同时,设置一些变量,以便于循环变化。
多个段的使用
使用代码段
求给定的一组数据的和,将结果存放在ax中。
assume cs: codesg
codesg SEGMENT
dw 0123H, 0456H, 0789H, 0abcH, 0defH, 0fedH, 0cbaH, 0987H; 定义若干数据,它们被存放在代码段中,由于在起始位置,故偏移地址为0
mov bx, 0
mov ax, 0
mov cx, 8
s: add ax, cs: [bx]
add bx, 2
loop s
mov ax, 4c00H
int 21H
codesg ends
end
使用堆栈段
利用栈,将一组数据逆序存放(假设给定的数据已经按顺序存放在了代码段CS中)。首先我们需要大致明白堆栈段以及堆栈段寄存器。
- 堆栈段位于内存中,在此处可以利用为栈结构;
- 堆栈段寄存器SS存储的是堆栈段的段基址;
- 此外,还有堆栈指针寄存器SP,它用来指向此时欲操作的堆栈段地址(实际上是偏移地址,配合SS就可以在内存中完全锁定地址)
这样一来,我们就可以让代码段中的原始数据先入栈、再出栈,代码段中就可以得到逆序的数据了。
~~~
## 数据、代码、栈的分开管理
在这一部分,我们将之前的程序进行优化:将数据、代码、栈放入不同的段,实现简单的封装。
~~~ assembly
ASSUME CS: codesg, DS: datasg, SS: stacksg
datasg SEGMENT; 表示声明一片数据段
dw 0123H, 0456H, 0789H, 0abcH, 0defH, 0fedH, 0cbaH, 0987H
datasg ends
stacksg SEGMENT; 表示声明一片堆栈段
dw 0, 0, 0, 0, 0, 0, 0, 0
stacksg ends
codesg SEGMENT; 表示声明一片代码段
start: mov ax, stacksg
mov ss, ax; 将我们自己定义的“堆栈段”的地址作为堆栈寄存器SS的指向(段基址)
mov sp, 16; 将堆栈指针寄存器指向偏移地址为16处(配合SS),即栈底
mov ax, datasg
mov ds, ax; 将我们自己定义的“数据段”的地址作为数据寄存器DS的指向(段基址)
mov bx, 0; 数据段的偏移地址,初始化为0
mov cx, 8; 设置循环次数
s: push [bx]; 入栈操作,DS: [BX]处的内容进入栈底,以此类推
add bx, 2; 偏移地址自增2
loop s
mov bx, 0
mov cx, 8
s1: pop [bx]
add bx, 2
loop s1
mov ax, 4100H
int 21H
codesg ends
end start
更灵活的定位内存地址
and和or指令
and指令:逻辑与,按位与运算
通过and指令,可以将操作对象的相应位设为0,而其他位不变。比如,将al的第六位设为0,可以使用
and al, 10111111B
。or指令:逻辑或,按位或运算
通过or指令,可以将操作对象的相应位设为1,而其他位不变。
ASCII码
通过一套共用的规则,将字符与(8位)二进制数建立一一对应。
比如,mov al, 'a'
相当于把‘a’的ASCII码值61H送入寄存器al。
大小写字母转换
将给定的一个全部由字母组成的字符串全部转换为小写或者大写形式。
进一步讨论内存的定位
数组的寻址
有如下命令:
mov ax, [bx+200]
,表示将段基址(段地址)为(ds)、偏移地址为(bx)+200这个内存单元中的数据送入ax,即:(ax)=((ds)*16+(bx)+20)
这种内存定位方式给予了我们更多的思考。就形式[bx+offset]而言,如果offset是一个可以改变的变量,bx固定指向一个地址,那么我们就可以轻而易举地访问一段连续的地址了——这就是访问数组的有效方法。
SI与DI
SI(源地址寄存器)、DI(目的变址寄存器)和BX(基址寄存器)都是通用寄存器,功能相近。它们最大的区别是:
SI、DI不可以分成两个8位寄存器来使用,事实上,它们只能按16位进行存取操作;
之所以在BX之外设置SI、DI,很大程度上是因为就寻址而言,仅仅一个BX不够用。
以下操作实现的效果一致:
mov bx, 0
mov ax, [bx]
mov si, 0
mov ax, [si]
mov di, 0
mov ax, [di]
寄存器寻址总结
在8086CPU中,只有BX、BP、SI、DI这四个寄存器可以用在方括号[]中进行内存单元的寻址。
对于寄存器BP,只要用到[BP]这种寻址,那么指令默认段地址在堆栈段寄存器SS中。
那么,机器指令在处理数据时,数据所在位置有哪些呢?
- CPU内部,比如
mov bx, ax
(把通用寄存器AX中的值送入BX)、mov bx, 1
(1是一个立即数,它事先存放在指令缓冲器中) - 内存中,比如
mov bx, [0]
- 端口中
转移指令
在8086中,CPU的转移指令可以分为:
- 无条件转移指令
- 条件转移指令
- 循环指令
- 子过程
- 中断
操作符offset
伪指令offset用于在汇编语言中取标号的偏移地址。
这里的标号,其实就是指示某一片地址的符号,比如
codesg segment
start:
...
codesg ends
end start
这里的start就是一个标号。offset start
即表示取出start这一行指令所在的偏移地址(比如后续可以将其存入基址寄存器BX等,配合代码段寄存器CS,定位内存)
datasg segment
array db 1, 2, 3, 4, 5
db 9, 8, 7, 6, 5
datasg ends
这其中的array也是一个标号。offset array
即表示取出array这一行的第一个变量的偏移地址。
JMP 指令
无条件转移指令。
段间转移
jmp far ptr 标号
有条件跳转指令
有条件转移指令都是短转移指令。
jcxz指令
格式:
jcxz 标号
如果(CX)=0,则转移到标号处执行。
call 和ret指令
CALL指令
必须有一个保存“断点”的操作,将CALL指令的下一条指令的段基址CS和偏移地址IP进栈保存(当然,若为段内调用,只需保存IP的值)
RET指令
返回断点。
- RET段内返回指令,将SP+1、SP中的值赋值给IP(指令指针,保存的是偏移地址),然后SP自增2;
- RETF段间返回指令,先将SP+1、SP中的值赋值给IP,同时SP自增2,再将SP+1、SP中的值赋值给CS(代码段基址寄存器)
子程序设计(模块化程序设计)
call和ret配合使用,可以实现子程序的执行:把call指令后面的指令地址存储在栈中,所以可以在子程序后面使用ret指令,用栈中的数据设置IP的值,从而转到call指令后面的代码处执行。
对于模块化程序设计,就需要根据实际问题,将原始问题拆分为多个子问题,从而用子程序分别实现。
那么,首先就会有一个问题:参数传递和结果传递问题。即,主程序的数据如何传递给子程序?子程序运行的结果又如何传递给主程序?
第一个方法,就是使用寄存器来存储参数,这也是最常使用的方法:调用者(比如主程序)将参数送入(参数)寄存器,从(结果)寄存器中取到返回值;而子程序从(参数)寄存器中取到参数,将返回值送入结果寄存器。
第一种方法的缺陷很明显。如果参数比较多的时候,不可能将它们都存入寄存器。第二种方法,就是使用内存传递参数。
第三种方法,也是最合适的方法,就是使用栈。