零、概览

首先申明,下面启动过程是基于 Linux 0.11 源码的分析的结果

Linux 0.11 源码

  1. 开机CPUPC(x86也叫IP)寄存器内容固定初始化为0xFFFF0(地址为BIOSROM程序地址)。开机会首先执行ROM中的程序。
    image.png
  2. BIOSROM程序会读取硬盘启动区(第一扇区512字节)的bootscet程序到内存的0x7c00位置
    image.png
  3. 把从bootscet程序从 0x7c000x90000,历史兼容原因只能先写到0x7c00,然后再复制到0x90000为什么主引导记录的内存地址是0x7C00?
    image.png
  4. 设置dsesss 几个寄存器的基地址为0x9000sp设置为0xFF00
    image.png
  5. 把操作系统setupsystem两个程序加载到内存中。
    image.png
  6. system代码复制到零地址处,覆盖掉0x7c00bootesect没用的代码。
    image.png
  7. 覆盖之后的内存布局如下:
    image.png
  8. 设置idtgdt
    image.png
  9. 设置CR0寄存器的第0位,进入保护模式。
    image.png
  10. 复制0x902000位置的idtgdt,到system程序的位置。
    image.png
  11. 设置页目录和页表。
    image.png
  12. 设置CR3寄存器。
    image.png
  13. 设置CR0寄存器的最后1位,开启分页模式。
    image.png
  14. main函数入栈。
    image.png
  15. 启动完成。
    image.png

一、开机后最开始的两行代码是什么?

1.1 开机后初始化指向 BIOS

首先,CPU 中有个 PC 寄存器,这里面存储着将要执行的指令在内存中的地址。当我们按下开机键后,CPU 就会有个初始化 PC 寄存器的过程,然后 CPU 就按照 PC 寄存器中的数值,去内存中对应的地址处寻找这条指令,然后进行执行。

初始化的值是多少呢?Intel 手册规定,开机后 PC 寄存器要初始化为 0xFFFF0,也就是从这个内存地址开始,执行 CPU 的第一条指令。

0xFFFF0对应的地址对应BIOSROM。所以开机以后会先执行BIOSROM中的程序。

image.png

1.2 读取硬盘启动区(第一扇区)

那什么是启动区呢?启动区的定义非常简单,只要硬盘中的 001 扇区(第一扇区)的 512 个字节的最后两个字节分别是 0x550xaa,那么 BIOS 就会认为它是个启动区。

image.png

1.3 加载到内存 0x7c00 位置并跳转

当我们把操作系统代码编译好后存放在硬盘的启动区中,开机后,BIOS 程序就会将代码搬运到内存的 0x7c00 位置,而 CPU 也会从这个位置开始,一条一条指令不断地往后执行下去。

BIOS 只帮我们把启动区的这 512 字节加载到内存,可是仍在硬盘其他扇区的操作系统代码就得我们自己来处理了,所以你很快就会看到这个过程。

就从用汇编语言写成的 bootsect.s 这个文件的前两行代码开始讲起吧!因为它会被编译并存储在启动区,然后搬运到内存 0x7c00,之后也会成为 CPU 执行的第一个指令,代码如下

mov ax,0x07c0  // ax = 0x07c0
mov ds,ax      // ds = 0x07c0

image.png

mov ax, [0x0001] 等价mov ax, [ds:0x0001]ds 是默认加上的,表示在 ds 这个段基址处,往后再偏移 0x0001 单位,将这个位置的内存数据复制到 ax 寄存器中。

我们再看看,为什么这个 ds 寄存器的数值要赋值为 0x07c0?这里是有历史因素的,x86 为了让自己在 16 位的实模式下,能访问到 20 位的地址线,所以要把段基址先左移四位。 0x07c0 左移四位就是 0x7c00,这刚好就和这段代码被 BIOS 加载到的内存地址 0x7c00 一样了。

总结

image.png

  1. 设置PC寄存器地址为0xFFFF0BIOSROM程序)。
  2. BIOS将磁盘第一扇区程序(bootsect)加载到内存 0x7c00
  3. 通过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位),其实就是不断重复地复制一个字。

  1. 重复执行多少次呢?答案是 cx 寄存器中的值,也就是 256 次。
  2. 从哪复制到哪呢?答案是从 ds:si 处复制到 es:di 处,也就是从 0x7c00 复制到 0x90000
  3. 一次复制多少呢?刚刚说过了,复制一个字16位,也就是两个字节。那么。一共复制256次的两个字节,其实就是复制512个字节。

好了,总结一下就是,将内存地址 0x7c00 处开始往后的 512 字节的数据,原封不动复制到 0x90000 处开始的后面 512 字节的地方,也就是下图的第二步:

image.png

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 寄存器的值分别复制给 dsesss 寄存器,然后又把 0xFF00 给了 sp 寄存器。

cs 寄存器表示代码段寄存器,CPU 即将要执行的代码在内存中的位置,就是由 cs:ip 这组寄存器配合指向的,其中 cs 是基址,ip 是偏移地址。

ds 是数据段寄存器,作为访问内存数据时的基地址。之前我们说过了,当时它被赋值为 0x07c0,是因为之前的代码在 0x7c00 处,现在代码已经被挪到了 0x90000 处,所以现在自然又改赋值为 0x9000 了。

es 是扩展段寄存器。

ss 是栈段寄存器,后面要配合栈指针寄存器 sp 来表示此时的栈顶地址。而此时 sp 寄存器被赋值为 0xFF00 了,所以目前的栈顶地址,就是 ss:sp 所指向的地址 0x9FF00 处。

image.png

3.1 CPU 访问内存的三种途径

CPU 访问内存有三种途径——访问代码的 cs:ip,访问数据的 ds:XXX,以及访问栈的 ss:sp

其中, cs 作为访问指令的代码段寄存器,被赋值为了 0x9000ds 作为访问数据的数据段寄存器,也被赋值为了 0x9000sssp 作为栈段寄存器和栈指针寄存器,分别被赋值为了 0x90000xFF00,由此计算出栈顶地址 ss:sp0x9FF00,之后的压栈和出栈操作就以这个栈顶地址为基准。

image.png

总结

为什么主引导记录的内存地址是0x7C00?

这一步主要初始化escsdsss几个段寄存器。

四、操作系统怎么把自己从硬盘搬运到内存?

4.1 操作系统的编译过程

整个编译过程,就是通过 Makefilebuild.c 配合完成的,最终达到这样一个效果:

  1. bootsect.s 编译成 bootsect 放在硬盘的 1 扇区;
  2. setup.s 编译成 setup 放在硬盘的 2~5 扇区;
  3. 把剩下的全部代码(head.s 作为开头,与各种 .c 和其他 .s 等文件一起)编译并链接成 system,放在硬盘的随后 240 个扇区。(不同版本的系统,占用的空间大小/扇区数不同)

image.png

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 个扇区。图示其实就是这样:

image.png

ok_load_setup:
    ...
    mov ax,#0x1000
    mov es,ax       ; segment of 0x10000
    call read_it
    ...
    jmpi 0,0x9020

剩下的核心代码就都写在这里了,就这么几行,其作用是把从硬盘第 6 个扇区开始往后的 240 个扇区,加载到内存 0x10000 处,和之前的从硬盘复制到内存是一个道理。

image.png

至此,整个操作系统的全部代码,就已经全部从硬盘加载到内存中了。然后这些代码,又通过一个熟悉的段间跳转指令 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 存储了列号。

image.png

计算机在加电自检后会自动初始化到文字模式,在这种模式下,一屏幕可以显示 25 行,每行 80 个字符,也就是 80 列。

那下一步 mov [0],dx 就是把这个光标位置存储在 [0] 这个内存地址处。注意,前面我们说过,这个内存地址仅仅是偏移地址,还需要加上 ds 这个寄存器里存储的段基址,最终的内存地址是在 0x90000 处,这里存放着光标的位置,以便之后在初始化控制台的时候用到。

image.png

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 位置,大概就是这么个效果。

image.png

5.2 洗牌后的内存布局

栈顶地址仍然是 0x9FF00 没有改变。

0x90000 开始往上的位置,原来是 bootsectsetup 程序的代码,而此时 bootsect 的代码现在已经被一些临时存放的数据,如内存、硬盘、显卡等信息,覆盖了一部分。

内存最开始的 00x80000512Ksystem 模块给占用了,之前讲过,这个 system 模块就是除了 bootsectsetup 之外的全部程序(head.s 作为开头,main.c 和其他文件紧随其后)链接在一起的结果,可以理解为操作系统的全部代码。

image.png

system 被放在了内存地址零位置处,之前的 bootsect 和现在所在的 setup,正逐步被其他数据所覆盖掉。

由此也可以看出,system 才是真正被视为重要的操作系统代码,其他的都是作为前期的铺垫,用完就被无情抛弃了。而 system 真正的大头要在第二部分才会展开讲解,所以为什么我把第一部分称为进入内核前的苦力活,这下知道了吧?

六、模式的转换

接下来就要进行真正的第一项大工程了,那就是模式的转换,需要从现在的 16 位的实模式转变为之后 32 位的保护模式。

因为这是 x86 的历史包袱问题,现在的 CPU 几乎都是支持 32 位模式甚至 64 位模式了,很少有还仅仅停留在 16 位的实模式下的 CPU

所以,我们要为了这个历史包袱,写一段模式转换的代码,如果 Intel CPU 被重新设计而不用考虑兼容性,那么今天的代码将会减少很多,甚至不复存在。

6.1 CPU的三种模式

image.png

  1. 实模式:兼容16CPU的模式,当前的PC系统处于实模式(16位模式)运行状态,在这种状态下软件可访问的物理内存空间不能超过1MB,且无法发挥Intel 80386以上级别的32CPU4GB内存管理能力。实模式将整个物理内存看成分段的区域,程序代码和数据位于不同区域,操作系统和用户程序并没有区别对待,而且每一个指针都是指向实际的物理地址。这样用户程序的一个指针如果指向了操作系统区域或其他用户程序区域,并修改了内容,那么其后果就很可能是灾难性的。
  2. 保护模式:操作系统所在模式,只有在保护模式下,8038632根地址线有效,可以寻址高达4G字节的线性内存空间和物理内存空间,可访问64TB的逻辑地址空间(有214个段,每个段最大空间为232个字节),可采用分段管理存储机制和分页管理存储机制。这不仅为存储共享和保护提供了硬件支持,而且为实现虚拟存储提供了硬件支持。通过提供4个特权级(R0 ~ R3)和完善的特权级检查制,既能实现资源共享又能保证代码数据的安全及任务的隔离。保护模式下有两个段表:GDTLDT
  3. 虚拟8086模式:可以模拟多个8086执行多任务。

6.2 实模式寻址过程

image.png

6.3 保护模式下的分段机制寻址过程

gdtr: 全局描述符表寄存器,前面提到,CPU现在使用的是段+分页结合的内存管理方式,那系统总共有那些分段呢?这就存储在一个叫全局描述符表(GDT)的表格中,并用gdtr寄存器指向这个表。这个表中的每一项都描述了一个内存段的信息。

ldtr: 局部描述符表寄存器,这个寄存器和上面的gdtr一样,同样指向的是一个段描述符表(LDT)。不同的是,GDT是全局唯一,LDT是局部使用的。

image.png

七、六行代码进入保护模式

// 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 这个寄存器的位 01,模式就从实模式切换到保护模式了

image.png

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)

image.png

九、Intel 内存管理两板斧:分段与分页

9.1 逻辑地址、线性地址、物理地址

image.png

  • 逻辑地址:我们程序员写代码时给出的地址叫逻辑地址,其中包含段选择子和偏移地址两部分。
  • 线性地址:通过分段机制,将逻辑地址转换后的地址,叫做线性地址。而这个线性地址是有个范围的,这个范围就叫做线性地址空间,32 位模式下,线性地址空间就是 4G。
  • 物理地址:就是真正在内存中的地址,它也是有范围的,叫做物理地址空间。那这个范围的大小,就取决于你的内存有多大了。
  • 虚拟地址:如果没有开启分页机制,那么线性地址就和物理地址是一一对应的,可以理解为两者相等。如果开启了分页机制,那么线性地址将被视为虚拟地址,这个虚拟地址将会通过分页机制的转换,最终转换成物理地址。

9.2 地址分页查找过程

image

image

image

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 位),还记得我们开启保护模式么?也是改这个寄存器中的一位的值。

image.png

十、进入 main 函数前的最后一跃

来看看设置分页代码的那个地方(head.s 里),后面这个操作就是用来跳转到 main.c 的。

after_page_tables:
    push 0
    push 0
    push 0
    push L6
    push _main
    jmp setup_paging
...
setup_paging:
    ...
    ret

image.png

除了 main 函数的地址压栈外,其他压入栈中的数据(比如 L6),是 main 函数返回时的跳转地址,但由于在操作系统层面的设计上,main 是绝对不会返回的,所以也就没用了。而其他的三个压栈的 0,本意是作为 main 函数的参数,但实际上似乎也没有用到,所以你也不必关心。

image.png

image.png

  • cs:eip 表示了我们要执行哪里的代码。
  • ds:xxx 表示了我们要访问哪里的数据。
  • ss:esp 表示了我们的栈顶地址在哪里。

image.png