suxxsfe

一言(ヒトコト)

汇编学习笔记-3

8 内中断

任何通用 cpu,都会在执行完当前指令后,检测从外部或内部传来的中断信息,并作出相应处理
这使得 cpu 在运行时能对中断请求及时处理,处理完再返回之前执行的地方,这个发出中断请求的东西叫中断源,根据它的不同可以把中断分为硬件中断、软件中断,其中硬件中断又分为外部中断和内部中断。其中外部中断是计算机外设发出的,比如键盘、打印机等,这种中断是可以通过一定手段来屏蔽的。内部中断是由于硬件出错(比如断电),或运算出错(除以零,溢出,单步中断等),而产生的,不能被屏蔽
而软件中断,只是可被调用的一般程序或 dos 的系统功能(比如 int 21H),可以说它们并不是真正的中断

下面就来说内部中断

8.1 产生

8086cpu,以下几种情形将会产生中断

  • 除法错误,除以零或溢出,中断类型码 \(0\)
  • 单步中断,中断类型码 \(1\)
  • into 指令,中断类型码 \(4\)
  • int 指令,中断码为 int X 中的 X(一个字节型立即数)

其中的中断码就是用来确定中断的来源,字节型数据

8.2 中断向量表

cpu 在收到中断信息时,会执行相应的处理。而这个处理,就是中断程序
中断码是字节型的,如何靠这个字节型数据索引到中断程序,就要用到中断向量表
在 8086 中,中断向量表存在与内存单元 0:0 到 0:3ff,共 1024 字节,因为一个内存地址应该由段地址、偏移地址共同给出,四个字节(高地址是段地址,低地址是偏移地址),然后一共 256 种中断码(中断码字节型),所以共 1024 字节
可以知道,如果中断码为 \(N\),则对应的段地址是 \(4N+2\),偏移地址 \(4N\)

还记得之前说过的一段安全的内存空间没?那个是 0:200 到 0:2ff,发现是中断向量表的后一部分,说它是安全的,是因为虽然 8086cpu 支持 256 种中断,但是实际用到的远没有这么多,后面的这部分中断向量表为空,而一般其他程序也不会去使用这段内存,所以可以安全的使用它

8.3 中断过程

8086cpu 在收到中断信息后,会有如下过程

  • 获取中断类型码 \(N\)
  • 为了保存标志寄存器的值,防止其被修改,将其入栈
  • 将标志寄存器第 8 为 TF 和第 9 为 IF 设为 \(0\)(以后会说为什么这样做)
  • 保存现在的 cs 和 ip,先入栈 cs,再入栈 ip
  • 获取中断程序的入口地址,\((ip)=(4N),(cs)=(4N+2)\)

完成后就开始执行中断程序了
注意这个中断过程是直接由硬件执行的,不能被更改,能更改的只是在这之后执行的中断程序

8.4 中断程序和 iret

中断处理程序和之前说的子程序比较像,或者可以说它是一类特殊的子程序
大概的过程,就是先入栈各种要用到的寄存器、内存单元,然后处理中断,再恢复之前入栈的值,最后 iret 返回
而这个 iret 执行的过程就是,按顺序分别弹栈 ip,cs 和标志寄存器,也就恢复了之前中断过程中压栈的值

其实可以发现中断和 iret,执行的方式与 callret 有一点相像

8.5 除法错误的中断

发生除法的溢出、除以零等情况时,会发生 0 号中断,也就是除法错误中断,比如下面这样一段程序:

mov ax,1000H
mov bh,1
div bh

如果在 xp 或 dos 中执行,debug 跟踪,会输出 Divide overflow 然后返回 cmd 或 dos
如果在 dosbox 执行,我这里是会卡住,进 debug 跟踪发现它是在代码中反复跳转,如下图

8.6 处理 0 号中断

比如我们想改变 0 号中断的处理方式,用自己写的中断处理程序,在屏幕中央输出 overflow!

首先编写这个处理程序很简单,之前也做过相似的,问题是如何在发生中断时让他被执行
也就是要先把这个程序安装,才能在发生 0 号中断时执行它
想一下,根据中断过程,cpu 会在中断向量表中获取入口地址,那我们改中断向量表就行了
那中断程序存放在哪?当然是之前说的那段安全的内存空间,所以安装过程首先要做的事也就明确了,将处理程序存入 0:200 中(一开始中断程序在安装程序中,要传送到对应地址),然后改 0 号中断的中断向量表指向 0:200
更具体的,我们分别用两个标号来表示中断程序的开始和结尾,然后 offset do0end-offset do0 来设置传送的代码的长度,用 ret movsb,改中断向量表就是 \((4\cdot 0)=200H,(4\cdot 0+2)=0\)

assume cs:code
code segment
start:
	mov ax,cs
	mov ds,ax
	mov si,offset do0;从 do0 标号开始传
	xor ax,ax
	mov es,ax
	mov di,200H;传到 0:200H
	mov cx,offset do0end-offset do0
	cld;正向传
	rep movsb;传送代码,安装
	
	mov word ptr es:[0*4],200H;中断向量表
	mov word ptr es:[0*4+2],0

    mov ax,4c00h
    int 21h
	
do0:
	jmp short do0start
	db "overflow!"
	;存放在被安装的代码段中,而不是在安装程序中开一段内存来放
	;因为安装程序执行完,他的内存就被释放了,后面再执行中断程序的时候就读不到这段字符了
do0start:
	push ax
	push ds
	push si
	push es
	push di

	mov ax,cs
	mov ds,ax
	mov si,202H;ds:si 指向字符串
	mov ax,0B800H
	mov es,ax
	mov di,12*160+36*2;es:di 指向显存
	
	mov cx,9
	S:
		mov al,[si]
		mov es:[di],al
		mov byte ptr es:[di+1],1;blue
		inc si
		add di,2
	loop S
	
	pop di
	pop es
	pop si
	pop ds
	pop ax
	iret

	;mov ax,4c00H
	;int 21H;如果是这样那么执行完直接返回 cmd 了,没有 iret,所以也不用保存、恢复寄存器的值
do0end:
code ends
end start

然后在执行溢出的代码前先执行这个代码,安装中断程序
但发现如果最后写 iret 而不是注释里的两句(这两句会让程序直接返回 cmd),程序虽然输出了相应的字符串,但仍然会卡死
为什么呢,进 debug 观察一下,如果是 xp 而不是 dosbox,不会自动进入中断程序,而是直接进入中断程序执行以后的地址(但是此时会卡),可以手动用 g 跳转一下,看栈中 ip 的值,与相应的指令(在溢出程序中)

会发现,原来对于除法错误中断,在中断过程中入栈的 cs:ip 其实是指向这个错误的语句的(比如 call 指令就是入栈的 cs:ip 指向下一句指令,这样 ret 回来就能正常执行),那么我们在中断程序返回时直接取出这个 ip,又到了溢出的那一句,又执行中断程序,就死循环了
所以应该用注释里面的那两句,如果要用 iret 的话,需要把栈中的 ip 修改一下再放回去

8.7 单步中断

cpu 提供这样一种中断机制的意义在于,如果不能做到单步中断,cpu 一加点就从当前的 cs:ip 开始一直执行下去,不可控制,如果真是那样就要有一个程序一直控制 cpu 直到他执行完最后一个指令,这显然是不可能的
debug 的 t 指令也正是利用了这样的机制,使用 t 时,debug 将 TF 设为 \(1\),然后执行一步程序,并单步中断,这个单步中断的中断程序就是将寄存器的值显示在屏幕并等待下一个指令的输入

这也解释了为什么中断过程要把 TF 设为 \(0\)

8.8 响应中断的特殊情况

有时候,cpu 即便收到中断信息,也不会进行中断

比如,我们想要同时设置 ss 和 sp 时,就应该将这两条语句放在一起,中间不穿插其他指令
也可以用 debug 看一下,t 指令在遇到改变 ss 时会一次执行两个指令

9 int 指令

int 就是一种软件中断

9.1 int

格式为 int n,其中 \(n\) 是终端类型码,中断过程与之前将的类似,不再说了
发现它与 call 比较相似,都是调用了一段程序(int 是调用了中断程序)

然后也可以根据这个 \(n\),改变对应的中断向量表,让发生这个中断时去执行我们编写的程序,就和上一章编写 0 号中断的中断程序的方法类似

如果使用这样一段代码来 int 0

mov ax,0
add ax,2
int 0

安装上一章编写的输出 overflow! 的程序,在不使用注释中的代码或更改栈中 ip 的情况下,直接 iret 发现他是可以正常返回的
那么再进 debug 观察一下

发现他的栈中 ip 指向的是下一个语句,也就是那个 mov ax,4c00H(这里高亮的那一句是我看错了,应该高亮 mov 那句,懒得改了
所以可以知道,用 int 指令跳转时,入栈的 ip 是下一个语句的偏移地址,这里和发生错误时的入栈 ip 是不同的,可以参看上一章进 debug 看除法溢出错误的栈中 ip 的情况

9.2 BIOS 和 DOS 提供的中断程序

BIOS 存放在主板的 rom 中,包含这几部分:  

- 硬件系统的检测和初始化程序
- 外部中断和内部中断的中断例程
- 对硬件设备进行 IO 的中断例程
- 其他和硬件相关的中断例程

可以调用 int 来执行相应的 BIOS 和 DOS 中断程序,尤其是和硬件相关的  

9.3 BIOS 和 DOS 提供的中断程序的安装过程

码的太累了,这部分没理解性的东西,直接放图

9.4 BIOS 中断例程的使用

比如 int 10H 的 2 号子程序,提供了设置光标的程序,参数是 bh 显存页码,dh 行号,dl 列号
int 10H 的 9 号子程序,提供了像光标处连续输出多个同样字符的程序,参数是 al 字符,bl 颜色,bh 显存页码,cx 重复次数
而这个子程序的编号用 ah 存放,那么可以用下面这个程序向屏幕输出三个 a

assume cs:codesg
codesg segment
start:
	mov ah,2;2 号子程序,设置光标
	mov bh,0;页码
	mov dh,5;行
	mov dl,12;列
	int 10H
	
	mov ah,9;9 号子程序,显示字符
	mov al,'a';字符
	mov bl,1000010B;颜色
	mov bh,0
	mov cx,3
	int 10H
	
	mov ax,4c00H
	int 21H
codesg ends
end start

9.5 DOS 中断例程的使用

现在可以解释为什么之前的程序都要在结尾加一句:

mov ax,4c00H
int 21H

了,这个 \(4CH\) 在 ah 中,他是调用的 int 21H 的子程序号,这个子程序实现了程序返回功能,而 al 中的 \(0\),就是程序的返回值,程序正常返回返回值就是 \(0\)

int 21H 的 9 号子程序,实现了从光标处向屏幕输出一个字符串,以 $ 结尾(如果到了最后一行最后一个字符,会回到第一行地一个字符),参数是 ds:dx 指向字符串开头
比如下面这个例子

assume cs:codesg
datasg segment
	db 'hahahahaha','$'
datasg ends

codesg segment
start:
	mov ah,2;置光标
	mov bh,0
	mov dh,5
	mov dl,12
	int 10H
	
	mov ax,datasg
	mov ds,ax
	mov dx,0
	mov ah,9;9 号子程序,显示字符串
	int 21H

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

10 端口

草草草草草,本来这里都写了一些了,然后电脑突然死机(估计只是图形界面爆炸了,终端指令还是照样能输),但博客保存不了了【悲】

cpu 可以从寄存器、内存、端口直接读取数据。与 cpu 连接的芯片,都有一个寄存器供 cpu 读取数据,这些寄存器在物理上可能不相邻,但都被 cpu 当作端口,统一编址,形成一个端口地址空间,cpu 就通过控制总线向他们发出读写指令

10.1 端口的读写

与对内存的读写相似,但毕竟要和内存区分,所以不能用一般的指令,而是用 inout,分别是从某端口读入数据,和向某端口写入数据。在底层原理上与读写内存相似
访问 8 位端口和 16 位端口时,读出来的数和写入的数必须分别存放在 al 和 ax
对于 0 到 255 号端口时,端口号可以是立即数或 dx,对于 256 到 65535,端口号必须是 dx

in al,20H;从 20H 端口读入数据
out 20H,al;向 20H 端口写入数据

mov dx,03F8H
in al,dx;与上面同理,只是端口号换成了 dx
out dx,al

10.2 shl 和 shr

分别是左移和右移,这两个运算具体是啥不用多说
就是移出的最后一位会被存在 CF 中,比如将 \(01001110B\) 这个字节型数据左移三位,那么 \(CF=0\)。右移同理
格式就是 shl XX,1shl XX,cl,就是如果移动位数不是 \(1\),则必须被存放在 cl 中然后在左或右移,XX 可以是通用寄存器或内存单元(要指定长度),右移也同理

10.3 CMOS RAM

CMOS RAM 芯片

  • 包含一个实时钟和一个 128 个内存单元的 RAM
  • 靠电池供电,就是断电以后信息不会丢失
  • RAM 中,0 到 0DH 保存的是实时钟信息,其他的大部分是供 BIOS 读取的系统配置信息
  • 有两个端口,70H 和 71H。70H 是地址端口,存放要访问的内存单元的地址,71H 是数据端口,存放从选定的内存单元的读取结果,或要写入的数据。所以,想要访问 CMOS RAM 的时候,就先把地址送入 70H,然后从 71H 中读或写

刚才说过里面的实时钟信息,也就是这个使得电脑不会断电后丢失当前的时间信息。他的年,月,日,小时,分钟,秒,各占一个字节,分别在 9,8,7,4,2,0 号单元
他们以 BCD 码的形式存放,比较符合人类阅读习惯

我们可以做一个从 CMOS RAM 中读取时间信息,并显示在屏幕上的程序,调用之前写的 show_str
其实可以发现,每个 BCD 码就代表没一个十进制位,只要加上 \(30H\) 即可把它转换为字符串
但是有一个问题,就是读取的是字节信息,包含两个 BCD 码,需要将他们分离。具体的做法就是在 16 为寄存器的高地位都存这个字节,然后右移 4 位,这样高 8 为就剩下原来的字节数据的低 4 位了,然后低 8 位只要用一个 and 保留想要的部分就行了

assume cs:code,ds:datasg,ss:stacksg
stacksg segment stack
	dw 32 DUP(0)
stacksg ends
datasg segment
	db 'YY/MM/DD HH:MM:SS',0
	db 9,8,7,4,2,0
datasg ends

code segment
start:
	mov ax,datasg
	mov ds,ax
	mov ax,stacksg
	mov ss,ax
	
	xor si,si
	mov di,18
	mov cx,6
S:
	push cx
	mov al,[di]
	out 70H,al;写入地址
	in al,71H;读取数据,现在 al 中存着 BCD 形式的表示两个十进制数的数据
	mov ah,al
	mov cl,4
	shr ah,cl;右移 4 位,这样 ah 存的就是原先 al 的高两位
	and al,1111B;al 存原先 al 的低两位
	add ah,30H
	add al,30H;转为字符
	mov [si],ah
	mov [si+1],al
	add si,3
	inc di
	pop cx
loop S
	
	mov dh,12
	mov dl,31
	mov cl,2
	xor bx,bx;准备调用 show_str
	call show_str

    mov ax,4c00h
    int 21h

show_str:;dh 行号,dl 列号,cl 颜色,从 ds:bx 开始,输出字符串
	push ax
	push bx
	push cx
	push dx
	push bp
	push es
	
	mov ax,0B800H
	mov es,ax;段地址
	mov ax,0A0H
	mul dh;前面有 dh 行,每行 0AH
	add dl,dl;这一行前面 dl 列,每列 2H
	mov dh,0
	add ax,dx;
	mov bp,ax;此时 bp 即第一个字符的偏移地址
	mov dl,cl;转存 cl,因为判断是否跳出要用到 cx
	
	do:
		mov al,[bx]
		mov es:[bp],al
		mov es:[bp+1],dl
		inc bx
		add bp,2
		mov cl,[bx]
		inc cl
	loop do
	
	pop es
	pop bp
	pop dx
	pop cx
	pop bx
	pop ax
ret
code ends
end start

执行效果:

可以看到都半夜了,死机又一折腾现在都一点多了。。。。

11 外中断

11.1 外中断信息

分为两种,可屏蔽中断和不可屏蔽中断

可屏蔽中断:可以被屏蔽,若 \(IF=1\) 则引发中断,若 \(IF=0\) 则 cpu 会屏蔽可屏蔽中断
中断过程与前面的内中断类似:取中断类型码,标志寄存器入栈,\(IF=0,TF=0\),cs ip 入栈,通过中断向量表做相应跳转
和内中断不同的,他的中断类型码来自 cpu 外部,由数据总线传入,而内中断的是由 cpu 内部产生
有这样两种指令

  • cli,设置 \(IF=0\)
  • sti,设置 \(IF=1\)

比如有几条有极强相关性的代码,只执行它们中的一部分会造成其他代码的执行错误,那么就让他们在开始执行前 cli 屏蔽中断,等都执行完了再 sti
这也解释了为什么中断过程中要设置 \(IF=0\),久石让中断程序执行时不发生其他中断

不可屏蔽中断:cpu 会在执行外当前指令后立即响应不可屏蔽中断
对于 8086cpu,不可屏蔽中断固定类型码为 2,不需要获取类型码的过程
于是中断过程:标志寄存器入栈,\(IF=0,TF=0\),cs ip 入栈,\((ip)=(8H),(cs)=(AH)\)
不可屏蔽中断只是极少情况下,系统中有必须处理的紧急情况时来通知 cpu 的,一般外设发出的都是可屏蔽中断

11.2键盘输入过程

以键盘输入作为一个例子

键盘输入

每个键相当于一个开关,键盘上有一个芯片对每个键的开关状态进行检测
当一个键的开关接通或断开,会产生一个扫描码,送入 \(60H\) 端口,分别叫做通码和断码
\(\text{断码}=\text{通码}+80H\),就是第 7 个二进制位的差异

部分扫描码(通码):


发生九号中断

扫描码到达 \(60H\),相关芯片向 cpu 发送 9 号中断信息
9 号中断的中断程序由 BIOS 提供

执行 9 号中断

  • 读出 \(60H\)
  • 如果是字符键,就把对应的 ASCII 码和扫描码送入内存中的 BIOS 缓冲区(8086中是 16 字节,这两个值分别一个字节,所以一共能存放 8 个键盘输入),如果是控制键或切换键,就改写对应的状态字节
  • 向芯片发出应答信息

0040:17 字节存储键盘的状态字节

11.3 编写 int9 中断

其实可以用 debug 查看一下 BIOS 的 int9 中断,先看 0:24H 的中断向量表,通过那里的值来查看,圈出的就是从 \(60H\) 读入的过程

可以利用键盘的输入来控制其他信息,比如编写这样一个程序:屏幕上依次显示 26 个字母,每次按下 ESC 改变颜色
先来实现显示 26 个字母的过程,为了让字母在屏幕上停留的时间加长,使得人能看清楚,需要做一些无用循环
sleep 函数,通过 dx:ax 拼成一个 32 位循环次数,只有当他们两个都为 \(0\) 才说明循环结束

assume cs:code
code segment
start:
	mov ax,0B800H
	mov es,ax
	mov ah,'a'
S:
	mov es:[160*12+40*2],ah
	call sleep
	inc ah
	cmp ah,'z'
	jna S

    mov ax,4c00h
    int 21h
	
sleep:
	push ax
	push dx
	
	mov dx,5H;dx 高位,ax 低位,存一个 32 位的循环次数
	xor ax,ax;次数为 5 0000H
	;这个实验的环境是 dosbox,cpu 调的 3000 cycles,所以次数改的小了
S1:
	sub ax,1
	sbb dx,0
	cmp ax,0
	jne S1
	cmp dx,0
	jne S1;如果 ax,dx 都为零,则 dx:ax 就是零,那么不跳转,返回
	
	pop dx
	pop ax
ret
code ends
end start

那么既然要对键盘的输入进行特殊处理,就要修改 int9 中断,一般的思路就是重写一遍,改写他来达到想要的效果
但 int9 中断处理其实是一个比较复杂的过程,完全重新手写一遍肯定不现实,所以要在新写的 int9 中断中,调用 BIOS 的 int9 中断,自己实现的只是特殊处理的部分
如何做?因为键盘产生扫描码,送入端口,发送 9 号中断信息,一直到执行 9 号中断,都是硬件在做,我们无法干预,所以想染执行 int9 中断的时候执行我们的程序,就只能改中断向量表
但改了中断向量表,以前 BIOS 的 9 号中断入口地址就丢失了,所以要再改之前先保存下来
这样,我们读入了 \(60H\) 的信息以后,先调用 BIOS 的 9 号中断,然后判断是不是 Esc,如果是,改变颜色
对于调用中断的时候,也不能直接 int 9 了(因为中断向量表变了),应该手动模拟调用的过程,先 pushf,然后再 pushf 并把她 pop 到一个寄存器里,通过这样修改 \(TF,IF\),然后在 popf 会标志寄存器,最后 call

assume cs:code,ds:datasg,ss:stacksg
stacksg segment stack
	dw 32 DUP(0)
stacksg ends
datasg segment
	dw 0,0
datasg ends

code segment
start:
	mov ax,datasg
	mov ds,ax
	mov ax,stacksg
	mov ss,ax
	mov sp,64
	
	xor ax,ax;下面保存以前 int9 中断的入口地址
	mov es,ax
	mov ax,es:[9*4]
	mov ds:[0],ax
	mov ax,es:[9*4+2]
	mov ds:[2],ax
	
	cli;禁止中断
	mov word ptr es:[9*4],offset int9;修改中断向量表
	mov es:[9*4+2],cs
	sti
	
	mov ax,0B800H
	mov es,ax
	mov ah,'a'
show:
	mov es:[160*12+40*2],ah
	call sleep
	inc ah
	cmp ah,'z'
jna show

	xor ax,ax
	mov es,ax
	mov ax,ds:[0]
	mov es:[9*4],ax
	mov ax,ds:[2]
	mov es:[9*4+2],ax
	;复原中断向量表,否则接下来再按键盘还是会去调用之前自己编些的 int9 的地址
	;但那段内存已经随着程序结束而释放了
    mov ax,4c00h
    int 21h
	
sleep:
	push ax
	push dx
	
	mov dx,5H;dx 高位,ax 低位,存一个 32 位的循环次数
	xor ax,ax;次数为 5 0000H
	;这个实验的环境是 dosbox,cpu 调的 3000 cycles,所以次数改的小了
S1:
	sub ax,1
	sbb dx,0
	cmp ax,0
	jne S1
	cmp dx,0
	jne S1;如果 ax,dx 都为零,则 dx:ax 就是零,那么不跳转,返回
	
	pop dx
	pop ax
ret

int9:
	push ax
	push bx
	push es
	
	in al,60H
	
	pushf
	pushf
	pop bx
	and bh,11111100B;将第 8 和 9 位的 TF,IF 置零
	push bx
	popf
	call dword ptr ds:[0];调用原来的 int9 中断
	
	cmp al,1
	jne int9_ret;如果不是 esc 直接返回
	mov ax,0B800H
	mov es,ax
	inc byte ptr es:[160*12+40*2+1];修改中间字符的颜色属性

	int9_ret:
	pop es
	pop bx
	pop ax
iret;这里是 iret,坑了我好久。。
code ends
end start

11.4 将新的 int9 安装

实现效果:按下 F1 使得整个屏幕改变颜色属性,其他键照常处理

其实和之前那个安装 do0 的差不多,就是先在安装程序中将代码写一遍,写完中 movsb 指令将他传送到那段安全的内存,然后改中断向量表什么的照旧
至于新的 int9 怎么写,就拿上面的程序改改就成了

assume cs:code,ss:stacksg
stacksg segment stack
	dw 32 DUP(0)
stacksg ends

code segment
start:
	mov ax,stacksg
	mov ss,ax
	mov sp,64
	push cs
	pop ds;(ds)=(cs)
	xor ax,ax
	mov es,ax
	
	mov si,offset int9
	mov di,204H
	mov cx,offset int9_end-offset int9
	cld;正向传送
	rep movsb
	
	push es:[9*4]
	pop es:[200H]
	push es:[9*4+2]
	pop es:[202H];存以前的 int9 中断入口地址
	
	cli;禁止在下面两条指令中间发生中断
	mov word ptr es:[9*4],204H
	mov word ptr es:[9*4+2],0
	sti

    mov ax,4c00h
    int 21h
	
int9:
	push ax
	push bx
	push cx
	push es
	
	in al,60H
	pushf;这里不用设置 IF 和 TF 了,因为本来就是调用中断进来的,已经设为零了
	call dword ptr cs:[200H];安装位置 (cs)=0

	cmp al,3BH
	jne int9_ret

	mov ax,0B800H
	mov es,ax
	mov bx,1
	mov cx,2000
	mov al,es:[1];如果只是给每个颜色属性加一,滚动时出现问题(颜色不一致)
	inc al
	int9_loop:
		mov es:[bx],al
		add bx,2
	loop int9_loop
	
	int9_ret:
	pop es
	pop cx
	pop bx
	pop ax
iret
int9_end:
	
code ends
end start

 

另一个更复杂的:如果输入 A 就显示满屏的 A,否则照常处理
这个难点主要在于如何判断大写,可以回去看一下之前说的状态字节,先用三个寄存器截取下来两个 shift,一个大写锁定的状态(零或一,可以通过与运算和逻辑右移来解决)
是大写的状态:

  • 大写锁定打开,且两 shift 都没有按下
  • 大写锁定没打开,两 shift 至少按下一个

可以通过这个推知小写的状态(大写锁定开,且两个 shift 按下至少一个,或大写锁定没开,且两个 shift 都没按下),如果是小写就返回
注意上面描述中的 “且”,那么 “至少按下一个” 其实也就对应了 “或”,“都没按下” 就是 “且”,把他们转换成更逻辑的描述,就可以通过逻辑与和逻辑或来判断了
代码:

assume cs:code,ss:stacksg
stacksg segment stack
	dw 32 DUP(0)
stacksg ends

code segment
start:
	mov ax,stacksg
	mov ss,ax
	mov sp,64
	push cs
	pop ds;(ds)=(cs)
	xor ax,ax
	mov es,ax
	
	mov si,offset int9
	mov di,204H
	mov cx,offset int9_end-offset int9
	cld;正向传送
	rep movsb
	
	push es:[9*4]
	pop es:[200H]
	push es:[9*4+2]
	pop es:[202H];存以前的 int9 中断入口地址
	
	cli;禁止在下面两条指令中间发生中断
	mov word ptr es:[9*4],204H
	mov word ptr es:[9*4+2],0
	sti

    mov ax,4c00h
    int 21h
	
int9:
	push ax
	push bx
	push cx
	push dx
	push es
	
	in al,60H
	pushf
	call dword ptr cs:[200H];安装位置 (cs)=0

	cmp al,1EH+80H;'A' 的断码
	jne int9_ret

	;判断是否为大写状态
	;大写的条件是:大写锁定打开,且两 shift 都没有按下
	;或没打开,两 shift 至少按下一个
	;下面 ax,bx,dx 分别是左右 shift,大写锁定的状态
	mov ax,40H
	mov es,ax
	mov ax,es:[17H]
	mov bx,ax
	mov dx,ax
	and ax,1B;第 0 位右 shift
	and bx,10B;第 1 位左 shift
	shr bx,1
	or ax,bx;ax 为真则至少按下了一个 shift
	and dx,1000000B;第 6 位大写锁定
	mov cl,6
	shr dx,cl

	mov cx,dx
	and cx,ax
	cmp cx,1
	je int9_ret
	mov cx,dx
	or cx,ax
	jcxz int9_ret;如果 cx ax 都假,也小写

	mov ax,0B800H
	mov es,ax
	;xor bx,bx
	mov bx,0
	mov cx,2000
	mov al,'A'
	int9_loop:
		mov es:[bx],al
		add bx,2
	loop int9_loop
	
	int9_ret:
	pop es
	pop dx
	pop cx
	pop bx
	pop ax
iret
int9_end:
	
code ends
end start

12 直接定址表

用于更有效合理的组织数据

12.1 描述单元长度的标号

以前用的 XXX: 这样来写标号,这样只是代表了内存单元的一个地址
另一种写法是,把这个冒号去掉,就是比如 a dw 1,2 这样,a 不仅代表了内存地址,还代表了在此标号出,是字单元数据
假设这是定义在代码段中,那么可以这样来使用

mov ax,a
相当于:mov ax,cs:[0]

inc a
相当于:inc word ptr cs:[0]

又比如定义的是字节型,那么:
mov al,a[si]
相当于:mov al,cs:0[si]

其他寻址方式类似

但是像这里的字形数据就不行,因为每个数据占的不是一个字节,如果是字形数据的话应该给 si 先乘一个 2

12.2 在其他段中使用数据标号

首先说,带有冒号的只表示地址的标号只能使用在代码段中

如果要在代码段中使用其他段里的数据标号,就需要用到伪指令 assume 了,它将段寄存器与某个段相联系,那么在编译时,编译器就能知道某个数据标号的短地址在哪个段寄存器里(但是它不能帮助我们设置段寄存器的值,还是需要手动设置,这个很久以前就说过了)

也可以将标号当做数据来定义,比如:

这就相当于 c 出定义了两个字形数据,分别是 a,b 的偏移地址
类似的,也可以把 c 那里的数据定义为双子型,那么就是分别存储 a,b 的段地址和偏移地址(段地址在高位)
另外,伪指令 seg 可以用来取某个标号的段地址

12.3 直接定址表

大概就是,根据给定的数据,要通过查一个数据表来确定结果,就是例如 mov al,table[bx] 这种的
其实如果没有这种带有数据长度的标号,这种查表返回结果的思路也能实现,只是用这种标号使得程序更简单了

还有一种技巧就是用程序的入口地址作为直接定址表的数据
就像之前说的 dos 和 BIOS 中断例程,都会包含好几个子程序,可以通过子程序编号一个个 jmp 到对应的入口地址,但那样显然太麻烦了,而且如果增加了一个子程序,还要再去手动添加一个 jmp
所以可以将各个子程序的入口地址,放入一个直接定址表,那么就可以根据子程序编号,通过这个表,从编号索引到入口地址(就是入口地址是值,编号是那个 table[bx] 的 bx 下标),进行跳转

13 使用 BIOS 进行键盘输入和磁盘读写

13.1 int9 中断处理键盘输入

至于处理的大体过程之前已经说过了,字符键存入键盘缓冲区,控制键就修改 0040:17 处的状态字节
再详细说一下键盘缓冲区,它是用环形队列结构来管理内存,队列就是一个队头一个队尾,从队尾加入从队头取出,然后环形队列当然就是队尾指针不断增加,到了键盘缓冲区末尾,就在回到最前面来(此时最前面的字符应该已经被读取了)
他的内存单元是 0040:1E 到 0040:2D

13.2 int16H 中断读取键盘缓冲区

读取键盘缓冲区的子程序在 int16H 中断中的编号是 0,所以调用前应该先 mov ah,0
返回值中,\(ah\) 存放扫描码,\(al\) 存放 ascii 码。具体的工作过程如下:

  • 检查键盘缓冲区中是否有字符
  • 如果没有,重复上一部,循环等待,知道有键盘输入(由此可见,int16H 中需要 int9 中断来向缓冲区写入键盘输入,那么 IF 在等待时不应该设为 \(1\)
  • 读取相应的扫描码、ascii 码,存入 \(al\)\(ah\)
  • 删除已经读取的键盘输入

int9 是有键盘输入,便向缓冲区写入数据;int16H 是程序调用这个中断,才从键盘缓冲区读取数据

下面是一个根据输入 r,g,b 三个字符来控制屏幕上的颜色的例子,回想一下颜色属性中哪几位分别代表什么颜色

assume cs:code
code segment
start:
	xor ah,ah
	int 16H
	
	mov ah,1
	cmp al,'r'
	je red
	cmp al,'g'
	je green
	cmp al,'b'
	je blue
	jmp short no
	
red:
	shl ah,1
green:
	shl ah,1
blue:
	mov bx,0B800H
	mov es,bx
	mov cx,25*80
	mov bx,1
	S:
		and byte ptr es:[bx],11111000B
		or es:[bx],ah
		add bx,2
	loop S

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

13.3 字符串的输入

关于字符串,其实可以用一个“字符栈”来维护,就是在内存中开辟一段内存当做“字符栈”,然后有字符输入就入栈,想删除字符,当然是删除最后输入的,那么符合栈的性质,删除栈顶元素

实现这样一个例子,用键盘输入,可以加入或删除字符(退格),按下回车后结束输入,程序结束
所需的就是一个可以向字符栈加入、删除字符,显示字符栈中字符的子程序

  • 通过 int16H 读入输入的字符
  • 比对它的 ascii 码或扫描码,如果是回车,就想字符栈加入一个 0(ascii 码,不是 0 这个字符)来表示输入结束,并返回
  • 是退格,删除字符栈中一个字符,并显示字符栈中字符
  • 是字符,就加入它,并显示字符栈字符

显示、加入、删除字符就调用写的那个子程序来实现,显示的时候如果显示的字符后面有其他字符,要清理掉,对应 clear 标号处的代码
繁而不难,一堆细节需要注意

assume cs:code,ds:data,ss:stack
data segment
	db 256 DUP(0)
data ends
stack segment stack
	dw 56 DUP(0)
stack ends

code segment
start:
	mov ax,data
	mov ds,ax
	mov ax,stack
	mov ss,ax
	mov sp,128
	xor si,si
	mov dh,12
	xor dl,dl

	call get_str

    mov ax,4c00h
    int 21h

;接收字符串输入
get_str:
	push ax
	get_str_work:
		xor ah,ah
		int 16H
		
		cmp al,' '
		jb not_char;看 ascii 码,通过 al,若小与空格则不是字符
		xor ah,ah
		call char_stack;入栈
		mov ah,2
		call char_stack;显示
		jmp short get_str_work
		
	not_char:
		cmp ah,0EH;这里看的是扫描码,所以是 ah
		je backspace
		cmp ah,1CH
		je enter_
		jmp short get_str_work
		
	backspace:
		mov ah,1
		call char_stack;弹栈
		mov ah,2
		call char_stack;显示
		jmp short get_str_work
	enter_:
		xor ax,ax;(ah)=(al)=0
		call char_stack
		mov ah,2
		call char_stack
	pop ax
ret

;向字符栈中加入、弹出一个字符,显示字符栈中字符串,功能编号 0 到 2,在 ah 中
;dh,dl 分别是字符在屏幕上显示的行和列
;ds:si 是字符串的存储空间,以 0 结尾
;输入的字符在 al,如果是出栈 al 存弹出的字符
char_stack:
	jmp short char_start
	table dw char_push,char_pop,char_show
	top dw 0
		
	char_start:
		push bx
		push dx
		push di
		push es
		
		cmp ah,2
		ja char_stack_ret
		mov bl,ah
		xor bh,bh
		add bx,bx
		jmp word ptr table[bx]
		
	char_push:
		mov bx,top
		mov [si+bx],al
		inc top
		jmp short char_stack_ret
	char_pop:
		cmp top,0
		je char_stack_ret
		dec top
		mov bx,top
		mov al,[si+bx]
		jmp short char_stack_ret

	char_show:
		mov bx,0B800H
		mov es,bx
		mov al,160
		mul dh
		mov di,ax
		xor dh,dh
		add dx,dx
		add di,dx;此时 di 是行号结合列号的总偏移地址
		
		xor bx,bx
	char_show_work:
		cmp bx,top
		je clear
		mov al,[si+bx]
		mov es:[di],al
		add di,2
		inc bx
		jmp short char_show_work
	clear:
		cmp byte ptr es:[di],' '
		je char_stack_ret
		mov byte ptr es:[di],' '
		add di,2
		jmp short clear

	char_stack_ret:
		pop es
		pop di
		pop dx
		pop bx
ret

code ends
end start

13.4 int13H 对磁盘进行读写

以 3.5 英寸软盘为例,它分为上下两面(从零编号),每面有 \(80\) 磁道(从零编号),每个磁道有 \(18\) 个扇区(从一编号),每个扇区是 \(512\) 个字节,扇区是对磁盘读写的最小单位
这样一张软盘大概就是有 \(1.44M\)
直接控制硬件对磁盘读写涉及很多硬件细节,但 BIOS 已经提供了这些包含了比较复杂的控制过程的中断例程,就是 int13H,下面是他的参数
对于读取扇区内容:

对于向扇区写入内容:

使用此中断前要仔细确认好写入的扇区号,避免覆盖掉重要的数据,应该在使用前先照一张空闲的软盘

 


完成于 2020-08-31下午,上午刚返校开学考,下午回来赶紧把最后这部分写完,等开了学怕是没空了
大概断断续续学了一个多月(从7月21就开始学了),上个暑假其实就看了一部分视频,然后开学以后就由于各种事情一直咕咕咕,结果后来发现之前看的前面的全忘了,只好从头再看,并有了这样一个笔记
算是写完了吧,就是最后那个课程设计还没写,好像是需要在纯dos中进行,虚拟机还没安上,改天安上就试一试,看起来还是比较麻烦的,而且也要开学了估计不太会有时间

posted @ 2020-08-18 01:45  suxxsfe  阅读(441)  评论(0编辑  收藏  举报