从零开始研究OS启动过程
初三 议论文 12195字 233人浏览 sylh小石头

前言

本篇文章并不旨在完整的讨论一个多引导系统程序怎样去引导不同的操作系统,而只打算从编写操作系统的角度出发,谈谈计算机怎样从加电开始,从无到有,将操作系统运行起来,在其中将尽量详尽的描述从实模式到保护模式的过渡,目的只在于能将所学与广大爱好者更享,为希望开发操作系统的朋友留下一点资料,也为自己留下一点心得。本篇文章将以开发中的 pyos 系统引导程序为例,pyos 是一个正在开发中的实验型操作系统,它并不打算以目前任何一种运行中的操作系统为模式,而只想通过自己编写一个从头到尾的操作系统来学习知识,积累技术,如果你有兴趣,非常欢迎你的加入!本篇纯属学习过程中的一点心得体会,如果你发现其中有错误或不当之处,非常希望你来信指教。

一、计算机从加电开始都做了什么?

当机算机的电源键被按下时,同这个键相联的电信号线就会送出一个电信号给主板,主板将此电信号传给供电系统,供电系统开始工作,为整个系统供电,并送出一个电信号给BIOS ,通知 BIOS 供电系统已经准备完毕。随后 BIOS 启动一个程序,进行主机自检,主机

自检的主要工作是确保系统的每一个部分都得到了电源支持, 内存储器、 主板上的其它芯片、键盘、鼠标、磁盘控制器及一些 I/O 端口正常可用,此后,自检程序将控制权还给 BIOS 。接下来 BIOS 读取 BIOS 设置,得到引导驱动器的顺序,然后依次检查,直到找到可以用来引导的驱动器(或说可以用来引导的磁盘,包括软盘、硬盘、光盘等) ,然后调用这个驱动器上磁盘的引导扇区进行引导。BIOS 是怎么知道或说分辨哪一个磁盘可以用来引导的呢?

二、认识引导程序

BIOS 将所检查磁盘的第一个扇区(512B )载入内存,放在 0x0000:0x7c00处(见图三) ,如果个扇区的最后两个字节是“55 AA” ,那么这就是一个引导扇区,这个磁盘也就是一块可引导盘。 通常这个大小为 512B 的程序就称为引导程序 (boot ) 。 如果最后两个字节不是 “55AA” ,那么 BIOS 就检查下一个磁盘驱动器。通过上面的表述我可可以总结出如下三点引导程序所具有的特

点:

1. 它的大小是 512B ,不能多一字节也不能少一字节,因为 BIOS 只读 512B 到内存中去。

2. 它的结尾两字节必须是“55 AA” ,这是引导扇区的标志。

3. 它总是放在磁盘的第一个扇区上(0 磁头,0 磁道,1 扇区) ,因为 BIOS 只读第一个扇区。

因此,在我们编写引导程序的时候,我们也必须注意上面的三点原则,符合上面三点原则的程序都可以看作是引导程序,至少 BIOS 是这样认为的,虽然它也许可能是你随意写的一段并没有什么实际意义的代码。因为 BIOS 一次只读一个扇区也即 512 字节的数据到内存中,这显然是不够的,现在操作系统都比较庞大, 因此我们必须在引导扇区里将存在磁盘上的操作系统的核心部份读进内存,然后再跳转到操作系统的核心部分去执行。

三、通过 BIOS 读磁盘扇区

从上面的描述我们可以知道,引导程序需要将存在于磁盘上的操作系统读入内存,因此这里我们不得不再讲一讲,怎样不通过操作系统(因为现在还没有操作系统)去读磁盘磁区。一般说来这有两种方法可以实现,一种是直接读写磁盘的 I/O 端口,一种是通过 BIOS 中断实现。前一种方法是最低层的方法(后一种方法也是在它的基础上实现的) ,具有极高的灵活性,可以将磁盘上的内容读到内存中的任意地方,但编程复杂。第二种方法是前一种方法稍微高层一点

的实现,牺牲了一点灵活性,比如,它不能把磁盘上的内容读到0x0000:0x0000 ~ 0x0000:0x03FF 处。为什么不能读到此处呢?这里我们将不得不描述一下CPU 在加电后的中断处理机制。

3.1 BIOS 的中断处理

中断是什么,相信学过计算机的人都不会陌生,如果你对中断一点都不了解建议你翻看一下《计算机组成原理》 (高等教育出版社 唐朔飞) ,上面有非常详尽的描述,而一般的汇编教材也多有谈及,因此这里只打算讲讲 BIOS 对中断的处理。

由上图我们可以清楚的看到,当由中断信号产生时,中断信号通过“中断地址形成部件”产生一个中断向量地址,此向量地址其实就是指向一个实际内存地址的指针,而这个实际内存地址中往往按排一条跳转指令(jmp )跳转到实际处理此中断的中断服务程序中去执行。这一块专门用于处理中断跳转的内存就被称

为中断向量表。在内存中这块中断向量表被放在什么地方的呢?而实际的中断处理程序又在什么地方的呢?

3.2 系统的内存安排(1M )

要回答上面的两个问题,我们需要看看系统中内存是怎么安排的。在 CPU 被加点的时候,最初的 1M 的内存,是由 BIOS 为我们安排好了的,每一字节都有特殊的用处。

由上图我们现在可以很方便的问答上面提出的两个问题。由于 0x00000~0x003FF 是中断向量表所在,因此不能将磁盘从的操作系统读到此处,因为这样会覆盖中断向量表,就无法再通过 BIOS 中断读取磁盘内容了。你也许会说:我是先调用中断,再读的啊。但事实在BIOS 在读的过程中自己会多次调用其它中断辅助完成。

3.3 利用 BIOS 13 号中断读取磁盘扇区

有了前面的描述作为基础,下面我们可以正式描述怎样通过 BIOS 中断读取磁盘扇区了。要读取磁盘扇区,我们需要使用 BIOS 的 13 号中断,13 号中断会将几个寄存器的值作为其参数,因此,我们在调用 13 号中断的过程中需要首先设置寄存器。那么当怎样设置寄存器呢?会用到哪些寄存器呢?请往下看:

AH 寄存器:存放功能号,为 2 的时候,表示使用读磁盘功能

DL 寄存器:存驱动器号,表示欲读哪一个驱动器

CH 寄存器:存磁头号,表示欲读哪一个磁头

CL 寄存器:存扇区号,表示欲读的启始扇区

AL 寄存器 :存计数值,表示欲读入的扇区数量

在设置了这几个寄存器后,我们就可以使用 int 13 这条指令调用 BIOS 13 号中断读取指定的磁盘扇区,它将磁盘扇区读入 ES:BX 处,因此,在调用它之前,我们实际上还需要设置 ES 与 BX 寄存器,以指出数据在内存中存放的位置。

四、保护模式下段模式内存地址的访问

写程序离不开对内存的访问,然而在保护模式下内存的访问与在实模式下内存的访问完全不同,这里我们将详细描述一下保护模式下内存的访问方法。当然,这里并不打算完整的介绍保护模式下所有的内存访问方法与机制, 只介绍从实模式转到保护模式下所需要进行的转换,完整的内存访问请你最好参见《Intel 用户手册》 ,当然,随着 pyos 的实验进行,我也会在后面的实验报告与心得体会中渐渐描述,现在不描述主要原因是我还为通过 pyos 进行实验, 对与没经过实验验证的东西, 我并不打算也不敢妄下结论, 因为在前言中已然说过,这篇文章只是我的一些心得体会,如果我没有实验过,就没有心得,也没有体会,也就无法描述了。

言归正转,我们还是先来看看实模式下内存访问方法吧。

4.1 实模式下的内存访问

计算机在加电时,处于“实模式” ,在计算机中有一个 CR0 寄存器,又称为 0 号控制寄存器,在这个寄存器中,最低位也即第 0 位,被称为 PM

(Protected Modle :保护模式)位,当它被清零的时候表示 CPU 在“实模式”下工作,当它被置位的时候,表示 CPU 在“保护模式”下工作。在计算机加电的时候,它是被清零的,所在这个时候的计算机,处于“实模式” 。“实模式”下的内存访问通过段寄存器与偏移量构成,比如前面描述中常常出现的0x:0000:0x0001 就是一个实模式下的内存地址。分号前面的值表时段寄存器中的值,分号后面的值表是偏移量,实际物理地址的形成如下图所示:

然而在保护模式下,内存地址却不是如上图所示的方法形成的。那么它又时怎样形成的呢?

4.2 保护模式下的内存地址形成

保护模式下内存地址就复杂多了, 我们首先要分清三个概念:逻辑地址、线性地址与物理地址。物理地址很好理解,逻辑地址也好理解,就是程序所使用的地址。那么什么是线性地址呢?其实如果不使用分页机制的话,线性地址就是物理地址,它与物理地址是一一对应的,线性地址 0,也就是物理地址 0。但我们知道,32位的 CPU 拥有 32 根地址线,也就是可以访问:

2的32次方 = 4GB 的内存空间,这实再是一个太大的空间了!现在很少有机器的物理内存能有这么大。那怎么在有限的物理空间中使用 4GB 的空间呢?人们把物理内存分成许多页,当某些页被使用的时候,某些页就没有被使用,而没有被使用的页就可以用来载入 4GB 上的空间,这就称为从线性地址到物理地址的映射,这是一个多对一的映射,也就是说多个线性空间中的页对应一个物理空间中的页,希望下面一幅图能有助于你理解这样的分页机制。

上面是一种最简单的映射方式,术语称作“直接相连”映射,它大约只能用来说明问题,而在一个实际的操作系统中通常是“全相联相连”映射,也就是说线性地址中的页可以是映射到物理地址中的任何一个页中,只要那块物理地址空间现在是空闲的。不过,通过上图也能说明问题,当线性地址中的页 5 需要被访问时,CPU 通过地址映射机制将其转换到物理地址,发现其对应物理地址中的页

1。于是 CPU 将物理地址页 1 中的内容放到硬盘上 的一个地方(虚拟内存) ,然后将线性地址中的页 5 载入物理内存页 1 中。这里就当可以比较明显的区别什么是线性地址,什么是物理地址了。

然而,当不使用分页机制的时候,线性地址就会被 CPU 当做物理地址来使用,线性地址会被直接放在 CPU 的地址信号线上,而在编写应用程序的时候,我们通常使用却是另一种地址——逻辑地址,从逻辑地址到线性地址也存在着与上述机制类似的一种映射机制,不过这个机制常常称为“段模式” ,它是由操作系统与 CPU 硬件共同完成的。操作系统的任务就是分配映射表,而 CPU 硬件的任务就是按着映射表进行映射。而这样的映射表在操作系统编写中又称之为“描述符表” ,有两种重要的描述符表,一种是“全局描述符表(GDT ) ”

另一种是“局部描述符表(LDT ) ” ,这两种表的用途不同,但它们的用法却是近似的,下面我们就来描述一下全局描述符表。说到表,学过数据结构的人都知道,其实它就是一种数据结构,全局描述符表也是一种数据结构,当这种结构放在一块连续的内存这间中就称之为表了。表由表项组成,全局描述符表由它

的表符全局描述符组成,其实单纯的术语就叫“描述符” ,只因为它放在全局描述符表中就成了全局描述符了。这个描述符由 8 个字节组成,下面我们就来看看它的结构:

TYPE : 表明此段的类型,4 位中的最高位被置 1 的时候表是它是数据段,相应的于下的三位,从左到右依次为 E 、W 、A ,即数据段的 TYPE 为:0EWA 。其中 E 表示向下增长位,置 1 时表示向下增长,W 表示可写位,置 1 表洋可定,A 表示被访问位(如果 CPU 访问了它,此位将会被置 1) 。

S :为 1 时表示其为代码或数据段,为 0 时表示为系统段

DPL :表示物权级,从 00~11,共 0,1,2,3 四个特权级

P :为 0 是表示此描术符无效,不能被使用

A VL :留给系统程序随便用的

D/B:为 0 的时候表示它是一个 16 位的段,为 1时表示它是一个 32 位

其中:

RPL :指示出特权级,00~11,共 0、1、2、3 四个特权级,与前述一样。 TI :为 0 时表明这是一个用于全局描述符表的选择子,为 1 时表明用于局部描述符表。

索引值:用来指示表中第几个描述符。索引值共有 13 位,因此,每张描述符表共

可有 8K 个表项,而一个表项如前的述,占 8 个字节,因此一张描述符表最大可达 64K 。

不知道大家是否注意到这样一个事实,如果将“段选择子”的最后 3 位置位零,这整个段选择子其实就是一个描述符在描述符表中的偏移量!这里我们可以发现 Intel 的工程师在设计的时候真的是非常精巧,如此的安排,可以使选取一个描述符的速度极大加快,因为将一个段选择子最后 3位清零后与描述符表的基址相加, 就立即可以得到一个描述符的物理地址,通过这个地址就可以直接得到一个描述符。那么这个描述符表的基址又是放在哪儿的呢?

所为描述符表的基址也就是此描述符表在内存中的起始地址,也即表中第一个描述符所在的内存地址,系统中用两个特殊的寄存器来存放,一个用于存放全局描述符表的基址,称之为“全局描述符表寄存器(GDTR ) ” ,另一个用来存放局部描述符表的基址,称之为“局部描述符表寄存器(LDTR ) ” ,它们的结构如下图所示:

其中表限也即表的大小限制,它的使用与前面所描述的段限是类似的,因此,

这里就不在描述了。在保护模式下,以前实模式下的段寄存器还是有用的,不过它不在用来存放段的基址,而是用来存放“段选择子” ,它的名子也变成了“段选择子寄存器” ,在访问内存的时候,我们需要给出的是“段选择子” ,而不是段基址了。比如,我现在想使用全局描述符表中第二个表项,即其中的第二个“段描述符” ,这个

“段选择子”就需按如下的方式构成:

RPL :00,因为我们现在是在写操作系统,工作在 0 特权级

TI :0,我们使用全局描述符索引值:01,我们使用第二个全局描述符,第一个全局描述符编号为 0,第二个为 1,用二进制表示就是 01。

因此,我们的“段选择子”为:0000 0000 0000 1000,也即 0x0008,因此,对于0x0008:0x0000 这样一个逻辑地址,在保护模式下就应看成是使用全局描述符段中第二个描述符所描述的段,偏移量为 0 的内存地址。这个逻辑地址的线性地址是怎样形成的呢?请看如下的图示:

相信, 从上图中你可以清楚的看见一个逻辑地址是怎样转换为一个32位的线性地址的。

五、pyos 引导程序编写

pyos 是一个正在编写中的操作系统,是一个实验中的项目,关于编写的目

的与动机我已在前言中谈论过了,这里,只就此篇单所讲述的内容,谈谈 pyos 引导程序的编写,在编写其间参考了 linux 0.11 内核引导程序的编写,不过 pyos 并不是基于 linux 的,就它们的引导程序之间也有许多不一样的地方。下面我们先来看看 pyos 的整个引导区的内存安排:

面一幅图就是 pyos 的内存安排图,也是引导程序流程图,pyos 是两级引导系统,首行是boot 被BIOS 读入, 随后boot 读入setup , setup 读入system 程序到暂存区, 然后把system 程序搬到内存顶部, 并建立指向 system 程序所在段的段描述符, 及建立 GDT , 然后切换 CPU 到保护模式, 然后跳转到 system 程序中执行, 至此 pyos 系统引导完毕, system 程序将是 pyos 真正的系统内核。图中的数据存放区用来存发在 boot 、setup 、pyos 三者程序间需要传递的参数。之所以做成两级引导主要是考虑到以后扩展性的方便,各程序间都差不多是独立的,以后可以重写 boot 或者 setup 以提供更多选择的引导方式。System 程序暂存区是因为如前所述,不能直接将数据读到中断向量表中覆盖原

中断向量变,当数据读完之后,不再调用中断了, 才将程序搬到内存顶部覆盖原中断向量表, 对于保护模式下的中断向量表, 将由 system 程序负责建立,交给 system 的将是一块完整的内存。对于 pyos 进程内存安排,准备参照 Linux 0.11 进行,内存安排如下:

一个进程享有 64M 空间,4GB / 64M = 64,也即系统最大进程数为 64。因此一个段的段限为:64MB ,每个进程占用全局描术符表中两个描术符,一个为数据段描述符,一个为代码段描述符,段限均为 64MB 。

六、pyos 引导程序源代码

下面将提供 pyos 引导程序的全部源代码,因为 system 还未完全完成,因此这里只是让它简单的打印一个字符以示引导工作完成,代码中已有较为详尽的注释,如果仍有不太清楚的地方,可http://purec.binghua.com(纯 C 论坛)操作系统实验专区,查看 pyos 以前的实验报告,上面有非常详尽的注释及相关原理说明,并详细描述了怎样编译及实验。

;boot.asm 0.04

;for pyos4

;xieyubo@126.com

;

; 这是 boot.asm 的第四个版本,这个版本有较大改动,参考了 linux 0.11的设计

; 首先指出的是版本标识改动,为了便于日后修改,以后每个文件设定独立版本号

; 并指出用于什么系统,此版本用于 pyos4 系统

;

; 本版本的内存分配如下

; 内存起示地址为 0x90000

; 最大结束地址为 0x9ffff

; 最大共 64KB

; 所有启动代码在一个段内,方便调用

; 启动代码共分两部分,一是 boot ,一是 setup ,这点照搬 linux 0.11 的

设计

; 但与其不同的是,boot 不会将自己搬到 0x90000 处,而直接跳到

0x90100 处运行

;0x90000~0x900ff (256B) 系统保留来存放一些从 BIOS 中取出的关键

数据

;0x90100~0x904ff (1KB):此处开始存放 setup ,setup 大小为 1KB

[BITS 16] ; 编译成 16

位的指令

[ORG 0x7C00]

;-------------------------------------------------------------------------------------------

---

jmp Main

;-------------------------------------------------------------------------------------------

---

; 数据定义

MSG db "Loading pyos ..." ; 输出信

db 13 , 10 , 0 ;13 表示

回车,10 表示换行,

;0 表示字符串结束

BOOTSEG equ

0x0000 ;boot 所在的段基址

SETUPSEG equ 0x9000 ;setup 所在的段基址

SETUPOFFSET equ 0x0100 ;setup 所在的偏移量

SETUPSIZE equ 1024 ;setup 的大小,

; 必须是 512 的倍数

BOOTDRIVER db 0 ;

保存启动的驱动器号

;-------------------------------------------------------------------------------------------

---

ShowMessage:

; 以下程序行为显示输出信息

mov ah , 0x0e ;

设置显示模式

mov bh , 0x00 ;

设置页码

mov bl , 0x07 ; 设

置字体属性

.nextchar:

lodsb

or al , al

jz .return

int 0x10

jmp .nextchar

.return:

ret

;-------------------------------------------------------------------------------------------

----

Main:

mov [BOOTDRIVER] , dl ; 得到启动的驱动器号

; 以下程序设置数据段

mov ax , BOOTSEG

mov ds , ax

mov si , MSG

call ShowMessage ; 显示信息

; 读入 setup

; 从磁盘的第二个扇区读到 0x90100 处

.readfloopy:

; 下面定义临时 GDT 表的描述符

; 总共定义三个段,一个空段由 intel 保留,一个代码段,一个数据段 gdt_addr:

dw

0x7fff ;GDT 表的大小

dw

gdt ;GDT 表的位置

dw 0x0009

gdt:

gdt_null:

dw 0x0000

dw 0x0000

dw 0x0000

dw 0x0000

gdt_system_code:

dw 0x3fff ; 段限( 16K * 64KB = 64MB )

dw 0x0000

dw 0x9a00

dw 0x00c0

gdt_system_data:

dw 0x3fff

dw 0x0000

dw 0x9200

dw 0x00c0

;------------------------------------------------------------------------------------- ; 等待键盘控制器空闲的子程序

Empty_8042:

in al , 0x64

test al , 0x2

jnz Empty_8042

ret

;------------------------------------------------------------------------------------- Main:

; 初始化寄存器,因为 Bios 中断及 call 会用到堆栈或 ss 寄存器

; 在 CPU 启动或复位时是由 BIOS 初始化的,而现在进行了段转移,需要我们重新设置

mov ax , SETUPSEG

mov ds , ax

mov es , ax

mov ss , ax

mov sp , 0xffff

;-------------------------------------------------------------------------

; 从 BIOS 中到底应读出哪些有用信息,现在还不确定,因此暂时跳过此功能块

;-----------------------------------------------

;0x90000 (1B): 保存启动驱动器号,由 boot 程序存入

;--------------------------------------------------------------------------

; 下面读入system 到 setup 程序的后面

; 因为 0x00000 现在是放 BIOS 中断的地方,因此还不能直接将 system 读到 0x00000 处,

; 否则将无法调用 BIOS 中断读入磁盘

.readfloopy:

mov ax , SETUPSEG

mov es , ax

mov bx , SETUPOFFSET + SETUPSIZE

mov ah , 2

mov dl , [0]

mov ch , 0

mov cl , 1 + 1 + SETUPSIZE / 512 ;system 所在的启始扇区

;( 第一个1是指从 1 开始记数,第二个 1 是boot 所占扇区数)

mov al , SYSTEMSIZE / 512 ; 读入扇区数( 2 个扇区共 1KB )

int 0x13

jc .readfloopy

; 下面将读入的 system 搬移到 0x00000 位置

cld

mov si , SETUPOFFSET + SETUPSIZE

mov ax , SYSTEMSEG

mov es , ax

mov di , SYSTEMOFFSET

mov cx , SYSTEMSIZE / 4

rep movsd

; 下面开始为进入保护模式而进行初始化工作

cli ; 关中断

lgdt [gdt_addr] ; 载入 gdt 的描述符

; 下面打开A20 地址线

call Empty_8042

mov al , 0xd1

out 0x64 , al

call Empty_8042

mov al , 0xdf

out 0x60 , al

call Empty_8042

; 下面设置进入 32 位保护模式运行

mov eax , cr0

or eax , 1

mov cr0 , eax

jmp dword 0x8:0x0

;------------------------------------------------------------------------------------- times 1024-($-$$) db 0

;system.asm 0.04

;for pyos4

;xieyubo@126.com

; 此程序将完全使用 32 位的汇编代码,是系统的核心模块

[BITS 32]

[ORG 0x0]

;----------------------------------------------------------------------------------------------

jmp Main

;----------------------------------------------------------------------------------------------

Main:

; 设置寄存器

mov ax , 0x10

mov ds , ax

mov cl , '1'

mov [0xb8000] , cl

mov cl , 0x04

mov [0xb8001] , cl

jmp $

;----------------------------------------------------------------------------------------------

以上程序中有一个地方本篇及以前的实验报告中也为提到,这就是 A20地址线的问题,对于有关 A20 地址线的问题,在《Linux 0.11 内核源代码完全注释》中有非常详细的描述,作者还列举了其它几种打开 A20 地址线的方法,并分析了可能存在的问题。这是一本非常好的书, 推荐大家阅读。 纯 C 论坛上 (http://purec.binghua.com) 可以下载本书的电子版 (PDF 格式) ,也可以在上面找到另外一些相关资源。下面就是运行时的截图,现在它只能引导,什么也干不了,希望下次它能多干一点~~