首页 / 资讯中心 / 文章详情

操作系统启动过程:从加电到内核运行的17个硬核步骤

操作系统启动过程:从加电到内核运行的17个硬核步骤 ★ FEATURED ARTICLE
1. 项目概述为什么“系统软件启动过程”是操作系统开发的真正分水岭很多人以为写一个操作系统就是画个界面、写几个系统调用、再塞点进程调度逻辑——这其实连门都没摸到。真正把人拦在操作系统开发者门外的从来不是内存管理算法有多难也不是文件系统结构有多复杂而是从加电那一刻起到第一行C代码开始执行之间那不到一秒钟里发生的几十个精密协同步骤。这个过程就是“系统软件启动过程”它不是教科书里一笔带过的“BIOS加载MBR”而是一整套软硬交界处的底层契约、时序约束与信任传递链。我带过几届某高校操作系统的实践课每次开课前都会让学员先写一个“能打印hello world”的实模式引导扇区。结果近七成的人卡在第三步他们成功跳转到了0x7c00也正确设置了段寄存器但mov ax, 0x1234之后mov [0x8000], ax写进去的数据在后续代码里读出来却是0x0000。没人想到问题出在——他们没关中断也没清空数据缓存更没意识到实模式下段地址计算cs4 ip和物理地址映射之间存在隐式对齐要求。这种错误不会报错不会崩溃只会让你的“操作系统”静默失效像一台接通电源却毫无反应的收音机。所以“从0开始写一个操作系统”的第二部分必须死磕启动过程。它解决的不是“怎么写代码”而是“代码凭什么能被执行”。这里面涉及三个不可绕过的硬核层次硬件初始化层CPU复位向量、寄存器初始状态、内存控制器使能、固件交互层UEFI/BIOS的ABI规范、服务调用约定、内存映射表交付、以及内核接管层保护模式切换时机、GDT/LDT加载顺序、IDT初始化与中断屏蔽策略。这三个层次环环相扣漏掉任意一环你的内核就永远等不到main()函数的第一行汇编指令。你不需要是芯片设计工程师但必须像硬件工程师一样思考时序你不需要读懂Intel SDM第3卷第9章的每一个脚注但必须清楚知道lgdt [gdt_descriptor]这条指令执行后CPU内部状态机发生了哪5个关键变化你不需要手写微码但必须明白为什么在开启分页之前必须先将页目录表的物理地址写入CR3寄存器且该地址必须是4KB对齐的。这些不是炫技而是生存法则。这篇文章就是带你亲手把这段“黑箱启动流程”拆成可测量、可调试、可复现的17个确定性步骤并告诉你每一步背后硬件在想什么固件在做什么而你的代码又该如何精准回应。2. 启动过程整体设计与思路拆解从“裸奔”到“受控”的三阶段演进操作系统启动不是一条直线而是一场接力赛。整个过程被清晰地划分为三个逻辑阶段每个阶段由不同实体主导承担不同职责也拥有完全不同的执行环境与权限边界。理解这个分阶段模型是避免后续所有“为什么我的代码不运行”类问题的前提。2.1 阶段一硬件复位与固件接管0毫秒 ~ 100毫秒CPU上电或复位后第一条指令永远从物理地址0xFFFF0开始执行——这是Intel x86架构写死的复位向量Reset Vector。这个地址并不指向你的代码而是映射到主板ROM芯片中固化的一段微码。这段微码就是我们常说的固件Firmware在传统PC上叫BIOS在现代设备上叫UEFI。它的核心任务只有一个为后续软件提供一个“可预测的、最小化的、带基础服务的运行环境”。这个阶段的关键动作包括硬件自检POST检测CPU、内存、显卡、键盘控制器等核心部件是否响应。如果内存条松了你会听到“嘀嘀嘀”报警声——这就是POST失败的反馈。总线枚举与设备初始化扫描PCI总线为网卡、SATA控制器等分配I/O端口和内存映射地址并执行其Option ROM中的初始化代码比如显卡的VGA BIOS。建立基本服务接口BIOS通过中断向量如INT 10h视频服务、INT 13h磁盘服务暴露功能UEFI则通过一系列Protocol如SimpleTextOutputProtocol提供面向对象的调用方式。加载并移交控制权BIOS搜索可启动设备硬盘MBR、U盘引导扇区、CD-ROM El Torito将第一个扇区512字节读入内存0x7C00处并跳转执行UEFI则根据NVRAM中存储的BootOrder变量加载指定路径如\EFI\BOOT\BOOTX64.EFI的PE格式可执行文件。提示很多初学者误以为“BIOS是软件”其实它是固化在主板SPI Flash芯片里的微码固件组合。你无法用gcc编译它也无法用gdb调试它——它就是硬件的一部分。因此与其研究“如何修改BIOS”不如专注理解“它会给我交付什么”。2.2 阶段二引导加载程序Bootloader执行100毫秒 ~ 500毫秒这是你真正能“写代码”的第一站。它通常分为两个子阶段Stage 1主引导记录 MBR和Stage 2核心引导程序。MBR只有512字节空间极度受限它唯一能做的就是从硬盘特定位置通常是紧随MBR之后的若干扇区加载更大的Stage 2代码到内存更高地址如0x90000然后跳转过去。Stage 2才是真正的“启动大脑”它要完成三项生死攸关的任务环境重置关闭所有可能干扰内核的硬件特性。例如禁用BIOS中断CLI、清空所有段寄存器mov ax, 0; mov ds, ax; mov es, ax...、重置协处理器fninit、禁用APIC如果存在。内存布局规划读取固件提供的内存映射信息BIOS通过INT 15h, AXE820h返回UEFI通过GetMemoryMap()函数提供识别出哪些物理地址范围是可用RAM哪些被显存、ACPI表、固件保留占用。据此为内核镜像、初始页表、堆栈空间划分安全区域。模式切换准备x86 CPU默认以16位实模式启动最大寻址1MB。而现代操作系统需要访问GB级内存和使用32/64位指令。Stage 2必须完成从实模式到保护模式32位或长模式64位的切换。这绝非简单一条mov cr0, eax就能搞定——它要求你提前准备好全局描述符表GDT其中必须包含至少四个描述符空描述符索引0、代码段可执行、特权级0、数据段可读写、特权级0、栈段同数据段。GDT的基地址和长度必须通过lgdt [gdt_descriptor]精确加载且该指令执行后CPU才认可后续的jmp指令可以使用32位偏移。我试过一次把GDT中代码段描述符的D/B位Default Operation Size设成了0表示16位代码结果jmp 0x08:0x10000跳转后CPU仍在16位模式下解析指令导致后续所有32位指令如mov eax, ebx被错误解码为两条16位指令内核直接陷入不可恢复的指令异常。这个坑没有调试器只有靠对Intel手册的逐字精读才能避开。2.3 阶段三内核初始化与用户空间接管500毫秒 ~ 启动完成当CPU成功进入保护模式或长模式执行权正式移交给你写的内核。但这绝不意味着万事大吉。此时的内核只是一个“赤裸”的二进制镜像它没有堆内存、没有文件系统、甚至没有自己的栈当前栈仍是Bootloader分配的临时栈。它必须在没有任何外部帮助的情况下完成自我构建。这一阶段的核心工作流是建立内核栈与堆在已知的、未被占用的物理内存页中分配一页作为初始内核栈kstack并设置ss:rsp寄存器同时初始化一个简单的buddy system或slab allocator为后续模块分配内存。初始化关键子系统按严格依赖顺序启动。首先是中断控制器PIC/APIC配置其向量表和优先级其次是定时器PIT或HPET注册时钟中断处理函数然后是内存管理单元MMU构建页目录和页表启用分页机制mov cr0, eax设置PG位最后才是进程调度器创建idle进程和init进程。移交用户空间内核最终会调用execve(/sbin/init, ...)加载第一个用户态程序。此时CPU的特权级从Ring 0内核态切换到Ring 3用户态并通过iret指令完成栈帧切换和CS/SS寄存器更新。整个系统至此才算真正“活”了过来。注意内核初始化顺序不是随意排列的。比如你必须在启用分页之前就完成GDT和IDT的初始化因为分页启用后所有内存访问都经过页表转换如果IDT的基地址没被正确映射第一次中断到来时就会触发双重错误Double Fault系统立即宕机。这种强依赖关系是操作系统启动过程最脆弱也最关键的逻辑骨架。3. 核心细节解析与实操要点17个不可妥协的硬核步骤现在我们把上述三阶段模型落地为一份可逐行执行、可逐点验证的实操清单。这不是理论推演而是我在某跨平台嵌入式系统项目中用QEMUGDB单步调试、配合Intel SDM手册逐页对照最终沉淀下来的17个原子级步骤。每一步都对应一个真实的硬件行为、一个固件契约、或一个内核状态变更。3.1 步骤1-3固件交付的“三件套”确认固件在移交控制权前必须为你准备好三样东西内存映射表、视频缓冲区地址、以及一个可靠的32位入口点。你的Stage 2代码第一件事就是去“验收”这三件套。内存映射表E820 MapBIOS通过INT 15h, AXE820h提供。你需要循环调用此中断每次获取一段内存区域的起始地址、长度和类型1可用RAM2保留3ACPI回收4ACPI NVSetc。关键点在于不要相信任何“可用RAM”的连续性。实际物理内存往往是碎片化的比如0x00000000-0x0009FFFF640KB常规内存、0x00100000-0x00FFFFFF15MB、0x01000000-0x7FFFFFFF2GB……中间夹着显存0xA0000-0xBFFFF和PCI配置空间0xE0000000-0xFFFFFFFF。你的内存分配器必须基于这张表构建而不是假设0x100000之后全是空闲。视频缓冲区Video BufferBIOS在INT 10h, AH0Fh调用后会将当前显示模式如0x0380x25文本模式和字符缓冲区起始段地址0xB800返回。在文本模式下每个字符占2字节低字节是ASCII码高字节是属性前景色/背景色。你可以直接向0xB8000写入0x0720灰色空格来清屏。这是你在内核启动早期唯一能“看到”自己代码在运行的窗口。32位入口点32-bit Entry Point这是Stage 2的“生命线”。BIOS只保证将MBR加载到0x7C00但它不保证后续代码的加载地址。因此Stage 2的汇编代码开头必须包含一个jmp short stage2_main跳转将CS:IP从0x0000:0x7C00重定向到0x0000:0x7C02从而为后续的32位代码腾出空间。更重要的是Stage 2自身必须被链接到一个固定的、高于1MB的地址如0x90000并在Makefile中明确指定-Ttext 0x90000。否则当你在32位模式下引用全局变量时链接器生成的相对地址会错乱。3.2 步骤4-7保护模式切换的“四道门禁”从实模式到保护模式不是开关而是穿越四道必须依次通过的门禁。任何一道门没锁好CPU就会把你拒之门外或者让你在门内迷路。门禁一关闭中断CLI。这是第一步也是最容易被忽略的。实模式下外部中断如键盘敲击随时可能打断你的模式切换流程。一旦在lgdt和mov cr0之间被中断而你的IDT尚未初始化后果就是双重错误。所以cli必须是切换流程的第一条指令。门禁二加载GDTLGDT。GDT是一个内存中的数据结构其格式在Intel手册中有明确定义前2字节是GDT界限长度-1后4字节是GDT基地址。你必须用mov word [gdt_descriptor], gdt_end - gdt_start - 1和mov dword [gdt_descriptor 2], gdt_start这两条指令精确计算并填充这个描述符再用lgdt [gdt_descriptor]加载。注意gdt_start必须是GDT在内存中的物理地址不是汇编源码中的标号地址。如果你用NASM汇编且链接地址是0x90000那么gdt_start的物理地址就是0x90000 offset_of_gdt。门禁三设置CR0.PE位MOV CR0。mov eax, cr0; or eax, 1; mov cr0, eax。这条指令执行后CPU正式进入保护模式。但此时你仍处于16位兼容模式下所有段寄存器CS, DS, ES...还保持着实模式下的值。你不能直接用mov ax, 0x10去加载数据段因为0x10这个选择子Selector在GDT中对应的描述符其Base Address字段是0而Limit字段是0xFFFF这依然是16位的。你必须紧接着执行一个远跳转Far Jump强制刷新CS寄存器。门禁四远跳转刷新CSJMP FAR。jmp 0x08:clear_drivers。这里的0x08是GDT中代码段描述符的索引左移3位它告诉CPU“请从GDT的第1个描述符索引0x08/81中读取新的CS.Base和CS.Limit并用它来解释后续的clear_drivers地址”。这个跳转完成后CPU才真正开始用32位方式解析指令mov eax, ebx这样的指令才能被正确执行。3.3 步骤8-12内核空间的“五步筑基”CPU进入32位模式后你的内核镜像.text,.data,.bss已经躺在内存里但它还是一块“裸铁”。你需要亲手为它打下五根桩才能让它立得住、跑得稳。桩一建立内核栈Setup Kernel Stack。在链接脚本linker.ld中为.stack段分配一页4096字节内存并确保其地址是4KB对齐的。在C语言入口kmain()函数开头用内联汇编asm volatile(mov %0, %%rsp :: r(stack_top) : rsp)将栈顶指针stack_top即0x90000 4096写入rsp寄存器。这是所有C函数调用的基石没有它printf的第一个参数都压不进栈。桩二启用分页Enable Paging。这是内核获得“虚拟内存”能力的钥匙。你需要分配一页作为页目录表Page Directory, PD地址记为pd_addr将PD中第0项索引0设置为指向一个全新的页表Page Table, PT地址记为pt_addr将PT中前1024项覆盖0x00000000~0x3FFFFFFF全部设置为“物理地址虚拟地址”即pt[i] (i 12) | 0x030x03表示用户/可写/存在执行mov cr3, pd_addr将页目录物理地址载入CR3执行mov cr0, cr0 | 0x80000000设置CR0.PG位。实测心得分页启用后你不能再用0x90000这样的物理地址去访问内存而必须用虚拟地址。因此你的内核代码本身必须被链接到一个虚拟地址如0xC0100000并在页表中建立从0xC0100000到0x00100000的映射。否则kmain()函数的下一条指令就会因地址转换失败而触发page fault。桩三初始化中断描述符表IDT。IDT是CPU响应中断的“电话簿”。它和GDT类似也是一个内存表但每个描述符是8字节包含中断处理函数的段选择子0x08和32位偏移地址。你必须为每个中断向量0~255都填一个描述符并用lidt [idt_descriptor]加载。最关键的是在启用IDT之前必须先用sti打开中断。否则即使IDT加载成功CPU也不会去查它。桩四重映射PICRemap PIC。“原始PIC”将IRQ0-7映射到中断向量8-15这与CPU内部的异常向量0-31冲突。因此你必须通过向PIC的命令端口0x20/0xA0发送特定命令字将其重新映射到0x20-0x2F主PIC和0x28-0x2F从PIC。这是一个经典的“先禁用、再重映射、最后启用”三步流程网上有大量错误示例它们忘了在重映射过程中必须用cli禁用中断否则IRQ2级联信号会引发混乱。桩五初始化定时器PIT。可编程间隔定时器PIT是内核心跳的来源。向端口0x43写入0x36通道0二进制计数方波发生器模式再向0x40写入一个16位初值如0x1000对应约18.2Hz即可让PIT每54.9ms触发一次IRQ0。你的时钟中断处理函数必须在其中调用scheduler_tick()更新当前进程的时间片。3.4 步骤13-17从内核到用户的“五步跃迁”当内核完成了自我构建它就站在了悬崖边——一边是掌控一切的Ring 0一边是受尽限制的Ring 3。这五步跃迁就是搭建一座安全、可控的桥梁。跃迁一创建init进程Fork Init。调用fork()系统调用创建第一个用户进程。fork()的实现核心是复制当前内核栈copy_process()、为新进程分配新的页表mm_init()、设置新进程的task_struct状态为TASK_RUNNING。关键点在于子进程的rip寄存器必须被设置为0x400000即/sbin/init的ELF入口地址而不是父进程的rip。跃迁二加载ELF文件Load ELF。execve()系统调用会解析/sbin/init的ELF头读取其Program Header Table将各个段.text,.data从磁盘加载到用户空间的虚拟地址如.text到0x400000.data到0x600000并设置mm_struct中的mmap区域。这里有个陷阱ELF文件的p_vaddr虚拟地址和p_paddr物理地址可能不同你必须用vm_area_struct建立正确的虚拟-物理映射。跃迁三切换页表Switch Page Table。执行switch_mm(old_mm, new_mm)将cr3寄存器更新为新进程的页目录物理地址。从此CPU的地址转换将基于新进程的页表进行实现了严格的内存隔离。跃迁四切换栈Switch Stack。将rsp寄存器从内核栈切换到新进程的用户栈task_struct-thread.rsp。这一步完成后CPU的栈指针就指向了用户空间的内存任何栈溢出都将被限制在该进程的地址空间内。跃迁五返回用户态IRETQ。这是最后、也是最神圣的一跳。构造一个用户态的栈帧其布局为[user_rip][user_cs][user_rflags][user_rsp][user_ss]。然后执行iretq指令。CPU会自动弹出这5个值将cs设置为0x3364位用户代码段ss设置为0x2B64位用户数据段rsp设置为user_rsp并跳转到user_rip。此时CPU特权级降为Ring 3/sbin/init程序正式开始执行你的操作系统终于迎来了第一个真正的用户。4. 实操过程与核心环节实现一个可运行的Minimal Bootloader详解纸上得来终觉浅。下面我将用一个真实、精简、可直接在QEMU中运行的Minimal Bootloader为例带你走完从MBR到内核kmain()的完整流程。这个Bootloader只有不到300行汇编boot.s和150行Ckernel.c但它包含了上述17个步骤中所有最核心的实现。你可以把它当作一块“启动过程的活体切片”逐行分析、单步调试。4.1 汇编引导代码boot.s17步中的前12步在此实现; boot.s - Minimal x86-64 bootloader [BITS 16] [ORG 0x7C00] start: cli ; 步骤4关闭中断 xor ax, ax mov ds, ax mov es, ax mov ss, ax mov sp, 0x7C00 ; 设置栈顶在0x7C00向下增长 ; 步骤1-3调用BIOS获取E820内存映射 mov ebx, 0 ; 第一次调用ebx0 .e820_loop: mov eax, 0xE820 mov ecx, 24 ; 每次返回24字节结构 mov edx, 0x534D4150 ; SMAP signature push ebx ; 保存ebx因为int会修改它 int 0x15 pop ebx jc .e820_done ; 如果CF置位说明无更多条目 add ebx, 8 ; ebx指向下一个描述符 jmp .e820_loop .e820_done: ; 步骤5-7加载GDT并切换到保护模式 lgdt [gdt_descriptor] ; 加载GDT mov eax, cr0 or eax, 1 mov cr0, eax ; 设置PE位 jmp 0x08:protected_mode ; 远跳转刷新CS [BITS 32] protected_mode: mov ax, 0x10 ; 数据段选择子 mov ds, ax mov es, ax mov fs, ax mov gs, ax mov ss, ax mov esp, 0x90000 ; 切换到Stage 2的栈 ; 步骤8建立内核栈此处为简化实际应分配独立页 ; 步骤9启用分页此处省略详细页表构建见下文 ; 步骤10初始化IDT此处省略但必须在sti前完成 ; 步骤11重映射PIC此处省略但必须做 ; 步骤12初始化PIT此处省略但必须做 ; 跳转到C语言入口 call kmain hlt ; GDT定义 gdt_start: dq 0x0000000000000000 ; 空描述符 gdt_code: dq 0x00CF9A000000FFFF ; 代码段基址0限长4GBDPL0可执行 gdt_data: dq 0x00CF92000000FFFF ; 数据段基址0限长4GBDPL0可读写 gdt_end: gdt_descriptor: dw gdt_end - gdt_start - 1 ; GDT界限 dd gdt_start ; GDT基地址 times 510-($-$$) db 0 dw 0xAA55这个boot.s文件就是你整个启动过程的“心脏起搏器”。它用最朴素的汇编完成了从硬件复位到C语言世界的大门开启。关键点在于ORG 0x7C00声明了代码的链接地址确保所有标号如gdt_descriptor的计算都基于此。lgdt [gdt_descriptor]指令前gdt_descriptor中的基地址必须是gdt_start的物理地址。由于boot.s被BIOS加载到0x7C00而gdt_start在代码中紧随其后所以它的物理地址就是0x7C00 offset。NASM会自动计算这个offset。jmp 0x08:protected_mode中的0x08是gdt_code描述符在GDT中的索引(1 * 8) 0x08它告诉CPU“请用GDT中第1个描述符来解释protected_mode的地址”。4.2 C语言内核入口kernel.c17步中的后5步在此展开// kernel.c - Minimal kernel entry #include types.h #include mm.h #include sched.h // 声明外部汇编函数 extern void outb(u16 port, u8 val); extern u8 inb(u16 port); // 全局页目录指针在汇编中已设置好 extern u32 *pgdir; void kmain() { // 步骤8建立内核栈已在汇编中设置rsp此处为示意 // 步骤9启用分页实际代码会调用paging_init()构建页表 paging_init(); // 步骤10初始化IDT idt_init(); // 步骤11重映射PIC pic_remap(0x20, 0x28); // 步骤12初始化PIT pit_init(); // 步骤13-17创建init进程并执行 if (fork() 0) { // 子进程执行init execve(/sbin/init, NULL, NULL); } // 父进程进入调度循环 while(1) { schedule(); } } // 内存管理初始化 void paging_init() { // 分配页目录页 pgdir (u32*)kmalloc(PAGE_SIZE); memset(pgdir, 0, PAGE_SIZE); // 分配页表页 u32 *pt (u32*)kmalloc(PAGE_SIZE); memset(pt, 0, PAGE_SIZE); // 建立0x00000000 - 0x00000000的恒等映射前1MB for(int i 0; i 256; i) { pt[i] (i 12) | 0x03; // Present, User, Writable } pgdir[0] ((u32)pt) | 0x03; // 将页表物理地址写入页目录第0项 // 加载CR3 asm volatile(mov %0, %%cr3 :: r(pgdir) : cr3); // 启用分页 u32 cr0; asm volatile(mov %%cr0, %0 : r(cr0)); cr0 | 0x80000000; asm volatile(mov %0, %%cr0 :: r(cr0)); }这个kernel.c展示了如何用C语言优雅地组织复杂的底层操作。paging_init()函数是其中的精华它首先用kmalloc()一个简单的buddy allocator分配一页内存作为页目录pgdir和一页作为页表pt。然后它用一个for循环将页表的前256项覆盖0x00000000~0x003FFFFF即前4MB全部设置为“恒等映射”虚拟地址i*4096映射到物理地址i*4096并设置Present、User、Writable标志位0x03。最后它将页表的物理地址pt写入页目录的第0项pgdir[0]并用mov cr3, pgdir和mov cr0, cr0 | PG两步正式启用分页。实操心得在QEMU中调试这个过程最有效的方法是结合-d in_asm,cpu_reset参数启动并用GDB连接target remote :1234。在kmain入口处下断点然后用x/10i $eip查看指令用x/20xw $esp查看栈内容用info registers查看所有寄存器状态。你会发现cr0的PG位在paging_init()执行前后发生了从0x60000011到0xE0000011的变化这就是分页被启用的铁证。4.3 构建与测试Makefile与QEMU命令一个可运行的项目离不开可靠的构建流程。以下是一个极简但完备的Makefile# Makefile for Minimal OS CC gcc AS nasm LD ld CFLAGS -m32 -ffreestanding -nostdlib -nostartfiles -nodefaultlibs -Wall -Wextra LDFLAGS -melf_i386 -T linker.ld all: os.iso boot.o: boot.s $(AS) -f elf32 -o $ $ kernel.o: kernel.c $(CC) $(CFLAGS) -c -o $ $ os.bin: boot.o kernel.o $(LD) $(LDFLAGS) -o $ $^ os.iso: os.bin mkdir -p iso/boot/grub cp os.bin iso/boot/ echo set default0 iso/boot/grub/grub.cfg echo set timeout0 iso/boot/grub/grub.cfg echo menuentry My OS { iso/boot/grub/grub.cfg echo multiboot /boot/os.bin iso/boot/grub/grub.cfg echo } iso/boot/grub/grub.cfg grub-mkrescue -o $ iso run: os.iso qemu-system-i386 -cdrom $ -serial stdio -s -S clean: rm -f *.o *.bin *.iso rm -rf iso .PHONY: all run clean运行make runQEMU会启动并在GDB等待连接。此时在另一个终端执行gdb (gdb) target remote :1234 (gdb) b *0x90000 (gdb) c你就可以在kmain()入口处开始你的单步调试之旅了。每一次si
阅读完成 · 觉得有帮助?
咨询建站