一个操作系统的实现
说明:本文是一个简单的学习记录,不是全面给大家提供学习的文章,文章内容均代表作者的个人观点,难免会有错误。转载请保留作者信息。
2010/11/20
sylar_xiong
MSN& Email:cug@live.cn
准备:UbuntuOS, 虚拟机(用于调试OS内核),这个新OS是一个简单的,常用的OS,以Intel I32为例(他帮我们完成了很多功能,例如中断/保护模式/特权级)。
首先需要掌握几个基本概念:
1 系统的启动顺序
系统会首先运行boot(实模式), 然后运行Loader,最后运行kernel(假设为微内核,加载文件系统,MM,shell等模块)。上个图:
启动阶段 |
完成的工作 |
说明 |
位置 |
|
1 Boot.bin |
加载loader进内存 |
只有512k |
位于硬盘或者软盘的第一个扇区 |
|
2 Loader.bin |
加载内核,启动保护和分页 |
|
|
|
3 Kernel.bin |
|
|
|
|
2 保护模式
系统启动的时候是实模式,在loader阶段实现实模式向保护模式的跳转。
首先看一下实模式: 8086的地址总线为20位,物理地址=段×16+offset 所以最大可以寻址1M的内存空间(2的20次方)。在实模式下,段0xXXXXh表示0xXXX0h开始的一段内存区域。
其次看一下地址的概念:我们平时调试程序的时候看到的地址均为logicaladdress,例如地址A等于0x40008000h, OS寻址的时候首先会根据GDT/LDT将A转化为linear address A’ , 这就是所谓的段地址转化。 接着A’会根据页表转化为A’’,这个A’’就是真正的physical address所在了。总结一下也就是:logical address -> linear address -> physical address(实际上我吧第一次跳转理解为段寻址,第二次跳转理解为页寻址)
保护模式的寻址方式为:
描述符1包括: |
段基址 |
段界限 |
属性 |
一般为全局段 |
描述符2包括: |
段基址 |
段界限 |
属性 |
|
LDT1 |
|
|
|
和进程相对应 |
LDT2 |
|
|
|
|
PED |
|
|
|
分页机制 |
PTD |
|
|
|
|
GDT(每个GDT中包含很多描述符和LDT,每个描述符描述了一个段(可以是数据段,代码段/堆栈段/系统段或者门描述符)的信息,包括段的基地址,界限和段的属性。其中段的属性包括段的特权级。GDT中还包含了很多指向LDT的描述符,每个LDT代表一个单独的进程,我们把一个单独的进程随需要的各种数据,例如数据段/代码段/堆栈段封装在一个LDT里面)
看一段简单的实模式跳转到保护模式的代码:
[section .gdt] // GDT段标识
LABEL_GDT: descriptor 0, 0, 0; //GDT首地址
LABEL_DESC_CODE32:descriptor 0, 200, 0;
LABEL_VIDEO: descriptor0xb8000h, 0ffffh, DA_DRW; //显存段基址为0xb80000h 段界限为offffh,属性为该段在内存中存在/为代码段
SelectorCoderequ LABEL_DESC_CODE32 – LABEL_GDT
SelectorViedoequ LABEL_DESC_VIDEO – LABEL_GDT
[section .s16]
[BITS 16] //16位代码
Cli//关中断
//打开地址线A20
jmp dword SelectorCode32:0 //跳转到LABEL_SEG_CODE32的0位置处。
[section .s32]
[BITS 32]
LABEL_SEG_CODE32:
Movax, SelectorVideo
Movgx, ax //gx为显存段首地址
分页机制补充:通过GDT,LDT得到线性地址后,会利用PED(页目录)和PTD(页表),PED中每个表项指向了一个PTD,而一个PED中每个表项指向了一个世纪的4K物理地址。
同时应该了解到,我们调试程序的时候看到的地址是线性地址,所以运行同一个可执行文件2次的地址和寄存器都是完全一样的,事实上他们的物理执行地址并不相同。
3 二进制文件 or 可执行文件格式
纯二进制文件:内存映像和二进制文件映像是一样的。
.bin文件: = loader.bin = COM文件:dos运行的文件
ELF文件:OS kernel的文件形式。他的结构如下(例如kernel.elf):
文件开始处 |
结构说明 |
备注 |
|
Elf header |
Elf文件头,包含文件大小,属性等 |
|
Program header1 |
数据段在文件中的位置和内存中的位置 |
|
Program header2 |
代码段从文件中到内存中的映射关系 |
|
|
文件执行的时候首先查看elf header,然后根据 Program headerN将每个段加载到内存相应的位置并执行。 |
4 特权级
我首先假定内核的level=0 系统服务的level=1 应用程序的level=3。程序从一个代码段转移到另一个代码段时(可能的情况是调用系统函数),需要考虑权限问题,利用了CPL,DPL和RPL来判断是否可以进行代码转移。
利用call调用门实现一个低特权级的进程访问高特权级的代码段,利用ret指令实现高特权级到低特权级的跳转。
Jmp时,不管长跳转还是短跳转都一样的实现。Call时,长跳转需要比短跳转额外保存一下当前进程的cs地址到堆栈段中(因为返回时需要知道要返回到哪个代码段)。例如用户进程A(ring3)执行时通过调用门访问系统进程B(ring0)的代码段(可以理解为一个系统函数调用),执行完后返回,这个过程可以归纳如下:
1) 用户进程A将参数,返回值,cs地址保存到LDT进程A的堆栈段中。
2) 用户进程A的程序通过call调用门跳转到系统进程B中执行。由于现在已经是B中的代码在执行,所以相应的使用的是B的堆栈段。同时,A的堆栈段的位置ss和esp会保存到TSS(每个进程一个)
3) B中相应的代码执行完毕(对应命令ret),执行完毕后通过取出TSS中保存的A的堆栈段中的返回地址来返回A。
反过来,由一个系统进程(ring0)进入用户进程(ring3)的关键在于,在ring0向ring3跳转之前,手动保存ring3进程A的当前cs,eip,ss,esp到A的ss中,然后调用命令retf,这个命令会自动加载A的cs,eip,ss,esp到CPU寄存器,并跳转开始执行A。这就实现了ring0 toring3。
5 中断和异常
中断是一个计算机执行的根本,进程调度,键盘输入等等都是中断。保护模式下,中断是通过IDT(中断描述符表)来实现的,中断发生时,会在IDT中找到对应的描述符,并转到相应的中断处理函数处运行。终端分为软件中断(通过int N命令实现)和外部硬件中断(例如时钟中断,鼠标键盘中断等)。
其中外部硬件中断需要通过和CPU的INTR引脚相连的2个8258A芯片实现。
下面只讨论保护模式下的中断:
保护模式下,有一个中断寄存器,指示了IDT的位置和IDT的大小。中断分为3种类型:中断门(运行时关闭中断,只能由内核调用);陷阱门(运行时不关中断,只能由系统调用);系统门(在用户态下,可以使用int3、into、bound 及int0x80四条汇编指令进入系统门)
从深层次看一下中断的过程:
1.CPU检查是否有中断/异常信号
CPU在执行完当前程序的每一条指令后,都会去确认在执行刚才的指令过程中中断控制器(如:8259A)是否发送中断请求过来,如果有那么CPU就会在相应的时钟脉冲到来时从总线上读取中断请求对应的中断向量。
对于异常和系统调用那样的软中断,因为中断向量是直接给出的,所以和通过IRQ(中断请求)线发送的硬件中断请求不同,不会再专门去取其对应的中断向量。
2. 根据中断向量到IDT表中取得处理这个向量的中断程序的段选择符
CPU根据得到的中断向量到IDT表里找到该向量对应的中断描述符,中断描述符里保存着中断服务程序的段选择符。
3.根据取得的段选择符到GDT中找相应的段描述符
CPU使用IDT查到的中断服务程序的段选择符从GDT中取得相应的段描述符,段描述符里保存了中断服务程序的段基址和属性信息,此时CPU就得到了中断服务程序的起始地址
6 最简单的文件系统FAT12
假设我们有一个硬盘,划分为了2个分区C和D。C为FAT12,D为NTFS。下面仅仅看看C分区的结构。从小到到依次为:扇区,簇,分区。其中软盘或硬盘第一个扇区被称为引导扇区。
…… |
数据区 |
|
…… | ||
扇区n |
根目录 |
存放文件信息(包括文件名,修改时间,文件大小,文件内容对应的数据区索引)和目录结构 |
…… | ||
扇区19 | ||
扇区18 |
FAT2 |
|
…… | ||
扇区10 |
FAT1 |
对于文件大小大于512字节的文件来说,将一个文件对应的所有扇区串联起来。 |
…… | ||
扇区1 | ||
扇区0 |
引导扇区 |
|
一般而言,引导扇区存放boot.bin, boot..bin运行的时候会寻找根目录中的loader.bin文件,如果找到这个文件名,就会从数据区中将loader.bin取出加载到内存相应位置。
下面开始正文
一 一个操作系统的启动过程
1从硬盘或者软盘的引导扇区(最大只能为512b,所以有了Loader.bin)开始启动,引导扇区(boot.bin)完成的主要任务是从硬盘(或者软盘,这里假设为FAT12格式的文件系统)中的根目录找到并加载Loader.bin到内存0x90000h处并将控制权交给boader.bin(即跳转到0x90000h处执行)。
2loader.bin 首先在硬盘(软盘中)查找并加载kernel.elf到内存(具体的过程就是将kernel.elf中不同的段放到不同的内存位置)
3Loader.bin中定义了GDT和一个GDT指向的代码段(假设为LABEL_PM_START)。然后loader.bin执行一系列汇编打开保护模式并最终通过jmp跳转到LABEL_PM_START处。
4进入保护模式之后,Loader.bin初始化各个寄存器,初始化堆栈,打开分页机制(步骤为:首先获取可用内存信息,根据可用内存信息初始化GTD中的页目录和页表,此时的分页机制还仅仅只是对等映射。)。
5loader将kernel.elf加载到物理地址
6由于kernel是一个elf文件,所以loader还需要把kernel.elf的不同段复制到指定的地方。此时的物理内存情况为:
起始物理地址 |
用途 |
备注 |
………… |
………… |
|
101000h |
Page tables(PTE) |
|
100000h |
PDT |
页目录 |
F0000h |
System ROM |
|
E0000h |
Expansion of sys ROM |
|
C0000h |
|
|
A0000h |
|
|
9fc00h |
系统保留 |
|
90000h |
Loader.bin |
Loader原文件 |
80000h |
Kernel.bin |
内核原始文件 |
30000h |
Kernel |
整理后的内核 |
7e00h |
Free |
|
7c00h |
Boot sector |
|
500h |
Free |
|
400h |
ROM BIOS参数 |
|
0h |
Int vectors |
|
7接着Loader将控制权交给kernel,跳转到内核的起始地址0x30400h。注意虽然控制权现在已经是内核了,但是esp仍然指向Loader虽在的内存中,同时GDT信息也在loader的内存中,所以我们需要将esp指向kernel中的堆栈,并且将loader中的GDT复制到kernel中。
8内核开始运行,内核的运行情况见后文。
运行到这里,看看可能的文件组织结构:
Tree:
|--Boot
|--boot.asm //boot.bin
|--loader.asm // loader.bin
|--include
|--load.inc
|--pm.inc //保护模式相关
|--fat12hdr.inc //文件系统相关
|--include
|--const.h
|--type.h //数据类型
|--protect.h
|--kernel
|--kernel.asm //kernel.bin 其中包含内核入口点_start和异常处理入口点。
|--start.c //内核的C入口cstart() / 初始化IDT
|--lib
|--string.asm //string
注意,在写makefile的时候,至少要3个标签,分别为boot loader kernel 和 clean
9这时,内核已经掌握了控制权,但是由于没有中断,内核几乎不能做什么工作(不能进程调度,不能接受输入),所以接下来需要完成中断处理。步骤为:
1)设置和CPU直连的2个8259A芯片的寄存器,将硬件初始化(代码位于starts.c中)。
2)手动建立IDT。
3)建立异常处理,过程如下。
………… //kernel.asm
divide_error: //如果发生异常便会跳到此函数处运行
push 参数(包括错误码和错误的ID)
call exception_hander //除零错处处理函数,显示错误吗
…………..//start.c中运行
idt[INT_VECTOR_DEVIDE].selsect= GDT_idtseclet; //寻址时先在GDT中找到IDT的段地址在加上偏移找到devide_error()的线性地址
idt[INT_VECTOR_DEVIDE].offset= devide_erro r; //错误处理函数为devide_error(); //idt中每一个元素代表了一个中断向量,发生中断时候,CPU会首先根据中断寄存器找到idt的起始位置,然后根据中断向量号和idt数组找到中断处理程序的位置,并保存相关寄存器后跳转到中断处理程序处运行!
4)建立中断处理(步骤和建立异常类似,就是中间多了一个设置初始化8259A)。
未完待续