一、实验题目
实验二:加载执行COM格式用户程序的监控程序
二、实验目的
- 了解监控程序执行用户程序的主要工作
- 了解一种用户程序的格式与运行要求
- 加深对监控程序概念的理解
- 掌握加载用户程序方法
- 掌握几个BIOS调用和简单的磁盘空间管理
三、实验要求
- 知道引导扇区程序实现用户程序加载的意义
- 掌握COM/BIN等一种可执行的用户程序格式与运行要求
- 将自己实验一的引导扇区程序修改为3-4个不同版本的COM格式程序,每个程序缩小显示区域,在屏幕特定区域显示,用以测试监控程序,在1.44MB软驱映像中存储这些程序。
- 重写1.44MB软驱引导程序,利用BIOS调用,实现一个能执行COM格式用户程序的监控程序。
- 设计一种简单命令,实现用命令交互执行在1.44MB软驱映像中存储几个用户程序。
- 编写实验报告,描述实验工作的过程和必要的细节,如截屏或录屏,以证实实验工作的真实性
实验内容
- 将自己实验一的引导扇区程序修改为一个的COM格式程序,程序缩小显示区域,在屏幕第一个1/4区域显示,显示一些信息后,程序会结束退出,可以在DOS中运行。在1.44MB软驱映像中制定一个或多个扇区,存储这个用户程序a。 相似地、将自己实验一的引导扇区程序修改为第二、第三、第四个的COM格式程序,程序缩小显示区域,在屏幕第二、第三、第四个1/4区域显示,在1.44MB软驱映像中制定一个或多个扇区,存储用户程序b、用户程序c、用户程序d。
- 重写1.44MB软驱引导程序,利用BIOS调用,实现一个能执行COM格式用户程序的监控程序。程序可以按操作选择,执行一个或几个用户程序。解决加载用户程序和返回监控程序的问题,执行完一个用户程序后,可以执行下一个。
- 设计一种命令,可以在一个命令中指定某种顺序执行若干个用户程序。可以反复接受命令。
- 在映像盘上,设计一个表格,记录盘上有几个用户程序,放在那个位置等等信息,如果可以,让监控程序显示出表格信息。
- 拓展自己的软件项目管理目录,管理实验项目相关文档
四、实验方案
1、实验环境
实验所使用的计算机配置为:
- CPU:Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz
- RAM:8.00GB
- 系统:Windows 10 - x64 10.0.17763
使用的开发工具如下:
- Oracle VM VirtualBox - 6.0.14 r133895 (Qt5.6.2):虚拟机软件,相比起VMware,有着小巧轻便的特点,功能也很齐全。用于创建、运行虚拟机,创建虚拟软盘,引导虚拟软盘启动。
- WinHex - 19.6 x64:16进制文件编辑器,用于查看、编辑虚拟软盘文件的内容。
- Visual Studio Code - 1.44.2 :代码编辑器,可以根据需要配置插件,用于编辑
.asm
代码。 - NASM version 2.14.02 :汇编语言编译程序,安装在Windows 10环境下;利用命令行操作,将
.asm
代码汇编成.bin
二进制文件。 - FloppyWriter:轻量级的软盘写入工具,用于将
.bin
二进制文件写入.img
软盘文件。 - bochs:x86硬件平台的开源模拟器,可以在运行过程中停止,本次实验中用于跟踪寄存器、堆栈,调试虚拟机。
2、实验思路
根据实验要求和相关知识,我们按照以下思路完成实验:
- 修改实验一的程序代码,实现在屏幕1/4区域显示的四个用户程序,汇编成.com文件;
- 编写引导扇区程序,让其引导用户程序运行;
- 使用WinHex将引导程序和用户程序写入虚拟软盘中;
- 在VirtualBox、Bochs上搭载该虚拟软盘,运行并进行调试。
- 在实现基本功能“监控、调用与返回”之后,增加新的功能。
3、程序设计
根据老师给的样例代码与实验要求,我希望自己的代码实现的功能如下:
用户程序
- 四个不同的用户程序,分别在1/4屏幕进行反弹;
- 由于屏幕大小为80x25,每一个用户程序占用的屏幕大小规定为40x12,剩下一行用于显示监控程序的信息;
- 在用户程序运行时,按下
esc
键可以返回监控程序。
监控程序
- 成功载入监控程序之后显示文本;
- 接收键盘输入,根据输入内容加载不同的用户程序;
- 用户程序返回监控程序后可继续接收键盘输入。
代码根据以上思路编写,具体实现方法在实验过程中进行说明。
五、实验过程
1、用户程序设计
用户程序只需要根据实验一的程序修改,为了满足.com文件的要求,使用org 100h
指令;
几个核心部分的说明如下:
a、程序初始化
这一部分关系到用户程序加载后是否能够成功运行,也是调试花费时间最长的一部分。
根据老师的提醒,在程序运行的起始阶段,需要将寄存器cs的值赋值给ds、es、ss,但实际上不能将cs的值赋给ss,原因如下:
监控程序使用call 800h:100h
进行跳转,这一指令会将cs、ip的值压栈,在用户程序执行retf
指令时再将其取出。如果修改了ss的值,用户程序将无法找到原来的栈,无法恢复cs、ip的值,所以会导致无法返回监控程序。
因此,程序初始化的代码最终确定为如下形式——
mov ax, cs ; 置其他段寄存器值与CS相同
mov ds, ax ; 数据段
mov es, ax ; 置ES=DS
b、响应键盘输入
我希望在用户程序运行时,按下任意键可以返回监控程序。在查阅相关资料后,决定使用中断int 16h
的01h
功能实现,该功能可以在不阻塞的情况下监控键盘缓冲区,我们判断缓冲区是否为0,如果不是(即有输入),跳转到返回处理的代码段。
注意,这一功能读取缓冲区后,缓冲区数据不会改变,即缓冲区的输入仍会被监控程序读取到,在监控程序中会对其作出处理。
show2:
mov ah,byte[color] ; ax高八位为字符颜色
mov al,byte[char+si] ; ax低八位为显示字符
mov [gs:bx],ax ; 存入显存
mov ah, 01h ; 查询键盘缓冲区
int 16h
cmp al, 0
jne return
jmp loop1 ; 进行下一次循环
c、返回处理
返回处理包括清屏和跳转回监控程序两部分,跳转功能只需要使用retf
指令,而清屏功能同样使用了一个Bios中断:
return:
mov ax, 0600h
mov ch,1 ; 左上角的行号
mov cl,0 ; 左上角的列号
mov dh,25 ; 右下角的行号
mov dl,79 ; 右下角的行号
mov bh,0x00; 属性
int 10h ; 中断调用,清屏
retf
d、不同用户程序
我们需要设计四个不同的用户程序,实现在不同位置的反弹,这里使用了INIT_X
和INIT_Y
两个常量,为程序实现时在屏幕上左上角的位置,在判断边界条件时也使用这一常量。
这么设计之后,四个用户程序的代码只有这一部分不同,代码移植时修改INIT_X
和INIT_Y
的数值即可。
; 对应UL的用户程序
INIT_X equ 0
INIT_Y equ 1
完成代码后分别进行汇编,生成的.com文件大小均不到512B,因此在监控程序中加载内存操作中,每次只需读取一个扇区。
2、监控程序设计
监控程序放在引导扇区中,因此需要org 7c00h
指令,在成功载入后显示文本 Hello, my OS is loading user program
字样;载入用户程序时,载入内存位置均为OffSetOfUserPrg1 equ 8100h
。
几个核心部分说明如下:
a、接收键盘输入,根据输入内容加载不同的用户程序
每次进行加载时,都要用寄存器cs的值更新ds、es、ss,这是为了用户程序返回监控程序后能够正常运行;
接受键盘输入使用了中断int 16h
,功能号00h
,这个接收会发生阻塞,若接收到的输入为数字1、2、3、4,跳转至加载阶段,否则重新接受输入(这一功能读取缓冲区后,缓冲区对应内容会被清除,所以用户程序的输入会在这一部分被清除,然后重新接收来自键盘的输入)
Choose:
mov ax, cs ; 置其他段寄存器值与CS相同
mov ds, ax ; 数据段
mov ss, ax ; 栈指针
mov es, ax ; 置ES=DS
mov ax, 0000h ; AH = 00h(功能号)
int 16h ; BIOS的16h功能:读取键盘输入, 完成后al = 输入字符
cmp al, '1'
je LoadnEx
cmp al, '2'
je LoadnEx
cmp al, '3'
je LoadnEx
cmp al, '4'
je LoadnEx
jmp Choose
b、加载内容至内存
判断输入有效之后,会跳转至该部分,该部分代码首先在屏幕上显示加载的程序名称,之后读取对应的扇区,将其加载至内存,并使用call
指令跳转。
由于输入的1、2、3、4是ASCII码,而且对应的扇区为2、3、4、5,(监控程序为首扇区,用户程序依次加载至接下来的扇区),所以先进行add ax, 1
操作,然后sub al, 0x30
将ASCII码转化为数字。
从用户程序跳转回来后,跳回Choose
继续等待键盘输入。
LoadnEx:
mov byte [gs:80],al
mov byte [gs:82],'.'
mov byte [gs:84],'C'
mov byte [gs:86],'O'
mov byte [gs:88],'M'
add ax, 1
sub al, 0x30
;读软盘或硬盘上的若干物理扇区到内存的ES:BX处:
mov bx, OffSetOfUserPrg1 ;偏移地址; 存放数据的内存偏移地址
mov ch,0 ; 柱面号 ; 起始编号为0
mov cl,al ; 起始扇区号 ; 起始编号为1
mov ah,2 ; 功能号
mov al,1 ; 扇区数
mov dl,0 ;驱动器号 ; 软盘为0,硬盘和U盘为80H
mov dh,0 ;磁头号 ; 起始编号为0
int 13H ; 调用读磁盘BIOS的13h功能
; 用户程序a.com已加载到指定内存区域中
mov dl,cl ; 信息保存在dl上
call 800h:100h ; call?
jmp Choose
汇编生成bin文件,接下来开始将数据写入软盘。
3、Bochs调试
完成两部分程序后,使用WinHex将监控程序、用户程序写入一个空白软盘test2.img
,监控程序存储在第一个扇区,第一个用户程序存储在第二个扇区,第二个用户程序存储在第三个扇区,以此类推。
完成后可以直接使用 VirtualBox 虚拟机运行,但这里使用Bochs进行调试,是因为一开始设计代码时,执行时无法成功跳转或返回,这些问题用Bochs可以比较清晰地找出来。下面简单介绍Bochs的功能,并说明使用Bochs调试的过程。
Bochs可以在内存中设置断点,在断点处中断执行,可以查看当前执行状态下内存情况、寄存器情况和堆栈情况,在本次实验中发挥了很大的作用。
在Bochs中使用指令u 0x7c00 0x7e00
可以反汇编一段内存,查询到call
指令的内存位置,使用pb 0x7c72
设置断点,并执行至该位置,单步执行后成功进入用户程序位置0x8100
。
此时使用sreg
可以查看段寄存器的状态,在跳转之后,cs寄存器已经被置为800h
,但ds、es依旧为0,所以我们需要用cs更新ds和es;
但是上面我们说过,我们不能更新堆栈段寄存器ss,ss=0时,使用print-stack
可以查看此时的栈,栈顶元素为call
指令压进的 ip=0x7c77
和 cs=0
,如果更新了ss,查看到的栈会偏移至其他位置,效果如下图所示,栈中元素均为0。
同样,反汇编找到用户程序中retf
的位置,设置断点,继续执行并追踪寄存器的值;
成功返回监控程序后,会跳转至Choose的位置,再次查看段寄存器的值,可以发现cs=0
,但ds、es仍为改变后的800h
,这也是在Choose的位置需要再次更新段寄存器的原因。
Choose:
mov ax, cs ; 置其他段寄存器值与CS相同
mov ds, ax ; 数据段
mov ss, ax ; 栈指针
mov es, ax ; 置ES=DS
使用Bochs观察跳转和返回时寄存器和内存的情况,让跳转和返回的过程十分清晰,寻找错误也更加方便。
4、最终实验结果
加载监控程序:
用户程序UL:
返回监控程序:
用户程序UR:
用户程序DL:
用户程序DR:
5、v2版本软盘设计
在实验内容3、4中,要求我们完成如下设计:
- 设计一种命令,可以在一个命令中指定某种顺序执行若干个用户程序。可以反复接受命令。
- 在映像盘上,设计一个表格,记录盘上有几个用户程序,放在那个位置等等信息,如果可以,让监控程序显示出表格信息。
为了完成以上设计,在新的监控程序myos_v2.asm
上添加代码。
为了按顺序执行若干命令,按照以下思路完善代码——
Choose
,接收命令前的初始化;EnterIns
,键盘输入,如果输入为1、2、3、4,跳转至Save
存储;如果输入为esc
,跳转至LoadnEx
开始执行;否则跳转至EnterIns
继续接收键盘输入;Save
,将输入的字符依次存储,存储后跳转至EnterIns
继续接收键盘输入;LoadnEx
,开始按照顺序读盘、执行,全部执行完成后跳转至Choose
接收下一命令。
各段代码如下:
所用变量
使用count
计算一共需要加载多少次,加载的顺序存储在instruction
中,num
用于按顺序执行
count dw 0
num dw 0
instruction db 0,0,0,0
Choose
进行简单的初始化,并将上一次输入的字符清除;
Choose:
mov ax, 0600h
mov ch,0 ; 左上角的行号
mov cl,40 ; 左上角的列号
mov dh,0 ; 右下角的行号
mov dl,79 ; 右下角的行号
mov bh,0x00; 属性
int 10h ; 中断调用,清屏
mov ax, cs ; 置其他段寄存器值与CS相同
mov ds, ax ; 数据段
mov ss, ax ; 栈指针
mov es, ax ; 置ES=DS
mov ax, 0000h ; AH = 00h(功能号)
mov byte[count], 0
EnterIns
输入字符,判断对应操作;
EnterIns:
mov ax, cs ; 置其他段寄存器值与CS相同
mov ds, ax ; 数据段
mov ax, 0000h
int 16h ; BIOS的16h功能:读取键盘输入, 完成后al = 输入字符
cmp al, 'f'
je ShowForm
cmp al, 27
je LoadnEx
cmp al, '1'
je Save
cmp al, '2'
je Save
cmp al, '3'
je Save
cmp al, '4'
je Save
jmp EnterIns
Save
如果输入的为1、2、3、4,将输入按顺序显示在第一行,并存储在变量instruction
中;
Save:
mov si, 40
add si, word[count]
add si, si
mov byte [gs:si],al
mov byte [gs:si+1], 0x07
add ax, 1
sub al, 0x30
mov si, word[count]
mov byte[instruction+si], al
add byte[count], 1
jmp EnterIns
LoadnEx
在加载扇区前,需要更新ds以保证后面访问的内存地址正确,之后判断、选择继续读取或者跳回Choose
;在call
后面的中断指令是为了清除缓冲区的键盘输入,保证程序正确运行。
LoadnEx:
mov ax, cs ; 置其他段寄存器值与CS相同
mov ds, ax ; 数据段
mov ss, ax ; 栈指针
mov es, ax ; 置ES=DS
mov si, word[num]
cmp word[count], si
je Choose
inc byte[num]
mov al, byte[instruction+si]
;读软盘或硬盘上的若干物理扇区到内存的ES:BX处:
mov bx, OffSetOfUserPrg1 ;偏移地址; 存放数据的内存偏移地址
mov ch,0 ;柱面号 ; 起始编号为0
mov cl,al ;起始扇区号 ; 起始编号为1
mov ah,2 ; 功能号
mov al,1 ;扇区数
mov dl,0 ;驱动器号 ; 软盘为0,硬盘和U盘为80H
mov dh,0 ;磁头号 ; 起始编号为0
int 13H ; 调用读磁盘BIOS的13h功能
; 用户程序a.com已加载到指定内存区域中
mov dl,cl ; 信息保存在dl上
call 800h:100h ; call?
mov ax, 0000h
int 16h ; BIOS的16h功能:读取键盘输入, 完成后al = 输入字符
jmp LoadnEx
完成后写入test2_v2.img
,用户程序与前一个版本相同,运行效果如下:按照输入顺序,屏幕显示12334321
,如果按下esc
,用户程序会依次执行,当前用户程序执行时,按下任意键执行下一用户程序。
接下来的要求是设计一个表格,记录盘上有几个用户程序,放在那个位置等等信息,这次我仅设计了一个粗糙的表格,在监控程序中使用了以下数据简单存储,其中Form对应四个用户程序,数据依次为[文件名,所在扇区,扇区大小]
info db ' .com Disk: Size: ',0
infoLength equ ($-info)
Form1 db 1,2,1
Form2 db 2,3,1
Form3 db 3,4,1
Form4 db 4,5,1
使用ShowForm
显示存储的表,利用了中断和显存显示;
ShowForm:
mov ax, 1301h ; AH = 13h(功能号)、AL = 01h(光标置于串尾)
mov bx, 0007h ; 页号为0(BH = 0) 黑底白字(BL = 07h)
mov bp, info ; BP=当前串的偏移地址
mov cx, infoLength ; CX = 串长
mov dh, 1 ; 行号=1
mov dl, 0 ; 列号=0
int 10h ; BIOS的10h功能:显示一行字符
mov dh, 2 ; 行号=2
mov dl, 0 ; 列号=0
int 10h ; BIOS的10h功能:显示一行字符
mov dh, 3 ; 行号=3
mov dl, 0 ; 列号=0
int 10h ; BIOS的10h功能:显示一行字符
mov dh, 4 ; 行号=4
mov dl, 0 ; 列号=0
int 10h ; BIOS的10h功能:显示一行字符
mov al, [Form1+0]
add al, 0x30
mov byte [gs:160],al
mov byte [gs:160+1], 0x07
mov al, [Form1+1]
add al, 0x30
mov byte [gs:160+24],al
mov byte [gs:160+1+24], 0x07
mov al, [Form1+2]
add al, 0x30
mov byte [gs:160+42],al
mov byte [gs:160+1+42], 0x07
mov al, [Form2+0]
add al, 0x30
mov byte [gs:320],al
mov byte [gs:320+1], 0x07
mov al, [Form2+1]
add al, 0x30
mov byte [gs:320+24],al
mov byte [gs:320+1+24], 0x07
mov al, [Form2+2]
add al, 0x30
mov byte [gs:320+42],al
mov byte [gs:320+1+42], 0x07
mov al, [Form3+0]
add al, 0x30
mov byte [gs:480],al
mov byte [gs:480+1], 0x07
mov al, [Form3+1]
add al, 0x30
mov byte [gs:480+24],al
mov byte [gs:480+1+24], 0x07
mov al, [Form3+2]
add al, 0x30
mov byte [gs:480+42],al
mov byte [gs:480+1+42], 0x07
mov al, [Form4+0]
add al, 0x30
mov byte [gs:640],al
mov byte [gs:640+1], 0x07
mov al, [Form4+1]
add al, 0x30
mov byte [gs:640+24],al
mov byte [gs:640+1+24], 0x07
mov al, [Form4+2]
add al, 0x30
mov byte [gs:640+42],al
mov byte [gs:640+1+42], 0x07
jmp Choose
在接受键盘输入时,如果接收到按键F,就会跳转到表格显示,显示效果如下:
六、实验总结
本次实验遇到的问题主要在调试阶段,只使用VirtualBox运行的话,不能快速的找到无法运行、无法跳转的原因,因此学习使用了Bochs,用以在执行过程中查看寄存器和内存。
这次体会到bin文件和com文件的区别,也对org伪指令有了更深的理解:可以认为在汇编时将指令中的标号所对应符号地址偏移量+跟在org后的值。同时,访问内存单元时如果是[ label ] ,对应的实际内存地址是ds : label+org
。所以,这次实验在将com文件加载至8100h时,需要将cs、ds、es置为800h,保证100h的偏移量,才能保证正常运行。
跟踪call和retf的过程,发现了 call 800h:100h 和 call 8100h的区别:如果使用call 8100h的命令,跳转过后的cs仍为0,由于com文件时org 100h,运行时会出现错误。只有call 800h:100h,跳转后cs=800h且ip=100h,并用其更新ds和es,代码执行时就不会产生错误。retf需要注意不能更改ss的值,或许可以先将ss的值保存起来,再修改至800h以建立一个新的栈,返回时再恢复,以找回监控程序cs、ip的值。不过这次实验的用户程序没有使用栈,所以索性不去修改ss的值。
实验中对键盘的监控也有细节需要注意:接收的字符为ASCII码,而不是纯数字,接收键盘输入的“1”“2”“3”之后还需要对字符做进一步的处理,一开始由于没有注意到,花了不少时间排错。同时,在非阻塞状态下读取键盘输入后,输入仍留在缓冲区,这一情况也是执行后才发现的,为了处理滞留在缓冲区中的输入,监控程序还需要做进一步的处理。
在完成v2版本的程序时,遇到的主要挑战是交互命令的设计,其中许多细节在调试中才被发现,例如:从retf返回后,需要重新设置ds,否则访存会出错;用户程序中键盘缓冲区接收的数据需要及时处理,否则循环会出错。