操作系统之进入内核前的准备
本来是想着入门内核的,然后看着看着就看到了这篇文章,觉得学到很多,反正以后操作系统也要接触,就当初探了一下,写个学习笔记记录一下学到的东西。(果然只有自己再总结加工才能真正吸收xs)
开机会发生什么?
当按下开机键后究竟发生了什么呢?
网上搜出来大体是这样:
1 | BIOS 按照“启动顺序”,把控制权转交给排在第一位的存储设备:硬盘。然后在硬盘里寻找主引导记录的分区,这个分区告诉电脑操作系统在哪里,并把操作系统被加载到内存中,然后你就能看到经典的启动界面了,这个开机过程也就完成了。 |
现在就来具体讲讲(也没有很具体)整个过程:
先放一张实模式下的内存分布图:(不用管实模式是什么)
可以简单理解为计算机刚开机的时候就只有 1M 的内存可用,可以看到 BIOS 被映射到了 0xC0000 - 0xFFFFF 的位置。
众所周知,CPU 会把内存中的指令放入 PC 寄存器 并执行。BIOS 程序的入口地址规定是 0xFFFF0,开机的一瞬间,CPU 的 PC 寄存器会被强制初始化为 0xFFFF0;说得再具体一点就是:CPU 将 段寄存器CS 初始化为 0xF000,将偏移地址寄存器 IP 初始化为 0xFFF0,再根据最终地址的计算规则,将段寄存器左移 4 位,再加上偏移地址,得到的最终的物理地址就是 0xFFFF0。这也就是为啥开机最先执行的是 BIOS 程序了。
上面说了 BIOS 被映射到了 0xC0000 - 0xFFFFF 的位置,而开机 CPU 跳转的地方是 0xFFFF0,只剩下 16 个字节可以写,这有啥用呢?很显然根本干不了啥,所以 0xFFFF0 这个入口地址处存的是个跳转指令,跳到一个更大范围的空间去执行代码。
0xFFFF0 处存储的机器指令,翻译成汇编语言就是:
1 | jmp far f000:e05b |
意思就是跳转到 0xfe05b 处执行。
也就是说 0xfe05b 的代码,才是 BIOS 程序真正要执行的代码,这一段代码会执行很多事情,比如检测/初始化……但最重要的还是最后执行的加载启动区。
1 | 这里浅说一下 BIOS 是如何找到启动区的: |
加载的意思就是把设备程序复制到内存的进程中;
在这里就是找到启动区之后 BIOS 就会把启动区的内容复制到内存中的 0x7c00(开发团队定的) 处,指令就会在这执行。
启动区 512 字节的代码示例:
1 | ; hello-os |
附上这个流程的简图:
以 Linux-0.11 为例
BIOS 将启动区复制到内存0x7c00
开机 BIOS 会将硬盘中启动区的 512 字节的数据,复制到内存 0x7c00 的位置,并跳转到该处执行。
用图表示就是:
最开始的代码是用汇编语言写的 bootsect.s,,位于 boot 文件夹下,存放在启动区中,也就是我们复制到 内存 0x7c00 处地址的内容。
所以就从这开始分析
1 | mov ax,0x07c0 |
首先会将 ds 寄存器赋值为 0x07c0 ,众所周知,ds 寄存器是数据段寄存器,存储着数据段的起始地址。之后的地址都会以它为标准进行偏移,也方便内存利用这个地址进行寻址。
但是为啥不是 0x7c00 呢?是因为地址的寻址方式,使得段地址要先左移四位。
再挪个位置:内存0x90000
接着往下看
1 | mov ax,0x9000 |
把 es(额外段寄存器) 赋值成 0x9000,cx 寄存器变成 十进制的 256(代码里是用十进制表示的,和其他地方有些不一样),si、di 为 0。
整合一下所有的寄存器就是:
1 | ds = 0x07c0 |
然后执行最后一句 ‘rep movw’
rep 表示重复执行后面的指令,movw 表示复制一个字,所有整句话的意思就是 重复复制一个字。
那么问题来了:
复制多少次?因为 cx 记录次数,所以会复制 256 次。
从哪复制到哪呢?是从 ds:si 复制到 es:di
一次复制多少呢? 刚刚有说过复制一个字,也就是两个字节(一个字不一定是两个字节,只是这个例子是这样)
所以整段话用更通俗的语言描述出来就是:将内存地址 0x7c00 处开始往后的 512 字节的数据复制到 0x90000 处。
所以经过这么一折腾,操作系统最开始的代码,又被挪到了另一个地方:0x90000。
内存的初步规划
接着往下看
1 | jmpi go,0x9000 |
jmpi 是一个段间跳转指令,表示跳转到 0x9000:go 处执行,根据寻址方式可以知道,go 是一个偏移地址,所以这句指令意思就是跳到 0x90000+go 处地址执行。
也就是后面的
1 | go: mov ax,cs |
全是赋值操作,把 cs 寄存器的值分别复制给 ds、es、ss 寄存器,然后把 0xff00 给了sp 寄存器。
CS 寄存器表示代码段寄存器,是 CPU 当前正在执行的代码在内存中的位置,由 CS:IP 这组寄存器配合指向的,CS 是基址,IP 是偏移。
所以之前执行 jmpi go,0x9000 后,CS 就被赋值为了 0x9000,之后的 mov 操作就把 ds、es、ss 这些寄存器都赋值为了 0x9000。
ds为数据段寄存器,之前代码在 0x7c00 处,所以它被赋值为了 0x07c0,现在代码在 0x90000 处,所以自然就被赋值成了 0x9000。
ss 是栈段寄存器,会配合栈基址寄存器 sp 来表示栈顶,此时 sp 被赋值为了 0xFF00,所以现在的栈顶地址是 ss:sp 所指向的 0x9FF00。这么设置是因为代码段在 0x90000,栈顶离代码段是很远的,所以栈向下发展就很难和代码段撞上。
总结一下就是,利用跳转指令跳到空间更大的地方,给一些寄存器赋值,说白了就是做了一个内存的初步规划,通过设置基址的方法访问代码和数据,设置栈顶指针的方法访问栈。
将整个操作系统代码搬到内存中
接着往下看
1 | load_setup: |
int 0x13 表示发起 0x13 号中断,上面被赋值的寄存器都作为它的参数。这句指令表示读取磁盘。
整句代码的意思就是将硬盘的第 2 个扇区开始,把数据加载到内存 0x90200 处,共加载 4 个扇区。
大概长这样:
可以看到程序有个判断,意思就是如果读取成功,就会跳到 ok_load_setup ,如果失败就会重试。
1 | ok_load_setup: |
只看重要部分,这一段代码的意思就是把从硬盘第 6 个扇区开始往后的 240 个扇区,加载到内存 0x10000 处。
在这之后,整个操作系统的代码,就全部从磁盘搬到内存中了。
之后再通过 jmpi 0,0x9020 ,跳到 0x90200 处,也就是硬盘里第二个扇区的开始。
1 | 再补充一下,操作系统的编译过程,整个过程是通过 Makefile 和 build.c 配合完成的 |
又一次做内存调整
所以现在就会执行第二扇区的开始内容了,也就是 setup.s 文件。先看看 setup.s 的开头
1 | start: |
INT 0x10 也是一个中断指令,用来触发屏幕及显示器的服务程序。ah 寄存器被赋为 0x03 表示读光标位置。
当这个程序执行完毕并返回时,dx 寄存器的值表示光标的位置,其中 dh 存储行号,dl 存储列号。
mov [0],dx 表示把光标的位置存储到偏移为 0 的地方,也就是 0x90000。
接下来的代码也和之前的套路差不多,一一对应着看就行。(参考链接:https://zh.wikipedia.org/zh-cn/BIOS%E4%B8%AD%E6%96%B7%E5%91%BC%E5%8F%AB)
1 | 比如获取内存信息。 |
经过这一连串的代码后,各地址存储的内容如下:
之前说过 0x90000 往后的 512 字节被复制成了 bootsect,所以这个地方相当于覆盖掉了一些 bootsect 的内容。
之后在分析用 C语言 编写的操作系统时,相应的变量会在上面给出的对应地址中取。
存储好信息后,接着往下看
1 | cli ; no interrupts allowed ; |
cli 是关闭中断的意思
继续看
1 | ; first we move the system to it's rightful place |
结合之前分析的,这段代码的意思就是把内存 0x10000 到 0x90000 的内容复制到 0x00000 处。
也就是 0x00000 到 0x80000 的内容被复制成了 system,包括之前的 0x7c00 处的 bootsect 也被覆盖了;system 可以理解为操作系统的全部,是除 bootsect 和 setup 之外全部程序链接在一起的结果。
那么现在的内存布局就是这样:
总结一下前面,操作系统又给自己移了下位置:从 0 开始存放着操作系统的所有代码,即 system,0x90000 之后的几十个字节存放了一些设备信息。
模式转换
接着会进行一项大工程,模式转换,即把现在 16 位的实模式转换成 32 位的保护模式。先别急着纠结什么是实模式和保护模式,等会就说。
1 | lidt idt_48 ; load idt with 0,0 |
要读懂这些指令,得先清楚实模式和保护模式寻址的区别:
实模式:物理地址=段基址左移四位+偏移地址。
保护模式:段寄存器(比如 ds、ss、cs)里存储的是段选择子,段选择子去全局描述符表 gdt 中寻找段描述符,从中取出段基址。
整个过程如下:
取出段基址的前提是 CPU 要知道存段基址的地方在哪,所以操作系统会把这个位置存储在一个叫 gdtr 的寄存器中。
lgdt gdt_48
就是用来干这事的。
lgdt 指令就是把后面的 gdt_48 存入 gdtr 寄存器中。
再具体看看 gdt_48 长什么样:
1 | .word 0x800 ! gdt limit=2048, 256 GDT entries |
看标签可以看出它是个 48 位的数据,其中高 32 位存储着 gdt 的内存地址:0x90200+gdt
指令中的 gdt 代表处在当下文件的偏移量,而文件又是 setup.s,所以基址就是 0x90200,所以 全局描述符表 gdt 所在的地址就是 0x90200+gdt。
gdt 里存储了一系列段描述符:
1 | gdt: |
看一下全局描述符表的构成:
可以看到目前有三个段描述符,第一个为空,第二个是代码段描述符,第三个是数据段描述符。
第二个和第三个段描述符的段基址都是 0 ,也就是说通过段选择子找到的无论是代码段还是数据段,取出的段地址都是 0 ,所以物理地址就是直接给出的偏移地址。
现在再来讲讲 lidt idt_48 是干嘛的
1 | idt_48: |
功能和 lgdt gdt_48 差不多
之前有说过 gdtr 寄存器存储着全局描述符表gdt的地址
idtr 寄存器存储的是中断描述符表idt的地址,当发生中断时,CPU 就会通过中断号去中断描述符表中找到中断处理程序的地址,然后跳过去执行。
总结一下前面,利用两个指令将全局描述符表和中断描述符表地址放入相应的寄存器中,也讲了描述表的地址要怎么看。
现在内存结构:
正式开启保护模式,跳至system处执行
上面说的只是进入保护模式前准备工作的一部分,接着往下看。
1 | mov al,#0xD1 ; command write |
这段代码的意思是:打开 A20 地址线
我们知道的是 8086 的 CPU 地址总线是 20 位,所以如果程序给出 21 位的内存地址,多出来的一位就会被忽略,举个例子就是:1 0000 00000000 00000000
那个 1 因为处在第 21 位,所以会被忽略,整个内存地址算下来就是 0,如今 CPU 已经有 32位 和 64位,但是因为兼容性,还是要保持只能用 20 位地址线的模式,所以打开 A20 地址线就相当于让地址总线突破 20 位变为 32位。
接着往下就是一大坨代码,不需要特别了解,就是对可编程中断控制器 8259 芯片进行的编程,然后引脚和中断号关系对应如下:
然后才到真正切换模式的时候:
1 | mov ax,#0x0001 ; protected mode (PE) bit |
前两行把 cr0 这个寄存器位 0 置 1,就会切到保护模式
后一行 jmpi 0,8
,8 表示 cs 中的值,0 表示偏移地址。因为现在是保护模式,所以 cs 里存的是段选择子。8 用二进制表示就是:00000,0000,0000,1000
对应着表的结构图可以发现,描述符索引就是1,再根据这个索引去全局描述符表里找段描述符,从而取出地址。
之前有说过,全局描述符表中0对应的空,1对应的代码段描述符(可读可执行),2对应的数据段描述符(可读可写),所以这里对应的就是代码段描述符,段基址都为0。又因为偏移也是0,所以整个地址就是0。也就是跳到内存地址为0处。
内存地址为 0 处之前也说过是system,system 可以理解为操作系统的全部,是除 bootsect 和 setup 之外全部程序链接在一起的结果。现在 bootsect 和 setup 两个文件都分析过了,所以盲猜 head.s 就是 system 的一部分,我们接下来就分析它。
总结一下之前的,开启了保护模式,并跳转到了 system 处执行。
再次设置 idt 和 gdt
1 | _pg_dir: |
_pg_dir 表示页目录,之后设置分页机制,页目录会存放在这。
之后就是把 ds、es、fs、gs 段寄存器赋值为 0x10,0x10对应的二进制数是10000,对应全局描述符表里的数据段描述符。(描述符索引里是 10,转换成十进制就是 2 )
lss 指令表示把 esp 指向 _stack_start(之前在 0x9FF00)
这里补充一下 _stack_start,它被定义在 sched.c 里
1 | long user_stack[4096 >> 2]; |
stack_start结构中高位 8 字节是 0x10,会赋值给 ss 栈段寄存器,低位 16 字节是 user_stack 这个数组的最后一个元素的地址值,会赋值给 esp 寄存器。又因为 0x10 对应全局描述符表里的数据段描述符,基址为 0。所以整个栈顶的地址,就是 esp 里存的地址。
继续往下看
1 | call setup_idt ;设置中断描述符表 |
设置了 idt 和 gdt ,然后又重新执行了一次之前的操作
重新再执行一次的原因是因为上面修改了 gdt
先来看看 setup_idt ,即设置 idt 的具体代码:
1 | setup_idt: |
这段代码的作用就是,设置了 256 个中断描述符,每个中断描述符都指向 ignore_int 的函数地址,ignore_int 是一个默认的中断处理程序,之后会被具体的中断程序覆盖。就比如你现在敲键盘是没什么反应的,因为键盘模块的中断程序还没覆盖掉它,任何中断都是调用 ignore_int 程序。
setup_gdt 也是同理就不多说了,直接看设置好了的结果吧
1 | _gdt: |
其实和之前设置的一样,也还是有代码段描述符和数据段描述符这些。
为什么之前设置过了现在又要设置一遍呢?
是因为之前设置的 gdt 所处的 setup 程序中,之后会被缓冲区覆盖,所以要重新给它挪个位置,挪到head。然后结果就变成了这样:
总结一下之前说的就是,把 idt 和 gdt 移位,其中 idt 方面给每个中断设置了一个默认中断程序 ignore_int,gdt 方面没有太多变化。
分页
1 | jmp after_page_tables |
这就是开启分页机制,并且跳转到 main 函数。
先来看看什么叫分页机制。
还记不记得,在保护模式下,我们在代码中给出一个内存地址,要经过分段机制的转换,才变成最终物理地址。
开了分页机制后,就多了一步转换
也就是说,在没有开启分页机制时,程序员给出的逻辑地址,通过分段机制转换成物理地址;
开启分页机制后,逻辑地址仍然要通过分段机制进行转化,但是这个时候得到的是线性地址,再通过一次分页机制转换,才得到最终的物理地址。
分页地址是如何转换的呢?
比如给出一个线性地址 15M ,二进制表示就是 0000000011_0100000000_000000000000
整个过程:(个人感觉应该是 13 M)
也就是说,线性地址会被拆成 高 10 位:中间 10 位:后 12 位
高 10 位负责在页目录表中找到一个页目录项,中间 10 位再去该页目录项中找到相应的页表项,这个页表项的值,再加上后 12 位的偏移地址,得到最终的物理地址。(由内存管理单元MMU负责)
浅看一下页目录项和页表项的结构:
当我们开启分页机制的开关,只需要更改 cr0 寄存器的一位即可,和之前开保护模式差不多
所以之前的那段代码,就是帮我们把页表和页目录存在内存中,然后开启 cr0 寄存器的分页开关。再粘出来看看。
1 | setup_paging: |
这段代码会产生什么效果呢?
当时 linux-0.11 认为,总共可以使用的内存不会超过 16M,即最大地址空间为 0xFFFFFF。
而对应分页机制,1 个页目录表最多包含 1024 个页目录项(页表),1 个页目录项最多包含 1024 个页表项(页),1 个页表项位 4 kb(因为偏移有 12 位),所以 16 M 的地址空间可以用 1 个页目录表 + 4 个页表搞定:4(页表数)* 1024(页表项数) * 4KB(一页大小)= 16MB
所以,上面这段代码,会将页目录表放在内存地址的最开头,也就是 head.s 的最开头的 _pg_dir
1 | _pg_dir: |
之后紧挨着这个页目录表,放置 4 个页表
1 | .org 0x1000 pg0: |
最终布局如下:
同 idt 和 gdt 一样,我们需要告诉 CPU 这些页表存在了什么地方,通过下面这个代码实现:
1 | xor eax,eax |
将 cr3 赋为0,0 地址处就是页目录表。
现在,看一下整个内存的布局是什么样的:
页表设置好了,我们再来看看内存是怎样映射的:
1 | setup_paging: |
前 5 行,以第 2 行为例,[eax]被赋为 pg0+7,也就是 0x1007,页表地址为 0x1000,页属性为 0x07 表示改页存在、用户可读写。
后面几行表示,填充 4 个页表的每一项,一共 4 * 1024 = 4096 项,依次映射到内存的前 16MB 空间。
经过这套分页机制,线性地址将恰好和最终转换的物理地址一样。
也就是如下图的效果:
至今为止,该设置的什么 idt、gdt、页表都设置好了,也开启了保护模式,之后就正式进入 main.c 了。
正式进入 main.c 还是有一个过程的,现在来讲一下这个过程。
main.c 是怎么被执行的呢?还是要回去看一下 head.s,设置分页的那个地方。
1 | after_page_tables: |
进行五次压栈,然后跳到 setup_paging,注意 setup_paging 函数最后有一个 ret 指令,会将栈顶的元素值当作返回地址(将esp赋值给eip),然后跳转过去。
整个栈结构如下:
L6 作为 main 函数的返回地址,三个 0 作为 main 函数的参数,都不用太关心。