从操作系统启动开始
# 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 的指令,并且会在物理地址 0 处初始化中断向量
- 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 即可。
例如,下面就是我们做实验时看到的画面,可以看到有 Loading system……(其他的打印我们暂且不看)
# 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 已经不在用到,可以被覆盖)。完整的参数和保留的位置请见下表:(该表来自《Linux 内核完全剖析 第 6.3.1 节》)
内存地址 | 长度(字节) | 名称 | 描述 |
---|---|---|---|
0x90000 | 2 | 光标位置 | 列号(0x00-最左端),行号(0x00-最顶端) |
0x90002 | 2 | 扩展内存数 | 系统从 1MB 开始的扩展内存数值(KB)。 |
0x90004 | 2 | 显示页面 | 当前显示页面 |
0x90006 | 1 | 显示模式 | |
0x90007 | 1 | 字符列数 | |
0x90008 | 2 | ?? | |
0x9000A | 1 | 显示内存 | 显示内存(0x00-64k,0x01- 128k,0x02- 192k,0x03=256k) |
0x9000B | 1 | 显示状态 | 0x00-彩色,I/O=0x3dX﹔ 0x01-单色,I/O=0x3bX |
0x9000C | 2 | 特性参数 | 显示卡特性参数 |
... | |||
0x90080 | 16 | 硬盘参数表 | 第 1 个硬盘的参数表 |
0x90090 | 16 | 硬盘参数表 | 第 2 个硬盘的参数表(如果没有,则清零) |
0x901FC | 2 | 根设备号 | 根文件系统所在的设备号(bootsec.s 中设置) |
其中,获取扩展内存数是非常重要的。
- 什么是扩展内存:早期计算机中,地址总线只有 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 小结
setup 做的事情小结:
- 因为操作系统就是管理硬件的,因此首先得知道硬件的情况:读了一些硬件参数并存到内存里
- 把 system 挪到 0 地址处,将来操作系统运行的时候,system 模块会一直存在那里
- 然后启动了保护模式(通过修改 CR0 寄存器),最后运用应用了 32 位的汇编指令 JMPI 0, 8 跳到了 0 地址处去执行
- 0 地址就是 system 模块,因此后面就是操作系统运行起来了
补充两个知识点:
- 为什么 bootsect 的代码在一开始不直接把 system 模块挪到 0 地址处呢?因为在 setup 模块中,还需要用到 ROM BIOS 的中断例程,而中断向量表是在 0 地址处的,因此要使用完 BIOS 的中断例程后,才能覆盖 0 地址处。
- setup.s 之所以临时设置 IDT 和 GDT 表,也是为了后续 system 模块的汇编代码能在 32 位模式下运行。在 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)
即可。但其实,,然后才被 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 函数停止了,就会跳转到这里,然后死循环,也就是我们常见的死机…………
# 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 模块(大概占 240 个扇区)。这个顺序是不能变的,如果有一点点差错,都会死机。那么,操作系统是如何从源代码,编译成我们想要的样子呢?这就得提到 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),最后将这些代码链接起来,生成镜像,然后就可以运行这个镜像了。
# 文件系统
仅仅在内存中加载了这些代码模块,并不能让 Linux 运行起来。完整可运行的 Linux 还需要一个基本的文件系统支持,即根文件系统。Linux0.11 内核仅支持 MINIX 的 1.0 文件系统。在 bootsect.s 的第 43 行给出了根文件系统所在的默认块设备号,更多知识我们后续再讲。
# 操作系统启动小结
我们总结下这堂课:
- bootsect 将操作系统从磁盘读进来,
- 而 setup 获得了一些参数,启动了保护模式,
- head 初始化了 GDT 和 IDT 表,初始化了一些页表,然后跳到 mian
- mian 里又是很多 int 初始化函数,比如初始化内存,中断,时钟,硬盘,显示器
以下截图来自《Linux 内核完全剖析基于 0.11 内核》第六章
图 6-2 清晰地显示出 Liux 系统启动时这几个程序或模块在内存中的动态位置。其中,每一竖条框代表某一时刻内存中各程序的映像位置图。先看图例
我们本堂课主要讲了引导扇区的代码做了什么事,其实我们可以换一个角度想想,bootsect.s 应该做什么?
在刚一上电的时候,操作系统一定还在硬盘上,而计算机的工作原理是取指执行,因此就要运行操作系统,就必须将操作系统读到内存里。所以 bootsect.s 应该就是将操作系统从磁盘读入到内存里。
当操作系统读入到内存后,操作系统就要管理硬件,因此需要初始化,读取硬件的参数,形成一些关键重要的数据结构来管理内存(例如 mem_map 管理内存)
所以,计算机在启动的时候就做了两件事,第一件事情把操作系统读到内存里,而第二步就是初始化操作系统运行需要的一些参数。
# 小知识
为什么 bootsect.s 会被加载到 0x7C00?
这个地址来源于 IBM 的 PC 5150 BIOS 开发团队,该团队最早开发了 DOS 1.0 的相关设计。0x7C00 其实是数值上等于 31KB,而最初的 DOS 1.0 最少需要 32KB 的 RAM。设计团队想给 OS 在这 32KB 内留下足够的空间用于 OS 加载,而加载程序需要 512 个字节。这样,团队选择了 32KB 中的最后 1KB 用来加载 OS。一旦 OS 加载之后,这部分 RAM 其实是可以继续让 OS 使用的。
那为什么后续到了其他的应用中依然还用这个地址呢?很简单了,肯定是为了各种兼容而存在的了。
# 参考
操作系统(哈工大李治军老师)32 讲(全) (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\。该目录下也就只有这几个文件