0x01 概述
之前已经熟悉过Boot的写法和FAT12文件系统的结构,现在要开始实现一个完整的Boot,以构建一个FAT12文件系统、从文件系统中搜索并读入Loader文件以及跳转到加载的Loader处执行。
0x02 实现
首先是一些伪代码,用来定义一些预计算的值来减少计算。在FAT12中,一些值是固定的,但是在标准的计算当中又需要使用这些值参与计算。如果将这些值提前计算好,然后用伪指令定义出来,就能节省计算成本和空间成本(代码量)。
; start address
org 0x7c00
BaseOfStack equ 0x7c00
RootDirSecNum equ 14 ; sector count of root directory (BPB_RootEntCnt * 32) / BPB_BytesPerSec
DirStruPerSec equ 16 ; directory structure in on sector
RootDirStart equ 19
BufferAddr equ 0x8000
DataStart equ 31 ; realstart - 2
FATTabStart equ 1
BaseOfLoader equ 0x1000
OffsetOfLoader equ 0x0000
org
指定起始地址位0x7c00。BaseOfStack用来给SP赋值作为初始栈底。
RootDirSecNum是根据BPB_RootEntCnt
计算的,由于根目录下每个目录结构占32 Bytes,所以用BPB_RootEntCnt * 32 / BPB_BytesPerSec
就能得到根目录占用的扇区数。在这就是228 * 32 / 512 = 14
。
DirStruPerSec
是每个扇区的目录结构数,在遍历根目录时需要用到。计算方法是用BPB_BytesPerSec
也就是每个扇区的字节数,除以每个目录结构的大小。在这就是512 / 32 = 16
。
RootDirStart是根目录的起始扇区数,上一章算过。
BufferAddr是用来存储临时数据的地址。
DataStart是数据区的起始扇区。计算方法是用根目录起始扇区加上根目录占用扇区,也就是RootDirStart + RootDirSecNum = 19 + 14 = 33
。但是由FAT表中的簇号转换为线性扇区数时,需要减2(FAT表前两项保留,为了不浪费空间,数据区从0开始存储数据),将这个减2提取出来,提前算好,也就得到了31这值。给出公式的话,就是LinearSecNum = DataStart + (OffsetInFAT - 2) * BPB_SecPerClus = DataStart - 2 * BPB_SecPerClus + OffsetInFAT * BPB_SecPerClus
,而在这里BPB_SecPerClus
是1,也就变成了LinearSecNum = DataStart - 2 + OffsetInFAT
,而DataStart算出来是33
,索性提前减2变成31
,之后计算起来就简单了,用DataStart + OffsetInFAT
就能算出当前簇的起始扇区。
FATTabStart是FAT1表的起始扇区。上一章算过。
BaseOfLoader和OffsetOfLoader分别是Loader的基址和偏移,最后Loader将存到BaseOfLoader:OffsetOfLoader
中去,换成线性地址就是BaseOfLoader << 4 + OffsetOfLoader
,在这就是0x1000 << 4 + 0x0000 = 0x10000
。
接下来是FAT12文件系统的一些结构定义。
; Entry point of boot sector
jmp short Label_Start ; jump to boot program
nop ; placeholder
BS_OEMName db 'WINIMAGE' ; OEM Name
BPB_BytesPerSec dw 512 ; bytes per section
BPB_SecPerClus db 1 ; sectors per cluster
BPB_RsvdSecCnt dw 1 ; reserved sector count (boot sector)
BPB_NumFATs db 2 ; number of FAT tables
BPB_RootEntCnt dw 224 ; max dir count of root dir
BPB_TotSec16 dw 2880 ; total number of sectors
BPB_Media db 0xf0 ; drive type
BPB_FATSz16 dw 9 ; size of each FAT table
BPB_SecPerTrk dw 18 ; sectors per track
BPB_NumHeads dw 2 ; number of magnetic heads
BPB_HiddSec dd 0 ; number of hidden sectors
BPB_TotSec32 dd 0 ; this value effects when BPB_TotSec16 is 0
BS_DrvNum db 0 ; number of drives
BS_Reserved1 db 0 ; Reserved
BS_BootSig db 29h ; boot signature
BS_VolID dd 0 ; volume ID
BS_VolLab db 'bootloader ' ; volume name, padding with space(20h)
BS_FileSysType db 'FAT12 ' ; file system type
jmp short Label_Start
放在第一行的目的是跳转到真正的程序入口,因为下面全都是一些数据的定义。NOP放在这的原因不明,感觉像是一个占位符。每一个字段的作用可以参考FAT Filesystem。
真正的引导程序入口从Label_Start
处开始。
首先是寄存器初始化。
; entry point
Label_Start:
; init registers
mov ax, cs
mov ds, ax
mov es, ax
mov ss, ax
mov sp, BaseOfStack
然后跟之前的示例Boot一样,清屏、设置光标位置和打印引导字符串:
; clear screen
; AH = 06h roll pages
; AL = page num (0 to clear screen)
; BH = color attributes
; CL = left row, CH = left column
; DL = right row, DL = right column
mov ax, 0600h
mov bx, 0700h
mov cx, 0
mov dx, 184Fh
int 10h
; set focus
; AH = 02h set focus
; DL = row
; DH = column
; BH = page num
mov ax, 0200h
mov bx, 0000h
mov dx, 0000h
int 10h
; display boot string
push 0000h
push StartBootMessageLength
push StartBootMessage
call Func_PrintString
接下来调用了一个函数,来从根目录寻找特定文件名的文件。
push LoaderFileName
call Func_FindFile
这个函数是自己实现的,从栈里接受一个字符串地址作为参数,从根目录里寻找一个文件名为参数指向文件名的文件,然后将它的第一个簇号用EAX
返回。搜索成功返回第一个簇号,搜索失败返回0。Func_FindFile
实现如下:
;;; Function: Func_FindFile
;;; Params: Stack: FileNameAddress
;;; Return value: AX = FirstCluster, zero if not found.
;;; Descryption: Find the file named [FileNameAddress] in root directory.
;;; The length of file name must be 11 bytes.
Func_FindFile:
; construct stack frame
push bp
mov bp, sp
xor cx, cx ; ch = inner, cl = outer
Label_StartSearch:
cmp cl, RootDirSecNum
ja Label_FileNotFound
mov ax, RootDirStart
add al, cl ; AX = current sector
push BufferAddr
push ax
call Func_ReadOneSector
xor ch, ch
Label_InnerLoop:
mov al, ch
xor ah, ah
mov bx, 32
mul bx
add ax, BufferAddr
mov bx, ax ; BX = cur dir struc addr
; BX = cur file name (11 btyes)
push bx
call Func_CompareFileName
cmp ax, 0
jnz Label_FileFound
inc ch
cmp ch, DirStruPerSec
jle Label_InnerLoop
; go to next round
inc cl
jmp Label_StartSearch
Label_FileFound:
mov ax, [bx + 0x1a]
jmp Label_FuncReturn
Label_FileNotFound:
xor ax, ax
Label_FuncReturn:
mov sp, bp
pop bp
ret 02h
之后所有的函数定义都将在上面标出它的函数名、参数传递方法、返回值以及函数描述。我自认为这是一个比较好的习惯,也坚持这样做了。因为这样做的话,很久之后再去调用这个函数的话就能很快想起调用它的方法。
Func_FindFile
函数使用栈传参,接受一个参数,参数为要搜索的文件名地址。返回值为这个文件的第一个簇号。
先建立一个栈帧:
push bp
mov bp, sp
将之前的bp
入栈,保护之前的bp
,然后将栈顶的地址给bp
,之后使用bp来寻找传进来的参数。
之后将cx
清0:xor cx,cx
,由于之后需要用两层循环来进行文件查找(逐扇区读入,每个扇区内按目录结构32 B
大小查找),并且每个循环次数都在0xff
以内,所以使用cl
来记录外层循环次数,用ch
来记录内层循环记录。
然后开始外层循环:
Label_StartSearch:
cmp cl, RootDirSecNum
ja Label_FileNotFound
比较cl
与RootDirSecNum
的大小,如果大于RootDirSecNum
就跳到Label_FileNotFound
。也就是,如果已经搜索完根目录的每一个扇区还没有找到指定文件的话,就判断为根目录下不存在指定,跳到文件不存在的标签处。
mov ax, RootDirStart
add al, cl ; AX = current sector
push BufferAddr
push ax
call Func_ReadOneSector
然后将RootDirStart
传给AX
,并加上cl
。这里也就是根据根目录的开始扇区,加上偏移,得到当前需要读入的扇区。然后将缓存区地址和扇区号入栈,传给Func_ReadOneSector
来将当前循环到的目录扇区读入Buffer
中。Func_ReadOneSector
是实现的函数,用来从指定扇区中读入一个扇区的数据到指定内存中。后面会提到它的实现。
接着就进入了内层循环。
xor ch, ch
Label_InnerLoop:
mov al, ch
xor ah, ah
mov bx, 32
mul bx
add ax, BufferAddr
mov bx, ax ; BX = cur dir struc addr
; BX = cur file name (11 btyes)
每次进入内层循环前先将计数器清零xor ch,ch
,因为要从Buffer的头部,也就是这个扇区的开始处,进行文件查找。然后,每次循环时用偏移值ch乘32得到字节偏移,并加上BufferAddr
,得到指向当前目录结构的指针,存入bx
中。并且,根据目录结构的定义,开头11个字节为文件名,所以bx
是目录结构指针的同时也是文件名指针。
然后要开始得到目录结构指针以及判断文件名了。
push bx
call Func_CompareFileName
cmp ax, 0
jnz Label_FileFound
将bx
作为参数传入,并调用Func_CompareFileName
。这个函数会比较传入的文件名指针指向的字符串与Loader文件名是否相等,如果不相等返回0,相等则返回一个非0值。对返回值ax进行判断,如果是非0值,则跳到Label_FileFound
,执行找到文件的流程。否则继续后续循环。
inc ch
cmp ch, DirStruPerSec
jle Label_InnerLoop
; go to next round
inc cl
jmp Label_StartSearch
这里先对内层计数器加1,然后比较与DirStruPerSec
的大小,如果不大于这个值,也就是没到该扇区结尾的话,就继续下次内层循环。否则跳出内层循环,增加外层循环计数器,并且跳到外层循环的下一次循环判断处。这两个循环对应到C语言的逻辑上,伪代码应该下面这样的:
char i = 0;
do
{
...
for(char j = 0; j<= DirStruPerSec; ++j)
{
...
}
++i;
} while(i <= Label_FileNotFound)
然后就是两个判断结果的逻辑。
Label_FileFound:
mov ax, [bx + 0x1a]
jmp Label_FuncReturn
Label_FileNotFound:
xor ax, ax
如果找到了文件,就会执行Label_FileFound标签处的指令,将bx + 0x1a
处的值,也就是目录结构里的DIR_FstClus
(首簇簇号)传给ax,并跳到返回逻辑处。如果没找到文件,就会将ax
清0并返回。
最后就是返回逻辑了。
Label_FuncReturn:
mov sp, bp
pop bp
ret 02h
将bp的值给sp,用来平衡栈。然后将被保护的bp
出栈恢复,最后使用ret 02h
返回。这里我实现的函数用的都是std call
的调用约定,由被调用者清栈。因为这里不需要用到可变参数,为了调用方便,使用std call
是最好的方法。
然后,上面有两个很重要的函数还没有提到实现。分别是Func_ReadOneSector
和Func_CompareFileName
。接下来就是它们的实现了。
先是Func_ReadOneSector
,给出它的描述:
;;; Function: Func_ReadOneSector
;;; Params: Stack: SectorNum, BufAddr
;;; Return value: AH = StatusCode
;;; Descryption: Read one sector from floppy, SectorNum is the sector number,
;;; BufAddr is the buffer address to store data of the sector read.
函数接受两个参数,用栈传递。第一个参数是要读的扇区号(线性),第二个参数是要读入的内存地址。要注意的是,由于使用的是std call
调用约定,参数是从右往左入栈的。返回值是读入的状态号,用AH
存储。具体又哪些状态号,可以查阅INT 0x13
中断的说明。
然后也是形成栈帧,并保护需要用到的寄存器。
push bp
mov bp, sp
sub sp, 02h
; protect registers
push bx
push cx
push dx
; SectorNum = bp + 4
; BufAddr = bp + 6
形成栈帧后,就能用bp对参数和局部变量寻址了。这里将栈顶抬高了0x2 bytes,目的是用两个字节来存储转化后的物理扇区号。bp + 4
处是传入的线性扇区号,bp + 6
是传入的缓存区地址。bp - 2
是局部变量物理扇区号。由于现在是16位实模式,入栈的返回地址和保护的bp
都是0x2 bytes
,所以第一个参数是从bp + 4
处开始的。
形成栈帧之后对bx、cx、dx
进行了入栈保护。
接下来就对线性扇区进行计算,得到磁头号、柱面号和扇区号。得到这三个物理位置后,就能确定软盘上唯一的一个扇区了。关于计算方法,FAT12文件系统 数据存储方式详解这篇文章中有比较详细的介绍。我这里对他的计算方法中能够提前计算的地方都进行了提前计算,实现上稍有不同。但是原理是一样的。
mov ax, [bp + 4]
mov bx, [BPB_SecPerTrk]
div bx
inc dx
mov [bp - 2], dx ; [bp - 2] is sector num
mov bx, [BPB_NumHeads]
xor bh, bh
xor dx, dx
div bx ; AX is cylinder, DX is head num
先将传入的线性扇区号传入AX
中,并且将每个磁道的扇区数传入bx
中,将它们相除,得到商ax和余数dx
。将余数加1
,得到物理扇区号,存入[bp - 2]
局部变量中。然后将磁头数存入bx
中,用之前得到的商除以磁头数,得到柱面号ax
和磁头号dx
。这样就得到读入一个扇区需要的三个物理位置了。
接下来开始使用INT 0x13
中断进行数据读入。
mov cx, [bp - 2] ; CL = sector num
mov ch, al ; CH = cylinder
mov dh, dl ; DH = head num
mov dl, [BS_DrvNum] ; DL = drive num
mov al, 1 ; AL = read count
mov ah, 02h ; AH = 0x02
mov bx, [bp + 6]
int 13h
从INT 0x13
的描述中可以得到,AH
传入功能号,这里是0x2
,代表从磁盘/软盘中读入数据。AL
传的是读入的扇区数量。ES:BX
传入的是读入的内存地址。CL
传物理扇区号。CH
传柱面号。DL
传驱动器号。DH
传磁头号。将上面计算得到的值传入对应位置,然后使用0x13号中断就能进行读入了。读入结果状态码会传入AH中。接下来只要将其返回就行了。
; recover registers
pop dx
pop cx
pop bx
; recover stack frame
mov sp, bp
pop bp
ret 04h
同样的,将保护的dx、cx、bx
出栈恢复,然后关闭栈帧,最后用RET 04h
返回。因为有两个字的参数,一共4字节,所以是04h。
接着是判断文件名的Func_CompareFileName
函数。
先看它的描述:
;;; Function: Func_CompareFileName
;;; Parms: Stack: FileNameAddr
;;; Return value: AX = not zero if equal, 0 if not equal
;;; Descryption: Compare if the file name is equal the loader file name.
Func_CompareFileName
函数从栈中接受一个参数FileNameAddr
,指向需要判断的文件名。返回值存在AX中,如果字符串相同返回1,否则返回0。
然后是它的实现:
Func_CompareFileName:
push bx
push cx
; FileNameAddr = [sp + 6]
mov bx, sp
mov ax, 1
cld
mov cx, 11
mov si, [bx + 6]
mov di, LoaderFileName
repe cmpsb
jcxz Label_Equal
xor ax, ax
Label_Equal:
pop cx
pop bx
ret 02h
由于没使用到局部变量,为了节省空间,这里就没有形成栈帧了。先将bx、cx
入栈保护。入栈后,sp + 6
处就是传入的参数地址(返回地址0x2 + bx0x2 + cx0x2 = 0x6
)。
先将sp的值给bx,因为只有bx和bp能够使用间接寻址。然后将ax传1,目的是初始化返回值为1。接下来用cld
清空方向寄存器。接着将字符串长度11传给cx
,然后将传入的地址传入si、loader
文件名地址传给di,并使用repe cmpsb来进行逐byte比较,比较11次。cmpsb
规定了比较跨度,按byte进行比较。repe
规定了比较方法,当前两个字节中有一个字节不相等就会跳出这条语句。每比较一次,si
和di
会自增1,cx
会自减1。比较这条语句结束时的cx值就能判断两个字符串是否相等。使用jcxz
,当cx
为0时判断两个字符串相等,跳到Label_Equal,否则将ax清零。
最后将保护的cx和bx出栈恢复,并返回。
到这里整个查找文件的过程就完成了。接下来回到主流程上,继续引导程序。
cmp ax, 0
jne Label_LoaderFound
; loader not found
push 0x0100
push ErrLoaderNotFoundLength
push ErrLoaderNotFound
call Func_PrintString
jmp $
判断Func_FildFile
的返回值。如果是0则说明没有找到文件,打印一个没找到文件的错误提示后使用jmp $
循环等待。否则跳到Label_LoaderFound
执行读文件过程。
Label_LoaderFound:
mov [CurrentCluster], ax
; read FAT Table to buffer
mov bx, BufferAddr
xor cx, cx
Label_ReadFATTable:
mov ax, FATTabStart
add ax, cx
push bx
push ax
call Func_ReadOneSector
add bx, [BPB_BytesPerSec]
inc cx
cmp cx, [BPB_FATSz16]
jle Label_ReadFATTable
先将Func_FindFile
返回的文件首簇号存到CurrentCluster
全局变量中。本来是要避免使用全局变量的,但是考虑到节省空间(偷懒),使用了一个全局变量来存。
要读文件,先要将FAT表读到内存中。因为要通过FAT表进行索引,才能找到文件所有的簇。将Buffer地址传给bx,将cx清零,并进入读取循环。将FATTabStart
,也就是FAT表起始扇区号,传给ax
,并加上偏移cx
得到当前要读入的扇区号,然后调用Func_ReadOneSector
将这个扇区读入到Buffer中。每次循环都将bx
指针后移BPB_BytesPerSec
个字节,在这就是512字节,来存放下一个扇区的数据。自增cx
计数器后判断是否不大于Label_ReadFATTable
,也就是判断是否到FAT表结尾扇区。如果没有读到FAT表结尾扇区,则继续下一次循环,读取下一个扇区,否则跳出循环。
接下来就要根据首簇号在FAT表中索引来读入整个文件了。
; BX = Loader address
mov bx, BaseOfLoader
mov es, bx
mov bx, OffsetOfLoader
Label_StartRead:
mov ax, [CurrentCluster]
add ax, DataStart
push bx
push ax
call Func_ReadOneSector
; move bx to next buffer addr
add bx, [BPB_BytesPerSec]
mov ax, [CurrentCluster]
call Func_GetNextCluster
mov [CurrentCluster], ax
cmp ax, 0xfef
jle Label_StartRead
先将BaseOfLoader
传给bx
,作为中间值存放,传给es
段寄存器(段寄存器不能直接传立即数),然后再将OffsetOfLoader
传给bx
,这样es:bx
就是BaseOfLoader:OffsetOfLoader
了。
然后,将当前簇号传给ax,并加上DataStart得到其在数据区的扇区号,调用Func_ReadOneSector
来读入这个扇区的内容,由于每个簇在这是一个扇区,就不用考虑多簇的情况了。
读入后,将bx
指针往后移动一个扇区的字节数,也就是下一个扇区的存放处。
接着,将当前簇号传给ax
,并调用Func_GetNextCluster
来得到下一个簇号。Func_GetNextCluster
是一个函数,实现为通过当前簇号查询FAT表得到下一个簇号。然后比较下一簇的值是否不大于0xfef
,如果不大于则判断为下一簇有效,继续读入下一簇。关于FAT表中每个取值范围的意义,可以参考FAT Filesystem。
这里还没有提到Func_GetNextCluster
的具体实现。下面是其描述。
;;; Function: Func_GetNextCluster
;;; Params: AX = CurrentCluster
;;; Return value: AX = NextCluster
;;; Descryption: Get next cluster number according to current clus num.
Func_GetNextCluster
接受ax
作为参数,表示当前簇号,并通过ax
返回下一簇的簇号。
下面是其实现。
Func_GetNextCluster:
push bx
push cx
push bp
; use bp to judge odd
mov bp, ax
mov bx, 3
mul bx
shr ax, 1 ; AX = CurClus * 3 / 2 = CurClus * 1.5
mov bx, ax
mov bx, [bx + BufferAddr]
shr bp, 1
jc Label_Odd
and bx, 0x0fff
jmp Label_GetNextClusRet
Label_Odd:
shr bx, 4
Label_GetNextClusRet:
mov ax, bx
pop bp
pop cx
pop bx
ret
关于FAT表的索引方法,可以参考上面提到的两个文献。里面都比较清晰地说明了计算下一个索引的方法。或者参考我写的上一章,也提到了计算方法,并使用C语言进行了实现。
首先仍然是保护寄存器。然后将ax
的值传给bp
,用来判断奇偶。这里的bp
仅仅是用来判断奇偶的,而不是用来寻址的。接着,就要用当前簇号的值乘1.5得到当前的字节偏移。由于只需要结果的整数部分,不要求精度,所以不需要去用FP
寄存器计算了。这里使用当前簇号乘3再右移1位(除2)来实现乘1.5。得到字节偏移之后,将其传给bx
,用BufferAddr
加上这个偏移,得到下一个簇号的一个word的值。判断当前簇号是偶数还是奇数,如果是偶数,则用这个值与0x0fff
做与操作,如果是奇数,则右移4位,得到最终的下一簇簇号。
将下一簇簇号传入ax
中,并恢复寄存器,然后返回。
到这里,从构造的FAT12文件系统中寻找和读入Loader
文件的过程就完成了。接下来只需要一个jmp
跳转过去,控制权就交给Loader了。而Loader大小可以非常大,不像Boot限制在一个扇区内,实现的时候可以不用缩手缩脚的了。
跳转到loader:
; jump to loader
jmp BaseOfLoader:OffsetOfLoader
剩下的一些字符串、全局变量和padding以及signature如下:
; Strings
StartBootMessageLength equ 16
StartBootMessage db 'Start booting...'
ErrLoaderNotFoundLength equ 24
ErrLoaderNotFound db 'Error! Loader not found!'
LoaderFileName db 'LOADER BIN'
; values
CurrentCluster dw 0
; padding zero
times 510 - ($ - $$) db 0
; boot signature
dw 0xaa55
0x03 总结
这一章实现了一个完整的Boot程序,在构建的FAT12文件系统根目录中查找Loader文件,将Loader载入内存中并跳转到Loader处执行。
在实现Boot的时候,我总是有一个担心:512 B到底够不够,我写到这是不是快满了?所以实现起来缩手缩脚的,很多想法都不敢去实现。事实证明,真的快满了。如下图。
能看到编译后离Signature
0x55 0xAA
只有红框处的一点空间了。通过对Boot进行编写,能够熟悉FAT12文件系统的工作原理,为之后构建更复杂的文件系统打下基础。
下一章就要开始编写Loader,进行内核加载了。
当前实现的进度我都会push到Github中,可以通过Github来获取完整代码:
Github地址
网友评论