从操作系统启动开始
# 1. 从操作系统启动开始
我们从计算机的启动开始,讲解开机的时候,计算机内部到底发生了什么。这是大家每次开机都会看到的画面,也是第一幅画面,从这里开始合情合理;并且安排了实验一,控制计算机的启动。
# 打开电源之后……
打开电源之后,计算机就开始工作了,那么计算机是如何工作的呢?
我们简单回顾下计组里的内容:计算机是在一个计算模型下设计出来的,也就是说计算机是计算模型的一种实现,最著名的模型就是图灵提出的图灵机(可以回顾计算机简史)。
图灵机是怎么定义的呢?实际上图灵机借鉴了人在计算的过程:例如我们计算3+2,我们用眼睛看到3+2后,就知道要做加法,然后在脑海里运算,得到结果是5,然后将答案写出。而图灵机也是这样,当图灵机在纸带上读到3+2这条指令后,就知道要做加法,并且用运算器 得出结果,并写回到纸带上。
计算机的工作原理无非4个字:取指执行。大家一定一定要牢记这4个字。CPU从内存中取出指令,如果是加法指令,就执行加法; 如果是乘法指令,就执行乘法。这就是通用图灵机。同样的,如果我们将应用程序放到内存里,例如浏览器,那么CPU就运行浏览器;如果是Word,CPU就运行Word。
一开始内存是空的,CPU从哪里取指呢?第一条指令是多少?PC指针的初值是多少?这个由硬件设计者决定的。
这就要了解一下硬件的知识。以x86结构为例,刚一上电的时候,内存中有一部分是固化的ROM,叫做ROM BIOS。BIOS全称Basic Input Output System,基本输入输出系统。也就是说,总得有一个基本的输入输出,如果什么都没有,CPU是做不到取指执行的。那么这一段指令做了什么事情呢?
- 以x86为例,一上电的时候,CPU处于实模式
- CS寄存器 = 0xFFFF, IP = 0x0000
- 因此CPU就会去执行 0xFFFF:0x0000处的指令,也就是内存 0xFFFF0处的ROM BIOS的指令
- BIOS处的指令首先会检查外设,例如CPU,键盘,显示器,硬盘等
- 然后会将磁盘0磁道0扇区的内容读到内存的0x7C00处
- 最后设置CS = 0x7C00, IP = 0x0000,开始执行引导扇区里的代码。
关于第4步的补充:如果有给电脑加过内存条的经历应该知道,加内存条后的第一次开机,计算机会在屏幕上提示 The amount of system memory has changed. 也就是计算机检测到内存总量变化了,是否继续开机:
关于第5步的补充:在计组里我们学过,一个扇区是512个字节的。而0磁道0扇区就是操作系统的引导扇区,因此就是将引导扇区里的指令读到内存里,并开始执行。这也就是操作系统的第一段代码。
# 准备Linux源码
接下来我们会讲Linux的部分源码,同学们可以先准备下,接下来我们仅仅会挑部分重点代码进行解读。
Linux 0.11 版本的代码地址:http://oldlinux.org/Linux.old/Linux-0.11/sources/system/linux-0.11.tar.Z
也可以在我的Gitee上下载:https://gitee.com/peterjxl/learn-os
本段代码很小,不到500k; 代码里也有详细的注释。
我们接下来讲的bootsect.s,setup.s 和head.s 的路径:linux-0.11\boot\。该目录下也就只有这几个文件
# 引导扇区代码:bootsect.s
引导扇区里的代码:是一段汇编代码,文件名是bootsect.s。为什么第一段代码是汇编代码呢?因此我们要对计算机进行精细的控制,而C语言的代码编译之后,它的内存位置是人为不可控的(比如自动分配栈),而汇编就不一样了,汇编中的每一条指令最后都变成了这都变成了真正的机器指令,所以你可以对它进行完整的控制。**而在引导过程中,你当然要对他进行完整的控制,绝对不能有任何差错或者细小的出入的控制。**在操作系统中有很多地方都要实现这样精细的控制。我们会挑bootsect.s的一些关键代码来解读
# 第一件事:将代码挪到0x9000处执行
SETUPLEN = 4 ! nr of setup-sectors
BOOTSEG = 0x07c0 ! original address of boot-sector
INITSEG = 0x9000 ! we move boot here - out of the way
SETUPSEG = 0x9020 ! setup starts here
entry start
start:
mov ax,#BOOTSEG
mov ds,ax
mov ax,#INITSEG
mov es,ax
mov cx,#256
sub si,si
sub di,di
rep movw
jmpi go,INITSEG !段间跳转 cs=INITSEG, ip=go
go: mov ax,cs
mov ds,ax
mov es,ax
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
我们来解读这段代码
1 ~ 4行可以理解为定义了一个变量,值是我们后续会用到的地址
第5行:关键字entry告诉链接器“程序入口” 从start 标号开始
第6行:CPU开始执行第5行的指令(上一句表明了程序的入口)
接下来执行完这两句后,ds = 0x07c0 :
mov ax,#BOOTSEG
mov ds,ax
2
同理,执行完这两句后,es = 0x9000 :
mov ax,#INITSEG
mov es,ax
2
接下来做什么呢?就是将bootsect.s里的代码,全部挪到内存 0x9000处:使用的是rep指令,复制256个字,也就是512字节,刚好一个扇区,从0x7c00开始的一个扇区的代码。为什么要将本段代码移动到9000,我们后续再讲
mov cx,#256
sub si,si
sub di,di
rep movw
2
3
4
接下来执行一个跳转指令:
jmpi go,INITSEG !段间跳转 cs=INITSEG, ip=go
也就是将0x9000作为基址,go作为偏移地址。我们可以推理出来,既然bootsect.s已经挪到 0x9000处, 我们就应该跳转到 0x9000处,继续往下执行bootsect.s处的代码,且我们可以看到go标号就在 跳转指令的下一行,所以其实就是继续往下执行bootsect.s。
# 第二件事:加载setup模块
接下来bootsect.s 做了什么事情呢?我们继续看一些关键的代码:
go: mov ax,cs
mov ds,ax
mov es,ax
mov ss,ax
mov sp,#0xFF00 ! arbitrary value >>512
load_setup:
mov dx,#0x0000 ! drive 0, head 0
mov cx,#0x0002 ! sector 2, track 0
mov bx,#0x0200 ! address = 512, in INITSEG
mov ax,#0x0200+SETUPLEN ! service 2, nr of sectors
int 0x13 ! read it
jnc ok_load_setup ! ok - continue
mov dx,#0x0000
mov ax,#0x0000 ! reset the diskette
int 0x13
jmp load_setup
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
当执行到go处的代码时,CS = 9000,而go标号处 到 load_sectup标号处的代码,将段寄存器DS,ES,SS的值都设置为了9000。因此目前所有段寄存器的值都是 0x9000
下一步,就是用0x13号中断 ,将setup模块从磁盘加载到内存里。因为我们一开始只将引导扇区读入进来了,而操作系统还有很多内容要读进来。
我们来解读下load_setup代码:
在执行13号中断之前,我们要先传参给中断例程,其中
- ah是功能号 02表示读磁盘。 00表示磁盘系统复位
- al 是读取扇区的数量,因为ax = 0x0200 + SETUPLEN,因此al = 4,表示读取4个扇区
- ch 是柱面号, 这里表示读取0柱面号,
- cl 是要读取的扇区。这里是2,表明读2号扇区(这里需要说明一下,其实扇区是从1开始编号的,而第一个扇区是引导扇区,因此读下一个扇区就是2号扇区;而之前说的0磁道0扇区指的是 0号逻辑扇区,其对应的是1号物理扇区。如果不理解也没关系,记住是读下一个扇区即可)
- dh 是磁头号, dl是驱动器号 这里都是0,表明还是读0磁道 0号驱动器
- es:bx 内存地址,用于存放磁盘里的内容
传递参数后,就执行13号中断,下一行代码是 jnc ok_load_setup
如果正常读入了,就跳转到 ok_load_setup处执行代码。而如果有异常,就将磁盘系统复位,继续尝试读取sectup扇区(无条件跳转 jmp load_setup
)。
因此load_setup就是读取4个扇区的内容到 0x90200 处。十六进制是200,转换成十进制就是512,因此就是sectup模块在内存的地址就是紧挨着bootsect.s 。内存示意图如下:
内存地址 | 内存的内容 |
---|---|
0xFFFF0 | ROM BIOS区 |
.......... | ............. |
0x90200 | setup模块 |
0x90000~0x901FF | bootsect.s模块 |
.............. | .................... |
# 第三件事:显示开机画面并读system模块
读入setup模块后,接下来做什么呢?setup模块只有4个扇区,操作系统当然不只这么少代码,因此读入了setup模块后,继续读入操作系统的system模块。我们来看看ok_load_setup的关键代码:
ok_load_setup:
mov ch,#0x00
mov sectors,cx
mov ax,#INITSEG
mov es,ax
mov ah,#0x03
xor bh,bh
int 0x10 ; ah 功能号为3,表示读取光标位置
mov cx,#24
mov bx,#0x0007
mov bp,#msg1
mov ax,#0x1301
int 0x10 ; ah功能为为13,作用是显示字符串,es:bp是串地址,cx是串长度
mov ax,#SYSSEG
mov es,ax ! segment of 0x010000
call read_it ; 这里就是读入system模块
jmpi 0,SETUPSEG ; 跳转到0x09020:0000处执行代码,也就是执行setup.s
; ...........这里省略一些代码, 以下这段代码在bootsect.s的244行.......
msg1:
.byte 13,10
.ascii "Loading system ..."
.byte 13,10,13,10
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
我们来分析下上述代码做了什么:
- 首先是用 int 0x10读取光标的位置,
- 然后将字符串 "Loading system ..."显示到光标的位置上
- 然后执行reat_it函数读入system模块,读到内存 0x10000处,这里我们就不继续展开了
- 最后跳转到setup.s处执行,
- 至此,bootsect.s 代码执行结束。
其实,第二步就是我们看到的开机画面了,只不过当时显示的比较简单和粗糙。因此,我们可以做实验一了,就是修改一下计算机的开机画面,例如改成 “Hello World!” 。这里也说一下大致的思路:首先修改下msg处的字符串,然后数一下要显示的字符个数,然后修改cx,重新汇编bootsect.s即可。
# bootsect.s小结
bootsect.s做了以下事情:
- 将自己挪到0x90000处
- 读入setup模块
- 读入system模块,并在屏幕上打印 “Loading System”
- 将控制权交给setup模块
具体代码执行逻辑:
- 通过汇编的rep movw指令,将自己的代码复制到0x9000:0000 处 512字节(256个字,所以cx=256)
- 执行跳转指令 jmp go 9000 (go是标号,会赋值给IP, 9000会赋值给CS,作为段基址)。其实就是顺序执行,只不过因为在第一步里将自己挪到了9000,所以CS:IP也指到那里去
- 目前bootset.s的段基址是9000,然后长度是256个字,引导扇区在内存里的结束地址是90200H
- 然后根据13号中断,从第二个扇区开始(第一个扇区是bootsect.s),读取setup的4个扇区的内容(al存放扇区数量),也就是2kb
- setup的代码放到哪里?紧接着bootsect.s,也就是从90200H开始, es:bx就是指向90200H
- 然后在屏幕上显示loding system(通过int 10h中断)
- 然后call read_it 读入system模块,读入到0x10000处
- 然后执行 jmpi 0, SETUPSEGMENT,也就是将代码转到setup的内存开始执行指令
- 至此,bootsect.s结束
# setup.s
目前操作系统还在启动中,还需要精细的控制,因此setup也是一段汇编。首先我们根据文件名可以联想到:setup应该是完成OS启动前的一些设置。我们还是挑部分重点代码来看。
# 第一件事:获取硬件参数
start:
mov ax,#INITSEG ; INITSEG 在setup.s里的第17行 定义为0x9000
mov ds,ax ; ds = 0x9000
mov ah,#0x03
xor bh,bh
int 0x10 ; 10号中断的3号功能 读光标位置
mov [0],dx ; 取出光标位置 放到0x9000处
mov ah,#0x88
int 0x15
mov [2],ax ; 获取扩展内存大小,并放到0x9000处
mov ah,#0x0f
int 0x10 ; 获取显卡参数 放到0x9000处
mov [4],bx
mov [6],ax
mov ah,#0x12
mov bl,#0x10
int 0x10 ; 获取根设备号 放到0x9000处
mov [8],ax
mov [10],bx
mov [12],cx
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
我们可以解读下这段代码:首先获取光标,内存,显卡等参数(后面还有很多代码获取硬件参数,这里不表),并放到0x90000处(此时bootsect.s已经不在用到,可以被覆盖)
内存地址 | 名称 |
---|---|
0x90000 | 光标位置 |
0x90002 | 扩展内存数 |
0x9000c | 显卡参数 |
0x901FC | 根设备号 |
其中,获取扩展内存数是非常重要的。
- 什么是扩展内存:早期计算机中,地址总线只有20位,因此只能寻址1M以内的内存;而如今的计算机,都是8G,16G内存起步的,那么通常把1M以后的这些内存就叫扩展内存
- 为什么获取内存很重要:操作系统,就是帮我们管理硬件的,而内存就是一个重要的硬件。要管理好内存,首先得知道内存的多大。
我们再谈谈,为什么这段代码的文件名是setup.s ? 要管理好硬件,就得设置一些数据结构去保存这些信息。就好比学校里管理学生,就会有一个学生信息表来存储学生的信息,方便查询、修改和删除等;不仅仅是内存,还有光标,显卡参数等,获取完这些信息后先存起来,后面会形成一些数据结构来保存这些信息,这就是为什么叫setup.s 。(实际上操作系统开机就做了2件事,第一件事就是读取操作系统到内存里,第二件事就是setup,初始化,我们后面会慢慢体会到这句话)
# 第二件事:挪动system模块
在获取完硬件参数后的代码如下:
cli ; 不允许中断
mov ax,#0x0000
cld
do_move:
mov es,ax
add ax,#0x1000
cmp ax,#0x9000
jz end_move
mov ds,ax
sub di,di
sub si,si
mov cx,#0x8000
rep ; 将system模块挪到0地址处!
movsw
jmp do_move
2
3
4
5
6
7
8
9
10
11
12
13
14
15
do_move,我们可以猜测是移动的意思,那么移动什么呢?
首先 ES被置成0;
在第一次循环中,DS = 1000,然后设置cx,因此就是将 DS:SI 也就是1000:0000处的代码,挪到ES:DI 也就是 0000:0000处。而在bootsect.s 里,1000:0处的代码就是system模块。因此本段代码的作用就是循环,将system模块挪到 0 地址处。(每次复制system模块的 0x8000个字节到0地址处,然后ax自增,判断是否复制完了,没有则继续循环)
那么接下来我们就可以回到之前留下的一个问题:为什么bootsect.s 会将自己从 0x7c00处挪到9000处?因为要给system模块腾出空间。system模块很长,会覆盖到0x7c00处,如果正在执行的代码被覆盖了,肯定是不行的。同时,当时的system模块不会太大,不会覆盖到0x90000处的bootsect和setup模块 (当时system模块不会超过0x80000字节(即512kb),Linux0.11内核只有14000行左右,大概325KB大小)。操作系统诞生之初,功能还比较简单,代码也不会太多。
之后,system模块就会一直在0地址处。而system之后的内存,我们就可以用于运行我们自己的程序了,例如浏览器,Word等。
# 第三件事:进入保护模式,将控制权交给操作系统
setup模块该做的事都差不多了,接下来就是将控制权交给操作系统了,但在这之前,还有一件非常重要的事去做:进入保护模式。
我们知道,早期计算机只支持1M的内存,这指的是早期的寻址方式,只支持1M。早期使用的是段基址 + 偏移地址这样的方式寻址的,这种方式不能满足 4G内存的寻址,因此,我们要切换到一个新的寻址模式。因此CPU接下来会从16位寻址模式(也叫实模式)切换到32位寻址模式(也叫保护模式)。
那么CPU是怎么切换寻址模式呢?根据一个寄存器:CR0 。 如果这个寄存器的最后一位是0,CPU就会用16位模式;如果是1,就用保护模式(其实就是换一条电路去寻址)。
mov ax,#0x0001
lmsw ax
jmpi 0,8
2
3
在setup.s第192行,有个lmsw ax,就是将ax的值赋给CR0寄存器,然后接下来的指令,就是用32位寻址模式了。
那么32位寻址模式怎么寻址呢?这就要提到一个非常著名概念叫 GDT(全局描述表Global Descriptor Table),GDT表里面存放的才是基址。 当然这也是硬件帮我们实现的寻址方式(因为硬件快)。如何用GDT寻址?
在16位模式下,代码寻址是用CS:IP 实现的,而在32位模式下,CS不再左移4位产生一个地址,而是用作选择子,换句话说就是CS的内容是GDT表的下标,对应的GDT表项的内容,才是段基址。
因此,32位寻址模式是这样工作的:首先根据CS取出GDT表的内容作为基址,IP还是作为偏移地址,因此来产生一个新的地址,示意图:
同样的,保护模式下,中断例程的寻址方式也发生了变化:仿照GDT表,新建了一个IDT表(中断描述符表Interrupt Descriptor Table),int n 就用n进行查表取出中断例程的地址,然后执行:
那GDT表的内容是什么呢?没有内容的话,CS选了也没意义,因此SETUP里也定义了GDT表(setup.s 的 205行,下面就是IDT表)
gdt:
.word 0,0,0,0 ! dummy
.word 0x07FF ! 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ! base address=0
.word 0x9A00 ! code read/exec
.word 0x00C0 ! granularity=4096, 386
.word 0x07FF ! 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ! base address=0
.word 0x9200 ! data read/write
.word 0x00C0 ! granularity=4096, 386
idt_48:
.word 0 ! idt limit=0
.word 0,0 ! idt base=0L
gdt_48:
.word 0x800 ! gdt limit=2048, 256 GDT entries
.word 512+gdt,0x9 ! gdt base = 0X9xxxx
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
我们可以看到有很多 word指令,一个word就是16位,而GDT表一个表项占8字节(64位),因此每4个word就是 一个GDT的表项(其中,第一个表项为空不使用)
每个表项的组成如下:
而GTD的下标如何确定呢?依次为0,8,16………… 我们以setup.s 的表为例:
GDT表下标 | GDT表内容 |
---|---|
16 | .word 0x07FF 0x0000 0x9200 0x00C0 |
8 | .word 0x07FF 0x0000 0x9A00 0x00C0 |
0 | .word 0,0,0,0 |
GDT的相关部分我们介绍完了,我们回到本小结的开始 ,jmpi 0,8
这条指令是如何确定跳转到哪呢?
mov ax,#0x0001
lmsw ax
jmpi 0,8
2
3
由于CS是8,因此我们去查8这个下标的内容:
.word 0x07FF 0x0000 0x9A00 0x00C0
而这几个word是如何存放到GDT表的呢?
.word 0x07FF 0x0000 0x9A00 0x00C0
在内存中,从高地址到 低地址极速 0x00C0 9A00 0000 07FF
用二进制展开来就是
0x00C0: 0000 0000 1100 0000
0x9A00: 1001 1010 0000 0000
0x0000: 0000 0000 0000 0000
0x07FF: 0000 0111 1111 1111
2
3
4
5
6
7
8
9
因此,我们可以这样将07FF放到0~15的地方,然后0x0000放到16 ~ 31的地方,然后0x9A00的后两个字节,00,放到16~23这个地方
放完后,我们可以看到,段基址就是0。因此,jmpi 0,8
其实就是跳到0地址处去执行。至此,set up的工作到此就完成。
# setup.s 小结
- 因为操作系统就是管理硬件的,因此首先得知道硬件的情况:读了一些硬件参数并存到内存里
- 把system挪到0地址处,将来操作系统运行的时候,system模块会一直存在那里
- 然后启动了保护模式(通过修改CR0寄存器),最后运用应用了32位的汇编指令JMPI 0, 8 跳到了0地址处去执行
- 0地址就是system模块,因此后面就是操作系统运行起来了
# head.s
system模块的第一个部分是head.s ,head.s 做了什么呢?
# 初始化GDT, IDT
我们可以看看其关键的代码:还是初始化了GDT和IDT表(之前的setup.s 里建立的GDT只是临时用于跳转而已),现在操作系统是真正的开始工作了,所以还要再次建立这个表
startup_32:
movl $0x10,%eax
mov %ax,%ds
mov %ax,%es
mov %ax,%fs
mov %ax,%gs ; 指向gdt的0x10项(数据段)
lss _stack_start,%esp ; 设置系统栈
call setup_idt ; 初始化 IDT表
call setup_gdt ; 初始化 GDT表
2
3
4
5
6
7
8
9
10
还有其他的一些内容,这里不表。
# 三种汇编格式
我们可以看到,这里的汇编和之前的汇编代码的格式有点不同,因为现在是32位保护模式,用了32位的汇编。我们对汇编的格式做个简单的介绍
(1) as86汇编:能产生16位代码的Intel 8086(386)汇编
mov ds, ax, ; ax → ds, 目标操作数在前
(2) GNU as汇编:产生32位代码,使用AT&T系统 V语法(AT&T美国电话电报公司,包含贝尔实验室等,1983年AT&T UNIX支持组发布了系统V)
movl var, %eax ; (var) → eax
movb -4(%ebp), %al ; 取出一字节
2
(3) 内嵌汇编,gcc编译x.c会产生中间结果 as汇编文件x.s
__asm__(“汇编语句”
: 输出
: 输入
: 破坏部分描述);
//例如
__asm__(“movb
%%fs:%2, %%al” //%2表示addr,
:”=a”(_res) //a表示使用eax,并编号%0
:”0”(seg),”m”(*(addr)) //0或空表示使用与相应输出一样的寄存器 m表示使用内存
);
2
3
4
5
6
7
8
9
10
11
其实操作系统确实是一个复杂的工程,光汇编就用了3种:16位汇编(bootsect.s和setup.s),32位汇编(head.s),还有内嵌汇编(在后面讲到的C语言代码里,因为有些指令还是要精细的控制)。这里不展开讲这3种汇编的语法,这些不是本堂课的主线,如果等学完了三种汇编,黄花菜都凉了,因此待后续讲到的时候再简单的说一说和查找资料即可
# 跳转到main
当head.s 执行完后,接下来就是执行 main.c 代码了。
after_page_tables:
pushl $0 ;These are the parameters to main :-)
pushl $0
pushl $0
pushl $L6 ; return address for main, if it decides to.
pushl $_main
jmp setup_paging
L6: jmp L6 ; main should never return here, but just in case, we know what happens.
setup_paging:
; …………这里省略一些设置页表代码……
ret
2
3
4
5
6
7
8
9
10
11
12
13
如何从汇编 跳去执行 C语言的main函数呢?怎么做到的?
我们知道,汇编执行子程序的话,可以通过跳转指令;
C语言执行函数(子程序)的话,用的是调用函数的语句,例如调用方法b(假设需要传参),就用 b(int a, int b)
即可。但其实, C语言最后还是会翻译成汇编,然后才被CPU执行。而传参,可以靠栈来实现。
因此,本段汇编代码的一开始几条压栈语句,就是传参给CPU;然后将main函数的地址压到栈中;当setup_paging执行ret后,就回执行函数main了!(执行ret指令后,会将栈里的内容取出作为下一个要执行的代码的地址)
# 如果main返回了……
最后我们来看一个奇怪的语句:
L6: jmp L6 ; main should never return here, but just in case, we know what happens.
这段代码不就是死循环吗?为什么要设置成死循环?
其实,操作系统是一个永远不停止的程序。如果一旦main函数停止了,就会跳转到这里,然后死循环,也就是我们常见的死机………… 注释是linus加的,也挺有意思
# head.s小结
head.s做了什么?
- 建立GDT和IDT表(用的是32位的汇编),设置页表等(后面会讲)
- 执行main.函数(通过汇编跳转到main函数,本质上就是调整PC指针而已)
# main.c
代码路径:init/main.c
我们看一些关键的代码
void main(void)
{
// ........省略部分代码 ........
mem_init(main_memory_start,memory_end);
trap_init();
blk_dev_init();
chr_dev_init();
tty_init();
time_init();
sched_init();
buffer_init(buffer_memory_end);
hd_init();
floppy_init();
sti();
move_to_user_mode();
if (!fork()) {
init();
}
// ........省略部分代码 ........
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
首先,为什么main函数的参数是void? 其实三个参数分别是envp,argv,argc,但目前版本的main没有使用,且我们在head.s 里可以看到,在push main函数之前,都压栈了3个0,所以这里是没有问题的
其次,我们可以看到,有很多的函数,并且都是带init字眼的,这些就是初始化内存,中断,时钟,硬盘,显示器等(Linux0.11不支持鼠标)
每一个都可以说很久很久,我们这里简单说说mem_init()
,其他的都类似
# mem_init()
mem_init()
顾名思义就是初始化内存的。前面我们提到操作系统就是管理硬件的,内存是一个重要的硬件,因此本函数就是初始化一些数据结构用来保存内存的信息,例如哪些被使用了,哪些是空闲的。
我们看看mem_init()
的部分代码(在linux-0.11\mm\memory.c 中 399行)
#define USED 100
#define PAGING_PAGES (PAGING_MEMORY>>12)
static unsigned char mem_map [ PAGING_PAGES ] = {0,};
void mem_init(long start_mem, long end_mem)
{
int i;
HIGH_MEMORY = end_mem;
for (i=0 ; i<PAGING_PAGES ; i++)
mem_map[i] = USED;
i = MAP_NR(start_mem);
end_mem -= start_mem;
end_mem >>= 12;
while (end_mem-->0)
mem_map[i++]=0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
我们先看看参数,start_mem, end_mem是main函数里传参的,而main函数是这样调用的:
static long main_memory_start = 0;
static long memory_end = 0;
//..........省略部分代码
memory_end = (1<<20) + (EXT_MEM_K<<10);
memory_end &= 0xfffff000;
//..........省略部分代码
mem_init(main_memory_start,memory_end);
2
3
4
5
6
7
其实内存的大小,在setup.s 里就已经存储到了 0x90000处,通过main函数里读取,然后传个mem_init
函数。
接下来 mem_init
里做什么呢?首先有一个全局变量mem_map,里面的值如果是0,表明内存没有被使用,如果是100,表明是已经被使用了。
所以在 mem_init
首先将 0地址处(也就是自己system模块)的代码标记为已使用;然后将剩余的内存表明为未使用
来看看是怎么标记的。第14行的代码,用了右移运算符,右移12位其实就是除以2的12次方,也就是除以4k(在内存里我们是用页来管理的,后续会详细说)。
这样的话大家可以看到所谓内存初始化就是形成这样一个表格,用这个表格来表示内存中哪些地方是使用的,哪些地方是没使用的,所以后面是没使用的,而前面是使用的。
# main.c小结
- 很多很多的初始化,每个都可以讲很久
- 这里讲下memory的初始化,建立一个数组,每个数组项就是一页内存,然后置为0,表示没使用过
# 谈点题外话:操作系统是怎么生成的
我们可以画下操作系统在磁盘里的逻辑示意图:
第一个部分存放bootsect.s,第二个部分存放setup模块,第三个部分存放system模块。这个顺序是不能变的,如果有一点点差错,都会死机。那么,操作系统是如何从源代码,编译成我们想要的样子呢?这就得提到make file。
我们平时写C语言的时候,都是由IDE帮我们编译并运行的,不用关心程序运行在内存的哪里;而如果做操作系统,一切的事情都要自己控制。除了要写源码之外,还要确定如何编译操作系统生成镜像(这里镜像可以简单理解为操作系统安装包,英文名Image),也就是make file
初步讲下如何确保磁盘里的第一段代码是bootsect,第二段是setup呢?通过makefile,并且有很多依赖文件,例如head.s,main.s,驱动等等。把这些汇编成.o文件,然后链接起来生成system。
我们可以看到Linux根目录下有个 Makefile文件,我们可以看看里面的关键内容:
Image: boot/bootsect boot/setup tools/system tools/build
tools/build boot/bootsect boot/setup tools/system $(ROOT_DEV) > Image
2
3
我们略略说一下,最后操作系统镜像Image,是依赖于bootsect的,还依赖于setup,system,还有很多工具类(tools),最后将这些代码链接起来,生成镜像,然后就可以运行这个镜像了。
# 操作系统启动小结
我们总结下这堂课:
- bootsect将操作系统从磁盘读进来,
- 而setup获得了一些参数,启动了保护模式,
- head初始化了GDT和IDT表,初始化了一些页表,然后跳到mian
- mian里又是很多int初始化函数,比如初始化内存,中断,时钟,硬盘,显示器
以下截图来自《Linux内核完全剖析基于0.11内核》第六章
图6-2清晰地显示出Liux系统启动时这几个程序或模块在内存中的动态位置。其中,每一竖条框代表某一时刻内存中各程序的映像位置图。先看图例
我们本堂课主要讲了引导扇区的代码做了什么事,其实我们可以换一个角度想想,bootsect.s应该做什么?
在刚一上电的时候,操作系统一定还在硬盘上,而计算机的工作原理是取指执行,因此就要运行操作系统,就必须将操作系统读到内存里。所以bootsect.s 应该就是将操作系统从磁盘读入到内存里。
当操作系统读入到内存后,操作系统就要管理硬件,因此需要初始化,读取硬件的参数,形成一些关键重要的数据结构来管理内存(例如mem_map管理内存)
所以,计算机在启动的时候就做了两件事,第一件事情把操作系统读到内存里,而第二步就是初始化操作系统运行需要的一些参数。
# 参考
操作系统(哈工大李治军老师)32讲(全)超清_哔哩哔哩_bilibili (opens new window) 第2课,第3课
《Linux内核完全剖析基于0.11内核》第6.1节 (本书好像已经不再发版了,当当上都是二手书。)
Linux v0.11源码:http://oldlinux.org/Linux.old/Linux-0.11/sources/system/linux-0.11.tar.Z
bootsect.s,setup.s 和head.s 的路径:linux-0.11\boot\。该目录下也就只有这几个文件