JoshWindsor 发表于 2023-2-14 12:00

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 -> RAXEBX -> RBXECX -> RCXEDX -> RDXESI -> RSIEDI -> RDIEIP -> RIPEBP -> RBPESP -> RSPEFLAGS -> RFLAGS

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

段寄存器依然存在,但 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 位内存空间被分割成两大部分,如下图所示:

addr-space.png

合法地址空间为 ∪ 。这两段空间中间产生的空洞是不可用的。

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


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)

paging.png

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

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

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

addr-conv.png

如果我们让一个地址依次经历四级页表的转换,最终在 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     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_zerounreal_mode_gdt_zero:    dq 0x0000000000000000unreal_mode_gdt_code:    dq 0x00009a000000ffffunreal_mode_gdt_data:    dq 0x00cf92000000ffffunreal_mode_gdt_end:配置四级页表


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

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

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

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

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

map.jpg

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


通过设置 CR4 寄存器内的相关页,开启 Long Mode 依赖的功能。
; 启用物理地址拓展和全局页表mov eax, cr4or eax, 0b10100000mov cr4, eax设置页目录


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


通过设置 EFER MSR 寄存器的相关位,启用 Long Mode 支持。当然,我们希望同时开启 syscall 支持,也是通过这个寄存器。
; 开启 long mode enable 和 syscallmov ecx, 0xc0000080rdmsror eax, 0x0101 wrmsr设置临时中断向量表和全局描述符表


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

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

等到内核代码加载完毕,我们再认真重新设置这两张表。
lidt lgdt ...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 0align 4gdt_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) ; 提醒编译器后面是处于 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 个参数使用寄存器传递。顺序如下:
RDIRSIRDXRCXR8R9

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

返回值同样采用 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 的支持如下:
模式IntelAMDProtected Modesyscall <br />sysenter syscall <br />sysenter Long Modesyscall <br />sysenter syscall <br />sysenter
为同时支持两家公司的芯片,在 Long Mode 下,需要使用 syscall/sysret 实现系统调用。后续也针对 syscall/sysret 方式展开讨论。
准备工作


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

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

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

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


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

指令执行时,寄存器内数据发生如下变化:
https://math.jianshu.com/math?formula=STAR%5B32%3A47%5D%20%5Crightarrow%20CS%20%5C%5C%20STAR%5B32%3A47%5D%20%2B%208%20%5Crightarrow%20SS%20%5C%5C%20RIP%20%5Crightarrow%20RCX%5C%5C%20LSTAR%20%5Crightarrow%20RIP%20%5C%5C%20RFLAGS%20%5Crightarrow%20R11%20%5C%5C%20RFLAGS%20%5C%20%5C%26%20%5C%20%5Csim%20SFMASK%20%5Crightarrow%20RFLAGS
SYSRET 指令细节


该指令可以有 32 位长和 64 位长多个版本,分别退回到 32 位兼容模式和 Long Mode。我们只讨论 64 位长的版本。
https://math.jianshu.com/math?formula=STAR%5B48%3A63%5D%20%2B%2016%20%5Crightarrow%20CS%20%5C%5C%20STAR%5B48%3A63%5D%20%2B%208%20%5Crightarrow%20SS%20%5C%5C%20R11%20%5Crightarrow%20RFLAGS%20%5C%5C%20RCX%20%5Crightarrow%20RIP
系统调用传参


不难发现,如果我们按照 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) amd64Qemu System X86_64 7.2.0Icelake-Server8GBArch Linux (Linux 6.1.8) amd64Bochs 2.7自行编译,启用64位支持启用 1gb_page128MBWindows 11 Pro amd64VMware Workstation 17 Pro 17.0.0TigerLake H3512GB参考资料

文本材料


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

Intel. Intel 64 and IA-32 Architectures Software Developer's Manual. 2022

OSDev WiKi. https://wiki.osdev.org/

踌躇月光. 操作系统实现

GCC online documentation. https://gcc.gnu.org/onlinedocs/

Using ld. 1994. https://ftp.gnu.org/old-gnu/Manuals/ld-2.9.1/html_mono/ld.html
项目


同济 Unix V6++ OOS

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

Linux 0.0.1

Linux 2.6.39

Linux 5.19.7

glibc 2.36
页: [1]
查看完整版本: X86架构的64位操作系统探索