實現一個簡單的64位操作系統 (0x04)實現一個完整的Boot

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

比較clRootDirSecNum的大小,如果大于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_ReadOneSectorFunc_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規定了比較方法,當前兩個字節中有一個字節不相等就會跳出這條語句。每比較一次,sidi會自增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到底夠不夠,我寫到這是不是快滿了?所以實現起來縮手縮腳的,很多想法都不敢去實現。事實證明,真的快滿了。如下圖。

編譯后的Boot

能看到編譯后離Signature0x55 0xAA只有紅框處的一點空間了。
通過對Boot進行編寫,能夠熟悉FAT12文件系統的工作原理,為之后構建更復雜的文件系統打下基礎。
下一章就要開始編寫Loader,進行內核加載了。

當前實現的進度我都會push到Github中,可以通過Github來獲取完整代碼:
Github地址

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容