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

该目录下实现了简易的 mbr 与 loader ,Intel 8086 有20条地址线,因此可以访问 1MB 的内存空间,是 0x00000 到 0xFFFFF,为了解 mbr 和 loader 的作用,我们先看看在 intel 8086 中,实模式下的 1MB 内存布局是什么样子

起始 结束 大小 用途
FFFF0 FFFFF 16B BIOS 入口地址,此地址属于BIOS 代码,当操作系统刚开始加载时, CPU 默认 CS:IP 值为ffff:0000,通过此部分是16字节的跳转指令, jmp f000:e05b 跳转到入口
F0000 FFFEF 64KB-16B 系统BIOS范围是F0000~FFFFF共64KB,最上面16字节为入口地址
C8000 EFFFF 160KB 映射硬件适配器的ROM或内存映射式I/O
C0000 C7FFF 32KB 显示适配器BIOS
B8000 BFFFF 32KB 用于文本模式显示适配器
B0000 B7FFF 32KB 用于黑白显示适配器
A0000 AFFFF 64KB 用于彩色显示适配器
9FC00 9FFFF 1KB 扩展BIOS数据区
7E00 9FBFF 622080B 可用区域
7C00 7DFF 512B MBR被BIOS加载到此处,共512字节
500 7BFF 30464B 可用区域
400 4FF 256B BIOS数据区
000 3FF 1KB Interrupt Vector Table(中断向量表)

因为20位地址线只能映射 1MB 的地址,而这 1MB 映射了 DRAM , ROM 和显卡三部分.

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

中间的 0xA0000-0xEFFFF 这部分主要留给其他一些需要通过地址总线访问的外设,因此我们不能把所有的地址完全映射到 DRAM ,因此提前预留这部分的地址空间给外设用,比如显存,硬盘控制器等,留够以后,地址总线上其他的可用地址在指向 DRAM ,也就是我们指的插到主板上的内存调,我们眼中的物理内存.

顶部的 0xF0000-0xFFFFF 这部分是 ROM ,这里面存放的是 BIOS 的代码, BIOS 主要工作是检测和初始化硬件,怎么初始化的?硬件自己提供了一些初始化的功能调用, BIOS 直接调用就好. BIOS 还建立的中断向量表,这样就可以通过"int 中断号"来实现相关的硬件调用了,当然BIOS建立的这些功能就是对硬件的IO操作,也就是输入输出,但由于就 64KB 大小的空间,不可能把所有硬件的IO操作实现得面面俱到,而且也没必要实现那么多,毕竟是在实模式之下,对硬件支持得再丰富也白搭,精彩的世界是在进入保护模式以后才开始,所以挑一些重要的,保证计算机能运行的那些硬件的基本IO操作,这就是BIOS称为基本输入输出系统的原因.

机器加电时,进入的实模式, CPU 访问 CS*16+EIP 这个地址, CS 段寄存器值为 0xF000 ,EIP 值为 0x0000FFF0,所以机器启动时 CPU 将访问 0xFFFF0 (该地址为 BIOS 入口地址),实模式下 1M 地址中的 0xF0000-0xFFFFF 这个内存地址就是 ROM ,其存储的就是 BIOS 代码(16字节大小:JMP F000:E05B),接着 CPU 执行地址为 0xFE05B 中的指令,而系统 BIOS 范围是 F0000~FFFFF ,此属于 BIOS 代码,为 BIOS 程序的真正内容.

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

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

因为 BIOS 在 ROM 中,所以不能更改(也没有更改的必要),因此我们只需要知道他做了什么事情就可以了,在上述检测进行完之后,最后一项工作是校验启动盘中位于0盘0道1扇区的内容(即 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. 输出字符串 MBR
  2. 从磁盘的第二扇区读取 loader (这个可以随意放,我选择放在第二扇区),将读取的内容存到 0x900 这个内存地址中,之后当 mbr jmp 到了 0x900 是,就会执行 0x900 这块地址的指令,也就是 loader 中的内容
  3. 因为按照规定 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 位的结构.

从开机到分段分页都做了什么?_第1张图片

在低32位中 0-15 位和32位 16-19 位代表段界限,描述段能达到的边界,具体的边界值要结合 23 位的 G 来看, G 为 1 是,笔叟暗示段界限的粒度为4 KB,G = 0 时,表示段界限的粒度为 1 Byte,实际的段界限 - (段描述符里的段界限 + 1) * 段界限粒度大小 - 1.低 32 位的 16-31 位和高 32 位的 0-7 位及 24-31 位共同描述段基址的 32 位,因为历史遗留原因,为了把段基址扩展到 32 位,把段界限扩展为 20 位,只能继续往后面添加,所以会段界限和段基址会分散在不同的地方.

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

Type 指定段的类型,一共四位.只有S决定了,Type才有它的意义.下图是Type在系统段和数据段里不同的意义.
  
从开机到分段分页都做了什么?_第2张图片
我们主要看一下数据段下Type的意义.当段为代码段时,Type由X、R、C、A组成,分别代表是否可执行、是否可读、是否一致、是否被访问过.当段位数据段时,Type由X、W、E、A组成,分别代表是否可执行、是否可写、扩展方向、是否被访问过.

DPL代表段属于哪一个特权级别.

P代表内存段是否存在,0代表段不存在,1代表段存在.

AVL代表可用的位,操作系统可以随意使用,没有特殊含义.

L代表代码段是64位还是32位.

D/B.对于代码段来说此位是D,用来给代码段指定是使用16位还是32位有效地址和操作数的.对于栈段来说此位是B,用来给栈段指定使用的是sp寄存器还是esp寄存器,sp寄存器的最大寻址范围是0xFFFF,esp寄存器的最大寻址范围是0xFFFFFFFF.

G代表段界限的粒度,是4KB还是1B.

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

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位译码,这样才能正常地运行下去.

分页模式

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

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

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

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

从开机到分段分页都做了什么?_第3张图片

分页实现

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

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

  • 页目录表表项结构
    从开机到分段分页都做了什么?_第4张图片

  • 页表表项结构
    从开机到分段分页都做了什么?_第5张图片

上图就是页目录项和页表项的格式.可以看出,由于页表或者页的物理地址都是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】:脏位.该位由处理器固件设置,用来指示此表项所指向的页是否写过数据.
【PS】:Page Size位.为0时,页的大小是4KB;为1时,页的大小是4MB(for normal 32-bit addressing )或者2MB(if extended physical addressing is enabled).
【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 数据.

从开机到分段分页都做了什么?_第6张图片

进入分页模式

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

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

这段过程主要在 loader.S 中实现,以下为代码部分

[bits 32]
p_mode_start:
   mov ax, SELECTOR_DATA
   mov ds, ax
   mov es, ax
   mov ss, ax
   mov esp,LOADER_STACK_TOP
   mov ax, SELECTOR_VIDEO
   mov gs, ax

   ; 创建页目录及页表并初始化页内存位图
   call setup_page

   ;要将描述符表地址及偏移量写入内存gdt_ptr,一会用新地址重新加载
   sgdt [gdt_ptr]          ; 存储到原来gdt所有的位置

   ;将gdt描述符中视频段描述符中的段基址+0xc0000000
   mov ebx, [gdt_ptr + 2]  
   or dword [ebx + 0x18 + 4], 0xc0000000      ;视频段是第3个段描述符,每个描述符是8字节,故0x18.
                          ;段描述符的高4字节的最高位是段基址的31~24位

   ;将gdt的基址加上0xc0000000使其成为内核所在的高地址
   add dword [gdt_ptr + 2], 0xc0000000

   add esp, 0xc0000000        ; 将栈指针同样映射到内核地址

   ; 把页目录地址赋给cr3
   mov eax, PAGE_DIR_TABLE_POS
   mov cr3, eax

   ; 打开cr0的pg位(第31位)
   mov eax, cr0
   or eax, 0x80000000
   mov cr0, eax

   ;在开启分页后,用gdt新的地址重新加载
   lgdt [gdt_ptr]             ; 重新加载

   mov byte [gs:160], 'V'     ;视频段段基址已经被更新,用字符v表示virtual addr

   jmp $

;-------------   创建页目录及页表   ---------------
setup_page:
;先把页目录占用的空间逐字节清0
   mov ecx, 4096
   mov esi, 0
.clear_page_dir:
   mov byte [PAGE_DIR_TABLE_POS + esi], 0
   inc esi
   loop .clear_page_dir

;开始创建页目录项(PDE)
.create_pde:                     ; 创建Page Directory Entry
   mov eax, PAGE_DIR_TABLE_POS
   add eax, 0x1000                  ; 此时eax为第一个页表的位置及属性
   mov ebx, eax                     ; 此处为ebx赋值,是为.create_pte做准备,ebx为基址.

;   下面将页目录项0和0xc00都存为第一个页表的地址,
;   一个页表可表示4MB内存,这样0xc03fffff以下的地址和0x003fffff以下的地址都指向相同的页表,
;   这是为将地址映射为内核地址做准备
   or eax, PG_US_U | PG_RW_W | PG_P         ; 页目录项的属性RW和P位为1,US为1,表示用户属性,所有特权级别都可以访问.
   mov [PAGE_DIR_TABLE_POS + 0x0], eax       ; 第1个目录项,在页目录表中的第1个目录项写入第一个页表的位置(0x101000)及属性(7)
   mov [PAGE_DIR_TABLE_POS + 0xc00], eax     ; 一个页表项占用4字节,0xc00表示第769个页表占用的目录项,0xc00以上的目录项用于内核空间,
                         ; 也就是页表的0xc0000000~0xffffffff共计1G属于内核,0x0~0xbfffffff共计3G属于用户进程.
   sub eax, 0x1000
   mov [PAGE_DIR_TABLE_POS + 4092], eax         ; 使最后一个目录项指向页目录表自己的地址

;下面创建页表项(PTE)
   mov ecx, 256                     ; 1M低端内存 / 每页大小4k = 256
   mov esi, 0
   mov edx, PG_US_U | PG_RW_W | PG_P         ; 属性为7,US=1,RW=1,P=1
.create_pte:                     ; 创建Page Table Entry
   mov [ebx+esi*4],edx                 ; 此时的ebx已经在上面通过eax赋值为0x101000,也就是第一个页表的地址 
   add edx,4096
   inc esi
   loop .create_pte

;创建内核其它页表的PDE
   mov eax, PAGE_DIR_TABLE_POS
   add eax, 0x2000              ; 此时eax为第二个页表的位置
   or eax, PG_US_U | PG_RW_W | PG_P  ; 页目录项的属性US,RW和P位都为1
   mov ebx, PAGE_DIR_TABLE_POS
   mov ecx, 254                 ; 范围为第770~1023的所有目录项数量
   mov esi, 769
.create_kernel_pde:
   mov [ebx+esi*4], eax
   inc esi
   add eax, 0x1000
   loop .create_kernel_pde
   ret

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

从开机到分段分页都做了什么?_第7张图片

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

  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 个页表的地址,按书上的说法是与之后建立用户进程相关,咱不在这里讨论.

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

如何从 loader 跳转到内核?

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

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

从开机到分段分页都做了什么?_第8张图片

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

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

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

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

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

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

从开机到分段分页都做了什么?_第9张图片

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

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

什么是 ELF 文件?

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

从开机到分段分页都做了什么?_第10张图片

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

从开机到分段分页都做了什么?_第11张图片

程序头表

程序头表中存储的是一种记录段信息的数据结构,每个成员称为条目(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

从开机到分段分页都做了什么?_第12张图片

总结一下:

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

你可能感兴趣的:(Linux操作系统相关,操作系统,编程语言,linux,汇编)