Appearance
启动流程详解
本章将深入讲解操作系统从上电到内核运行的完整启动过程。我们将从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在实模式下可以访问的内存只有1MB。这1MB里有很多区域是被BIOS和硬件占用的,真正能用的空间其实不多。
最底下的1KB(0x0-0x3FF)是中断向量表,BIOS在这里存放了各种中断处理程序的入口地址。紧接着的256字节是BIOS数据区。0x7C00到0x7DFF这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结构是这样的:前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_start在0xC00的位置。这个偏移是我在设计Loader内存布局时算好的。
MBR的最后两字节必须是0x55, 0xAA:
text
times 510 - ($ - $$) db 0
db 0x55, 0xaatimes指令用剩余空间填充0,确保MBR正好512字节。
Loader加载器
为什么需要Loader
MBR只有512字节,根本装不下那么多功能。所以需要一个更大的程序来完成复杂的启动工作,这就是Loader。
我的Loader要做下面这些事情:探测系统有多少物理内存、构建全局描述符表(GDT)、从16位实模式切换到32位保护模式、建立分页机制、把内核ELF文件加载进来并解析、最后跳转到内核入口点。这些功能加起来代码量不小,所以我给Loader分配了4个扇区(2KB)的空间。
Loader的内存布局
Loader被加载到0x900,但代码入口在0xC00。中间这768字节(0x300)存放的是GDT和其他数据结构。

从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进入保护模式后通过视频段寄存器写入的,表示保护模式切换成功。

保护模式切换是一个不可逆的过程。虽然技术上可以切回实模式,但一般没人这么做。从这里开始,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(0x00000000到0x003FFFFF)映射到同样的物理地址。这个恒等映射是为了让开启分页前后程序能继续运行。开启分页后,如果当前指令的虚拟地址找不到对应的物理地址,CPU就会崩溃。
第二,把虚拟地址0xC0000000到0xC03FFFFF也映射到物理地址低端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
retmemcpy函数就是个简单的字节拷贝:
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的实模式一步步过渡到现代操作系统需要的保护模式和分页机制。理解这个过程,对于后面分析内核代码会很有帮助。