如何在裸机上直接运行程序—王爽《汇编语言》课程设计2

平常我们的程序基本都是运行在操作系统之上,很少有机会直接在裸机上运行程序。现代操作系统需要支持多任务环境的工作方式,这要求CPU在硬件上提供支持。以Intel处理器为例,从80286/80386开始,CPU开始支持工作在保护模式,为多任务环境提供保护机制。

操作系统为我们提供了保护和抽象,这方便了开发和使用,但是同时也对学习者了解计算机底层工作原理造成了一定的障碍。本文通过编码实践王爽老师《汇编语言》中的课程设计2,与寄存器、内存、显存、中断、外设等进行了近距离的亲密接触,通过这次实践也算是入门汇编了吧。

背景知识

计算机上电之后,CPU自动从FFFF:0单元处开始执行,这里有一条跳转指令,CPU转去执行BIOS中的硬件系统检测和初始化程序。初始化程序将从0:0单元处开始建立中断向量表。硬件系统检测和初始化完成之后,调用int 19h进行操作系统引导。从软盘或硬盘第一个扇区读取内容到0:7c00,然后CS:IP指向0:7c00开始执行。

这个课程设计的任务就是编写一个可以直接在裸机上运行的程序,具体功能如下:

  1. 列出功能选项,让用户通过键盘进行选择,界面如下。
    1. reset pc ; 重新启动计算机
    2. start system ; 引导现有的操作系统
    3. clock ; 进入时钟程序
    4. set clock ; 设置时钟
  2. 用户输入“1”后重新启动计算机(重新跳转到ffff:0单元)
  3. 用户输入“2”后引导现有的操作系统(读取硬盘C的0道0面1扇区进行引导)
  4. 用户输入“3”后,执行动态显示当前日期、时间的程序(循环读取CMOS),格式:年/月/日 时:分:秒。按下F1键后,改变显示颜色;按下Esc键后,返回到主选单(利用键盘中断)
  5. 用户输入“4”后,用户可输入字符串更改CMOS中的日期时间,更改后返回到主选单

准备工作

  • 首先需要准备一个常用虚拟机,VMware/VirtualBox等都可以
  • 准备MS-DOS 6.22映像,或者DOSBox,作为开发和调试的环境,DOS 6.22虚拟机的安装可以参考这里
  • 准备debug、masm、link程序,分别是DOS下的调试器、编译器和链接器,可以从这里下载

程序设计

由前面的背景知识可知,计算机刚上电之后,CPU会自动执行硬件系统检测及初始化工作,这些操作我们用户是无法进行插手的。完成初始化之后,将调用int 19h读取启动设备第一个扇区的内容到0:7c00,从这里开始我们就接手CPU的控制权了。

制作启动盘

所以我们首先要做的就是制作一个启动盘,我们这里选用1.44M的软盘,它比硬盘具有更高的优先级。创建一个空的软盘映像非常简单,在Window下可以使用WinImage工具,在Linux上则直接使用如下dd命令即可:

1
dd if=/dev/zero of=floppy.img bs=512 count=2880

安装程序

有了软盘映像之后,接下来的任务就是将我们的程序写到软盘上,这个可以通过一个安装程序来完成,借助int 13h中断将任务程序的二进制代码写到软盘上。

任务程序引导部分

如前所述,int 19h会帮我们将启动盘的第一个扇区读到0:7c00,然后CPU从0:7c00开始执行。一个扇区基本不可能放下我们整个任务程序,所以我们需要将任务程序分成两个部分:引导部分和主体部分。引导部分的代码负责把剩余主体部分读到内存中。我们这里选择读到0:7e00h处,紧跟在第一个扇区后面。读完之后就跳转到主体部分代码的开头继续执行。

任务程序主体部分

主体部分完成我们程序的主要功能

  1. 首先我们需要注册一个新的int 9中断例程,包装BIOS提供的中断例程,因为我们需要根据用户的键盘输入做不同的操作。这个中断例程是我们程序的核心。
  2. 接着显示几个选项供用户选择
  3. 然后进入循环,根据模式标志决定是否显示时间

int 9中断例程

int9中断例程负责处理用户的键盘输入,分为两个状态。

  • 用户在主选单时
    • 输入按键1:重新启动计算机,跳到ffff:0执行
    • 输入按键2:首先复原BIOS的int9中断例程,然后清屏,将硬盘的第一个扇区读取到内存0:7c00处,然后跳转到0:7c00进行硬盘引导
    • 输入按键3:保存屏幕(主选单),清屏,修改模式为1(动态显示时间)
    • 输入按键4:首先需要复原BIOS的int9中断例程,保存屏幕(主选单),清屏,显示提示字符串,等待用户输入日期时间,检查格式是否正确,将时间设置到CMOS RTC上。完成之后恢复屏幕,重新安装我们包装的int 9中断例程。
  • 用户在时钟程序时
    • 输入按键F1:改变显示的颜色
    • 输入按键Esc:恢复屏幕,修改模式为0(主选单)

处理用户输入的时间

我们使用一个字符栈来保存用户输入的字符,同时用一个变量top保存当前栈顶的位置。

  • 当用户输入字符时:首先检查是否到栈顶, 到了栈顶则忽略,否则将字符入栈,然后更新屏幕显示
  • 当用户输入退格键时:如果栈是空的,不做啥操作,否则弹出一个字符,然后更新屏幕显示
  • 当用户输入Enter键时:结束输入过程

避坑指南

笔者在编码测试过程碰到了许多的问题,汇编的调试不是那么方便,有些bug还藏得比较隐蔽,所以花了不少时间来调试问题。笔者痛定思痛,含泪总结了下面这几条,希望对有类似经历的朋友有所帮助。

  • 逻辑独立的程序块最好设计成子程序
    • 一方面,是因为条件转移指令只能是短转移,程序太长会超出其范围。
    • 另一方面,代码逻辑更加清晰,而且可以单独进行单元测试,否则全部写到一块后面问题会比较难定位。
    • 子程序在实现的时候一定要标注好参数和返回值的情况,前提条件和后置条件,这关系到寄存器的使用。
  • 寄存器的保护
    • 被调用者保护的寄存器:调用方在调用子函数前,需要先备份它。我们这里主要是返回值用到的寄存器。
    • 调用者保护的寄存器:子函数在修改前需要先保存原值
    • push和pop一定要对应,顺序相反,建议加了一个push之后立马写对应的pop,否则很可能遗忘。
  • 内存的操作
    • 处理内存的时候一定要注意长度,尤其是寄存器一起使用的指令,要使用对应长度的寄存器,这个如果搞错了编译器也是不会给出报错的。
    • 内存寻址时段寄存器最好不要省略,避免疏忽造成不必要的bug。(比如用到了bp寄存器,段地址默认在ss中)
  • 合理安排寄存器的使用
    • 寄存器是稀缺资源,总共就这么十几个,有些使用场景还受限
    • 首先确定内存寻址需要用到的寄存器,内存寻址只能使用这几个寄存器:bx/bp、di/si。可以单独使用,也可以左右两个组合使用。提示:其中bx可以分成bl和bh使用,其他几个则只能作为整体使用,所以有些场景可能只有bx能胜任。
    • 其次是确定指令、子程序或中断规定使用的寄存器
    • 字节的操作只能使用ah/al、bh/hl、ch/cl、dh/dl,其余寄存器不能拆分使用。
    • 如果寄存器实在不够用,就只能临时先压栈,然后用完再出栈。或者将程序分拆为多个子程序。
  • 注意标号的偏移地址,是其在安装程序中的偏移地址,并不是后面任务程序从启动盘被读取到内存中之后的偏移地址。所以我们访问数据时需要对偏移地址做修正。(call 标号指令在编译成机器码时会转成相对位移,所以可以直接使用标号)
  • 留心程序中的竞争问题,因为中断随时可能发生,需要注意中断例程和主程序中都用到的数据,必要时可以关中断。
  • 名字相似的指令不要打错,比如and和add指令,它们真的太像了,我至少在它们上面栽了两个跟头。

程序实现

接下来来看实际代码实现,首先看下整体的框架,然后看每个部分的细节。

程序框架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
assume cs:code, ss:stack, ds:data
stack segment
dw 64 dup(0)
stack ends
data segment
mes db 'fail to write to floppy!', 0
data ends

code segment
; 任务程序引导部分(1个扇区512字节)
; 功能: 将任务程序的主体部分读入内存
; 第一个扇区由BIOS读入内存0:7c00h处
boot:
; 从第二个扇区开始读取到0:7e00h处
; 然后跳转到任务程序主体部分task
省略...
db 512-($-boot) dup(0) ; pad到512字节

; 任务程序主体部分(3个扇区1536字节)
; 功能:处理用户输入,执行对应操作
; 程序位于内存0:7e00h处
task:
省略...
db 1536-($-task) dup(0) ; pad到3个扇区的长度

; 任务程序数据部分(1个扇区512字节)
dat:
省略...
db 512-($-dat) dup(0) ; pad到512字节

; 安装程序: 将任务程序写到软盘上
start:
省略...
ok:
mov ax, 4c00h
int 21h
code ends
end start

可以看到我们的代码段分为4大块,除了安装程序之外,是任务程序的引导部分、主体部分以及数据部分。我们在任务程序的每个部分最后都做了补0处理,补齐到扇区的长度,使操作更方便。另外,将数据单独放在一个扇区中,方便计算偏移,进行数据的访问。

安装程序

我们先来看安装程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
; 安装程序: 将任务程序写到软盘上
start:
mov ax, data
mov ds, ax
mov ax, stack
mov ss, ax
mov sp, 128

mov ax, cs
mov es, ax
mov bx, offset boot ; es:bx 指向缓存数据的内存地址

mov al, 5 ; 读写的扇区数
mov ch, 0 ; 磁道号
mov cl, 1 ; 扇区号
mov dl, 0 ; 驱动器号 软驱从0开始,0:软驱A,1:软驱B;
; 硬盘从80h开始,80h:硬盘C,81h:硬盘D
mov dh, 0 ; 磁头号(对于软盘即面号)
mov ah, 3 ; 功能号,2表示读扇区,3表示写扇区
int 13h

cmp ah, 0
je ok
mov dh, 10
mov dl, 10
mov si, 0
call showstr
ok:
mov ax, 4c00h
int 21h

首先设置段寄存器,然后将es:bx指向代码段的开头即boot的位置,我们将从这个位置开始写5个扇区到软盘中。如果写磁盘成功,则程序结束;否则显示一条错误信息。其中showstr是我们封装的子程序,用于在屏幕指定位置显示以0结尾的字符串。

任务程序引导部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
; 任务程序引导部分(1个扇区512字节)
; 功能: 将任务程序的主体部分读入内存
; 第一个扇区由BIOS读入内存0:7c00h处
boot:
; 从第二个扇区开始读取到0:7e00h处
; 然后跳转到任务程序主体部分task
mov ax, 0
mov es, ax
mov bx, 7e00h ; es:bx 指向接收从扇区读取数据的内存区

mov al, 4 ; 读取的扇区数
mov ch, 0 ; 磁道号(0~79)
mov cl, 2 ; 扇区号(1~18),第二个扇区开始
mov dl, 0 ; 驱动器号 软驱从0开始,0:软驱A,1:软驱B
; 硬盘从80h开始,80h:硬盘C,81h:硬盘D
mov dh, 0 ; 磁头号(0~1)(对于软盘即面号)
mov ah, 2 ; 功能号,2表示读扇区,3表示写扇区
int 13h
; 这里失败的话,回退到从硬盘启动

jmp bx ; 跳转到任务程序主体部分task

db 512-($-boot) dup(0) ; pad到512字节

引导部分负责将任务程序的主体部分读到内存中,我们这里选择读到0:7e00h处,紧跟在第一个引导扇区后面。总共读了4个扇区,其中3个扇区代码、1个扇区数据。读完之后,跳转到0:7e00h处执行。

数据部分

我们将所有的数据集中到了一起以方便访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
; 任务程序数据部分(1个扇区512字节)
dat:
; 相对task偏移+7e00h
; m1:0, m2:1, m3:2, m4:3, m5:4
dw offset m1-task+7e00h, offset m2-task+7e00h, offset m3-task+7e00h
dw offset m4-task+7e00h, offset m5-task+7e00h
m1 db '--------------------------------------MENU--------------------------------------', 0
m2 db '1) reset pc', 0 ; 重新启动计算机
m3 db '2) start system', 0 ; 引导现有的操作系统
m4 db '3) clock', 0 ; 进入时钟程序
m5 db '4) set clock', 0 ; 设置时间
prompt db 'time format: yy/MM/dd hh:mm:ss', 0 ; 设置时间提示字符串
timeformat db 'yy/MM/dd hh:mm:ss', 0 ; 时间格式
timeoffset db 9, 8, 7, 4, 2, 0 ; CMOS时间各项寄存器号
old9 dd 0 ; 保存int9中断原本的地址
mode db 0 ; int9中断例程模式,0表示主选项,1表示时间程序
charstk db 32 dup(0) ; 设置时间的字符栈
top dw 0
digitoffset db 0,1,3,4,6,7,9,10,12,13,15,16 ; 时间字符串中数字的偏移
daysofmonth db 0,31,29,31,30,31,30,31,31,30,31,30,31 ; 每个月的天数
hextable db '0123456789ABCDEF' ; 十六进制打印

db 512-($-dat) dup(0) ; pad到512字节

主程序

引导程序将程序的剩余部分读取到内存之后,会跳转到主程序开始这里开始执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
; 任务程序主体部分(3个扇区1536字节)
; 功能:处理用户输入,执行对应操作
; 程序位于内存0:7e00h处
task:
;主程序逻辑:
;- 首先注册主菜单int9中断例程
;- 显示菜单
;- 无限循环, 根据模式标志决定是否显示时间
mov ax, 0 ;
mov ds, ax ; 主程序中将ds设为0

call mount9 ; 注册包装过的int9中断例程

mov cx, 5 ; 显示主菜单,5行
mov di, 0 ;
mov dh, 0 ; 行号
mov dl ,0 ; 列号
s:
mov si, 8400h[di] ; 菜单字符串偏移
call showstr
inc dh
add di, 2 ; 指向下一个字符串的偏移
loop s ;

mov dh, 5 ; 行号
mov dl, 0 ; 列号
mov bx, 0 ; bh页号
mov ah, 2 ; 功能号2: 设置光标
int 10h

mov bx, offset mode-task+7e00h
mov si, offset timeformat-task+7e00h ; +3
mov dh, 0
mov dl, 0
; 进入无限循环
s1:
call getclock ; 从CMOS读取时间
pushf
cli ; 屏蔽中断,防止中断中将模式改为0后,还显示时间
cmp byte ptr [bx], 0 ; 模式值
je skiptime
call showstr ; 显示从CMOS读取的时间
skiptime:
popf
jmp s1

getclock是我们封装的一个子程序,用于从CMOS读取时间写到timeformat所在的位置。最后在显示字符串的地方,我们关闭了中断,因为int 9中断中随时可能将发生,如果在主程序判断模式的值和完成时间显示之间,中断例程将模式从1改为0,那么显示就会出现问题。

中断例程的注册及恢复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
; 用于注册新的int 9中断例程,原来的中断向量保存在old9的位置
mount9:
push ax
push si
mov si, offset old9-task+7e00h
mov ax, ds:[9*4] ; 保存原来的中断例程的偏移地址
mov [si], ax ; 保存到old9位置
mov ax, ds:[9*4+2] ; 保存原来的中断例程的段地址
mov [si].2, ax ; 保存到old9+2位置

pushf
cli ; 屏蔽中断,防止中断向量出现非法状态
mov ds:[9*4], offset int9 + 7c00h ; 设置新的int9偏移, +7c00h修正
mov word ptr ds:[9*4+2], 0h ; 新的int9段地址, 9000h
popf

pop si
pop ax
ret

mount9用于注册新的int 9中断例程,将对应的中断向量设置为新的中断例程int9的偏移地址。因为我们还要用来原来的中断例程,所以将其中断向量保存在old9所在的位置。

同样地,这里在修改中断向量之前,我们也屏蔽了中断。因为修改偏移地址和段地址至少需要两个指令,如果在两者之间正好发生了中断,那么中断向量就处于一个非法的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
; 恢复int 9中断例程为BIOS自带的那个
umount9:
push ax
push si
mov si, offset old9-task+7e00h
pushf
cli ; 屏蔽中断,防止中断向量出现非法状态
mov ax, [si]
mov ds:[9*4], ax ; 偏移地址
mov ax, [si].2
mov ds:[9*4+2], ax ; 段地址
popf

pop si
pop ax
ret

umount9则是将old9中保存的中断向量恢复到中断向量表中对应的位置。

int 9中断例程实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
;主选菜单int9中断例程逻辑:(处理字符1~4)
;- 用户输入1,重新启动计算机,跳到ffff:0执行
;- 用户输入2,首先复原int9中断例程,然后清屏,将硬盘第一个扇区内容读到0:7c00,跳转到0:7c00
;- 用户输入3,保存屏幕(主选单),清屏,修改模式为1(动态显示时间)
;- 用户输入4,首先复原int9中断例程,保存屏幕(主选单),清屏,显示提示字符串
; 等待用户输入日期时间,检查格式是否正确,将时间设置到CMOS RTC上
; 完成之后恢复屏幕,重新安装我们包装的int 9中断例程。
;
;时钟程序int9中断例程逻辑:(处理F1和Esc)
;- 用户输入F1,改变显示颜色
;- 用户输入Esc,恢复屏幕,修改模式为0(主选单)
int9:
push ax
push bx
push si

pushf ; 中断过程:标志寄存器入栈
mov si, offset old9-task+7e00h
call dword ptr [si] ; 中断过程:模拟int9

int9s:
mov ah, 1 ; int9进来,键盘缓冲区不一定有数据
int 16h
je int9ret

mov ah, 0
int 16h ; 读取键盘缓冲区,防止键盘缓冲区溢出
; (ah)=scan code, (al)=ascii

int9s1:
;in al, 60h ; 从60h端口读取扫描码
mov bx, offset mode-task+7e00h
mov al, [bx] ; 获取模式值
cmp al, 1
je timemode
cmp ah, 02 ; 1的扫描码02
je sub1
cmp ah, 03 ; 2的扫描码03
je sub2
cmp ah, 04 ; 3的扫描码04
je sub3
cmp ah, 05 ; 4的扫描码05
je sub4
jmp int9ret
timemode:
cmp ah, 3bh ; F1的扫描码3bh
je subf1
cmp ah, 01 ; Esc的扫描码01
je subesc
jmp int9ret
int9ret:
pop si
pop bx
pop ax
iret

首先调用BIOS的int 9中断例程进行处理,这里为了模拟int指令的操作,我们手动模拟中断过程。中断过程可以简单描述如下:

  1. 取得中断类型码N
  2. pushf
  3. TF=0, IF=0
  4. push CS
  5. push IP
  6. (IP)=(N*4), (CS)=(N*4+2)

其中第一步已经知道,我们需要手动执行pushf将标志寄存器入栈,第三步外部已经置0也可以省略,4~6步通过call指令实现。

然后我们通过int 16h中断从键盘缓冲区读取数据,为了防止缓冲区溢出,这里读取之后将缓冲区中对应数据移除。(实际测试发现如果不移除,缓冲区满了之后键盘输入就没有响应了)。

获取到键盘扫描码之后,首先判断当前模式,如果是模式0,处理按键1~4;如果是模式1,处理按键F1和Esc。处理结束之后都会跳到int9ret处中断返回。

接下来依次来看每个按键的处理

按键1: 重新启动

1
2
3
4
5
6
7
8
sub1:
pop si
pop ax
mov ax, 0ffffh
push ax
mov ax, 0
push ax
retf ; 0ffffh:0

按键1的处理非常简单,直接跳转到ffff:0即可。

按键2: boot操作系统

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
sub2:
push cx
push dx
push es
call umount9 ; 调用umount9

; 清屏
mov ah, 0 ; 功能号0, 清屏
call screen

mov dh, 0 ; 行号
mov dl, 0 ; 列号
mov bx, 0 ; bh页号
mov ah, 2 ; 功能号2: 设置光标
int 10h

; 从硬盘第一个扇区读取到0:7c00h处
; 然后跳转
mov ax, 0
mov es, ax
mov bx, 7c00h ; es:bx 指向接收从扇区读取数据的内存区

mov al, 1 ; 读取的扇区数
mov ch, 0 ; 磁道号
mov cl, 1 ; 扇区号,第一个扇区开始
mov dl, 80h ; 驱动器号 软驱从0开始,0:软驱A,1:软驱B;硬盘从80h开始,80h:硬盘C,81h:硬盘D
mov dh, 0 ; 磁头号(对于软盘即面号)
mov ah, 2 ; 功能号,2表示读扇区,3表示写扇区
int 13h

; 需要清栈么
jmp bx ; 跳转到硬盘引导

按键2从硬盘引导现有操作系统,为了不影响后面的显示,我们先进行清屏操作,将光标设到开头,然后将硬盘的第一个扇区读取到0:7c00处,最后跳转到0:7c00处开始执行。

按键3: 动态显示CMOS时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
sub3:
; 保存屏幕
mov ah, 2 ; 功能号2, 保存
call screen

; 清屏
mov ah, 0 ; 功能号0, 清屏
call screen

mov byte ptr [bx], 1 ; 模式改为1

push dx
mov dh, 0 ; 行号
mov dl, 17 ; 列号
mov bx, 0 ; bx已经不用了, 可以覆盖
mov ah, 2 ; 功能号2: 设置光标
int 10h
pop dx

jmp int9ret

screen是我们封装的一个子程序,负责屏幕相关操作。首先保存当前屏幕(主选单),然后清屏,将模式改为1,最后为了美观一点将光标设置到了时间后面。中断例程只是修改了模式,时间的动态显示在外面中程序循环中进行。因为在动态显示时间的时候,也是需要处理键盘按键中断的。

按键4: 设置CMOS时间

按键4的处理稍微有点复杂,我们一点点来看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
sub4:
push dx
call umount9 ; 恢复原来的int 9中断例程

mov ah, 2 ; 功能号2, 保存屏幕
call screen

sub4s:
mov ah, 0 ; 功能号0, 清屏
call screen

mov si, offset prompt-task+7e00h
mov dh, 0
mov dl, 0
call showstr ; 显示提示字符串

mov dh, 1 ; 行号
mov bx, 0 ; bx已经不用了, 可以覆盖
mov ah, 2 ; 功能号2: 设置光标
int 10h
mov si, offset charstk-task+7e00h
call inputclock ; 用户输入字符串
call checkclock ; 检查字符串格式是否合法
cmp ah, 0
jne sub4s ; 格式不正确,重新输入

call setclock ; 写到CMOS RTC

mov ah, 3 ; 功能号3, 恢复屏幕
call screen ; call screen

mov dh, 5 ; 行号
mov dl, 0 ; 列号
mov bx, 0 ; bx已经不用了, 可以覆盖
mov ah, 2 ; 功能号2: 设置光标
int 10h

call mount9 ; 重新注册新的int9中断例程
pop dx
jmp int9ret

首先调用umount9恢复原来的int 9中断例程,不再额外处理那些键盘输入,接着保存当前屏幕(主选单)并清屏,然后显示一个提示字符串并显示光标到下一行。

这些准备工作做完之后,就调用intputclock让用户输入字符串,用户完成输入之后调用checkclock检查格式是否正确,如果不正确跳到前面重新输入。如果格式正确,则调用setclock将时间写到CMOS上。

完成设置之后,还有一些恢复的工作别忘了,需要恢复屏幕及光标位置,然后重新注册int 9中断例程。

按键F1: 修改显示颜色

1
2
3
4
subf1:
mov ah, 1 ; 功能号1, 修改颜色
call screen
jmp int9ret

因为我们将屏幕的相关操作都封装成了screen函数,所以这里简单调用即可

按键Esc: 退出时间显示,恢复为主选单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
subesc:
; 恢复屏幕
mov ah, 3 ; 功能号3, 恢复
call screen

mov byte ptr [bx], 0 ; 模式改为0

push dx
mov dh, 5 ; 行号
mov dl, 0 ; 列号
mov bx, 0 ; bx已经不用了, 可以覆盖
mov ah, 2 ; 功能号2: 设置光标
int 10h
pop dx

jmp int9ret

这个也比较简单,恢复屏幕,设置光标位置,将模式修改为0。

屏幕操作子程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
; 屏幕相关操作
; ah 功能号: 0清屏, 1换颜色, 2保存当前屏幕,3恢复保存的屏幕
; 从第0页保存到第1页,从第1页恢复到第0页
screen:
push ax
push bx
push cx
push ds
push es
push si
push di

cmp ah, 0
je clear
cmp ah, 1
je color
cmp ah, 2
je save
cmp ah, 3
je restore
jmp screenret

; 省略
screenret:
pop di
pop si
pop es
pop ds
pop cx
pop bx
pop ax
ret

screen子程序包含4个功能,由ah寄存器指定功能号。

(ah)=0表示清屏操作,修改第0页的显示缓冲区,所有字符都改成空格,颜色属性改成黑底白字。

1
2
3
4
5
6
7
8
9
10
11
clear:
mov ax, 0b800h ; 显示缓冲区第0页起始地址
mov ds, ax
mov si, 0
mov cx, 2000 ; 80 * 25
clears:
mov byte ptr [si], ' ' ; 清屏
mov byte ptr [si].1, 00000111b ; 黑底白字
add si, 2
loop clears
jmp screenret

(ah)=1表示修改显示颜色,操作跟上面类似

1
2
3
4
5
6
7
8
9
10
11
color:
mov ax, 0b800h ; 显示缓冲区第0页起始地址
mov ds, ax
mov si, 1
mov cx, 2000 ; 80 * 25
colors:
inc byte ptr [si] ; 修改颜色
add si, 2
loop colors
jmp screenret

(ah)=2表示保存当前屏幕,我们选择将显示缓冲区第0页内容拷贝到了第1页中。这里使用了串处理指令简化代码。

1
2
3
4
5
6
7
8
9
10
11
save:
mov ax, 0b800h ; 第0页起始地址
mov ds, ax
mov si, 0
mov ax, 0b8fah ; 第1页起始地址
mov es, ax
mov di, 0
mov cx, 4000 ; 80 * 25 * 2
cld ; df=0
rep movsb
jmp screenret

(ah)=2表示恢复保存的屏幕,也即执行跟前一个相反的操作,将第1页的内容拷贝到第0页。

1
2
3
4
5
6
7
8
9
10
restore:
mov ax, 0b8fah ; 第1页起始地址
mov ds, ax
mov si, 0
mov ax, 0b800h ; 第0页起始地址
mov es, ax
mov di, 0
mov cx, 4000
cld ; df=0
rep movsb

时间输入子程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
; 输入时间,从键盘缓冲区读取
; dh, dl 显示的行号列号
; ds:si 指向字符栈起始位置
inputclock:
push ax
push bx
push si

mov bx, offset top-task+7e00h
mov word ptr [bx], 0 ; top先清0

; 先清空键盘缓冲区
cleanbuf:
mov ah, 1
int 16h
je getstrs
mov ah, 0
int 16h
jmp cleanbuf

getstrs:
mov ah, 0
int 16h
cmp al, 20h ; ASCII码小于20h,说明不是字符
jb nochar
mov ah, 0
call charstack ; 字符入栈
mov ah, 2
call charstack ; 显示栈中的字符
jmp getstrs
nochar:
cmp ah, 0eh ; 退格键的扫描码
je backspace
cmp ah, 1ch ; Enter键的扫描码
je enter
jmp getstrs
backspace:
mov ah, 1
call charstack ; 字符出栈
mov ah, 2
call charstack ; 显示栈中的字符
jmp getstrs
enter:
pop si
pop bx
pop ax
ret

我们使用一个字符栈来保存用户输入的字符串,为了代码的简洁将字符栈的相关操作封装成了charstack子程序。

首先将top清零,保险起见将键盘缓冲区清理了一下。接下来调用int 16h中断读取用户的键盘输入,如果是字符就入栈,如果是退格键就出栈,如果是Enter键则结束输入。每次用户做了修改之后,更新显示。

字符栈的子程序实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
; 字符栈的入栈、出栈和显示
; 参数说明: (ah)=功能号,0入栈,1出栈,2显示
; ds:si 指向字符栈空间
; 对于0号功能: (al)=入栈字符
; 对于1号功能: (al)=返回的字符
; 对于2号功能: (dh)和(dl)=字符串在屏幕上显示的行、列
charstack:
push bx
push di
push es
push bp
mov bp, offset top-task+7e00h
cmp ah, 0
je charpush
cmp ah, 1
je charpop
cmp ah, 2
je charshow
jmp charret

charpush:
mov bx, ds:[bp] ; top的值
cmp bx, 31 ; 防止溢出
ja charret
mov [si][bx], al
inc word ptr ds:[bp]
jmp charret
charpop:
cmp word ptr ds:[bp], 0
je charret
dec word ptr ds:[bp]
mov bx, ds:[bp]
mov al, [si][bx]
jmp charret
charshow:
mov bx, 0b800h
mov es, bx
mov ax, 160
mul dh ; 行号*160
mov di, ax
mov ax, 2
mul dl ; 列号*2
add di, ax

mov bx, 0
charshows:
cmp bx, ds:[bp]
jne noempty
mov byte ptr es:[di], ' '

push dx
mov dl, bl
mov bx, 0 ; bx已经不用了, 可以覆盖
mov ah, 2 ; 功能号2: 设置光标
int 10h
pop dx
jmp charret
noempty:
mov al, [si][bx]
mov es:[di], al
mov byte ptr es:[di+2], ' '
inc bx
add di, 2
jmp charshows

charret:
pop bp
pop es
pop di
pop bx
ret

(ah)表示功能号,0入栈,1出栈,2显示。注意入栈和出栈时要检查栈顶位置,防止溢出。另外,在显示的时候,我们将最后一个字符后面的位置也置成了空格,是为了处理退格的情况。

时间检查子程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
; 检查时间格式是否正确
; 参数: ds:si指向字符栈起始位置
; 返回: (ah)=0表示格式正确, (ah)=1表示格式错误
; yy/MM/dd hh:mm:ss
checkclock:
push bx
push cx
push dx
push si
push di
push ax

; 检查数字
mov di, offset digitoffset-task+7e00h
mov bx, 0
mov cx, 12

checkdigits:
mov bl, [di] ; 获取数字在字符串中的偏移量
mov al, [si][bx]
call isdigit ; 检查对应位置是否是数字
cmp ah, 0
jne checkfail ; 返回值0表示是数字
inc di
loop checkdigits

; 检查特殊符号
mov cx, 5
mov di, offset timeformat-task+7e00h
mov bx, 2 ; +3
checksigns:
mov al, [di][bx]
cmp [si][bx], al ; 检查对应位置的符号是否正确
jne checkfail
add bx, 3
loop checksigns

; 检查日期
call checkdate ; 检查日期是否合法,包括闰年的检查
cmp ah, 0
jne checkfail

; 检查时间
add si, 9
call checktime ; 检查时间是否合法
cmp ah, 0
jne checkfail

pop ax
mov ah, 0
jmp checkend
checkfail:
pop ax
mov ah, 1
checkend:
pop di
pop si
pop dx
pop cx
pop bx
ret

checkclock子程序用于检查格式,首先要确保用户输入的字符串是yy/MM/dd hh:mm:ss格式的,这部分比较简单,其中isdigit用于检查某个字符是否是数字。然后还要检查日期和时间是否合法的,因为这块比较复杂,分别封装成了两个子程序checkdatechecktime

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
; 检查日期是否合法
; 参数: ds:si指向字符串起始位置
; 返回: (ah)=0表示格式正确, (ah)=1表示格式错误
; YY/MM/dd
checkdate:
push bx
push si
push di
push ax

; 检查月份是否超出
add si, 3 ; 月份从位置3开始
call char2number ; 将ds:si所指位置的两个字符转成数值
cmp al, 1 ; 月份有效值1到12
jb datefail
cmp al, 12
ja datefail

mov bx, 0
mov bl, al ; 月份先保存到bl

; 检查日期是否超出
add si, 3 ; 日期从位置6开始
call char2number
cmp al, 1 ; 日期最小1
jb datefail

mov di, offset daysofmonth-task+7e00h
cmp al, [di][bx]
ja datefail ; 最大根据月份来判断

; 不是2月29日不用检查闰年
cmp al, 29
jne datepass
cmp bl, 2
jne datepass

; 检查闰年
mov bh, al ; 日期先保存到bl
mov ax, 0
sub si, 6
call char2number
add ax, 1900 ; (al)>=90, 认为是19YY
cmp ax, 1990
jnb nineteenth
add ax, 100 ; (al)<90, 认为是20YY
nineteenth:
call isleapyear ; 判断ax所表示的年份是否是闰年
cmp ah, 0
jne datepass ; 闰年不能有2月29日
datefail:
pop ax
mov ah, 1
jmp dateend
datepass:
pop ax
mov ah, 0
dateend:
pop di
pop si
pop dx
ret

首先看日期的检查,月份的有效值1到12,日期的最小值也是1,最大值根据月份来决定,我们已经将每个月的天数做成了表格放在数据中,可以直接查表获取。其中char2number用于将两个字符转成字节型数值。

如果是2月29日,还需要检查是否是闰年,我们将闰年的检查封装成了isleapyear子程序,篇幅原因不再展示。另外,因为CMOS RTC时间不一定有表示世纪的寄存器,我们将大于等于90的年份认为是20世纪,其余的认为是21世纪。

checktime子程序比较简单,这里不再赘述。

CMOS RTC时间的读写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
; 设置CMOS RTC时间
; 参数: ds:si指向字符串起始位置
; 实际测试,不是闰年也设置不了2月29日
; 2月28日直接跳到3月1日, 暂不清楚原因
setclock:
push ax
push cx
push si
push di

mov di, offset timeoffset-task+7e00h
mov cx, 6 ; 循环次数
setclocks:
mov al, [di] ; 获取内存单元号
out 70h, al ; 写到控制端口

mov ah, [si] ; 十位字符
sub ah, 30h ; 实际数值
push cx
mov cl, 4 ; 左移位数
shl ah, cl ; 高4位放十位的值
pop cx
mov al, [si].1 ; 个位字符
sub al, 30h ; 实际数值
add al, ah ; 相加

out 71h, al ; 写到数据端口

add si, 3
inc di
loop setclocks

pop di
pop si
pop cx
pop ax
ret

我们通过端口来跟外设通信,CMOS RAM芯片有两个端口70h和71h,70h是地址端口,存放要访问的CMOS RAM单元地址;71h为数据端口,存放要读写的数据。每次对CMOS RAM的读写分两步进行,首先将地址写入端口70h,然后将数据写到71h端口。

我们依次将年月日时分秒的值写入CMOS中,其中时间信息都是以BCD码的形式存放的,一个字节表示两个十进制数。所以写之前,先要将两个字符转化对应的BCD码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
; 设置CMOS RTC时间
; 参数: ds:si指向字符串起始位置
getclock:
push ax
push cx
push si
push di

mov di, offset timeoffset-task+7e00h
mov cx, 6 ; 循环次数
getclocks:
mov al, [di] ; 获取内存单元号
out 70h, al ; 写到控制端口
in al, 71h ; 从数据端口读取

mov ah, al
push cx
mov cl, 4 ; 右移位数
shr ah, cl ; ah中为十位
pop cx
and al, 00001111b ; al中为个位
add ah, 30h ; 对应ascii码
add al, 30h ; 对应ascii码
mov [si], ah
mov [si].1, al

add si, 3
inc di
loop getclocks

pop di
pop si
pop cx
pop ax
ret

getclock子程序用于从CMOS读取,操作正好相反,这里不再赘述。

测试

最终完成整个程序之后,可以按如下步骤进行测试(这里我是在DOSBox环境下开发的,程序文件名为design2.asm)

1
2
3
4
5
6
7
Z:\>mount c ~
Z:\>c:
C:\>masm design2;
C:\>link design2;
C:\>imgmount 0 c:\floppy.img -t floppy -fs none
C:\>design2.ext
C:\>imgmount -u 0

首先进行编译、链接,因为软盘映像我们前面已经做好,这里直接挂载即可,然后运行design2安装程序将任务程序写到软盘中,最后再卸载软盘映像。

现在我们就可以把这个软盘映像放到虚拟机上进行测试了,如果虚拟机没有创建过软驱的话需要先创建一个,选择启动时连接这个软驱,然后启动虚拟机,接下来就是见证奇迹的时刻了。

小结

完整的代码已上传Github,欢迎交流讨论。

-------------本文结束感谢您的阅读-------------

欢迎关注我的其它发布渠道