平常我们的程序基本都是运行在操作系统之上,很少有机会直接在裸机上运行程序。现代操作系统需要支持多任务环境的工作方式,这要求CPU在硬件上提供支持。以Intel处理器为例,从80286/80386开始,CPU开始支持工作在保护模式,为多任务环境提供保护机制。
操作系统为我们提供了保护和抽象,这方便了开发和使用,但是同时也对学习者了解计算机底层工作原理造成了一定的障碍。本文通过编码实践王爽老师《汇编语言》中的课程设计2,与寄存器、内存、显存、中断、外设等进行了近距离的亲密接触,通过这次实践也算是入门汇编了吧。
背景知识 计算机上电之后,CPU自动从FFFF:0单元处开始执行,这里有一条跳转指令,CPU转去执行BIOS中的硬件系统检测和初始化程序。初始化程序将从0:0单元处开始建立中断向量表。硬件系统检测和初始化完成之后,调用int 19h进行操作系统引导。从软盘或硬盘第一个扇区读取内容到0:7c00,然后CS:IP指向0:7c00开始执行。
这个课程设计的任务就是编写一个可以直接在裸机上运行的程序,具体功能如下:
列出功能选项,让用户通过键盘进行选择,界面如下。
reset pc ; 重新启动计算机
start system ; 引导现有的操作系统
clock ; 进入时钟程序
set clock ; 设置时钟
用户输入“1”后重新启动计算机(重新跳转到ffff:0单元)
用户输入“2”后引导现有的操作系统(读取硬盘C的0道0面1扇区进行引导)
用户输入“3”后,执行动态显示当前日期、时间的程序(循环读取CMOS),格式:年/月/日 时:分:秒。按下F1键后,改变显示颜色;按下Esc键后,返回到主选单(利用键盘中断)
用户输入“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处,紧跟在第一个扇区后面。读完之后就跳转到主体部分代码的开头继续执行。
任务程序主体部分 主体部分完成我们程序的主要功能
首先我们需要注册一个新的int 9中断例程,包装BIOS提供的中断例程,因为我们需要根据用户的键盘输入做不同的操作。这个中断例程是我们程序的核心。
接着显示几个选项供用户选择
然后进入循环,根据模式标志决定是否显示时间
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
指令的操作,我们手动模拟中断过程。中断过程可以简单描述如下:
取得中断类型码N
pushf
TF=0, IF=0
push CS
push IP
(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
用于检查某个字符是否是数字。然后还要检查日期和时间是否合法的,因为这块比较复杂,分别封装成了两个子程序checkdate
和checktime
。
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
子程序比较简单,这里不再赘述。
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 ,欢迎交流讨论。