操作系统开发(4)——有引导功能的引导程序

发布于 2022-08-13  37 次阅读


上一章我们实现了一个能在VMware虚拟机中运行的"Hello world"程序,它的功能就是在虚拟机中显示Start Booting KNOS字符串。这一章我们将对这段代码进行进一步的完善,让它具有真正的“引导功能”。

在阅读和实验操作本章之前,建议先阅读并掌握操作系统开发番外篇(2)——FAT12文件系统的内容,这样会在阅读代码时更容易理解。同时,本章内容会大量涉及汇编编程,不过基本上都是最基础的mov、add之类的,其中有一些比较特殊的指令我会再加以解释。

FAT12引导扇区

如果大家已经学习过FAT12文件系统,或者已经读过操作系统开发番外篇(2)——FAT12文件系统的话,就会知道在引导扇区里,FAT12文件系统是有一个固定结构的。这里我就不在复述了,如果不知道我在说些什么的,可以去看看番外篇(2),里面有很详细地介绍到FAT12文件系统的相关内容。这里我就直接把番外篇(2)的表格粘贴过来了:

名称偏移量数据长度详细描述本系统数据示例
跳转指令0x003跳转到真正的启动代码,启动代码之前都是
FAT12引导扇区结构表,这一点有
操作系统开发(3)——操作系统的Hello world中提到过
jmp    short start
OEM名称0x038OEM名称,这部分如果不足8个字节,需要用空格补齐
MS-DOS检查这个区域以确定使用启动记录中的
哪一部分数据(来自Wikipedia)
'KNOSBOOT'
每扇区字节数0x0b2每扇区字节数512
每簇扇区数0x0d1每簇扇区数1
保留扇区数0x0e2保留扇区数1
文件分配表数0x101FAT表的数量,一般是两份,其中一份用作备份2
最大根目录条目数0x112根目录能保存的最大目录条数,这一点与FAT的目录表有关
详细内容会在介绍文件目录树结构时讲到
224
总扇区数0x132总扇区数,此处如果值为0,则使用0x20偏移处的值
不知道为什么要这么设计
2880
介质描述0x151介质描述,此处值Wikipedia是这样描述的:
0xF8 单面、每面80磁道、每磁道9扇区
0xF9 双面、每面80磁道、每磁道9扇区
0xFA 单面、每面80磁道、每磁道8扇区
0xFB 双面、每面80磁道、每磁道8扇区
0xFC 单面、每面40磁道、每磁道9扇区
0xFD 双面、每面40磁道、每磁道9扇区
0xFE 单面、每面40磁道、每磁道8扇区
0xFF 双面、每面40磁道、每磁道8扇区
同样的介质描述必须在重复复制到每份FAT的第一个字节。
有些操作系统(MSX-DOS 1.0版)全部忽略启动扇区参数,
而仅仅使用FAT的第一个字节的介质描述确定文件系统参数。
0xf0
每个文件分配表的扇区数0x162每个文件分配表占用的扇区数(也就是FAT表的长度)9
每个磁道的扇区数0x182每个磁道的扇区数18
磁头数0x1a2磁头数2
隐藏扇区数0x1c4隐藏扇区数0
总扇区数0x204总扇区数,如果此处超过0xffff,则使用0x13偏移处的值0
int 13h的驱动器号0x241int 13h的驱动器号0
保留0x251保留0
扩展引导标记0x2610x29
卷序列号0x274卷序列号0
卷标0x2b11卷标,11字节,不足的需要使用空格补足'KNOS       '
文件系统类型0x368文件系统类型,8字节,不足的需要使用空格补足'FAT12   '
引导代码、数据等其他程序0x3e448引导代码、数据等其他程序...
引导扇区识别标志0x1fe2引导扇区结束识别标志0xaa55
FAT12文件系统引导扇区结构表

那么接下来,我们就直接按照这个表格来创建一个对应格式的FAT12文件系统的引导扇区吧! :diana_yeah:

其实如果看过我之前(上一章结尾)传到GitHub上的代码的同学应该就会注意到,在bootloader/boot.asm中有一段比较长的注释,其实那个就是引导扇区的格式,那个格式是《一个64位操作系统的设计与实现》这本书中的代码所使用的。不过我们这里做了一些修改,所以还是以这里为准!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    nop
    OEMName             db  'KNOSBOOT'          ; 启动区名称(8字节)
    BytesPreSector      dw  512                 ; 每个扇区大小为0x200
    SectorsPreCluster   db  1                   ; 每个簇大小为一个扇区
    FATStartCluster     dw  1                   ; FAT起始位置(boot记录占用扇区数)
    FATNumber           db  2                   ; FAT表数
    MaxRootDirNumber    dw  224                 ; 根目录最大文件数
    TotalSectorsNumber  dw  2880                ; 磁盘大小(总扇区数,如果这里扇区数为0,则由下面给出)
    MediaType           db  0xf0                ; 磁盘种类
    FATSize             dw  9                   ; FAT长度(每个FAT表占用扇区数)
    SectorsPreTrack     dw  18                  ; 每个磁道的扇区数
    HeadsNumber         dw  2                   ; 磁头数(面数)
    UnusedSector        dd  0                   ; 不使用分区(隐藏扇区数)
    TotalSectorsNumber2 dd  0                   ; 重写一遍磁盘大小(如果上面的扇区数为0,则由这里给出)
    DriveNumber         db  0                   ; INT 13H的驱动器号
    Reserve             db  0                   ; 保留
    Bootsign            db  0x29                ; 扩展引导标记(29h)磁盘名称(11字节)
    VolId               dd  0                   ; 卷序列号
    VolLab              db  'KNOS       '       ; 磁盘名称(11字节)
    FileSystemType      db  'FAT12   '          ; 磁盘格式名称(8字节)

我们只需要把这段代码插入到jmp    short startstart:之间,再进行编译,得到的镜像就是FAT12格式的啦。不过由于现在的Windows系统都没办法直接装载FAT12格式的镜像了,所以我们如果想看效果的话,只能通过软碟通或者diskgenius这类第三方软件来查看了。这里我们使用diskgenius查看生成的镜像,是下面这个样子的:

以虚拟硬盘的形式装在到软件上就可以了

可以看到我们设置的文件系统已经被正确识别到了。

接下来,我们就可以来写真正的“引导代码”了,不过在写之前,先理清思路。

引导程序思路

首先要明白,引导的到底是什么。

在上一章的谁来把系统从硬盘“搬”到内存中?一节中我们讲到了,计算机上电并且经过BIOS自检之类的一顿操作以后,会将引导设备的第一个扇区读到内存的0x7c00处,然后开始执行第一个扇区中的代码。但是很明显,一个扇区(512字节)是根本不够我们写一个操作系统的,甚至连写一个复杂一点的boot引导程序都恼火(后面我们把这个程序写出来大家就明白了)。所以为了解决这样一个问题,我们需要利用这一个扇区来写一个类似“加载器”一样的东西,并且通过这个加载器,再进一步地载入和初始化操作系统(这些东西在上一章的Boot引导程序一节里也有提到)。

那么今天我们的任务就是,通过汇编写一段”加载器“程序,并且把名为LOADER.KAL的文件载入到内存,然后在进行完这些操作后,把处理器的执行权限转交给LOADER.KAL,这个就是我们这一章的”终极目的“了。

通过番外篇(2)我们知道,FAT12文件系统分为了引导扇区、FAT表区、根目录区、数据区四个部分,我们需要获取到一个指定文件的数据,应该按照以下流程来进行:首先是确定根目录区的地址(扇区号);然后通过遍历查找根目录区中的目录,对比文件名与目录中的名称是否一致,如果不一致则查找下一条目录,如果一致则查找目录项中的起始扇区号;找到起始扇区号后再去查找FAT表,再通过解码的FAT表找到下一个数据扇区的位置,直到FAT的标识标记为结尾扇区或空扇区为止。

有了这些思路以后,我们就可以通过代码把这些功能实现出来了。

更方便的软盘读取程序

在实模式里(如果不知道什么叫实模式,我们后面会详细介绍到,大家只需要知道到目前为止CPU都处于实模式就可以了),我们可以通过BIOS里预留的功能(中断,如果有不知道这是啥的同学就应该去学学汇编语言了,不过我也打算有空的时候写写相关的学习笔记)来直接对软盘控制器进行操作,这样就避免了自己写代码去操作IO端口并向软盘控制器发指令,这省了我们不少事。

在对软盘进行操作以前,我们要先定义一些需要用到的值和变量,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
; 下面这一段放到org 0x7c00和jmp short start之间
; Loader程序的相关配置
    LoaderBase      equ     0x9000  ; Loader程序的基地址
    LoaderOffset    equ     0x0000  ; Loader程序的偏移地址
    ; 通过上面两个地址可以计算出Loader在内存中的物理地址为0x90000

; 文件系统相关配置(数据都来源于FAT12引导扇区内的数据,不嫌麻烦的可以自己写一个计算程序来计算)
    RootDirSectors      equ     14      ; 根目录占用的扇区数,其计算方法为:(根目录数 * 32 + 每扇区字节数 - 1) / 每扇区的字节数
    RootDirStartSectors equ     19      ; 根目录起始扇区,其计算方法为:保留扇区数 + FAT表数 * 每个FAT表占用扇区
    FAT1StartSectors    equ     1       ; FAT表1起始扇区
    SectorBalance       equ     17      ; 根目录起始扇区-2,方便计算FAT

; 下面这一段放在times 510 - ($ - $$) db 0之前
; 变量空间
RootDirSearchLoop       dw  RootDirSectors                      ; 通过根目录占用扇区大小来设置循环搜索次数
SectorNumber            dw  0                                   ; 扇区号变量(找个内存地址保存相关变量值)
Odd                     db  0                                   ; 这个变量标记了奇偶性(奇数为1,偶数为0)
; 栈(有一部分是上一章的内容,请注意)
StartBootMessage:       db  "Start booting KNOS"                ; 启动显示信息
LoaderFileName:         db  "LOADER  KAL",0                     ; 需要搜索的Loader程序文件名
NoLoaderFoundMessage:   db  "LOADER.KAL not found!"             ; loader.kal未找到的错误信息

这些变量和值的功能在注释里写得比较清楚了,这里就不再展开讨论了,后续在用到这些变量的时候再加以解释。在做好这些前置准备以后,就需要对软盘控制器进行重置(reset),这一段很简单,没有太多好介绍的,直接看代码就行:

1
2
3
4
; ————————————重置软盘驱动器————————————
    xor ah, ah
    xor dl, dl
    int 13h    ;int 13h 是BIOS对硬盘的操作功能

既然这一章会对软盘控制器进行大量操作,那么我们会频繁使用到BIOS的13h功能,不过BIOS自带的软盘操作功能比较麻烦(来源:《汇编语言》第四版):

  • 软盘操作中断服务程序:
  • int 13h,功能号ah=02h:读取磁盘操作
  • (al)=读入扇区数
  • (ch)=磁道号
  • (cl)=扇区号
  • (dh)=磁头号
  • (dl)=驱动器号:软驱从0开始,0:软驱A,1:软驱B;
    硬盘从80h开始,80h:硬盘C,81h:硬盘D
  • es:bx 指向接受从扇区读入数据的内存区
  • 返回参数:
  • 操作成功:(ah)=0,(al)=读入扇区数
  • 操作失败:(ah)=出错代码

这种中断服务的寻址方式是基于CHS来寻址,而这种方法对我们开发引导扇区来说并不是很方便。所以我们需要基于这个中断服务封装一个新的函数,使其使用LBA方式寻址,具体封装如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
; ————————————软盘驱动器处理程序(封装函数)————————————

; 函数:ReadOneSectorFunction
    ; 从软盘读取数据(这一部分代码是对int 13h的2号功能的简单封装,使其更容易使用)
    ; 参数:
    ;   AX - 待读取的起始扇区号(LAB)
    ;   CL - 读取扇区数量
    ;   ES:BX - 目标缓冲区起始地址
ReadOneSectorFunction:
    ; 在调用int 13h前需要先把LAB转换成CHS
    push bp
    mov bp, sp
    sub esp, 2
    mov byte [bp - 2], cl       ; 找个地方存需要读取的扇区数
    push bx
    mov bl, [SectorsPreTrack]
    div bl
    inc ah
    mov cl, ah
    mov dh, al
    shr al, 1
    mov ch, al
    and dh, 1
    pop bx
    mov dl, [DriveNumber]
ReRead:
    mov ah, 2
    mov al, byte [bp - 2]
    int 13h
    jnc ReadSuccess     ; 读取成功,跳到success执行,否则继续向下执行
    add si, 1           ; 每次出错si加1
    cmp si, 5          
    jae ReadError       ; 如果重试5次还是出错,则跳转到ReadError
    jmp ReRead          ; 不足5次继续重试
ReadSuccess:            ; 读取成功,恢复调用现场
    add esp, 2
    pop bp
    ret
ReadError:      ; 读取数据出现错误(这里想的是先重试,重试5次都不行就报错
    jmp LoopHLTFunction
  • 其使用方式为:
  • (ax)=待读取的起始扇区号(LAB)
  • (cl)=读取扇区数量
  • es:bx=目标缓冲区起始地址

这里再简单介绍以下LBA转CHS的计算方法:

LBA÷每次道扇区数=Q余R,其中:柱面号(C)=Q>>1,磁头号(H)=Q&1,起始扇区号(S)=R+1

将上述公式以汇编的方式实现出来,然后再调用int 13h即可。这里都是比较基础的计算,就不展开解释了,大家可以结合代码和计算原理来理解,毕竟注释写得也比较详细。需要特别说明的是ReadError:这个位置,我在注释中也写到了,这里想的是先重试,重试5次都不行就报错。但是实际上开发发现引导扇区空间不够了(本文后续会讲到),就没有实现这个功能,而是直接跳转到了hlt循环的函数。

既然都提到这个函数了,那顺便就把其实现方法讲一下,先看代码:

1
2
3
4
5
; 函数: LoopHLTFunction
    ; 调用此函数以进入hlt循环,常用于出现错误时使用
LoopHLTFunction:        ; 出错时跳转到这里进行死循环
    hlt
    jmp LoopHLTFunction

函数体很简单,实际内容也就一行hlt,然后就是一个循环体,这句话应该也不需要太多的解释。至于hlt指令,该指令会让CPU休眠,从而在停止运行的同时达到省电的效果。同样,使用jmp $也可以达到停止运行的效果。不过需要注意的是,hlt指令需要R0权限等级(后续章节会详细介绍),所以应用程序没有办法使用这个指令。而jmp $有点类似于while(True),就是一个简单的无限循环,应用程序也可以使用,但CPU会不断进行计算,所以能耗较高。所以在R0级还是尽量使用hlt指令比较好。

有了更方便的软盘读取程序,我们就可以开始尝试对软盘内的文件进行操作了。

获取根目录内容

从这一节开始,难度就有一个相对大幅度的提升了,同时要把其代码以文本的形式描述出来,对我也有一定的挑战。 :ava_headhurts: :ava_headhurts: :ava_headhurts:

根据我们之前所说明的步骤,第一步就是获取根目录的内容,这一节我们就来实现这个功能。

首先还是先上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
; ————————————搜索loader.kal————————————
    mov word [SectorNumber], RootDirStartSectors        ; 将SectorNumber变量指向根目录开始的扇区
SearchRootDir:
    cmp word [RootDirSearchLoop], 0         ; 如果RootDirSearchLoop不是0,则说明根目录扇区还没有遍历完
    jz LoaderNotFound                       ; 如果RootDirSearchLoop已经为0,说明根目录区没有找到loader,跳转到未找到
    dec word [RootDirSearchLoop]            ; 剩余扇区-1
    ; 下面调用封装的读取软盘程序
    mov ax, 0000h
    mov es, ax
    mov bx, 7e00h               ; 将读取的内容保存在内存0x0000:0x7e00处(es:bx)
    mov ax, [SectorNumber]      ; 读取位置
    mov cl, 1                   ; 一次读一个扇区(这里可以灵活调整)
    call ReadOneSectorFunction
    mov si, LoaderFileName
    mov di, 7e00h
    cld
    mov dx, 10h                 ; 每扇区容纳目录项个数(512 / 32 = 16)
SearchLoader:
    cmp dx, 0                   ; 该扇区目录项已搜索完毕
    jz SearchNextDirSector     
    dec dx
    mov cx, 11                  ; 文件名长度(8字节文件名+3字节扩展名)
CompareFileName:
    cmp cx, 0
    jz FindLoaderFile
    dec cx
    lodsb                       ; (在这段程序中)这条指令可以把DS:SI指定的内存地址读入al(文件名)
    cmp al, byte [es:di]
    jz ContinueComparison       ; 此次匹配,继续进行匹配,知道11字节匹配完毕
    jmp FileNameNotMatch        ; 文件名不匹配,搜索下一条目录
ContinueComparison:
    inc di
    jmp CompareFileName

FileNameNotMatch:
    and di, 0ffe0h
    add di, 20h
    mov si, LoaderFileName
    jmp SearchLoader

; ————————————该扇区目录搜索完毕,继续读取下一扇区————————————
SearchNextDirSector:
    add word [SectorNumber], 1          ; 需要读取的扇区号+1
    jmp SearchRootDir

下面对这段代码分部分解释一下(实际上注释已经写得比较全了,也没啥好额外解释的)。

这段程序最开始从内存堆栈中找了一块区域,作为临时的(扇区号,SectorNumber)变量,使用word [SectorNumber]就可以使用这个“变量”,其中前面的word是指明这个变量所占的空间大小(因为我们在上一节申请变量时使用的就是dw,dw的意思之前的章节已经解释过了),代码第一句mov word [SectorNumber], RootDirStartSectors就是给[SectorNumber]赋值为根目录的起始扇区,我们需要从这个扇区开始搜索LOADER.KAL这个文件(至于为什么是.kal文件,后面再解释)。

下面以标签为单位对代码进行介绍。

SearchRootDir: 这一段的作用是对跟目录项进行搜索。首先我们使用了一个变量word [RootDirSearchLoop]来记录剩余还没搜索过的根目录扇区数,当这个变量为0时说明根目录已经查找完,如果此时还没有找到目标文件,则跳转到未找到的分支。下面就是调用了我们之前封装的基于LBA寻址的软盘读取函数,并从根目录的扇区中读取一个扇区,并保存到物理地址0x7e00处。之所以选择这个地址是因为计算机启动后会把引导扇区的代码读取到内存的0x7c00处,所以从这个位置开始的512字节都是引导扇区的代码(0x07C00 - 0x07DFF [512Byte])而从0x7e00开始是可以自由使用的区域,直到0xa0000开始是显卡缓冲区(这个我们后续还会详细介绍,很重要的一个概念)。至于哪个时刻操作系统的内存分布情况,等我后面有空应该会做个表格出来,到时候哪些内存地址能用就会清楚一些。由于我们是一个扇区一个扇区地搜索,所以每次只用从软盘读取一个扇区的内容到内存中,所以只占用512字节。搜索完一个扇区,如果没有找到目标项,则继续读入下一个扇区并搜索,直到根目录所有扇区搜索完毕或找到目标项。

SearchLoader: 这一段的作用是计算当前扇区已经搜索的目录项,通过操作系统开发番外篇(2)——FAT12文件系统我们可以知道,在目录区里的所有目录项,每一项占32个字节,所以一个扇区(512字节)就可以保存16个目录项,我们每搜索完一个目录项就进行一次记录,直到搜索完16个目录项则读取下一个扇区。这一段的四行代码实现的功能就是检查剩余还没有搜索的目录项,并保存在dx寄存器中,如果为0,则说明这个扇区已经搜索完成。搜索完成则会跳转到SearchNextDirSector标签并继续读入下一个扇区。

CompareFileName: 这一段的作用是比较当前目录项和目标文件的文件名。其中使用了cx寄存器来保存还没有比对的字符数。在比对时用到了lodsb指令,Wikipedia对这个指令的解释是:加载字符串字节。 可以与 REP 前缀一起使用以重复指令 CX 次。其中DF会决定其加载方向。简单来说,这个指令可以把ds:(e、r)si寄存器指定的内存地址中读取数据到(e、r)ax(l)寄存器,df=0时ds:(e、r)si自动增加,反之减少。在这个地方,数据会被读入到al寄存器,并通过cmp al, byte [es:di]进行对比。如果字节匹配则继续执行,否则跳转到FileNameNotMatch搜索下一个目录项。

ContinueComparison: 对比完一个字节后,如果字节匹配,则继续对比下一字节,并且把需要对比的目标地址(di寄存器)加1(也就是对比内存中的下一字节)。

FileNameNotMatch: 字节不匹配时会执行到这里并匹配下一个目录项。这一届里用到了一个and(and di, 0ffe0h),这一句是为了把di对其此次对比的目录项开始地址。然后是一个add指令(add di, 20h)这个指令是让di把地址增加0x20,也就是32字节,一个目录项的长度。然后再把文件名在内存中的地址赋值给si,然后再次执行SearchLoader对比下一个目录项。

SearchNextDirSector: 如果整个扇区都没找到目标文件,则继续扫描下一个扇区。

上面就是所有查找LOADER.KAL的相关说明,在执行完这些步骤后,一般会有两个结果:一是找到了LOADER.KAL、另一个是没有找到目标文件。下面再分别对这两个结果进行处理。

没有找到LOADER.KAL应该怎么办

先把代码放出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
; ————————————没有找到loader,显示错误信息然后循环hlt————————————
LoaderNotFound:
    mov ah, 13h     ; 显示字符串
    mov al, 01h     ; 完成显示后将光标移动至字符串末尾
    mov bh, 00h     ; 页码(第0页)
    mov bl, 8ch     ; 字符属性(红色高亮,黑色背景闪烁)
    mov dh, 01h     ; 游标行号
    mov dl, 00h     ; 游标列号
    mov cx, 21      ; 字符串长度
    push    ax
    mov ax, ds
    mov es, ax
    pop ax
    mov bp, NoLoaderFoundMessage        ; es:bp 指向字符串地址
    int 10h
    jmp LoopHLTFunction     ; 跳转到死循环

其实这部分很简单,该讲的部分注释里面都写清楚了。总的来说,如果没有找到LOADER.KAL会先显示错误信息"LOADER.KAL not found!",然后跳转到hlt循环函数。显示文字和hlt函数之前都讲过了,这里就不再细说了。

最难的还是找到了的情况。:/

找到了LOADER.KAL应该怎么办

先看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
; ————————————在根目录表中找到loader.kal————————————
FindLoaderFile:
    mov ax, RootDirSectors
    and di, 0ffe0h
    add di, 01ah
    mov cx, word [es:di]
    push cx
    add cx, ax
    add cx, SectorBalance
    mov ax, LoaderBase
    mov es, ax
    mov bx, LoaderOffset
    mov ax, cx
LoadingFile:
    push ax
    push bx
    mov ah, 0eh
    mov al, '*'             ; 每加载一个扇区,就打印一个"*"
    mov bl, 0fh
    int 10h
    pop bx
    pop ax

    mov cl, 1
    call ReadOneSectorFunction
    pop ax
    call DecodeFAT
    cmp ax, 0fffh
    jz LoaderLoadSuccess
    push ax
    mov dx, RootDirSectors
    add ax, dx
    add ax, SectorBalance
    add bx, [BytesPreSector]
    jmp LoadingFile
LoaderLoadSuccess:
    jmp LoaderBase:LoaderOffset         ; 跳转到loader继续执行,正常流程走下来,这一步应该是引导程序的最后一步

根据之前的思路,到这一步就是要从目录项中解析出起始扇区地址,然后根据FAT表将数据区中的所有内容载入到内存中了。

根据番外篇(2)我们可以知道,数据起始扇区的地址位于目录项的0x1a偏移处,所以还是使用之前介绍过的套路,先用and指令取到目录项的起始地址,然后再用add加偏移量即可。取到值后保存在cx寄存器中。

得到起始扇区值后,将该值减2就可以得到对应FAT的表项(此处是保存在SectorBalance变量中),然后再调用硬盘处理函数(ReadOneSectorFunction)把第一个数据区扇区读取到内存LoaderBase:LoaderOffset处(也就是0x90000,选择这个地址是因为Linux系统的loader在这个时候也存在这个位置)。通过DecodeFAT这个函数可以解析FAT表并找到数据区的扇区地址,循环这个过程把Loader全部载入内存即可。如果在FAT表遇到0xfff说明是最后一个扇区,在读完这个扇区后跳转到LoaderLoadSuccess:否则再执行LoadingFile:继续读取。

读入成功后执行jmp LoaderBase:LoaderOffset,把CPU的控制权交给Loader程序。整个boot的工作到此结束。

FAT解析函数

还差一个DecodeFAT没有说到,这一节加以说明。同样,首先看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
; 函数 DecodeFAT
DecodeFAT:
    push es
    push bx
    push ax
    mov ax, 0000h
    mov es, ax
    pop ax
    mov byte [Odd], 0
    mov bx, 3
    mul bx
    mov bx, 2
    div bx
    cmp dx, 0
    jz Even
    mov byte [Odd], 1
Even:
    xor dx, dx
    mov bx, [BytesPreSector]
    div bx
    push dx
    mov bx, 7e00h
    add ax, FAT1StartSectors
    mov cl, 2
    call ReadOneSectorFunction
    pop dx
    add bx, dx
    mov ax, [es:bx]
    cmp byte [Odd], 1
    jnz Even2
    shr ax, 4
Even2:
    and ax, 0fffh
    pop bx
    pop es
    ret

整个函数可以通过FAT表项索引处下一个FAT的表项,其输入输出都是ax寄存器。

每个FAT表占1.5字节,所以将表项乘以3除以2(也就是乘以1.5),再判断余数的奇偶性并保存到变量byte [Odd]中,再将结果除以每字节扇区数,最后得到余数为FAT表偏移位置,商为FAT表偏移扇区号(FAT表长度为九个扇区,超过一个扇区就要通过扇区号+偏移来定位)。同样,在读取FAT表区时也是一次性读两个扇区,防止表项跨区的问题。最后根据奇偶标志位来处理起始位置,如果是奇数则向前取4位(半个字节),否则向后取4位(取满1.5个字节)。

到这里,完整的boot引导程序就完成了。

测试启动

为了测试这段代码是否有效,我们需要一个loader来进行测试,但是现在写一个loader肯定是来不及的,所以就简单写一个只显示字符串的程序,试试引导程序能不能正常工作。loader代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
org 90000h    ; 这里要注意

    mov ax, cs
    mov ds, ax
    mov es, ax
    mov ax, 0x00
    mov ss, ax
    mov sp, 0x7c00

    ; 显示Entered Loader
    mov ah, 13h     ; 显示字符串
    mov al, 01h     ; 完成显示后将光标移动至字符串末尾
    mov bh, 00h     ; 页码(第0页)
    mov bl, 0fh     ; 字符属性(白色高亮,黑色背景不闪烁)
    mov dh, 02h     ; 游标行号
    mov dl, 00h     ; 游标列号
    mov cx, 14      ; 字符串长度
    push    ax
    mov ax, ds
    mov es, ax
    pop ax
    mov bp, StartBootMessage        ; es:bp 指向字符串地址
    int 10h

    jmp $

StartBootMessage:   db  "Entered Loader"

这段代码就不解释了,主要说一说编译部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# /bootloader/Makefile
# bootloader makefile
INCLUDE = -I ../include
BIN_DIR = ../bin/
KAL_DIR = ../kal/
# 默认编译指令
all: boot.bin LOADER.KAL
.PHONY: boot.bin LOADER.KAL
# bootloader
boot.bin : boot.asm
    nasm $< -o $@ $(INCLUDE)
    mv boot.bin $(BIN_DIR)
LOADER.KAL : loader.asm
    nasm $< -o $@ $(INCLUDE)
    mv LOADER.KAL $(KAL_DIR)

注意区分各个不同文件夹下的makefile,具体代码属于哪个makefile可以看第一行注释。具体改变了哪些位置可以对比一下之前的代码。总之,在这里增加了一个LOADER.KAL的编译。

之所以文件名是全大写,是因为FAT12文件系统的限制,FAT12文件系统在存储文件名时都是以大写的方式存储。还有就是后缀名,这里为kal,其实这是我自己设计的一种文件格式,全称为KNOS Application,也就是KNOS的应用程序,这个格式在后续我们再深入介绍。总之,这里的loader是以可执行程序的方式存在的,而非跟boot一样的二进制文件(虽然现在还只是二进制文件,没有文件头(这个我们下一章再来解决)。

总之,loader.asm(源文件)是在/bootloader/中,但是编译后的LOADER.KAL保存在/kal/里,同样,以后所有的所有kal可执行文件都保存在这个文件夹(包括loader.kal和kernel.kal)。

最后通过makefile和之前提供的ruby语言写的工具,生成一个镜像并把LOADER.KAL写进去。makefile代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# /Makefile

FAT12IMG=tools/fat12img
BIN_DIR = ./bin/
KAL_DIR = ./kal/

# 需要拷贝到硬盘中的文件(暂时)
DISK_FILES = $(KAL_DIR)LOADER.KAL \

# 默认规则
all: bootloader KNOS.img

.PHONY: bootloader KNOS.img

# 生成系统和软件的镜像
KNOS.img: bootloader
    $(FAT12IMG) $@ format
    $(FAT12IMG) $@ write $(BIN_DIR)boot.bin 0
    for filename in $(DISK_FILES); do \
      $(FAT12IMG) $@ save $$filename; \
    done

# 一般规则
## 编译bootloader中的代码
bootloader:
    make -C bootloader

# 仅保留源代码(暂时)
clean:
    rm -f $(BIN_DIR)*.bin
    rm -f *.img

其实有改动的就两个部分,一是增加了一个变量KAL_DIR = ./kal/,二是修改了DISK_FILES = $(KAL_DIR)LOADER.KAL \这一行。

修改完成以后,执行make进行编译,然后得到KNOS.img,我们在启动前可以先通过bz162工具看一下镜像当前的状态。

前两个扇区的部分