我们要运行程序,必须先启动操作系统,但是刚开机的时候又是谁运行了操作系统呢?开机的那刻究竟发生了什么?接下来让我们一起来揭开操作系统启动过程的神秘面纱~
我先引用《Linux内核注释》的一段原话来让大家对开机后发生的情况有段简要的认识,然后结合代码来详细描述这个过程:
Linux 的最最前面部分是用 8086 汇编语言编写的(boot/bootsect.s),它将由 BIOS 读入到内存绝对地址 0x7C00(31KB)处,当它被执行时就会把自己移到绝对地址 0x90000(576KB)处,并把启动设备中后2kB 字节代码(boot/setup.s)读入到内存 0x90200 处,而内核的其它部分(system 模块)则被读入到从地址 0x10000 开始处,因为当时 system 模块的长度不会超过 0x80000 字节大小(即 512KB),所以它不会覆盖在 0x90000 处开始的 bootsect 和 setup 模块。随后将 system 模块移动到内存起始处,这样 system模块中代码的地址也即等于实际的物理地址。便于对内核代码和数据的操作。图 3.1 清晰地显示出 Linux系统启动时这几个程序或模块在内存中的动态位置。其中,每一竖条框代表某一时刻内存中各程序的映像位置图。在系统加载期间将显示信息"Loading..."。然后控制权将传递给 boot/setup.s 中的代码,这是另一个实模式汇编语言程序。
下面我将针对上图的1~6号步骤结合Linux代码进行详解,这节都是汇编,所以讲解起来可能很枯燥,如果你只是想知道这个时候操作系统做了什么,可以不必详读每步的内容,只需看标签上的总结和上面的图3.1基本上就可以了解了(学习资料传送门)
- 步骤<1>硬件自动加载硬盘第一个扇区内容到内存
为什么不直接让BOIS加载操作系统?由于BOIS只会自动加载硬盘的第一个扇区的512字节的内容,而操作系统的大小远远大于这个值,所以才会先加载操作系统自己的加载程序(这个可以很小),然后通过操作系统的加载程序加载操作系统(SYSTEM模块)到内存中。
- 步骤<2>将引导扇区内容移到0x90000
.text
BOOTSEG = 0x07c0
INITSEG = 0x9000
SYSSEG = 0x1000 | system loaded at 0x10000 (65536).
ENDSEG = SYSSEG + SYSSIZE
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
这段汇编不是很难,应该不难看出,就是将ds(0x07c0):si指向的内容赋值给es(0x9000):di,一共赋值256字(MOVW)即512字节,然后跳到go这个标签地方,并且指明代码段为CS=INITSEG,即下条指令从INITSEG(0x9000)+offset go开始执行(段内跳转是跳转偏移不是绝对地址),执行的指令就等于现在的go标签后的内容(步骤3)。之所以要移动是因为0x10000-0x90000(实地址模式下地址是等于段寄存器值左移4位加上段内偏移的,所以这边是4个0不是前面段寄存器里面的3个了)等下要放操作系统的内核代码
- 步骤<3>完成新的段寄存器设置以及打印系统加载提示字符(注意0.11内核和0.12不同,没有setup.s)
go: mov ax,cs
mov ds,ax
mov es,ax
mov ss,ax
mov sp,#0x400 | arbitrary value >>512
;这边AH=0x03是BIOS调用读取光标位置,返回值CH=光标起始行 DH,DL=行,列
mov ah,#0x03 | read cursor pos
xor bh,bh
int 0x10
;这边AH=0x13是BIOS调用显示字符串,具体调用参数可以百度
mov cx,#24
mov bx,#0x0007 | page 0, attribute 7 (normal)
mov bp,#msg1
mov ax,#0x1301 | write string, move cursor
int 0x10
这段主要是通过BOIS调用(这个时候操作系统还未启动,所有系统功能都是通过调用BOIS中断完成INT 0x10)完成打印"Loading system ..."字符串
- 步骤<4>加载真正的Linux操作系统到内存
mov ax,#SYSSEG
mov es,ax | segment of 0x010000
call read_it
call kill_motor
完成加载SYSTEM模块到0x10000的工作,关闭软驱马达用于读取其静态参数,有关read_it的流程图我已上传到本节学习资料
- 步骤<5>把Linux操作系统内核从0x10000移动到0x00000,在设置全局描述符后开启保护模式,并跳到0x0处执行操作系统内核指令
mov ah,#0x03 | read cursor pos
xor bh,bh
int 0x10 | save it in known place, con_init fetches
mov [510],dx | it from 0x90510.
cli | no interrupts allowed !
把当前光标位置保存到0x90510处,以后会用到,然后关闭中断,准备移动内核
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
j do_move
这边就是把内核从0x90000移动到0x00000过程了,汇编很简单,movsw表示一次拷贝一个字,每次循环0x8000次,就是0x10000字节,如果你熟悉实地址模式的汇编,就应该知道这是一个段的最大长度,即每次拷贝一个段;ds:di=0x0:di能表示的范围0x0~0x10000-1,ds:di=0x1000:di能表示的范围是0x10000~0x20000-1,所以上面每次为段寄存器add ax,0x1000其实是指向下个段,即下一个0x10000字节
end_move:
mov ax,cs | right, forgot this at first. didn't work :-)
mov ds,ax
lidt idt_48 | load idt with 0,0
lgdt gdt_48 | load gdt with whatever appropriate
| that was painless, now we enable A20
call empty_8042
mov al,#0xD1 | command write
out #0x64,al
call empty_8042
mov al,#0xDF | A20 on
out #0x60,al
call empty_8042
这边设置中断描述符表和全局描述符表(有关全局描述符的内容下节详细介绍,这两张表只有在保护模式下才有用),并开启A20信号线,最初的CPU只能使用20根地址线来寻址,后面CPU的地址线增加了,能寻址更多的范围,但是为了保持向下兼容,所以设置了A20开关,当关闭的时候20比特以上的地址都被清除
mov al,#0x11 | initialization sequence
out #0x20,al | send it to 8259A-1
.word 0x00eb,0x00eb | jmp $+2, jmp $+2
out #0xA0,al | and to 8259A-2
.word 0x00eb,0x00eb
mov al,#0x20 | start of hardware int's (0x20)
out #0x21,al
.word 0x00eb,0x00eb
mov al,#0x28 | start of hardware int's 2 (0x28)
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0x04 | 8259-1 is master
out #0x21,al
.word 0x00eb,0x00eb
mov al,#0x02 | 8259-2 is slave
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0x01 | 8086 mode for both
out #0x21,al
.word 0x00eb,0x00eb
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0xFF | mask off all interrupts for now
out #0x21,al
.word 0x00eb,0x00eb
out #0xA1,al
这边主要是对8259A中断控制器的编程,具体内容可以参见文献或者百度,目前我对研究这个没有太大兴趣,而且这个不影响我们理解操作系统
mov ax,#0x0001 | protected mode (PE) bit
lmsw ax | This is it!
jmpi 0,8 | jmp offset 0 of segment 8 (cs)
gdt:
;gdt[0]
.word 0,0,0,0 | dummy
;gdt[1]
.word 0x07FF | 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 | base address=0
.word 0x9A00 | code read/exec
.word 0x00C0 | granularity=4096, 386
;gdt[2]
.word 0x07FF | 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 | base address=0
.word 0x9200 | data read/write
.word 0x00C0 | granularity=4096, 386
开启保护模式,并加载状态字,然后取出gdt[1](CS=0000 0000 0000 01000b,高13位为选择子,即gdt数组下标)对应表项的段基地址0,加上偏移0,即跳转到内存地址0:0这个位置开始执行指令,由于SYSTEM模块被移动到内存地址0x0处,所以这边就是要开始执行操作系统的第一条指令了这边简单介绍下实地址模式和保护模式,在实地址模式下一个逻辑地址cs:xx/ds:xx对应的物理地址为cs<<4+xx/ds<<4+xx,而保护模式下,cs/ds变成了选择子,他们只是一个索引,用于指示对应全局描述符表中对应表项(全局描述符表类似数组,选择子类似数组下标,这边还未开启分页模式,所以逻辑地址通过段映射得到的线性地址就是物理地址),全局描述符表具体内容在下节介绍