文章主要参考新设计团队的《Linux内核设计的艺术》,哈工大李治军老师的操作系统慕课;图是通过draw.io画的~
一、操作系统是什么?
操作系统是用来管理计算机硬件的软件,狭义上实现该定义的为操作系统内核 ;而更加宽泛的操作系统概念为根据内核对外提供了一些OS服务 ,比如windows的图形化界面等 。
二、操作系统启动过程:
既然操作系统是在硬盘或软盘中的程序软件,那么按照冯诺依曼体系--取指执行,我们需要将操作系统从硬盘中加载到内存中,这样CPU 才可以取指执行;
2.1 第一个程序:BIOS
按下开机键后,内存(RAM) 在刚上电时是空白的,所以CPU中的CS(段地址):IP(段内偏移) 指针如何知道运行的第一条代码是什么呢?
段寄存器名称 |
含义 |
指向的初始地址 |
CS |
段基址 (16位) |
0xF000 |
IP |
段内偏移(16位) |
0xFFF0 |
计算机是在硬件层面解决这个问题,即在CPU刚上电时,将其置为16位模式 ,启动20根地址线(A0 ~ A19,即内存的寻址空间为20位),并将CS:IP 直接置在0xF000:0x0000 ,即0xFFFF0 地址处(计算方法为 CS<<4 + IP,CS为什么要左移四位呢?是因为一共有20位的寻址空间,这样才可以寻址到全部的内存地址空间),而该地址为ROM上BIOS程序的入口地址,这一段是硬件固化好的程序 ,所以计算机执行的第一段代码为BIOS ;
BIOS 不是操作系统,所以它最核心的任务是要将内核代码从磁盘中加载进内存,它是如何做到的呢?
BIOS程序 首先在内存空间中初始化了中断向量表 (用来指向相应中断号的中断程序的CS:IP地址,共1KB),而一个中断向量为4个字节(CS:16位,IP:16位),并且初始化了数据区域;
而接下来BIOS 就会通过调用0x19号 中断(INT )服务程序加载磁盘中第一个扇区(512Bytes)中的bootsect程序 (--内核中的引导程序)到内存0x07C00处 ,并且jmp 到该地址执行;
//bootsect.s
BOOTSEG = 0x07c0 ! original address of boot-sector
复制代码
2.2 第二个程序:bootsect.s
bootsect 是操作系统的引导扇区中的代码,其主要责任是将后续的内核代码(主要是setup代码和system代码 )妥善的加载到内存中,并且因为此时计算机只打开了20根地址线 ,所以内存寻址范围为2^20 = 1MB ,所以bootsect代码 首先要对内存进行规划。
SETUPLEN = 4 ! setup程序代码一共占4个扇区
INITSEG = 0x9000 ! 将bootsect代码移到0x9000(稍后会分析为什么要移)
SETUPSEG = 0x9020 ! setup程序会以 0x9020 为起始地点加载
SYSSEG = 0x1000 ! system(内核)会以 0x10000 为起始地点加载
SYSSIZE = 0x3000 ! 内核模块的长度
ENDSEG = SYSSEG + SYSSIZE ! system(内核)模块截止的位置
复制代码
内存规划情况如下:
而迁移bootsect 主要的原因在于system(内核)模块之后会迁移到0x00000处,如果不进行迁移,那么bootsect程序就会被覆盖,在说setup模块时会提到;
规划好内存,接下来就会把setup模块和system模块依次通过0x13 中断载入到内存的相应位置中,最终效果如上图所示;
在内存中不仅需要规划程序代码内存区域,也要对一些段寄存器进行重新初始化;
这里总结一下会涉及到的段寄存器,而这些段寄存器都处在CPU中:
段寄存器名称 |
含义 |
指向的初始地址 |
CS |
段基址 (16位) |
0x9000 |
IP |
段内偏移(16位) |
[go] 复制bootsect时运行到bootsect程序的位置,这样可以接着执行bootsect |
SS |
内核栈基址指针(16位) |
0x0000(与CS合为0x90000) |
SP |
内核栈栈顶指针 (16位) |
0xFF00 (0x9FF00) |
DS |
数据段寄存器(16位) |
0x0000 (0x90000) |
其中SS与SP 初始化了一个从高地址向低地址压栈的栈结构,从而程序在接下来的指令中可以使用pop与push操作 运行一些复杂的数据运算类指令。
2.3 第三个程序:setup
我们已经通过bootsect.s 将setup模块 加载至0x92000处 ,正好在bootsect.s 运行结束的位置,并且system 内核模块也已经加载进入内存,而setup 主要作用是让操作系统设置起来 。
- 首先操作系统最主要的功能是用来管理硬件,所以
setup 会通过BIOS提供的中断程序 加载内核(system程序)运行所需的机器系统数据,例如:光标位置(0x03 )、显示页面等数据,并通过0x41、0x46中断向量所指的内存区域中获取硬盘参数表1和硬盘参数表2 ,而这些数据(510Bytes)放在bootsect区, 可见操作系统对于空间的利用是十分严格的;
- 由
20位寻址 转变为32位寻址 (即由实模式转变为保护模式)
因为20位地址空间 只有1MB 的寻址空间,这对于一个现代操作系统来讲着实捉襟见肘,所以要转变为保护模式 ;
(1)第一步我们需要关闭中断并且将system内核模块 移动到0x00000处;
为什么要关中断,是因为我们会将BIOS中断体系摧毁,BIOS中断体系是不适合32位的操作系统的 ,所以在32位保护模式中断体系没有建立起来前中断是一直关闭的;
如何关闭中断,将CPU中的标志寄存器(EFLAGS)中的中断允许标志(IF)置0 ;
为什么要移动system模块 ,人家不是呆在0x10000处 挺好的嘛?如果此时要移动,那为什么不在一开始就直接把system模块 放到0x00000处呢?
因为:1)当我们移动至0x00000处 时,会覆盖掉BIOS的中断向量表、BIOS数据区、中断服务程序 ,而这也使得BIOS中断体系被废除掉 ;
2)让内核程序的寻址空间开始地址为0x00000 ,这样内核的线性地址与物理地址是相同的;
3)一开始没有放在0x00000处 是因为我们在加载完system 模块后仍然需要使用BIOS中断服务 。
(2)构建中断描述符表(IDT )和全局描述符表(GDT ):
IDT 与BIOS中断向量表 作用相同,都存储着中断处理程序的入口地址,不同的是:IDT 的位置是可以改变的,其在内存中的基地址存储在CPU的IDTR 寄存器中,这样给操作系统的内存规划带来了灵活性;
GDT 中存储段描述符,用于找到数据段、代码段等段地址,在进程状态查询、进程切换中扮演了比较重要的角色;该表在内存中的基地址存储在CPU的GDTR 寄存器中;
gdt_48:
.word 0x800 ! gdt limit=2048, 256 GDT entries //GDT的限长
.word 512+gdt,0x9 ! gdt base = 0X9xxxx //GDT表的基地址
复制代码
在实模式的CS:IP 中,CS中存储的是代码段基址,而在保护模式下 ,通过GDT 确定CPU下一个指令的位置,CS中存储的是GDT表中对应的段选择符 ,
mov ax,#0x0001 ! protected mode (PE) bit
lmsw ax ! This is it!
jmpi 0,8 ! jmp offset 0 of segment 8 (cs)
复制代码
其中0 表示段内偏移,而8(1000) 表示GDT表的选择子 ,末尾00 表示当前为内核态,0 表示访问的是GDT表,1 表示是GDT表的第2项(从0开始计数)
gdt:
//GDT的第一项 为空
.word 0,0,0,0 ! dummy
//GDT的第二项,内核代码段描述符
.word 0x07FF ! 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ! base address=0 //CS下一条指向的代码段位置
.word 0x9A00 ! code read/exec
.word 0x00C0 ! granularity=4096, 386
//GDT的第三项 内核数据段描述符
.word 0x07FF ! 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ! base address=0
.word 0x9200 ! data read/write
.word 0x00C0 ! granularity=4096, 386
复制代码
那计算机在保护模式下为什么要通过这种间接的方式寻找段基地址等信息呢?
是因为硬件版本兼容的考虑,CS 寄存器只有16位,在20位 寻址模式下,CS只要左移四位(最后四位默认为0)即可表示出20位的地址空间;而在32位 地址空间下,CS需要表达的信息不仅有段基址,段限长,还需要有权限信息,而这会导致需要64位的段信息,CS 表示不了这么多信息,便用作GDT表的索引,去找到该段对应的一个64位信息,并且我们可以看到GDT的每一项都是16个16进制位,即64个二进制位。
(3) 打开A20 地址线,转变成32位寻址模式:
(4)为建立32位中断体系 做硬件层面的准备:
setup程序 对8259A(可编程中断控制器) 进行重新编程,因为此时的中断控制器对应的还是BIOS中断体系 ,此处需要一定的单片机基础 ,我们可以对中断控制器进行编程,设置中断条件,中断处理函数等等......
2.4 system模块的head.s开始执行:
head.s 程序的主要作用在于为操作系统第一个.c 函数main.c 的运行做准备工作;
2.4.1 建立页目录与页:
- 为什么要建立页目录? 主要是为了便于内核对内存进行管理,为什么要在初始地址建立页目录?是因为这样在内核中线性地址与物理地址相同,不需要通过
MMU表 进行从线性地址到物理地址的映射;MMU在进程内存管理中会涉及到,与Java虚拟机中的TLAB作用相似,即给各个进程分配各自的内存空间,这样就不会产生多进程的数据冲突。
- 如何建立页目录?
在0x000000 处开始的4KB 内存空间建立页目录表,并将页目录的前四项指向页1、2、3、4,这样页目录表便设置完成了;接着设置页表,页表中一个页表项对应了内存空间中的一个4k 的页表,然后按照从高地址向低地址写的特性,从0xFFFFFF~ 0xFFF000 这个内存中最后一个4K 页的地址存放到页4最后一个页表项中,以此类推,从0xFFFE00开始的4k页 由页4倒数第二个页表项引用。
2.4.2 重建GDT表与IDT表
(1) 在setup.s 程序执行过程中,我们废除了原有的BIOS中断机制 ,并重新设置了可编程中断控制器,这样硬件层面的中断已经处理完成,所以我们要在软件层面完成32位中断体系的建立--即中断描述符表(IDT)的建立与中断程序的挂接(与BIOS中断体系一致);IDT表 存储在0x054AA处 ,即紧跟在页4后,一共有256个中断entries 入口。
可以品味一下两个中断表名称的差异,中断向量,何为向量,即直接存储中断程序的IP地址;何为中断描述符表,即存储中断描述符,中断描述符中存储了该描述符对应的中断程序的32位IP(EIP)段内偏移地址 ,16位CS(GDT段选择符)地址 ,还有一些权限信息--DPL,该页是否存在于内存中(P,为虚拟内存的换页提供信息),段描述符的TYPE等信息 ,前两者确定了中断程序的32位地址,也侧面说明了我们为什么要重建中断体系的 核心原因;而初始中断程序都是挂接在默认中断程序ignore_int 上,等后续的main函数运行时再进行挂接具体函数 ,这样的好处在于如果使用了错误的中断号,也可以获得一个默认的提示信息反馈,知道当前系统没有提供这个中断;
(2) 在setup.s 程序执行过程中,我们其实已经新建了一个GDT 表,其中有两项,一项指向内核数据区,一项指向内核代码区;那我们为什么要重建呢?需要注意GDT表的 位置是0x90200起 ,而这部分区域在之后的内存规划中是设置为缓冲区的,所以为了防止GDT表 被覆盖导致CPU无法找到下一条指令的位置从而崩溃的问题,我们需要将GDT 放在安全的地方--0x054B2 ,即紧跟在IDT表后 。
完成上述设置后,内存从0x00000 开始的内存空间如下所示:
2.4.3 重新设置段寄存器:
因为寻址模式和系统位数(16~32)的改变,CS 中不再存储段基址,而是存储对应GDT寻址方式的段选择符 ,所以我们需要将SS 也需要转变为栈段选择符(还是16位),而栈顶指针(SP )因为可能不与CS 在同一个段中,故要改为32位的ESP ;
2.4.4 跳转到main.c函数执行:
经历了前面一系列的铺垫,终于可以执行C语言函数了,因为C作为一个高级语言,它的编译运行离不开操作系统的支持。
那是如何跳转的呢?我们知道程序的运行离不开数据结构-栈,C语言实现的函数调用,本质上是封装了通过共享栈完成参数的传递、被调用函数的执行与返回结果的过程;
所以我们调用main 函数也是通过push与pop 完成的。
当head.s 完成GDT、IDT、页表的设置后,会跳转到setup_paging 执行,而后再通过往内核栈里压入调用main()函数的参数 --envp、argc、argv[] 和L6标号 以及当前程序执行到ret(返回指令) 后,需要调用的函数--main函数 的地址;
为什么要通过这种方式,我们知道main 的地址,直接jmp不好吗?是因为安全性的考虑,mian() 函数是不应该退出的,如果退出则说明出现了异常,则会返回到L6 ,而在这个标号的程序处,我们可以做一些系统调用,给与用户一些提示信息,并维持进程的轮转,而不至于完全崩溃。
三、 总结:
至此,计算机便算是基本启动起来,而操作系统也会在main()函数中 初始化一些数据结构用来管理硬件资源、设置各种中断程序、初始化进程等等操作,这会在下一篇详细讲~
|