0%

《汇编语言》笔记

本文是我在大二的时候学习王爽老师的《汇编语言》所记录的笔记,里面包含我对每章的总结以及思考,最后还有一篇“读后感”总结全书。

第一章 基础知识

本章最重要的任务就是理解CPU与各个存储器芯片之间的数据读写是通过总线的。总线分为三种:

  1. 地址总线
  2. 数据总线
  3. 控制总线

然后,我们可以将各个存储器芯片整体看做一个逻辑存储器,不同的存储器对应着不同的地址区间。

第二章 寄存器

本章主要介绍了寄存器,寄存器主要起着存储操作码(指令)和数据的作用。

不同CPU的寄存器个数和结构是不一样的。

这一章主要讲了:

  • 通用寄存器如何存储数据。可以将16位通用寄存器分为两个8位寄存器。

  • 一个字由两个字节组成

  • 一些汇编指令
  • 8086CPU(16位CPU)如何寻址20位的地址
    • 将20位地址拆分为 段地址x16+偏移地址,段地址和偏移地址分别由两个寄存器保存并传输给地址加法器,形成20位物理地址
  • 内存中段的概念:将连续的内存人为划分成不同的段区,一个段的起始地址就成为段地址。
  • 段寄存器(以CS为例),指令指针寄存器(IP)
  • 修改CS、IP的指令:jmp
  • debug的使用
  • 汇编不区分大小写
  • 数据和指令没有本质区别,都是二进制数,区别在于你怎样解释它。8086CPU只认被CS:IP指向的内存单元中的内容为指令
  • 将一个16位寄存器拆分成两个8位寄存器用时,低8位寄存器溢出的数据并不会存在高8位中,而是存在另一个单独的寄存器中。
  • 只有位数相等的寄存器才能相互加减或移动数据
  • 同一个物理地址可以由不同的段地址x16+偏移地址构成
  • CPU会根据CS:IP的地址读取内存中的指令/数据,每读取一条完整的指令,IP就会自动增加以读取下一条指令

第三章 寄存器(内存访问)

字单元

内存中的单元是以字节为单位,存放一个字则需要两个连续的内存单元。

以哪一个地址为起始地址的字单元就称为几地址单元,例如:

一个以地址1为起始地址的字单元,它占用了1、2两个字节单元,我们称它为1地址字单元

DS和[address]

DS是另一个段寄存器,与CS不同的是,CS是代码段寄存器(指向要运行的指令的段地址),DS通常用来存放要访问数据的段地址。

8086并不支持将数据直接送入段寄存器(貌似是所有段寄存器),只能通过另一个寄存器/内存单元进行中转。

设置了段寄存器DS的值之后,就可以使用方括号[]来表示偏移地址了,这样就可以访问内存数据了,例如:

1
2
3
mov bx,1000H
mov ds,bx
mov al,[0]

前面两条指令先将ds设置为1000,然后用方括号表达式将1000:0地址处的数据移动到al中。

字的传送

如果在mov中给出的是16位寄存器的名字,一次就能传送16位数据了

1
2
3
mov ax,1000H
mov ds,ax
mov bx,[0]

最后一句就是将1000:0和1000:1这两个字节单元的数据复制到bx中,bx的高8位存放1000:1的数据,低8位存放1000:0的数据,其他情况可类比。

3.4 mov、add、sub指令

add、sub指令不能对段寄存器操作,即add ds,ax是错误的。但是可以对内存单元和通用寄存器操作。

3.5 数据段

数据段的长度是人为规定的,同时是可以变化的,关键在你怎么看。

3.6 栈

  • 两个基本操作:入栈、出栈
  • 特点LIFO(Last In First Out)

3.7 CPU提供的栈机制

  • 两个基本操作:入栈——push、出栈——pop
  • 两个地址寄存器:SS——段寄存器、SP——偏移地址寄存器
    • 任意时刻,SS:SP指向栈顶元素
  • 8086CPU的入栈和出栈操作都是以字为单位进行的,且数据的高8位总是对应高地址,低8位对应低地址

  • 8086CPU中栈是从高地址向低地址移动的(不知道其他CPU是不是一样),所以SP=SP-2就是指向栈顶上面的一个新的字单元

  • 8086CPU的入栈和出栈操作都分为两步

    Push:

    1. SP=SP-2
    2. 将输入送入SS:SP指向的字单元

    Pop:

    1. 将SS:SP指向的内存单元送出到某一个寄存器
    2. SP=SP+2

问题3.6

栈为空时,SS:SP指向的是栈底地址的SP+2,原理还是上面所说的,SS:SP是随着数据的入栈,由高地址移向低地址的。

数据出栈之后并不会被删除,仍然存在,但是下一次有数据入栈就会把他覆盖掉,这也就解释了为什么C++中的vector,pop之后再次将下标指向原来的那个,数据仍然存在且没有变化的现象。

3.8 栈顶超界的问题

入栈和出栈都存在越界的危险,且这种情况很可能覆盖掉栈外的其他程序的代码或数据,导致一系列错误,所以这是极度危险的。

但是8086CPU中并没有防止栈越界的机制,所以只能靠我们编程的时候注意了。

3.9 push、pop指令

3.10 栈段

  • 栈段也是我们编程时人为确定的,自己知道就好,计算机CPU并不会帮你设置一个栈段的标志,我们需要做的就是将SS:SP指向我们定义的栈段

  • 8086CPU一个栈段最大容量为64KB,因为偏移地址只有16位,SP减小到0000H就会循环回到FFFFH

段的综述

段可以分为三种:

  • 数据段——用来存放数据
  • 代码段——用来存放代码
  • 栈段——当做栈

计算机怎么区分这三种段呢?

就是通过不同的段寄存器所指向的位置来区分:

  • DS指向数据段的段地址,通过方括号[]来表示偏移地址
  • CS指向代码段的段地址,IP用于表示偏移地址
  • SS指向栈段的段地址,SP用于表示偏移地址

内存单元 寄存器中的二进制机器码本身既可以表示指令,又可以表示数据,关键在于怎么解释,而计算机就是通过上面这几个段寄存器和偏移地址寄存器来对二进制机器码进行解释的。

实验2 用机器指令和汇编指令编程

  • Debug的t命令在执行修改寄存器SS——栈的段寄存器之后,下一条指令会紧接着被执行,并不会暂停。这一点与中断机制有关,会在后半部分研究。

第四章 第一个程序

编写一个完整的汇编语言程序的流程是:

  1. 编写汇编程序代码文件,即源程序
  2. 对源程序进行编译连接,生成可执行文件

源程序

汇编语言的最简单的框架如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
assume cs:codesg

codesg segment

mov ax,0123h
mov bx,0456h
add ax,bx
add ax,ax

mov ax,4c00h
int 21h

codesg ends

end

其中

  • assume XX:XXX

  • XXX segment

    XXX ends

  • end

都是伪指令,由编译器执行。

顾名思义,segment的作用就是定义一个段,例子中的段名称是codesg(这种人为定义的名称称为标号,这个标号最终会被编译、连接程序处理成一个段的段地址),assume用来指明某一个寄存器与哪一个段相关联,ends和end都是结束标志。

真正被翻译为机器码的是其中的汇编指令,也就是segment部分里的代码。

编译、连接

在dos系统中,用masm程序把源程序编译,生成包含机器码的目标文件,然后用连接程序link连接目标程序,声称可执行文件。

运行可执行文件

在DOS系统中,一个程序P2要被运行,它必须被一个正在运行的程序P1加载入内存,然后把CPU的控制权交给P2运行过程中,P1暂停运行,P2运行完毕后,P1重新获得CPU的控制权。

首先我们要知道在系统中是哪个程序(P1)将P2载入内存的。

  • 如果是直接在命令行执行可执行程序,那么这个P1就是命令行程序——command.com,称为命令行解释器,也是DOS系统的shell程序。
  • 如果是通过其他程序执行的,例如debug 1.exe,那么P1就是debug程序。

(那么问题来了!是哪个程序把command.com装入内存的呢?操作系统吗?那么操作系统是如何被装载入内存的呢?这个问题可以自行查找)

P2运行到最后的时候,怎么判断程序是否结束呢?

这时候就要有一个标志返回的代码,上面的例子中mov ax,4c00hint 21h,就是用来实现返回的,我们这里不过多地讨论原理,只要知道这两句代码的意义就行。

debug调试一个程序时最后的int 21h指令要用p命令执行

P2被加载到内存中的过程

  1. 找到一段偏移地址为0的地址,SA:0000
  2. 从这个地址开始,创建一个称为程序段前缀(PSP)的数据区,大小为256字节(16进制表示就是100h),DOS会利用这个PSP来和被加载的程序进行通信。
  3. SA+10h:0地址处开始加载程序
  4. 将SA的值赋给DS寄存器(数据段寄存器),将SA+10h:0赋值给CS:IP这两个寄存器。

第五章 [BX]和loop指令

[BX]

在debug程序中,我们可以直接使用:mov ax,[0],将DS:0地址处的数据送入ax寄存器中,但是我们在编写源程序时,若也写成上述形式,则会被编译器解释为mov ax,0,若要想达到同样的目的,有几种方法可以实现:

  • 我们可以使用一个寄存器bx来保存偏移地址,然后mov ax,[bx],这样编译后就没问题了,相当于是把bx当做一个变量。
  • mov ax,ds:[0],这样也能正确的被编译器理解。
  • mov ax,ds:[bx],同上。

loop

loop就是循环,基本框架如下:

1
2
3
	mov cx,3
s:add ax,ax
loop s

其中s是一个标号,在汇编语言中,标号就代表了一个地址,这里的s代表的是存放add ax,ax这个指令的地址。

汇编语言中循环次数通过寄存器cx的值来控制,每次执行loop s这个语句的时候,会先将cx的值减一,然后判断cx是否为0,若cx的值为0,就停止循环,执行loop之后的指令,若不为零,则跳到s所标志的地址处。

两个“新的”DEBUG命令

  • g命令

    g 偏移地址,这个指令会自动执行程序指导ip指向目标的偏移地址。在我们不想单步运行,想直接快速运行到一个语句的时候就可以用这个命令。

  • p命令

    在遇到loop指令的时候,可以用这个指令直接一次运行到loop结束的时候,在循环次数比较多的时候可以使用。

loop和[bx]联合应用

这里就是把bx当做一个循环里的变量,每循环一次bx会改变,以指向不同的地址,学过一些高级语言(例如c语言)的同学应该很好理解这个。

段前缀

除了直接mov ax,[bx],这样会默认把ds的值当做段地址,我们还可以显示地写出,例如:mov ax,cs:[bx]

这是把寄存器cs中的值当做段地址。

利用这个特性,我们可以方便的操作一些跨度比较大的地址了——通过改变段前缀的值。

一段安全的空间

一般来说,直接向一段未知的内存空间写入内容是很危险的,因为其中可能存放着重要的系统数据或代码。

例如,向0000:0026h写入数据会引起程序崩溃和死机。

DOS系统中,系统和其他合法程序一般都不会使用0:200~0:2ff这段256字节的空间,所以,我们使用这段空间是安全的。

实验4

  • cx寄存器存放的是程序的字节多少,不包含最后的mov ax,4c00hint 21h两句。

第六章 包含多个段的程序

程序获取内存空间有两种方法:

  1. 在加载程序的时候为程序分配
  2. 在执行的过程中向系统申请

这里我们只讨论第一种。

定义数据

我们可以在一个段中定义一些“常量数据”,用dw关键字来定义字类型数据,例如:dw 0123h,0456h,0789h

如果我们把这句话写在只包含一个段的源程序的开头,例如:

1
2
3
4
5
6
assume ...

code segment
dw 0123h,0456h,0789h
...
code ends

那么在该代码段的开头的三个字大小的空间,也就是我们所定义的三个字类型的数据,会被解释为该机器码对应的汇编指令,我们在debug程序中用u指令可以看到这些机器码对应一些“奇怪的汇编指令”。

为了使只被当做数据区域,我们需要定义一个标号,来告诉编译器真正的汇编指令开始的地址在哪,然后最后的end后要加上该标号的名字以对应,例如:

1
2
3
4
5
6
7
8
assume ...
code segment
dw ...
start: ...

code ends

end start

其中的start就是那个标号。编译之后,CS:IP就会指向start所指的地址了,从该处开始执行汇编指令。

在段中使用栈

我们还可以在段中使用栈,方法就是用dw定义一些0数据,以达到申请内存空间的目的:dw 0,0,0,0,0。然后可以通过将SS:SP指向最后一个0后面的一个字的地址(如果忘了为什么是这样,请回顾第三章的栈部分)来把这段空间当做栈使用。

使用多个栈

目前我们这样还只是把数据和栈还有汇编指令都放在一个段中,这样做可能会给以后造成麻烦,结构不清晰。我们可以使用多个段来分别存储数据、代码和栈,一个例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
assume cs:code,ds:data,ss:stack

data segment
dw ...
data ends

stack segment
dw 0,0,0,...
stack ends

code segment
start:...
code ends

end start

声明多个段的方法很好理解(如上)。

值得注意的是,如果我们将代码部分的段不是放在第一个,例如上面是放在最后一个。那么end后就必须要加上start来指明程序的入口,如果不加的话,CS:IP就会默认指向第一个段的起始地址,这就会把数据,或者其他段的机器码解释为汇编指令从而造成错误。如果代码部分的段是放在第一个,那么最后的end后的标号可以去掉,不过还是建议写上以获得更清晰的逻辑。

第七章 更灵活的定位内存地址的方法

and、or指令和定义字符数据

这两个指令分别是按位与和按位或。按位与可以用来把特定的某一位变成0,按位或可以把特定的某一位变成1。这个特性可以方便地实现一些操作,比如大小写字母转换的操作,因为由观察可以得到,对应的大小写字母的二进制码只有第五位(以第零位开头)是不一样的,大写字母是0,小写字母是1,这样就可以利用按位与和按位或来实现转换了,并且不用判断原来的字母是大写还是小写。

用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
assume ...
datasg segment
db 'd'
datasg ends

code segment
start: mov ax,datasg
mov ds,ax
mov bx,0
mov al,[bx]
and al,11011111B
mov [bx],al

mov ax,4c00h
int 21h
code ends
end start

上述的代码就是把字母d转换成大写字母,db '...'就是定义字符数据(也就是一个字节)的命令(用任意字符替代…),and al,11011111B就是按位与的语法,按位或是or ...,...

更灵活的寻址方式

  1. [idata],用一个常量来表示偏移地址
  2. [bx],用一个变量来表示偏移地址
  3. [bx+idata],用一个变量和一个常量来表示偏移地址
  4. [bx+si],用两个变量来表示偏移地址
  5. [bx+si+idata],用两个变量加一个常量来表示偏移地址

其中si是一个和bx作用差不多的寄存器,另外还有一个是di,也能如此利用。

第3种方法还有其他形式的写法:[idata+bx]idata[bx][bx].200,其中第二个形式与一些高级语言(例如C语言)中的数组形式类似,idata可类比为数组名。

  • 利用变量来表示偏移+常量地址,我们可以更方便地干更多的事了,例如要处理一些“二维数组”的问题。

在这一章中,作者还沁出了二重循环的问题,由于loop循环默认只能使用cx作为循环计数器,所以存在内外两层循环重复使用的问题,会造成一些逻辑bug,这时我们就要想办法了。

  1. 可以将外部的cx值先保存在一个寄存器中,待内部循环完成后将寄存器中的值重新取出

    这个方法可能在更复杂的编程中会遇到寄存器不够用的问题

  2. 我们还能单独申请一个内存空间用于存放cx的值

    这样我们需要记住在哪个内存单元中存放的是哪个循环的cx值

  3. 最后我们可以申请一个栈段的空间来专门保存暂存值

    这样就比较清晰地将数据和暂存数据分开,并且操作方便了

具体例子请看书上P155-158

第八章 数据处理的两个基本问题

1. 处理的数据在哪?

第一个问题的答案就是地址,前面的章节中介绍的各种寻址方式就可以用来确定数据所在的地址。

这里还介绍了一个新的寄存器bp,用处于bx相同,不能与bp同时使用,但可以用来替换bx,例如:[bp+si+idata]如果没有显性地给出段地址,包含bp的偏移地址的默认段地址就是ss。

指令在执行前,所要处理的数据可以在三个地方:1. CPU内部——即寄存器、2. 内存、3. 端口(后面介绍)

2. 要处理的数据有多长?

第二个问题的答案,汇编语言中有三种方法来确定(8086CPU中能处理两种长度的数据:字节byte和字word):

  1. 通过寄存器名来确定

因为汇编语言指令所处理的数据必须等长,因此只要处理的数据中出现了某一个寄存器名,那么该指令处理的数据长度就是该寄存器的长度,例如:mov ax,1,这里ax是一个16位寄存器,因此后面的1也是一个16位的数据;mov ax,ds:[0],同样,这里处理的ds:[0]是处理ds:[0]和ds:[1]这两个字节的数据;mov al,1,这里al是一个8位寄存器,那么1代表的就是一个8位数。

  1. 在没有寄存器的情况下,用操作符X ptr指明内存单元的长度(X代表内存长度)

X ptr能定义两种长度:byte和word,例如:

mov word ptr ds:[0],1

mov byte ptr ds:[0],1

在上面两个就是分别指明了word和byte长度。

  1. 默认

有一些指令有默认的处理长度,例如push [1000h]就不用指明是字单元还是字节单元,因为push指令只进行字操作。

div指令

div是除法指令。语法:div regdiv 内存单元

div后面紧跟的是除数,被除数默认放在AX或AX和DX中,如果除数为8位,则被除数位16位,默认放在AX中,如果除数为16位,被除数为32位,默认存放在DX和AX中,DX存放高16位,AX存放低16位。

如果除数为8位,执行div指令后,AL的内容位为除法操作的商,AH的内容位除法操作的余数;如果除数为16位,则AX存储除法操作的商,DX存储除法操作的余数。

伪指令dd

dd类似db和dw,dd是define double word的缩写,即定义双字型数据。

dup

dup(duplicate)用来配合db、dw、dd等伪指令来实现数据的重复定义,例如:

db 3 dup (0),定义3个字节,值都为0,相当于db 0,0,0

db 3 dup (0,1,2),定义了9个字节,相当于db 0,1,2,0,1,2,0,1,2

第九章 转移指令的原理

可以修改IP,或者同时修改CS和IP的指令统称为转移指令

操作符offset

offset在汇编语言中是由便一起处理的符号,他的功能是取得标号的偏移地址,例如:

1
2
3
4
5
6
7
8
assume cs:codesg
codesg segment

start: mov ax,offset start ;这里就是取得start的偏移地址,也就是0,送入ax中
s: mov ax,offset s ;这里是取得s的偏移地址3送入ax中

codesg ends
end start

jmp指令

jmp是无条件转移指令,可以只修改ip,也能同时修改cs和ip。

根据jmp指令的原理来分类,可以分为两种:

  1. 指令中存在的是相对位移的jmp指令
  2. 指令中存在的是绝对地址的jmp指令

1. 依据位移进行转移的jmp指令

这中根据相对偏移地址来转移的jmp指令有两种形式:

  1. jmp short 标号
  2. jmp near ptr 标号

他们之间的不同是存放相对位移的位数不同,short用8位补码来存放位移,因此所表示的范围是-128~127,

near是用16位补码来存放位移,所表示的范围是-32768~32767。

值得注意的是,这里的相对偏移地址是指在CPU读取jmp指令后,(IP)=(IP)+所读取的指令长度后,(IP)与标号所表示的偏移地址的差,由于依据位移进行转移的jmp指令长度为2字节,因此上述公式一般为(IP)=(IP)+2。

同时,这个相对位移是在程序编译的时候算出来的

2. 依据绝对地址转移的jmp指令

依据绝对地址进行转移的jmp指令又分为3种

  1. 根据标号所在的绝对地址来修改CS:IP
  2. 转移地址在寄存器中的jmp指令
  3. 转移地址在内存中的jmp指令
第一种指令的格式:jmp far ptr 标号

其中“far ptr”就指明了用标号所在的段地址和偏移地址修改CS:IP。转换成机器码之后,占用了5个字节,后面四个字节中,低字节所存储的是标号的偏移地址,高字节存储的段地址。

第二种指令的格式:jmp 16位reg

这个指令就是直接将一个16位寄存器中的值赋给IP,例如:jmp ax就相当于mov ip,ax

这个指令在第二章中讲到过

第三种指令的格式:jmp X ptr 内存单元地址

这里的X可以换成word或dword,内存单元地址可以用之前所讲的任何一种形式表示。下面以实例讲解:

1
2
3
mov ax,0123h
mov ds:[0],ax
jmp word ptr ds:[0]

这些指令就是将ds:[0]的值赋给IP,达到跳转的目的。

1
2
3
4
mov ax,0123h
mov ds:[0],ax
mov word ptr ds:[2],0
jmp dword ptr ds:[0]

jmp dword ptr ds:[0]就是将起始地址为ds:[0]的两个字的数值分别赋值给CS和IP,高地址的字赋给CS,低地址的赋给IP,也就是(CS)=(ds:[2]),(IP)=(ds:[0])

jcxz指令

jcxz指令是一个条件转移指令,所有的条件转移指令都是短转移,同时对应的机器码中只有位移,而不是绝对地址,也就是只有8位用来存储位移,表示范围为-128~127。

格式:jcxz 标号

功能:如果(cx)==0,则执行转移,否则什么也不做,用高级语言来表示的话,如下:

if((cx)==0) jmp short 标号;

loop指令

loop一般称为循环指令,但实质上也是一种条件转移指令,不过loop在判断cx的值之前要将cx的值减一,同时与jcxz的判断条件相反:如果(cx)==0,则什么也不做,否则转移至标号。

用高级语言来描述则是:

1
2
3
(cx)--;
if((cx)!=0)
jmp short 标号;

根据位移进行转移的意义

前面所讲的:

  1. jmp short 标号
  2. jmp near ptr 标号
  3. jcxz 标号
  4. loop 标号

都是根据位移进行转移的,这种根据相对地址来转移的好处在于不用关心每次运行程序时,程序具体被装在在哪段内存地址。

转移位移超界

编译器会对依据位移进行转移的指令进行检测,如果位移的范围超过了其能表示的范围,那么编译器就会报错。

1
2
3
4
5
6
7
assume cs:code
code segment
start:jmp short s
db 128 dup (0)
s:mov ax,0ffffh
code ends
end start

上面这段程序在编译的时候就会报错,因为转移的位移超过了127,如果将short改成near ptr就没有问题了。

第十章 CALL和RET指令

这一章所讲的内容是非常重要的,它对理解高级语言中的函数调用有着重要的意义!

1. “调用”和“返回”

我们首先来介绍一下调用子程序中最重要的两个指令:CALLRET

CALL

call在英语中有调用的意思,所以也就不难猜到它的作用了,就是“调用”,但是它的基本原理还是转移指令。总体来说执行call指令相当于进行两步操作:

1)将当前的IP或CS和IP压入栈中;

2)转移

与上一章的转移指令一样,它也有多种形式:

  1. call 标号

当执行这个命令的时候,CPU进行了两步操作:

​ 1)将当前的IP压入栈中;

​ 1)依据16位位移转移至标号的偏移地址处

相当于执行了以下两句指令:

1
2
push IP
jmp near ptr 标号
  1. call far ptr 标号

这个指令实现的是段间转移,总体操作还是那两个步骤,下面直接用学过的汇编语句来解释更为清晰:

1
2
3
push CS
push IP
jmp far ptr 标号
  1. call 16位reg

相当于:

1
2
push IP
jmp 16位reg ;也就是mov IP,reg
  1. call X ptr 内存单元地址

X可替换为word和dword。

call word ptr 内存单元地址相当于:

1
2
push IP
jmp word ptr 内存单元地址

call dword ptr 内存单元地址相当于:

1
2
3
push CS
push IP
jmp dword ptr 内存单元地址

RET

ret可以理解为return的缩写,它有两种形式,一个是ret,一个是retf(相当于ret far?)

执行ret指令时,相当于进行pop IP,执行retf指令时,相当于先进行pop IP 然后pop CS

2. mul指令

这里我们插入一个mul指令,为以后些更复杂的程序做准备。

mul是multiply的缩写,指令后面跟的参数只有1个:mul regmul 内存单元

如果后面的reg或内存单元是8位的,那么另一个乘数默认存放在al中,计算的结果默认存放在ax中;如果是16位,另一个乘数默认存放在ax中,计算结果的高16位存放在dx中,低16位存放在ax中。

一个小例子:

1
2
3
4
;计算100*10
mov al,100
mov bl,10
mul bl

3. 子程序框架

学习了CALL和IP后,我们就可以知道写一个子程序并调用的框架是什么了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
assume cs:code
code segment
main: ...
call sub1
...
mov ax,4c00h
int 21h

sub1: ...
call sub2
...
ret

sub2: ...
...
ret
code ends
end main

4. 参数和结果的传递

在高级语言中调用函数我们有参数和结果的传递,那么在底层是如何实现的呢?

1)可以通过把参数和结果存放在寄存器中来传递参数。但这种方法只适用于参数和结果比较少的情况,因为CPU内寄存器数量有限。

2)当需要传递的参数和结果很多的时候,我们可以将参数放到内存中,然后传输这组参数的首地址,就能通过定位内存地址的方法来处理所传参数了,这对传入参数和返回结果都适用。

5. 寄存器冲突问题

当主程序和子程序中都用到同一个寄存器(例如cx)时,那么运行时就会存在一些“意想不到”的问题。

之前在多重循环中解决类似问题的方法是将cx压入栈,再取出来,我们这里也能借鉴这种方法。

这里我们设计了一个更好的、更保险的方法:在子程序开始之前将子程序中所用到的所有寄存器的内容都保存在栈中,在子程序返回之前再恢复(这刚好应证了我前几天在《算法竞赛入门经典》中看到的函数调用栈部分)。

一个子程序的例子:

1
2
3
4
5
6
7
8
9
10
11
12
sub1: 	push cx
push si ;假设在子程序中只要用到cx和si这两个寄存器

change: ...
...
jcxz ok
...
...

ok: pop si
pop cx ;注意入栈和出栈的顺序
ret

做实验10遇到的坑(犯的2)!!!

1.显示字符串

我把初始化di(列位移)放在了change内部导致最终结果只显示最后一个字符。

2.除法溢出

没大问题,理清思路就行

3.数值显示

我写这个子程序时一开始是用的8位除法,结果没有考虑到317AH(12666)除以AH(10)的结果16进制超过了2位(4F2H),导致div cl溢出,然后出现中断iret,但是我一开始并没有意识到这是中断……然后按照网上的改成了16位的除数,没有在每次除法过后清零dx,仍然是溢出的问题…..最后对比网上的代码终于发现漏了mov dx,0这一句……心累/(ㄒoㄒ)/~~

第十一章 标志寄存器

标志寄存器是一种比较特殊的寄存器,它有三种作用:

  1. 用来存储相关指令的某些执行结果(这些相关指令大多是运算指令,例如:add、sub、mul、div、inc、or、and等,但这些指令也不是对所有的标志位都有影响,而mov、push、pop等传送指令则对标志寄存器没有影响)
  2. 用来为CPU执行相关指令提供行为依据
  3. 用来控制CPU的相关工作方式

8086CPU中的标志寄存器是flag,flag的一些位在8086中没有用,不具有任何含义(具体见书P213页)。下面简要说明一下那些有意义的寄存器的作用:

  1. ZF(Zero Flag)标志——flag的第6位,零标志位。它记录相关指令执行后,其结果是否为0,如果为零,则ZF=1,否则ZF=0。

  2. PF标志——flag的第2位,奇偶标志位。它记录相关指令执行后,其结果的所有bit位中1的个数是否为偶数,如果为偶数,PF=1,否则PF=0

  3. SF(Sign Flag)标志——flag的第7位,符号标志位。它记录在进行有符号数的相关运算后,其结果是否为负,如果为负,SF=1,否则SF=0
  4. CF(Carry Flag)标志——flag的第0位,进位标志位。它记录在进行无符号数运算的时候,运算结果的最高有效位向更高位的进位值,或从更高位的借位值
  5. OF(Overflow Flag)标志——flag的第11位,溢出标志位。它记录有符号数运算的结果是否发生溢出

值得注意的是,3、4、5中所说的有符号数和无符号数,都是在判断的时候将所操作的二进制数据解释为有符号或无符号,二进制数本身并不代表有符号或者无符号。

adc指令

带进位加法指令,与add指令相比,adc指令还会加上CF位上的值。例如:adc ax,bx就相当于(ax)=(ax)+(bx)+CF

sbb指令

带借位减法指令,与sub指令相比,sbb指令还会减去CF位上的值。例如:sub ax,bx相当于(ax)=(ax)-(bx)-CF

cmp指令

格式:cmp ax,bx

功能:比较ax,bx的大小,相当于减法指令sub ax,bx,但是不保存结果,只会对相关标志寄存器产生影响。

一个例子:

1
2
3
mov ax,8
mov bx,3
cmp ax,bx

执行后:(ax)=8,zf=0,pf=1,sf=0,cf=0,of=0。

cmp能对有符号数和无符号数进行判断,但是与之前所说的一样,二进制本身并没有有符号或者无符号的意义,关键是在于你如何解释。cmp在进行减法运算的时候按照的是无符号数,可以借位,解释的时候可以解释为有符号数的补码。

要注意的是有时候用cmp来影响sf的值来判断有符号数的大小关系可能并不会得到我们期望的结果,因为对于有符号数的计算来说,还是按照二进制的减法来进行的(CPU的加减法都是按照二进制加减法来执行的),但在最后解释为有符号数,可能会发生溢出。例如:

1
2
3
mov ah,22h
mov bh,0A0h
cmp ah,bh

我们来看一下每个步骤:

  1. 把22h(00100010)送入ah中。
  2. 把0A0h(10100000)送入bh中。
  3. 计算0010,0010-1010,0000,由于前面的数字小于后面的,会向最高位的前一位假借一位,于是得到结果1000,0010。解释为无符号数的话就是130,解释为有符号数(补码)的话就是-126。

而sf记录的是有符号数的符号,因此sf=1,但是如果把这个理解为有符号数的减法时,22h是34,0A0h是-96,相减得到结果是130,超过了8位无符号数的表示范围。我们的理想结果是sf=0,而结果是1,由此可以得出如果用cmp来比较有符号数时,只看sf的值是不准确的,还应结合of溢出标志位来看。如果of=1,那么逻辑上的cmp结果与实际的sf的值相反。

检测比较结果的条件转移指令

所有的8086条件转移指令的转移位移都是[-128,127]。

下面列出一些其他的条件转移指令:

指令 含义 检测的相关标志位
je 等于则转移 zf=1
jne 不等于则转移 zf=0
jb 低于则转移 cf=1
jnb 不低于则转移 cf=0
ja 高于则转移 cf=0且zf=0
jna 不高于则转移 cf=1或zf=1

这些检测结果的条件转移指令与cmp配合来使用可以。例如:

1
2
3
4
5
cmp ah,bh
je s
...
...
s:add ah,ah

上面代码的逻辑就是如果ah和bh相等,则跳转到s标号处。

DF标志和串传送指令

串传送指令:zhong

格式:movsbmovsw

功能:将内存ds:[si]的字节/字(b是byte,w是word)传送到es:[di]处,然后si和di的值根据DF标志位的值递增一或递减一。

flag的第10位是DF,方向标志位。如果df=0,每次串传送指令操作后si、di递增,如果df=1,si、di递减。

用汇编语法描述movsw的功能如下:

1
2
3
4
5
6
7
8
mov es:[di],word ptr ds:[si]	;8086并不支持这样的指令,这里只是个描述
;如果df=0
add si,2
add di,2

;如果df=1
sub si,2
sub di,2

另外再介绍一个与串传送指令配合使用的指令:rep,格式:rep movsb,相当于:

1
2
s:movsb
loop s

只要赋给cx合适的值,就能把一段特定的字符串复制到另一段目标地址中。

由于df的作用,8086提供了两个指令来对df的值来进行设置:

  1. cld(clean df):将df置0
  2. std(set df):将df置1

pushf和popf

pushf是将标志寄存器的值压入栈,popf是从栈中弹出数据,送入标志寄存器中。

第十二章 内中断

每个CPU在执行完当前正在执行的指令时,都会检测CPU内部和外部是否有中断信息传来,如果有,则停止继续执行后面指令,转而去处理接收到的信息。

中断分为来自CPU内部和外部,分别称作内中断和外中断,本章节主要讨论内中断。

中断的产生

CPU内部会在以下四种情况时产生的中断:

  1. 除法错误,例如,div指令产生除法溢出
  2. 单步执行
  3. 执行into指令
  4. 执行int指令

CPU要知道中断信息是来源于哪种情况,所以中断信息中有一个字节(中断类型码)用来标识中断信息的来源(最多可以表示256种)。上述四种中断源的中断码如下:

  1. 除法错误:0
  2. 单步执行:1
  3. 执行into指令:4
  4. 执行int指令,该指令的格式为int n,指令中的n为字节型立即数,是提供给CPU的中断码类型。

中断处理程序

CPU收到中断信息后会跳转到相应的中断处理程序,即更改CS:IP使其指向中断处理程序的入口。那么如何根据中断类型码来判断中断相应的处理程序在哪呢?就是下面要说的中断向量表

中断向量表

中断向量就存放着中断处理程序的入口地址,中断向量表按照顺序从0号递增排序,存放在内存中的某一段。CPU只需根据中断类型码就能找到相应的中断处理程序的入口地址。

在8086CPU中,中断向量表存放在内存地址0处,从0000:0000到0000:03FF的1024个内存单元都存放着中断向量,并且,中断向量只能存放在这一段空间。

每一个中断向量对应一个中断处理程序的段地址和偏移地址,也就是4字节,因此1024个内存单元最多能存放256个中断向量,低地址存放偏移地址,高地址存放段地址。

中断过程

8086CPU在收到中断信息之后,会发生以下过程:

  1. 从中断信息中获取中断类型码
  2. 将标志寄存器的值压入栈
  3. 设置标志寄存器的第8位TF和第9位IF的值为0
  4. CS的值入栈
  5. IP的值入栈
  6. 从内存地址为中断类型码4和中断类型码4+2 的两个字单元中读取中断处理程序的入口地址设置IP和CS

中断处理程序和iret指令

iret是配合中断处理程序使用的返回指令,它比ret指令多了一步popf操作:

  1. pop IP
  2. pop CS
  3. popf

中断处理程序的内部操作如下:

  1. 保存用到的寄存器
  2. 处理中断
  3. 恢复用到的寄存器
  4. 用iret指令返回

自定义中断处理程序

具体的自定义中断处理程序的步骤如下:

  1. 编写好中断处理程序的逻辑代码
  2. 将中断处理程序的代码送入一段安全的内存区域
  3. 设置中断向量,使之指向你存放中断处理程序的内存

在第1步的时候,最好将中断处理程序所要用到的数据写在中断处理程序内部的最开始,并且在第2步的时候将他们一起送入一段安全的内存区域,例如:0:200h处shenrulijie

单步中断

CPU在执行完一条指令后,会检测TF的值,如果TF=1,则会产生单步中断,引发中断过程。单步中断的中断类型码为1。

我们之前在debug程序中用t指令来单步运行的时候,debug会将TF设为1,使CPU工作于单步中断方式下,在CPU执行完这条指令后就引发单步中断,由之前使用t指令的现象可以猜到,单步中断对应的中断处理程序就是用来显示各个寄存器的值的。

为了避免执行单步中断处理程序的时候TF的值为1从而又执行单步中断处理程序的死循环,CPU会在获取中断类型码之后将TF、IF设为0。

中断响应的特殊情况

在前面的编程体验中,我们观察到在将一个寄存器的值送入ss之后,t指令现实的并不是紧跟的下一条将一个值送入sp的指令,而是再下一条指令。这是其实是发生了中断,但是CPU并没有响应,这是因为,如果在改变了ss的值之后执行中断,那么就要将标志寄存器和CS、IP的值压入栈,但是此时sp并没有改变,所以有极大的可能将这些值压入到错误的栈,然后导致一系列错误。因此,我们要让设置sp的指令紧跟设置ss的指令,CPU也不会在中途引发中断过程。

第十三章 int指令

在前一章提到过,int指令会引发中断,我们之前所写的所有程序最后都有两句:mov ax,4c00hint 21h

我们在这一章就可以解释了这两条指令所做的操作了。

首先int指令的格式就是int n,n是一个立即数,其含义是中断类型码,执行完这条指令后CPU就会执行对应中断类型码的中断处理程序,例如,如果执行了int 0这条指令,即使没有出现除法错误的情况,CPU也会去执行处理除法错误的中断处理程序。

由此可见,int指令和call指令的功能相似,都是调用一段程序。

一般而言,系统会将一些有特定功能的子程序以中断处理程序的形式供给应用程序调用,我们可以在编程时使用int指令直接调用,我们也能自己编写中断处理程序供给别人使用。以后,我们将中断处理程序简称为中断例程

那么int指令前面的那一条指令的作用是什么?

这是因为在一段中断例程中包含多个具有不同功能的子程序,如何选择执行哪个子程序?就要通过设置各种寄存器的值来传递参数,例如21h号中断例程,通过设置ah的值来选择子程序,4ch号子程序的功能室程序返回,然后通过al来设置程序返回值,因此mov ax,4c00h就是调用4c号子程序来使程序返回,并设置返回值为0。树上的13.6和13.7小结讲解了几个调用BIOS和DOS提供的中断例程的例子,具体请君自行看书~

编写供应用程序调用的中断例程

步骤与前一章自定义中断处理程序的步骤一致。不过在设置中断向量表的时候要注意将中断向量设置到没有被占用的内存区中(书上例子是保存在7ch表项)。

对int、iret和栈的深入理解

这一小节中,书上自定义了一个与loop具有相同功能的中断例程,存放在7ch处。

有几个关键点:

  1. 如何获取要转移的位移?

    书上的解决方案是在int指令后加一个结束标号,然后用起始标号减去结束标号,差值存放在bx中,传递给中断例程。

  2. 如何设置CS:IP?

    如果直接在子程序中间设置CS:IP,那么在iret返回主程序之后,CS:IP的值会被从栈中弹出的值覆盖掉,于是我们可以利用CPU执行中断例程前的操作和iret指令的特点来设置:直接修改栈中的值,然后iret返回操作时直接将CS:IP的值设置为我们想要的值。

BIOS和DOS所提供的中断例程

BIOS(Basic Input Output System)存放于系统板ROM,主要有以下内容:

  1. 硬件系统的检测和初始化程序
  2. 外部中断(15章讲解)和内部中断的中断例程
  3. 用于对硬件设备进行I/O操作的中断例程
  4. 其他和硬件系统相关的中断例程

操作系统DOS也提供了中断例程,从操作系统的角度来看,DOS的中断例程就是操作系统向程序员提供的编程资源。

我们可以用int指令直接调用BIOS和DOS提供的中断例程。

和硬件设备相关的DOS中断例程中,一般都调用了BIOS的中断例程。

BIOS和DOS中断例程的安装过程

  1. 开机后,CPU一家店,初始化(CS)=0FFFFH,(IP)=0,自动从FFFF:0单元开始执行程序。FFFF:0处有一条跳转指令,CPU执行该指令后,转去执行BIOS中的硬件系统检测和初始化程序。
  2. 初始化程序将建立BIOS所支持的中断向量,即将BIOS提供的中断例程的入口地址登记在中断向量表中。注意,对于BIOS所提供的中断例程,只需将入口地址登记在中断向量表中即可,因为他们是固化到ROM中的程序,一直在内存中存在。
  3. 硬件系统检测和初始化完成后,调用int 19h进行操作系统的引导。从此将计算机交由操作系统控制。
  4. DOS启动后,除完成其他工作外,还将他所提供的中断例程装入内存,并建立相应的中断向量。

检测点13.2

  1. 我们不能改变FFFF:0处的指令,应为这些是固化在ROM中的
  2. int 19h中断例程不能由DOS提供,因为DOS是由int 19h例程引导之后才启动的

第十四章 端口

在PC机中,和CPU通过总线相连的芯片除了各种存储器外,还有以下3中芯片。

  1. 各种接口卡(比如,网卡、显卡)上的接口芯片,它们控制接口卡进行工作;
  2. 主板上的接口芯片,CPU通过它们对部分外设进行访问;
  3. 其他芯片,用来存储相关的系统信息,或进行相关的输入输出处理。

这些芯片中,都有一组可以由CPU读写的寄存器。这些寄存器,他们在物理上可能处于不同的芯片中,但是他们可以在以下两点上相同。

  1. 都和CPU的总线相连,当然这种连接是通过他们所在的芯片进行的。
  2. CPU对他们进行读或写的时候都通过控制线向他们所在的芯片发出端口读写命令。

可见,从CPU的角度,将这些寄存器都当做端口,对他们进行统一编址,从而建立了一个统一的端口地址空间。每个端口在地址空间中都有一个地址。

端口的读写

在PC系统中,CPU最多可以定位64KB个不同的端口。则端口地址的范围为0~65535。对端口的读写不能通过mov、push、pop等命令,而只能使用intout,分别用于从端口读取数据和往端口写入数据。例子:in al,60hout 20h,al。注意:在in和out指令中,只能使用ax或al来存放从端口中读入的数据或要发送到端口中的数据。访问8位端口时用al,访问16位端口时用ax

对0~255以内的端口进行读写时:

1
2
in al,20h	;从20h端口读入一个字节
out 20h,al ;往20h端口写入一个字节

对256~65535的端口进行读写时,端口号放在dx中:

1
2
3
mov dx,3f8h
in al,dx
out dx,al

对端口地址的个人理解:8086的端口地址与内存地址在数字上可以重复,因此对端口的操作局需要in和out两个专门的指令来指明我们是在读写端口,而不是在读写内存。还有另一种端口编址就是与内存地址不重复,这样就可以不需要单独的指令了。

以上两种编址方式分别为:独立编址和统一编址,请君自行搜索了解。

CMOS RAM芯片

该芯片的一个特点是:

该芯片内部有2个端口,端口地址为70h和71h。CPU通过这两个端口来读写CMOS RAM。

其中70h为地址端口,存放要访问的CMOS RAM单元的地址;71h为数据端口,存放从选定的CMOS RAM单元中读取的数据或要写入到其中的数据。可见,CPU对CMOS RAM的读写分两步进行,比如,读CMOS RAM的2号单元:

1
2
out 70h,2	;将2送入端口70h
in al,71h ;从端口71h读出2号单元的内容

其余特点请见书~

shl和shr指令

shl和shr指令是逻辑位移指令。

shl是逻辑左移,它的功能为:

  1. 将一个寄存器或内存单元中的数据向左移位;
  2. 将最后移除的一位写入CF中;
  3. 最低位用0补充。

shr是逻辑右移,与shl的功能仅仅是方向不同,高位用0补充。

有二进制基础的同学应该知道左移和右移的数学意义就是乘以2和除以2

实例:

1
2
3
4
5
mov al,01001000b
shl al,1 ;将al中的数据左移一位

mov cl,3 ;如果移动位数大于1,必须将移动位数放在cl中
shr al,cl

CMOS RAM中存储的时间信息

CMOS RAM中以BCD码的形式存放着当前的时间。

第十五章 外中断

之前已经说来自于CPU外部的中断称为外中断,外中断的来源就是CPU外部的芯片(各种外设)。

接口芯片和端口

CPU对外设的读写、控制是通过接口芯片的端口来连接的:读写数据以及控制命令都是CPU发送到相关芯片的端口中,然后再由相关的芯片根据命令对外设实施读写、控制。

外中断信息

外部中断信息是由外部相关芯片发送到CPU的。

外中断源一共有以下两类:

  1. 可屏蔽中断
  2. 不可屏蔽中断

可屏蔽中断

可屏蔽中断是CPU可以不响应的外中断。CPU是否响应可屏蔽中断,要看标志寄存器的IF位的设置。如果IF=1,则CPU会响应中断,如果IF=0则不会响应。

之前我们学过,CPU响应中断之前会将IF、TF的值设为0,这正是为了在中断例程中屏蔽其他可屏蔽中断。

如果我们在自己编写的中断例程中可以处理可屏蔽中断,CPU也提供了相关指令来设置IF的值:

  1. sti,设置IF=1;
  2. cli,设置IF=0。

不可屏蔽中断

如字面意思,不可屏蔽中断就是CPU必须响应的中断。对于8086CPU,不可屏蔽中断的中断类型码固定为2,所以在终端过程中,不需要取中断类型码。

不可屏蔽的中断过程:

  1. 标志寄存器入栈,IF=0,TF=0;
  2. CS、IP入栈
  3. (IP)=8,(CS)=(0AH)。

几乎所有由外设引发的外中断,都是可屏蔽中断。

不可屏蔽中断是在系统中有必须处理的紧急情况是用来通知CPU的终端信息。

PC机键盘的处理过程

1.键盘输入

键盘上每按下一个键,键盘上的芯片就会产生一个扫描码(键的编码),然后被送入主板上相关接口芯片的寄存器中,该寄存器的端口地址为60h(8086机)。

松开按键的时候,也会产生一个扫描码,用来说明松开的键在键盘上的位置。松开键时产生的扫描码也被送入60h端口中。也会引发中断。

一般讲按下键产生的扫描码成为通码,松开键产生的扫描码成为断码。它们有如下关系:断码=通码+80h。

2.引发9号中断

当扫描码到达60h端口时,相关芯片就会向CPU发出中断类型码为9的可屏蔽中断信息。

3.执行int 9中断例程

  1. 读出60h中的扫描码;
  2. 如果是字符键的扫描码,将该扫描码和它对应的字符码(ASCII码)送入内存中BIOS键盘缓冲区;如果是控制键(比如Ctrl)和切换键(比如CapsLock)的扫描码,则将其转变为状态字节(用二进制位记录控制键和切换键状态的字节)写入内存中存储状态字节的单元。
  3. 对键盘系统进行相关的控制,比如说,向相关芯片发出应答信息。

BIOS键盘缓冲区是系统启动后,BIOS用于存放int 9中断例程所接收的键盘输入的内存区。该内存区可以存储15个键盘输入。

第十六章 直接定址表

描述了单元长度的标号

我们之前使用的标号后面都带有一个冒号,这种标号代表着内存单元的地址(段地址和偏移地址),而这里我们在介绍一种新的标号——不带后面的冒号。

例:a db 0,1,2,3,4,5,6,7,8,9。其中a就是标号。

这种标号不但代表内存单元的地址,还表示了内存单元的长度——是字节单元还是字单元还是双字单元。若后面定义的是db,则是字节单元,若是dw则是字单元,dd类比。

既然这种标号还表示内存单元长度,那么在mov等指令中就不必指出内存单元长度了,可以直接mov a,2,就相当于mov byte ptr cs:0,2,这里的cs:0只是用来表示a所代指的地址。

同样,如果mov两个操作数的长度不匹配,则在编译时会报错。

在其他段中使用数据标号

注意:我们之前常用的带有冒号的标号只能在代码段中使用,也就是assume指明关联cs的段。

但是我们刚刚讲的不带冒号的标号可以用在其他段中,特别是在数据段中,能发挥很大的作用。

如果想在代码段中直接用数据标号访问数据,则需要用伪指令assume将标号所在的段和一个段寄存器联系起来。否则在编译的时候,无法确定标号的段地址在哪一个寄存器。使用了assume伪指令之后,我们还要让该段寄存器存放与之关联的段的地址。

例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
assume cs:code,ds:data
data segment
a db 1,2,3,4,5,6,7,8
b dw 0
data ends

code segment
start:
mov ax,data
mov ds,ax

mov si,0
mov cx,8
s: mov al,a[si]
mov ah,0
add b,ax
inc si
loop s

mov ax,4c00h
int 21h
code ends
end start

上面这段程序的功能是将a标号处的8个数据累加,存储在b标号处的字中。

1
2
3
4
5
6
7
表示下一条地址从偏移地址204H开始,和安装后的偏移地址相同,若没有org 204H,中断例程安装后,标号代表的地址改变了,和之前编译器编译的有所区别
;名称:int_7CH
;功能:如下功能子程序:清屏,设置前景色,设置背景色和向上滚动一行,功能号为0~3表示下一条地址从偏移地址204H开始,和安装后的偏移地址相同,若没有org 204H,中断例程安装后,标号代表的地址改变了,和之前编译器编译的有所区别
;名称:int_7CH
;功能:如下功能子程序:清屏,设置前景色,设置背景色和向上滚动一行,功能号为0~3表示下一条地址从偏移地址204H开始,和安装后的偏移地址相同,若没有org 204H,中断例程安装后,标号代表的地址改变了,和之前编译器编译的有所区别
;名称:int_7CH
;功能:如下功能子程序:清屏,设置前景色,设置背景色和向上滚动一行,功能号为0~3

再将ds与data关联,并且设置ds后,编译器会将指令mov al,a[si]编译为mov al,[si+0]add b,ax编译为add [8],ax

如果把标号当做数据来定义,那么编译器会将标号所表示的地址当做数据的值:

1
2
3
4
5
6
data segment
a db 1,2,3,4,5,6,7,8,9
b dw 0
c dw a,b ;若定义为dw类型,则储存的是a,b所在的偏移地址
d dd a,b ;若定义为dd类型,则存储的是a,b的偏移地址和段地址——低字存放偏移地址,高字存放段地址
data ends

这里再补充一个指令seg,取段地址

c dw seg a就是取a标号的段地址存放在c处。

直接定址表

直接定址表就相当于高级语言中的数组,也能理解为一一映射的hashmap。

直接定址表是通过上一节所讲的不带冒号的标号来实现的。例:

1
table db '01234567890ABCDEF'	;这里定义了一个字符表

通过这个表,我们能很容易的把16进制的数字转换成相应的能显示的字符。例:mov ah,table[10]就是将字符A移入ah中。

可以想象,通过构造直接定址表,我们能把一些有限情况、且已知答案的问题的答案存放在直接定址表中,根据输入直接输出对应的答案,这种程序运行的速度是非常快的,我们所做的牺牲就是一些存储单元。

我们还能将其他标号的地址构成一个直接定址表,通过访问这个直接定址表来获取其他标号的地址,从而访问其他标号。使用这种方法的程序比不使用直接定址表的程序要简洁、优美。

1
2
3
4
5
表示下一条地址从偏移地址204H开始,和安装后的偏移地址相同,若没有org 204H,中断例程安装后,标号代表的地址改变了,和之前编译器编译的有所区别
;名称:int_7CH
;功能:如下功能子程序:清屏,设置前景色,设置背景色和向上滚动一行,功能号为0~3表示下一条地址从偏移地址204H开始,和安装后的偏移地址相同,若没有org 204H,中断例程安装后,标号代表的地址改变了,和之前编译器编译的有所区别
;名称:int_7CH
;功能:如下功能子程序:清屏,设置前景色,设置背景色和向上滚动一行,功能号为0~3

程序入口地址的直接定址表

这一节就是用到了上面提到的将其他标号所代表的子程序的地址构成一个直接定址表,并通过这个直接定址表来访问子程序。

直接定址表可以实现利用功能号来访问子程序,使程序结构清晰,便于扩充。

实验16遇到的坑及感悟!!!

遇到的坑:

  • 最大并且唯一的坑就是setscreen程序中call word ptr table[bx]这条指令,由于table标号在编译阶段就被转化为偏移地址,因此在安装至0:204h之后,table仍然代表的是原来的偏移地址,但是我们在触发7ch号中断后的段地址是0,因此偏移地址肯定不是原来的偏移地址了,这就导致call指令会调到其他的子程序去。

解决方法:

  • 在安装程序中的setscreeen中断例程前加一条org 204h指令。这个指令是在下一条指令之前补充一定数量的0机器码(用来“凑数”)来使得偏移地址达到org后面所加的数值。这样编译好的table所代表的偏移地址就是安装之后的偏移地址了。

经过这一次实验的“历练”,我印象最深刻的就是标号所代表的偏移地址是在编译阶段就被转化为具体的数的,而不是在运行阶段。。。。。。虽然这一点在本书的前面某个章节提到过。。。。。。但是我当时没有特别重视/(ㄒoㄒ)/~~

第十七章 使用BIOS进行键盘输入和磁盘读写

int9中断例程对键盘输入的处理

在第十五章中,我们已经讲解过int9中断例程,这里再简单介绍一下。

键盘输入将引发9号中断,从60h端口读出扫描码,并将其转化为相应的ASCII码或状态信息,存储在内存的指定空间(键盘缓冲区或状态字节)中。8086的键盘缓冲区有16个字单元,可以存储15个案件的扫描码和对应的ASCII码。

键盘缓冲区是用环形队列结构管理的内存区

使用int16h中断例程读取键盘缓冲区

int16h中断例程中包含的最重要的功能是从键盘缓冲区读取一个键盘输入,该功能的编号为0。下面的指令从键盘缓冲区读取一个键盘输入,并且将其从缓冲区删除:

1
2
mov ah,0
int 16h

执行结果是:(ah)=扫描码,(al)=ASCII码。

因为键盘缓冲区是通过环形队列来管理的内存区,所以读取键盘缓冲区的数据的顺序与输入的顺序相同。

int16h中断例程的0号功能,会进行如下的工作:

  1. 检测键盘缓冲区是否有数据;
  2. 没有则继续做第1步;
  3. 读取缓冲区第一个子单元中的键盘输入;
  4. 将读取的扫描码送入ah,ASCII码送入al;
  5. 将已读取的键盘输入从缓冲区中删除。

字符串的输入

思路:通过利用int9和int16h中断来实现获取输入的字符。同时自己构造一个字符栈用来存储输入的字符,然后“转发’’到显示缓存区去。

用int13h中断例程对磁盘进行读写

BIOS提供了int13h中断例程对磁盘进行读写,由于这部分不是重点,具体的参数和用法请自行看书。

读后感

个人觉得我们现在还是应该要学一学汇编语言的,为的不是以后用汇编来编写真正实用的程序,而是为了理解我们使用高级语言的一些特性背后的东西,理解一些重要的概念、思想和原理。

例如:

  1. 在本书的第一章,最重要的概念就是一台PC机中,各个芯片与CPU是通过总线相连的。我们可以把各个存储器芯片整体看成一个逻辑存储器,不同部分的地址,对应着不同的芯片。
  2. 第二章最重要的就是理解“段”、“段地址”和“偏移地址”的概念和思想了。这种思想可以让我们在寄存器位数不足的情况下能访问更多的地址。同时理解CPU读取、执行指令的过程也很重要。
  3. 第三章又介绍了一个非常重要的概念——栈。栈在之后的学习中起着非常重要的作用,例如:它能帮助暂时保存寄存器中的数值。同时,联想到C++语言中的栈,不难理解为什么栈存在越界的问题了。
  4. 第四章至第八章主要讲的是汇编语言本身的一些语法以及技巧,在以后的编程过程中,我们也能从中获益,毕竟所有的程序都会被编译成汇编语言进而转化为机器语言的。其中第七章的定位内存地址的方法与高级语言中的数组之类的数据结构有相似之处。
  5. 第九章与第十章所讲的,个人认为是对我们理解高级语言中的一些东西最有意义的了。例如,第九章的转移指令原理让我们理解了循环、选择语句的原理;第十章的CALL和RET就对应着高级语言中的函数调用了,这里介绍的解决函数调用寄存器冲突的方法与C语言中的函数调用栈相一致(这也说明了栈的重要性。同时似乎也能部分解释高级语言中的局部变量和全局变量?)。
  6. 第十一章到第最后一章,个人认为是比较偏向硬件的。其中对于中断和端口的理解可能会在以后的编程中给我们启示。

总的来说,看完这本书的收获还是蛮多的,感觉在以后的高级语言编程中会给我一些帮助。