基础知识
汇编语言指令组成
- 汇编指令:机器码的助记符,有对应的机器码。
- 伪指令:没有对应的机器码,编译器执行,机器不执行。
- 其他符号:如±*/有编译器识别,无对应机器码。
CPU与外部器件交互需要
- 存储单元地址(地址信息)
- 器件选择,读写命令(控制信息)
- 数据(数据信息)
总线
总线就是一根根导线的集合,分为
- 地址总线,越宽(数量越多)代表可以寻址的范围越大
- 数据总线,越宽代表一次性读写的数据越多(8根1字节)
- 控制总线,越宽代表对器件控制操作越多
小结
汇编指令和机器指令一一对应
每一种cpu都有自己的汇编指令集
在存储器中指令和数据都是二进制,没有任何区别
CPU可以直接使用的信息存放在存储器中(内存)
接口卡
CPU无法直接控制显示器,键盘等的外围设备,但CPU通过直接控制这些外围设备在主板上的接口卡来控制这些设备。
存储器
随机存储器(RAM):带电存储,关机丢失,可读可写
- 用于存放CPU使用的绝大部分程序和数据,主随机存储器由装在主板上的RAM和扩展插槽的RAM组成。
- 其他接口卡上也可能有自己的RAM
只读存储器(ROM):关机不丢,只能读取
- 主板上的ROM装有系统的BIOS(基本输入输出系统)。
- 其他接口卡上也可能有自己的ROM,一般装着相应的BIOS。
(P10图)
内存地址空间
以上这些内存都和CPU总线相连,CPU都通过控制总线向他们发出内存读写命令。所以CPU都把他们当内存对待,看做一个一个由若干存储单元组成的逻辑存储器,即内存地址空间(一个假想的逻辑存储器P11图)。
内存地址空间中的各个不同的地址段代表不同的存储设备,内存地址空间大小收到CPU地址总线长度限制。
寄存器
内部总线
之前讨论的总线是CPU控制外部设备使用的总线,是将CPU和外部部件连接的。而CPU内部由寄存器,运算器,控制器等组成,由内部总线相连,内部总线负责连接CPU内部的部件。
通用寄存器
8086CPU寄存器都是16位的,一共14个,分别是AX,BX,CX,DX,SI,DI,SP,BP,IP,CS,SS,DS,ES,PSW。其中AX,BX,CX,DX四个寄存器通常存放一般性的数据,称为通用寄存器。
而且为了兼容上一代的8位寄存器,这四个寄存器可以拆开成两个8位的寄存器来使用。称为AH,AL,BH,BL,CH,CL,DH,DL。低八位(编号0-7)构成L寄存器,高八位构成H寄存器。
字
8086CPU可以处理以下两种数据
- 字节byte,8位
- 字word,连个字节,16位。分别称为高位字节和低位字节。
简单的汇编指令
指令 | 操作 | 高级语言 |
---|---|---|
mov ax,18 | 将18存入AX寄存器 | AX=18 |
add ax,8 | 将AX寄存器中的数加8 | AX=AX+8 |
mov ax,bx | 将BX中的数据存入AX | AX=BX |
add ax,bx | 将AX中的数据和BX中的数据相加存入AX | AX=AX+BX |
汇编指令或寄存器名称不区分大小写。
注:AX寄存器当做两个8位寄存器al和ah使用的时候,CPU就把他们当做两个8位寄存器使用,而不会看成是一个16未分开,即如果al进行加法运算C5+93=158,即add al,93,al会变成58,ax则是0058而不是0158。
CPU位结构
16位结构的CPU指的是运算器一次最多处理16位数据,寄存器宽度16,寄存器和运算器之间通路也是16位。
CPU表示物理地址
如果物理总线宽度超过寄存器宽度,CPU寻址方法是两个寄存器输出一个地址,当地址总线宽度20的时候,P21图。一个寄存器输出短地址,另一个输出偏移地址。然后通过地址加法器合并为一个20位的地址,然后通过内部总线送给控制电路,控制电路通过地址总线送给内存。
公式:物理地址=段地址x16+偏移地址(这里的x16其实就是左移四位,P21图)
虽然这么表示,但内存并没有被分为一段一段的,是CPU划分的段。段地址x16称为基础地址,所以我们可以根据需求把任意的基础地址加上不超过一个寄存器表示的最长(64KB)的偏移地址来表示地址。而且一个实际地址往往可以有各种不同的方法表示,通常我们表示21F60H这个地址通过下面方法:
- 2000:1F60
- 2000H段中的1F60单元中
段寄存器与指令指针寄存器
8086CPU有四个段寄存器:CS,DS,SS,ES
除此之外,IP寄存器称为指令指针寄存器,所以任意时刻可以读取从CSx16+IP单元开始,读取一条指令执行。也就是说,CPU将IP指向的内容当做指令执行。
P26图,CPU执行一段指令。另外,8086CPU开机时CS被置为FFFFH,IP被置为0000H,也就是说刚开机的第一条指令从FFFF0H开始读取执行。
CPU将CS:IP指向的内存中的内容当做指令,一条指令被执行了,那一定被CS:IP指向过。
修改CS,IP
CS和IP寄存器不可以使用传送指令mov来改变,而能改变CS,IP内容的指令是转移指令。
jmp指令用法:
- jmp 段地址:偏移地址 同时修改CS和IP的值 如jmp 2AE3:3 结果CS=2AE3H IP=0003H
- jmp 某一合法寄存器 只修改IP的值 如jmp ax,将IP的值置为AX中的值(AX不变)
小结
8086CPU有四个段寄存器,CS是用来存放指令的段地址的段寄存器
IP用来存放指令的偏移地址
CS:IP指向的内容在任意时刻会被当做指令执行
使用转移指令修改CS和IP的内容
实验
Debug命令:
- R:查看,改变CPU寄存器内容
- 直接-r查看寄存器内容
- -r 寄存器名,改变寄存器内容
- D:查看内存中内容
- -d直接查看
- -d 段地址:偏移地址 查看固定地址开始的内容
- -d 段地址:偏移地址 结尾偏移地址 查看指定范围内存
- E:改写内存中内容
- -e 起始地址 数据 数据 数据 …
- 提问方式修改 -e 段地址:偏移地址 从这个地址开始一个一个改,空格下一个,回车结束
- 也可以写入字符 ‘a’
- U:将内存中的机器指令翻译成汇编指令
- -u 段地址:偏移地址
- T:执行一条机器指令
- -t 执行cs:ip指向的命令
- A:以汇编指令格式在内存中写入一条机器指令
- -a 段地址:偏移地址 从这个地址开始一行一行的写入汇编语句
寄存器(内存访问)
内存到寄存器的储存
寄存器是16位的,可以存放一个字即两个字节,而内存中的一个存储单元是一字节。所以一个寄存器可以存两个存储单元的内容,高地址存储单元存在高位字节中,低地址存储单元存在低位字节中。
字单元:存放一个字型数据的两个地址连续的内存单元。
DS寄存器
与CS类似,DS寄存器存放的是要从内存中读取的数据的段地址。我们想要使用mov指令从内存10000H(1000:0)中的数据送给AL时,如下:
mov al,[0]
后面的[0]指的是内存的偏移地址是0,CPU会自动从DS寄存器中提取段地址,所以应该首先将段地址1000H写入DS寄存器中。但却不能直接使用mov ds,1000指令,只能从其他寄存器中转传入DS寄存器。所以完整命令如下:
1 | mov bx,1000 |
当然,从AL寄存器中将数据送入内存只要反过来使用mov就可以了,mov [0],al
如果需要传输字型数,只要使用对应的16位寄存器就可以了,传输的是以相应地址开始的一个字型数据(连续两个字节)。如mov [0],cx。
mov,add,sub
mov常见语法:
1 | mov 寄存器,数据 mov ax,8 |
add,sub常见语法:
1 | add 寄存器,数据 add ax,8 |
注意,add,sub不可以操作段寄存器。
栈
栈是一种后进先出的存储空间,从栈顶出栈入栈。LIFO(last in first out)
入栈指令:push ax ax中的数据送入栈顶
出栈指令:pop ax 栈顶送入ax
入栈和出栈指令都是以字为单位的。P58图
栈寄存器SS,SP与push,pop
CPU通过SS寄存器和SP寄存器来知道栈的范围,段寄存器SS存放的是栈顶的段地址,SP寄存器存放的是栈顶的偏移地址。所以,任意时刻SS:SP指向栈顶元素。
指令push ax执行过程:
- SP=SP-2,SP指针向前移动两格代表新栈顶
- AX中的数据送入SS:SP目前指向的内存字单元,P59图
所以栈顶在低地址,栈底在高地址。初始状态下,SP指向栈底的下一个单元。
反之pop ax执行过程相反。
8086CPU并不会自己检测push是否会超栈顶,pop是否会超栈底。
push和pop可以加寄存器,段寄存器,内存单元(直接偏移地址[address])
指定栈空间通常通过指定SS来进行,如:
1 | 指定10000H~1000FH为栈空间 |
注:将一个寄存器清零 sub ax,ax 两个字节,mov ax,0 三个字节
注:若设定一个栈段为10000H~1FFFFH,栈空的时候SP=0(要知道入栈操作先SP-2,然后再送入栈)
实验
Debug中的t命令一次执行一条指令,但如果执行的指令修改了ss段寄存器,下一条命令也会紧跟着执行(中断机制)。
简单编程
一个汇编语言程序
- 编写
- 编译(masm5.0)
- 连接
一些伪指令功能
1 | cs:codesg |
涉及到的一些知识:
- XXX segment···XXXends
- segment和ends成对出现,代表一个段的开始和结束。
- 一个汇编程序可以有多个段,代码,数据和栈等,至少要有一个段。
- end
- end代表一个汇编程序结束,遇到end编译器停止编译。
- assume
- assume 假设,假设某一个段寄存器和程序中的一个段关联。
- 可以理解为将这个段寄存器指向程序段的段地址
- 标号(codesg)
- 一个标号代表一个地址
- 程序返回mov ax,4c00 int 21
- 暂时记住这两条指令代表程序返回
编译和连接方法,P83。
注:编译器只能发现语法错误而无法发现逻辑错误。
CPU执行一个程序,需要有另一个程序将它加载进内存(即将CS:IP指向它),一般情况下我们通过DOS执行这个.exe,所以是DOS程序将它加载进入内存。当这个程序运行结束,再返回DOS程序继续执行。如果是DOS调用Debug调用.exe,那么先返回Debug再返回DOS。
DOS加载一个.exe时,先在内存中找到一段内存,起始段地址SA,然后分配256字节的PSP区域,用来和被加载程序通信。在之后的段地址SA+10就是程序开始的段地址。CS:IP指向它,DS=SA。
注:在Debug中,最后的int 21指令要使用P命令执行。
[BX]和loop指令
内存单元的描述
内存单元可以使用[数字]表示,当然也可以使用[寄存器]表示,如[bx],mov ax,[bx],mov al,[bx]
为了表示方便,使用()来表示一个内存单元或寄存器中的内容,如(ax),(20000H),或((dx)*16+(bx))表示ds:bx中的内容,但不可写为(1000:0),((dx):0H)。而(X)中的内容由具体寄存器名或运算来决定。
我们使用idata来表示常亮。所以以下语句可以这么写:mov ax,[idata] mov ax,idata。
loop指令
loop指令格式:loop 标号。
loop指令通常用来实现循环功能,当执行loop指令时,CPU进行两步操作:
- (cx)=(cx)-1
- (cx)不为零则跳至标号处执行程序。
所以CX中存放的是循环次数,一个简单的例子如下(计算2^12):
1 | cs:code |
所以使用loop注意三点:
- 先设置cx的值 mov cx,循环次数
- 设置标号与执行循环的程序段 s:执行程序段
- 在程序段最后写loop loop
注:在汇编语言中,数据不能以字母开头,所以大于9fffH的数据,要在开头加0,如0A000H
注:debug中G命令 g 0012表示CPU从当前CS:IP开始一直执行到0012处暂停。P命令可以将loop部分一次执行完毕,直到(CX)=0,或使用g loop的下一条命令。
Debug和masm编译器对指令的不同处理
mov ax,[0]这条指令在Debug和masm中有着不同的解释,Debug是将DS:0内存中的数据送给AX,而masm中则是mov ax,0,即将0送入AX。
解决方法1:先将偏移地址送入BX,然后再使用mov ax,[bx]
解决方法2:直接显式给出地址,如mov al,ds:[0] (相应的段寄存器还有CS,SS,ES这些在汇编语言中可以称为“段前缀”)当然,这种写法通过编译器之后会变成Debug中的mov al,[0]
注:inc bx bx值加一
安全的编程空间
在之前没有提到的一个问题,如果在写程序之前不看一眼要操作的内存,就直接开始使用的话,万一改写了内存中重要的系统数据,可能会引起系统崩溃。所以我们一般在一个安全的内存空间中操作。一般操作系统和合法程序都不会使用0:200~0:2ff这256字节的空间,所以我们可以在这里操作。
学习汇编语言的目的就是直接和硬件对话,而不理会操作系统,这在DOS(实模式)下是可以做到的,但在windows或Unix这种运行与CPU保护模式的操作系统上却是不可能的,因为这种操作系统已经将CPU全面严格的管理了。
段前缀的使用
将ffff:0ffff:b中的数据转存入0:2000:20b中:
1 | cs:code |
[bx]直接使用的时候默认段前缀是ds,但要使用其他的段前缀,如es就要在前面加上。
程序的段
数据段
一般一个程序想要使用内存空间,有两种方法,在程序加载的时候系统分配或在需要使用的时候向系统申请,我们先考虑第一种情况。所以我们应事先将所需的数据存入内存中的某一段中,但我们又不可以随意的指定内存地址,以下面的求8个数据累加和的代码为例:
1 | cs:code |
代码第一行的dw是定义字类型数据,define word的意思。这里定义了8个字类型数据,占16字节。由于是在程序最开始定义的dw,所以数据段的偏移地址为0,也就是说第一个数据0123h的地址是CS:[0]第二个0456h的地址是CS:[2]以此类推。
所以这个程序加载之后CS:IP指向的是数据段的第一个数据,我们要是想成功执行,需要把IP置10,指向第一条指令mov bx,0,所以我们想要直接执行(不在Debug中调整IP)的话,需要指定程序开始的地方:
1 | ··· |
在第一条指令前加start,后面的end变成end start,end除了通知编译器程序在哪里结束之外,也可以通知程序的入口在哪,也就是第一条语句,在这里编译器就知道了mov bx,0是程序的第一条指令。也就是说,我们想要CPU从何处开始执行程序,只要在源程序中使用end 标号指定就好了。
所以有如下框架:
1 | assume cs:code |
栈段
看下面一段使8个数逆序存放的代码:
1 | cs:codesg |
在定义了8个字型数据之后,又定义了16个取值为0的字型数据,用作栈空间。所以dw这个定义不仅仅用来定义数据,也可以用来开辟内存空间留给之后的程序使用。
数据,代码,栈的程序段
在8086CPU中,一个段的长度最大为64KB,所以如果我们将数据或栈空间定义的比较大,就不能像前面一样编程了。我们需要将代码,数据,栈放入不同的段中:
1 | cs:code,ds:data,ss:stack |
我们可以这样在写代码时就将程序分为几个段,这段代码中,mov ax,data的意思是将data段的段地址送入ax寄存器。但我们不可以使用mov ds,data这样是错误的,因为在这里data被编译器视为一个数值。
在这里将数据命名为data,代码命名为code,栈命名为stack只是为了方便阅读,CPU并不能理解,和start,s,s0一样,只在源程序中使用。而assume cs:code,ds:data,ss:stack这段代码也并不能让CPU的cs,ds,ss指向对应的段,因为assume是伪指令,CPU并不认识,它是由编译器执行的。源程序中end start语句指明了程序的入口,在这个程序被加载后,CS:IP被指向start处,开始执行第一条语句,这样CPU才会将code段当做代码执行。而当CPU执行
1 | mov ax,stack |
这三条语句后才会将stack段当做栈空间开使用。也就是说,CPU如何区分哪个段的功能,全靠我们使用汇编指令对ds,ss,cs寄存器的内容设置来指定。
灵活定位内存地址
and和or指令
and:逻辑与指令,按位与运算,如:
1 | mov al,01100011B |
执行结果是al=00100011B,所以我们想要把某一位置零的时候可以使用and指令。
or:逻辑或指令,按位或运算,如:
1 | mov al,01100011B |
执行结果是al=01111011B,or指令可以将相应位置1。
ASCII码和字符形式的数据
在汇编语言中我们可以使用’···’的方式指明数据是以字符形式给出的,编译器会自动将它们转化为ASCII码。例如:
1 | cs:code,ds:data |
db和dw类似,只不过定义的是字节型数据,然后通过’unIX’相继在接下来四个字节中写下75H,6EH,49H,58H即unIX的ASCII值。同理,mov al,’a’也是将’a’的ASCII值61H送入al寄存器。
使用and和or指令改变一串字符串字母的大小写,将第一串全变为大写,第二串全变为小写:
首先分析ASCII码:
1 | 大写 十六进制 二进制 小写 十六进制 二进制 |
可见,只有第5位(从右往左数,从0开始计数)在大写和小写的二进制中是不一样的,所以我们只要把所有字母的二进制第五位置零,那就是大写,置1就是小写。代码如下:
1 | cs:codesg,ds:datasg |
[bx+idata]的内存表示方法与数组处理
除了使用[bx]来表示一个内存单元外,我们还可以使用[bx+idata]来表示一个内存单元,他表示的意思是偏移地址为(bx)+idata(bx中的数值加idata)的内存单元。当然也可写为[idata+bx],除此之外还可写为,200[bx],[bx].200。
既然有了这种表示方法,我们就可以使用这种方法来操作数组,刚才将两个字符串改变大小写的代码的循环部分可以如下优化:
1 | ··· |
当然也可写为0[bx]和5[bx],注意这种写法和C语言中数组的相似之处:C语言中数组表示为a[i],汇编语言中表示为5[bx]。
SI和DI寄存器
SI和DI功能和BX相似,但不可以拆分为两个8位寄存器。也就是说下面代码等价:
1 | mov bx|si|di,0 |
所以在这里可以使用更方便的方式:[bx+si]和[bx+di],这两个式子表示偏移地址为(bx)+(si)的内存单元,使用方法如:mov ax,[bx+si]等价于mov ax,[bx][si]。
当然,有了这些表示方法,自然就有[bx+si+idata]和[bx+di+idata],相似的,也可以写成
1 | mov ax,[bx+200+si] |
那我们总结一下这些内存寻址方法:
- [idata]用一个常量表示偏移地址,直接定位一个内存单元
- [bx]用一个变量表示偏移地址,定位一个内存单元
- [bx+idata]用一个常量和一个变量表示偏移地址,可在一个起始地址的基础上间接定位一个内存单元
- [bx+si]用两个变量表示偏移地址
- [bx+si+idata]用两个变量和一个常量表示偏移地址
使用双循环,使用一个寄存器暂存cs的值,如:
1 | ··· |
假如循环比较复杂,没有多余的寄存器可用,我们可以使用内存暂存cx或其他数据:
1 | ··· |
这么使用的话注意需要在数据段声明用来暂存的内存,好在程序加载时分配出来。当然,在需要暂存的地方,还是建议使用栈:
1 | ··· |
数据处理的两个基本问题
两个基本问题
- 处理的数据在什么地方
- 要处理的数据有多长
接下来的讨论中,使用reg来表示一个寄存器,使用sreg来表示一个段寄存器。所以:
- reg:ax,bx,cx,dx,ah,al,bh,bl,ch,cl,dh,dl,sp,bp,si,di
- sreg:ds,ss,cs,es
bx,si,di和bp
在8086CPU中,只有这四个寄存器可以使用[···]来进行内存寻址,可以单个出现,或以下面组合出现(常数可以随意出现在这些表示方法中):
- bx+si/di
- bp+si/di
注:如果使用了bp来寻址,而没有显式的表明段地址,默认使用ss段寄存器,如:
1 | mov ax,[bp] ;(ax)=((ss)*16+(bp)) |
数据的位置
绝大部分机器指令都是用来处理数据的,基本可分为读取,写入,运算。在机器指令这个层面上,并不关心数据是什么,而关心指令执行前数据的位置。一般数据会在三个地方,CPU内部,内存,端口。
汇编语言中使用三个概念来表示数据的位置:
- 立即数(idata)
- 对于直接包含在机器指令中的数据,在汇编语言中称为立即数
- 例:mov ax,1 add bx,2000h
- 寄存器
- 指令要处理的数据在寄存器中,在汇编指令中给出相应寄存器名
- 例:mov ax,bx mov ds,ax
- 段地址(SA)和偏移地址(EA)
- 指令要处理的数据在内存中,在指令中使用[X]方式给出,SA在某个段寄存器中
- 例:mov ax,[0] mov ax,[di]
总结一下寻址方式:
寻址方式 | 含义 | 名称 |
---|---|---|
[idata] | EA=idata;SA=(DS) | 直接寻址 |
[bx|si|di|bp] | EA=(bx|si|di|bp);SA=(DS) | 寄存器间接寻址 |
[bx|si|di|bp+idata] | EA=(bx|si|di|bp+idata);SA=(DS) | 寄存器相对寻址 |
[bx|bp+si|di] | EA=(bx|bp+si|di);SA=(DS|SS) | 基址变址寻址 |
[bx|bp+si|di+idata] | EA=(bx|bp+si|di+idata);SA=(DS|SS) | 相对基址变址寻址 |
数据的长度
8086CPU中可以指定两种尺寸的数据,byte和word,所以在使用数据的时候要指明数据尺寸。
- 在有寄存器参与的时候使用寄存器的种类区分
- 字:mov ax,1
- 字节:mov al,1
- 在没有寄存器参与的时候,使用X ptr指明内存单元长度,X是word或byte
- 字:mov word ptr ds:[0],1 add word ptr [bx],2
- 字节:mov byte ptr ds:[0],1 add byte ptr [bx],2
- 其他默认指明处理类型的指令
- push [1000H],push默认只进行字操作
灵活使用寻址方式的例子,修改下面内存空间中的数据:
段seg:60
起始地址 | 内容 |
---|---|
00 | ‘DEC’ |
03 | ‘Ken Oslen’ |
0C | 137 |
0E | 40 |
10 | ‘PDP’ |
1 | ··· |
这段代码中地址的使用类似c++中结构体的使用。[bx].idata.[si],就类似与c++中的dec.cp[i]。dec是结构体,cp是结构体中的字符串成员,[i]表示第几个字符。
div指令
div是除法指令,需要注意以下三点:
- 除数:8位或16位,在一个reg或内存单元中
- 被除数:默认在AX或DX中,如果除数8位,被除数则为16位,放在AX中;如果除数16位,则被除数32位,在DX和AX中,DX存放高16位,AX放低16位。
- 结果,除数8位,结果(商)存放在AL中,AH存放余数;如果除数16位,则AX存放商,DX存放余数
格式:div reg或div 内存单元,所以div byte ptr ds:[0]表示:
1 | (al)=(ax)/((ds)*16+0)的商; |
div word ptr es:[0]表示:
1 | (al)=[(dx)*10000H+(ax)]/((es)*16+0)的商 |
例:计算100001/100,因为100001(186A1H)大于65535,则需要存放在ax和dx两个寄存器,那么除数100只能存放在一个16位的寄存器中,实现代码:
1 | mov dx,1 |
执行之后(ax)=03E8H(1000),(dx)=1。
伪指令dd
dd是一个伪指令,类似dw,但dd是用来定义dword(double word,双字),如:
1 | dd 1 ;2字,4字节 |
将data段中第一个数据除以第二个数据,商存入第三个数据:
1 | ··· |
总结一下div相关:
- div后面跟的是除数
- 被除数位数是除数两倍
- 被除数存在ax中或ax+dx(ax低,dx高)
- 商在ax或al中,余数在ah或dx中(高余数,低商)
dup
dup是一个操作符,由编译器识别,和db,dw,dd配合使用,如:
db 3 dup (0)表示定义了三个值是0的字节,等价于db 0,0,0
db 3 dup (1,2,3)等价于db 1,2,3,1,2,3,1,2,3 共九个字节
db 3 dup (‘abc’,‘ABC’)等价于db ‘abcABCabcABCabcABC’
综上,db|dw|dd 重复次数 dup (重复内容)
转移指令原理
转移指令
可以修改IP或同时修改CS,IP的系统指令称为转移指令,可分为以下几类:
- 转移行为:
- 只修改IP,称为段内转移,如jmp ax
- 同时修改CS和IP,称为段间转移,如jmp 1000:0
- 修改范围(段内转移):
- 短转移:修改IP范围-128~127
- 近转移:修改IP范围-32768~32767
- 转移指令分类:
- 无条件转移:jmp
- 条件转移
- 循环指令
- 过程
- 中断
offset操作符
offset是由编译器处理的符号,它能去的标号的偏移地址,如:
1 | start:mov ax,offset start |
这里就是将start和s的偏移地址分别送给ax,也就是0和3
jmp指令
jmp是无条件转移指令,可以只修改IP也可以同时修改CS和IP,只要给出两种信息,要转移的目的地址和专一的距离。
依据位移的jmp指令:jmp short 标号(转到标号处执行指令)。这个指令实现的是段内短转移,对IP修改范围是-128~127,指令结束后CS:IP指向标号的地址,如:
1 | 0BBD:0000 start:mov ax,0 (B80000) |
执行之后ax值为1,因为跳过了add指令。
还应注意的是,jmp short短转移指令并不会在机器码中直接写明需要转移的地址(0BBD:0008),jmp的机器码是EB03并没有包含转移的地址,这里的转移距离是相对计算而出的地址,来看下面的执行过程:
- (CS)=0BBDH,(IP)=0006H,CS:IP指向EB03(jmp short s)
- 读取指令EB03进入指令缓冲器
- (IP)=(IP)+指令长度,即(IP)=(IP)+2=0008H,之后CS:IP指向add ax,1
- CPU指向指令缓冲器中的指令EB03
- 执行之后(IP)=000BH,指向inc ax
在jmp short s的机器码中,包含的并不是转移的地址,而是转移的位移,这里的位移是相对计算出来的,用8位一字节来表示,所以表示范围是-128127,用补码表示。计算方法如是,8位位移=标号处地址-jmp下一条指令的地址。当然还有一种类似的指令是jmp near ptr 标号,是近转移,原理一样,只是表示位移的是字类型16位,表示范围-3276832767。
jmp+地址远转移
jmp far ptr 标号实现的是段间转移,也就是远转移,它的机器码中指明了转移的目的地址的CS和IP的值,如下面例子:
1 | 0BBD:0000 start:mov ax,0 (B80000) |
可以看出,jmp的机器码中明确指明了跳转位置s的地址0BBD:010B,在低位的是IP的值,高位的是CS的值。
jmp+寄存器|内存转移
jmp+寄存器:jmp 16位reg,实现的是(IP)=(16位reg),之前讨论过,直接修改IP的值为寄存器中的值。
jmp+内存:jmp加内存使用的时候有两种用法:
- jmp word ptr 内存单元地址(段内转移)
- 从内存单元地址处开始存放一个座位转移目的的偏移地址的字
- 内存单元支持任何寻址方式
- 如jmp word ptr ds:[0],执行后(IP)=0123H(ds:[0]中的值是123H)
- jmp dword ptr 内存单元地址(段间转移)
- 从内存单元地址处开始存放两个字,高位存放段地址,低位偏移地址作为转移的目的地址
- (CS)=(内存单元地址+2),(IP)=(内存单元地址),支持任一种寻址方式
- 如jmp dword ptr [bx]跳转到0:123H
jcxz指令
jcxz指令为条件转移指令,所有的条件转移指令都是短转移,转移范围是-128~127。使用格式是jcxz 标号,功能是如果(cx)=0则跳转到标号处执行;如果(cx)!=0,那么什么也不做继续执行代码。
loop指令
loop为循环指令,所有的循环指令都是短转移,转移范围是-128~127。使用格式是loop 标号,功能是如果(cx)!=0那么跳转到标号处执行;如果(cx)=0那么什么也不做继续执行程序。
根据位移进行转移的指令总结
下面几条指令是根据位移进行转移(相对计算转移位置,而不是直接提供转移目的的IP和CS的值)
- jmp short 标号
- jmp near ptr 标号
- jcxz 标号
- loop 标号
这些指令之所以是间接计算标号的位置,是为了方便在代码中浮动装配,使得循环体或这些指令的代码段在任何位置都可以执行(不要超跳转范围)。而编译器会对跳转的范围进行检测,如果跳转超过了范围,编译器会报错。
注:jmp 2100:0是debug使用的汇编指令,编译器并不认识。
call和ret指令
ret和retf
ret和call都是转移指令,都是修改IP的值,或同时修改CS和IP。
ret指令用栈中的数据修改IP,实现的是近转移;retf指令用栈中的数据修改CS和IP的值,实现远转移。格式:直接用 ret。
ret执行步骤:
- (IP)=((SS)*16+(SP))
- (SP)=(SP)+2
retf执行步骤:
- (IP)=((SS)*16+(SP))
- (SP)=(SP)+2
- (CS)=((SS)*16+(SP))
- (SP)=(SP)+2
所以ret指令相当于 pop ip,执行retf指令相当于执行pop ip,pop cs。
call指令
call指令也是一个转移指令,执行格式:call 目标(具体使用接下来说明),call的执行步骤:
- 将当前的IP或CS和IP入栈
- 转移
call不能实现短转移,但它实现转移的原理和jmp相同。
根据位移转移:call 标号,近转移,16位转移范围,也是使用相对的转移地址。
执行步骤:
- (SP)=(SP)-2
- ((SS)*16+(SP))=(IP)
- (IP)=(IP)+16
所以执行这条命令相当于执行push ip,jmp near ptr 标号。
直接使用地址进行(远)转移:call far ptr 标号,执行步骤:
- (SP)=(SP)-2
- ((SS)*16+(SP))=(CS)
- (SP)=(SP)-2
- ((SS)*16+(SP))=(IP)
- (CS)=标号所在的段的段地址
- (IP)=标号的偏移地址
所以执行call far ptr 标号相当于执行push cs,push ip,jmp far ptr 标号
使用寄存器的值作为call的跳转地址:call 16位reg
- (SP)=(SP)-2
- ((SS)*16+(SP))=(IP)
- (IP)=(16为reg)
相当于执行push ip,jmp 16位reg
使用内存中的值作为call的跳转地址:call word ptr 内存单元地址,当然还有call dword ptr 内存单元地址,这样进行的就是远转移。
联合使用ret和call
联合使用ret和call实现子程序的框架:
1 | cs:code |
mul指令
mul是乘法指令,使用时应注意,两个相乘的数,要么都是8位,要么都是16位,如果是8位,那么其中一个默认放在al中,另一个在一个8位reg或字节内存单元中;若是16位,则一个默认在ax中,另一个在16位reg或字内存单元中。如果是8位乘法, 则结果放在ax中,结果是16位;若是16位乘法,结果默认在ax和dx中,dx高位,ax低位,共32位。
格式:mul reg 或 mul 内存单元,支持内存单元的各种寻址方式。
如mul word ptr [bx+si+8]代表:
1 | (ax)=(ax)*((ds)*16+(bx)+(si)+8)低16位 |
例:计算100*10
1 | mov al,100 |
参数的传递和模块化编程
看下面一段程序:计算data中第一行的数的立方存在第二行
1 | cs:code |
寄存器冲突
观察下面将data中的数据全转化为大写的代码:
1 | cs:code |
这段代码有一个问题出在,主函数部分使用cx设置循环次数4次,在循环中调用了子函数,而子函数中有一个判断语句jcxz也是用了cx,并且在之前修改了cx的值,造成逻辑错误。虽然修改的方法有很多,但我们应遵循以下的标准:
- 编写调用子程序的程序不必关心子程序使用了什么寄存器
- 编写子程序不用关心调用子程序的程序使用了什么寄存器
- 不会发生寄存器冲突
针对这三点,我们可以如下修改代码:
1 | ··· |
虽然和上面的程序中没有冲突的是si,但我们保险起见,在子程序开始时将子程序用到的所有的寄存器的内容存入栈中,在返回之前在出栈回到相应寄存器中。这样无论调用子程序的程序使用了什么寄存器,都不会产生寄存器冲突。
标志寄存器
标志寄存器
CPU中有一种特殊的寄存器——标志寄存器(不同CPU中的个数和结构都可能不同),主要有以下三种作用:
- 存储相关指令的某些执行结果
- 为CPU执行相关质量提供行为依据
- 控制CPU相关工作方式
8086CPU中的标志寄存器有16位,其中存储的信息通常被称为程序状态字(PSW),标志寄存器以下简称为flag。标志位如图:
1 | 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0 |
如上图所示,1,3,5,12,13,14,15位没有使用,没有任何意义,而其他几位都有不同的含义。
ZF标志
ZF位于flag第6位,零标志位,功能是记录相关指令执行后结果是否为0,如果结果为0,则ZF=1,否则ZF=0。如:
1 | mov ax,1 |
执行后结果为0,ZF=1。一般情况下,运算指令(如add,sub,mul,div,inc,or,and)影响标志寄存器,而传送指令(如mov,push,pop)不影响标志寄存器。
PF标志
flag的第2位是PF标志位,奇偶标志位,功能是记录相关指令执行后,其结果的所有bit中1的个数是否为偶数,若1的个数是偶数,pf=1,如果是奇数,fp=0。如:
1 | mov al,1 |
执行后结果为00001011b,有3个1,所以PF=0。
SF标志
flag的第7位是SF标志位,符号标志位,它记录相关指令执行后,结果是否为负,如果结果为负,则sf=1,结果为正,sf=0。计算机中通常用补码表示数据,一个数可以看成有符号数或无符号数,如:
1 | 00000001B,可以看成无符号1或有符号+1 |
也就是说对于同一个数字,可以当做有符号数运算也可以当做无符号数运算。如:
1 | mov al,10000001b |
这段代码结果是(al)=10000010b,可以将add指令进行的运算当做无符号运算,那么相当于129+1=130,也可以当做有符号运算,相当于-127+1=-126。SF标志就是在进行有符号运算的时候记录结果的符号的,当进行无符号运算的时候SF无意义(但还会影响SF,只是对我们来说没有意义了)。
CF标志
flag的第0位是CF标志位,进位标志位,一般情况下载进行无符号运算时,他记录了运算结果的最高有效为向更高为的进位值,或从更高位的借位值。加入一个无符号数据是8位的,也就是0-7个位,那么在做加法的时候就可能造成进位到第8位,这时并不是丢弃这个进位,而是记录在falg的CF位上。如:
1 | mov al,98h |
执行后al=30h,CF=1。当两个数据做减法的时候有可能向更高位借位,如97h-98h借位后相当于197h-198h,CF也可以用来记录借位,如:
1 | mov al,97h |
执行后(al)=FFH,CF=1记录了向更高位借位的信息。
OF标志
在进行有符号运算的时候,如果结果超过了机器能表示的范围称为“溢出”。机器能表示的范围是指如8位寄存器存放或一个内存单元存放,表示范围就是-128~127,16位同理。如果超出了这个范围就叫做溢出,如:
1 | mov al,98 |
第一段代码(al)=(al)+99=98+99=197超过了8位能表示的有符号数的范围,第二段代码结果(al)=(al)+(-120)=(-16)+(-12-)=-136也超过了8位有符号的范围,所以计算的结果是不可信的。如第一段代码计算之后(al)=0C5H,换成补码表示的是-59,98+99=-59很明显是不正确的结果。
flag的第11位是OF标志位,溢出标志位,一般情况下,OF记录有符号数运算结果是否溢出,如果溢出则OF=1,如果没有溢出,OF=0。所以CF是对无符号数的标志,OF是对有符号的标志。但对于一个运算指令,他们是同时生效的,只不过这个指令究竟是有符号还是无符号,是看实际的操作的。有符号CF无意义,无符号OF无意义。
adc指令
adc是带进位加法指令,利用了CF标志位上记录的进位值。格式:adc 操作对象1,操作对象2。功能:操作对象1=操作对象1+操作对象2+CF。如abc ax,bx实现的是(ax)=(ax)+(bx)+CF,如:
1 | mov ax,2 |
注意这段代码,首先ax中的值是2,bx中的值是1,然后进行(bx)-(ax)的计算,结果是-1造成了无符号的借位,此时CF=1,在进行adc ax,1时,进行的是(ax)+1+CF=2+1+1=4。仔细分析一下就可以发现,如果把整个加法分开,低位先相加,然后高位相加再加上进位CF, 就是一个完整的加法运算,也就是说add ax,dx这个指令可以拆分为:
1 | add al,bl |
所以有了adc这个指令我们就可以完成一些更庞大的数据量的加法运算。如计算1EF000H+000H的值:
1 | mov ax,001eh |
注:inc和loop指令不影响CF位。
sbb指令
sbb和adc类似,是带借位的减法,格式:sbb 操作对象1,操作对象2,执行的功能是操作对象1=操作对象1-操作对象2-CF,如:sbb ax,bx即(ax)=(ax)-(bx)-CF。sbb指令影响CF。
cmp指令
cmp是比较指令,cmp的功能相当于减法,只是不保存结果。cmp执行后影响标志寄存器,其他相关指令通过识别被影响的标志位来得知结果。格式:cmp 操作对象1,操作对象2,执行功能是计算对操作对象1-操作对象2但不保存结果,仅仅根据结果对标志位进行设置,如:cmp ax,ax结果为0,但并不保存在ax中,执行之后zf=1,pf=1,sf=0,cf=0,of=0。若执行cmp ax,bx通过标志位就可以判断结果:
1 | 若(ax)=(bx)则(ax)-(bx)=0,zf=1 |
但实际上往往会出现溢出,如34-(-96)=82H(82H是-126的补码),但应该等于130超出了补码表示的范围,所以sf=1。我们可以同时检验sf和of两个来验证cmp的结果:cmp ah,bh
- 若sf=1,of=0说明没有溢出,那么sf的计算结果正确(ah)<(bh)
- 若sf=1,of=1说明出现了溢出,那么sf结果相反(ah)>(bh)
- 若sf=0,of=1说明有溢出,那么sf结果相反(ah)<(bh)
- 若sf=0,of=0说明没有溢出,那么结果正确(ah)>=(bh)
检测比较结果的条件转移指令
下面几条指令和cmp一起使用,检测不同的标志位来达到不同的条件跳转效果:
指令 | 含义 | 检测的标志位 |
---|---|---|
je | 等于则转移 | zf=1 |
jne | 不等于转移 | zf=0 |
jb | 小于转移 | cf=1 |
jnb | 不小于转移 | cf=0 |
ja | 大于转移 | cf=0且zf=0 |
jna | 不大于转移 | cf=1或zf=1 |
指令中的字母含义如下:
- e:equa;
- ne:not equal
- b:below
- nb:not below
- a:above
- na:not above
上面的检测都是在cmp进行无符号比较时的检测位,有符号数检测原理一样,只是检测的标志位不同而已。下面看一个例子,如果(ah)=(bh)则(ah)=(ah)+(ah),否则(ah)=(ah)+(bh)
1 | cmp ah,bh |
这里注意的是,je检测的是zf位,而不管之前执行的是什么指令,只要zf=1就会发生转移,所以cmp的位置需要仔细的把控,当然是否和cmp配合使用也是取决于编程者,下面例子实现了统计data中数值为8的字节个数,然后用ax保存:
1 | ··· |
DF标志位和串传送指令
flag的第10位是DF标志位,方向标志位,在串处理中,每次操作si,di的增减。
- df=0每次操作后si,di递增
- df=1每次操作后si,di递减
串传送指令,movsb,这个指令相当于执行:
((es)*16+(di))=((ds)*16+(si))
如果df=0:(si)=(si)+1,(di)=(di)+1
如果df=1:(si)=(si)-1,(di)=(di)-1
可以看出,movsb是将DS:SI指向的内存单元中的字节送入ES:DI中,然后根据DF的值对SI和DI增减1
同理mobsw就是将DS:SI指向的内存单元中的字送入ES:DI中,然后根据DF的值对SI和DI增减2
但一般来说,movsb和movsw都是和rep联合使用的,格式:rep movsb,这相当于:
1 | s:movsb |
所以rep的作用是根据cx的值重复执行后面的串传送指令,由于每次执行movsb之后si和di都会自行增减,所以使用rep可以完成(cx)个字节的传送。movsw也一样。
由于DF位决定着串传送的方向,所以这里有两条指令用来设置df的值:
1 | cld:df=0 |
例子:使用串传送指令将data段中第一个字符串复制到他后面的空间中:
1 | ··· |
pushf和popf
pushf的功能是将标志寄存器的值入栈,popf是出栈标志寄存器。有了这两个命令,就可以直接访问标志寄存器了,如:
1 | mov ax,0 |
标志寄存器在Debug中的表示
Debug中-r查看寄存器信息,最后有一段表示,下面列出我们已知的寄存器在Debug里的表示:
标志 | 值1的标记 | 值0的标记 |
---|---|---|
of | OV | NV |
sf | NG | PL |
zf | ZR | NZ |
pf | PE | PO |
cf | CY | NC |
df | DN | UP |
内中断
内中断的产生
任何一个通用CPU都拥有执行完当前正在执行的指令后,检测到从CPU发来的中断信息,然后立即去处理中断信息的能力。这里的中断信息是指几个具有先后顺序的硬件操作,当CPU出现下面请看时会产生中断信息,相应的中断信息类型码(供CPU区分来源,是字节型,共256种)如下:
- 除法错误,如执行div指令出现除法溢出 0
- 单步执行 1
- 执行into指令 4
- 执行int指令 指令执行的int n后面的n就是一个字节型立即数,即为中断类型码
中断处理和中断向量表
CPU接收到中断信息之后,往往要对中断信息进行处理,而如何处理使我们编程决定的。而CPU通过中断向量表来根据中断类型找到处理程序的入口地址(CS:IP)也称为中断向量。
中断向量表中存放着不同的中断类型对应的中断向量(处理程序的入口地址),中断向量表存放在内存中,8086PC指定必须放在内存地址0处,从0000:0000到0000:03FF的1024个单元存放中断向量表,每个表项占两个字,四个字节。
CPU会自动根据中断类型找到对应的中断向量并设置CS和IP的值,这个过程称为中断过程,步骤如下:
- (从中断信息中)取得中断类型码
- 标志寄存器的值入栈(暂存)pushf
- 设置标志寄存器第8位TF和第9位IF的值为0 TF=0,IF=0
- CS内容入栈 push cs
- IP内容入栈 push ip
- 在中断向量表中找到对应的CS和IP值并设置
(ip)=(N*4),(cs)=(N*4+2)
这么做的目的是,在中断处理之后还要回复CPU的现场(各个寄存器的值),所以先把那些入栈。
中断处理程序和iret指令
运行中的CPU随时都可能接收到中断信息,所以CPU随时都可能执行中断程序,执行的步骤:
- 保存用到的寄存器
- 处理中断
- 回复用到的寄存器
- 用iret返回
iret的指令功能是:pop ip pop cs popf(前面说到了,这三个寄存器的入栈是硬件自动完成的,所以iret是和硬件自动完成的步骤配合使用的)。
以处理0号除法溢出中断为例,我们想要编写除法溢出的中断处理程序需要解决如下几步问题:
- 编写程序
- 找到一段没有使用的内存空间
- 将程序写入到内存
- 将内存中的程序的入口写入0号中断的向量表位置
我们可以采取下面框架来完成这个过程:
1 | ··· |
可以看出我们分成了两部分,第一部分称之为“安装”,第二部分是代码实现。安装部分的函数实现思路如下:
1 | 设置es:di至项目的地址 |
实现如下:
1 | start:mov ax,cs |
这里offset do0end-fooset do0的意思是do0到do0end的代码长度,-是编译器可以识别并运算的符号,也就是说编译器可以再编译时处理表达式,如8-4等。还要注意的是,假如代码部分要输出“owerflow!”的话,需要将输出的内容写在代码部分并写入选择的内存中,否则如果单单在这个安装程序开始开辟一段data段的话,是会在程序返回时被释放。如:
1 | do0:jmp short do0start |
单步中断
当标志寄存器的TF标志位为1的时候,CPU会在执行一条语句之后将资源入栈,然后去执行单步中断处理程序,如Debug就是运行在单步中断的条件下的,它能让CPU每执行一条指令都暂停,然后我们可以查看CPU的状态。但CPU可以防止在运行单步中断处理程序的时候再发生中断然后又去调用单步中断处理程序…CPU可以将TF置零,这样就不会再中断了。CPU提供这个功能就是为了单步跟踪程序的执行。
但需要注意的是,CPU并不会每次接收中断信息之后立即执行,在某些特定情况下它不会立即响应中断,如设置ss寄存器的时候如果接收到了中断信息,就不会响应。因为我们需要连续设置ss和ip的值,在debug中单步执行的时候也是,mov ss,ax和mov sp,0是在一步之内执行的,所以我们需要灵活使用这个特性,sp紧跟着ss执行,而不要在设置ss和sp之间插入其他指令。
int指令
int指令
int指令也会引发中断,使用格式是int n,n就是int引发的中断类型码,int中断的执行过程:
- 获取类型码n
- 标志寄存器入栈,if=0,tf=0
- cs,ip入栈
(ip)=(n*4),(cs)=(n*4+2)
- 执行n号中断的程序
所以我们可以使用int指令调用任何一个中断的中断程序,如int 0调用除法溢出中断。一般情况下,系统将一些具有一定功能的小程序以中断的方式提供给程序调用,当然也可以自己编写,可以简称为中断例程。
编写中断例程
如编写中断7ch的中断例程,实现word型数据的平方,返回dx和ax中。求2*3456^2,代码:
1 | cs:code |
接下来写7ch的功能和安装程序,并修改7ch中断向量表:
1 | cs:code |
编写7ch中断实现loop指令,主程序输出80个“!”:
1 | ··· |
7ch实现部分:
1 | lp:push bp |
因为bx里面是需要专一的偏移地址,而使用bp的时候默认段寄存器是ss,所以add [bp+2],bx就可以实现将栈中的sp的值修改回s处,自行推导一下就ok。
BIOS和DOS提供的中断例程
系统ROM中存放着一套程序,称为BIOS,除此之外还有DOS都提供了一套可以供我们调用的中断例程,不同历程有不同的中断类型码,并且还能根据传入的参数不同而实现不同的功能,也就是说同一个类型码的中断例程可以实现很多不同功能,如int 10h是BIOS提供的包含了多个和屏幕输出相关子程序的中断例程。传参数如下面例子:
1 | ··· |
BIOS和DOS安装历程的过程是,开机后CPU一加电,自动初始化CS为0FFFFH,IP为0,而在这个地方有一个跳转指令,挑战到BIOS和系统检测初始化程序。在BIOS系统检测初始化程序中会设置中断向量表中的值。
端口
端口的概念
各种存储器都要和CPU的地址线,数据线,控制线相连,在CPU看来,总线就是一个由若干个存储单元构成的逻辑存储器,称之为内存地址空间。除了各种存储器,通过总线和CPU相连的还有下面三种芯片:
- 各种接口卡(如网卡显卡)上的接口芯片,他们控制接口卡工作
- 主板上的接口芯片,CPU通过它们访问外部设备
- 其他芯片,用来存储相关系统信息,或进行相应的输入输出
上面的芯片中都有一种由CPU读写的寄存器,它们都和CPU的总线相连(通过各自的芯片),CPU对他们进行读写时候都通过控制线向他们所在的芯片发出端口读写指令。
所以,对于CPU来说,将这些寄存器都当做端口,对他们进行统一编址,建立了一个端口地址空间,每一个端口拥有一个地址,所以CPU可以直接读取下面三个地方的数据:
- CPU内部的寄存器
- 内存单元
- 端口
端口的写
因为端口所在的芯片和CPU通过总线相连,所以端口地址和内存地址一样通过地址总线传送,并且在PC系统中,CPU最多可以定位64KB个不同的端口,所以端口地址范围是0~65535。
对端口的读写不能使用mov,push,pop等内存读写指令,端口的读写指令只有两个:in和out分别用于从端口读取数据和往端口写入数据。
访问端口的步骤:
- CPU通过地址总线降低至信息60h发出
- CPU通过控制线发出端口读命令,选中端口所在芯片,并通知它要从中读取数据
- 端口所在芯片将目标端口中的数据通过数据线送入CPU
注:在in和out指令中,只能通过ax或al来存放从端口中读入的数据或要发送到端口中的数据,且访问8位端口时,用al,访问16位端口用ax。
对0~255以内的端口进行读写时:
1 | in al,20h |
对256~65535的端口进行读写时,需要将端口号写在dx中:
1 | mov dx,3f8h |
CMOS RAM芯片
PC中有一个叫做CMOS RAM的芯片,称为CMOS,有如下特征:
- 包含一个实时钟和一个有128个存储单元的RAM存储器(早期的计算机64个字节)
- 靠电池供电,关机后内部的实时钟仍可继续工作,RAM中的信息不丢失
- 128个字节的RAM中,内部实时钟占用0~0dh单元保存时间信息,其余大部分单元用于保存系统配置信息,供系统启动时BIOS程序读取,BIOS也提供了相关的程序可以让我们在开机时配置CMOS中的系统信息。
- 芯片内部有两个端口70h和71h,CPU通过这两个端口读写CMOS
- 70h为地址端口,存放要访问CMOS单元的地址,71h为数据端口,存放从选定的单元中读取的数据,或写入的数据。
所以可以看出,想要从CMOS中读取数据,应分两步,先将单元号送入70h,然后再从71h读出对应号的数据。
shl和shr指令
shl和shr是逻辑移位指令,shl是逻辑左移,功能为:
- 将一个寄存器或内存单元中的数向左移位
- 将最后移出的一位写入CF
- 最低位补0
如:mov al,01001000b shl al,1执行结束后(al)=10010000b,CF=0。
注:如果移动位数大于1,那么必须将移动位数写在cl中。
1 | mov al,01010001b |
执行后(al)=10001000b,最后移出的一位是0,所以CF=0。可以看出左移操作相当于x=x*2。
右移shr同理,最高位用0补充,移出的写入CF,若移动位数大于1,也要写在cl中,相当于x=x/2
在CMOS中存放着当前时间的年月日时分秒,分别存在下面的单元内:
秒 | 分 | 时 | 日 | 月 | 年 |
---|---|---|---|---|---|
0 | 2 | 4 | 7 | 8 | 9 |
每个信息使用一个字节存放,以BCD码的形式,BCD码是对0-9这几个数字使用二进制表示,如:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
0000 | 0001 | 0010 | 0011 | 0100 | 0101 | 0110 | 0111 | 1000 | 1001 |
如果要表示一个两位数如13,就是一个字节高四位是十位1的BCD码,低四位是个位3的BCD码,表示为00010011b。下面程序获取当前月份:
1 | ··· |
外中断
接口芯片和端口
CPU除了需要拥有运算的能力,还要拥有I/O(输入输出)能力,我们键入一个字母,要能处理,所以我们需要面对的是:外部设备随时的输入和CPU何处得到外部设备的输入。
外部设备拥有自己的芯片连接到主板上,这些芯片内部由若干寄存器,而CPU将这些寄存器当做端口访问,外设的输入或CPU向外设输出都是送给对应的端口然后再由芯片处理送给目标(CPU或外设)。
外中断
CPU提供外中断来处理这些如随时可能出现的来自外设的输入,在PC系统中,外中断源有以下两类:
可屏蔽中断:CPU可以不响应的外部中断,CPU是否响应看标志寄存器IF的设置,如果IF=1,CPU执行完当前指令后响应中断,如果IF=0,则不响应。可屏蔽中断的执行步骤和内部中断类似:
- 获取中断类型码n(从外部通过总线输入)
- 标志寄存器入栈,IF=0,TF=0
- CS,IP入栈
(IP)=(n*4),(CS)=(n*4+2)
可见,将IF置零的原因是以免在处理中断程序的时候再发生中断。当然我们也可以选择处理,下面两个指令可以改变IF的值:sti,设置IF=1,cli,设置IF=0。
不可屏蔽中断:CPU必须响应的外部中断,CPU检测到不可屏蔽中断后执行完当前指令立即响应中断。8086CPU中不可屏蔽中断的中断类型码固定位2,所以中断过程中不需要获取中断类型码,步骤:
- 标志寄存器入栈,IF=0,TF=0
- CS,IP入栈
- (IP)=(8),(CS)=(0AH)
几乎所有由外设引发的外中断都是可屏蔽中断,如键盘输入,不可屏蔽中断通常是在系统中又必须处理的紧急情况发生时通知CPU的中断信息。
PC键盘处理过程
键盘上每个按键都相当于一个开关,按下就是开关接通,抬起就是开关断开。键盘上有一个芯片对键盘中每一个键盘的状态进行扫描,开关按下生成一个扫描码——通码,记录按下的按键位置,开关抬起也会产生一个扫描——断码,码记录松开的位置,都是送入60h端口。通码的第7位为0,断码第7位为1,也就是说断码=通码+80h。P247表。
当键盘输入送达60h时,相关新品就会向CPU发送中断类型码为9的可屏蔽中断信息。CPU检测到该中断信息之后,如果IF=1,响应中断,引发中断过程并执行int9的中断例程。BIOS中int9的中断程序用来进行基本的键盘输入处理,步骤如下:
- 读出60h的扫描码
- 如果是字符的扫描码,将对应的字符的ASCII吗存入内存中的BIOS键盘缓冲区,如果是控制键(Ctrl)和切换键(CapsLock)扫描码,则将其转换为状态字(二进制位记录控制键和切换键状态的字节)写入内存中的存储状态字节的单元。
- 对键盘系统进行相关控制,如向新平发出应答
BIOS中键盘缓冲区能存储15个键盘输入,每个键盘输入两字节,高位存放扫描码,低位存放字符。此外,0040:17单元存放键盘状态字节,记录了控制键和切换键的状态,记录信息如下:
位 | 含义 |
---|---|
0 | 右shift,1表示按下 |
1 | 左shift,1按下 |
2 | Ctrl,1按下 |
3 | Alt,1按下 |
4 | ScrollLock状态,1表示指示灯亮 |
5 | NumLock状态,1表示小键盘输入的是数字 |
6 | CapsLock状态,1表示大写字母 |
7 | Insert状态,1表示处于删除状态 |
可以看书P276的一个改写int 9的中断例程。
直接定址表
描述单元长度的标号
我们可以使用下面的标号来表示数据的开始:
1 | ··· |
a,b都是代表对应数据的起始地址,但并不能判断数据的长度或类型。下面一段程序将a中的8个数累加存入b中:
1 | cs:code |
code段中a和b后并没有”:”号,这种写法同时描述内存地址和单元长度的标号。a描述了地址code:0和从这个地址开始后的内存单元都是字节单元,而b描述了地址code:8和从这个地址开始以后的内存单元都是字单元。所以b相当于CS:[8],a[si]相当于CS:0[si],使用这种标号,我们可以间接地访问内存数据。
其它段中使用数据标号
刚说的第一种标号即加”:”号的标号,只能使用在代码段中,不能在其他段中使用。如果想要在其它段中(如data段)使用标号可以使用第二种:
1 | cs:code,ds:data |
如果想在代码段中直接使用数据标号访问数据,需要使用assume伪指令将标号所在段和一个寄存器联系起来,是让寄存器明白,我们要访问的数据在ds指向的段中,但编译器并不会真的将段地址存入ds中,我们做了如下假设之后,编译器在编译的时候就会默认ds中已经存放了data的地址,如下面的编译例子:
1 | mov al,a[si] |
可以看出编译器默认了a[si]在ds所在的段中。所以我们需要手工指定ds指向data:
1 | mov ax,data |
也可以这么使用:
1 | data segment |
c处存放的是a和b的偏移地址,相当于c dw offset a,offset b。同理c dd a,b相当于c dw offset a,seg a,offset b,seg b即存的是a和b的段地址和偏移地址。
直接定址表
使用查表的方法编写相关程序,如输出一个字节型数据的16进制形式(子程序):
1 | showbyte jmp short show |
可见我们直接使用需要的数值和地址的映射关系来寻找需要的数据。
程序入口地址的直接定址表
可以看书P296的例程,主要思想是,编写多个子程序实现不同功能,每个子程序有自己的标号,如sub1,sub2···等。将它们存在一个表中:
1 | table dw sub1,sub2,sub3,sub4 |
然后按照之前的方法使用如:
1 | setscreen:jmp short set |
使用BIOS进行键盘输入和磁盘读写
int 9中断例程对键盘输入的处理
键盘处理依次按下A,B,C,D,E,shift_A,A的过程:
我们知道,键盘有16字的缓冲区,可以存放15个按键的扫描码和对应的ASCII码值,如下:
1 | | | | | | | | | | | | | | | | | | |
我们按下A时,引发键盘中断,CPU执行int 9中断例程,从60h端口读出A键通码,然后检测状态字,看是否有控制键或切换键按下,发现没有,将A的扫描码1eh和对应的ASCII码’a’61h写在缓冲区:
1 | |1e61| | | | | | | | | | | | | | | | | |
然后BCDE同理:
1 | |1e61|3062|2e63|2064|1265| | | | | | | | | | | | | | | | | |
在按下shift之后引发键盘中断,int 9程序接受了shift的通码之后设置0040:17处状态字第一位为1,表示左shift按下,接下来按A间,引发中断,int 9中断例程从60h端口督导通码之后检测状态字,发现左shift被按下,于是将A的键盘扫描码1eh和’A’的ASCII41h写入缓冲区:
1 | |1e61|3062|2e63|2064|1265|1e41| | | | | | | | | | | | | | | | |
松开shift,0040:17第一位变回0,之后又按下A和之前一样。
int 16h读取键盘缓冲区
int 16h可以供程序员调用,编号为0的功能是从键盘缓冲区读一个键盘输入,(ah)=扫描码,(al)=ascii码。如:
1 | mov ah,0 |
执行后,缓冲区第一个没了,然后ah中是1eh,al中是61h。如果缓冲区为空的时候执行,那么会循环等待知道缓冲区有数据,所以int 16h的0号功能的步骤是:
- 检测键盘缓冲区是否有数据
- 没有则继续1
- 读取第一个单元的键盘输入
- 扫描码送ah,ascii码送al
int 13h读写磁盘
3.5寸软盘分为上下两面,每面80个磁道,每个磁道18个扇区,每个扇区512字节,共约1.44MB。磁盘的实际访问时磁盘控制器进行的,我们通过控制磁盘控制器来控制磁盘,只能以扇区为单位读写磁盘,每次需要给出面号,磁道号,和扇区号,面号和磁道号从0开始,扇区号从1开始。BIOS提供int 13h来实现访问磁盘,读取0面0道1扇区的内容到0:200的程序:
1 | mov ax,0 |
es:bx指向接收数据的内存区。操作成功(ah)=0,(al)=读入的扇区数,操作失败(ah)=错误代码。将0:200的数据写入0面0道1扇区:
1 | mov ax,0 |
es:bx指向写入磁盘的数据,操作成功(ah)=0,(al)=写入的扇区数,操作失败(ah)=错误代码