距离上一次写操作系统相关的内容已经过去两年半了,中间因为去搞其他东西所以一直没有推进,最近这段时间过年回家没事做,正好把这个坑填一填。
上一章我们实现了一个具有引导功能的引导扇区,其代码的功能就是将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位) | 是否有效 |
AVL | 1位(52位) | 操作系统可用 |
L(64位标志) | 1位(53位) | 64位代码段(长模式必须启用,长模式GDT更加简化) |
D/B(操作数大小) | 1位(54位) | 0 = 16位, 1 = 32位 |
G(粒度) | 1位(55位) | 0 = 字节单位, 1 = 4KB单位 |
一般来说,正如我们上面的代码所示,GDT至少需要三个条目(代码段和数据段也可以有多个条目),分别是:
- 空描述符(Null Descriptor):必须存在,防止访问错误的GDT索引。
- 代码段(Code Segment):负责执行代码,在上面的代码中只有一个代码段条目,值为0x0000FFFF,0x00CF9A00,由LABEL_DESC_CODE32指向该条目。
- 数据段(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功能来获取内存结构信息,具体调用参数如下:
寄存器 | 作用 |
---|---|
EAX | 0xE820(功能号) |
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_addr | 8 | uint64_t | 内存块的起始物理地址 |
length | 8 | uint64_t | 内存块的大小(字节) |
type | 4 | uint32_t | 内存类型(见下表) |
acpi | 4 | uint32_t | ACPI 扩展字段(通常为 0) |
内存类型:
值 | 类型名称 | 描述 |
---|---|---|
1 | 可用内存 | 可供操作系统使用的 RAM |
2 | 保留内存 | 受 BIOS 保护,不可使用 |
3 | ACPI 可回收内存 | ACPI 相关,可在引导后重新分配 |
4 | ACPI 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 后续处理 |
该功能调用方法为:
寄存器 | 值 | 说明 |
---|---|---|
AX | 0x4F00 | 请求 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_char、print_hex16、print_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 来获取模式号对应信息,该功能调用方法如下:
寄存器 | 值 | 描述 |
---|---|---|
AX | 0x4F01 | 请求 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的判断,实际可以按照需求继续增加例如显示模式等属性的判断。
这段代码最终运行效果如下: