
1. 从启动到内核接管kern_init 到底在忙什么很多同学做操作系统实验走到kern_init这一步时会有一种终于进正题了的感觉。前面 bootloader 折腾了半天把内核镜像从磁盘搬进内存设置好保护模式跳转到内核入口然后 C 语言的kern_init函数被调用——从这一刻起控制权正式从汇编交到了 C 代码手里。但紧接着问题就来了这个函数看起来只有几十行为什么实验指导书要花一整个章节来讲它它到底在忙什么我个人的理解是kern_init是整个内核的总装配线。你可以把它想象成一家工厂开工前的准备流程先确认厂房通电了初始化串口让后续调试信息能输出再把仓库的货架清空并登记好初始化物理内存管理器然后建立一套地址翻译规则初始化页表最后把各个车间的负责人叫来开会初始化中断、时钟、进程调度等子系统。顺序不能乱因为后面的模块往往依赖前面模块提供的服务。具体到 ucore 这个实验框架kern_init的典型结构大致是这样的int kern_init(void) { extern char edata[], end[]; memset(edata, 0, end - edata); // 清空 BSS 段 cons_init(); // 初始化控制台/串口 const char *message ucore is booting...; cprintf(%s\n, message); pmm_init(); // 物理内存管理初始化 pic_init(); // 中断控制器初始化 idt_init(); // 中断描述符表初始化 clock_init(); // 时钟初始化 intr_enable(); // 开中断 // 后续实验会在这里加入调度器、进程等初始化 while (1) { if (num 0) { ... } } }看起来简单但每一行背后都有一堆细节。这一篇我就围绕kern_init这个入口把它的执行逻辑、各子模块的初始化顺序、以及实际调试中容易踩的坑掰开揉碎讲清楚。适合正在做 ucore 实验、卡在内核起来了但不知道下一步该干嘛阶段的同学也适合想理解操作系统启动流程的读者。2. 为什么 kern_init 的第一步是清空 BSS 段2.1 BSS 段在内存里的真实状态先解释一个容易被忽略的点为什么kern_init一上来就要memset(edata, 0, end - edata)这行代码的作用是把从edata到end之间的内存全部清零。edata和end是链接脚本里定义的两个符号分别标记了数据段的结束位置和整个内核映像的结束位置。它们之间的区域就是所谓的 BSS 段Block Started by Symbol。BSS 段存放的是那些未初始化或初始化为零的全局变量和静态变量。比如你写int global_counter;或者static int flag 0;这些变量就落在 BSS 段里。关键问题在于BSS 段在可执行文件里不占实际空间它只记录一个大小。因为如果所有未初始化变量都要在文件里存一堆零那文件会大得离谱。链接器很聪明它只告诉加载器这里有一块 N 字节的区域加载时请全部置零。但 ucore 的 bootloader 是自己写的简易加载器它只负责把内核映像从磁盘读进内存并不会自动帮你清零 BSS 段。所以这个活儿必须由内核自己干而且必须在任何 C 代码依赖这些全局变量之前干完。这就是为什么memset是kern_init的第一条语句——它必须抢在所有初始化函数之前执行。2.2 不清零会出什么幺蛾子我见过不少同学在调试时遇到变量值莫名其妙不对的情况排查半天发现就是 BSS 没清干净。举个典型场景你在pmm_init里定义了一个全局的struct Page *pages;指针本意是初始化为 NULL然后根据物理内存大小分配数组。如果 BSS 没清零这个指针可能带着内存里的随机垃圾值后续判断if (pages NULL)就会失效直接跳到错误分支然后内核在访问一个非法地址时 triple fault 重启。更隐蔽的是有些变量在 QEMU 里跑的时候恰好那块内存是零因为 QEMU 启动时内存通常被清零于是代码看起来正常。但换到真实硬件或者不同的模拟器上内存里可能残留着 bootloader 用过的数据问题就暴露了。这种在我机器上能跑的 bug 最难查。提示如果你在实验中发现某个全局变量的初始值不符合预期第一反应应该是检查 BSS 清零是否执行到位而不是怀疑编译器。2.3 edata 和 end 符号是怎么来的这两个符号不是 C 语言里定义的而是在链接脚本通常是kernel.ld里通过PROVIDE或者直接定义的方式给出的。链接脚本大致长这样SECTIONS { . 0x100000; .text : { *(.text) } .rodata : { *(.rodata) } .data : { *(.data) } PROVIDE(edata .); .bss : { *(.bss) *(COMMON) } PROVIDE(end .); }edata标记在.bss段开始之前end标记在整个映像结束处。所以end - edata正好就是 BSS 段的大小。在 C 代码里用extern char edata[], end[];声明它们注意这里声明成数组类型只是为了拿到地址实际并不访问数组内容。这种用符号地址当边界的技巧在操作系统开发里非常常见值得记住。3. cons_init 与 cprintf内核的第一双眼睛3.1 为什么串口初始化要排在内存管理之前kern_init里紧接着 BSS 清零的就是cons_init()也就是控制台初始化。很多同学会疑惑内存管理、中断管理这些大模块都排在后面为什么一个串口驱动要这么靠前答案很实际因为你需要调试手段。内核开发最痛苦的地方在于没有 printf。用户态程序出错了可以打印堆栈、可以 gdb 单步但内核一旦跑飞你连它在哪飞的都不知道。串口输出是内核开发者唯一的眼睛。如果pmm_init里有个 bug 导致内核崩溃而串口还没初始化那你看到的就是 QEMU 窗口一片漆黑或者无限重启完全无从下手。所以正确的顺序是先把输出通道打通再去做那些可能出错的复杂初始化。这样一旦后面哪个模块崩了至少你能看到它崩之前打印的最后一条信息从而定位问题范围。这是内核开发的一条黄金法则——永远先建立可观测性再动复杂逻辑。3.2 cprintf 和用户态 printf 的本质区别ucore 里的cprintf看起来和标准库的printf很像但实现完全不同。标准printf依赖操作系统的系统调用最终通过文件描述符把数据写到终端。而cprintf是直接操作硬件寄存器的——它把字符一个一个写进串口的数据寄存器由串口硬件负责按波特率把数据发出去。以常见的 16550 串口芯片为例发送一个字符的流程是读取线路状态寄存器LSR检查 bit 5THRE发送保持寄存器空是否为 1。如果为空把字符写入发送保持寄存器THR。硬件自动把数据移位发送出去。这个过程是**忙等待polling**的没有中断参与。为什么不用中断因为中断系统还没初始化呢。cons_init阶段只能用轮询方式简单可靠。等后面idt_init和intr_enable执行完理论上可以改成中断驱动但 ucore 为了简单串口输出一直用轮询。void cons_putc(int c) { // 等待发送保持寄存器空 while ((inb(COM1 5) 0x20) 0); outb(COM1, c); }inb和outb是 x86 的端口 I/O 指令通过内联汇编实现。这里有个细节串口的 I/O 端口地址比如 COM1 是 0x3F8是固定的这是 PC 体系结构的历史遗留。你在cons_init里要做的就是配置串口的波特率、数据位、停止位等参数让 QEMU 的虚拟串口和你的输出设置匹配。3.3 实测中串口没输出的排查思路如果你发现cprintf没有任何输出可以按这个顺序排查排查项检查方法常见问题串口参数确认波特率、数据位配置QEMU 默认 115200 8N1端口地址确认 COM1 基址应为 0x3F8初始化顺序cons_init 是否被调用可能被注释掉了输出函数cprintf 实现是否正确格式化逻辑有 bugQEMU 参数是否加了 -serial 选项输出可能被重定向我踩过的一个坑是QEMU 启动参数里同时用了-serial stdio和-nographic导致输出行为不符合预期。后来统一用-nographic它会把串口输出直接接到当前终端同时把显示器输出也重定向过来调试起来最方便。4. pmm_init物理内存管理的初始化链路4.1 从探测内存大小到建立页帧数组pmm_init是kern_init里最重的一个初始化函数它要完成物理内存管理器的搭建。整个流程可以拆成几个关键步骤每一步都有它的道理。第一步是探测物理内存大小。ucore 通常通过读取 BIOS 的 e820 内存映射表来获取信息。这个表在 bootloader 阶段就已经查询好并保存在内存某个固定位置了。e820 表会告诉你哪些物理地址范围是可用的 RAM哪些是保留给硬件设备的。为什么不能简单地假设内存从 0 到某个值都是可用的因为 PC 体系结构里0xA0000 到 0x100000 这段地址被显存和 BIOS 占用了物理内存在这里是断档的。如果不处理这个空洞直接按连续内存管理就会把显存当成普通 RAM 来分配后果可想而知。第二步是建立页帧数组。ucore 用struct Page来描述每一个物理页帧通常 4KB 一页。假设探测到可用内存是 128MB那么页帧数量就是 128MB / 4KB 32768 个。需要为这些页帧分配一个struct Page数组。这个数组本身也要占内存所以它的位置需要精心安排——通常放在内核映像结束之后也就是end符号之后因为那块区域肯定是可用的。// 伪代码示意 npages 可用内存大小 / PGSIZE; pages (struct Page *)ROUNDUP(end, PGSIZE); for (i 0; i npages; i) { pages[i].ref 0; pages[i].flags 0; // 设置空闲链表指针等 }这里有个容易忽略的点pages数组本身占用的内存也要从可用内存里扣除。如果内存很小比如实验环境只给 32MB而页帧数组又很大可能会覆盖到不该覆盖的区域。所以计算npages时要考虑数组自身的大小或者干脆把数组放在内核之后然后从数组结束的位置开始算可用内存。4.2 页目录和页表的建立时机pmm_init里另一个核心任务是建立页表开启分页机制。在 ucore 的早期实验中这一步可能只是建立了一个简单的映射让内核虚拟地址等于物理地址恒等映射保证开启分页后代码还能继续执行。为什么需要分页因为操作系统要给每个进程独立的地址空间还要实现内存保护、虚拟内存等功能。分页是这些高级功能的基础。但开启分页有个鸡生蛋的问题开启分页的指令本身需要取指执行而取指用的是虚拟地址如果页表没建好开启分页的瞬间就会崩溃。所以标准做法是在物理内存里建好页目录和页表建立恒等映射虚拟地址 物理地址。把页目录的物理地址写入 CR3 寄存器。设置 CR0 寄存器的 PG 位开启分页。由于是恒等映射下一条指令的虚拟地址和物理地址相同能正常执行。// 建立恒等映射的示意 boot_pgdir[0] PADDR(boot_pt) | PTE_P | PTE_W; // 加载页目录 lcr3(PADDR(boot_pgdir)); // 开启分页 uint32_t cr0 rcr0(); cr0 | CR0_PG; lcr0(cr0);这段代码在实验里通常已经给好了但理解它的原理很重要。我见过同学在改页表映射时把恒等映射破坏了结果开启分页后内核直接跑飞QEMU 报 Triple fault排查了很久才发现是页表项没设置对。4.3 物理内存分配的第一次实战初始化完成后pmm_init通常会做一次自检比如分配几个页帧再释放验证分配器工作正常。这是很好的习惯——在初始化阶段做自检比等到后面模块用到时才发现问题要容易定位得多。ucore 的物理内存分配器有两种常见实现首次适应first fit和伙伴系统buddy system。早期实验一般用简单的空闲链表每个struct Page里维护一个page_link把空闲页串起来。分配时从链表头取释放时插回链表。这种实现简单但容易产生外部碎片。伙伴系统则通过按 2 的幂次管理内存块来减少碎片但实现复杂一些。如果你在实验里发现分配内存后返回的地址不对可以检查这几个点空闲链表的初始化是否正确每个空闲页都要挂上去、分配时是否更新了ref计数、释放时是否把页正确归还。我调试时习惯在分配和释放函数里加cprintf打印页帧号和操作类型跑一遍就能看出链表操作是否符合预期。5. 中断系统的初始化pic_init 与 idt_init 的配合5.1 8259A 中断控制器的初始化逻辑pic_init负责初始化 8259A 可编程中断控制器。这块芯片在 PC 体系结构里负责管理硬件中断把设备发来的中断信号转成 CPU 能识别的中断向量号。虽然现代硬件更多用 APIC但 ucore 实验为了简单还是用 8259A。8259A 的初始化需要发送一系列 ICWInitialization Command Word命令字。这个过程有点像给一个新设备做配置先告诉它我要开始配置了ICW1然后告诉它中断向量从哪个号开始ICW2再告诉它主从片怎么连接ICW3最后告诉它工作模式ICW4。// 初始化主片 outb(PIC_MASTER_CMD, 0x11); // ICW1: 边沿触发, 需要ICW4 outb(PIC_MASTER_IMR, 0x20); // ICW2: 中断向量从0x20开始 outb(PIC_MASTER_IMR, 0x04); // ICW3: 从片接在IR2上 outb(PIC_MASTER_IMR, 0x01); // ICW4: 8086模式为什么中断向量从 0x20 开始因为 0x00 到 0x1F 被 CPU 保留给异常比如除零、缺页、通用保护错误了。硬件中断必须避开这些向量号否则会和异常混淆。这是一个很容易记错的地方如果设成从 0x00 开始时钟中断一来就会触发除零异常让人一头雾水。5.2 IDT 表的填充与中断门的选择idt_init负责建立中断描述符表IDT。IDT 是一个数组每个表项 8 字节描述一个中断处理程序的入口地址和属性。CPU 收到中断后用中断向量号作为索引去 IDT 里查表找到处理程序并跳转。ucore 里通常用SETGATE宏来填充 IDT 表项#define SETGATE(gate, istrap, sel, off, dpl) { ... }这里有几个参数需要理解istrap区分中断门和陷阱门。中断门会自动关中断清除 IF 标志陷阱门不会。对于硬件中断一般用中断门防止中断嵌套导致栈溢出。sel代码段选择子通常是内核代码段KERNEL_CS。off处理程序的偏移地址。dpl描述符特权级。对于系统调用如int 0x80需要设置 DPL3允许用户态触发对于硬件中断DPL0 即可。我见过同学把所有中断门的 DPL 都设成 3结果用户态程序可以随意触发时钟中断处理程序造成混乱。记住只有系统调用这类需要用户态主动触发的门才设 DPL3硬件中断的门保持 DPL0。5.3 开中断的时机为什么不能太早intr_enable()执行sti指令打开 CPU 的中断响应。这行代码的位置很讲究——它必须在所有中断相关的初始化完成之后才能执行。如果 IDT 还没填好就开中断一旦时钟中断到来CPU 去查 IDT 发现是空的或者内容不对就会触发异常而异常处理程序也没准备好最终导致 triple fault。所以kern_init里的顺序是pic_init→idt_init→clock_init→intr_enable。先把中断控制器配好再把 IDT 建好然后初始化时钟让它开始产生中断最后才开中断。这个顺序不能颠倒。注意有些同学为了调试方便会在idt_init之前就调用intr_enable想看看中断能不能工作。这样做几乎必然导致内核崩溃因为 IDT 还没建立。调试中断相关代码时建议用cprintf打印而不是靠中断来验证。6. 调试 kern_init 时那些让人抓狂的坑6.1 内核在 memset 处就跑飞了这是最诡异的一类问题kern_init的第一行memset就崩了。按理说memset是最简单的函数怎么会出问题我遇到过一次原因是链接脚本里edata和end的定义位置不对。当时我把PROVIDE(edata .)放在了.data段之前导致edata指向了错误的位置end - edata算出来是个巨大的值memset直接把整个内存都写了一遍覆盖了 bootloader 留下的关键数据内核自然跑飞。排查这类问题的方法是在memset之前用cprintf打印edata和end的地址值看看差值是否合理。正常情况下BSS 段大小应该是几 KB 到几十 KB 量级。如果打印出来是几百 MB那肯定是链接脚本有问题。另一个可能的原因是memset本身的实现有 bug。ucore 里的memset通常是自己实现的简单版本如果循环条件写错比如用了而不是就会多写一个字节可能破坏相邻数据。这种 bug 很隐蔽因为多写一个字节往往不会立即崩溃而是过一段时间才表现出症状。6.2 页表开启后 cprintf 输出乱码开启分页后如果页表映射有问题cprintf可能输出乱码或者干脆没输出。这是因为cprintf访问的字符串常量在.rodata段如果这个段的虚拟地址映射错了读出来的就是垃圾数据。排查思路先确认恒等映射覆盖了内核映像的所有段.text、.rodata、.data、.bss。如果只映射了.text那访问.rodata里的字符串就会出错。可以在开启分页前打印几个关键地址开启后再打印同样的地址对比内容是否一致。还有一种情况是页表项的对齐问题。页目录和页表的物理地址必须是 4KB 对齐的如果定义时没加__attribute__((aligned(4096)))链接器可能把它们放在非对齐地址上写入 CR3 后 CPU 会忽略低 12 位导致查表查到错误的位置。6.3 时钟中断频率设置不当导致系统卡顿clock_init里要设置时钟中断的频率。ucore 通常用 8253/8254 定时器通过设置计数器初值来决定中断间隔。如果频率设得太高比如 1000Hz每次中断处理都要花时间系统大部分时间都在处理中断用户程序几乎跑不动。如果设得太低比如 10Hz调度器响应就很不及时交互体验差。一般实验里设 100Hz 比较合适也就是每 10ms 一次中断。计算计数器初值的公式是初值 输入频率 / 目标频率假设 8254 的输入频率是 1.193182 MHz目标 100Hz那么初值 1193182 / 100 ≈ 11932。把这个值分高低字节写入定时器通道 0 的计数器寄存器即可。我踩过的坑是写入计数器时高低字节的顺序搞反了导致实际频率和预期差很多。8254 要求先写低字节再写高字节顺序错了频率就完全不对。调试时可以用cprintf打印计算出的初值和预期对比。6.4 中断处理程序返回后系统行为异常如果中断处理程序没有正确返回系统可能表现出奇怪的行为比如时钟中断处理一次后就再也不触发了。这通常和中断返回指令有关。在 x86 上中断处理程序应该用iret指令返回它会自动恢复被中断时的 CS、EIP 和 EFLAGS。如果误用了ret栈上的数据会被错误解释导致跳转到非法地址。ucore 里通常用汇编写的vector宏来统一处理中断入口最后调用 C 函数再由 C 函数调用iret返回。检查这部分代码时重点看栈的平衡进入中断时 CPU 压入了 EFLAGS、CS、EIP如果有特权级切换还会压入 SS 和 ESP中断处理程序如果压入了其他寄存器返回前必须全部弹出否则iret弹出的就是错误的值。7. 从 kern_init 延伸出去后续实验的接入点7.1 进程调度初始化应该放在哪里做完内存和中断的初始化后后续实验会要求加入进程调度。那么调度器的初始化应该放在kern_init的什么位置我的建议是放在intr_enable之前。因为调度器初始化会创建第一个内核线程idle 进程这个过程需要分配内存依赖pmm_init已完成、可能需要操作中断状态依赖idt_init已完成。如果在intr_enable之后初始化时钟中断可能在你还没建好进程结构时就到来中断处理程序去访问未初始化的调度器数据就会崩溃。所以合理的顺序是pmm_init→pic_init→idt_init→clock_init→sched_init创建 idle 进程→intr_enable→ 进入主循环。这样开中断时调度器已经就绪时钟中断到来可以安全地触发调度。7.2 主循环的设计与 idle 进程的关系kern_init的最后通常是一个while(1)循环。在早期实验里这个循环可能只是简单地检查某个标志位然后执行hlt指令让 CPU 休眠。加入调度后这个循环的角色会变化——它可能变成 idle 进程的主体或者被调度器的schedule()函数取代。理解这个演变过程对做实验很有帮助。早期的主循环是忙等待模式CPU 一直在转。加入hlt指令后CPU 在空闲时进入低功耗状态等到中断到来再唤醒这样更高效。再往后当有了完整的进程调度主循环就不再需要了因为调度器会在时钟中断里自动切换进程idle 进程本身也是一个可被调度的实体。7.3 实验之间的代码复用与隔离ucore 实验是分阶段的每个阶段在前一阶段基础上增加功能。kern_init作为入口会随着实验推进不断增加新的初始化调用。这里有个工程上的建议每加入一个新模块的初始化都在cprintf里加一条对应的日志。比如cprintf(pmm_init done\n); cprintf(idt_init done\n); cprintf(sched_init done\n);这样当系统崩溃时你能从最后一条成功打印的日志判断出是哪个模块出了问题。这个习惯在调试复杂系统时非常有用比用调试器单步要快得多尤其是在 QEMU 里 gdb 连接不稳定的时候。另外不同实验阶段的代码要注意隔离。比如做虚拟内存实验时修改了pmm_init的页表建立逻辑要确保这个修改不会影响之前实验已经验证过的功能。我习惯在修改前先用 git 提交一个版本改完后如果发现之前能跑的功能坏了可以快速对比差异。8. 一些值得记住的实操细节关于kern_init这个入口还有几个零散但很实用的点我在这里一并分享。第一cprintf的格式化支持有限。ucore 自己实现的cprintf通常只支持%d、%x、%s、%c这几种格式不支持%f、%lld等。如果你不小心用了不支持的格式符输出会错乱甚至崩溃。调试时如果需要打印 64 位整数要自己拆成高低 32 位分别打印。第二全局变量的初始化顺序不确定。C 语言标准没有规定不同编译单元里全局变量的初始化顺序。如果你的代码依赖某个全局变量在另一个全局变量之前初始化可能会出问题。内核开发里通常避免依赖全局变量的初始化顺序所有需要初始化的东西都显式在kern_init里调用初始化函数。第三栈的大小要留够。kern_init里调用的函数可能嵌套很深如果内核栈太小比如只给了 4KB可能会栈溢出覆盖到相邻数据。ucore 通常在 bootloader 阶段就设置好了栈但如果你在实验里加了递归调用或者大的局部数组要留意栈的使用量。可以在栈顶和栈底放魔术数字运行一段时间后检查魔术数字是否被改写以此判断是否溢出。第四QEMU 的-d选项是调试利器。加上-d int,cpu_reset可以让 QEMU 打印中断和 CPU 重置的日志对于排查 triple fault 特别有用。当日志里出现Triple fault时往前翻能看到最后执行的中断和异常从而定位问题。配合-D logfile把日志写到文件里方便搜索。第五不要忽视编译警告。内核代码里一个未初始化的变量、一个类型不匹配的赋值都可能导致运行时崩溃。把编译器的警告级别调高-Wall -Wextra并认真对待每一条警告。我遇到过因为一个int和unsigned int比较的警告没处理导致内存大小计算出错内核只识别出一半内存的情况。做到这里kern_init这条线基本上就理清楚了。它看起来只是几十行调用但每一行背后都牵扯着一个子系统的初始化逻辑而且顺序、时机、参数都有讲究。把这一关过了后面做进程管理、文件系统这些实验时你会发现自己对内核的整体运转有了清晰的把握遇到问题也知道该从哪个模块入手排查。