零、概览
首先申明,下面启动过程是基于 Linux 0.11 源码的分析的结果
- 开机
CPU
的PC
(x86
也叫IP
)寄存器内容固定初始化为0xFFFF0
(地址为BIOS
的ROM
程序地址)。开机会首先执行ROM
中的程序。
BIOS
的ROM
程序会读取硬盘启动区(第一扇区512
字节)的bootscet
程序到内存的0x7c00
位置
- 把从
bootscet
程序从0x7c00
到0x90000
,历史兼容原因只能先写到0x7c00
,然后再复制到0x90000
为什么主引导记录的内存地址是0x7C00?。
- 设置
ds
、es
、ss
几个寄存器的基地址为0x9000
,sp
设置为0xFF00
。
- 把操作系统
setup
和system
两个程序加载到内存中。
- 把
system
代码复制到零地址处,覆盖掉0x7c00
处bootesect
没用的代码。
- 覆盖之后的内存布局如下:
- 设置
idt
和gdt
- 设置
CR0
寄存器的第0
位,进入保护模式。
- 复制
0x902000
位置的idt
、gdt
,到system
程序的位置。 - 设置页目录和页表。
- 设置
CR3
寄存器。 - 设置
CR0
寄存器的最后1
位,开启分页模式。 main
函数入栈。- 启动完成。
一、开机后最开始的两行代码是什么?
1.1 开机后初始化指向 BIOS
首先,CPU
中有个 PC
寄存器,这里面存储着将要执行的指令在内存中的地址。当我们按下开机键后,CPU
就会有个初始化 PC
寄存器的过程,然后 CPU
就按照 PC
寄存器中的数值,去内存中对应的地址处寻找这条指令,然后进行执行。
初始化的值是多少呢?Intel
手册规定,开机后 PC
寄存器要初始化为 0xFFFF0
,也就是从这个内存地址开始,执行 CPU
的第一条指令。
0xFFFF0
对应的地址对应BIOS
的ROM
。所以开机以后会先执行BIOS
的ROM
中的程序。
1.2 读取硬盘启动区(第一扇区)
那什么是启动区呢?启动区的定义非常简单,只要硬盘中的 0
盘 0
道 1
扇区(第一扇区)的 512
个字节的最后两个字节分别是 0x55
和 0xaa
,那么 BIOS
就会认为它是个启动区。
1.3 加载到内存 0x7c00 位置并跳转
当我们把操作系统代码编译好后存放在硬盘的启动区中,开机后,BIOS
程序就会将代码搬运到内存的 0x7c00
位置,而 CPU
也会从这个位置开始,一条一条指令不断地往后执行下去。
BIOS
只帮我们把启动区的这 512
字节加载到内存,可是仍在硬盘其他扇区的操作系统代码就得我们自己来处理了,所以你很快就会看到这个过程。
就从用汇编语言写成的 bootsect.s
这个文件的前两行代码开始讲起吧!因为它会被编译并存储在启动区,然后搬运到内存 0x7c00
,之后也会成为 CPU
执行的第一个指令,代码如下:
mov ax,0x07c0 // ax = 0x07c0
mov ds,ax // ds = 0x07c0
mov ax, [0x0001]
等价mov ax, [ds:0x0001]
,ds
是默认加上的,表示在 ds
这个段基址处,往后再偏移 0x0001
单位,将这个位置的内存数据复制到 ax
寄存器中。
我们再看看,为什么这个 ds
寄存器的数值要赋值为 0x07c0
?这里是有历史因素的,x86
为了让自己在 16
位的实模式下,能访问到 20
位的地址线,所以要把段基址先左移四位。 0x07c0
左移四位就是 0x7c00
,这刚好就和这段代码被 BIOS
加载到的内存地址 0x7c00
一样了。
总结
- 设置
PC
寄存器地址为0xFFFF0
(BIOS
的ROM
程序)。 BIOS
将磁盘第一扇区程序(bootsect
)加载到内存0x7c00
。- 通过
mov
指令将默认的数据段寄存器ds
的值改为0x07c0
,方便以后的基址寻址方式。
二、从 0x7c00 到 0x90000
接下来我们带着这两行代码,继续往下看6
行,代码如下:
mov ax,0x9000 // ax = 0x9000
mov es,ax // es = 0x9000
mov cx,#256 // cx = 256
sub si,si // si = 0
sub di,di // di = 0
rep movw // 重复256次 mov 操作,每次复制 word 16位, 复制512字节的数据
其中 rep
表示重复执行后面的指令,而后面的指令 movw
表示复制一个字(word 16
位),其实就是不断重复地复制一个字。
- 重复执行多少次呢?答案是
cx
寄存器中的值,也就是256
次。 - 从哪复制到哪呢?答案是从
ds:si
处复制到es:di
处,也就是从0x7c00
复制到0x90000
。 - 一次复制多少呢?刚刚说过了,复制一个字
16
位,也就是两个字节。那么。一共复制256
次的两个字节,其实就是复制512
个字节。
好了,总结一下就是,将内存地址 0x7c00
处开始往后的 512
字节的数据,原封不动复制到 0x90000
处开始的后面 512
字节的地方,也就是下图的第二步:
jmpi go,0x9000
go:
mov ax,cs
mov ds,ax
jmpi
是一个段间跳转指令,表示跳转到 0x9000:go
处执行。
段基址仍然要先左移四位再加上偏移地址,段基址 0x9000
左移四位就是 0x90000
,因此结论就是跳转到 0x90000 + go
这个内存地址处执行。
这里主要把bootsect
程序从0x7c00
复制了一份到0x90000
。然后跳转到0x90000 + go
这个内存地址处执行。
三、做好访问内存的最基础准备工作
jmpi go,0x9000 // cs = 0x9000, ip = go
go: mov ax,cs
mov ds,ax
mov es,ax
mov ss,ax
mov sp,#0xFF00
这段代码的直接意思很容易理解,就是把 cs
寄存器的值分别复制给 ds
、es
和 ss
寄存器,然后又把 0xFF00
给了 sp
寄存器。
cs
寄存器表示代码段寄存器,CPU
即将要执行的代码在内存中的位置,就是由 cs:ip
这组寄存器配合指向的,其中 cs
是基址,ip
是偏移地址。
ds
是数据段寄存器,作为访问内存数据时的基地址。之前我们说过了,当时它被赋值为 0x07c0
,是因为之前的代码在 0x7c00
处,现在代码已经被挪到了 0x90000
处,所以现在自然又改赋值为 0x9000
了。
es
是扩展段寄存器。
ss
是栈段寄存器,后面要配合栈指针寄存器 sp
来表示此时的栈顶地址。而此时 sp
寄存器被赋值为 0xFF00
了,所以目前的栈顶地址,就是 ss:sp
所指向的地址 0x9FF00
处。
3.1 CPU 访问内存的三种途径
CPU
访问内存有三种途径——访问代码的 cs:ip
,访问数据的 ds:XXX
,以及访问栈的 ss:sp
。
其中, cs
作为访问指令的代码段寄存器,被赋值为了 0x9000
。ds
作为访问数据的数据段寄存器,也被赋值为了 0x9000
。ss
和 sp
作为栈段寄存器和栈指针寄存器,分别被赋值为了 0x9000
和 0xFF00
,由此计算出栈顶地址 ss:sp
为 0x9FF00
,之后的压栈和出栈操作就以这个栈顶地址为基准。
总结
这一步主要初始化es
、cs
、ds
、ss
几个段寄存器。
四、操作系统怎么把自己从硬盘搬运到内存?
4.1 操作系统的编译过程
整个编译过程,就是通过 Makefile
和 build.c
配合完成的,最终达到这样一个效果:
- 把
bootsect.s
编译成bootsect
放在硬盘的1
扇区; - 把
setup.s
编译成setup
放在硬盘的2~5
扇区; - 把剩下的全部代码(
head.s
作为开头,与各种.c
和其他.s
等文件一起)编译并链接成system
,放在硬盘的随后240
个扇区。(不同版本的系统,占用的空间大小/扇区数不同)
4.2 把剩下的操作系统代码从硬盘请到内存
load_setup:
mov dx,#0x0000 ; drive 0, head 0
mov cx,#0x0002 ; sector 2, track 0
mov bx,#0x0200 ; address = 512, in 0x9000
mov ax,#0x0200+4 ; 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
ok_load_setup:
...
本段代码的注释已经写的很明确了,直接说最终的作用吧——从硬盘的第 2 个扇区开始,把数据加载到内存 0x90200 处,共加载 4 个扇区。图示其实就是这样:
ok_load_setup:
...
mov ax,#0x1000
mov es,ax ; segment of 0x10000
call read_it
...
jmpi 0,0x9020
剩下的核心代码就都写在这里了,就这么几行,其作用是把从硬盘第 6
个扇区开始往后的 240
个扇区,加载到内存 0x10000
处,和之前的从硬盘复制到内存是一个道理。
至此,整个操作系统的全部代码,就已经全部从硬盘加载到内存中了。然后这些代码,又通过一个熟悉的段间跳转指令 jmpi 0,0x9020
,跳转到 0x90200
处,就是硬盘第二个扇区开始处的内容。
五、重要代码放在零地址处
5.1 内存拷贝
好,我们向下一个文件 setup.s
进发!现在程序跳转到了 0x90200
这个位置开始执行,这个位置处的代码就位于 setup.s
的开头,代码如下:
start:
mov ax,#0x9000 ; this is done in bootsect already, but...
mov ds,ax
mov ah,#0x03 ; read cursor pos
xor bh,bh
int 0x10 ; save it in known place, con_init fetches
mov [0],dx ; it from 0x90000.
这个 int 0x10
中断程序执行完毕并返回时,将会在 dx
寄存器里存储好光标的位置,具体说来其高八位 dh
存储了行号,低八位 dl
存储了列号。
计算机在加电自检后会自动初始化到文字模式,在这种模式下,一屏幕可以显示 25 行,每行 80 个字符,也就是 80 列。
那下一步 mov [0],dx
就是把这个光标位置存储在 [0]
这个内存地址处。注意,前面我们说过,这个内存地址仅仅是偏移地址,还需要加上 ds
这个寄存器里存储的段基址,最终的内存地址是在 0x90000
处,这里存放着光标的位置,以便之后在初始化控制台的时候用到。
cli ; no interrupts allowed ;
就一行 cli
,表示关闭中断的意思。因为后面我们要覆盖掉原本 BIOS
写好的中断向量表,也就是破坏掉原有的表,写上我们自己的中断向量表,所以此时是不允许中断进来的。
; first we move the system to it's rightful place
mov ax,#0x0000
cld ; 'direction'=0, movs moves forward
do_move:
mov es,ax ; destination segment
add ax,#0x1000
cmp ax,#0x9000
jz end_move
mov ds,ax ; source segment
sub di,di
sub si,si
mov cx,#0x8000
rep movsw
jmp do_move
; then we load the segment descriptors
end_move:
...
最终的结果是,把内存地址 0x10000
处开始往后一直到 0x90000
的内容,统统复制到内存的最开始的 0
位置,大概就是这么个效果。
5.2 洗牌后的内存布局
栈顶地址仍然是 0x9FF00
没有改变。
而 0x90000
开始往上的位置,原来是 bootsect
和 setup
程序的代码,而此时 bootsect
的代码现在已经被一些临时存放的数据,如内存、硬盘、显卡等信息,覆盖了一部分。
内存最开始的 0
到 0x80000
这 512K
被 system
模块给占用了,之前讲过,这个 system
模块就是除了 bootsect
和 setup
之外的全部程序(head.s
作为开头,main.c
和其他文件紧随其后)链接在一起的结果,可以理解为操作系统的全部代码。
system
被放在了内存地址零位置处,之前的 bootsect
和现在所在的 setup
,正逐步被其他数据所覆盖掉。
由此也可以看出,system
才是真正被视为重要的操作系统代码,其他的都是作为前期的铺垫,用完就被无情抛弃了。而 system
真正的大头要在第二部分才会展开讲解,所以为什么我把第一部分称为进入内核前的苦力活,这下知道了吧?
六、模式的转换
接下来就要进行真正的第一项大工程了,那就是模式的转换,需要从现在的 16 位的实模式转变为之后 32 位的保护模式。
因为这是 x86
的历史包袱问题,现在的 CPU
几乎都是支持 32
位模式甚至 64
位模式了,很少有还仅仅停留在 16
位的实模式下的 CPU
。
所以,我们要为了这个历史包袱,写一段模式转换的代码,如果 Intel CPU
被重新设计而不用考虑兼容性,那么今天的代码将会减少很多,甚至不复存在。
6.1 CPU的三种模式
- 实模式:兼容
16
位CPU
的模式,当前的PC
系统处于实模式(16
位模式)运行状态,在这种状态下软件可访问的物理内存空间不能超过1MB
,且无法发挥Intel 80386
以上级别的32
位CPU
的4GB
内存管理能力。实模式将整个物理内存看成分段的区域,程序代码和数据位于不同区域,操作系统和用户程序并没有区别对待,而且每一个指针都是指向实际的物理地址。这样用户程序的一个指针如果指向了操作系统区域或其他用户程序区域,并修改了内容,那么其后果就很可能是灾难性的。 - 保护模式:操作系统所在模式,只有在保护模式下,
80386
的32
根地址线有效,可以寻址高达4G
字节的线性内存空间和物理内存空间,可访问64TB
的逻辑地址空间(有214
个段,每个段最大空间为232
个字节),可采用分段管理存储机制和分页管理存储机制。这不仅为存储共享和保护提供了硬件支持,而且为实现虚拟存储提供了硬件支持。通过提供4
个特权级(R0 ~ R3
)和完善的特权级检查制,既能实现资源共享又能保证代码数据的安全及任务的隔离。保护模式下有两个段表:GDT
和LDT
- 虚拟8086模式:可以模拟多个
8086
执行多任务。
6.2 实模式寻址过程
6.3 保护模式下的分段机制寻址过程
gdtr: 全局描述符表寄存器,前面提到,CPU
现在使用的是段+分页结合的内存管理方式,那系统总共有那些分段呢?这就存储在一个叫全局描述符表(GDT
)的表格中,并用gdtr
寄存器指向这个表。这个表中的每一项都描述了一个内存段的信息。
ldtr: 局部描述符表寄存器,这个寄存器和上面的gdtr
一样,同样指向的是一个段描述符表(LDT
)。不同的是,GDT
是全局唯一,LDT
是局部使用的。
七、六行代码进入保护模式
// setup.s
mov al,#0xD1 ; command write
out #0x64,al
mov al,#0xDF ; A20 on
out #0x60,al
这段代码的意思是打开 A20
地址线。到底什么是 A20
地址线呢?
简单来说,这一步就是为了突破地址信号线 20
位的宽度,变成 32
位可用。这是由于 8086
CPU
只有 20
位的地址线,所以如果程序给出 21
位的内存地址数据,那多出的一位就被忽略了。
mov ax,#0x0001 ; protected mode (PE) bit
lmsw ax ; This is it;
jmpi 0,8 ; jmp offset 0 of segment 8 (cs)
将 cr0
这个寄存器的位 0
置 1
,模式就从实模式切换到保护模式了
jmpi
,后面的 8
表示 cs
寄存器的值,0
表示 ip
寄存器的值,换一种伪代码表示就等价于:
cs = 8
ip = 0
可以知道描述符索引值是 1
,也就是 CPU
要去全局描述符表(gdt
)中找索引 1
的描述符。
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
所以,这里取的就是这个代码段描述符,段基址是 0
,偏移也是 0
,那加一块就还是 0
。那么最终这个跳转指令,就是跳转到内存地址的 0
地址处,开始执行。
第二个和第三个段描述符的段基址都是 0,也就是之后在逻辑地址转换物理地址的时候,通过段选择子查找到无论是代码段还是数据段,取出的段基址都是 0,那么物理地址将直接等于程序员给出的逻辑地址(准确说是逻辑地址中的偏移地址)
八、重新设置idt、gdt
call setup_idt ;设置中断描述符表
call setup_gdt ;设置全局描述符表
mov eax,10h
mov ds,ax
mov es,ax
mov fs,ax
mov gs,ax
lss esp,_stack_start
_gdt:
DQ 0000000000000000h ;/* NULL descriptor */
DQ 00c09a0000000fffh ;/* 16Mb */
DQ 00c0920000000fffh ;/* 16Mb */
DQ 0000000000000000h ;/* TEMPORARY - don't use */
DQ 252 dup(0)
九、Intel 内存管理两板斧:分段与分页
9.1 逻辑地址、线性地址、物理地址
- 逻辑地址:我们程序员写代码时给出的地址叫逻辑地址,其中包含段选择子和偏移地址两部分。
- 线性地址:通过分段机制,将逻辑地址转换后的地址,叫做线性地址。而这个线性地址是有个范围的,这个范围就叫做线性地址空间,32 位模式下,线性地址空间就是 4G。
- 物理地址:就是真正在内存中的地址,它也是有范围的,叫做物理地址空间。那这个范围的大小,就取决于你的内存有多大了。
- 虚拟地址:如果没有开启分页机制,那么线性地址就和物理地址是一一对应的,可以理解为两者相等。如果开启了分页机制,那么线性地址将被视为虚拟地址,这个虚拟地址将会通过分页机制的转换,最终转换成物理地址。
9.2 地址分页查找过程
9.3 开启分页
setup_paging:
mov ecx,1024*5
xor eax,eax
xor edi,edi
pushf
cld
rep stosd
mov eax,_pg_dir
mov [eax],pg0+7
mov [eax+4],pg1+7
mov [eax+8],pg2+7
mov [eax+12],pg3+7
mov edi,pg3+4092
mov eax,00fff007h
std
L3: stosd
sub eax,00001000h
jge L3
popf
xor eax,eax
mov cr3,eax
mov eax,cr0
or eax,80000000h
mov cr0,eax
ret
之后再开启分页机制的开关。其实就是更改 cr0 寄存器中的一位(31 位),还记得我们开启保护模式么?也是改这个寄存器中的一位的值。
十、进入 main 函数前的最后一跃
来看看设置分页代码的那个地方(head.s
里),后面这个操作就是用来跳转到 main.c
的。
after_page_tables:
push 0
push 0
push 0
push L6
push _main
jmp setup_paging
...
setup_paging:
...
ret
除了 main
函数的地址压栈外,其他压入栈中的数据(比如 L6
),是 main
函数返回时的跳转地址,但由于在操作系统层面的设计上,main
是绝对不会返回的,所以也就没用了。而其他的三个压栈的 0
,本意是作为 main
函数的参数,但实际上似乎也没有用到,所以你也不必关心。
- cs:eip 表示了我们要执行哪里的代码。
- ds:xxx 表示了我们要访问哪里的数据。
- ss:esp 表示了我们的栈顶地址在哪里。