距离上一次写操作系统相关的内容已经过去两年半了,中间因为去搞其他东西所以一直没有推进,最近这段时间过年回家没事做,正好把这个坑填一填。
上一章我们实现了一个具有引导功能的引导扇区,其代码的功能就是将LOADER.KAL文件读取到内存中,并跳转到loader中执行后续的启动工作,这一章我们就来实现一个基本的loader程序,并且向前推进一部,让操作系统进入真正的内核运行。
这一章我们会涉及到大量的汇编代码,以及一些基本的CPU功能和专业名词,所以阅读起来可能会比较费劲。

这一章我们会实现一个具有基本功能的loader程序,在loader中我们会进行一些进入内核前的初始化操作,下面先来了解一下我们具体要在loader中干些什么。

注意:


文章中提到的都是示例代码片段,可能缺少部分不关键的内容,具体的完整代码参考文章末尾的Github链接!

loader是干什么的?

实际上,loader程序就是用来准备kernel运行所需环境的。引导代码将loader程序从存储介质读入到内存中以后,将计算机控制权转交给loader程序,loader程序会进行一系列准备工作,包括切换CPU模式、配置一个基本的GDT和IDT(以及TSS)、设置内存分页、配置显示模式、初始化SMP等操作。

KNOS的loader程序会按照进入伪保护模式->载入kernel.kal->获取内存结构信息->获取SVGA VBE信息->设置VBE模式->配置临时GDT和IDT->开启分页和长模式->跳转到kernel程序的顺序运行。

我们可以按照这个思路,继续通过汇编代码来实现loader程序的功能。

Loader程序内存空间分配

由于Loader程序相对来说功能比较复杂,涉及到的数据操作也比较多,所以最好在开始写代码之前先把各地址的内存使用情况搞清楚,目前预计的内存规划如下:

  • 0x7C00:堆栈及初始数据区:进入loader以后,boot引导程序数据就没有用了,所以原来boot引导程序占用的内存可以用作堆栈及初始数据;
  • 0x7E00 – 0x7FFF:临时内核加载缓冲区/内存结构信息区:在载入kernel阶段,这段内存被用于临时缓存,完成载入kernel的工作后用于保存内存结构信息;
  • 0x8000 – 0x81FF:VBE 信息块:用于存放 VBE 信息块,括VBE 模式列表偏移、段地址等信息;
  • 0x8200 – 0x83FF:VBE 模式信息块:用于存放单个 SVGA 模式的详细信息;
  • 0x8400 – 0x85FF:VBE 选定模式信息缓冲区:用于存放最终选择的SVGA模式详细信息,同时后续kernel程序也会从这个地址获取系统SVGA信息;
  • 0x90000 – 0x9202C:临时页表区域:设置用于开启 PAE 和长模式的临时页表,不过后续进入kernel以后会重新配置页表数据;
  • 0x10000 – ?:Loader 代码区域:loader的代码被boot引导程序加载到这段地址当中;
  • 0x100000 - ?:Kernel代码区域:kernel的代码被loader程序加载到这段地址当中;

写代码前先搞清楚每个数据使用的位置,防止出现内存使用区域冲突的问题,到时候启动失败再来debug就很麻烦了,毕竟没有什么报错提示,只能通过dbg工具来观察各个寄存器和内存分布情况,所以最好是在写之前就安排好。

接下来就正式进入loader程序的开发了,loader程序相比之前的boot引导,逻辑功能更复杂,要处理的数据也更多,所以实现起来也更加困难,不过只要一个模块一个模块地实现,也不是什么不可能完成的工作。

开始实现Loader程序

因为之前在boot程序中已经实现过软盘读取程序,同时如果先把kernel读取了,那么后续用于kernel缓冲区这段内存就可以拿去做其他用处了(比如我们会在后面的代码中将这段内存用于存储内存结构信息),所以我们在loader程序中就先把这部分给做了。

根据上面的内存分布列表可以看到,kernel被计划放到0x100000的位置,也就是1M以上的位置,但是看过操作系统开发番外篇(3)——实模式?保护模式?长模式?就知道,实际上实模式最高寻址只能到1M,所以其实没有办法在实模式将kernel数据放到0x100000的位置。

不过在番外篇(3)的实模式的4G寻址一节中,我们有提到解决方法,没有看过的小伙伴可以先去看一下这篇文章,这会对下面的内容有很多帮助。

全局段描述符表

接下来,我们就先开启4G内存寻址,首先我们需要准备一些32位保护模式的GDT数据,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
; ===================================================================
; 32位保护模式下的全局描述符表 (GDT) 定义
; ===================================================================
[SECTION gdt]

LABEL_GDT:     
    dd  0,0                   ; 第0项:空描述符(必须项)
LABEL_DESC_CODE32: 
    dd  0x0000FFFF,0x00CF9A00  ; 32位代码段描述符,访问位和属性设定:可读、执行、存在等
LABEL_DESC_DATA32: 
    dd  0x0000FFFF,0x00CF9200  ; 32位数据段描述符,属性设定:可读写、存在等

GdtLen  equ $ - LABEL_GDT       ; 计算GDT总长度
GdtPtr  dw  GdtLen - 1          ; GDT界限(长度-1)
    dd  LABEL_GDT             ; GDT在内存中的起始地址

; 用于选择子计算,选择子为描述符在GDT内的偏移
SelectorCode32  equ LABEL_DESC_CODE32 - LABEL_GDT
SelectorData32  equ LABEL_DESC_DATA32 - LABEL_GDT

和之前的文章一样,代码中的注释已经写得非常详细了,所以就不再重复对代码的介绍了,简单来说,这段代码包含三个描述符:一个空描述符,一个用于32位代码段的描述符(可读、可执行、存在),以及一个用于32位数据段的描述符(可读写、存在),同时计算了GDT的长度,以及设置了一个指向GDT的指针。

先来了解一下这个GDT是干什么的:

Global Descriptor Table(GDT,全局描述符表)是x86保护模式下用于存储段描述符(Segment Descriptors)的一个数据结构,其用于x86处理器分段机制的内存段特性的定义,GDT中的每一个表项都标记了一段内存的权限、访问权限(DPL,关于这一点会在后续介绍分级保护域时详细说明)、起始地址和大小。GDT在分段内存管理模式中非常重要,CPU需要靠GDT来确认哪些地方的内存是可用的,以及这些地方的内存应该如何使用。

32位保护模式GDT每个描述符占8字节(64位),其基本结构如下:

字段名位宽(bit)作用
Base Address(段基址)32位(0~23位 + 56~63位)段的起始地址
Limit(段界限)20位(0~15位 + 48~51位)段的大小
Type(类型)4位(40~43位)描述段的用途(代码/数据等)
S(描述符类型)1位(44位)0 = 系统段, 1 = 普通段
DPL(特权级)2位(45~46位)访问权限(0 = 内核, 3 = 用户)
P(存在位)1位(47位)是否有效
AVL1位(52位)操作系统可用
L(64位标志)1位(53位)64位代码段(长模式必须启用,长模式GDT更加简化)
D/B(操作数大小)1位(54位)0 = 16位, 1 = 32位
G(粒度)1位(55位)0 = 字节单位, 1 = 4KB单位

一般来说,正如我们上面的代码所示,GDT至少需要三个条目(代码段和数据段也可以有多个条目),分别是:

  1. 空描述符(Null Descriptor):必须存在,防止访问错误的GDT索引。
  2. 代码段(Code Segment):负责执行代码,在上面的代码中只有一个代码段条目,值为0x0000FFFF,0x00CF9A00,由LABEL_DESC_CODE32指向该条目。
  3. 数据段(Data Segment):用于数据存储,,在上面的代码中只有一个数据段条目,值为0x0000FFFF,0x00CF9200,由LABEL_DESC_DATA32指向该条目。

准备好数据以后,我们就可以开启A20地址线,并且通过lgdt指令载入GDT,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
    ; ======= 打开 A20 地址线(使能超过1MB内存访问) =======
    push    ax
    in  al, 92h             ; 从端口 0x92 读取当前状态
    or  al, 00000010b       ; 设置 A20 地址线使能位(bit1)
    out 92h, al            ; 写回端口 0x92
    pop ax

    cli                     ; 关闭中断

    ; ======= 加载32位保护模式下的GDT =======
    db  0x66               ; 操作数前缀,转换为32位操作数模式
    lgdt    [GdtPtr]          ; 加载GDT寄存器,准备进入保护模式

进入伪保护模式(Unreal mode)

这一步在番外篇(3)中有提到,伪保护模式即可以操作4G内存寻址的保护模式。至于为什么不直接在保护模式进行loader的工作,而需要在实模式下进行,主要是因为我想尽可能地利用BIOS自带的功能,BIOS依赖于CPU实模式运行,而保护模式改变了CPU的工作方式,使得实模式下的中断机制、内存寻址方式以及BIOS自身的执行环境都不再适用,所以一旦CPU切换至保护模式,BIOS功能便不再可用。

以软盘读取为例,上一章介绍到了BIOS的软盘读取功能,即int 13h,功能号ah=02h,我们只需要配置几个参数,准备好一段内存,然后调用这个BIOS功能即可。然而如果进入保护模式,int 13h不再可以,此时如果我们需要在保护模式下读取软盘,需要操作软盘控制器(FDC)的I/O端口来进行读写,需要手动初始化和操作软盘设备,读取工作需要大量代码来实现,同时还需要自己实现中断功能来处理,这一点没有办法在loader阶段实现,所以我们需要尽可能在实模式下进行这些操作。当然,如果我们后续改用硬盘或U盘启动,还需要去实现硬盘控制器的操作,这样就更麻烦了。

代码很简单,没有什么好说的,按照番外篇(3)中的说法操作即可,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    ; ======= 进入保护模式(设置CR0的保护模式位) =======
    mov eax, cr0
    or  eax, 1              ; 设置CR0最低位为1,开启保护模式
    mov cr0, eax

    ; ======= 切换段寄存器到32位段描述符 =======
    mov ax, SelectorData32  ; 将32位数据段选择子加载到AX
    mov fs, ax              ; FS 寄存器设为数据段
    ; 此处做个伪保护模式退出操作:
    mov eax, cr0
    and al, 11111110b      ; 清除最低位(保护模式位)
    mov cr0, eax

    sti                     ; 重新开启中断

此时虽然操作系统处于实模式(cr0寄存器最低位为0),但因为段寄存器仍然缓存着段描述符信息,所以可以进行32位的内存寻址了

又是软盘读取程序

这部分代码与上一张读取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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
; -----------------------------
; 内核文件加载相关地址定义
; -----------------------------
BaseOfKernelFile    equ 0x00         ; 内核文件在内存中的基地址(段基址,一般为0)
OffsetOfKernelFile  equ 0x100000    ; 内核文件加载到内存的偏移地址(1MB处)

BaseTmpOfKernelAddr equ 0x00         ; 临时内核文件加载时使用的基地址(段基址,通常为0)
OffsetTmpOfKernelFile   equ 0x7E00      ; 临时内核文件加载偏移地址(一般放在低内存)

MemoryStructBufferAddr  equ 0x7E00   ; 内存结构信息存放缓冲区地址


    ; ======= 重置软驱(调用 int 13h,复位软驱控制器) =======
    xor ah, ah
    xor dl, dl
    int 13h

    ; ======= 搜索内核文件 kernel.kal =======
    ; 将根目录起始扇区号放入 [SectorNo]
    mov word    [SectorNo], RootDirStartSectors

Label_Search_In_Root_Dir_Begin:
    ; 检查根目录剩余扇区是否遍历完毕
    cmp word    [RootDirSizeForLoop],   0
    jz  Label_No_LoaderKal    ; 如果遍历完毕还没找到,则显示错误信息
    dec word    [RootDirSizeForLoop]   
    ; 读取当前根目录扇区到内存 0:8000h
    mov ax, 00h
    mov es, ax
    mov bx, 8000h
    mov ax, [SectorNo]
    mov cl, 1
    call    Func_ReadOneSector  ; 读取1个扇区
    ; 准备比较文件名:内核文件名在 KernelFileName 中
    mov si, KernelFileName
    mov di, 8000h
    cld
    mov dx, 10h             ; 每个目录项大小或文件名长度(可能有多个目录项)

Label_Search_For_LoaderKal:
    ; 循环比较目录中每个文件项的文件名
    cmp dx, 0
    jz  Label_Goto_Next_Sector_In_Root_Dir
    dec dx
    mov cx, 11             ; 文件名长度为11字节(8.3格式)
Label_Cmp_FileName:
    cmp cx, 0
    jz  Label_FileName_Found   ; 文件名匹配成功,跳转到文件加载流程
    dec cx
    lodsb                   ; 从 DS:SI 加载下一个字符到 AL
    cmp al, byte    [es:di]   ; 比较当前字符与目录项内的字符
    jz  Label_Go_On         ; 若相等则继续比较
    jmp Label_Different    ; 否则表示不同
Label_Go_On:
    inc di                ; 指向下一个目录项字符
    jmp Label_Cmp_FileName

Label_Different:
    ; 若不匹配,则调整目录项指针到下一个文件项(每项长度为32字节)
    and di, 0FFE0h
    add di, 20h
    mov si, KernelFileName
    jmp Label_Search_For_LoaderKal

Label_Goto_Next_Sector_In_Root_Dir:
    ; 当前扇区文件项未匹配,读下一扇区
    add word    [SectorNo], 1
    jmp Label_Search_In_Root_Dir_Begin

; ======= 显示 "ERROR:No KERNEL Found" 错误信息 =======
Label_No_LoaderKal:
    mov ax, 1301h
    mov bx, 008Ch           ; 错误颜色设置
    mov dx, 0300h           ; 在屏幕第3行显示
    mov cx, 21              ; 字符串长度
    push    ax
    mov ax, ds
    mov es, ax
    pop ax
    mov bp, NoLoaderMessage ; 错误提示字符串
    int 10h
    jmp $                   ; 死循环

; ======= 找到内核文件目录项,开始加载内核文件 =======
Label_FileName_Found:
    ; 计算内核文件大小(存放在目录项中某处)
    mov ax, RootDirSectors
    ; 将目录项指针调整到文件大小字段所在位置
    and di, 0FFE0h
    add di, 01Ah
    mov cx, word    [es:di]  ; 读取文件大小(以扇区计数或其它单位)
    push    cx
    add cx, ax
    add cx, SectorBalance  ; 计算文件所占扇区数(包含扇区平衡值)
    mov eax,    BaseTmpOfKernelAddr ; 目标加载内存基地址
    mov es, eax
    mov bx, OffsetTmpOfKernelFile   ; 目标加载内存偏移地址
    mov ax, cx

Label_Go_On_Loading_File:
    ; 显示加载过程中打印一个点或标识字符'.'
    push    ax
    push    bx
    mov ah, 0Eh
    mov al, '.'
    mov bl, 0Fh
    int 10h
    pop bx
    pop ax

    ; 读取内核文件的1个扇区到内存缓冲区
    mov cl, 1
    call    Func_ReadOneSector
    pop ax

    ; ======= 将临时加载的数据拷贝到最终内核加载区域 =======
    push    cx
    push    eax
    push    edi
    push    ds
    push    esi

    mov cx, 200h             ; 复制 0x200 字节(一个扇区大小)
    mov ax, BaseOfKernelFile   ; 目标内核段基地址
    mov fs, ax
    mov edi,    dword   [OffsetOfKernelFileCount]  ; 目标内核偏移地址计数器

    mov ax, BaseTmpOfKernelAddr  ; 源数据段基地址
    mov ds, ax
    mov esi,    OffsetTmpOfKernelFile  ; 源数据偏移地址

Label_Mov_Kernel:  
    ; 循环复制扇区数据到最终内核加载地址
    mov al, byte    [ds:esi]
    mov byte    [fs:edi],   al
    inc esi
    inc edi
    loop    Label_Mov_Kernel

    ; 切换数据段为0x1000(可能为内核段转换前的临时段)
    mov eax,    0x1000
    mov ds, eax

    ; 更新内核加载偏移计数器
    mov dword   [OffsetOfKernelFileCount],  edi

    pop esi
    pop ds
    pop edi
    pop eax
    pop cx

    ; ======= 获取FAT表中的下一个扇区号 =======
    call    Func_GetFATEntry
    cmp ax, 0FFFh        ; FAT结尾标记判断(0xFFF 表示最后一簇)
    jz  Label_File_Loaded    ; 如果达到结束,则跳转到加载结束处理
    push    ax
    mov dx, RootDirSectors
    add ax, dx
    add ax, SectorBalance
    jmp Label_Go_On_Loading_File

Label_File_Loaded:
    ; 文件加载完成后,在屏幕上显示一个 'G' 字母(提示加载成功)
    mov ax, 0B800h         ; 文本模式显存段地址
    mov gs, ax
    mov ah, 0Fh           ; 字符属性:黑底白字
    mov al, 'G'
    ; 在屏幕第0行,第39列显示字符
    mov [gs:((80 * 0 + 39) * 2)], ax

KillMotor:
    ; 关闭软驱马达(防止持续转动)
    push    dx
    mov dx, 03F2h
    mov al, 0  
    out dx, al
    pop dx



; -----------------------------
; Func_ReadOneSector: 从软驱读取1个扇区
; 使用 int 13h 读取扇区,内含错误重试循环
; 输入:cl中扇区数(一般为1),[SectorNo] 存放要读取的扇区号
; -----------------------------
Func_ReadOneSector:
    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]   ; 软驱号
Label_Go_On_Reading:
    mov ah, 2              ; INT 13h 读取扇区函数号
    mov al, byte    [bp - 2]
    int 13h
    jc  Label_Go_On_Reading  ; 若出错则重试
    add esp,    2
    pop bp
    ret

; -----------------------------
; Func_GetFATEntry: 获取FAT表中的下一个文件簇号
; 根据当前读取的目录项计算对应FAT项位置并返回文件簇号
; -----------------------------
Func_GetFATEntry:
    push    es
    push    bx
    push    ax
    mov ax, 00
    mov es, ax
    pop ax
    mov byte    [Odd],  0
    mov bx, 3
    mul bx
    mov bx, 2
    div bx
    cmp dx, 0
    jz  Label_Even
    mov byte    [Odd],  1
Label_Even:
    xor dx, dx
    mov bx, [BytesPreSector]
    div bx
    push    dx
    mov bx, 8000h
    add ax, FAT1StartSectors
    mov cl, 2
    call    Func_ReadOneSector
    pop dx
    add bx, dx
    mov ax, [es:bx]
    cmp byte    [Odd],  1
    jnz Label_Even_2
    shr ax, 4
Label_Even_2:
    and ax, 0FFFh        ; FAT12项低12位为文件簇号
    pop bx
    pop es
    ret

这一部分参考代码注释和上一章的说明内容即可。

获取内存结构信息

我们还可以通过INT 15h, AH=0xE820功能来获取内存结构信息,具体调用参数如下:

寄存器作用
EAX0xE820(功能号)
EBX上次调用的返回值,首次调用时 EBX=0
ECX结构体大小(通常 20)
EDX必须为 'SMAP' (0x534D4150),以确认 BIOS 支持该功能
ES:DI指向存放返回内存信息的结构体的缓冲区

如内存分配一节所述,我们把内存结构信息放在MemoryStructBufferAddr,也就是0x7e00,接下来实现该代码(因为篇幅问题,下面的代码省略了一些日志打印函数,如Label_Get_Mem_OK和Label_Get_Mem_Fail,完整代码去我的GitHub上查看):

1
2
3
4
5
6
7
8
9
10
11
Label_Get_Mem_Struct:
    ; 调用 INT 15h, AH=0xE820 获取内存映射信息,ECX=20 表示缓冲区大小
    mov eax,    0x0E820
    mov ecx,    20
    mov edx,    0x534D4150   ; “SMAP”签名
    int 15h
    jc  Label_Get_Mem_Fail     ; 如果出错,跳转到错误处理
    add di, 20             ; 每次记录20字节的内存结构信息
    cmp ebx,    0
    jne Label_Get_Mem_Struct
    jmp Label_Get_Mem_OK

返回信息结构如下:

寄存器作用
EAX仍为 'SMAP' (0x534D4150),如果不同,表示 BIOS 不支持
EBX继续调用的标识符(如果返回 0,表示已到列表末尾)
ECX返回的结构体大小(通常 20)
CF如果 CF=1,表示错误

这段代码就是循环获取内存信息结构,直到EBX为0,则跳转到Label_Get_Mem_OK,出错(CF=1)则跳转到Label_Get_Mem_Fail,每获取一次就把di增加20(结构体长度),在上面的代码中没有判断返回的EAX值的代码,因为现在的BIOS基本上都支持这个功能了,如果不放心可以加一个判断代码。

调用这段代码将把返回的内存结构体保存在es:di指向的位置(MemoryStructBufferAddr),这些信息我们在loader阶段暂时还用不上,不过后续kernel会通过这些信息来进行内存初始化和管理。

内存信息结构如下:

字段大小 (字节)类型描述
base_addr8uint64_t内存块的起始物理地址
length8uint64_t内存块的大小(字节)
type4uint32_t内存类型(见下表)
acpi4uint32_tACPI 扩展字段(通常为 0)

内存类型:

类型名称描述
1可用内存可供操作系统使用的 RAM
2保留内存受 BIOS 保护,不可使用
3ACPI 可回收内存ACPI 相关,可在引导后重新分配
4ACPI NVS需要保存的 ACPI 数据,不可覆盖
5坏内存有缺陷的内存区域,不可用

除此之外,如果BIOS真的老到不支持e820功能号,还有一些替代方案:

  • INT 15h, AH=0xE801 (支持 16MB 以上的内存)
  • INT 15h, AH=0x88 (仅适用于 1MB 以内的内存)

至于如何使用,有需要的话自己上网搜搜吧。

关于内存信息结构这一块,这里就只简单了解一下即可,重头戏还在后面呢。内存管理是操作系统一大重点,工作量会非常大,所以有关内存的这些功能都留到后面一起说吧。

VBE画面设置

这一部分算是loader中比较麻烦的部分了,因为NVIDIA GPU没有完整的开源的驱动,也没有开放低级硬件编程接口,所以我们没办法通过正常的方式给NVIDIA GPU开发显卡驱动程序,所以我们只能靠VESA VBE或者UEFI GOP(如果后续我们要兼容UEFI的话)。不过VBE将要使用到的VESA模式号并没有一个强制要求的标准(新版本VBE中一些旧版本中要求强制兼容的VESA模式号也取消了),所以很多信息我们必须动态获取,而没办法写死,否则很有可能在某个模拟器或某台计算机上可用,但是换个环境就用不了了。

不过使用VBE仍然存在一点问题,因为64位长模式强制开启分页内存管理后,无法再返回实模式,所以一旦进入64位长模式,就不能再更改VBE设置(比如更改分辨率或色彩模式等),后续我们可能会使用其他方法来进行画面显示(比如逆向的GPU驱动,或UEFI GOP),不过目前还是先使用VBE系统吧。所以,同样因为这个原因,我们必须在进入长模式前准备好操作系统所需的VBE信息结构,以及做好VBE设置,一旦进入长模式,除非reset CPU,否则就无法更改画面模式了。

下面先来简单了解一下什么是VBE和VESA,不过VBE系统比较复杂,内容繁多,后续可能还会单独写一篇番外篇来详细介绍这个系统。

VESA

VESA(Video Electronics Standards Association,视频电子标准协会) 是一个行业标准组织,负责制定各种显示标准,以便不同厂商的硬件可以互操作,我们后面会用到的VBE就是其制定的标准之一。

VBE

VBE(VESA BIOS Extensions) 是由上面提到的VESA组织制定的一套视频BIOS扩展标准,其允许操作系统或应用程序在不同显卡上以统一的方式访问高分辨率和高色深的显示模式。VBE最早是为16位实模式BIOS设计的,因此它主要提供中断调用(INT 10h)来进行模式切换和帧缓冲操作。

其中目前VBE有三个版本,分别是1.0、2.0和3.0,其区别如下:

版本主要特性
VBE 1.0只支持模式切换,必须通过VGA端口访问显存。
VBE 2.0增加了线性帧缓冲(LFB),可以直接访问显存。
VBE 3.0增强了多缓冲支持,提高了性能。

我本来是希望让KNOS支持VBE3.0的,但是bochs和VMware workstation都默认只支持VBE2.0,所以就改成实现VBE2.0相关功能了。不过实际上VBE3.0中最大的优化是性能上的优化,除了一个多缓冲支持以外(对于多任务窗口来说还是挺有用的),其余比较有用的新功能不算太多,目前来看VBE2.0也够用了,不过后续如果有必要还是可以增加对3.0的支持。

简单来说,我们要配置VBE,需要按照如下步骤:设置目标显示参数->获取系统支持的模式号->寻找符合配置的模式号->切换显示模式;接下来我们就来逐步实现VBE显示模式的设置。

配置显示参数

由于不同设备上支持的VBE模式号不同(标准VGA模式参数是一致的,但是我们使用的是VBE扩展模式号),所以我们没办法直接在代码中写死某个模式号,我们可以预设一个显示分辨率和色彩位数,然后寻找系统中是否有匹配的模式号。我们可以使用配置文件来实现参数设置,不过如果使用配置文件,loader的代码又要多一个读取和解析配置文件,这样就太复杂了,还是简单一点,直接使用常量编译到loader代码中比较好,代码如下(同时这里还包括一些VBE设置用到的其他常量,这里一起写上,后面就不单独写了):

1
2
3
4
5
6
7
8
9
10
11
12
13
; -----------------------------
; VBE(视频扩展 BIOS)相关地址定义
; -----------------------------
VBE_INFO_BLOCK          equ 0x8000   ; 存放VBE信息块的内存物理地址(0:0x8000)
VBE_MODE_LIST_OFF       equ 0x800E   ; VBE模式列表的偏移地址(在VBE信息块内)
VBE_MODE_LIST_SEG       equ 0x8010   ; VBE模式列表的段地址(在VBE信息块内)
VBE_MODE_INFO_BLOCK     equ 0x8200   ; 存放单个VBE模式信息的缓冲区地址

VBE_MODE_SELECTED_INFO  equ 0x8400  ; [临时]存放最终选择的VBE模式信息缓冲区地址

DEFAULT_X_RES   equ 1024
DEFAULT_Y_RES   equ 768
DEFAULT_BPP     equ 32

如上代码所示,我们预设的分辨率位1024x768,色彩长度位32位(32bpp,每个像素点占32位),后续设置模式号前寻找符合这个配置的分辨率即可,当然,你也可以改成更高分辨率(1080P, 1920x1080)。

除此之外,我们还会用到一些输出语句来显示获取、设置模式码是否成功(不过因为篇幅问题,文章中的代码我会去掉日志打印代码),以及一个用于保存最终选定的模式号的变量,我先全部列在下面了:

1
2
3
4
5
6
7
8
9
CandidateMode dw 0    ; 保存选中的 SVGA 模式号,初始为0表示未找到

StartGetSVGAVBEInfoMessage: db  "Start Get SVGA VBE Info"
GetSVGAVBEInfoErrMessage:       db  "Get SVGA VBE Info ERROR"
GetSVGAVBEInfoOKMessage:        db  "Get SVGA VBE Info SUCCESSFUL!"
StartGetSVGAModeInfoMessage:    db  "Start Get SVGA Mode Info"
GetSVGAModeInfoErrMessage:      db  "Get SVGA Mode Info ERROR"
GetSVGAModeInfoOKMessage:       db  "Get SVGA Mode Info SUCCESSFUL!"
SetSVGAModeErrMessage:          db  "Set SVGA Mode ERROR"

有了这些数据后,我们就可以正式开始开发业务逻辑代码了,首先调用INT 10h功能号AX=4F00h来获取VBE信息块,通过该功能号可以获取显卡支持的 VBE 版本、可用的图形模式等:

1
2
3
4
5
6
7
8
9
10
11
12
13
    ; 调用 INT 10h, AX=4F00h 获取VBE信息块
    ; 准备512字节缓冲区(ES:DI=0:0x8000),并写入'VBE2'标志以表明支持VBE2.0+
    mov ax, 0x00
    mov es, ax
    mov di, VBE_INFO_BLOCK
    ; 写入'VBE2'标志
    mov word [es:di], 'VB'
    mov word [es:di+2], 'E2'
    mov cx, 512             ; 缓冲区大小为512字节
    mov ax, 4F00h
    int 10h
    cmp ax, 004Fh
    jz  .KO           ; 如果AX==004Fh表示成功,则跳转到 .KO 后续处理

该功能调用方法为:

寄存器说明
AX0x4F00请求 VBE 控制器信息
ES:DI指向缓冲区存储 VBE 信息的结构体

返回值只有一个状态码,AX=004Fh为调用成功,否则失败。结构体结构的具体信息可以参考我后续写的VBE的番外篇(如果我记得写的话),简单概括为如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct VBEInfoBlock {
    char     VbeSignature[4];   // "VESA" 签名
    uint16_t VbeVersion;        // VBE 版本号
    uint32_t OemStringPtr;      // OEM 字符串指针
    uint32_t Capabilities;      // 图形功能支持情况
    uint32_t VideoModePtr;      // 指向支持的视频模式列表
    uint16_t TotalMemory;       // 显存大小(以 64KB 块为单位)
    // VBE 2.0+ 额外字段
    uint16_t OemSoftwareRev;
    uint32_t OemVendorNamePtr;
    uint32_t OemProductNamePtr;
    uint32_t OemProductRevPtr;
    uint8_t  Reserved[222];     // 保留
    uint8_t  OemData[256];      // OEM 额外数据
} __attribute__((packed));

如果获取成功,则进行数据解析,这一步代码如下:

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
.KO:
    ; ======= 解析获取到的 SVGA 模式信息 =======
    ; VESA标识
    mov al, 0dh
    call print_char
    mov al, 0ah
    call print_char
    mov al, [es:VBE_INFO_BLOCK]
    call print_char
    mov al, [es:VBE_INFO_BLOCK + 1]
    call print_char
    mov al, [es:VBE_INFO_BLOCK + 2]
    call print_char
    mov al, [es:VBE_INFO_BLOCK + 3]
    call print_char

    ; 打印VBE版本信息
    mov al, ' '
    call print_char
    mov al, 'v'
    call print_char
    mov ax, [es:VBE_INFO_BLOCK + 0x4]
    call print_hex16
    mov al, 0dh
    call print_char
    mov al, 0ah
    call print_char

现在来简单介绍一下这段解析代码。首先是这段代码中用到的几个函数:print_charprint_hex16print_dec,这几个函数分别用于打印ax寄存器中字符保存的值对应的ascii字符、打印ax寄存器值的16进制数、打印ax寄存器值的十进制数,其实现如下:

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
; -----------------------------
; 以下为打印函数,用于输出字符、16进制和10进制数字到屏幕
; -----------------------------

; print_char: 利用 BIOS int 10h, AH=0Eh 输出 AL 中的 ASCII 字符
print_char:
    mov ah, 0Eh
    int 10h
    ret

; print_hex16: 打印 AX 中的16进制数字(4位),例如0x1A2B将显示为 "1A2B"
print_hex16:
    push ax
    push bx
    push cx
    push dx
    mov bx, ax         ; 备份AX到BX
    mov cx, 4          ; 循环4次(每次输出一个16进制字符)
.hex_loop:
    mov dx, bx
    shr dx, 12         ; 取最高的4位(一个 nibble)
    and dx, 0x000F
    cmp dl, 9
    jbe .digit
    add dl, 'A' - 10
    jmp .out
.digit:
    add dl, '0'
.out:
    mov al, dl
    call print_char
    shl bx, 4         ; 左移4位,准备下一 nibble
    dec cx
    jnz .hex_loop
    pop dx
    pop cx
    pop bx
    pop ax
    ret

; print_dec: 打印 AX 中的10进制无符号整数
; 例如 AX=1234 将显示 "1234"
print_dec:
    push ax
    push bx
    push cx
    push dx
    mov bx, 10        ; 除数10
    xor cx, cx        ; 初始化计数器
.convert_loop:
    xor dx, dx
    div bx            ; AX除以10,AX存商,DX存余数
    add dl, '0'       ; 将数字转换成ASCII字符
    push dx           ; 将余数保存到栈中
    inc cx
    test ax, ax
    jnz .convert_loop
.print_loop:
    pop ax            ; 弹出余数字符
    call print_char
    loop .print_loop
    pop dx
    pop cx
    pop bx
    pop ax
    ret

这段代码逻辑比较简单,其中涉及到一些数据值转换工作,在注释中都有介绍,这里就不再单独说明了,继续分析SVGA结构解析代码。

首先是打印出es:VBE_INFO_BLOCK所在地址值开始的四个字节,按照上方返回值描述,这四个字节应该正好是“SVGA”字符串,如果不是这段字符串说明获取失败或数据被污染;其次是打印VBE版本信息,位于结构体偏移值0x4处,这里bochs和VMware workstation的值应该都是0200(当然,你也可以换一下打印顺序,让它输出0002),表示支持VBE2.0,如果是0300则表示支持VBE3.0;

再然后我们就需要来打印设备支持的模式号,和对应的模式信息了,这部分代码如下:

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
    ; VBE信息块已成功存放在 0:0x8000
    ; 获取模式列表:VBE信息块内偏移地址VBE_MODE_LIST_OFF和段地址VBE_MODE_LIST_SEG
    mov ax, 0
    mov es, ax             ; ES=0,指向物理地址 0x8000
    mov bx, [es:VBE_MODE_LIST_OFF]  ; 获取模式列表偏移量
    mov cx, [es:VBE_MODE_LIST_SEG]  ; 获取模式列表段地址
    ; 切换到VBE模式数据段,设置 ES 为模式列表段地址,SI为偏移
    mov es, cx
    mov si, bx

; 开始循环遍历所有支持的SVGA模式
GetModeListLoop:
    ; 1) 读取当前模式号(16位模式号存储在ES:SI处)
    mov cx, [es:si]

    ; 2) 判断是否已经到模式列表结束标志 0xFFFF
    cmp cx, 0FFFFh
    jz EndModeListLoop
   
    ; 3) 打印当前模式号(16进制显示)
    push ax
    mov ax, cx
    call print_hex16   ; 调用打印16位十六进制数的函数
    pop ax

    ; 4) 打印一个空格分隔符
    mov al, ' '
    call print_char

    ; ---------------------------------
    ; 调用 INT 10h, AX=4F01h 获取模式信息
    ; ---------------------------------
    push es         ; 保存当前ES(VBE模式列表所在段)
    mov ax, 0
    mov es, ax      ; 切换ES=0,指向VBE_MODE_INFO_BLOCK所在物理地址
    mov bx, cx      ; BX = 当前模式号
    mov ax, 4F01h   ; VBE函数:获取模式信息
    mov di, VBE_MODE_INFO_BLOCK  ; 存放模式信息的缓冲区地址
    int 10h
    cmp ax, 0x004F
    jnz ModeInfoFail

    ; ---------------------------------
    ; 解析并显示模式信息
    ; ---------------------------------
    ; 显示分辨率宽度(存放在偏移0x12处)
    mov ax, [es:VBE_MODE_INFO_BLOCK + 0x12]
    call print_dec
    mov al, 'x'
    call print_char
    ; 显示分辨率高度(偏移0x14处)
    mov ax, [es:VBE_MODE_INFO_BLOCK + 0x14]
    call print_dec
    mov al, ','
    call print_char
    mov al, ' '
    call print_char
    ; 显示每像素位数(BitsPerPixel,偏移0x19处)
    mov al, [es:VBE_MODE_INFO_BLOCK + 0x19]
    mov ah, 0
    call print_dec
    ; 打印 "bpp" 字符串
    mov al, 'b'
    call print_char
    mov al, 'p'
    call print_char
    mov al, 'p'
    call print_char
    mov al, ' '
    call print_char
    ; 显示线性帧缓冲区地址
    mov ax, [es:VBE_MODE_INFO_BLOCK + 0x2a]
    call print_hex16
    mov ax, [es:VBE_MODE_INFO_BLOCK + 0x28]
    call print_hex16
    ; 打印分割
    mov al, ' '
    call print_char
    mov al, '|'
    call print_char
    mov al, ' '
    call print_char

    ; ---------------------------------
    ; 判断模式号是否满足默认需求
    ; ---------------------------------
    mov ax, [es:VBE_MODE_INFO_BLOCK + 0x12]     ; 读取 X 分辨率(VBE_MODE_INFO_BLOCK 偏移 0x12)
    cmp ax, DEFAULT_X_RES
    jne RestoreAndNextMode

    mov ax, [es:VBE_MODE_INFO_BLOCK + 0x14]     ; 读取 Y 分辨率(偏移 0x14)
    cmp ax, DEFAULT_Y_RES
    jne RestoreAndNextMode

    mov al, [es:VBE_MODE_INFO_BLOCK + 0x19]     ; 读取 BitsPerPixel(偏移 0x19)
    cmp al, DEFAULT_BPP
    jne RestoreAndNextMode

    mov [CandidateMode], cx     ; 保存当前模式号到 CandidateMode

    ; 保存模式信息
    push ds
    mov ax, 0
    mov ds, ax
    mov si, VBE_MODE_INFO_BLOCK
    mov di, VBE_MODE_SELECTED_INFO          ; 目标地址
    mov cx, 256                     ; 复制 512 字节,即 256 个 WORD
    rep movsw
    pop ds

    jmp EndModeListLoop

RestoreAndNextMode:
    pop es         ; 恢复之前保存的ES(指向模式列表)
    ; ---------------------------------
    ; 指向下一个模式号(每个模式号占2字节)
    add si, 2
    jmp GetModeListLoop

这段代码首先按照上面描述的偏移量,找到设备支持的模式号列表,然后通过GetModeListLoop来遍历这个列表,如果读取到模式号为FFFFh则表示模式号列表结束,否则打印该模式号,然后通过调用 INT 10h, AX=4F01h 来获取模式号对应信息,该功能调用方法如下:

寄存器描述
AX0x4F01请求 VBE 模式信息
CX模式号需要获取信息的视频模式编号(如 0x118 表示 1024x768 16 位色)
ES:DI指向缓冲区地址存储模式信息的 VBEModeInfoBlock 结构体

返回值只有一个状态码,AX=004Fh为调用成功,否则失败。该功能返回的结构体简单概括为如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct VBE_ModeInfoBlock {
    uint16_t attributes;        // 0x00: 该模式的属性
    uint8_t  winA, winB;        // 0x02: 窗口 A/B 类型
    uint16_t granularity;       // 0x04: 窗口大小增量
    uint16_t winsize;           // 0x06: 每个窗口的大小(KB)
    uint16_t segmentA, segmentB;// 0x08: 窗口 A/B 的段地址
    uint32_t winFuncPtr;        // 0x0A: 窗口控制函数指针
    uint16_t pitch;             // 0x0E: 每行的字节数
    uint16_t width, height;     // 0x10: 分辨率(像素)
    uint8_t  wChar, yChar, planes, bpp, banks, memoryModel, bankSize, imagePages;
    uint8_t  reserved0;
    uint32_t framebuffer;       // 0x28: 线性帧缓冲区地址(VBE 2.0+)
    uint32_t offscreenMemOff;
    uint16_t offscreenMemSize;
    uint8_t  reserved1[206];    // 预留
} __attribute__((packed));

获取到模式号具体信息后,打印出其分辨率和每像素位数,以及线性帧缓冲区地址,并且以“ | ”最为分隔符。完成信息显示后,判断是否符合预设的分辨率和bpp,如果满足则保存最终选择的模式号到[CandidateMode],然后保存该模式号的具体结构体到VBE_MODE_SELECTED_INFO位置,最后跳转到设置模式号相关的代码,否则跳转到失败代码。

这段代码中获取符合条件的模式号只做了分辨率和bpp的判断,实际可以按照需求继续增加例如显示模式等属性的判断。

这段代码最终运行效果如下:

Bochs支持的显示模式号及分辨率信息
VMware workstation支持的显示模式号及分辨率信息