Ch3nyang's blog

home

home

person

about

hive

project

collections_bookmark

mindclip

rss_feed

rss

MiniOS64 | (2)
32位的世界

calendar_month 2024-07
archive 系统
tag minios tag os tag bootloader tag asm

There are 4 posts in series MiniOS64.

32 位的世界

16 位实模式

我们之前一直在 16 位下工作,这是因为计算机是充满妥协的,当你设计出一个新东西的时候,总是要考虑向下兼容。

对于刚开始启动的计算机来讲,当然不知道操作系统是多少位的。为了兼容,它只能先进入一个 16 位的模式——16 位实模式。实模式是 Intel 8086 处理器的一种工作模式,在实模式下,CPU 只能访问 1MB 的内存,而且只能使用 16 位的寄存器。

我们刚刚就一直在实模式下工作。但是,实模式有着很多缺点。因此,我们在实模式下读取到一些硬盘数据后,便可以顺势进入 32 位保护模式,享受到更大的内存、更多更大的寄存器、更丰富的功能。具体来讲有以下几点:

  • 寄存器全部变为 32 位,我们向之前的寄存器名称前添加 e 来表明这一点,例如 eaxebx
  • 增加了 2 个新的段寄存器 fsgs
  • 内存偏移增加至 32 位,因此我们可以访问到 4 GB 的内存;
  • 支持了虚拟内存、内存保护等功能。

当然,在这之后,还可以进入 64 位长模式,我们后文会具体介绍。但现在,让我们先从 32 位保护模式开始。

要想从 16 位实模式进入 32 位保护模式,有几项必须完成的步骤:

  1. 禁用中断;
  2. 设置并加载 GDT;
  3. 设置 CR0 寄存器的指示位;
  4. 更新各寄存器。

这些步骤我们会逐一介绍。

事实上,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_32WHITE_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 位的类型、特权级、段是否存在等信息。

不知道是哪个脑洞大开的人搞出来的,段描述符并不是依次排开的。比如,段基址和段长就被拆分成了好几部分放在段描述符的各个角落。下图就是一个段描述符的结构

SD 结构

尽管我们可以定义很多段。由于在内核中定义段要方便得多,因此在这里,我们通常只需要定义两个段:一个用于代码,一个用于数据。

对于代码段,除了段基址和段长,这里面有一些 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 了:

boot/real_mode/gdt.asm

[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 位保护模式了。我们需要做的是:

  1. 禁用中断。这是因为,BIOS 在 16 位实模式下的中断将不再适用于 32 位保护模式;
  2. 使用 lgdt 指令加载 GDT;
  3. cr0 寄存器的第 0 位设置为 1,进入保护模式;
  4. 刷掉 CPU 的管道队列,确保接下来不会再去执行实模式的指令。这可以通过执行一个长距离的 jmp 来实现。我们需要将 cs 设置为 GDT 的位置;
  5. 更新所有段寄存器,让它们指向数据段;
  6. 更新栈的位置。

根据以上流程,我们可以写出代码:

boot/real_mode/elevate.asm

[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

Share This Post