【操作系统】X86架构的64位操作系统探索

背景

8086 系列芯片的成功带领英特尔 IA-32 指令集架构占据芯片市场的主导地位。20 世纪末,英特尔与惠普一同针对 64 位芯片展开研究,并推出 IA-64 指令集架构。然而,该架构与已经流行的 IA-32 架构不兼容,这导致它的发展受到阻碍。

于此同时,AMD 选择设计一种与当时流行的 IA-32 架构高度兼容的 64 位芯片架构,而不是推翻重做。该架构便是当下个人电脑平台最为流行的 AMD64 架构。

IA-64 惨淡退场后,英特尔参照 AMD64 架构,对原有的 IA-32 架构进行拓展,设计出一套与 AMD64 几乎相同的架构。英特尔称之为 IA-32e。

本文研究内容

本文作者在参考多方文献后,尝试使用 C++ 实现一个简单的 64 位操作系统,运行于较为现代的 x86_64 芯片(模拟器),并对其中与 x86 架构下 32 位操作系统区别较大的部分进行更多学习。

寄存器

一般寄存器

所有的通用寄存器皆被拓展到 32 位。具体如下:

  • EAX -> RAX
  • EBX -> RBX
  • ECX -> RCX
  • EDX -> RDX
  • ESI -> RSI
  • EDI -> RDI
  • EIP -> RIP
  • EBP -> RBP
  • ESP -> RSP
  • EFLAGS -> RFLAGS

同时,引入 8 个新的通用寄存器:

  • R8
  • R9
  • R10
  • R11
  • R12
  • R13
  • R14
  • R15

段寄存器依然存在,但 FS 和 GS 的功能发生很大变化。其中,GS 被 MSR GS Base 取代,具有 64 位的长度。

型号专用寄存器(Model-Specific Registers)

为提供更好的控制,CPU 里引入多个 MSR 寄存器。我们可以在这些寄存器里存储一些值,以便在特殊时候使用。

这些寄存器的字长都是 64 位。

全局描述符

64 位模式,也称 Long Mode。该模式里,分段机制几乎完全失效。对于内核和 64 位应用代码,强制使用平铺模型(Flat Model),即所有段的起始地址皆为 0,皆可直接寻址整个线性内存空间。

Long Mode 里,我们需要设计 5 个描述符,并按照如下顺序连续放置在全局描述符表内:

  • 内核代码描述符
  • 内核数据描述符
  • 用户32位代码描述符
  • 用户数据描述符
  • 用户64位代码描述符

使用 syscall 机制实现系统调用时,CPU 会假设我们的描述符按照上述顺序紧密排列。

地址空间

Long Mode 下,理论寻址位长拓展到 64 位,达到高达 4EB(约 1000000 TB)寻址能力。

但在实际情况下,CPU 的地址线长度并不一定真正达到 64 位,一般只到 50 位左右。以作者的实验环境为例,CPU 提供的物理寻址能力为 45 位,逻辑寻址能力为 48 位,即最高可以寻址 32 TB 物理内存,可以使用 256 TB 逻辑地址。

逻辑寻址能力是 48 位,即一个地址只有低 48 位是有效的。CPU 要求高 16 位的值正好等于第 48 位的值,即高位拓展。

由此,我们的 64 位内存空间被分割成两大部分,如下图所示:

【操作系统】X86架构的64位操作系统探索_第1张图片

合法地址空间为 [0x0000000000000000, 0x00007FFFFFFFFFFF] ∪ [0xFFFF800000000000, 0xFFFFFFFFFFFFFFFF]。这两段空间中间产生的空洞是不可用的。

考虑到用户程序一般运行在较低地址空间,内核一般在高位,空洞前的空间交给用户程序使用,空洞后的高位空间交给内核使用。

分页

Long Mode 需要支持的内存远超过 Protected Mode 的 4GB,仅靠 2 级页表很难支持那么大的内存空间。因此,CPU 使用四级页表(甚至五级)完成分页。

给四个级别起名字是很困难的。我们简单将它们称为:

  • Page Map Level 4 (PML4)
  • Page Map Level 3 (PML3)
  • Page Map Level 2 (PML2)
  • Page Map Level 1 (PML1)

【操作系统】X86架构的64位操作系统探索_第2张图片

最小页大小和 Protected Mode 一样,都是 4KB,占 12 位。将 48 位逻辑地址空间去掉这 12 位,剩下的 36 位平均分配给四个页表等级,即每个页表级别负责转换 9 位地址。

我们知道,在 Protected Mode 里,页目录内的记录不一定指向一张页表。它可以直接指向一个页框。此时,该记录指向的是一个高达 4MB 的大页。

在 Long Mode 里,页大小设计同样十分自由。

【操作系统】X86架构的64位操作系统探索_第3张图片

如果我们让一个地址依次经历四级页表的转换,最终在 PML1 内找到一条指向页框的记录,该页框大小为我们熟悉的 4KB。

如果我们令 PML2 内的地址直接指向页框,则可得到一个大小为 2MB 的页。

如果我们令 PML3 内的地址直接指向页框(需要CPU开启“1G页”功能),则可得到一个高达 1GB 的页框。

作者设计的实验系统借助数千个 1G 页在内核地址空间创建一个对物理内存的直接映射,以便轻松管理物理内存。

进入 Long Mode

完成以下步骤,CPU 将进入 Long Mode:

  • 设置中断描述符表(IDT)
  • 设置全局描述符表(GDT)
  • 开启物理页拓展功能(在 CR4)
  • 开启分页
  • 开启 Long Mode(在 EFER MSR)

当然,我们还要做一些准备工作。

检查 CPU 是否支持 Long Mode

并不是所有芯片都支持 Long Mode(不过都 2023 年了,应该基本都支持吧)。

我们可以借助 CPUID 检测芯片是否支持该模式。此外,如果系统重度依赖 1G 页功能,也要检测该功能是否支持。

check_cpu:

    ;    原理见:
    ;        https://wiki.osdev.org/CPUID
    ;        https://en.wikipedia.org/wiki/CPUID 

    pushfd
    pop eax ; 得到 Flags
    mov ecx, eax
    xor eax, 0x200000 ; 如果不支持 CPUID 指令,这位在 Flags 寄存器里恒为0.
    push eax
    popfd ; 手动设置 Flags 寄存器。

    pushfd
    pop eax ; 重新得到 Flags。如果设置的位被清空了,表明 CPUID 指令不可用。
    xor eax, ecx
    shr eax, 21
    and eax, 1
    push ecx
    popfd

    test eax, eax
    jz .bad_cpu

    mov eax, 0x80000000 ; 获知可查询的功能范围。
    cpuid

    cmp eax, 0x80000001
    jb .bad_cpu

    mov eax, 0x80000001
    cpuid

    ; 检查 cpu 是否支持 long mode
    test edx, 1 << 29 
    jz .bad_cpu
    
    ; 检查 cpu 是否支持 1GB 页。
    test edx, 1 << 26
    jz .bad_cpu

    ret

.bad_cpu:
    jmp error

虚模式(Unreal Mode)

我们的一级启动引导程序最大只有 512 字节。这个容量太小了,难以完成整个启动引导。因此,我们需要用它加载一个二级启动程序。

我们不希望在二级启动程序里重写一遍读硬盘的代码,因此需要在一级启动引导程序内完成对内核代码的加载。

实模式下,CPU 不让我们访问 1M 以上的内存,除非进入 Protected Mode 或 Long Mode。但是,我们并不希望进入 Protected Mode,也需要在 Long Mode 前完成内核代码的拷贝。因此,我们借助一个中间状态完成工作,即虚模式(unreal mode)。

为达到目的,我们先短暂启用 Protected Mode,通过 far jump 刷新 CPU 内关于界限的寄存器后,立刻退回到实模式。此时,CPU 回到实模式工作,但 1MB 内存界限消失,我们的操作变得很自由。

    ; 进入 unreal mode (big real mode)
    push ds
    lgdt [unreal_mode_gdt_pointer]

    mov eax, cr0
    or al, 1
    mov cr0, eax
    jmp 0x8:.temporary_protected_mode

.temporary_protected_mode:
    mov bx, 0x10
    mov ds, bx

    and al, 0xfe
    mov cr0, eax
    jmp 0:.unreal_mode

.unreal_mode:
    pop ds
    
...

unreal_mode_gdt_pointer:
    dw unreal_mode_gdt_end - unreal_mode_gdt_zero - 1
    dd unreal_mode_gdt_zero
unreal_mode_gdt_zero:
    dq 0x0000000000000000
unreal_mode_gdt_code:
    dq 0x00009a000000ffff
unreal_mode_gdt_data:
    dq 0x00cf92000000ffff
unreal_mode_gdt_end:

配置四级页表

Unix V6++ 系统在启用分页之前,借助分段机制将内核“推”到高位。该手段在 Long Mode 不可用。

作者的系统中,首先使用一个 2M 页,将线性地址的前 2MB 和物理内存的前 2MB 直接映射,保障启动引导程序工作正常。之后,通过多个 2MB 页,将物理内存的前 16MB 映射到线性空间的内核区域,供内核二进制代码和内核栈使用。

借助该页表,即可支持 Long Mode 的初始运行。

进入 Long Mode 后,借助 64 位寄存器,将 32T 物理内存空间完整映射到内核地址区,令内核可以自由控制任意位置的内存。

当然,成功进入 Long Mode 后,需要取消前 2MB 的直接映射。

【操作系统】X86架构的64位操作系统探索_第4张图片

开启全局页表和物理内存拓展

通过设置 CR4 寄存器内的相关页,开启 Long Mode 依赖的功能。

; 启用物理地址拓展和全局页表
mov eax, cr4
or eax, 0b10100000
mov cr4, eax

设置页目录

这一步我们很熟悉。将页目录(PML4)地址放置到 CR3 寄存器即可。

启用 Long Mode 支持

通过设置 EFER MSR 寄存器的相关位,启用 Long Mode 支持。当然,我们希望同时开启 syscall 支持,也是通过这个寄存器。

; 开启 long mode enable 和 syscall
mov ecx, 0xc0000080
rdmsr
or eax, 0x0101 
wrmsr

设置临时中断向量表和全局描述符表

既然是临时的,就不要太在意了。

直接设置一个空的中断向量表。对于全局描述符表,只设置内核的,不要管用户的。

等到内核代码加载完毕,我们再认真重新设置这两张表。

lidt [empty_idt]
lgdt [gdt_pointer]

...

align 4 ; 4 字节对齐
empty_idt:
    .length dw 0
    .base dd 0
    
gdt_pointer:
    dw (gdt_end - gdt_base) - 1 ; limit
    
    dd gdt_base ; base
    dd 0

align 4

gdt_base:
    dq 0

; 代码段。
gdt_code:

    ; (idx)    : 1
    ; limit    : 0
    ; base     : 0
    ; access   : 0
    ; rw       : 1
    ; dc       : 0
    ; exec     : 1
    ; descType : code/data
    ; privi lv : 0
    ; present  : 1
    ; longMode : 1
    ; sizeFlag : 16 bits
    ; granular : 1 B

    dq 0x00209a0000000000
    
gdt_data:

    ; (idx)    : 2
    ; limit    : 0
    ; base     : 0
    ; access   : 0
    ; rw       : 1
    ; dc       : 0
    ; exec     : 0
    ; descType : code/data
    ; privi lv : 0
    ; present  : 1
    ; longMode : 0
    ; sizeFlag : 16 bits
    ; granular : 1 B

    dq 0x0000920000000000
    

gdt_end:

同时开启分页和保护模式

同时开启分页和保护模式,再进行一个 Long Jump 刷新缓存。基于之前的准备工作,CPU 将进入 Long Mode。

    mov ebx, cr0
    or ebx, 0x80000001
    mov cr0, ebx
    jmp code_selector:kernel_loader

; 选择子。
code_selector equ (1 << 3)
data_selector equ (2 << 3)

[bits 64] ; 提醒编译器后面是处于 64 位模式的代码。

kernel_loader:

    ; 初始化段寄存器。
    mov ax, data_selector

    mov ds, ax
    mov es, ax
    mov ss, ax

    mov ax, 0
    mov fs, ax
    mov gs, ax

    mov rsp, 0xFFFF_C000_0000_0000

完整代码

一级启动程序:YurongOS/boot.asm at master · FlowerBlackG/YurongOS · GitHub

二级启动程序:YurongOS/kernel_loader.asm at master · FlowerBlackG/YurongOS · GitHub

ABI

遵循 System V ABI,函数调用的前 6 个参数使用寄存器传递。顺序如下:

  • RDI
  • RSI
  • RDX
  • RCX
  • R8
  • R9

被调用者负责保存以下寄存器:

  • RBX
  • RSP
  • RBP
  • R12
  • R13
  • R14
  • R15

返回值同样采用 a 寄存器返回,即 RAX。

系统调用

在实模式和 Protected Mode 里,人们喜欢使用软中断实现系统调用。如,dos 系统使用 int 21h 代表系统调用,unix 系列系统使用 int 80h 作为系统调用入口。

然而,软中断的效率相对较低。在面对某些任务时,int 80h 软中断带来的开销导致 2GHz 的奔腾4处理器性能不如 850MHz 的奔腾三。

AMD 实现的 syscall/sysret 和 Intel 实现的 sysenter/sysexit 可以实现快速进入和退出内核态,实现系统调用。

目前,AMD 和 Intel 芯片对 syscall 和 sysenter 的支持如下:

模式 Intel AMD
Protected Mode syscall ❌
sysenter ✅
syscall ❌
sysenter ✅
Long Mode syscall ✅
sysenter ✅
syscall ✅
sysenter ❌

为同时支持两家公司的芯片,在 Long Mode 下,需要使用 syscall/sysret 实现系统调用。后续也针对 syscall/sysret 方式展开讨论。

准备工作

首先,我们需要设置几个 MSR。

LSTAR 寄存器负责存储系统调用入口。即,syscall 指令执行时,LSTAR 内的值会被加载到 RIP 寄存器。

在 SFMASK 内设置的位,将在 syscall 执行时,用于清空 Rflags 寄存器对应位的值。

STAR 寄存器的最高 16 位存储用户态32位代码的段选择子,第 32 到 47 位存储内核代码选择子。

SYSCALL 指令细节

该指令不应在内核态触发。

指令执行时,寄存器内数据发生如下变化:
S T A R [ 32 : 47 ] → C S S T A R [ 32 : 47 ] + 8 → S S R I P → R C X L S T A R → R I P R F L A G S → R 11 R F L A G S   &   ∼ S F M A S K → R F L A G S STAR[32:47] \rightarrow CS \\ STAR[32:47] + 8 \rightarrow SS \\ RIP \rightarrow RCX\\ LSTAR \rightarrow RIP \\ RFLAGS \rightarrow R11 \\ RFLAGS \ \& \ \sim SFMASK \rightarrow RFLAGS STAR[32:47]CSSTAR[32:47]+8SSRIPRCXLSTARRIPRFLAGSR11RFLAGS & SFMASKRFLAGS

SYSRET 指令细节

该指令可以有 32 位长和 64 位长多个版本,分别退回到 32 位兼容模式和 Long Mode。我们只讨论 64 位长的版本。
S T A R [ 48 : 63 ] + 16 → C S S T A R [ 48 : 63 ] + 8 → S S R 11 → R F L A G S R C X → R I P STAR[48:63] + 16 \rightarrow CS \\ STAR[48:63] + 8 \rightarrow SS \\ R11 \rightarrow RFLAGS \\ RCX \rightarrow RIP STAR[48:63]+16CSSTAR[48:63]+8SSR11RFLAGSRCXRIP

系统调用传参

不难发现,如果我们按照 System V ABI 传递参数,RCX 寄存器存储的值会被原本的 RIP 覆盖。因此,Linux 系统选择将 RCX 内的值用 R10 寄存器传递。

问题:进入核心栈?

中断到来时,借助 TSS,我们可以直接进入核心栈。但 syscall 和 sysret 并不会帮我们做栈的切换工作。

此时,我们遇到一个问题:当前所在的栈是用户栈,当前已经没有任何可以直接使用的寄存器了(它们不是存储了关键数据,就是要求被调方保存后才可用)。在作者的实验系统中,直接将寄存器存放到用户栈是很危险的,如果恰好抵达栈顶,缺页异常将在内核态被调用,带来不可收拾的后果。这是一个很棘手的问题。

Linux 系统的“每核心”数据和 Swapgs 指令

Linux 系统定义了一些每个CPU核心都独享一个拷贝的变量,并放置到二进制文件的一个特殊位置。

CPU 为我们提供两个额外的 MSR,分别是 GS Base 和 Kernel GS Base。GS Base 是仅剩的两个依旧可以通过段偏移得到地址的寄存器之一,且具有 64 位。通过 swapgs 指令可以交换这两个寄存器的值。

我们将单个核心的独享数据位置存放到 Kernel GS Base 寄存器,在进入核心态时,用 swapgs 指令获得它,并在回到用户态前,用 swapgs 将其放回 Kernel GS Base。

作者的实验系统中也做了一个类似结构,称之为 Per-Cpu Cargo。令 Kernel GS Base 指向核心自己的专属数据(Cargo)。

Syscall 入口

作者在实验系统的 Cargo 内设置一个 64 字节大小的数据暂存区,暂存区后放置指向当前 Task 结构的指针,后者内包含该进程内核栈的栈顶指针。

系统调用到来时,先将 R12 和 R13 寄存器存放到暂存区,再用 R12 多次寻址,找到核心栈地址。将该地址加载入 RSP,保存好其他寄存器的值,便可跳转入系统调用处理器。

void __omit_frame_pointer entrance() {

    x86asmSwapgs();

    __asm (
        "movq %%r12, %%gs:8 \n\t" // 暂存 r12
        "movq %%r13, %%gs:16 \n\t" // 暂存 r13

        "movq %%rsp, %%r12 \n\t"
        "movq %1, %%r13 \n\t"
        "cmpq %%r12, %%r13 \n\t"

        "jg _ZN10SystemCall8entranceEv.saveRegisters \n\t" // 跳转条件:已经在核心栈。

        "movq %%gs:0, %%r12 \n\t" // Cargo.self => r12
        "addq %0, %%r12 \n\t" // 令 r12 指向 currentTask 指针
        "movq (%%r12), %%r12 \n\t" // 核心栈 rsp => r12
        "xchgq %%r12, %%rsp \n\t" // 进入核心栈

        "_ZN10SystemCall8entranceEv.saveRegisters: \n\t"

        "pushq %%r12 \n\t" // old rsp
        "movq %%gs:8, %%r12 \n\t"
        "pushq %%r12 \n\t" // old r12
        "movq %%gs:16, %%r12 \n\t"
        "pushq %%r12 \n\t" // old r13
        "pushq %%r11 \n\t"
        "pushq %%rcx \n\t"
        "movq %%r10, %%rcx \n\t" 
        "pushq %%rbx \n\t"
        "movq %%ds, %%r12 \n\t"
        "pushq %%r12 \n\t"
        "movq %%es, %%r12 \n\t"
        "pushq %%r12 \n\t"

        :
        :
        "i" (offsetof(PerCpuCargo, currentTask)),
        "i" (MemoryManager::ADDRESS_OF_PHYSICAL_MEMORY_MAP)
    );

    x86asmLoadKernelDataSegments();

    __asm ("movq %rax, %rbx");

    __asm (
        "xchgq %%rax, %%rbx \n\t"
        "pushq %%rdx \n\t"
        "movq %0, %%r12 \n\t"
        "mulq %%r12 \n\t"
        "popq %%rdx \n\t"
        "addq %%rbx, %%rax \n\t"
        "movq (%%rax), %%rax \n\t"
        "call *%%rax \n\t"

        :
        : 
        "i" (sizeof(void*)),
        "a" (handlers)
    );

    __asm (
        "popq %%r12 \n\t"
        "movq %%r12, %%es \n\t"
        "popq %%r12 \n\t"
        "movq %%r12, %%ds \n\t"

        "popq %%rbx \n\t"
        "popq %%rcx \n\t"
        "popq %%r11 \n\t"
        "popq %%r13 \n\t"

        // 要恢复 task 结构内存储的栈顶指针。
        "movq %%gs:0, %%r12 \n\t"
        "addq %0, %%r12 \n\t"

        "addq $16, %%rsp \n\t"
        "movq %%rsp ,(%%r12) \n\t"
        "subq $16, %%rsp \n\t"

        "popq %%r12 \n\t"
        "popq %%rsp \n\t"

        :
        : 
        "i" (offsetof(PerCpuCargo, currentTask))
    );

    x86asmSwapgs();
    x86asmSti();
    x86asmSysretq();
}

系统调用处理完毕,用类似的方法恢复环境即可。

由于 syscall 指令的触发是可预测的(相比之下,时钟中断等是不可预测的。不知道执行到哪里时,会突然打断你)。我们可以认为,调用者已经规范地保存好该由它保存的寄存器,而处理函数处不需要保存自己没使用到的寄存器,使得上下文保存恢复过程也十分简短。

参考实验系统

仓库

GitHub - FlowerBlackG/YurongOS: 一个简陋的 x86-64 操作系统。

参考开发环境

OS: Arch Linux

Kernel: GNU/Linux 6.1.4

Graphics: X11

GCC: 12.2.0

NASM: 2.15.05

GNU Make: 4.3

参考虚拟机环境

主机环境 虚拟化平台 虚拟化平台备注 芯片设置 内存大小
Arch Linux (Linux 6.1.8) amd64 Qemu System X86_64 7.2.0 Icelake-Server 8GB
Arch Linux (Linux 6.1.8) amd64 Bochs 2.7 自行编译,启用64位支持 启用 1gb_page 128MB
Windows 11 Pro amd64 VMware® Workstation 17 Pro 17.0.0 TigerLake H35 12GB

参考资料

文本材料

[1] 同济大学. 操作系统原理(讲义). 同济大学, 2019

[2] Intel. Intel® 64 and IA-32 Architectures Software Developer’s Manual. 2022

[3] OSDev WiKi. https://wiki.osdev.org/

[4] 踌躇月光. 操作系统实现

[5] GCC online documentation. https://gcc.gnu.org/onlinedocs/

[6] Using ld. 1994. https://ftp.gnu.org/old-gnu/Manuals/ld-2.9.1/html_mono/ld.html

项目

[1] 同济 Unix V6++ OOS

[2] 踌躇月光.onix. https://github.com/StevenBaby/onix

[3] Linux 0.0.1

[4] Linux 2.6.39

[5] Linux 5.19.7

[6] glibc 2.36

你可能感兴趣的:(C/C++学习笔记,操作系统,系统架构,c++,linux)