距离第一部问世已经有一段时间了.
第 2 部分是更多的理论,但随着我们摆脱 BIOS 提供的漂亮抽象并开始自己处理事情,事情也变得更加有趣。
我们在第 1 部分中做了什么?
- 我们建立了一个开发环境。
- 我们了解了这个引导加载程序运行的环境(x86系统)。
- 我们了解了引导加载程序的工作原理,以及底层 BIOS 如何感知我们正在编写的引导加载程序。
- 我们尝试使用一些原始方法打印到屏幕上。
- 我们了解标准 x86 系统的内存组织。
- 我们创建了自己的堆栈供引导加载程序使用。
- 由于我们拥有堆栈的奢侈,我们编写了用于打印字符串和打印十六进制数据的函数。
第 1 部分中完成的所有操作的代码都可以在下面链接的存储库中找到:
BellLabs/OSDev/EP1 在主 ·prithivi-maruthachalam/贝尔实验室
贝尔实验室的代码片段和示例。通过创建...
以下是第 1 部分的续篇
Thing 4.5 : 细分
不那么有趣的事实:这就是分段错误的来源。
分段是 x86 操作系统中一种较旧的内存管理方式(虚拟内存是过去许多年来一直很热门的新事物)。在理想的操作系统中,您不希望不同的程序能够访问彼此的内存;您尤其不希望用户空间程序访问内核空间。因此,我们需要一种将内存划分为特定目的的区域(如代码与数据)的方法,以及一种保护这些区域免受未经授权的访问(如访问内核数据的用户代码)的方法。分割是一种让我们实现这一目标的技术。
在基本层面上,分段意味着我们使用基数 + 偏移量来访问内存位置,而不是直接物理地址。我们在操作开始时设置基值,然后使用基数的偏移量来访问我们需要的内存位置。每当我们在设置基值后在代码中指定偏移值时,MMU 都会自动将基值添加到偏移量中,以获取我们打算访问的物理地址。
一个例子
假设 8 位基数和 16 位偏移量。因此,十六进制表示中的基数和偏移量范围为:
- 基数 : 至
0x00
0xFF
- 偏移量 : 至
0x0000
0xFFFF
如果将程序的基数设置为,并且程序尝试以 的偏移量访问内存,则 RAM 中的 MMU 访问的有效地址将是 。
0x1A
0xABC1base + offset = 0xABDB
在 x86 中,这是使用段寄存器实现的,段寄存器是存储不同类型段(代码、数据、堆栈等)基值的特殊寄存器。因此,通过更改存储在其中一个寄存器中的基值,您还可以更改给定偏移量所访问的段。
设置后,这些基本值将被隐式添加到程序员在其指令中提供的地址中。
另外,我对你撒了一点谎;对不起。在 x86 中,基数不仅添加到偏移量中。你重叠了它们!physical_address = (base << 4) + offset
了解分段的表示法
很多时候,您会注意到一个地址被表示为两个寄存器的组合,例如 ES:BX——这意味着分段与存储在寄存器 ex 中的基数和存储在寄存器 bx 中的偏移量一起使用
如果它已经过时了,我们为什么要这样做?
尽管虚拟内存是管理内存块和保护内存块的更好方法,但仍应支持分段以实现向后兼容性。因此,我们需要学会将分段与虚拟内存结合使用。
事情 5 : 磁盘的东西
众所周知,操作系统存储在磁盘中。第 1 部分介绍如何将磁盘格式化为操作系统映像。我们的整个操作系统将无法容纳 512 字节的内存。构成引导加载程序的前 512 个字节必须包含从磁盘读取操作系统代码并手动将其加载到内存中的指令。这是引导加载程序的主要工作。
我们知道如何编写和使用函数;因此,以真正的工程师方式,让我们用我们已经知道的东西来做一些我们不知道的事情——编写一个函数来从磁盘读取数据并将其放入内存中。如果要具体一点,可以将此操作称为 load。
快速回顾我们如何打印到屏幕上
- 将寄存器 ah 设置为
0x0E
- 将要打印的字符存储在寄存器中
- 引发 int 0x10 (中断 0x10)
在我们这样做之前,有必要了解用于寻址磁盘块的 CHS 系统。
从磁盘加载
- 将寄存器 ah 设置为
0x02
- 为与要从中读取的磁盘块相关的柱面、扇区数、起始扇区、磁头号、驱动器号等设置一些其他寄存器。
- 将 ES:BX 设置为要加载此数据的内存位置的地址。
- 提高 int 0x13
下面是一张图片,其中详细介绍了读取磁盘扇区的中断是如何工作的。请记住,这些中断(磁盘、打印等)的处理程序是由 BIOS 设置的。当你有一天(希望)编写自己的操作系统时,你会编写自己的驱动程序来处理磁盘之类的事情。
气缸、缸盖和扇区?
如果您已经了解了柱面、磁头和扇区,那么应该很容易看出 MBR(主引导记录)是前 512 个字节,这使得它成为扇区 1 或柱面 0、磁头 0 和驱动器 0。这是因为气缸、缸盖和驱动器编号从 0 开始,而扇区编号从 1 开始。
让我们看一个例子
在此示例中,我们将编写一个函数,该函数使用我们刚刚了解的中断0x13将特定扇区从磁盘读取到内存中。
;data is loaded to es:bx
;bx will have to be set to the position of the kernel offset in the calling function
disk_load:
;parameter: dh - number of sectors(512 bytes) to read starting from the second sector
;paraneter: bx - data will be loaded into es:bx
;parameter: dl - the drive to read from
pusha ;push all existing registers to the stack to preserve them
push dx ;input parameter - save to stack for later use
mov ah,0x02 ;read instruction
mov al,dh ;number of sectors to be read
mov cl,0x02 ;the sector to be read first - 0x01 is the boot sector
mov ch, 0x00 ;the cylinder number
mov dh, 0x00 ;repurposing dh to be used as the head number
; dl will have been set by the caller to the drive number
int 0x13 ;the iterrupt to read from disk
jc disk_error
pop dx ;clear the old dx values from the stack back into the register
cmp al,dh; dh stores the number of sectors that were actually read
jne sectors_error
popa
ret
sectors_error:
mov bx,SECTORS_ERROR_STRING
call print_string
call print_newline
mov dh,ah ;ah contains the error code
call print_hex_data
jmp disk_error_loop ;put in an infinite loop on error
disk_error:
mov bx, DISK_ERROR_STRING
call print_string
disk_error_loop:
jmp $
;data
DISK_ERROR_STRING:
db "Disk read error",0
SECTORS_ERROR_STRING:
db "Incorrent number of sectors were read",0
将此代码添加到单独的文件中,将其命名为 disk_access.asm。 此外,请设置 boot.asm 文件以使用此函数。
[org 0x7c00]
; set up stack
mov bp, 0x8000
mov sp, bp
mov bx, GREETING_STRING
call print_string
call print_newline
mov bx, 0x8000
mov dh, 2
call disk_load
mov dx, [0x9000]
call print_hex
call print_newline
mov dx, [0x9000 + 512] ; first word from second loaded sector, 0xface
call print_hex
; infinite loop
jmp $
;including files
%include "printing.asm"
%include "disk_access.asm"
; data
GREETING_STRING:
db 'bootloader entry', 0
; fill empty bytes with zeroes
times (510-($-$$)) db 0
;$ - current line address
;$$ - current section address
;magic number
dw 0xAA55
; fill with data to use
; sector 1 has been filled. so the following is written in sectors 2 and 3
times 256 dw 0xabab
times 256 dw 0xcdcd
Thing 5.5:位模式
当 BIOS 启动引导加载程序时,我们以 16 位模式启动。这意味着所有数据和地址都限制为 16 位,无论处理器实际支持多少位。这样做是为了向后兼容,以便为现代系统编写的代码可以首先在较旧的机器上运行,然后决定是否要进入 32 位模式(现在是 64 位,但我们将坚持使用 32 位用于我们的美化引导加载程序)。
为什么选择 32 位模式?
- 寄存器可以容纳更多位;即 32 位
- 可以访问更大范围的内存;地址现在长度为 32 位
- 在 32 位模式下,我们能够添加内存保护,就像我们在分段中讨论的那样
32 位模式下的情况有何不同
到目前为止,我们一直在使用的中断(打印、磁盘)是由 BIOS 设置的。当我们发出中断指令时,会调用 BIOS 例程。这在 32 位模式下不起作用,因为 BIOS 只会设置在 32 位模式下不起作用的 16 位中断。
在 16 位之前,我们处于实模式。在 32 位中,我们将处于所谓的保护模式,因为在此模式下提供了在 16 位模式下不可用的内存保护功能。在本文后面处理全局描述符表时,我们将更深入地探讨内存保护的概念。
还不如把事情掌握在我们自己手中,创建一个能够在不使用 32 位模式下的 BIOS 中断的情况下打印字符串的函数。
事情 6 : 打印回来了,但这次是 32 位
我们已经确定 32 位模式是一个绝对单位,我们需要它。我们还确定,到目前为止,我们一直依赖的 BIOS 中断在 32 位模式下无法工作。那么,我们将如何在 32 位模式下打印呢?
要以 32 位模式打印,您需要使用 VGA(视频图形阵列;还记得那些带螺丝的蓝色显示电缆吗?)内存。VGA 是一种旧的芯片组,用于在屏幕上显示彩色图形。它有一个文本模式(在这种模式下,我们只能打印文本;谁会猜到呢?),我们可以用它来打印到屏幕上,并带有一些颜色。VGA 还支持文本的背景色和前景色,但颜色范围有限。
要打印到屏幕上,RAM的一部分标记为VGA缓冲区。它位于地址。请注意,此地址大于 16 位。这就是为什么我们以前不能在实模式下使用它的原因。VGA 提供了一个 80 x 25 的网格供我们打印字符。因此,要访问缓冲区的第一个位置,您需要写入地址。为了进行有趣的练习,请尝试创建一个公式,如果每个字符都使用 2 个字节,则写入 80 x 25 网格上的任何给定位置。为什么是 2 个字节?1 个字节用于 ASCII 文本,1 个字节用于前景色和背景色。0xB8000
0xb8000
如果你有正确的,它看起来像这样:0xb8000 + 2 * (row * 80 + col)
快速运动
想象一下,您想在黑色背景上打印浅绿色的字符“X”。您需要存储在 VGA 缓冲区内的正确位置的值将是一个 2 字节值,其中最低有效 8 位设置为,最高有效 8 位设置为01011001
00001010
- 前 8 位是大写字母 X = 88 的 ASCII 值的二进制表示形式
01011001
- 对于第二个 8 位,最低有效 4 位是 VGA 中浅绿色(文本颜色)的二进制表示形式
1010
- 对于第二个 8 位,最重要的 8 位是 VGA(背景色)中颜色的二进制表示
0000
您可以在下面找到 VGA Colors 的参考。
如果没有例子,这一切有什么价值?
[bits 32] ;instruct assembler to use 32-bit mode
VIDEO_MEMORY equ 0xb8000
WHITE_ON_BLACK equ 0x0f ;color byte
pm_print_string:
pusha
mov edx,VIDEO_MEMORY
pm_print_string_loop:
mov al, [ebx] ;char at ebx
mov ah, WHITE_ON_BLACK ;color attributes in ah
cmp al, 0
je pm_print_done
mov [edx], ax ;put into edx, the values in edx
add ebx, 1
add edx, 2
jmp pm_print_string_loop
pm_print_done:
popa
ret
将此文件称为 32-bit_printing.asm。
准备好使用该功能了吗?不!我们不能使用此函数,因为我们还没有处于 32 位模式。为此,我们必须切换到 32 位模式,并且应该首先使用 GDT( 全局描述符表)在 32 位模式下设置分段。
事情 7 : 设置全局描述符表以处理 32 位分段
还记得我们谈到过细分吗?基数+偏移量?好吧,分段在 32 位模式下是不同的。它更高级,包括
- 隔离内核空间和用户空间的特殊段
- 更好的内存保护
32 位模式下的分段如何工作?
当我们将分段的基值存储在寄存器中时,我们可以拥有的段数受到可用寄存器数量的限制。这并不理想。因此,在 32 位模式下,我们创建一个表。表中的每个条目都包含有关基础、访问权限、段大小、内核空间/用户空间等的信息。我们将此表存储在内存中的某个位置,并告诉一个特殊的寄存器在哪里可以找到该表。然后,MMU 将能够使用此表来计算有效地址,就像我们之前看到的那样。
设置完成后,内存访问是两个参数的组合:
- 区段选择器 — 它是 GDT 的索引,其中包含不同区段的描述符
- Offset — 该段的偏移量(这与之前 Thing 4.5 : Segmentation 中的偏移量值一样
我显然不是 GDT 的最佳信息来源,所以请随时阅读此页面或搜索互联网!
创建全局描述符表
GDT 中的每个条目的长度为 8 个字节,表如下所示
每个条目(内容字段)都具有 64 位(8 字节)长的复杂结构。每个条目都称为段描述符,因为它描述了一个段。
如何使用 GDT 进行内存访问?
因此,作为低级程序员,您将内存位置指定为全局描述符表中的索引和该段内的偏移量的组合。我们没有将我们之前看到的段寄存器设置为基值,而是将它们设置为 GDT 中相应段描述符的索引。
请注意,线段的基数和该线段内的偏移量之和必须小于该线段的极限值。然后,MMU 检查描述符中的标志,以确保可以访问您尝试访问的内存区域,然后使用它来访问物理内存。这里,A 是 GDT 的索引,B 是偏移量。Physical address = Segment Base (Found from the descriptor GDT[A]) + B
Thing 7.5 : 实际创建 GDT 条目
让我们来看看如何创建实际的 GDT 并将其填充到 ASM 中。我们也可以用更高级别的语言来做到这一点,但我们现在将坚持使用良好的 x86 程序集。
让我们创建一个文件,它只表示内存中的 GDT,并用我们想要的 GDT 数据填充它。这就是我们需要的
- 条目 1:GDT 的前 8 个字节应为
0
- 条目 2:接下来的 8 个字节将描述一个代码段(内核空间)
- 条目 3:接下来的 8 个字节将描述一个数据段(内核空间)
由于引导加载程序需要以最高权限运行,因此我们只创建内核空间段。
我们还需要创建称为 GDT 描述符的东西。这是一个 32 位值,存储在 GDTR 寄存器中,用于告诉 MMU 在哪里找到 GDT 以及 GDT 有多大。描述符的最低有效 16 位应该是 GDT 的大小,最高有效 16 位应该是 GDT 开始的地址。这实质上是 GDT 中条目 1 的第一个字节的物理地址。
gdt_start:
;needs 8 null bytes to start
dd 0x0 ;4 bytes
dd 0x0 ;4 bytes
;descriptor for code segement
gdt_code:
;base has to be 0x0
;limit is 0xfffff
;1st flags - present:1, privilege:00, descriptor type: 1 > 1001
;type flags - code:1, conforming:0, readable:1, accessed:0 > 1010
;2nd flags: granularity:1,32-bit default:1,64-bit seg:0,AVL,0 > 1100
dw 0xffff ;segment length mltiplier-ish - limit - (bits 0-15)
dw 0x0 ;Base address (bits 0-15)
db 0x0 ;Base address (bits 16-23)
db 10011010b ;1st flags, type flags
db 11001111b ;2nd flags, limit - (bits 16-19)
db 0x0 ;Base address (bits 24-31)
gdt_data:
;basically everything except type flags are the same, because we are creating overlapping segments
;type flags - code:0, expand down:0, writable: 1, accessed:0 >0010
dw 0xffff
dw 0x0
db 0x0
db 10010010b ;1st flags, type flags
db 11001111b
db 0x0
gdt_end:
;only to calculate the size of the GDT
gdt_descriptor:
dw gdt_end - gdt_start - 1 ;indexing, logic and shit right? - basically size of the descriptor
dd gdt_start ;address of the descriptor (32-bit)
;pointers to the code and data segments
CODE_SEGMENT equ gdt_code - gdt_start
DATA_SEGMENT equ gdt_data - gdt_start
我们将文件命名为 32-bit_gdt.asm
需要注意的几点:
- Present Flag :设置为指示此段有效且可以使用。
1
- Privilege :设置为表示这是内核级代码/数据;即最高特权。
0x00
- 粒度 :设置为表示每个内存块的长度为 4KB。否则,每个块的长度为 1 个字节,我们将有太多的块需要处理。
1
- 代码段的 Executable 标志设置为 。
1
- 代码段仅设置为可读,而数据段同时具有读取和写入权限。
我们只是做了一些非常厚颜无耻的事情;)
如果您仔细查看我们的 GDT 条目中的值,您会注意到两件事:
- 代码和数据的基本值实际上都只是
0
- 代码和数据的极限值实际上都只是
0xffff
为什么会这样? 好吧,这就是我们让 Segmentation 与虚拟内存一起工作的方式(在我们的引导加载程序中,这个阶段不需要虚拟内存)。这些值的本质含义是,代码和数据段都跨越了我们能够以 32 位访问的整个 4GB 内存。因此,从一个地址到另一个地址的所有内存都是两个段的一部分。这几乎就像内存分段不存在一样,但我们仍然有分段,以便与不支持虚拟内存的处理器向后兼容。除此之外,如果我们将当前段设置为代码段并尝试写入地址,即使两个段覆盖相同的内存区域,此操作也会失败。这是因为 GDT 提供的保护仍然处于活动状态(我们将代码段设置为可读但不可写)。0
0xffff
事情8:实际切换到32位模式
切换到 32 位模式需要采取的步骤定义明确:
- 禁用中断 :我们不希望 CPU 在执行以下操作时分心。
- 使用指令加载 GDT。
lgdt
- 在 CPU 控制寄存器 cr0 上设置一个位,使 CPU 处于 32 位模式。请记住,CPU 默认以 16 位模式启动,以便向后兼容。
- 使用远跳转刷新 CPU 管道 — 这只是为了清除管道中的任何待处理指令。
- 更新所有段寄存器。
- 更新堆栈。
- 调用具有 32 位指令的程序集标签,例如我们之前编写的 print 函数。
[bits 16] ;16-bit instructions to switch to 32-bit protected mode
switch_to_pm:
cli ;disable interrupts
lgdt [gdt_descriptor] ;load gdt
mov eax, cr0 ;put the value of cr0 in a register
or eax, 0x1 ;set 32-bit mode in cr0
mov cr0, eax
jmp CODE_SEGMENT:init_pm ;the offset of the code segment in the gdt to perform a far jump
[bits 32] ;these are 32-bit instructions
init_pm:
;update segment registers
mov ax, DATA_SEGMENT ;put the address in ax temporarily
mov ds, ax
mov ss, ax
mov es, ax
mov fs, ax
mov gs, ax
;update stack
mov ebp, 0x90000 ;set stack base to start of free space
mov esp, ebp
call BEGIN_PM ;Call to 32-bit code
将此文件命名为 32bit_switch.asm。请注意,标签 和 来自我们之前创建的 32-bit_gdt.asm 文件。gdt_descriptor
CODE_SEGMENT
DATA_SEGMENT
在这一点上,您一定已经注意到标签是新的,而且确实如此!我们将不得不在项目的其他位置创建标有 32 位代码。我选择在 boot.asm 文件中执行此操作。修改 boot.asm 文件如下所示:BEGIN_PM
BEGIN_PM
[org 0x7c00]
; set up stack
mov bp, 0x9000
mov sp, bp
mov bx, REAL_MSG
call print_string
; switch to 32-bit mode
call switch_to_pm
; infinite loop
jmp $
;including files
%include "printing.asm"
%include "32bit_gdt.asm"
%include "32bit_printing.asm"
%include "32bit_switch.asm"
[bits 32] ;Indicate that the following code is in 32-bit mode
BEGIN_PM:
mov ebx, PROTECTED_MSG
call pm_print_string
jmp $
; data
REAL_MSG db "Starting bootloader in Real Mode", 0
PROTECTED_MSG db "Entered 32-bit protected mode", 0
; bootsector
times (510-($-$$)) db 0
dw 0xAA55
另外,请注意,PM 代表保护模式,这是 32 位模式的另一个名称。
事情 9 : 运行和测试!
我们到达终点线了吗?不,但我们在这里做了什么不可思议的事情吗?是的!使用 qemu 编译和运行。nasm -f bin boot.asm -o boot.bin
qemu-system-i386 boot.bin
如果你看到这样的东西,请给自己一个大大的拍背!如果没有,请不要担心,因为这些东西在第一次尝试时很少奏效。回溯您的步骤,调试并从我在上一篇文章中列出的来源中阅读更多内容。此外,您可以随时非常自由地向我寻求帮助!
您可以在下面的存储库中找到我们在此处构建的内容的源代码:
BellLabs/OSDev/EP2 在主 ·prithivi-maruthachalam/贝尔实验室
贝尔实验室的代码片段和示例。通过创建...
原文始发于微信公众号(安全狗的自我修养):构建一个美化的引导加载程序 — 第 2 部分
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论