Skip to content

启动流程详解

本章将深入讲解操作系统从上电到内核运行的完整启动过程。我们将从BIOS加载MBR开始,逐步分析Loader的工作原理、保护模式切换、分页机制建立,直到最终跳入内核执行。

启动流程概述

从上电到内核

x86架构计算机的启动是一个挺有意思的过程。从按下电源键到最终看到Shell,系统要经历好几个阶段,每个阶段的运行环境和能力都不一样。

最开始是BIOS阶段。CPU上电后会从一个固定地址0xFFFF0开始执行,这个地址指向BIOS ROM里的代码。BIOS会做一些硬件自检,然后按照启动顺序去找可启动的设备。找到硬盘后,BIOS会把硬盘第一个扇区(也就是MBR)读到内存0x7C00的位置,然后跳过去执行。

MBR只有512字节,能做的事情很有限。它的主要任务就是把更大的Loader程序从磁盘读进来。我的MBR会把Loader加载到0x900这个地址。

Loader阶段是最复杂的。它要做的事情包括:探测物理内存大小、构建GDT、从实模式切换到保护模式、建立分页机制、把内核ELF文件加载进来并解析。这些事情做完后,Loader就会跳转到内核入口点0xC0001500,把控制权交给内核。

内核拿到控制权后,会依次初始化中断、内存管理、进程调度这些核心子系统,最后启动Shell等待用户输入。

启动时的内存布局

在启动过程中,各个组件会被加载到特定的内存位置。理解这个内存布局对于调试启动问题很重要。

x86启动阶段内存布局

x86在实模式下可以访问的内存只有1MB。这1MB里有很多区域是被BIOS和硬件占用的,真正能用的空间其实不多。

最底下的1KB(0x0-0x3FF)是中断向量表,BIOS在这里存放了各种中断处理程序的入口地址。紧接着的256字节是BIOS数据区。0x7C000x7DFF这512字节是MBR的加载位置,这个地址是BIOS规定的。

我选择把Loader放在0x900,把内核ELF临时放在0x70000。这些地址的选择主要是为了避开BIOS占用的区域,同时留出足够的空间。

高地址区域(0xA0000以上)是给显存和BIOS ROM用的,不能用来存放代码。

CPU的初始状态

CPU复位后处于实模式,这时候它的行为和8086差不多:16位寄存器、20位地址线、没有内存保护。CS寄存器的值是0xF000,但它的基址实际上是0xFFFF0000,加上IP寄存器的0xFFF0,第一条指令从0xFFFFFFF0取得——这个地址正好落在BIOS ROM区域。

虽然CPU复位后是16位实模式,但由于地址线的特殊设置,第一条指令实际上是从4GB地址空间的最高端附近取的。这是x86架构的一个历史遗留设计。

从这个起点开始,BIOS会初始化硬件、建立中断向量表,然后把控制权交给我们的MBR。

MBR引导扇区

MBR是什么

MBR(Master Boot Record,主引导记录)是硬盘的第一个扇区,只有512字节。这512字节要完成引导操作系统的任务,空间相当紧张。

MBR扇区结构

标准的MBR结构是这样的:前446字节是引导代码区,用来存放启动程序;接着的64字节是分区表,记录硬盘的分区信息(4个分区表项,每个16字节);最后2字节是魔数0x55AA,BIOS通过检查这个魔数来判断这是不是一个有效的引导扇区。

不过我的MBR没有用到分区表,因为我们的内核是直接写到硬盘固定扇区的。所以这446字节全都可以用来写引导代码。

MBR的工作流程

BIOS把MBR加载到0x7C00后就跳转过来执行。这时候CPU还在实模式,段寄存器的值不确定,所以MBR第一件事就是初始化段寄存器:

text
org 0x7c00
section mbr
    mov ax, cs
    mov ds, ax
    mov es, ax
    mov ss, ax
    mov fs, ax
    mov sp, 0x7c00      ; 栈指针指向MBR下方
    mov ax, 0xb800
    mov gs, ax          ; gs指向显存

我把CS的值复制给其他段寄存器,这样数据段和代码段就指向同一个地方。GS寄存器指向显存基址0xB8000,方便后面直接往屏幕上写字符。栈指针设在0x7C00,栈向下增长,不会和MBR代码冲突。

初始化完成后,我做了一个清屏操作,然后往屏幕上写几个字符表示MBR开始运行了:

text
    ; 通过BIOS中断0x10清屏
    mov ax, 0x600       ; AH=0x06 向上滚屏, AL=0 全部
    mov bx, 0x700       ; 属性
    mov cx, 0           ; 左上角(0,0)
    mov dx, 0x184f      ; 右下角(79,24)
    int 0x10

    ; 直接写显存显示"KKKZBH"
    mov byte [gs:0x00], 'K'
    mov byte [gs:0x01], 0xA4    ; 绿色背景红色前景
    ; ... 后续字符

从硬盘读取Loader

MBR最重要的任务是把Loader从硬盘读进内存。我把Loader放在硬盘的第2扇区开始的位置,要读取4个扇区。

读硬盘用的是ATA PIO模式,需要操作IDE控制器的一系列端口。Primary通道的端口基址是0x1F0。读取流程是这样的:

text
rd_disk_m_16:   ; (eax: LBA地址, cx: 扇区数, bx: 目标内存)
    mov esi, eax
    mov di, cx 

    ; 1. 设置要读取的扇区数
    mov dx, 0x1f2
    mov al, cl
    out dx, al 

    ; 2. 写入LBA地址的低24位
    mov eax, esi
    mov dx, 0x1f3
    out dx, al          ; LBA 7~0位

    mov cl, 8
    shr eax, cl
    mov dx, 0x1f4
    out dx, al          ; LBA 15~8位

    shr eax, cl
    mov dx, 0x1f5
    out dx, al          ; LBA 23~16位

    ; 3. 写入LBA高4位和模式位
    shr eax, cl
    and al, 0x0f    
    or al, 0xe0         ; LBA模式, 主盘
    mov dx, 0x1f6
    out dx, al

    ; 4. 发送读命令
    mov dx, 0x1f7
    mov al, 0x20        ; 读命令
    out dx, al

    ; 5. 等待硬盘就绪
.not_ready:
    nop
    in al, dx
    and al, 0x88        ; 检查BSY和DRQ位
    cmp al, 0x08
    jnz .not_ready

    ; 6. 读取数据
    mov ax, di
    mov dx, 256 
    mul dx              ; 每扇区512字节 = 256个字
    mov cx, ax
    mov dx, 0x1f0
.go_on_read:
    in ax, dx
    mov [bx], ax
    add bx, 2
    loop .go_on_read
    ret

这段代码实现了LBA模式的磁盘读取。LBA(Logical Block Addressing)把磁盘当作一个线性的扇区数组,比传统的CHS寻址简单很多。端口0x1F7既是命令端口也是状态端口:写入时发送命令,读取时获取状态。

跳转到Loader

读完Loader后,MBR就可以跳转过去了:

text
    mov eax, LOADER_START_SECTOR    ; 扇区2
    mov bx, LOADER_BASE_ADDR        ; 0x900
    mov cx, 4
    call rd_disk_m_16

    jmp LOADER_BASE_ADDR + 0x300    ; 跳转到loader_start

注意跳转地址是0x900 + 0x300 = 0xC00。为什么要加0x300?因为Loader从0x900开始的前面一段是GDT等数据结构,真正的代码入口loader_start0xC00的位置。这个偏移是我在设计Loader内存布局时算好的。

MBR的最后两字节必须是0x55, 0xAA

text
    times 510 - ($ - $$) db 0
    db 0x55, 0xaa

times指令用剩余空间填充0,确保MBR正好512字节。

Loader加载器

为什么需要Loader

MBR只有512字节,根本装不下那么多功能。所以需要一个更大的程序来完成复杂的启动工作,这就是Loader。

我的Loader要做下面这些事情:探测系统有多少物理内存、构建全局描述符表(GDT)、从16位实模式切换到32位保护模式、建立分页机制、把内核ELF文件加载进来并解析、最后跳转到内核入口点。这些功能加起来代码量不小,所以我给Loader分配了4个扇区(2KB)的空间。

Loader的内存布局

Loader被加载到0x900,但代码入口在0xC00。中间这768字节(0x300)存放的是GDT和其他数据结构。

Loader内存布局

0x900开始依次是:GDT的4个描述符(空描述符、代码段、数据段、视频段),然后预留了60个描述符槽位供将来使用,接着是存放内存大小的变量total_mem_bytes、GDT指针gdt_ptr、用于内存探测的ARDS缓冲区。loader_start标签正好在0xC00的位置。

text
org LOADER_BASE_ADDR    ; 0x900
section loader
    ; GDT定义
    GDT_BASE:           dd 0x00000000
                        dd 0x00000000

    CODE_DESC:          dd 0x0000FFFF
                        dd DESC_CODE_HIGH4

    DATA_STACK_DESC:    dd 0x0000FFFF
                        dd DESC_DATA_HIGH4

    VIDEO_DESC:         dd 0x80000007
                        dd DESC_VIDEO_HIGH4

    GDT_SIZE equ $ - GDT_BASE
    GDT_LIMIT equ GDT_SIZE - 1
    times 60 dq 0       ; 预留60个描述符槽位

    ; 段选择子
    SELECTOR_CODE equ (0x0001 << 3) + TI_GDT + RPL0
    SELECTOR_DATA equ (0x0002 << 3) + TI_GDT + RPL0
    SELECTOR_VIDEO equ (0x0003 << 3) + TI_GDT + RPL0

    total_mem_bytes dd 0        ; 0xB00
    gdt_ptr:
        dw GDT_LIMIT
        dd GDT_BASE
    ards_buf times 244 db 0     ; ARDS缓冲区
    ards_nr dw 0

loader_start:                   ; 0xC00
    ; 代码从这里开始

内存探测

进入保护模式之前,要先通过BIOS中断获取物理内存的大小和布局。这个信息在保护模式下是拿不到的,因为保护模式下不能调用BIOS中断。

我用了三种方法来探测内存,按优先级依次尝试:

方法一:INT 15h, EAX=0xE820

这是最全面的方法,可以获取内存的详细布局,包括哪些区域可用、哪些被保留。BIOS会返回一系列ARDS(Address Range Descriptor Structure)结构体:

text
    xor ebx, ebx
    mov edx, 0x534D4150     ; "SMAP"签名
    mov di, ards_buf
.e820_mem_get_loop:
    mov eax, 0xE820
    mov ecx, 20             ; ARDS大小20字节
    int 0x15
    jc .e820_failed_so_try_e801
    add di, cx 
    inc word [ards_nr]
    cmp ebx, 0
    jne .e820_mem_get_loop

每个ARDS包含:基地址(8字节)、长度(8字节)、类型(4字节)。我把所有ARDS存到缓冲区,然后遍历找出最大的内存容量。

方法二:INT 15h, AX=0xE801

如果E820不可用,就试E801。这个方法返回两部分:15MB以下的内存(AX,单位KB)和16MB以上的内存(BX,单位64KB)。

方法三:INT 15h, AH=0x88

这是最老的方法,只能检测1MB到64MB之间的扩展内存。

不管用哪种方法,最终得到的内存大小会存到total_mem_bytes变量里。

构建GDT

GDT(Global Descriptor Table)是保护模式下的核心数据结构,定义了各个内存段的属性。我在Loader开头就定义好了4个段描述符:

第一个描述符必须是空的,这是x86的规定。

代码段描述符定义了一个从0开始、大小4GB、可执行的段。数据段描述符也是一样的范围,但属性是可读写。这种设置叫做"平坦模型"——所有段都覆盖整个4GB地址空间,相当于把分段机制架空了。

视频段比较特殊,它的基址是0xB8000,大小只有32KB(0x7FFF),专门用来访问文本模式显存。

每个描述符8字节,结构比较复杂。我用boot.inc里定义的宏来组装:

text
DESC_CODE_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + \
                    DESC_L + DESC_AVL + DESC_LIMIT_CODE2 + \
                    DESC_P + DESC_DPL_0 + DESC_S_CODE + \
                    DESC_TYPE_CODE + 0x00

这些宏把G位(粒度)、D位(默认操作数大小)、DPL(特权级)、类型等属性组合成描述符的高4字节。

保护模式切换

实模式的局限

CPU上电后处于实模式,这是为了兼容8086处理器。实模式有很多限制:只能访问1MB内存、没有内存保护、没有特权级机制。想要用上32位寻址和现代操作系统的那些功能,必须切换到保护模式。

切换保护模式需要三个步骤:打开A20地址线、加载GDT、设置CR0寄存器的PE位。

打开A20地址线

这是个历史遗留问题。8086有20根地址线,可以访问1MB内存。当地址超过1MB时会自动回绕到0。有些古老的程序依赖这个回绕特性。到了80286,地址线变成24根,但为了兼容老程序,IBM在主板上加了一个门电路来屏蔽第21根地址线(A20)。

我们需要打开A20才能访问1MB以上的内存。最简单的方法是通过端口0x92

text
    in al, 0x92
    or al, 0000_0010b
    out 0x92, al

读出0x92端口的值,把第1位置1,再写回去。这样A20就打开了。

加载GDT

GDT前面已经定义好了,现在要用lgdt指令告诉CPU它在哪:

text
    lgdt [gdt_ptr]

gdt_ptr是一个6字节的结构:前2字节是GDT的界限(大小减1),后4字节是GDT的基地址。

设置CR0.PE

万事俱备,最后一步是把CR0寄存器的PE位(第0位)置1:

text
    mov eax, cr0 
    or eax, 0x00000001
    mov cr0, eax

写入CR0后,CPU就进入保护模式了。但这时候指令流水线里可能还有实模式下取的指令,所以要紧接着一条远跳转来刷新流水线和段寄存器缓存:

text
    jmp dword SELECTOR_CODE:p_mode_start

这条jmp使用代码段选择子,跳转到p_mode_start标签。跳转完成后,CS寄存器就加载了新的段选择子,CPU彻底进入32位保护模式。

保护模式初始化

进入保护模式后,需要用新的段选择子初始化其他段寄存器:

text
[bits 32]
p_mode_start:
    mov ax, SELECTOR_DATA
    mov ds, ax
    mov es, ax
    mov ss, ax
    mov esp, LOADER_STACK_TOP

    mov ax, SELECTOR_VIDEO
    mov gs, ax

[bits 32]告诉汇编器后面是32位代码。DS、ES、SS都指向数据段,GS指向视频段。ESP设置为栈顶地址。

至此,我们已经从16位实模式成功切换到32位保护模式。接下来就可以从磁盘把内核读进来了。

1.4展示了MBR和Loader的运行效果。左上角的"KKKZBH"是MBR在实模式下直接写显存显示的,用红色前景绿色背景。下面的"V"是Loader进入保护模式后通过视频段寄存器写入的,表示保护模式切换成功。

MBR与Loader运行结果

保护模式切换是一个不可逆的过程。虽然技术上可以切回实模式,但一般没人这么做。从这里开始,BIOS中断就不能用了,所有硬件操作都要自己写驱动。

分页机制建立

为什么需要分页

保护模式只是第一步。虽然现在可以访问4GB地址空间了,但所有程序共用一个地址空间,没有隔离。要实现进程间的内存隔离,需要开启分页机制。

分页机制让每个进程有自己的虚拟地址空间。不同进程的同一个虚拟地址可以映射到不同的物理地址,互不干扰。内核代码通常映射到每个进程的高地址空间(比如0xC0000000以上),这样在任何进程里都能访问内核。

页表结构

x86使用两级页表:页目录表(Page Directory)和页表(Page Table)。页目录有1024项,每项指向一个页表;每个页表也有1024项,每项指向一个4KB的物理页。所以总共可以映射 $1024 \times 1024 \times 4KB = 4GB$。

我把页目录放在物理地址0x100000(1MB处)。页目录后面紧跟着页表。

text
setup_page:
    ; 先清空页目录表
    mov ecx, 4096
    mov esi, 0
.clear_page_dir:
    mov byte [PAGE_DIR_TABLE_POS + esi], 0
    inc esi
    loop .clear_page_dir

映射方案设计

我需要建立两个映射:

第一,把虚拟地址低端4MB(0x000000000x003FFFFF)映射到同样的物理地址。这个恒等映射是为了让开启分页前后程序能继续运行。开启分页后,如果当前指令的虚拟地址找不到对应的物理地址,CPU就会崩溃。

第二,把虚拟地址0xC00000000xC03FFFFF也映射到物理地址低端4MB。这样内核代码就可以用高地址来访问了。内核最终会运行在0xC0000000以上的虚拟地址空间。

要实现这两个映射,我让页目录的第0项和第768项指向同一个页表:

text
.create_pde:    
    mov eax, PAGE_DIR_TABLE_POS
    add eax, 0x1000         ; 第一个页表的位置
    mov ebx, eax
    
    or eax, PG_US_U | PG_RW_W | PG_P     ; 属性位
    mov [PAGE_DIR_TABLE_POS + 0x000], eax ; PDE 0
    mov [PAGE_DIR_TABLE_POS + 0xc00], eax ; PDE 768 (0xC00/4)

页目录第0项对应虚拟地址0x00000000开始的4MB,第768项对应0xC0000000开始的4MB($768 \times 4MB = 3GB$)。

还有一个技巧:页目录最后一项(第1023项)指向页目录自己。这样通过访问特定的虚拟地址就可以修改页表,不用再计算物理地址。

text
    sub eax, 0x1000
    mov [PAGE_DIR_TABLE_POS + 4092], eax  ; 最后一项指向自己

填充页表项

第一个页表要映射低端1MB(256个4KB页):

text
    mov ecx, 256        ; 1MB / 4KB = 256页
    mov esi, 0          
    mov edx, PG_US_U | PG_RW_W | PG_P
.create_pte:
    mov [ebx + esi * 4], edx
    add edx, 4096       ; 下一个物理页
    inc esi 
    loop .create_pte

我还预先建立了剩余的内核页目录项(第769到1022项),给将来的内核空间预留:

text
    mov eax, PAGE_DIR_TABLE_POS
    add eax, 0x2000         ; 从第二个页表开始
    or eax, PG_US_U | PG_RW_W | PG_P
    mov ecx, 254            ; 769~1022
    mov esi, 769
.create_kernel_pde:
    mov [ebx + esi * 4], eax
    inc esi 
    add eax, 0x1000
    loop .create_kernel_pde

开启分页

页表建好后,设置CR3指向页目录,然后把CR0的PG位(第31位)置1:

text
    mov eax, PAGE_DIR_TABLE_POS
    mov cr3, eax

    mov eax, cr0 
    or eax, 0x80000000
    mov cr0, eax

开启分页后,还要处理一些收尾工作:GDT里的视频段基址要改成虚拟地址(加0xC0000000)、GDT指针里的基址也要改、栈指针ESP也要映射到高地址:

text
    ; 修改视频段描述符的基址
    mov ebx, [gdt_ptr + 2]
    or dword [ebx + 0x18 + 4], 0xC0000000

    ; 修改GDT基址
    add dword [gdt_ptr + 2], 0xC0000000
    add esp, 0xC0000000

    ; 重新加载GDT
    lgdt [gdt_ptr]

现在整个系统运行在分页模式下,虚拟地址和物理地址的概念开始分离。接下来就可以加载内核了。

内核加载与解析

加载内核ELF文件

进入保护模式并开启分页后,终于可以加载内核了。内核是一个ELF格式的可执行文件,我在CMake里配置它链接到虚拟地址0xC0001500,入口函数名是kkkzbh

我把内核ELF文件存放在硬盘第9扇区开始的位置,总共375个扇区。Loader用32位版本的磁盘读取函数把它加载到物理地址0x70000

text
    mov eax, KERNEL_START_SECTOR    ; 扇区9
    mov ebx, KERNEL_BIN_BASE_ADDR   ; 0x70000
    mov ecx, 255                    ; 先读255扇区
    call rd_disk_m_32

    ; 再读剩下的
    mov eax, KERNEL_START_SECTOR
    add eax, 255
    mov ebx, KERNEL_BIN_BASE_ADDR
    add ebx, 0x1FE00
    mov ecx, 120
    call rd_disk_m_32

为什么要分两次读?因为rd_disk_m_32一次最多读255个扇区(cx寄存器8位的限制)。

CMake中的内核构建配置

内核的构建在CMake里是这样配置的:

cmake
add_executable(kernel
    main.c
    interrupt.asm
)

target_link_options(kernel PRIVATE
    "-nostartfiles"
    "-Wl,-Ttext,0xC0001500"
    "-Wl,-e,kkkzbh"
    "-Wl,--gc-sections"
    "-Wl,--build-id=none"
)

-Ttext,0xC0001500让链接器把代码段放到虚拟地址0xC0001500开始的位置。-e,kkkzbh指定入口点函数名。--build-id=none去掉ELF里的build-id节,减小文件体积。

编译完成后还要strip掉调试符号,然后用dd写入磁盘镜像:

cmake
add_custom_command(TARGET kernel POST_BUILD
    COMMAND \${CMAKE_OBJCOPY} --strip-all 
            $<TARGET_FILE:kernel> 
            \${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/kernel_stripped
)

add_disk_target(write_kernel 
    \${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/kernel_stripped 
    9 380)

解析ELF文件

内核ELF加载到0x70000后,不能直接跳过去执行,因为ELF文件包含了文件头、程序头表这些元数据,真正的代码和数据分散在各个段(segment)里。要把这些段复制到它们应该在的虚拟地址处。

ELF文件头的结构(简化版):

c
// 偏移28: e_phoff   - 程序头表在文件中的偏移
// 偏移42: e_phentsize - 每个程序头项的大小
// 偏移44: e_phnum   - 程序头项的数量

程序头(Program Header)描述了每个段的信息:

c
// 偏移0:  p_type   - 段类型
// 偏移4:  p_offset - 段在文件中的偏移
// 偏移8:  p_vaddr  - 段的虚拟地址
// 偏移16: p_filesz - 段在文件中的大小

Loader遍历所有程序头,把类型不为PT_NULL的段复制到对应的虚拟地址:

text
kernel_init:
    xor eax, eax 
    xor ebx, ebx        ; 程序头表位置
    xor ecx, ecx        ; 程序头数量
    xor edx, edx        ; 程序头大小

    mov dx, [KERNEL_BIN_BASE_ADDR + 42]  ; e_phentsize
    mov ebx, [KERNEL_BIN_BASE_ADDR + 28] ; e_phoff
    add ebx, KERNEL_BIN_BASE_ADDR
    mov cx, [KERNEL_BIN_BASE_ADDR + 44]  ; e_phnum

.each_segment:
    cmp byte [ebx + 0], PT_NULL
    je .PTNULL

    ; memcpy(dst, src, size)
    push dword [ebx + 16]               ; p_filesz
    mov eax, [ebx + 4]                  ; p_offset
    add eax, KERNEL_BIN_BASE_ADDR
    push eax                            ; src
    push dword [ebx + 8]                ; p_vaddr (dst)
    call memcpy
    add esp, 12

.PTNULL:
    add ebx, edx        ; 下一个程序头
    loop .each_segment
    ret

memcpy函数就是个简单的字节拷贝:

text
memcpy:
    cld 
    push ebp
    mov ebp, esp 
    push ecx

    mov edi, [ebp + 8]      ; dst
    mov esi, [ebp + 12]     ; src
    mov ecx, [ebp + 16]     ; size
    rep movsb

    pop ecx 
    pop ebp 
    ret

解析完成后,内核的代码和数据就被放到了它们该在的虚拟地址。这时候可以跳转到入口点了。

内核入口与初始化

跳转到内核

ELF解析完成后,Loader设置好内核栈,然后跳转到入口点:

text
enter_kernel:
    call kernel_init        ; 解析ELF
    mov esp, 0xc009f000     ; 设置内核栈
    jmp KERNEL_ENTRY_POINT  ; 0xC0001500

为什么内核栈设在0xC009F000?这个地址在虚拟地址空间的内核区域,往下增长不会和内核代码冲突。这个位置是我根据内核大小和内存布局算出来的。

从这里开始,控制权正式交给了内核。Loader的使命完成。

内核入口函数

内核入口函数名为kkkzbh(在链接时通过-Wl,-e,kkkzbh指定)。这个函数定义在main.c里:

c
// kernel/main.c
#include <stdio.h>

void start();

int kkkzbh()
{
    puts("kkkzbh says: Hello OS\n");
    puthex(0x123);
    putchar('\n');

    start();

    return 0;
}

kkkzbh函数做的事情很简单:打印一条欢迎消息,然后调用start()进入真正的初始化流程。这里用C语言写入口是因为链接器需要一个符号作为入口点。

1.5展示了内核入口函数的运行效果。可以看到屏幕上显示了"kkkzbh says: Hello OS"和十六进制数0x123,证明内核已经成功接管控制权。

内核入口函数运行输出

初始化流程

start()函数定义在start.cpp里,它负责调用各个子系统的初始化:

cpp
// kernel/start.cpp
auto init_all() -> void;
auto main() -> void;

import write_execution;
import thread;
import schedule;

extern "C" auto start() -> void
{
    init_all();
    clear();
    write_execution();  // 切记仅运行一次!
    main();
    thread_exit(running_thread(), true);
}

init_all()是真正干活的函数,定义在init.cpp里:

cpp
// kernel/init.cpp
auto init_all() -> void
{
    puts("init_all\n");
    clear_bss();                // 清零 BSS 段
    call_global_constructors(); // 调用C++全局构造函数

    idt_init();         // 初始化 中断
    mem_init();         // 初始化 内存管理系统
    thread_init();      // 初始化 线程环境
    timer_init();       // 初始化 PIT
    keyboard_init();    // 初始化 键盘中断
    tss_init();         // 初始化 tss
    syscall_init();     // 初始化 系统调用

    // 初始化硬盘要开中断
    intr_enable();
    ide_init();         // 初始化 硬盘

    filesystem_init();  // 初始化 文件系统
}

初始化顺序是有讲究的。BSS段清零必须在全局构造函数之前,否则静态变量会被错误地覆盖。中断系统要先初始化,因为后面的很多驱动都依赖中断。内存管理次之,因为线程创建需要分配内存。硬盘初始化需要先开中断,因为IDE驱动用中断来完成读写操作。

进入用户态

init_all()返回后,start()会调用main()进入用户逻辑。main()函数可以用来测试各种功能,最终会启动Shell等待用户交互。

内核主线程执行完main()后会调用thread_exit退出。系统的实际工作由init进程及其子进程完成。

小结

回顾整个启动流程:BIOS加载MBR → MBR加载Loader → Loader探测内存、构建GDT、切换保护模式、建立分页、加载解析内核 → kkkzbh()start()init_all()初始化各子系统 → 启动Shell。

这个流程看起来复杂,但每一步都有它的必要性。x86的启动过程其实是一部计算机发展史,从8086的实模式一步步过渡到现代操作系统需要的保护模式和分页机制。理解这个过程,对于后面分析内核代码会很有帮助。