1. 上电后先过三关boot.s、setup.s、head.s 各干了什么先把内核初始化的起点摆清楚。linux0.11 的初始化不是从main()开始的而是从机器加电那一刻就开始了。上一章已经聊过 boot 过程这里直接进入正题在 C 代码接管之前还有三段汇编代码要执行。很多人卡在这一步不是因为汇编有多难而是不知道每一段汇编到底在为后续哪个 C 模块铺路。1.1 BIOS 到 boot.s为什么非要从 0x7c00 挪到 0x90000机器上电后BIOS 会把硬盘第一个扇区里的 512 字节加载到物理地址 0x7c00然后跳过去执行。这个地址是历史习惯Intel 早期设计师留的一个约定我们不用管为什么只需要知道 boot.s 的第一件事就是“赶紧搬家”。boot.s 把自己从 0x7c00 整体复制到 0x90000然后把 setup 模块读进 0x90200再把 system 模块读进 0x10000。我第一次看这段代码时很奇怪既然 BIOS 已经把控制权交给我了为什么还要费劲搬家后来写自己的引导程序才明白0x7c00 附近只有 31KB 不到的空间而且和 BIOS 数据区、中断向量表离得太近后续 setup 要读取磁盘、要拼接更大的模块在 0x7c00 原地展开很容易撞车。搬到 0x90000 之后整个 0x90000 到 0x9ffff 这一段都归我折腾腾挪空间大了才敢继续加载后面的内容。boot.s 的另一个职责是“读盘”。它通过 BIOS 的int 0x13中断把 setup 从第 2 个扇区开始读入再把 system 模块读入。这里要注意扇区号、磁头号、柱面号的计算方式我在独立实现引导时踩过一次坑BIOS 中断int 0x13的入参是 CHS 编址不是 LBA 编址直接把 LBA 地址塞进去读出来的数据全是错的。0.11 的 boot.s 写得很老实用了显式的dh、dl、cx参数来指定柱面、磁头和扇区你照着参数表逐字节算一遍基本就懂 CHS 转 LBA 的逻辑了。1.2 setup.s把控制权从实模式交接给 32 位世界setup.s 是承上启下的关键模块。它做四件大事读取 BIOS 参数、搬运 system 模块、建立保护模式环境、跳转 head.s。读取 BIOS 参数这一步很有意思。setup.s 通过int 0x15读取扩展内存大小也就是 1MB 以上的内存有多少 KB通过int 0x10读取显卡模式再通过int 0x41读取第一块硬盘的参数表。这些数据不是拿来就被用掉的而是被存到 0x90000 附近的内存里留给后面 main() 里的drive_info和EXT_MEM_K使用。所以你能看到 main.c 里写drive_info DRIVE_INFO; memory_end (120) (EXT_MEM_K10);DRIVE_INFO和EXT_MEM_K并不是 C 语言变量而是编译阶段直接定位到 0x90000 附近物理地址的宏。这个设计在今天看来很原始但它特别直观地展示了“汇编设置信息、C 语言消费信息”的配合模式这也是学习 0.11 最舒服的地方边界清晰不绕弯。setup.s 的另一个任务是把 system 模块从 0x10000 搬到 0x00000。0 地址是实模式的中断向量表所在位置一旦进入保护模式这些实模式中断就不再有效所以系统敢于直接覆盖这段内存。搬完之后setup.s 初始化临时的 GDT 和 IDT打开 A20 地址线把 CR0 的保护模式位 PE 置 1然后跳转到 head.s。打开 A20 这件事值得单独说。早期的 80286 芯片地址线只有 20 根如果程序访问超过 1MB 的地址地址线会回卷到 0。为了保证和旧软件兼容PC 在默认状态下地址线第 20 根A20是被屏蔽的。保护模式下我们需要使用 1MB 以上内存必须通过键盘控制器端口 0x60/0x64 或者 BIOS 中断把 A20 打开。0.11 的 setup.s 用的是向键盘控制器发送命令的方式你读代码时如果看到call empty_8042这种函数就是在等待键盘控制器缓冲清空然后写 A20 控制位。1.3 head.s建好页表、中断门才敢把舞台交给 C 语言head.s 是进入 main() 之前最后一段汇编它做的事情特别像一个“舞台布置员”先搭好页目录和页表开启分页再初始化 256 个中断描述符最后建立 GDT把内核态和用户态的代码段、数据段、任务状态段 TSS、局部描述符表 LDT 都安排妥当。很多人会问setup.s 不是已经建过 GDT 和 IDT 了吗为什么 head.s 要再建一遍答案是 setup.s 里的 GDT/IDT 只是临时用一下设计得很简陋不够支撑后续完整的内核运行。head.s 里的setup_idt循环把 256 个 IDT 门全部指向一个ignore_int函数这个函数只有一条iret。换句话说此时中断处理函数还没有真正就位但 CPU 不会再因为 IDT 表项无效而触发三重故障。页表这一块是理解内存布局的关键。head.s 把页目录放在物理地址 0 处也就是覆盖了原来的实模式中断向量表区域。它建立两个页表一个把物理地址 0 到 4MB 映射到线性地址 0 到 4MB另一个把同一段物理内存映射到线性地址 3GB 处也就是 0xc0000000 开始的内核地址空间。我第一次看这段时很不理解为什么同一个物理页要映射到两个不同线性地址后来才意识到这是为了给内核同时提供“低地址直接访问”和“高地址内核空间访问”两种途径。因为 0.11 没有用户态和内核态完全分离的高地址映射设计很多驱动代码直接使用物理地址操作这种双重映射让代码写起来很省心。head.s 完成后跳转到main()这时候 CR3 寄存器已经指向页目录CR0 的分页位 PG 也已经置 1整个系统处在“保护模式 分页开启”的状态。从这一刻起内核初始化正式进入 C 语言的战场。2. main() 开头的内存公式才是系统“地皮划分”的起点进入main()第一眼看到的不是各种高深的数据结构而是几行看似简单的内存计算memory_end (120) (EXT_MEM_K10); memory_end 0xfffff000; if (memory_end 16*1024*1024) memory_end 16*1024*1024; if (memory_end 12*1024*1024) buffer_memory_end 4*1024*1024; else if (memory_end 6*1024*1024) buffer_memory_end 2*1024*1024; else buffer_memory_end 1*1024*1024; main_memory_start buffer_memory_end;这段代码决定了整个系统后续可用的物理内存布局我建议读它的时候不要只看计算过程而是看成“地皮划分”的三部曲。2.1 1MB 基准、16MB 上限和 4KB 对齐的三重限制memory_end的初始值由两部分组成120是 1MBEXT_MEM_K10是从 BIOS 读到的扩展内存大小转换成字节数。也就是说Linux 0.11 把最低 1MB 内存视为固定保留区这部分包含了实模式中断向量表、BIOS 数据区、显示缓冲区以及被搬移后的启动代码。用户程序能使用的内存只能从 1MB 之后开始算。memory_end 0xfffff000的意思是向下对齐到 4KB 边界。因为页式内存管理的最小单位是页一页 4KB内存结束地址必须是一页的边界否则最后一页会算错。这个对齐看起来不起眼但在mem_init里算页数的时候能避免很多边界 bug。我自己写内存管理器时也习惯先对齐再除页大小对齐这一步能省掉后面所有“剩了半页怎么办”的问题。16MB 上限是 0.11 时代的设计抉择。当时大多数 PC 的内存就是 4MB 到 16MB而且 16MB 以上内存的检测和映射方式五花八门Linux 0.11 干脆一刀切超过 16MB 一律按 16MB 处理。这个限制也把后续所有数据结构的大小限制在了一个可控范围内比如内存位图只需要一个很小的数组就能描述完所有物理页。2.2 缓冲区、主内存区和用户进程的分水岭buffer_memory_end的三档选择是我觉得很值得玩味的逻辑物理内存大小缓冲区结束地址缓冲区大小小于 6MB1MB0 ~ 1MB 内建缓冲区6MB ~ 12MB2MB1MB ~ 2MB大于 12MB4MB1MB ~ 4MB为什么缓冲区要占这么多因为 0.11 没有现代系统中的磁盘页缓存和文件系统缓存拆分文件读写全都走通用缓冲区。缓冲区太小磁盘 I/O 性能会很难看缓冲区太大用户进程可用的连续内存又被挤压。那个时代的内存实在有限所以设计者用了一个很务实的做法根据总内存多少分档调整让低端机器也能跑起来。main_memory_start buffer_memory_end这行把“内核和缓冲区占用区域”与“用户可用内存区域”分开。从这个地址开始往上直到memory_end才交给mem_init()管理。Linux 0.11 的用户进程内存就是从这一段的物理页面中分配的也就是说用户进程在这套设计里用的其实是物理内存在 1MB 到 16MB 之间相对靠后的部分。2.3 mem_init 到底在初始化什么mem_init(main_memory_start, memory_end)位于mm/memory.c做的事情非常朴素把main_memory_start到memory_end之间的物理内存按页划入一个位图标记为可分配。每个物理页对应位图中的一个比特位分配时从头扫描找一个空闲位释放时把对应位清零。这个位图管理器虽然简单但它是后续所有用户页分配的基础。很多现代系统的内存管理算法都在这套简单逻辑上演进而来。我建议你先别急着跳去学 slab 分配器把位图分配和释放的代码读懂你就理解了“物理页框”是怎么被内核记账的后面看什么分配器都不慌。mem_init执行完之后系统里实际可用的物理页已经分好了但还没有真正为任何进程分配一页。接下来的trap_init、sched_init这一串函数才开始把这些“地皮”和具体的运行机制对接起来。3. trap_init、sched_init 和设备表初始化顺序里的依赖关系初始化顺序是我读内核代码时最关注的东西。顺序意味着依赖一个函数为什么排在另一个函数前面背后一定有一条“数据结构被谁使用”的逻辑链。0.11 的初始化顺序从trap_init开始接着是blk_dev_init、chr_dev_init、tty_init、time_init、sched_init、buffer_init、hd_init、floppy_init。这个顺序不是拍脑袋拍的下面逐个拆。3.1 trap_init给 CPU 的异常处理铺好第一张网trap_init()在kernel/traps.c里它把 IDT 中从 0 号到 31 号的异常门都填上了对应的处理函数。包括除零错误、单步调试、断点、溢出、边界检查、非法指令、协处理器不可用、双重故障、非法 TSS、段不存在、栈段错误、通用保护错误、页错误、协处理器错误等等。为什么它必须最先执行因为sched_init里面会设置定时器中断tty_init里面会设置串口和键盘中断这些硬件中断一旦注册就可能立刻触发。如果异常门还没准备好任何一个意外中断都可能导致系统跳转到未初始化的 IDT 表项行为完全不可预测。这里有一个细节很多人会忽略trap_init 里对中断门和系统门做了区分。像int3断点和overflow溢出这类可以被用户程序触发的异常使用set_system_gate设置成 DPL3允许用户态调用而像页错误、通用保护错误这类只能由内核处理或用户态触发后陷入内核的异常使用set_trap_gateDPL0。这个 DPL 区分在后面用户进程跑起来之后非常重要如果设错了用户程序随便一调int 3就可能触发保护错误。3.2 sched_init0 号进程和 GDT 里的 TSS/LDTsched_init()在kernel/sched.c它做的工作可以概括为两件事初始化进程表和定时器中断。进程表初始化最重要的是把task数组清零这里有个容易误解的点0.11 在刚开始时并没有“创建”0 号进程而是直接使用当前正在执行的内核态代码作为 0 号进程。为什么可以这样因为进程在 Linux 0.11 里本质上是“内核栈 一个 task_struct 一套 TSS/LDT”当前正在执行的这段 main() 代码天然占据了 CPU它就是一个隐形的进程。sched_init要做的是把这个隐形的运行上下文显式登记到系统里把它的 TSS 段和 LDT 段挂到 GDT 的固定位置。这个过程在kernel/sched.c里就是给 tss 和 ldt 数组赋初值然后把它们对应的段描述符填进 GDT。定时器中断是sched_init里另一个重头戏。它把 0x20 号中断门设置为timer_interrupt也就是把 IRQ0 和时钟节拍绑在一起。从这之后每 10ms 时钟滴答一次就会触发一次中断进入timer_interrupt处理。这个中断是整个进程调度的时间基准。为什么sched_init排在tty_init之前因为终端驱动里的很多逻辑要用到时钟节拍来判断超时和输入延迟时钟源必须先就位。3.3 块设备、字符设备、TTY、硬盘和软盘表驱动架构的早期雏形blk_dev_init()做的事情是把块设备表blk_dev[]清零chr_dev_init()把字符设备表chr_dev[]清零。它们看起来都像“清内存”但清完之后的表结构就会作为整个设备系统的核心数据后面读写硬盘、读写终端都要从这里找入口。tty_init()是设备初始化里最复杂的一个。它调用rs_init()初始化两个串口调用con_init()初始化显示器控制台。con_init里有一堆向 VGA 寄存器写值的操作还包含字符发生器的配置。这里我不建议一开始就泡进每一个寄存器细节而是抓住“中断号 缓冲区 读写函数”这个框架。串口和键盘都通过中断接收数据驱动里提前申请了缓冲区硬件中断把数据填进去用户态程序从缓冲区读出来。这个思路贯穿所有字符设备。buffer_init()负责初始化通用缓冲区。它会根据前面算出的buffer_memory_end建立空闲缓冲区链表还会初始化一个 hash 表用于快速查找指定块号的缓冲块。为什么buffer_init要在hd_init之前因为硬盘驱动一旦注册完成随后的读写请求就会用到缓冲区如果缓冲区还没建立硬盘请求只能直接访问用户内存效率和安全性都会有很大问题。0.11 把所有磁盘块读写都包在缓冲区这层里这样文件系统读一个块时如果块已经在缓冲区里就直接命中返回不用真正访问磁盘。hd_init()和floppy_init()是最后两个设备初始化函数它们把硬盘和软盘的中断处理函数注册到对应的 IRQ 中断号上并把请求队列函数挂到设备表上。完成这一步之后系统中的主要设备才算“可对外服务”。这里我想分享一个自己读代码时的经验不要试图一次记住每个 init 函数里的所有寄存器操作而是先画一个表格把函数名、所在文件、初始化了哪个全局表、注册了哪个中断、后续被哪个模块使用五个要素填清楚。等你把这张表填完整个初始化顺序的逻辑就全部浮现出来了之后再遇到任何具体设备寄存器你都知道它是为了支撑哪个读写路径。4. sti() 与 move_to_user_mode开中断和降特权级的临界点初始化进行到这里系统中各个子系统已经各就各位但 CPU 的中断还是关着的。main() 里接下来三行代码是整个初始化的临门一脚sti(); move_to_user_mode(); if (!fork()) { init(); }sti()是打开中断move_to_user_mode()是从内核态降到用户态fork()创建一个子进程子进程去执行init()父进程进入死循环。这一小段代码的每一行都值得细品。4.1 为什么要拖到最后才开中断中断开启时间的选择是一个典型的“状态一致性问题”。如果sti()放在初始化函数中间比如trap_init刚执行完就打开中断那之后任何一个设备中断都可能触发而设备表、缓冲区、进程表可能还没准备好中断处理函数一跑就访问空指针或未初始化的数据结构系统瞬间崩溃。0.11 的做法是把所有模块的准备工作放到开中断之前等一切就绪后只执行一条sti()。这个思路放到今天依然值得学习初始化阶段保持单线程、关中断把系统带到一个稳定状态后再对外开放这和现代操作系统启动后期才打开中断的做法一脉相承。4.2 move_to_user_mode 的“假中断返回”戏法特权级切换在 x86 上通常只能通过中断门、调用门或iret完成。Linux 0.11 没有办法直接从 C 语言调用一个系统调用去切换特权级所以move_to_user_mode用了一个巧妙的办法伪造一个中断返回现场。具体来说这个宏把用户态数据段选择子、当前栈指针、程序状态字 EFLAGS、用户态代码段选择子、一个返回地址依次压入当前内核栈然后执行iret。CPU 在执行iret时会把栈里的这些值弹出来发现目标特权级是 3就会自动切换到用户态并把 CS、SS、ESP、EFLAGS 全部设置为预设的值。这是整个初始化中我觉得最巧妙的部分。iret 本来是为了从中断处理程序返回而设计的但内核把一个本来不是中断产生现场的结构精心摆好让 CPU “误以为”这是一次中断返回从而完成从内核态到用户态的降落。理解了这段再去读进程切换中 TSS 换栈的代码会顺畅很多。move_to_user_mode之后当前这段代码实际上是 0 号进程在用户态继续跑。也就是说0.11 中的 0 号进程一开始就成了一个用户态进程而不是像某些教材里描述的“纯内核线程”。这是 0.11 早期版本的一个鲜明特点后面很多教学内核改成了先建内核线程再创建用户进程的设计但 0.11 的做法更适合讲解“特权级如何反转”的机制。4.3 fork() 返回值的一场“分身术”fork()在内核里被调用一次却会返回两次父进程收到子进程的 PID子进程收到 0。靠这个返回值同一段代码被两个执行流同时使用main() 里通过if (!fork())把父进程和子进程分派到不同的分支。子进程进入init()继续做系统最后的环境准备挂载根文件系统、打开终端设备、创建第一个 shell。父进程则进入for(;;) pause()的死循环把 CPU 让给调度器管理。看起来父进程“死了”其实它只是挂起在可中断睡眠状态。这个父进程就是 0 号进程它承担着整个系统的“最后兜底”职责如果调度器发现没有其他进程可运行最终还是会回到 0 号进程。init()里面的逻辑也很值得展开。setup((void *) drive_info)负责读取根文件系统的超级块建立根文件系统的挂载关系open(/dev/tty0, O_RDWR, 0)把终端打开三次分别作为标准输入、标准输出、标准错误随后它启动一个子进程执行/etc/rc初始化脚本再不断派生 shell 来提供登录界面。这套流程和现代 Linux 的 init/systemd 在职责上是相似的只是实现极简代码一眼能看完。4.4 0 号进程退场与第一个稳定循环当init()那个分支已经带着 1 号进程跑起来之后我们回过头看for(;;) pause()。pause()会让当前进程进入可中断睡眠状态CPU 会转去执行其他就绪进程。如果整个系统里没有其他进程可跑调度器就会让 0 号进程反复醒来又睡下这个忙等不占 CPU只是把时间片让给等待队列。到这里一个完整的初始化闭环就出现了从实模式的汇编引导到保护模式的环境建立再到各个子系统的初始化最后降级到用户态创建第一个进程。整个系统不再是一次性代码而是一个可以持续接受新进程、响应中断的动态系统。5. 用 bochs/qemu 跟读初始化代码我的断点习惯与错误排查方向这一章最后聊聊我看初始化代码时用的实际工具和踩过的坑。读这种底层代码最怕的就是“眼睛看懂了但不知道跑起来是什么状态”。所以我一贯的做法是用模拟器把系统跑起来在关键节点打断点亲眼观察寄存器和内存的变化。5.1 推荐断点位置三个观察窗口如果是用 bochs可以预先在源码里加入xchg bx, bx作为魔术断点也可以用 bochs 的调试命令直接设置物理地址断点。我常用的三个观察窗口是setup.s 刚进入保护模式的位置。在这里看 CR0 的 PE 位是否置 1、GDTR 是否指向临时 GDT、CS 段选择子是否变成了 0x10。如果这里就不对后面 head.s 几乎没有正确执行的可能。head.s 开启分页之后。在这里看 CR3 是否指向页目录、页目录第一个 PTE 是否存在、线性地址能否正确映射到物理地址。我试过把页表基址填错结果一开分页就三重故障模拟器直接重启。main() 入口。进入 C 代码前确认栈指针esp已经指向user_stack的顶部这决定了第一个内核栈是否安全。如果栈没设好进入 main() 后第一个函数调用就会莫名其妙 crash。用 qemu 的话可以通过-s -S启动 GDB server用 GDB 给内核符号断点。但 0.11 是老代码编译时可能没有生成完整 ELF 调试信息我更推荐先看汇编断点再跳进 C 函数单步。5.2 初始化失败的三种典型迹象常遇到的现象和排查方向可以整理成一张表现象可能原因排查方向系统在进入保护模式后无限重启A20 未开启、GDTR 设置错误、或页表开启时机不对检查 setup.s 中 A20 控制端口操作查看 CR0 的 PG/PE 位开中断后马上收到异常IDT 某个门没有初始化或中断处理函数访问了未初始化数据检查 trap_init 是否完整注册异常门确认 sched_init 等函数已经执行fork 之后子进程跑不起来终端无输出根文件系统 setup 失败、tty0 设备节点不存在、缓冲区链表损坏检查 setup 的驱动参数、/dev/tty0的创建、buffer_init 后的空闲链表头第三类问题非常隐蔽。我遇到过一次现象是init()里printf能打印但open(/etc/rc)一直失败。后来盯着setup()追查才发现是硬盘设备的drive_info参数没有正确保留BIOS 读到的硬盘参数表和内核期望的格式不对齐。这种问题在纸上推演推不出来必须跟读hd_init()里的中断链和请求处理逻辑。5.3 我给新手的阅读顺序建议如果你是第一次接触 linux0.11 初始化我建议按下述顺序读代码先读include/linux/sched.h把task_struct、tss_struct、GDT相关的宏定义扫一遍知道系统里有哪些全局数据结构。再读init/main.c里整个main()把初始化函数调用序列背下来脑海里先形成一个地图。然后回头读boot/setup.s和boot/head.s此时你已经知道后面要用到drive_info、页表、GDT 这些概念再看汇编就有目标了。最后逐个深入kernel/traps.c、kernel/sched.c、kernel/blk_drv、fs/buffer.c每读完一个 init 函数就回到 main() 地图上打勾确认这个模块的初始化成果被哪个下游函数使用。这个方法我试过很多次对初学内核的读者格外有效。核心思路是“先横向建立地图再纵向深入细节”而不是一头扎进con_init里的 VGA 寄存器结果看了两天还不知道它在整个启动流程中的位置。等这几个文件读透了你可以试着做一个小实验把buffer_memory_end的分配策略改一档比如把“大于 12MB 分 4MB”改成“分 2MB”重新编译后跑一遍观察printf输出的内存统计是否按预期变化。这个实验能让你直观感受到初始化代码里每一行内存计算的价值。内核初始化的魅力就在这种地方不是一堆枯燥的寄存器赋值而是用最小的代码量把整个系统的地基搭好每一个决定都会在之后的运行行为中显形。