我们今天来接着学习操作系统。在之前我们在一个新的 OS 上编写了一个打印 hello 的语句,那么在实际的 OS 中,主程序的 512 字节肯定是放不下的。那么我们就要学习如何突破这 512 个字节,进而接着在 OS 上运行随后的代码。在上节博客中我们学习了主引导程序的扩展,那么我们在后面的学习中就是要将 512 字节后的代码交由软盘来存储。也就是将控制权由主引导程序交由软盘上的程序,进而执行后面的工作。我们先来做一个准备工作,编写一个辅助函数。它的功能是:1、字符串打印;2、软盘读取。
我们先来看看在 BIOS 中的字符串打印有哪些特点,如下
1、指定打印参数(AX = 0x1301, BX = 0x0007);
2、指定字符串的内存地址(ES:BP = 串地址);
3、指定字符串的长度(CX = 串长度);
4、中断调用(int 0x10)。
下来我们来看一个字符串打印示例,如下所示
那么既然在代码中涉及到了汇编代码,我们就稍微来介绍下相关的汇编知识。1、在汇编中可以定义函数(函数名使用标签定义):call function,注意函数体的最后一条指令为 ret;2、如果代码中定义函数,那么需要定义栈空间:用于保存关键寄存器的值,栈顶地址通过 sp 寄存器保存;3、汇编中的“常量定义”(equ):a> 用法:const equ 0x7c00; ==> #define const 0x7c00;b> 与 dx(db, dw, dd) 的区别:dx 定义占用相应的内存空间,equ 定义不会占用任何内存空间。下来我们就来看看打印函数是怎么编写的,在编写打印函数之前,我们先写一个 makefile,用来代替那些繁琐的镜像制作步骤
makefile 源码
.PHONY : all clean rebuild SRC := boot.asm OUT := boot.bin IMG := data.img RM := rm -rf all : $(SRC) $(OUT) dd if=$(OUT) of=$(IMG) bs=512 count=1 conv=notrunc @echo "Success!" $(IMG) : bximage $@ -q -fd -size=1.44 $(OUT) : $(SRC) nasm $^ -o $@ clean : $(RM) $(IMG) $(OUT) rebuild : @$(MAKE) clean @$(MAKE) all
boot.asm 源码
org 0x7c00 jmp short start nop header: BS_OEMName db "D.T.Soft" BPB_BytsPerSec dw 512 BPB_SecPerClus db 1 BPB_RsvdSecCnt dw 1 BPB_NumFATs db 2 BPB_RootEntCnt dw 224 BPB_TotSec16 dw 2880 BPB_Media db 0xF0 BPB_FATSz16 dw 9 BPB_SecPerTrk dw 18 BPB_NumHeads dw 2 BPB_HiddSec dd 0 BPB_TotSec32 dd 0 BS_DrvNum db 0 BS_Reserved1 db 0 BS_BootSig db 0x29 BS_VolID dd 0 BS_VolLab db "D.T.OS-0.01" BS_FileSysType db "FAT12 " start: mov ax, cs mov ss, ax mov ds, ax mov es, ax mov sp, ax mov ax, MsgStr ; 指定打印的字符串 mov cx, 6 ; 指定打印的个数 mov bp, ax ; 指定目标字符串的段内偏移地址 mov ax, ds mov es, ax ; 指定目标字符串所在段的起始地址 mov ax, 0x1301 mov bx, 0x0007 int 0x10 ; 指定 BIOS 的 0x10 号中断 last: hlt jmp last MsgStr db "Hello, YHOS!" Buf: times 510-($-$$) db 0x00 db 0x55, 0xaa
我们来编译看看结果
我们看到确实打印出了指定的前 6 个字符,说明我们的 Print 函数已经实现完成。接下来我们思考一个问题:主引导程序中如何读取指定扇区处的数据?
我们先来看看软盘的构造:一个软盘有 2 个盘面,每个盘面对应 1 个磁头;每一个盘面被划分为若干个圆圈,成为柱面(磁道);每一个柱面被划分为若干个扇区,每个扇区 512 个字节。具体表示如下
那么之前的 3.5 寸的软盘的数据特性如下:
1、每个盘面一共有 80 个柱面(编号为 0~79);
2、每个柱面有 18 个扇区(编号为 1~18);
3、存储大小:2 * 80 * 18 * 512 = 1474560 Bytes = 1440 KB = 1.44MB
下来我们就来看看软盘数据的读取。软盘数据以扇区(512字节)为单位进行读取,指定数据所在位置的磁头号、柱面号、扇区号。计算公式如下
我们接下来就来看看 BIOS 中的软盘数据读取,通过 int 0x13 来实现,具体功能如下所示
下来看看软盘数据读取的流程,如下
我们在上面的公式中用到了除法操作,那么我们就来介绍下汇编中的 16 位除法操作(div),被除数放到 AX 寄存器,除数放到通用寄存器或内存单元(8 位),结果:商位于 AL,余数位于 AH。下来我们就来实现磁盘数据的读取操作代码
org 0x7c00 jmp short start nop define: BaseOfStack equ 0x7c00 header: BS_OEMName db "D.T.Soft" BPB_BytsPerSec dw 512 BPB_SecPerClus db 1 BPB_RsvdSecCnt dw 1 BPB_NumFATs db 2 BPB_RootEntCnt dw 224 BPB_TotSec16 dw 2880 BPB_Media db 0xF0 BPB_FATSz16 dw 9 BPB_SecPerTrk dw 18 BPB_NumHeads dw 2 BPB_HiddSec dd 0 BPB_TotSec32 dd 0 BS_DrvNum db 0 BS_Reserved1 db 0 BS_BootSig db 0x29 BS_VolID dd 0 BS_VolLab db "D.T.OS-0.01" BS_FileSysType db "FAT12 " start: mov ax, cs mov ss, ax mov ds, ax mov es, ax mov sp, BaseOfStack mov ax, 34 mov cx, 1 mov bx, Buf call ReadSector mov bp, Buf mov cx, 34 call Print last: hlt jmp last ; es:bp --> string address ; cx --> string length Print: mov ax, 0x1301 mov bx, 0x0007 int 0x10 ret ; no parameters ResetFloppy: push ax push dx mov ah, 0x00 mov dl, [BS_DrvNum] int 0x13 pop dx pop ax ret ; ax --> 逻辑扇区号 ; cx --> 连续读取的扇区 ; es:bx --> 内存地址 ReadSector: push bx push cx push dx push ax call ResetFloppy push bx push cx mov bl, [BPB_SecPerTrk] div bl mov cl, ah add cl, 1 mov ch, al shr ch, 1 mov dh, al and dh, 1 mov dl, [BS_DrvNum] pop ax pop bx mov ah, 0x02 read: int 0x13 jc read pop ax pop dx pop cx pop bx ret MsgStr db "Hello, YHOS!" MsgLen equ ($-MsgStr) Buf: times 510-($-$$) db 0x00 db 0x55, 0xaa
我们先来看看生成的 data.img 中,我们所需的数据在什么地方,如下
我们看到是在 0x4400 处存放的,那么我们用 4400 的十进制 17424/512 = 34,因此我们在上面的 start 中, mov ax 34。字节长度为 34。下来我们来看看运行结果,是不是我们指定的这个地址处的这个字符串。结果如下
那么我们看到已经在正确打印出我们指定的字符串了。下来我们接着继续做准备工作,实现下面两个函数:内存比较和根目录区查找,整体思路如下
那么我们如何在根目录区查找目标文件呢?那便是通过根目录项的前 11 个字节进行判断,我们之前有用 C++ 实现过,代码如下
接下来我们便要用汇编语言来实现这部分的代码逻辑了。我们在实现之前先来看看内存比较是怎么回事,首先指定源起始地址(DS : SI),接着指定目标起始地址(ES : DI),最后判断在期望长度(CX)内每一个字节是否都相等。如下
在汇编中的比较与跳转是用 cmp 和 jz 实现的;比较指令示例:cmp cx, 0 ==> 比较 cx 的值是否为 0;跳转指令示例:jz equal ==> 如果比较的结果为真,则跳转至 equal 标签处。那么我们的比较操作示例代码如下
我们来看看具体源码是怎么编写的
start: mov ax, cs mov ss, ax mov ds, ax mov es, ax mov sp, BaseOfStack mov si, MsgStr mov di, DEST mov cx, MsgLen call MemCmp cmp cx, 0 jz label jmp last label: mov bp, MsgStr mov cx, MsgLen call Print last: hlt jmp last ; ds:si --> souurce ; es:di --> destination ; cx --> length ; ; return: ; ( cx == 0) ? equal : noequal MemCmp: push si push di push ax compare: cmp cx, 0 jz equal mov al, [si] cmp al, byte [di] jz goon jmp noequal goon: inc si ; si++ inc di ; di++ dec cx ; cx-- jmp compare equal: noequal: pop ax pop di pop si ret MsgStr db "Hello, YHOS!" MsgLen equ ($-MsgStr) DEST db "Hello, YHOS!" Buf: times 510-($-$$) db 0x00 db 0x55, 0xaa
我们基于之前的代码添加上面的部分代码。我们来编译运行看看 DEST 和 MsgStr 是相同的,因此会打印出这个字符串
我们看到确实已经是打印出来了,那么我们如何确认是程序正常运行还是异常的呢?我们通过反汇编来查找 cmp cx, 0 这句指令的地址,进而打上断点,通过查看相关的寄存器的值。如果 cx 的值此时为 0,那么便证明我们的代码是正确的了。我们通过查看这句指令的地址如下
那么我们在这块打上断点,来看看此时相关寄存器的值是多少
我们看到 ecx 寄存器的值确实是 0,因此它是正确的。如果我们将 DEST 字符串的最后一个! 改为 ?,我们来看看这个寄存器的值此时是不是还是 0
我们看到此时 ecx 寄存器的值为 1,证明就是最后一个字符不匹配导致的。因而我们的内存比较操作函数是正确的,下来我们继续来看看如何查找根目录区是否存在目标文件,思路如下
那么如何来加载根目录区呢?示例代码如下
我们在访问栈空间中的栈顶数据时,不能使用 sp 直接访问栈顶数据,而是要通过其他通用寄存器间接访问栈顶数据,示例代码如下
我们来看看最终的代码是怎么写的
define: BaseOfStack equ 0x7c00 RootEntryOffset equ 19 RootEntryLength equ 14 start: mov ax, cs mov ss, ax mov ds, ax mov es, ax mov sp, BaseOfStack mov ax, RootEntryOffset mov cx, RootEntryLength mov bx, Buf call ReadSector mov si, Target mov cx, TarLen mov dx, 0 call FindEntry cmp dx, 0 jz output jmp last output: mov bp, MsgStr mov cx, MsgLen call Print last: hlt jmp last ; es:bx --> root entry offset address ; ds:si --> target string ; cx --> target length ; ; return: ; (dx != 0) ? exist : noexist ; exist --> bx is the target entry FindEntry: push di push bp push cx mov dx, [BPB_RootEntCnt] mov bp, sp find: cmp dx, 0 jz noexist mov di, bx ; bx 寄存器的值指向了根目录区的第一项的入口地址 mov cx, [bp] call MemCmp cmp cx, 0 jz exist add bx, 32 ; 每一项代表 32 个字节 dec dx ; dx-- jmp find exist: noexist: pop cx pop bp pop di ret MsgStr db "No LOADER ..." MsgLen equ ($-MsgStr) Target db "LOADER " TarLen equ ($-Target)
我们来查找根目录区中有没有 LOADER 的字符串,如果有,就什么都不打印,如果没有,就打印 No LOADER ...。我们来看看结果,方法是一样的。我们还是通过查看相关寄存器的值来确定函数是否正确执行。 dx 不是 0 ,则证明目标字符串存在,如果为 0,则没有。
我们看到 dx 不是 0,那么它就是存在的。我们再通过之前在 bochsrc 中加载 freedos 的方式来看看 data.img 是否存在 LOADER 呢?
我们看到在 data.img 中确实是存在 LOADER 字符串的,接下来我们在目标字符串前面 加上 - ,来看看是否会打印出 No LOADER ... 呢?
我们看到再次执行后,dx 的值已经为 0,No LOADER ... 字符串也被打印出来了。从而再次证明我们写的根目录区查找函数是正确的,我们接着向下看,我们再来看看下来的流程图
我们现在的目标就是备份目标文件的目录信息(MemCpy),加载 Fat 表,并完成 Fat 表项的查找与读取(FatVec)。我们来看看目标文件的目录信息都有什么,备份它其实质就是内存拷贝。如下
在实现 MemCpy 的时候,注意的一个事项就是拷贝方向。要区分是从尾部向头部进行拷贝还是从头部向尾部进行拷贝,如下
我们在实现前先来看看相关的汇编代码,大于小于的代码指令的编写如下所示
我们接下来看看具体的源码是怎么实现的,如下
; ds:si --> source ; es:di --> destinaton ; cx --> length MemCpy: push si push di push cx push ax cmp si, di ja btoe ; si > di add si, cx add di, cx dec si dec di jmp etob ; si < di btoe: cmp cx, 0 jz done mov al, [si] mov byte [di], al inc si inc di dec cx jmp btoe etob: cmp cx, 0 jz done mov al, [si] mov byte [di], al dec si dec di dec cx jmp etob done: pop ax pop cx pop di pop si ret
测试代码如下
start: mov ax, cs mov ss, ax mov ds, ax mov es, ax mov sp, BaseOfStack mov ax, RootEntryOffset mov cx, RootEntryLength mov bx, Buf call ReadSector mov si, Target mov cx, TarLen mov dx, 0 call FindEntry cmp dx, 0 jz output mov si, Target mov di, MsgStr mov cx, TarLen call MemCpy output: mov bp, MsgStr add bp, MsgLen call Print
我们想要在内存中查找 LOADER 这个字符串并实现拷贝,看到运行的结果如下
我们接下来来看看 Fat 表项的读取,Fat 表项中的每个表项占用 1.5 个字节,即:使用 3 个字节可以表示 2 个表项,如下
我们下来看看 Fat 表项的“动态组装”,如下图所示
当 FatVec[j] 中的下标 j = 0, 2, 4, 6, 8 等时,i = j / 2 * 3 ==>(i, j 均为整数); FatVec[j] = ((Fat[i+1] & 0x0F) << 8) | Fat[i]; FatVec[j+1] = (Fat[i+2] << 4) | ((Fat[i+4]) & 0x0F); 接下来讲讲汇编中的相关代码的操作,在汇编中的 16 为乘法操作(mul):a> 被乘数放到 AL 寄存器; b> 乘数放到通用寄存器或内存单元(8位);c> 相乘的结果放到 AX 寄存器中。具体实现源码如下
; cx --> index ; bx --> fat table address ; ; return: ; dx --> fat[index] FatVec: mov ax, cx mov cl, 2 div cl ; cx / 2 push ax mov ah, 0 mov cx, 3 mul cx mov cx, ax pop ax cmp ah, 0 ; 余数是否为0 jz even jmp odd even: mov dx, cx add dx, 1 add dx, bx mov bp, dx mov dl, byte [bp] and dl, 0x0F shl dx, 8 ; add cx, bx mov bp, cx or dl, byte [bp] jmp return odd: mov dx, cx add dx, 2 add dx, bx mov bp, dx mov dl, byte [bp] mov dh, 0 ; 将 dx 寄存器的高8位全部赋值为0 shl dx, 4 add cx, 1 add cx, bx mov bp, cx mov cl, byte [bp] shr cl, 4 and cl, 0x0F mov ch, 0 or dx, cx return: ret
测试代码如下
define: BaseOfStack equ 0x7c00 BaseOfLoader equ 0x9000 RootEntryOffset equ 19 RootEntryLength equ 14 EntryItemLength equ 32 FatEntryOffset equ 1 FatEntryLength equ 9 start: mov ax, cs mov ss, ax mov ds, ax mov es, ax mov sp, BaseOfStack mov ax, RootEntryOffset mov cx, RootEntryLength mov bx, Buf call ReadSector mov si, Target mov cx, TarLen mov dx, 0 call FindEntry cmp dx, 0 jz output mov si, bx ; 将起始地址放到 si 中 mov di, EntryItem mov cx, EntryItemLength call MemCpy ; 计算 Fat 表所占用的内存 mov ax, FatEntryLength mov cx, [BPB_BytsPerSec] mul cx ; 将所占用的内存大小结果保存到 ax 中 mov bx, BaseOfLoader sub bx, ax ; bx 就是 Fat 表在内存中的起始位置了 mov ax, FatEntryOffset mov cx, FatEntryLength call ReadSector mov cx, [EntryItem + 0x1A] ; 获取目标起始处的位置 call FatVec jmp last output: mov bp, MsgStr mov cx, MsgLen call Print last: hlt jmp last
我们先来看看之前生成的镜像中,FatVec[j] 的值为多少。用 Qt 之前写程序来进行验证,在 ReadFileContent 函数中进行 j 的输出。将 main 函数中的目标字符串换成 LOADER ,然后看看结果
我们看到打印出来的是 4,我们再在 Linux 下进行断点调试,看看 ecx 寄存器的值是不是也是 4。通过反汇编我们查到在获取目标起始处的位置和调取 FatVec 的地方打上断点,我们来看看结果
我们看到第一次 ecx 的值确实 4,也就和在 Qt 中的结果进行相互验证了,edx 的值之前为 0,在调取完之后变成了 7。那么我们的代码调试也到此结束。
通过今天的学习,总结如下:1、如果在汇编代码中定义了函数,那么需要定义栈空间。读取数据前,逻辑扇区号需要转化为磁盘的物理地址;2、物理软盘上的数据位置由磁头号,柱面号和扇区号唯一确定,软盘数据以扇区(512字节)为单位进行读取;3、可通过查找目录区判断是否存在目标文件:加载根目录区至内存中(ReadSector),遍历根目录区中每一项(FindEntry),通过每一项的前11个字节进行判断(MemCmp),当目标不存在时打印错误信息(Print);4、内存拷贝时需要考虑进行拷贝的方向,当 si > di 时,从前向后拷贝。当 si <= di 时,从后向前拷贝;5、Fat 表加载到内存中只会,需要“动态组装”表项:Fat 表中使用 3 个字节表示 2 个表项,其实字节 = 表项下标 / 2 * 3 --> (运算结果取整)。