32 位的世界
16 位实模式
我们之前一直在 16 位下工作,这是因为计算机是充满妥协的,当你设计出一个新东西的时候,总是要考虑向下兼容。
对于刚开始启动的计算机来讲,当然不知道操作系统是多少位的。为了兼容,它只能先进入一个 16 位的模式——16 位实模式
。实模式是 Intel 8086 处理器的一种工作模式,在实模式下,CPU 只能访问 1MB 的内存,而且只能使用 16 位的寄存器。
我们刚刚就一直在实模式下工作。但是,实模式有着很多缺点。因此,我们在实模式下读取到一些硬盘数据后,便可以顺势进入 32 位保护模式
,享受到更大的内存、更多更大的寄存器、更丰富的功能。具体来讲有以下几点:
- 寄存器全部变为 32 位,我们向之前的寄存器名称前添加
e
来表明这一点,例如eax
、ebx
; - 增加了 2 个新的段寄存器
fs
和gs
; - 内存偏移增加至 32 位,因此我们可以访问到 4 GB 的内存;
- 支持了虚拟内存、内存保护等功能。
当然,在这之后,还可以进入 64 位长模式
,我们后文会具体介绍。但现在,让我们先从 32 位保护模式开始。
要想从 16 位实模式进入 32 位保护模式,有几项必须完成的步骤:
- 禁用中断;
- 设置并加载 GDT;
- 设置 CR0 寄存器的指示位;
- 更新各寄存器。
这些步骤我们会逐一介绍。
事实上,32 位保护模式还有一个子模式,叫做
虚拟 8086 模式
。在这个模式下,CPU 会模拟出一个 8086 处理器,让它运行 16 位的程序。这个模式在运行一些老的程序时很有用。我们这里将不会涉及。
切换到 32 位实模式可能还会需要启用
A20
,但这是老黄历了,我们不做介绍。
Yet Another Hello, World!
在 32 位保护模式中,BIOS 就无法使用了。这让我们没法方便地调用系统中断来打印字符。但幸运的是,我们不需要当人肉显卡手操像素点!
不管你的计算机是亮机卡还是 4090,在进入 32 位保护模式时都会从 VGA(Video Graphics Array)开始。VGA 能够打印出 80×25 的字符,并且可以给它们设置颜色、样式等。VGA 位于内存 0xb8000
处,每个字符占用 2 个字节,第一个字节是字符的 ASCII 码,第二个字节是字符的颜色样式。
因此,我们只需要向 VGA 内存中写入字符,就可以在屏幕上显示出来。例如:
boot/protected_mode/print.asm
:
[bits 32]
; @param esi: 指向字符串的指针
print_32:
pusha ; 保存寄存器状态
mov edx, VGA_BASE_32 ; 设置显存地址
.print_loop_32:
mov al, [esi] ; 取出 bx 指向的数据
mov ah, WHITE_ON_BLACK_32 ; 设置样式
cmp al, 0 ; 判断是否为字符串结尾
je .print_done_32 ; 如果是,结束循环
mov [edx], ax ; 将 ax 中的数据写入显存
add esi, 1 ; 指向下一个字符
add edx, 2 ; 指向下一个字符的显存位置
jmp .print_loop_32 ; 继续循环
.print_done_32:
popa ; 恢复寄存器状态
ret ; 返回
这个程序每次都会将字符串写到左上角,覆盖之前的字符串。其中,有一些常量,如 VGA_BASE_32
、WHITE_ON_BLACK_32
等,可以在 boot/boot.asm
中定义它们:
VGA_BASE_32 equ 0x000b8000 ; VGA 显示内存地址
VGA_LIMIT_32 equ 80 * 25 * 2 ; VGA 显示内存地址限制
WHITE_ON_BLACK_32 equ 0x0f ; 白色文本,黑色背景
但字符串互相叠着会很难看,我们可以再写一个清空屏幕的函数:
boot/protected_mode/print_clear.asm
:
[bits 32]
print_clear_32:
pusha
mov ebx, VGA_LIMIT_32 ; 显示内存地址限制
mov ecx, VGA_BASE_32 ; 设置显存地址
mov edx, 0 ; 指向当前要写入的位置
.print_clear_loop_32:
cmp edx, ecx ; 判断是否到达显示内存地址限制
jge .print_clear_done_32 ; 如果是,结束循环
push edx
mov al, SPACE_CHAR_32 ; 设置空格字符
mov ah, WHITE_ON_BLACK_32 ; 设置样式
add edx, ecx ; 计算显示内存地址
mov [edx], ax ; 将 ax 中的数据写入显存
pop edx; 恢复 edx
add edx, 2 ; 指向下一个字符的显存位置
jmp .print_clear_loop_32 ; 继续循环
.print_clear_done_32:
popa
ret
SPACE_CHAR_32 equ 0x20 ; 空格字符
当然,现在的这个打印函数还很简陋。但不用管它,我们只需要它打印出必要信息即可。不久之后我们就能用上 C 语言了,没必要在它身上浪费精力。
现在的当务之急是,这个打印函数怎么运行它?
GDT
答案是,运行这个 32 位的打印函数需要先进入 32 位保护模式。
在进入 32 位保护模式之前,我们需要先设置好全局描述符表(GDT, Global Descriptor Table)。GDT 是一个表格,里面存储了段的信息,每个段的信息构成一个 8 字节的段描述符(SD, Segment Descriptor)。段描述符包括:
- 32 位的段基址;
- 20 位的段长;
- 12 位的类型、特权级、段是否存在等信息。
不知道是哪个脑洞大开的人搞出来的,段描述符并不是依次排开的。比如,段基址和段长就被拆分成了好几部分放在段描述符的各个角落。下图就是一个段描述符的结构:
尽管我们可以定义很多段。由于在内核中定义段要方便得多,因此在这里,我们通常只需要定义两个段:一个用于代码,一个用于数据。
对于代码段,除了段基址和段长,这里面有一些 flags,包括:
-
Type
:-
A=0
:是否被访问过(用于 Debug 和虚拟内存); -
R=1
:是否可读(1
= 可以读取其中的常量,0
= 只能执行); -
C=0
:是否可以被更低特权级的代码段调用; -
1
:是否是代码段; -
1
:段类型(0
= 系统段,1
= 代码段或数据段);
-
-
DPL=00
:描述符特权级(00
= 最高特权级,11
= 最低特权级); -
P=1
:段是否真实存在(0
用于虚拟内存); -
AVL=0
:自行定义的位; -
L=0
:是否是 64 位代码段; -
D=1
:默认操作数大小(0
= 16 位,1
= 32 位); -
G=1
:粒度(设置为1
时会将基址左移 12 位,即将基址乘以 4KB)。
数据段和代码段几乎一样,只是 Type
有所不同:
-
Type
:-
A=0
:是否被访问过(用于 Debug 和虚拟内存); -
W=1
:是否可写(1
= 可以写入其中的数据,0
= 只能读取); -
E=0
:扩展方向(1
= 向上,0
= 向下); -
0
:是否是代码段; -
1
:段类型(0
= 系统段,1
= 代码段或数据段)。
-
在写入段描述符之前,我们还需要设置 8 个字节的空描述符。这些描述符是为了让我们在忘记设置基址时,能够捕获到错误。
现在,我们可以照着上面的内容,写一个 GDT 了:
[bits 16]
gdt_start_32:
dd 0x00000000 ; 空描述符(32 bit)
dd 0x00000000 ; 空描述符(32 bit)
; 代码段
gdt_code_32:
dw 0xffff ; 段长 00-15(16 bit)
dw 0x0000 ; 段基址 00-15(16 bit)
db 0x00 ; 段基址16-23(8 bit)
db 0b10011010 ; flags(8 bit)
db 0b11001111 ; flags(4 bit)+ 段长 16-19(4 bit)
db 0x00 ; 段基址 24-31(8 bit)
; 数据段
gdt_data_32:
dw 0xffff ; 段长 00-15(16 bit)
dw 0x0000 ; 段基址 00-15(16 bit)
db 0x00 ; 段基址16-23(8 bit)
db 0b10010010 ; flags(8 bit)
db 0b11001111 ; flags(4 bit)+ 段长 16-19(4 bit)
db 0x00 ; 段基址 24-31(8 bit)
gdt_end_32:
; GDT 描述符
gdt_descriptor_32:
dw gdt_end_32 - gdt_start_32 - 1 ; 比真实长度少 1(16 bit)
dd gdt_start_32 ; 基址(32 bit)
; 常量
CODE_SEG_32 equ gdt_code_32 - gdt_start_32
DATA_SEG_32 equ gdt_data_32 - gdt_start_32
切换
现在,我们已经准备好从 16 位实模式切换到 32 位保护模式了。我们需要做的是:
- 禁用中断。这是因为,BIOS 在 16 位实模式下的中断将不再适用于 32 位保护模式;
- 使用
lgdt
指令加载 GDT; - 将
cr0
寄存器的第 0 位设置为1
,进入保护模式; - 刷掉 CPU 的管道队列,确保接下来不会再去执行实模式的指令。这可以通过执行一个长距离的
jmp
来实现。我们需要将cs
设置为 GDT 的位置; - 更新所有段寄存器,让它们指向数据段;
- 更新栈的位置。
根据以上流程,我们可以写出代码:
[bits 16]
elevate_32:
cli ; 禁用中断
lgdt [gdt_descriptor_32] ; 加载 GDT
mov eax, cr0 ; 将 CR0 寄存器的第 0 位置 1
or eax, 0x1
mov cr0, eax
jmp CODE_SEG_32:.init_pm_32 ; 长距离的 jmp
[bits 32]
.init_pm_32:
mov ax, DATA_SEG_32 ; 更新段寄存器
mov ds, ax
mov ss, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ebp, 0x90000 ; 更新栈位置
mov esp, ebp
call BEGIN_PM_32 ; 去执行接下来的代码
合体
现在,我们可以将所有的代码合并到一起了。这里,我们选择将 32 位的代码放置在磁盘上一个单独的扇区,并在 16 位下通过已经实现的读取磁盘功能加载它:
boot/boot.asm
:
[org 0x7c00]
; 16 位实模式
BEGIN_RM_16:
[bits 16]
mov bp, 0x0500 ; 将栈指针移动到安全位置
mov sp, bp ; 使其向着 256 字节的 BIOS Data Area 增长
mov [BOOT_DRIVE], dl
mov bx, 0x7e00 ; 将数据存储在 512 字节的 Loaded Boot Sector
mov cl, 0x02 ; 从第 2 个扇区开始
mov dh, 1 ; 读取 1 个扇区
mov dl, [BOOT_DRIVE] ; 读取的驱动器号
call disk_load_16 ; 读取磁盘数据
mov bx, MSG_REAL_MODE ; 打印模式信息
call print_16
call elevate_32 ; 进入 32 位保护模式
.boot_hold_16:
jmp $ ; 根本执行不到这里
%include "real_mode/print.asm"
%include "real_mode/disk.asm"
%include "real_mode/gdt.asm"
%include "real_mode/elevate.asm"
BOOT_DRIVE db 0
MSG_REAL_MODE db "Started 16-bit real mode", 0
times 510-($-$$) db 0 ; 填充 0
dw 0xaa55 ; 结束标志
BOOT_SECTOR_EXTENDED_32:
; 32 位保护模式
BEGIN_PM_32:
[bits 32]
call print_clear_32 ; 清屏
mov esi, MSG_PROT_MODE ; 打印模式信息
call print_32
.boot_hold_32:
jmp $
%include "protected_mode/print.asm"
%include "protected_mode/print_clear.asm"
VGA_BASE_32 equ 0x000b8000 ; VGA 显示内存地址
VGA_LIMIT_32 equ 80 * 25 * 2 ; VGA 显示内存地址限制
WHITE_ON_BLACK_32 equ 0x0f ; 白色文本,黑色背景
MSG_PROT_MODE db "Loaded 32-bit protected mode", 0
times 512 - ($ - BOOT_SECTOR_EXTENDED_32) db 0 ; 填充 0
编译运行可以得到:
如果你跟上了节奏的话,当前的文件夹应该是这样的:
MiniOS
└── boot
├── real_mode
│ ├── disk.asm
│ ├── elevate.asm
│ ├── gdt.asm
│ ├── print_hex.asm
│ ├── print_nl.asm
│ └── print.asm
├── protected_mode
│ ├── print_clear.asm
│ └── print.asm
└── boot.asm
每次编译运行还是比较麻烦的,我们可以写两个 shell 来简化工作:
boot/build.sh
:
#!/bin/bash
# 检查 nasm 是否已安装
if ! command -v nasm >/dev/null 2>&1; then
echo "nasm could not be found, please install it first."
exit 1
fi
# 检查源文件是否存在
if [ ! -f "boot.asm" ]; then
echo "Source file boot.asm not found."
exit 1
fi
# 创建输出目录(如果不存在)
mkdir -p dist
# 编译源文件
nasm -f bin boot.asm -o dist/boot.bin
# 检查编译是否成功
if [ $? -eq 0 ]; then
echo "Compilation successful. Output file is located at dist/boot.bin"
else
echo "Compilation failed."
exit 1
fi
debug.sh
:
#!/bin/bash
# 检查 qemu-system-x86_64 是否已安装
if ! command -v qemu-system-x86_64 >/dev/null 2>&1; then
echo "qemu-system-x86_64 could not be found, please install it first."
exit 1
fi
# 检查 dist/boot.bin 文件是否存在
if [ ! -f "dist/boot.bin" ]; then
echo "Boot file dist/boot.bin not found, please run build.sh first."
exit 1
fi
# 使用 qemu-system-x86_64 运行 dist/boot.bin
qemu-system-x86_64 -drive format=raw,file=dist/boot.bin
# 检查 qemu 是否成功启动
if [ $? -eq 0 ]; then
echo "qemu-system-x86_64 successfully started the boot image."
else
echo "Failed to start qemu-system-x86_64 with the boot image."
exit 1
fi
之后,我们每次编写玩程序后,只需要在 boot
文件夹下运行 sh build.sh
编译,然后运行 sh debug.sh
运行即可。
Comments