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地址