第二章 内核,启动! - 从零开始开发UEFI引导的64位操作系统内核

声明:此文章为本人在知乎首发的原创文章


  • 与本文同时编写的内核项目现已开源至github

  • 注:本章全部内容仅适用于x86架构,其它架构请读者自行阅读对应的文档。

一、简单启动一下

创建一个汇编代码文件,可以是entry32.s等文件名。
首先给multiboot2头创建一个.multiboot2段,方便安排multiboot2头的位置:

    section .multiboot2 align=8
    MULTIBOOT2_HEADER_MAGIC    equ 0xe85250d6
    MULTIBOOT2_HEADER_LENGTH   equ multiboot2_header_end - multiboot2_header
multiboot2_header:
    dd MULTIBOOT2_HEADER_MAGIC
    dd 0
    dd MULTIBOOT2_HEADER_LENGTH
    dd - (MULTIBOOT2_HEADER_MAGIC + MULTIBOOT2_HEADER_LENGTH)
    end_tag:
        dd 0
        dd 8
multiboot2_header_end:

根据上一章所述的multiboot2头定义,前4个双字分别为魔数架构头的长度校验码,接下来是一系列标签,我们暂时先只写一个结束标签。
接着创建一个.entry段,其中应该有从内核被引导开始到进入内核主程序之前的所有代码。
虽然我们使用的是64位UEFI启动,但是由于内存管理的硬件支持需要在32位模式下配置,所以内核被引导时会切换回32位模式。因此有一部分程序需要使用32位汇编,有一部分需要使用64位汇编;汇编代码也分别在两个源文件中编写。

    section .entry
init32:
    jmp $

首先写一段死循环代码,按照前一章的链接脚本,这个init32就是内核程序的入口点。$代表本指令所在的内存地址。
然后可以使用nasm汇编器将其汇编成可重定位文件:

nasm -f elf32 -o entry32.o entry32.s

由于内核整体上是64位的,又因为链接脚本指定的是64位的,因此我们需要将这个elf32格式的目标文件直接转换成elf64格式:

objcopy -I elf32-i386 -O elf64-x86-64 entry32.o entry32.o

接着根据上一章所述的链接脚本kernel.lds将这个可重定位文件链接成elf64可执行文件:

ld -T=kernel.lds -o kernel.elf entry32.o

这样就生成了可以直接被grub加载的内核文件kernel.elf
根据上一章的内容,将可引导磁盘镜像挂载,将内核文件移动到指定的位置,卸载磁盘镜像并用qemu运行。
等grub引导了内核后,打开compatmonitor0使用info registers查看eip寄存器:可以看到eip寄存器的值已经是在链接脚本中设置好的地址了可以看到eip寄存器的值已经是在链接脚本中设置好的地址了。


二、模式切换

有关x86架构的模式切换,不得不提到英特尔白皮书第三卷中的处理器模式切换示意图:处理器模式切换图当我们的内核被引导时,正处于Protected Mode下,即通常说的32位模式。
虽然UEFI和grub都是64位的,在引导的过程中也确实处于64位模式(即图中的IA-32e模式),但是作为一个内核,我们首先要在32位模式下设置好内存的分段和分页数据,然后切换到64位模式。
根据从保护模式切换到ia32e模式的注解,在白皮书第三卷9.8.5章节有详细的描述:保护模式到ia32e模式切换流程经过图中所描述的操作后,处理器的状态还没有更新,只要经过一个远跳转即可将设置好的状态更新,进入64位模式。
在这之前,我们首先需要将分段机制设置好,其次,在远跳转时,就已经使用了64位地址,因此也要把分页机制一并设置。

1. 设置分段机制

程序中的段不是这里所说的段,作为区分,在本节中写作程序段

首先,在程序段.cpumeta中创建一个GDT(全局描述符表),其中可以包括多个段描述符(GDT中除了段描述符还可以包括其它描述符)。

    section .cpumeta
gdt:
    dq  0
    dq  0x0020980000000000   ; 内核态代码段
    dq  0x0000920000000000   ; 内核态数据段
    dq  0x0020f80000000000   ; 用户态代码段
    dq  0x0000f20000000000   ; 用户态数据段
gdt_end:

gdt_ptr:
    dw  gdt_end - gdt - 1
    dq  gdt

在gdt中,第0项是保留项,不可以作为任何一个描述符,我们分别定义了4个描述符。
段描述符根据白皮书的描述,四个段描述符都描述了一个起始地址为0,段限长为0的段;
DPL决定了这个段是用户态(DPL=3)还是内核态(DPL=0),
type决定了段的读写和执行的权限,
其它项的含义就很明确了。
我们可以发现,起始地址和段限长都是0,这是因为amd在开发ia32e模式时,引入了平坦内存模型;
由于现代操作系统都使用分页作为基本的内存管理功能,分段机制非常多余,因此平坦内存模型规定,所有的段都必须将起始地址和段限长设为0,而内存访问时会直接忽略段限长,这样分段机制映射出来的逻辑地址空间就与线性地址空间完全相同。

逻辑地址、线性地址与物理地址:物理地址直接对应了实际上的内存空间,线性地址经过分页机制会转换为物理地址,逻辑地址经过分段机制会转换为线性地址。

接下来的gdt_ptr定义了一个gdt描述符,将会被加载到gdtr寄存器中,其内容分别是gdt表的长度和起始地址。
然后开始在init32中编写加载gdt的代码:

    ; 设置gdt_ptr
    mov eax, 0x10402a   ; gdt_ptr + 2
    mov dword [eax], 0x104000   ; gdt
    ; 加载GDTR和段寄存器
    db 0x66
    lgdt [0x104028]     ; gdt_ptr
    mov ax, 0x10
    mov ds, ax
    mov ss, ax
    mov es, ax
    mov fs, ax
    mov gs, ax

这段代码首先为gdt_ptr写入正确的值<1>,并用临时64位指令前缀0x66后加lgdt指令,将64位GDT加载到gdtr中。
然后将0x10写入除cs之外的段寄存器中。
在注释中我标出了地址常数对应的符号名,这些常数的值取决于你的链接脚本如何安排这些符号的位置,可以先将它们置为0,并将生成的内核文件反编译,查看这些符号的地址,例如在我的项目中,使用如下命令:

objdump -D src/metaverse.elf

可以看到反编译结果(截取gdtgdt_ptr的部分,由于gdt_endgdt_ptr地址相同,gdt_end覆盖了gdt_ptr):

0000000000104000 <gdt>:
        ...
  10400c:       00 98 20 00 00 00       add    %bl,0x20(%rax)
  104012:       00 00                   add    %al,(%rax)
  104014:       00 92 00 00 00 00       add    %dl,0x0(%rdx)
  10401a:       00 00                   add    %al,(%rax)
  10401c:       00 f8                   add    %bh,%al
  10401e:       20 00                   and    %al,(%rax)
  104020:       00 00                   add    %al,(%rax)
  104022:       00 00                   add    %al,(%rax)
  104024:       00 f2                   add    %dh,%dl
        ...

0000000000104028 <gdt_end>:
  104028:       27                      (bad)
  104029:       00 00                   add    %al,(%rax)
  10402b:       10 10                   adc    %dl,(%rax)
  10402d:       00 00                   add    %al,(%rax)
  10402f:       00 00                   add    %al,(%rax)

然后可以看到,我们向除了cs寄存器之外的所有段寄存器写入了0x10,这就是段选择子: 段选择子平坦内存模型要求总是使用GDT,因此TI总是为0,而我们在开始创建进程前都处于内核态,所以RPL为0。
高13位是此段寄存器所使用的段选择子的索引,根据前面段描述符的定义,第1项为代码段,第2项为数据段,应为cs寄存器使用第一项,为其余的段寄存器使用第二项。因此段选择子在内核态中,总是所选择的段描述符在gdt中的偏移量。
cs寄存器不可以使用指令直接设置,需要通过远跳转指令间接设置,在切换到64位模式的远跳转时趁机设置就好了。
cs寄存器名为代码段寄存器,在读取指令时,处理器访问代码段寄存器确定应该使用哪个段访问代码,将段起始地址与ip寄存器(eip/rip)相加,作为线性地址,读取这个线性地址处的指令。
其它的段寄存器用于各种不同的用途,但肯定不是读取指令,因此其它的寄存器都使用数据段。

  • 坑点

ld链接器会在链接时将以下内容替换:

gdt_ptr:
    dw  gdt_end - gdt - 1
    dq  gdt

由于我们使用了objcopy命令将生成的elf32格式可重定位文件直接转换成elf64位格式,虽然都是elf,但是32位和64位多多少少有些区别,所以链接器会错误地将此处的gdt替换成另外一个常数而不是gdt的地址;这就是前文要在写入gdtr前为gdt_ptr写入正确的值的原因。后文也有类似的情况发生,将不再作出解释。

2. 设置分页机制

首先罗列英特尔白皮书中有关线性地址、多级页表等有关数据结构的示意图:4级页表下的线性地址,以及线性地址的寻址方式;2MB页、1GB页的寻址方式类似,只是更高的9位也用作Offset而已;
cr3寄存器以及4、5级分页页表项格式;详细内容见白皮书第三卷4.5中的表4-14至4-20。

在4级分页中,cr3储存PML4 table,而PML4 table中的每个表项都指向下一级页表;下一级页表中的表项指向再下一级的页表或页框起始地址;以此类推。
另外,页表项的低12位被设为页表属性的各种标志位,因此所有页表的起始地址必须4096字节对齐<2>,这样,在访问页表时,CPU直接将低12位置为0即可得到下一级页表的起始地址。
首先,在程序段.cpumeta中写出一套分页的数据结构,写在程序段开头,这样这三个符号就都是4096字节对齐了:

    ; 分页
PML4:
    dq  0x003 + PDPT0
    resq 511

PDPT0:
    dq  0x003 + PD0
    resq 511

PD0:
    resq 512

我暂时将物理地址前128MB直接映射为线性地址的前128MB,用64个2MB页来完成,因此只写到了PD,没有写到PT
接下来还是使用汇编指令写入正确内容环节:

    ; 设置PML4的第0项
    mov eax, 0x102000   ; PDPT0
    add eax, 0x003
    mov edi, 0x101000   ; PML4
    mov dword [edi], eax
    add edi, 4
    mov dword [edi], 0

    ; 设置PDPT的第0项
    mov eax, 0x103000   ; PD0
    add eax, 0x003
    mov edi, 0x102000   ; PDPT0
    mov dword [edi], eax
    add edi, 4
    mov dword [edi], 0

    ; 设置PD0中的PDE
    mov ecx, 64
    mov eax, 0
    mov edi, 0x103000   ; PD0
    init32_loop0:
        mov edx, eax
        add edx, 0x183
        mov dword [edi], edx
        add eax, 0x400000   ; 2MB
        add edi, 4
        mov dword [edi], 0
        add edi, 4
    loop init32_loop0

然后将PAE(物理地址扩展)打开:

    ; 打开PAE
    mov eax, cr4
    bts eax, 5
    mov cr4, eax

然后将PML4的地址写入cr3中:

    ; 加载cr3
    mov eax, 0x101000   ; PML4
    mov cr3, eax

3. 切换至ia32e模式并跳转至64位代码

    ; 切换ia32e模式
    mov ecx, 0xc0000080 ; ia32_efer在msr中的地址
    rdmsr
    bts eax, 8
    wrmsr

    ; 打开保护模式和分页机制
    mov eax, cr0
    bts eax, 0
    bts eax, 31
    mov cr0, eax

    ; 远跳转
    jmp 0x8:init64

在远跳转之前,CPU依然处于32位模式,不会出现在64位模式执行32位代码的现象。
在这个远跳转指令中,0x8将被写入cs寄存器,即GDT中第1个段描述符。
接下来创建另一个源文件entry.s:

    section .entry  align=8
    global init64
init64:
    endbr64
    cli
    jmp $

这段代码依然在.entry段中,但是这个源文件会以elf64格式生成可重定位文件:

nasm -f elf64 -o entry.o entry.s

并与转换为elf64格式的entry32.o一同链接。
entry32.s的程序段.entry的段声明后写一行extern init64使远跳转命令能使用它的地址。
将链接后的内核放进虚拟磁盘中用qemu运行,打开视图compatmonitor0,输入命令info registers查看寄存器:info registers命令我们可以通过objdump命令反编译内核文件,其中符号init64的代码如下:

00000000001000b8 <init64>:
  1000b8:       f3 0f 1e fa             endbr64
  1000bc:       fa                      cli
  1000bd:       eb fe                   jmp    1000bd <init64+0x5>

可以看出rip应该最终处于0x1000bd,qemu的运行结果正确。
各个段寄存器中储存的段选择子以及段限长也被正确地设置。
gdtrcr3寄存器的值也是正确的。


经过本章内容,我们的内核终于进入了64位模式,进入64位模式后,我们依旧需要设置许多CPU数据结构;下一章,我们会跳过这部分内容,设置好multiboot2头之后直接进入内核主程序。
而跳过的这部分内容将会在恰当的时机补回来。


与本文同时编写的内核项目现已开源至github


参考:

  1. 会产生疑问的吧?后文会解释的
  2. 即它的起始地址必须是4096的整数倍
  • 18
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
随着国家十四五新战略规划的推出,众多国内企业都参与到国产芯片替代浪潮中来,可以预测未来越多的国产芯片会被设计、生产和使用在我们日常所使用的电子产品中,国产芯片拥有巨大的市场前景。 目前国产芯片采用的体系架构主要有X86、ARM、MIPS、RISC V、PowerPC、Alpha等。我们知道电子产品正常工作必须要有操作系统和各种应用软件,没有操作系统和应用软件的芯片就是一堆废铁,而大多数人并不知道的是没有系统固件来加载操作系统的电脑亦是一堆废铁, UEFI就是由UEFI行业协会提出和维护一种行业标准的系统固件,它支持目前市面上的大多数芯片体系结构和操作系统,随着标准的不断演进相信越来越多的体系结构的芯片和操作系统会被支持。 笔者从事BIOS开发已有十余年的时间,见证了Legacy BIOS辉煌与隐退,也有幸了参与了新世纪初系统固件从Legacy BIOS往UEFI BIOS的迁移的全过程。科技行业风起云涌新技术新架构日新月异,每每回望不禁感慨我辈可谓是“眼见着他起高楼,眼见着他宴宾客”的那一波BIOS人。曾经系统固件江湖还是Legacy BIOS的天下,BIOS人使用汇编语言编码、通过中断来与操作系统沟通。自UEFI框架被广泛使以来开我们的发环境从纯汇编变成了99%的C语言加1%的汇编语言的模式,开发效率大大的加强了。 虽然UEFI框架大大加快了开发效率,但是由于系统固件开发属于比较偏门和专业的领域,学习和入门门槛比较高,现有的BIOS工程师又分布在大大小小的各个公司内部缺乏有效沟通和交流,同时BIOS源码又属于敏感和机密数据受到各种NDA限制,市面上对UEFI框架介绍的资料少之又少,因此笔者从2000左右开始就陆续以Cstyle_0x007为ID在https://blog.csdn.net/CStyle_0x007发布一系列博文,现已有数十篇原创文章。刚开始的想法是把博文当作工作笔记方便自己随时查阅,后来慢慢发展成了与业内外感兴趣的朋友的沟通交流的平台。 随手写的博文难免有错误与纰漏为了避免误导大众,准备把博文重新整理在纠正谬误同时也会补充一些新的内容,尽量做到所写的每句话都是无误的,也欢迎有兴趣的朋友踊跃提出意见和建议。组建了微信公众号,目的在于方便有兴趣的朋友一起交流,名字初步定为“固件C字营”,其中“固件”泛指一切固化的软件,这里主要指UEFI BIOS系统固件,“C”泛指“China“,我们可以把这里当作大家沟通交流的营地,我们会不定时发布一些行业资讯、工作、学习心得,感兴趣扫描下面二维码就可以加入,也可以发邮件到CstyleFirmWareCamp@outlook.com投稿分享你的想法。 本文取名《UEFI内核的导读》这里的UEFI专指“UEFI BIOS”,全文专注于对UEFI内核的梳理与分享,同时兼顾对X86系统固件生态中常用的工程技术的介绍,主要包含以下内容:UEFI启动流程以及各个阶段主要完成的任务及参考的实现方式导读UEFI及PI规范中的常见Protocol的实现与使用技巧UEFI固件生态中常见外设、总线、行业标准的协议内容及使用方法 雄关漫道真如铁,而今迈步从头越,系统固件雄起之路道阻且长,相信我们的BIOS人一定可以为国产芯片的起飞助力、为系统固件团队的壮大贡献自己的一份微薄之力,为每一个不畏艰难、不惧寂寞坚守在工作岗位的BIOS人加油,好样的。
openwrt-gdq-winter[2022]-x86-64-generic-squashfs-uefi 是一个基于OpenWrt项目的操作系统镜像文件,适用于x86-64架构的通用硬件,并且支持Squashfs文件系统和UEFI引导方式。 OpenWrt是一个基于Linux的嵌入式操作系统,专注于路由器和其他网络设备的嵌入式解决方案。它具有强大的定制能力和高度灵活性,可以通过安装软件包来扩展功能,支持各种网络协议和安全功能。 该镜像文件名称中的"gdq-winter[2022]"表示这是为2022年冬季设计的版本。它可能包含了特定的功能或者进行了一些优化,以应对寒冷环境下硬件的需求。 x86-64是一种计算机处理器架构,适用于大多数个人电脑和服务器。该镜像文件适用于x86-64架构的通用硬件,可以在不同的设备上使用。 Squashfs是一种只读的压缩文件系统,它可以有效地减小存储空间的占用。该镜像文件使用Squashfs文件系统,可以在有限的存储空间下安装更多的软件包和应用程序。 UEFI是统一的可扩展固件接口,它取代了传统的BIOS引导方式。UEFI引导方式具有更快的启动速度和更强大的安全功能。该镜像文件支持UEFI引导方式,可以在基于UEFI的设备上进行安装和引导。 总而言之,openwrt-gdq-winter[2022]-x86-64-generic-squashfs-uefi是一个专为2022年冬季设计的OpenWrt操作系统镜像文件,适用于x86-64架构的通用硬件,并支持Squashfs文件系统和UEFI引导方式。它具有高度灵活性和定制能力,适用于各种网络设备和应用场景。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

指向BIOS的野指针

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值