当我们要使用一个操作系统的时候,我们要做的第一步就是打开电源.
当我们打开电源之后第一个运行的软件就是BIOS,于是会产生以下三个问题.
起始 | 结束 | 大小 | 用途 |
---|---|---|---|
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(中断向量表) |
顶部的 0xF0000-0xFFFFF 这 64KB 是内存是 ROM(只读存储器) ,这里面存放的是 BIOS 的代码, BIOS 主要工作是检测和初始化硬件,怎么初始化的?硬件自己提供了一些初始化的功能调用, BIOS 直接调用就好.其次 BIOS 还建立的中断向量表,这样就可以通过 “int 中断号” 来实现相关的硬件调用了,当然 BIOS 建立的这些功能就是对硬件的 IO 操作,也就是输入输出,但由于就 64KB 大小的空间,不可能实现所有硬件的 IO 操作,所以只实现了一些能保证计算机能运行的那些硬件的基本IO操作,这就是 BIOS 称为基本输入输出系统的原因.
因为 BIOS 在 ROM 中,所以不能更改(也没有更改的必要),因此我们只需要知道他做了什么事情就可以了,在上述检测进行完之后,最后一项工作是校验启动盘中位于 0 盘 0 道 1 扇区的内容(即 MBR ),当检测无误之后, BIOS 会通过 jmp 0:0x7c00 跳转到 mbr 中.
MBR 只能是 512 字节,而且最后两字节为0x55,0xaa(魔数).
我们需要在启动操作系统前,将硬盘的 0 盘 0 道 1 扇区填充为 MBR 程序的内容(通过 dd 命令),然后给计算机配置此硬盘为启动盘,这样计算机启动时,就能够自动从 BIOS 到 MBR 了.
通过 ROM 中默认的 BIOS 程序,我们成功进入了 MBR 程序,在 MBR 中我们主要干了下面几件事情.
当 mbr 跳转到 loader 之后,就到来 loader 大显身手的时候了,在 loader 程序中我们主要做这几件事情.
这里牵扯到了新的概念,保护模式是什么,为什么要有保护模式?
上文提到了段描述符,一个段描述符保存一个段的信息,有一个专门的数据结构保存着多个段描述符,称为描述符表, 80386/80486 CPU 共有3 种描述符表:全局描述符表( GDT ),局部描述符表( LDT )和中断描述符表( IDT ).描述符表由描述符顺序排列组成,占一定的内存.
段描述符是一个 8 字节 64 位的结构.
低 32 位的 16-31 位和高 32 位的 0-7 位及 24-31 位共同描述段基址的 32 位,因为要兼容之前的处理器,因此,当要把段基址扩展到 32 位,把段界限扩展为 20 位时,只能继续往后面添加,所以段界限和段基址会分散在不同的地方.
当段界限和段基址都被拆分之后,如果每次访问都需要去拼接段界限和段基址未免太过于繁琐,而且还要多余访问一次内存,性能也会受到影响,因此,当今的 CPU 会将段信息缓存到段描述符缓存寄存器中,赐婚村寄存器中保存的内容是段描述符的内容,它是经过 CPU 整理后的,段界限和段基址已经被拼合到一起,CPU 下次会直接从段描述符缓存寄存器中取段数据.
S 代表一个段是系统段还是数据段,在 CPU 眼里,凡是硬件使用到的东西称为系统,凡是软件使用到的东西称为数据,所以代码段,数据段,栈段等也属于 S 中所代表的的数据段.
Type 指定段的类型,一共四位.只有S决定了,Type才有它的意义.下图是Type在系统段和数据段里不同的意义.
表中的 A 为代表的是 Accessed 位,这是由 CPU 来设置的,每当该段被 CPU 访问之后,CPU 就将此位置职位1,创建一个新描述符时,应该将此位置设置为0.
C 表示一致性代码,一致性代码段是指如果自己是转移的目标段,并且自己是一致性代码段.自己的特权级一定要高于当前特权级,转移后的特权级不与自己的 DPL 为主,而是与转移前的低特权级一致,也就是听从,依从转移前的低特权级. C 为 1 时则表示该段是一致性代码段, C 为 0 时则表示该段为非一致性代码段.
R 表示可读,R 为 1 表示可读,为0不可读,这个属性用来限制代码段的访问.
X 表示该段是否可以执行,如果 X 为 1,说明是代码段.可以执行,如果为 0 ,说明是数据段不可以执行.
W 表示是否可写,W 为 1 表示可写,通常用于数据段, W 为 0 表示不可写,通常用于代码段.
DPL 字段,表示描述符特权级,这是保护模式提供的安全解决方案,将计算机世界按权利分为不同等级,每一种等级称为一种特权级.
P 字段,表示段是否存在在内存中,P 为 1表示段在内存中,P 为 0 表示不在,但是这是在未开启分页时的解决方案,目前的保护模式有分页功能,所以按照也的单位来将内存换入换出.
ACL 字段没有什么实际意义.
L 字段用于设置目前环境是 32 位还是 64 位,32 位设置为0,64位设置为1.
G 字段,用于制定段界限的单位大小,配合段界限使用, G 为 0,表示段界限单位为 1 字节,G 为 1 ,表示段界限的单位为 4KB.
一个段描述符只用来定义(描述)一个内存段.代码段要占用一个段描述符,数据段和栈段等,多个内存段要各自占一个段描述符,放在全局描述符表中,全局描述符表示共用的,多个程序都可以在这个表定义自己的段描述符.我们进入保护模式的其中一个步骤之一就是加载全局描述符表,让 CPU 知道全局描述符表的位置,在操作内存的时候, CPU 就会根据描述符的信息检查这操作是否有效.
进入保护模式的最后一个步骤是,打开 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 中,如下。
BIOS 0x15 中断提供了丰富的功能,具体要调用的功能,需要在寄存器 ax 中指定。其中 0xE8xx 系列的子功能较为强大, 0x15 中断的子功能 0xE820 和 0xE801 都可以用来获取内存,区别是 0xE820 返回的是内存布局,信息量相对多一些,操作也相对复杂。而 0xE801 直接返回的是内存容量,操作适中.子功能 0x88 也能获取内存容量,这是最简单的用法,功能也最薄弱。
一般来说就使用 0xE820 子功能就好了.
经过了一系列操作,终于实现了分段,操作系统也进入了保护模式,分段模式解决了一些实模式留下的问题,但是分段模式还有一个缺陷没有解决.
注意:分段是分页的基础,段页式的内存布局映射如下
为了节省分页的开销,我们采取的每个内存页的大小为 4KB,这样在 32 位下,内存块的数量就是 1MB 左右,但是单纯使用页大小的为 4KB 的一级页表也有一定的问题,因为一个页表项是 4 字节, 1M 个内存块就是 4MB 的开销,这样算下来,一个进程光页表就要占据 4MB 的开销,而且很多时候根本也用不到这么多的内存映射,所以这是一笔很大的内存开销,因此,我们使用二级页表的形式去实现分页功能,另外,目前 Linux 已经发展到了5级页表.
页表表项结构
如果使用二级页表,理论上,每次访问内存需要经过三次访问内存才能访问到真正的物理单元.
由上文可得,进入分页模式需要三个步骤
下面解释一下各个步骤的意义:
下一步我们要进入内核的编程,之前的 mbr.S 和 loader.S 都是使用的汇编语言,在我们使用汇编语言的时候,我们用的是 nasm 去将汇编语言转换为二进制文件,而现在到了内核,我们需要用 C 语言了,那么 C 语言是如何给变成二进制文件的呢?又是怎么和通过 nasm 汇编成的代码一起运行的呢?因此在我们写内核之前,我们需要一些前置知识.
对于高级语言如何变成二进制文件,我之前写过一篇帖子,,如果你对 C 语言编译成可执行文件的过程很熟悉,就可以不用看了.
在实现 mbr 和 loader 的时候,我们使用 nasm 汇编器直接生成纯二进制文件,当时的我们是这样调用程序的
所以到了内核这一步,我们需要处理的就不是纯的二进制文件了,而是 ELF 文件.
现在,内核被加载到内存后, loader 还要通过分析其 elf 结构将其展开到新的位置,所以说,内核在内存中有两份拷贝,一份是 elf格式的原文件 kernel.bin,另一份是 loader 解析 elf格式的 kernel.bin 后在内存中生成的内核映像(也就是将程序中的各种段 segment 复制到内存后的程序体),这个映像才是真正运行的内核.
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 内存存储的是有序的段,节头表就不使用了.
// 在 /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 文件到内存,主要是为了加载内核到内存,在上文说道,在此操作系统中内核有两份拷贝,源文件在 0x70000 处,之后会被真正的内核映像覆盖掉,至于如何将源文件加载到内核,实际上这里与从硬盘读取loader到内存的方法基本一样,区别在于是32位操作数和寻址方式.具体代码请参考 loader.S 中的 rd_disk_m_32 函数.
其实这里在这里,加载内核到内存与打开分页模式这两步其实是顺序可以互换,在代码中是先加载内核,后打开分页,这样处理会简单一点,我在叙述的时候把为了描述清楚,把分页和分段放在了一起讲
在加载完了内核源文件,我们就需要初始化内核了,主要就是解析 ELF 头和程序头,具体步骤如下:
enter_kernel:
call kernel_init ;返回地址压栈4字节
mov esp,0xc009f000 ;栈转移到可用区域 0xc0001000起始为内核映像,大小为 60KB,栈不会破坏
jmp kernel_entry_addr ;进入内核的入口虚拟地址 0xc0001500
总结一下:
loader 建立分段,分页机制等,并读取内核所在的磁盘区域,把内核加载到内存,然后跳转到内核入口处,结束自己.