从开机到分段分页都做了什么?

引言

当我们要使用一个操作系统的时候,我们要做的第一步就是打开电源.

当我们打开电源之后第一个运行的软件就是BIOS,于是会产生以下三个问题.

  1. 他是有谁加载的?
  2. 他被加载到哪里?
  3. 他的 cs:ip 是谁更改的?

BIOS (基本输入输出系统)

在学习 BIOS 之前,先了解一些前置知识, 在 Intel 8086 中有 20 条地址线,因此可以访问 1MB 的内存空间,从 0x00000 到 0xFFFFF,在 intel 8086 中,实模式下的 1MB 内存布局是这个样子.

起始结束大小用途
FFFF0FFFFF16BBIOS 入口地址,此地址属于BIOS 代码,当操作系统刚开始加载时, CPU 默认 CS:IP 值为ffff:0000,通过此部分是16字节的跳转指令, jmp f000:e05b 跳转到入口
F0000FFFEF64KB-16B系统BIOS范围是F0000~FFFFF共64KB,最上面16字节为入口地址
C8000EFFFF160KB映射硬件适配器的ROM或内存映射式I/O
C0000C7FFF32KB显示适配器BIOS
B8000BFFFF32KB用于文本模式显示适配器
B0000B7FFF32KB用于黑白显示适配器
A0000AFFFF64KB用于彩色显示适配器
9FC009FFFF1KB扩展BIOS数据区
7E009FBFF622080B可用区域
7C007DFF512BMBR被BIOS加载到此处,共512字节
5007BFF30464B可用区域
4004FF256BBIOS数据区
0003FF1KBInterrupt Vector Table(中断向量表)

地址 0x00000-0x9FFFF 这部分内存是 DRAM ,也就是插在主板上的内存条(地址总线不完全只能访问电脑中的内存条),这部分的空间范围是 640KB .

中间的 0xA0000-0xEFFFF 这部分主要留给其他一些需要通过地址总线访问的外设,因此我们不能把所有的地址完全映射到 DRAM ,因此提前预留这部分的地址空间给外设用,比如显存,硬盘控制器等.

顶部的 0xF0000-0xFFFFF 这 64KB 是内存是 ROM(只读存储器) ,这里面存放的是 BIOS 的代码, BIOS 主要工作是检测和初始化硬件,怎么初始化的?硬件自己提供了一些初始化的功能调用, BIOS 直接调用就好.其次 BIOS 还建立的中断向量表,这样就可以通过 “int 中断号” 来实现相关的硬件调用了,当然 BIOS 建立的这些功能就是对硬件的 IO 操作,也就是输入输出,但由于就 64KB 大小的空间,不可能实现所有硬件的 IO 操作,所以只实现了一些能保证计算机能运行的那些硬件的基本IO操作,这就是 BIOS 称为基本输入输出系统的原因.

如何进入 BIOS ?

BIOS是一个程序,程序要执行需要一个入口地址,这个地址就是上面表中第一项 0xFFFF0(因此在 CPU 清零的时候,实际上CS和IP寄存器并没有清0,反而是编程了 0xF000 和 0xFFF0),在 机器加电时,进入实模式, CPU 访问 CS*16+EIP 这个地址, CS 段寄存器值为 0xF000 ,IP 值为 0xFFF0,所以机器启动时 CPU 将访问 0xFFFF0 ,实模式下 1M 地址中的 0xF0000-0xFFFFF 这个内存地址就是 ROM ,其存储的就是 BIOS 代码(16字节大小:JMP F000:E05B),接着 CPU 执行地址为 0xFE05B 中的指令,而系统 BIOS 范围是 F0000~FFFFF ,此属于 BIOS 代码,之后 BIOS 就开始进行检测内存,显卡的外设信息等任务了.

BIOS 会从 0x0000:0x0000 处初始化并设置中断向量表(IVT),而各中断的默认中断服务程序则在 BIOS 中给出.由于中断向量表中的向量是按中断号顺序排列,因此给定一个中断号 N,那么它对应的中断向量在内存中的位置就是 0x0000:N * 4,即对应的中断服务程序入口地址保存在物理内存0x0000:N * 4位置处.

它还设置了两个 8259A 芯片支持的 16个 硬件中断向量和 BIOS 提供的中断号为 0x10-0x1f 的中断调用功能向量等.对于实际没有使用的向量则填入临时的哑中断服务程序的地址.以后在系统引导加载操作系统时会根据实际需要修改某些中断向量的值.对于当前常用的操作系统,为了不和硬件牵扯过多,除了在刚开始加载内核时需要用到 BIOS 提供的显示和磁盘读操作中断功能,在内核正常运行之前则会在程序中重新初始化 8259A 芯片并且会重新设置一张中断向量表(中断描述符表).抛弃了 BIOS 所提供的中断服务功能.

因为 BIOS 在 ROM 中,所以不能更改(也没有更改的必要),因此我们只需要知道他做了什么事情就可以了,在上述检测进行完之后,最后一项工作是校验启动盘中位于 0 盘 0 道 1 扇区的内容(即 MBR ),当检测无误之后, BIOS 会通过 jmp 0:0x7c00 跳转到 mbr 中.

MBR (主引导记录)

简单介绍

  1. MBR 只能是 512 字节,而且最后两字节为0x55,0xaa(魔数).

  2. MBR 冲虚段的入口地址为0x7c00 ,所以在 MBR 的代码中我们可以看到 SECTION MBR vstart=0x7c00 的设置.

  3. 我们需要在启动操作系统前,将硬盘的 0 盘 0 道 1 扇区填充为 MBR 程序的内容(通过 dd 命令),然后给计算机配置此硬盘为启动盘,这样计算机启动时,就能够自动从 BIOS 到 MBR 了.

MBR 主要做了什么?

通过 ROM 中默认的 BIOS 程序,我们成功进入了 MBR 程序,在 MBR 中我们主要干了下面几件事情.

  1. 从磁盘的第二扇区读取 loader (内核加载器),当然,内核加载器的位置可以随意放在磁盘的不同位置,我选择放在第二扇区,将读取的内容存到 0x900 这个内存地址中,之后当 mbr jmp 到了 0x900 是,就会执行 0x900 这块地址的指令,也就是 loader 中的内容.
  2. 因为按照规定 MBR 的大小必须是 512 字节,而且最后两个字节必须是魔数0x55,0xaa,因此在代码的最后如果不足 512 字节,还需要用类似与 times 510-( − - $) db 0 这样的指令凑满 512 字节.

loader 主要做了什么?

当 mbr 跳转到 loader 之后,就到来 loader 大显身手的时候了,在 loader 程序中我们主要做这几件事情.

  1. 我们通过上文提到的 BIOS 中断获取整个计算机中的物理内存
  2. 跳转进入保护模式,因此需要在 loader.S 中定义代码段,数据段,显示段,还要定义页表(我采用二级页表的形式).
  3. 加载内核

这里牵扯到了新的概念,保护模式是什么,为什么要有保护模式?

为什么要有保护模式?

在实模式下,操作系统和用户程序是属于同一特权级的没有区别,逻辑地址就直接对应物理地址,用户程序是可以通过修改段机制访问到所有地址,很显然这样很不安全,除此之外,在访问内存的时候,需要不断的更换段基址,因为一个段的大小只有 64KB ,内存一共也只有 1M ,并且每次只能运行一个程序,这显然不能适应现在多核的场景,因此就有了带有分段机制的保护模式.

保护模式有什么特点?

  1. 保护模式赋予不同的进程不同的特权等级,操作系统为最高的 0 级,用户进程为 3 级,将用户资源和操作系统资源隔离,更加安全.
  2. CPU 和操作系统通过分段机制,根据段描述符(8字节,是描述段的结构,信息包括段基质,段界限,段类型,段是否可读,段的方向(由低到高还是由高到低)等等)
  3. 在 CPU 发展到 32 位后,地址总线和数据总线扩展到来 32 位,通用寄存器的大小也扩展为 32 位,这样能访问的内存空间编程了 4G ,可以不需要段基址了,不过兼容性依旧保存了段基址+偏移地址的访问方式来访问最终的物理地址,这也就是传说中的平坦模式.这里有个概念需要明确一下,是什么模式以处理器是多少位并没有关系,即使是 32 位系统,在刚开机时都是实模式,只有在经过 loader 的一系列操作之后,才会变为保护模式.

分段机制概述

上文提到了段描述符,一个段描述符保存一个段的信息,有一个专门的数据结构保存着多个段描述符,称为描述符表, 80386/80486 CPU 共有3 种描述符表:全局描述符表( GDT ),局部描述符表( LDT )和中断描述符表( IDT ).描述符表由描述符顺序排列组成,占一定的内存.

段描述符是一个 8 字节 64 位的结构.

在这里插入图片描述

  • 低32位中 0-15 位和 32 位 16-19 位代表段界限,描述段能达到的边界,具体的边界值要结合 23 位的 G 来看, G 为 1 是,段界限的粒度为4 KB,G = 0 时,段界限的粒度为 1 Byte,实际的段界限 - (段描述符里的段界限 + 1) * 段界限粒度大小 - 1.内存访问时需要用到段基址:段偏移地址,段界限的主要作用是来限制段内偏移地址的,段内偏移地址必须在段的范围之内,否则 CPU 会抛异常,根据段的扩展方向,此段界限*单位便是段内偏移地址的最大值(向上扩展)或最小值(向下扩展),任何超过此值的偏移地址都被认为是非法访问.

  • 低 32 位的 16-31 位和高 32 位的 0-7 位及 24-31 位共同描述段基址的 32 位,因为要兼容之前的处理器,因此,当要把段基址扩展到 32 位,把段界限扩展为 20 位时,只能继续往后面添加,所以段界限和段基址会分散在不同的地方.

  • 当段界限和段基址都被拆分之后,如果每次访问都需要去拼接段界限和段基址未免太过于繁琐,而且还要多余访问一次内存,性能也会受到影响,因此,当今的 CPU 会将段信息缓存到段描述符缓存寄存器中,赐婚村寄存器中保存的内容是段描述符的内容,它是经过 CPU 整理后的,段界限和段基址已经被拼合到一起,CPU 下次会直接从段描述符缓存寄存器中取段数据.

在这里插入图片描述

  • S 代表一个段是系统段还是数据段,在 CPU 眼里,凡是硬件使用到的东西称为系统,凡是软件使用到的东西称为数据,所以代码段,数据段,栈段等也属于 S 中所代表的的数据段.

  • Type 指定段的类型,一共四位.只有S决定了,Type才有它的意义.下图是Type在系统段和数据段里不同的意义.
    在这里插入图片描述
    在这里插入图片描述
    我们主要看一下非系统段下 Type 的意义

    1. 表中的 A 为代表的是 Accessed 位,这是由 CPU 来设置的,每当该段被 CPU 访问之后,CPU 就将此位置职位1,创建一个新描述符时,应该将此位置设置为0.

    2. C 表示一致性代码,一致性代码段是指如果自己是转移的目标段,并且自己是一致性代码段.自己的特权级一定要高于当前特权级,转移后的特权级不与自己的 DPL 为主,而是与转移前的低特权级一致,也就是听从,依从转移前的低特权级. C 为 1 时则表示该段是一致性代码段, C 为 0 时则表示该段为非一致性代码段.

    3. R 表示可读,R 为 1 表示可读,为0不可读,这个属性用来限制代码段的访问.

    4. X 表示该段是否可以执行,如果 X 为 1,说明是代码段.可以执行,如果为 0 ,说明是数据段不可以执行.

    5. E 表示段的扩展方向,E 为 0 表示向上扩展,即地址原来也高,常用于代码段和数据段,E 为 1 表示向下扩展,地址越来越低,常用于栈段.

    6. W 表示是否可写,W 为 1 表示可写,通常用于数据段, W 为 0 表示不可写,通常用于代码段.

  • DPL 字段,表示描述符特权级,这是保护模式提供的安全解决方案,将计算机世界按权利分为不同等级,每一种等级称为一种特权级.

  • P 字段,表示段是否存在在内存中,P 为 1表示段在内存中,P 为 0 表示不在,但是这是在未开启分页时的解决方案,目前的保护模式有分页功能,所以按照也的单位来将内存换入换出.

  • ACL 字段没有什么实际意义.

  • L 字段用于设置目前环境是 32 位还是 64 位,32 位设置为0,64位设置为1.

  • D/B 用来表示有效地址和操作数的大小

    1. 对于代码段来说,此位 是 D 位,当 D 为 0,表示指令中的有效地址和操作数是 16 位,指令的有效地址用 IP 寄存器,当 D 为 1, 表示指令中的有效地址和操作数是 32 位,指令有效地址用 EIP 寄存器.
    2. 对于栈段来说,此处 为 B 位,用来指定操作数大小,此操作数涉及到栈指针寄存器的选择及栈的地址上限.若 B 为 0 ,使用的是 sp 寄存器,也就是栈的起始地址是 16 位寄存器的最大寻址范围, 0xFFFF.若 B 为 1,使用的是 esp 寄存器,也就是栈的起始地址是 32 位寄存器的最大寻址范围, 0xFFFFFFFF .
  • G 字段,用于制定段界限的单位大小,配合段界限使用, G 为 0,表示段界限单位为 1 字节,G 为 1 ,表示段界限的单位为 4KB.

全局描述符表 GDT

一个段描述符只用来定义(描述)一个内存段.代码段要占用一个段描述符,数据段和栈段等,多个内存段要各自占一个段描述符,放在全局描述符表中,全局描述符表示共用的,多个程序都可以在这个表定义自己的段描述符.我们进入保护模式的其中一个步骤之一就是加载全局描述符表,让 CPU 知道全局描述符表的位置,在操作内存的时候, CPU 就会根据描述符的信息检查这操作是否有效.

全局段描述符表是公用的,位于内存之中,需要专门的寄存器指向他, CPU 才可以找到它,这个寄存器就是 GDTR ,专门来存储 GDT 的内存打下, GDTR 是一个 48 位的寄存器.其中前 16 位是 GDT 以字节为单位的界限值,所以这 16 位相当于GDT 的字节大小减 1.后 32 位是 GDT 的起始地址.由于 GDT 的大小是 16 位二进制,其表示的范围是 2 的 16 次方等于65536字节.每个描述符大小是8字节,因此 GDT 中最多可容纳的描述符数量是65536/8=8192个,即 GDT 中可容纳 8192 个段或门.

在这里插入图片描述

段选择子

段描述符有了,描述符表也有了,我们该如何使用它呢?段寄存器 CS, DS, ES, FS, GS, SS,在实模式下时,段中存储的是段基地址,即内存段的起始地址.在保护模式下时,由于段基址已经存入了段描述符中,所以段寄存器中再存放段基址是没有意义的,在段寄存器中存入的是一个叫作选择子的东西,用此索引值在段描述符表中索引相应的段描述符,这样,便在段描述符中得到了内存段的起始地址和段界限值等相关信息.

由于段寄存器是 16 位,所以选择子也是 16 位,在其低 2 位即第 0~1 位,用来存储 RPL,即请求特权级,可以表示 0, 1, 2, 3 四种特权级.在选择子的第 2 位是 TI 位,用来指示选择子是在 GDT 中,还是 LDT (因为现代操作系统已经不在使用LDT了,因此我也不会过多介绍)中索引描述符. TI为 0 表示在 GDT 中索引描述符, TI 为 1 表示在 LDT 中索引描述符.选择子的高 13 位,即第 3~15 位是描述符的索引值.用此值在 GDT 中索引描述符.此选择子中的索引值就是 GDT 中的下标.由于选择子的索引值部分是 13 位,即 2 的 13 次方是 8192,故最多可以索引 8192 个段,这和 GDT
中最多定义 8192 个描述符是吻合的.选择子的作用主要是确定段描述符,确定描述符的目的,一是为了特权级、界限等安全考虑,最主要的还是要确定段的基地址.

在这里插入图片描述

A20 地址线

在实模式下, A20 地址线是默认禁用的,原因是还未进入保护模式之前,地址总线还是要模拟 20 位的效果,即只保留 20 位以内的地址,如果地址超过 20 位,地址就会回绕到 0 ,将地址 20 位(从 0 开始算)舍弃,所以要将 A20 地址线给禁用掉.但进入保护模式后,我们需要恢复地址总线的原貌,即使地址超过 20 位,地址也不应该回绕到 0 ,所以此时将 A20 地址线打开,我们就能访问超过20位的地址了.因此,打开 A20 地址线,是进入保护模式的步骤之一.

CR0 的 PE 位

进入保护模式的最后一个步骤是,打开 CR0 的 PE 位, CR0 是控制寄存器.控制寄存器是 CPU 的窗口,它既可以展示 CPU 的内部状态,也可以控制 CPU 的运行机制. CR0 的第0位, PE 位,就是保护模式的开关,我们打开 PE位,就是告诉 CPU 接下来我们要进入保护模式.

在这里插入图片描述

进入保护模式

由上面可以知道,进入保护模式的步骤如下:

1. 打开 A20 地址线
2. 加载 GDT
3. 将 CR0 的 PE 位置为 1

值得注意的是:jmp dword SELECTOR_CODE:p_mode_start,这个指令是用来刷新流水线的,因为在进入保护模式之前,p_mode_start后面的指令也会被放上流水线,指令会按照16位译码,其实本来应该按照32位译码才能正常执行,所以我们需要清除流水线上的这些指令,保证这些指令按32位译码,这样才能正常地运行下去.

获取物理内存容量

在实现了分段之后,我们紧接着就要实现分页模式和虚拟内存基址了,在这之前,我们得先做一个小任务就是获取内存容量是多少?

在 Linux 中有多种方法获取内存容量,其函数在本质上是通过调用 BIOS 中断 0x15 实现的,分别是 BIOS 中断 0x15 的 3 个子功能,子功能号要存放到寄存器 EAX 或 AX 中,如下。

  • EAX=0xE820:遍历主机上全部内存。
  • AX=0xE801: 分别检测低 15MB 和 16MB~4GB 的内存,最大支持 4GB。
  • AH=0x88:最多检测出 64MB 内存,实际内存超过此容量也按照 64MB 返回。

BIOS 0x15 中断提供了丰富的功能,具体要调用的功能,需要在寄存器 ax 中指定。其中 0xE8xx 系列的子功能较为强大, 0x15 中断的子功能 0xE820 和 0xE801 都可以用来获取内存,区别是 0xE820 返回的是内存布局,信息量相对多一些,操作也相对复杂。而 0xE801 直接返回的是内存容量,操作适中.子功能 0x88 也能获取内存容量,这是最简单的用法,功能也最薄弱。

一般来说就使用 0xE820 子功能就好了.

分页模式

经过了一系列操作,终于实现了分段,操作系统也进入了保护模式,分段模式解决了一些实模式留下的问题,但是分段模式还有一个缺陷没有解决.

  • 由于分段模式是以进程为单位分配内存的,本来剩余的内存空间是足以分配给进程的,但由于这些剩余的内存片并不连续,我们就不能分配这些内存给对应的进程了.

因此,为了解决这个问题,就有了分页模式,分页就是通过映射的方式,将连续的线性地址转化为不连续的物理地址;这样,在处理器进入分页模式之后,用户直接访问的并不是物理地址,而是分页模式下的虚拟地址.

注意:分段是分页的基础,段页式的内存布局映射如下

在这里插入图片描述

分页实现

为了节省分页的开销,我们采取的每个内存页的大小为 4KB,这样在 32 位下,内存块的数量就是 1MB 左右,但是单纯使用页大小的为 4KB 的一级页表也有一定的问题,因为一个页表项是 4 字节, 1M 个内存块就是 4MB 的开销,这样算下来,一个进程光页表就要占据 4MB 的开销,而且很多时候根本也用不到这么多的内存映射,所以这是一笔很大的内存开销,因此,我们使用二级页表的形式去实现分页功能,另外,目前 Linux 已经发展到了5级页表.

二级页表比一级页表多了一层,我们说这一层为页目录,在二级页表中,我们在一级页表中按照 4MB 为单位,通过 4KB(4*1KB) 的页目录表将 4GB 的内存地址进行映射,每个页目录项指向一个页表,当对应的页目录表项没有使用,则不需要有对应的页表,而且页表之间也不需要连续存储.

  • 页目录表表项结构
    在这里插入图片描述

  • 页表表项结构
    在这里插入图片描述

在这里插入图片描述

上图就是页目录项和页表项的格式.可以看出,由于页表或者页的物理地址都是4KB对齐的(低12位全是零,原因是页目录表和页表中对应的页目录项和页表项都是 1024(10位) 个,每项都是 4B(2位) ,所以单个页目录表和页表都是 4KB ),所以上图中只保留了物理基地址的高20位bit[31:12].低12位可以安排其他用途.

  • P :存在位.为 1 表示页表或者页位于内存中.否则,表示不在内存中,必须先予以创建或者从磁盘调入内存后方可使用.
  • R/W :读写标志.为 1 表示页面可以被读写,为 0 表示只读.当处理器运行在 0,1,2 特权级时,此位不起作用.页目录中的这个位对其所映射的所有页面起作用.
  • U/S :用户/超级用户标志.为 1 时,允许所有特权级别的程序访问;为 0 时,仅允许特权级为0 ,1 ,2 的程序访问.页目录中的这个位对其所映射的所有页面起作用.
  • PWT : Page 级的 Write-Through 标志位.为 1 时使用 Write-Through 的Cache类型;为0时使用Write-Back的Cache类型.当 CR0.CD = 1 时( Cache 被 Disable 掉),此标志被忽略.这里我将此位清零.
  • PCD : Page 级的 Cache Disable 标志位.为 1 时,物理页面是不能被 Cache 的;为 0 时允许 Cache .当 CR0.CD = 1 时,此标志被忽略.我将此位清零.
  • A : 访问位.该位由处理器固件设置,用来指示此表项所指向的页是否已被访问(读或写),一旦置位,处理器从不清这个标志位.这个位可以被操作系统用来监视页的使用频率.
  • D : 脏位.该位由处理器固件设置,用来指示此表项所指向的页是否写过数据.
  • PAT :意为页属性表位,能够在页面一级的粒度上设置内存属性.比较复杂,将此位置 0 即可.
  • PS : Page Size 位.为 0 时,页的大小是 4KB ,为 1 时,页的大小是 4MB 或者 2MB
    G:全局位.如果页是全局的,那么它将在高速缓存中一直保存.当CR4.PGE=1 时,可以设置此位为1,指示 Page 是全局 Page ,在 CR3 被更新时, TLB 内的全局 Page 不会被刷新.
    AVL :被处理器忽略,软件可以使用.

页目录表的基址存在页目录基址寄存器 PDBR (控制寄存器 CR3 )

一个页目录项的 32 位含义比较丰富,这都是为分页机制,分页算法设计的,为了实现分页基址我们需要注意:

  1. 实现分页机制,分段是前提
  2. 需要提前实现好页目录表和页表
  3. 页目录表的基地址写入控制寄存器 CR3
  4. 控制寄存器 CR0 的 PG 位设置为 1 ,表示开启分页基址

二级页表的地址映射

  • 线性地址,分段得到的地址,再到页表中找到对应的页表项,再到物理地址,映射过程(二级分页)
  1. 虚拟地址高10位*4,作为页目录表内的偏移地址,加上目录表的物理地址(CR3寄存器含有页目录表基地址),就能得到页目录的物理地址.读取页目录表的内容,可以得到页表的物理地址
  2. 虚拟地址的中间10位*4,作为页表内的偏移地址,加上步骤1的页表物理地址,将得到页表项的物理地址.读取该页表项的内容,可以得到分配的物理页的地址.
  3. 虚拟地址高10位和中间10位分别是页目录表和页表的索引值,所以需要乘以4.低12位不是索引值,其范围是0-0xfff,作为页内偏移.步骤2的物理地址加上此偏移,得到最终的物理地址.

在保护模式下,线程是运行的调度的最小单位,进程是资源分配的最小单位,每个进程有自己独立的进程地址空间,因此每个进程都有自己 4G 的虚拟空间,所以每个进程都有会有自己的页目录和页表.

页表总结:

如果使用二级页表,理论上,每次访问内存需要经过三次访问内存才能访问到真正的物理单元.

  • 第一次访问内存是通过段地址寄存器(CR3)中得到页目录表基址,加上虚拟地址高 10位 * 4 的偏移量获取页表基址.

  • 第二次访问内存是根据获取到的页表基址加上虚拟地址中间 10位 * 4 的偏移地址访问得到的页表项地址.

  • 第三次访问内存是虚拟地址最后 12 位的值加上页表基址得到的真正物理地址,然后访问物理地址.

TLB

上面说道每次访问物理地址需要多次访问内存,因为访问内存中的页目录,页表太花时间,比起 CPU 执行指令或者访问寄存器慢一个数量级,因此,目前 Linux 操作系统通过 CPU 缓存( TLB 高速缓存)加快了这个过程,环节了处理器和与内存访问速度之间的不匹配,保存这虚拟地址的高 20 位到物理地址高20位的映射,这样就能加快对内存的访问,不用每次访问内存都要访问页表,因此,每次处理器访问内存时,其实是先访问的 TLB ,所以一定要保证 TLB 的有效性.

因此,每次当页目录和页表数据被改变的时候,我们就要负责维护 TLB 的有效性,更新 TLB ,这里有两种方法,一种是通过 invlpg 指令刷新某个虚拟地址对应的条目,另一种是重新加载页目录,使整个 TLB 失效,进而重新加载 TLB 数据.

在这里插入图片描述

进入分页模式

由上文可得,进入分页模式需要三个步骤

  1. 准备好页目录和页表
  2. 将页目录的地址加载到 CR3 控制寄存器
  3. 将 CR0 控制寄存器的 PG 位打开

首先先准备页目录和页表,首先将页目录的 4K 内存清空位 0 ,避免之前存在的数据指向错误的地方,然后开始创建页目录项,将第 1 个和第 769 个页目录项设为第一个页表的地址(这里页目录和页表项从1开始算);将最后一个页目录设为页目录的地址,将第一个页表映射到低 1M 的物理内存;将第 770~1023 的页目录项设置成第 2~255 个页表的地址,第 2~255 个页表是紧接着第1个页表之后的.流程图如下:

在这里插入图片描述

下面解释一下各个步骤的意义:

  1. 清空页目录的内存作初始化,防止原来存在的数据指向错误的地方.

  2. 将第 1 个和第 769 个页目录项设置为第一个页表的地址,而后面第一个页表映射到了低1M物理内存,低1M物理内存存储着内核程序.主要是因为打开分页模式之后,首先获得的是虚拟地址,然后将这个虚拟地址转换到最终的物理地址.所以试图访问内核程序的地址已经变成了虚拟地址了,如果最后转换到的物理地址不是原来的物理地址就会出问题.举个例子,假设内核程序在打开分页模式之前,通过地址 0x900 读写变量 A ,此时的地址是线性地址,也是物理地址,因为还没打开分页模式;但是打开分页模式之后,我们再想读写这个变量 A 时,提交的还是 0x900 ,但是这个地址已经变成了虚拟地址了,处理器最终要访问的是物理地址,而变量 A 的物理地址仍然是 0x900 ,所以需要将虚拟地址 0x900 映射到物理地址 0x900 ,即一一对应,这样才能保证之前的程序能够正确运行.综上,我们需要将虚拟地址空间的低1M与物理地址的低 1M 进行一对一映射.将第 769 个页目录项设置为第一个页表的地址,主要是将虚拟地址空间的高 1G 内存作为内核程序的空间,以后试图请求内核程序的帮助都会访问高 1G 内存的空间;而低 1M 内存也属于内核程序的一部分,所以将0xc000 0000~0xc001 0000也映射到低1M的物理地址.

  3. 将最后一个页目录项指向页目录的作用是对页表进行操作.再细想一下,我们通过什么地址可以对页表进行操作.

  • 我想最终访问页目录表,获得页目录项存储的页表地址.将高 10 位设置为最后一项页目录的索引,将中 10 位也设为最后一项页目录的索引,低 12 位再索引页目录表即可.

  • 我想最终访问页表,获得页表项存储的页地址.将高 10 位设置为最后一项页目录的索引,将中 10 位设为某个页目录项的索引,低 12 位就可以索引页表了.

  1. 将第 770-1023 的页目录项设置为第 2-255 个页表的地址,按书上的说法是与之后建立用户进程相关,咱不在这里讨论.

之后将段描述符段基址+0xc000 0000,禁止用户进程直接访问显存,只能通过高 1G 的内核空间去访问显存.将栈指针和 GDT 也映射到内核地址空间.最后按三部曲打开分页模式.

如何从 loader 跳转到内核?

下一步我们要进入内核的编程,之前的 mbr.S 和 loader.S 都是使用的汇编语言,在我们使用汇编语言的时候,我们用的是 nasm 去将汇编语言转换为二进制文件,而现在到了内核,我们需要用 C 语言了,那么 C 语言是如何给变成二进制文件的呢?又是怎么和通过 nasm 汇编成的代码一起运行的呢?因此在我们写内核之前,我们需要一些前置知识.

对于高级语言如何变成二进制文件,我之前写过一篇帖子,Linux下使用GCC编译时到底进行了什么?,如果你对 C 语言编译成可执行文件的过程很熟悉,就可以不用看了.

由上图可知,一个 C 语言程序是在汇编之后变为二进制文件的,而这个时候生成的是 .o 文件,也就是可重定位文件,重定位表示文件中所用到的符号没有安排地址,这些符号需要和其他目标文件经过链接才能形成一个可执行文件,在这一步,连接器会给对应的符号编排地址.

  • 符号指的是函数或者变量
  • 可执行文件是由几个目标文件组成, kernel 内核有多个代码文件,生成了 kernel.bin
  • 编排地址就是对程序中的代码安排对应的地址

汇编和链接后的文件是纯二进制文件?

在实现 mbr 和 loader 的时候,我们使用 nasm 汇编器直接生成纯二进制文件,当时的我们是这样调用程序的

BIOS 初始化之后,将第0扇区的 MBR 加载到 0x7c00 并且跳到那里执行,mbr 再去调用 loader,loader 的地址是 0x900 .可以看到这些程序的地址都是固定的,并且调用方和被调用方需要约定好地址,然后我们需要在硬盘的特定位置将文件读到特定的地址,这种方式是很不灵活的.

因此比较好的做法程序中应该是规定一种可执行文件的加载格式,程序在运行的时候我们根据格式来加载解析,实际上在 Linux 下生成的会是 ELF 文件,通过内核的 elf 程序头 loader 可以获取到内核需要加载的虚拟地址,程序入口地址等和内核相关的信息.

所以到了内核这一步,我们需要处理的就不是纯的二进制文件了,而是 ELF 文件.

现在,内核被加载到内存后, loader 还要通过分析其 elf 结构将其展开到新的位置,所以说,内核在内存中有两份拷贝,一份是 elf格式的原文件 kernel.bin,另一份是 loader 解析 elf格式的 kernel.bin 后在内存中生成的内核映像(也就是将程序中的各种段 segment 复制到内存后的程序体),这个映像才是真正运行的内核.

什么是 ELF 文件?

Linux下的可执行文件格式为ELF,即Executable and Linkable Format,可执行链接格式.与ELF相关的文件类型有三种,是我们需要区分一下的.

ELF的布局在链接阶段和运行阶段并不太一样,主要是因为节最终会合并成段,ELF 格式的文件头包含了程序头表(program header table)和节头表(section header table)和 ELF 表.

程序头表

程序头表中存储的是一种记录段信息的数据结构,每个成员称为条目(entry),条目对应着段的描述信息.

// 在 /usr/include/elf.h
typedef struct
{
  Elf32_Word	p_type;			/* Segment type */
  Elf32_Off	p_offset;		/* Segment file offset */
  Elf32_Addr	p_vaddr;		/* Segment virtual address */
  Elf32_Addr	p_paddr;		/* Segment physical address */
  Elf32_Word	p_filesz;		/* Segment size in file */
  Elf32_Word	p_memsz;		/* Segment size in memory */
  Elf32_Word	p_flags;		/* Segment flags */
  Elf32_Word	p_align;		/* Segment alignment */
} Elf32_Phdr;
节头表

多个节经过链接之后就被合并成一个段,因为 CPU 内存存储的是有序的段,节头表就不使用了.

ELF 头
// 在 /usr/include/elf.h
typedef struct
{
  unsigned char	e_ident[EI_NIDENT];	/* Magic number and other info */
  Elf32_Half	e_type;			/* Object file type */
  Elf32_Half	e_machine;		/* Architecture */
  Elf32_Word	e_version;		/* Object file version */
  Elf32_Addr	e_entry;		/* Entry point virtual address */
  Elf32_Off	e_phoff;		/* Program header table file offset */
  Elf32_Off	e_shoff;		/* Section header table file offset */
  Elf32_Word	e_flags;		/* Processor-specific flags */
  Elf32_Half	e_ehsize;		/* ELF header size in bytes */
  Elf32_Half	e_phentsize;		/* Program header table entry size */
  Elf32_Half	e_phnum;		/* Program header table entry count */
  Elf32_Half	e_shentsize;		/* Section header table entry size */
  Elf32_Half	e_shnum;		/* Section header table entry count */
  Elf32_Half	e_shstrndx;		/* Section header string table index */
} Elf32_Ehdr;

一个固定大小的数据结构来描述程序头表和节头表的大小及位置信息,位于文件最开始的部分,可以说是用来描述各种"头"的"头".

关于 ELF 文件的具体细节实在是太多了,我这里建议去看《程序员的自我修养》和《深入理解计算机系统》两本书,我这里就不多说了.

如何加载 ELF 文件?

我们学习加载 ELF 文件到内存,主要是为了加载内核到内存,在上文说道,在此操作系统中内核有两份拷贝,源文件在 0x70000 处,之后会被真正的内核映像覆盖掉,至于如何将源文件加载到内核,实际上这里与从硬盘读取loader到内存的方法基本一样,区别在于是32位操作数和寻址方式.具体代码请参考 loader.S 中的 rd_disk_m_32 函数.

其实这里在这里,加载内核到内存与打开分页模式这两步其实是顺序可以互换,在代码中是先加载内核,后打开分页,这样处理会简单一点,我在叙述的时候把为了描述清楚,把分页和分段放在了一起讲

在加载完了内核源文件,我们就需要初始化内核了,主要就是解析 ELF 头和程序头,具体步骤如下:

  1. 得到程序头的大小
  2. 得到第一个程序头的偏移量
  3. 得到程序头的个数
    • 开始复制段
  4. 判断段类型是否被葫芦哦,是的话不复制,跳到第 7 步,否则继续
  5. 得到段在文件的偏移量,段的大小,在内存的地址
  6. 将段复制到内存里.
  7. 判断是否全部段都复制好了,不是就跳到下一个程序头,跳到 4 ,否则复制完成.

这一段的代码主要参看 loader.S 中的 enter_kernel 函数和 kernel_init 函数,所有段加载到 0x0001000 处,入口地址为0x0001500,从这里开始的内存区域就是内核了.

enter_kernel:
	call kernel_init          ;返回地址压栈4字节
	mov esp,0xc009f000        ;栈转移到可用区域  0xc0001000起始为内核映像,大小为 60KB,栈不会破坏
	jmp kernel_entry_addr     ;进入内核的入口虚拟地址 0xc0001500

在这里插入图片描述

总结一下:

mbr

被加载到物理地址 0x7c00 ,有 BIOS 读取磁盘的 MBR 分区(即磁盘的第一个扇区-512字节)

mbr负责读取磁盘 2-4 扇区的 loader 内容,加载在物理内存可用区域,我们选择了 0x9000 , MBR 结束自己,跳转到 loader 入口地址

loader

loader 建立分段,分页机制等,并读取内核所在的磁盘区域,把内核加载到内存,然后跳转到内核入口处,结束自己.

参考资料
  • 《从实模式到保护模式》
  • 《操作系统真相还原》
  • 《程序员的自我修养》
  • https://www.cnblogs.com/thougr/p/12158456.html
  • https://www.cnblogs.com/thougr/p/11874962.html
  • https://www.cnblogs.com/thougr/p/12203650.html
  • https://blog.csdn.net/qq_33620667/article/details/60145621
  • https://www.cnblogs.com/nullecho/p/10266467.html
  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Randy__Lambert

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

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

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

打赏作者

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

抵扣说明:

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

余额充值