ucore Lab1 实验:x86 从开机到内核启动全链路解析
发布时间:2026/10/1 15:39:11来源:尧图网络
如果你正在上一门操作系统课或者自己啃到操作系统到底是怎么从一堆裸机硬件把内核跑起来这个坎上那 ucore Lab1 大概率是你绕不过去的一个实验。标题叫操作系统实验报告1ucore Lab 1说白了就是把一个教学用的操作系统 ucore 从开机第一秒到进入 C 语言 main中间那条最短也最容易被忽略的路径全给你拆开看一遍。它不像写业务代码那样敲完就能跑出结果而是要求你盯着汇编、ELF 格式、QEMU 调试器一点点把计算机怎么启动这件事还原出来。这一篇我按自己实际做 Lab1 的顺序来写不讲空话重点放在每一步为什么这么设计、代码里的坑在哪、调试时怎么快速定位。适合三类人正在做 ucore Lab1 需要一份能对照的实操笔记的同学、想搞清楚 x86 开机流程但一直没找到入口的自学者以及准备面试操作系统方向、需要把保护模式中断描述符表函数调用栈这些名词讲明白的人。下面从环境准备一路走到 QEMU 断点调试和踩坑复盘尽量把能直接抄的部分都给出来。1. 先搞清楚 ucore Lab1 到底在练什么很多人拿到实验指导书第一反应是懵Experiments 里一堆练习扩展练习还有两个到底哪个是重点、哪个可以先跳过。我的理解是Lab1 真正的核心命题只有一个——把操作系统镜像文件是怎么被硬件一步步加载并执行起来这件事讲透。围绕这个命题它拆成了编译链接链路、bootloader 引导、ELF 解析、中断初始化、栈帧跟踪这几块。每一块单看都像是老古董知识但组合起来其实就是一台机器从加电到能响应时钟中断的完整最小闭环。1.1 Lab1 在整门课里承担的角色ucore 这门课的实验是循序渐进的Lab1 是整个系列的地基浇筑。它不涉及进程调度、虚拟内存管理这些看起来更高级的东西反而把大量篇幅压在汇编和硬件细节上原因很直接后面所有的实验都建立在能启动、能中断、能调试这三点之上。如果你在 Lab1 里没有把 GDT、IDT、栈帧这几样东西弄清楚到 Lab2 做物理内存管理、Lab3 做虚拟内存时你会发现自己连gdb该在哪个地址下断点都不敢确定。我在第一遍做的时候犯过一个典型错误练习1、练习2 草草看完就跳去做练习5的代码题结果print_stackframe输出全是乱码。回头补课才发现问题出在我根本没搞清楚 bootloader 把内核加载到了哪个物理地址所以栈上的 ebp 值我完全对不上号。Lab1 的练习顺序是有讲究的前面的理解题是后面代码题的坐标系别嫌麻烦跳着做。1.2 一套能跑起来的实验环境环境这块其实老手都知道ucore 的实验推荐在 Linux 下做主要依赖gcc最好是支持 32 位交叉编译的那套、make、qemu-system-i386和gdb。ubuntu 系直接用包管理器装就行sudo apt-get install build-essential qemu-system-x86 gdb注意这里要装的是qemu-system-x86而不是别的虚拟化工具因为 Lab1 的Makefile里调用的qemu命令和调试脚本都默认按这个来。装完之后用qemu-system-i386 --version确认一下能打印版本能看到版本号就说明二进制在 PATH 里了。另外要检查一下你的gcc能不能编 32 位代码ucore 是纯 32 位内核gcc -m32 -c -x c /dev/null -o /tmp/test.o echo 32位编译OK如果报错找不到 32 位的头文件需要补装gcc-multilib。这一步我遇到过不止一次很多人卡在make 一堆报错其实根因就是没有 32 位支持。环境搭好之后进到lab1目录直接make最后能生成bin/ucore.img就说明基础链路通了。提示第一次 make 建议保留完整输出别加-s静默参数。Lab1 练习1 就是要你解释 make 生成了什么完整输出本身就是答题素材。2. 从 make 到可执行文件读懂编译链接这条链路练习1 问的是ucore.img 是如何一步步生成的这题看着像送分题其实非常考验你对编译、链接、镜像打包的理解。很多人答得干巴巴就是没真正跟踪过 make 的每一步。我的做法是先看Makefile的依赖关系再对照实际生成的文件一步步验证这样答起来才有内容。2.1 敲下 make 之后到底发生了什么ucore 的构建分成两条独立的小链路最后合到一张磁盘镜像里。第一条是bootloader 链路bootasm.S和bootmain.c被编译、汇编成obj/bootblock.o然后用链接器按-Ttext 0x7C00 -e start把它链接成入口在0x7C00的可执行文件。这里0x7C00不是随便选的它是 x86 开机后 BIOS 把第一个扇区加载到的固定内存地址硬件层面的约定。关键的一步是objcopy把链接好的 ELF 文件用-O binary转成纯二进制obj/bootblock.out再用sign工具给它加上0x55AA的启动签名同时确认它正好等于 512 字节一个扇区$(OBJCOPY) -S -O binary obj/bootblock.o obj/bootblock.out tools/sign obj/bootblock.out bin/bootblocksign这个工具的作用就是补全扇区尾部的两个魔数字节BIOS 只有在扇区最后两个字节看到0x55AA才认为它是可引导扇区。第二条链路是内核链路kern目录下所有.c和.S文件编译链接成obj/kernel.elf注意它是 ELF 格式、不是纯二进制因为后面 bootloader 要靠解析 ELF 头来加载它。2.2 ELF 文件长什么样怎么用工具验证不把 ELF 结构看明白练习4 根本没法答。ELF 文件由**一个 ELF 头 若干程序头program header 若干节section**组成。对于加载器来说真正关心的是程序头表它描述了这个文件里每一段应该被搬到内存的哪个位置、搬多少。用readelf可以直接把这两张表打出来readelf -h obj/kernel.elf # 看 ELF 头重点是 e_entry 和 e_phoff readelf -l obj/kernel.elf # 看程序头表重点是 p_offset、p_va、p_memsz、p_filesz我在核对的时候会重点盯e_entry它告诉 bootloader 该跳到哪儿执行内核。ucore 里这个入口是kern/init/entry.S里的kern_entry。程序头表里每个 segment 的p_filesz和p_memsz也值得看前者是文件里实际有的字节数后者是加载到内存后应该占的字节数两者不相等说明有.bss段需要在内存里补零。这些都是练习4 答题要用到的术语先把它们看熟。注意不要用readelf -S节头表去解释加载过程。加载器只认程序头表节头表是给链接器和调试器用的考试答错这个点会被扣分。2.3 练习1怎么答才不被扣分练习1 要求比较详细地解释 Makefile 中每一条相关命令和参数的含义以及命令导致的结果。我自己的答法是分三层写第一层列出依赖关系bootloader 和 kernel 各自怎么生成的第二层逐条命令解释参数比如-N让代码段和数据段贴近、-Ttext 0x7C00指定链接基址、-e start指定入口符号第三层说清楚最后的镜像打包。镜像打包那几条dd命令是重点很多人写不清楚dd if/dev/zero ofbin/ucore.img count10000 dd ifbin/bootblock ofbin/ucore.img convnotrunc dd ifbin/kernel ofbin/ucore.img seek1 convnotrunc第一条把ucore.img填成一个 10000 扇区的空文件约 5MB。第二条把 bootloader 写到镜像最开头convnotrunc表示不截断目标文件只覆盖那 512 字节。第三条把内核写到镜像里seek1是关键——它跳过第一个扇区让内核从第二个扇区开始存放。这样设计的原因很简单BIOS 只自动读第一个扇区后面的内容必须由 bootloader 自己去磁盘上按扇区号读取所以 bootloader 和内核必须物理上分开。3. bootloader 如何把 CPU 从实模式拽进保护模式练习2 和练习3 是 Lab1 里最硬的一块因为涉及CPU 加电后第一条指令这个平时完全感知不到的层面。我当时用gdb单步跟了一遍 BIOS最大的感受就是机器刚通电时它其实还停留在几十年前那种寻址只有 1MB、寄存器全是 16 位的实模式下而我们要做的是在几百行汇编里把 CPU 一步步升级到 32 位保护模式然后交棒给 C 写的内核。3.1 为什么开机第一件事是切换模式实模式有几个致命限制地址线只有 20 位实际寻址 1MB、没有内存保护、所有程序都在同一权限下运行。作为教学操作系统要跑起来不可能一直待在这种环境里所以 bootloader 的第一个核心任务就是打开保护模式。顺序大致是关中断、清零数据段寄存器、使能 A20 地址线、加载 GDT、设置cr0的 PE 位、远跳转刷新流水线最后跳到 32 位代码段。关中断用cli这一步不能省。为什么在 GDT 还没建立、IDT 还是空的时候一旦来中断CPU 会按还没准备好的描述符去查表直接跑飞。清零ds/es/ss是为了让段寄存器在切换前处于已知状态。使能 A20 是历史遗留问题——早年为了兼容第 21 根地址线默认是关闭的不打开的话地址会回绕超过 1MB 的访问就会出问题。3.2 A20 地址线、GDT 与段寄存器的配合A20 的打开有两种常见做法一种是通过键盘控制器 8042 的慢速方式另一种是走0x92端口的快速方式。笔试里两种都会被问到慢速方式典型代码长这样seta20.1: inb $0x64, %al testb $0x2, %al jnz seta20.1 movb $0xd1, %al outb %al, $0x64 seta20.2: inb $0x64, %al testb $0x2, %al jnz seta20.2 movb $0xdf, %al outb %al, $0x60每一句都有意图0x64是键盘控制器的状态/命令端口先轮询状态位确认它可以接收命令再发0xd1写输出端口然后再轮询最后往0x60写0xdf把 A20 那一位拉高。这套流程容易读晕我当时的办法是先记住两个轮询 两个写这个骨架细节再填。GDT 是保护模式下段机制的字典。ucore 里定义了一个简单的 GDT包含空段描述符、内核代码段、内核数据段三项。这里要理解一个反差保护模式下段寄存器存的已经不是段基址而是 GDT 里的选择子selector真正描述段的基址、界限、权限的信息都存在 GDT 表项里。加载 GDT 用lgdt然后设置cr0的 PE 位lgdt gdtdesc movl %cr0, %eax orl $CR0_PE_ON, %eax movl %eax, %cr0 ljmp $PROT_MODE_CSEG, $protcseg那句ljmp是精髓它强制刷新 CPU 流水线让 CS 重新按保护模式规则加载选择子。如果只改cr0不做远跳转CPU 可能还在用旧的实模式含义解释 CS行为会非常诡异。3.3 bootmain 如何把 ELF 内核搬进内存进到保护模式后bootasm.S设置好栈就调用bootmain.c里的bootmain。这个函数是练习4 的主角逻辑很清晰从磁盘第二个扇区开始读内核按 ELF 头找到程序头表遍历每个 segment 把它读进内存最后跳到 ELF 入口执行。读磁盘用的是最原始的 PIO 方式靠0x1F0~0x1F7这一组端口。readsect的流程是先等磁盘不忙然后设置要读的扇区号分四个端口写 24 位 LBA发送读命令0x20等磁盘就绪后用insl一次性读 512 字节进来。这里有个容易忽视的点是扇区号按 LBA 编码最高 4 位还要拼上0xE0表示主盘主通道写端口的时候很容易漏。读进 ELF 头之后先做魔数校验看头 4 个字节是不是\x7fELFstruct elfhdr *elf (struct elfhdr *)ELFHDR; if (elf-e_magic ! ELF_MAGIC) { goto bad; }校验通过后遍历程序头逐个把 segment 读到p_va指向的物理地址并且如果p_memsz p_filesz要把多出来那段内存清零对应.bss。最后调用((void (*)(void))(elf-e_entry))()跳进内核。这一步看似简单但一旦地址算错表现就是内核跑飞或者直接 QEMU 重启调试时建议在e_entry那一行下断点验证。4. 中断描述符表让中断能落到你的代码上练习6 是 Lab1 里唯一的填空写代码主菜分三个小问分别问 IDT 表项的结构、idt_init的实现、以及时钟中断处理。做这块之前一定要把中断向量表和中断描述符表这两个概念理清实模式下是中断向量表保护模式下换成了 IDT本质都是中断号到处理代码入口的映射表。4.1 IDT、门描述符与 SETGATE 宏IDT 里每一张表项叫门描述符占8 字节最多 256 项对应 0~255 号中断。这 8 字节里装的是处理代码的段选择子和偏移地址两者拼起来才是完整的 32 位入口另外还有类型位中断门还是陷阱门、DPL 权限位、P 存在位。练习6 第一问标准答案就是8 字节入口由段选择子16 位 偏移高 16 位 偏移低 16 位共同组成。初始化 IDT 要遍历所有 256 项把向量表里的处理函数地址填进门描述符。ucore 里给了SETGATE宏封装这些位域操作核心实现void idt_init(void) { extern uintptr_t __vectors[]; int i; for (i 0; i sizeof(idt) / sizeof(struct gatedesc); i) { SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL); } SETGATE(idt[T_SYSCALL], 1, GD_KTEXT, __vectors[T_SYSCALL], DPL_USER); lidt(idt_pd); }这里有两个细节容易被忽略一是大部分中断的 DPL 是内核权限唯独系统调用要设成用户权限否则用户态触发不了它这是练习6 暗设的考点二是__vectors[]这张表定义在vector.S里每个vectorN是段汇编桩负责压栈中断号再跳到统一的__alltraps。最后别忘了lidt把 IDT 的基址和界限写进 IDTR 寄存器不写这一步前面全白做。4.2 时钟中断和 ticks 计数练习6 第三问要你在trap函数里处理时钟中断让系统每收到 100 次时钟中断就打印一次。时钟中断由 8253/8254 定时器周期性触发ucore 里对应的中断号是IRQ_OFFSET IRQ_TIMER。要补的代码就三行case IRQ_OFFSET IRQ_TIMER: ticks ; if (ticks % TICK_NUM 0) { print_ticks(); } break;TICK_NUM常量是 100print_ticks会输出100 ticks。这题代码本身不难难在理解为什么时钟中断是操作系统的心跳。它是抢占式调度的基础——没有时钟中断一个跑死的用户程序就能把整个系统挂住。我在调试时特意把TICK_NUM改成 10 观察输出频率确认中断确实在周期性触发这个验证动作很值得做能帮你排除中断根本没进来这类问题。提示如果打印不出100 ticks先确认 IDT 初始化有没有正确lidt再确认中断是否被sti打开。顺序错一环都会导致中断静默丢失。5. 栈帧跟踪用 ebp 链把调用关系扒出来练习5 是很多人第一次真正用汇编理解 C 函数调用的地方实现print_stackframe把每一层函数调用的 ebp、eip 和参数打印出来。这题一旦理解栈布局代码其实很短但如果不理解ebp链写出来就是错的。5.1 一次函数调用的栈布局x86 上函数调用约定是这样的调用者把参数从右往左压栈然后call指令自动把返回地址压栈进入被调函数后头两句通常是push %ebp和mov %esp, %ebp。这样做的就是把旧的 ebp 保存到栈上并让 ebp 指向它形成一条可以一直往回追的链。所以任意一层栈帧从 ebp 往上看就是[ebp]存着上一层的 ebp[ebp4]是返回地址[ebp8]开始才是参数。用一张表描述更清楚栈位置相对 ebp内容ebp 8 及以上传入参数arg1, arg2...ebp 4返回地址 eipebp上一层函数的 ebpebp - 4 及以下本函数的局部变量理解这张表print_stackframe就变成一道读地址的题了。5.2 print_stackframe 的实现细节实现的核心是从当前 ebp 出发每轮打印[ebp]和[ebp4]打印四个参数然后把 ebp 更新成[ebp]往上一层走同时 eip 更新成[ebp4]void print_stackframe(void) { uint32_t ebp read_ebp(); uint32_t eip read_eip(); int i, j; for (i 0; i STACKFRAME_DEPTH ebp ! 0; i) { cprintf(ebp:0x%08x eip:0x%08x args:, ebp, eip); uint32_t *args (uint32_t *)ebp 2; for (j 0; j 4; j) { cprintf(0x%08x , args[j]); } cprintf(\n); eip *(uint32_t *)(ebp 4); ebp *(uint32_t *)ebp; } }read_ebp和read_eip用内联汇编读寄存器。这里有个特别经典的坑read_eip如果写成普通函数return一个局部变量返回的其实是它自己在print_stackframe里的返回地址不是调用点的 eip。正确写法要直接读当前帧static inline uint32_t read_eip(void) { uint32_t eip; asm volatile(movl 4(%%ebp), %0 : r(eip)); return eip; }我第一次就是踩了这个坑打印出来的 eip 全是一样的值。如果你也遇到每一层 eip 都相同八成是这个原因。另外循环要有终止条件最后一定会走到某一层的 ebp 为 0通常是kern_init的上一层不加判断会一直读下去直到访问非法内存。6. QEMU 调试实操与踩坑速查Lab1 大量的练习依赖调试器练习2 甚至要求从开机第一条指令开始单步。用好gdb和 QEMU 的配合能让 Lab1 从靠猜变成看得见。这块我把自己常用的命令和踩过的坑整理出来直接照着做基本不会卡。6.1 用 gdb 挂上 QEMU 单步ucore 的 make 流程里已经内置了调试目标通常make debug会启动 QEMU 并让它等待 gdb 连接再另开一个终端make gdb连上去。连接成功后设置架构和远程目标(gdb) target remote :1234 (gdb) set architecture i8086 (gdb) break *0x7c00 (gdb) continue为什么一开始要把架构设成i8086因为加电后的那段代码是 16 位实模式如果按默认的 32 位反汇编看到的指令会面目全非。等 bootloader 切到保护模式后再切回来的话可以再设一次架构。在0x7c00下断点验证 bootloader 被正确加载是练习2 的标准流程。看反汇编用x/10i $pc看寄存器用info registers看栈用x/16xw $esp。想不起来当前执行到哪一个习惯是每次si单步后对比obj/bootblock.asm那是链接器生成的反汇编清单和 gdb 看到的应该基本一致。练习2 第三问就是让你做这个比对所以提前把bootblock.asm开着会省很多事。6.2 常见问题速查表做 Lab1 时高频问题基本集中在下面这几类我做了张速查表方便对照现象可能原因排查方向make 报 32 位头文件缺失没装 multilib装gcc-multilibucore.img 生成失败sign 工具没编译检查 tools 目录是否 make 过QEMU 一运行就重启bootloader 签名或扇区大小不对确认 bootblock 恰好 512 字节内核没进去ELF 入口地址或加载地址错误对照readelf -h和 bootmain打印不出 100 ticksIDT 未 lidt 或中断未使能检查idt_init和stiprint_stackframe 乱码read_eip 实现错误用内联汇编读4(%ebp)gdb 反汇编全是乱码架构设置不对实模式下设成 i8086这张表里的每一条我基本都亲历过至少一次尤其QEMU 一运行就重启这条根因往往是sign没执行成功导致扇区末尾没有0x55AABIOS 直接跳过引导。6.3 我踩过的那几个坑第一个坑是磁盘扇区号计算。readsect里写 LBA 分四个端口高 4 位要和0xE0拼接我一开始漏了这一步导致读到的扇区错位内核加载进来全是垃圾数据。第二个坑是忘记关中断在 GDT 还没建好的时候一个意外中断就把系统带偏表现是跳到一个随机地址跑飞。第三个坑是.bss段没清零练习4 里那段p_memsz p_filesz时清零的逻辑如果不写内核里未初始化的全局变量会带着随机值调试时非常难查。我在实际操作中的体会是Lab1 这类实验最忌讳对着答案改代码。因为答案给你的只是一行代码但调试经验是你自己走的弯路换来的。我后来养成的习惯是每改一处代码都重新make一次并观察输出改动越小、反馈越快定位问题的成本就越低。另一个小习惯是给每个练习单独建一个笔记文件记录当时下的断点、看到的寄存器值、以及为什么这么判断等到做 Lab2 再回头翻这些记录会变成特别顺手的参考资料。其实 Lab1 做完之后你会发现它教的这些知识点——保护模式、ELF 加载、IDT、栈帧——并不是过时的考试内容而是理解任何一款主流操作系统内核启动流程的通用模型。这套模型建立起来之后你再去看别的系统的引导代码或者去读 x86 手册里关于中断门、任务门的章节会明显觉得不那么吃力了。如果还有余力可以顺着 Lab1 的扩展练习继续往下挖把页目录自映射那块也做一遍那基本就是通往 Lab2 物理内存管理的桥了。
网站建设高端定制企业官网