简介:本资源是经典操作系统实践教程《自己动手写操作系统》(于渊著)配套源码与开发环境的完整压缩包,面向计算机专业本科生、嵌入式开发者及系统编程爱好者,解决操作系统原理难理解、内核开发无从下手的核心痛点。压缩包共2000个文件,主体为956个C源文件(实现内核逻辑)、748个头文件(定义数据结构与接口)、119个ASM汇编文件(处理启动、中断与底层硬件交互),辅以Makefile构建脚本、IMG磁盘镜像、Bochs模拟器配置及多份README说明,总大小121.84MB,结构清晰、模块可独立编译调试。目前已有205人学习下载,读者可直接解压运行loader.asm等启动代码,在Bochs中实操观察从实模式跳转、内存分页初始化、简易进程调度等关键过程;源码严格对应书中章节,含完整注释与阶段性可运行版本,便于边学边调、逐层剖析OS内核构建脉络。
1. 这不是“玩具系统”:一份能真机启动、带完整内存管理与进程调度的 x86 实操操作系统源码包
你手头这份.rar文件,不是教学演示动画,也不是跑在 QEMU 里打个hello world就收工的壳子。它是一套从实模式跳转到保护模式、手动初始化 GDT/LDT、实现分页内存管理(含页表动态分配)、完成基于时间片轮转的多任务调度、并支持键盘输入与 VGA 文本输出的完整 x86 操作系统内核——所有代码由 C 与内联汇编混合编写,全部可编译、可链接、可烧录进软盘镜像、可在真实老式 PC(Pentium II 及以上)上冷启动运行。它解决的不是“操作系统长什么样”的认知问题,而是“中断向量怎么填才不蓝屏”“GDT 描述符特权级设错为何跳不进保护模式”“页目录基址寄存器 CR3 加载后为何立即触发双重错误”这类血泪级实操卡点。适合正在啃《Orange'S》《Linux 内核完全注释》但总在第 3 章就断连的嵌入式初学者,也适合想亲手验证 TLB 刷新时机、观察 TSS 切换时 SS0/ESP0 自动加载行为的固件工程师。它不承诺“5 分钟跑起来”,但承诺:每行汇编你都能查到 Intel SDM 卷 3A 的对应章节,每个 C 函数调用栈你都能用 Bochs 单步跟到底层。
2. 编译链与环境准备:为什么必须用 i386-elf-gcc 而非系统默认 gcc
2.1 工具链选型的底层逻辑:目标平台 ≠ 宿主平台
操作系统内核是裸机程序(bare-metal),不依赖任何 libc、不链接 glibc、不使用系统调用接口。它运行在自己的地址空间,起始入口是0x7c00(引导扇区)或0x100000(内核加载地址),段选择子需显式加载,堆栈需手动初始化。若用宿主机x86_64-linux-gnu-gcc编译,链接器会默认插入_start符号、调用__libc_start_main、依赖.dynamic段和PT_INTERP解释器——这些在无 OS 环境下全是非法指令。因此必须使用交叉编译工具链:目标架构为i386,ABI 为elf,不带任何操作系统后缀(即i386-elf-前缀)。该工具链生成的 ELF 文件可被ld链接成纯二进制(.bin),再经dd写入软盘镜像首扇区,BIOS 才能正确加载执行。
2.2 三步构建可用工具链(Ubuntu/Debian 系统)
提示:不要用
apt install gcc-multilib,它提供的是x86_64-linux-gnu-gcc -m32,仍链接宿主 libc,不可用于内核开发。
# 1. 安装基础依赖 sudo apt update && sudo apt install -y build-essential bison flex libgmp3-dev libmpc-dev libmpfr-dev texinfo # 2. 下载并编译 binutils(注意:必须指定 --target=i386-elf) wget https://ftp.gnu.org/gnu/binutils/binutils-2.40.tar.gz tar -xzf binutils-2.40.tar.gz mkdir build-binutils && cd build-binutils ../binutils-2.40/configure --target=i386-elf --prefix=/opt/cross --with-sysroot --disable-nls --disable-werror make -j$(nproc) && sudo make install # 3. 编译 GCC(关键:禁用自带 libc,仅编译前端与后端) cd .. wget https://ftp.gnu.org/gnu/gcc/gcc-12.2.0/gcc-12.2.0.tar.gz tar -xzf gcc-12.2.0.tar.gz mkdir build-gcc && cd build-gcc ../gcc-12.2.0/configure --target=i386-elf --prefix=/opt/cross --disable-nls --enable-languages=c,c++ --without-headers --disable-libgcc --disable-libstdc++ --disable-libgomp --disable-libquadmath make -j$(nproc) && sudo make install参数说明:
--without-headers:不安装任何 C 标准库头文件,避免污染;内核所需stdint.h等由项目自带include/目录提供。--disable-libgcc:禁用 GCC 运行时库(如__udivmoddi4),内核中除法等运算需自行实现或用内联汇编替代。--prefix=/opt/cross:将工具链安装至独立路径,避免与系统工具冲突;后续需将/opt/cross/bin加入PATH。
2.3 验证工具链有效性:一个最小可启动引导扇区
创建boot.s测试是否能生成纯二进制:
# boot.s .code16 .global _start _start: mov $0x07C0, %ax mov %ax, %ds mov %ax, %es mov %ax, %ss xor %ax, %ax mov %ax, %sp # 输出 'A' mov $0x0e, %ah mov $0x41, %al mov $0x00, %bh mov $0x07, %bl int $0x10 loop: jmp loop编译验证:
# 使用交叉工具链编译 /opt/cross/bin/i386-elf-as --32 boot.s -o boot.o /opt/cross/bin/i386-elf-ld -Ttext 0x7C00 -o boot.elf boot.o /opt/cross/bin/i386-elf-objcopy -O binary boot.elf boot.bin # 检查是否为纯二进制(无 ELF 头) file boot.bin # 应输出:boot.bin: data # 检查大小是否为 512 字节 ls -l boot.bin # 必须为 512 字节,否则 BIOS 不认作引导扇区逻辑说明:此测试绕过整个内核构建流程,直击工具链最底层能力——能否生成符合 BIOS 规范的 512 字节、末尾含0xAA55签名的引导扇区。若boot.bin大小非 512 字节,或file命令显示为 ELF,则说明objcopy参数错误或链接脚本未生效,需回溯检查i386-elf-ld的-Ttext地址与objcopy的-O binary选项是否匹配。
3. 项目结构解析与核心模块编译流程:从 bootsect 到 kernel.bin 的七阶段构建
3.1 目录树还原:.rar解压后的真实组织结构
解压自己动手写操作系统(于渊-含代码).rar后,典型结构如下(已按功能重命名注释):
os/ ├── boot/ # 引导模块:实模式入口 │ ├── bootsect.asm # 主引导扇区(512B,BIOS 加载至 0x7C00) │ └── setup.asm # 设置模块(加载内核至 0x100000,切换保护模式) ├── kernel/ # 内核主体:保护模式下运行 │ ├── kernel.asm # 内核入口(GDT/LDT 初始化、IDT 建立、开启 A20) │ ├── protect.c # 保护模式核心:描述符操作、特权级切换 │ ├── memory.c # 内存管理:页目录/页表分配、malloc/free 实现 │ ├── process.c # 进程调度:TSS 初始化、task_struct、schedule() 轮转 │ └── interrupt.c # 中断处理:时钟 IRQ0、键盘 IRQ1、异常门设置 ├── include/ # 公共头文件 │ ├── type.h # 基础类型定义(uint8_t, size_t) │ ├── const.h # 常量宏(PAGE_SIZE=4096, GDT_SIZE=128) │ └── protect.h # 保护模式相关结构(Descriptor, TSS, GateDesc) ├── tools/ # 构建辅助工具 │ └── mkimage # Shell 脚本:合并 bootsect/setup/kernel 并填充 0 └── Makefile # 主构建文件(关键!控制七阶段链接顺序)关键事实:该项目没有使用 GRUB 或任何现代引导协议,采用传统软盘镜像格式:前 512 字节为bootsect.asm,紧随其后 4KB 为setup.asm,之后为压缩/未压缩的kernel.bin。tools/mkimage脚本负责将三者拼接,并在末尾补零至 1.44MB(1474560 字节),形成标准软盘镜像。
3.2 Makefile 七阶段构建逻辑拆解
原项目Makefile隐含七个关键阶段,顺序不可颠倒(否则链接失败):
| 阶段 | 目标文件 | 关键命令 | 作用 |
|---|---|---|---|
| 1 | boot/bootsect.o | i386-elf-as --32 boot/bootsect.asm -o boot/bootsect.o | 实模式汇编,必须用--32生成 32 位兼容指令 |
| 2 | boot/setup.o | i386-elf-as --32 boot/setup.asm -o boot/setup.o | 同上,含cli/sti和lgdt指令 |
| 3 | kernel/kernel.o | i386-elf-gcc -m32 -c kernel/kernel.asm -o kernel/kernel.o | 注意:.asm文件用 gcc 预处理(支持#include)再汇编 |
| 4 | kernel/protect.o | i386-elf-gcc -m32 -c kernel/protect.c -o kernel/protect.o | C 代码,需-m32强制生成 32 位目标码 |
| 5 | kernel/memory.o | i386-elf-gcc -m32 -c kernel/memory.c -o kernel/memory.o | 同上,含__attribute__((section(".data")))段声明 |
| 6 | kernel.bin | i386-elf-ld -Ttext 0x100000 -o kernel.bin kernel/*.o | 链接地址固定为0x100000(1MB),与 setup.asm 中加载地址一致 |
| 7 | Image | sh tools/mkimage boot/bootsect.o boot/setup.o kernel.bin | 拼接三者,生成可启动镜像 |
参数深挖:
-m32:强制 GCC 生成 32 位指令(即使宿主机是 x86_64),避免movq等 64 位指令混入。-Ttext 0x100000:指定代码段起始地址为 1MB,这是传统 PC 内核加载惯例,setup.asm中mov $0x100000, %eax正是为此预留。__attribute__((section(".data"))):在memory.c中用于将空闲内存链表free_list显式放入.data段,确保其物理地址连续且可被memset安全初始化。
3.3 手动复现构建流程(避坑版)
不要直接make all—— 原 Makefile 有路径硬编码。按以下步骤逐条执行:
# 进入项目根目录 cd os/ # 1. 清理旧对象文件(重要!避免符号残留) rm -f boot/*.o kernel/*.o kernel.bin Image # 2. 编译引导扇区(必须用 as,不能用 gcc) /opt/cross/bin/i386-elf-as --32 boot/bootsect.asm -o boot/bootsect.o # 3. 编译 setup(同上) /opt/cross/bin/i386-elf-as --32 boot/setup.asm -o boot/setup.o # 4. 编译 kernel.asm(用 gcc 预处理 + 汇编) /opt/cross/bin/i386-elf-gcc -m32 -x assembler-with-cpp -c kernel/kernel.asm -o kernel/kernel.o # 5. 编译 C 模块(关键:-I 指定头文件路径) /opt/cross/bin/i386-elf-gcc -m32 -I include -c kernel/protect.c -o kernel/protect.o /opt/cross/bin/i386-elf-gcc -m32 -I include -c kernel/memory.c -o kernel/memory.o /opt/cross/bin/i386-elf-gcc -m32 -I include -c kernel/process.c -o kernel/process.o /opt/cross/bin/i386-elf-gcc -m32 -I include -c kernel/interrupt.c -o kernel/interrupt.o # 6. 链接内核(注意:顺序决定符号解析优先级) /opt/cross/bin/i386-elf-ld -Ttext 0x100000 -o kernel.bin \ kernel/kernel.o kernel/protect.o kernel/memory.o \ kernel/process.o kernel/interrupt.o # 7. 生成镜像(执行 mkimage 脚本) chmod +x tools/mkimage sh tools/mkimage boot/bootsect.o boot/setup.o kernel.bin逻辑说明:此流程强制暴露每一步的输入输出,便于定位失败环节。例如若kernel.bin链接失败报undefined reference to 'printl',说明interrupt.c中调用的printl()函数未在任何.o文件中定义——此时需检查include/const.h是否被正确包含,或printl是否被误写为println。而mkimage脚本本质是cat bootsect.o setup.o kernel.bin | dd of=Image bs=1024 count=1440,若执行后Image大小非 1474560 字节,说明kernel.bin超出预期空间,需检查memory.c中free_mem_ptr初始化是否越界。
4. 避坑:实操中高频翻车的五个边界问题与硬核解法
4.1 现象:Bochs 启动后黑屏,日志显示00000000000 >>PANIC<< unknown exception 0x0D
原因:#GP(0)通用保护异常,通常因 GDT 描述符特权级(DPL)与当前 CPL 不匹配。常见于kernel.asm中mov %ax, %ds指令——若ax加载的是0x10(指向 GDT 中第 2 个描述符),但该描述符 DPL=0(内核级),而当前 CS 是实模式(CPL=0),却试图用0x10加载 DS(数据段),而 DS 描述符 DPL=3(用户级),则触发 GP。
解决:检查gdt_ptr结构体中gdt[2](数据段描述符)的 DPL 字段是否为0x0(二进制00),而非0x3。正确写法:
; gdt.asm 中数据段描述符(第2项,索引1) .word 0xFFFF ; limit low .word 0x0000 ; base low .byte 0x00 ; base middle .byte 0x92 ; access byte: P=1, DPL=0, S=1, TYPE=0010 (data) .byte 0xCF ; limit high + flags: G=1, D/B=1, L=0, AVL=0 .byte 0x00 ; base high注意:
byte 0x92的二进制为10010010,其中第 5-6 位(从0开始)为00,即 DPL=0。
4.2 现象:进入保护模式后,mov %eax, %cr0执行完立即重启,Bochs 日志无异常
原因:A20 地址线未开启。实模式下地址线 A20 被屏蔽,导致0xFFFFF + 1 = 0x00000(回绕);保护模式下必须开启 A20,否则访问0x100000以上地址会错误映射到低地址,引发不可预测崩溃。
解决:在setup.asm中cli后、lgdt前插入 A20 开启代码:
; 开启 A20 inb $0x64, %al # 等待 8042 输入缓冲区空 testb $0x2, %al jnz 1b movb $0xD1, %al # 发送命令:写输出端口 outb %al, $0x64 inb $0x64, %al # 等待空 testb $0x2, %al jnz 1b movb $0xDF, %al # 写入值:开启 A20(bit1=1) outb %al, $0x604.3 现象:memory.c中malloc返回地址0x100000,但memset后读取为全 0
原因:页表未正确映射物理地址。memory.c初始化时调用init_memory(),其中alloc_page()分配页目录/页表,但若cr3寄存器未加载新页目录物理地址,CPU 仍使用旧页表(可能全为 0),导致虚拟地址0x100000映射到物理地址0x0。
解决:确认kernel.asm中enable_paging:标签后,mov %eax, %cr3的eax是否为页目录物理地址:
; kernel.asm 片段 enable_paging: mov $page_dir_table_phys, %eax # page_dir_table_phys 是页目录物理地址(需在 linker script 中定义) mov %eax, %cr3 mov %cr0, %eax orl $0x80000000, %eax mov %eax, %cr0 jmp 1f 1: nop补充:
page_dir_table_phys需在链接脚本kernel.ld中定义为SECTIONS { . = 0x100000; page_dir_table_phys = .; ... }。
4.4 现象:键盘 IRQ1 中断触发后,屏幕卡死,Bochs 显示INT 0x21: not handled
原因:IDT(中断描述符表)中键盘中断门(IRQ1 对应 IDT[0x21])的 DPL=0,但当前 CPL=3(用户态进程),权限不足无法触发。而本项目中所有中断处理程序均在内核态(CPL=0)运行,故 IDT 门描述符 DPL 必须为0x0。
解决:检查interrupt.c中set_intr_gate(0x21, keyboard_handler)调用生成的门描述符:
void set_intr_gate(u8 n, u32 addr) { // 门描述符:偏移量低16位、选择子、DPL=0、P=1、TYPE=1110(中断门) idt[n].offset_low = addr & 0xFFFF; idt[n].selector = SELECTOR_KERNEL_CS; // 必须是内核代码段选择子(DPL=0) idt[n].dcount = 0; // 不需要压栈 idt[n].attr = 0x8E00; // 1000 1110 0000 0000 → DPL=00, P=1, TYPE=1110 idt[n].offset_high = (addr >> 16) & 0xFFFF; }关键:
attr = 0x8E00,其中第 13-12 位(DPL)为00,非11。
4.5 现象:process.c中schedule()切换任务后,新进程eax寄存器值为随机数
原因:TSS(任务状态段)中esp0(内核栈指针)未初始化。当从用户态(CPL=3)触发中断时,CPU 自动切换到 TSS 中ss0:esp0指定的内核栈。若esp0为 0,压栈操作将破坏内存,导致后续iret时栈帧错乱。
解决:在protect.c的init_protect()中,为每个task_struct的 TSS 显式设置esp0:
// tss[i].esp0 = (u32)task_stack[i] + STACK_SIZE; // task_stack[i] 是内核栈底地址 tss[i].esp0 = (u32)&task_stack[i][STACK_SIZE]; // 确保 esp0 指向栈顶 tss[i].ss0 = SELECTOR_KERNEL_DS; // 内核数据段选择子注意:
STACK_SIZE通常为0x2000(8KB),&task_stack[i][STACK_SIZE]计算出栈顶物理地址。
5. 真机验证与调试技巧:用 Bochs + GDB 定位“看不见”的寄存器错误
5.1 Bochs 配置文件bochsrc.bxrc关键参数
不要用默认配置——它不输出足够底层信息。创建定制化配置:
megs: 32 cpu: count=1, ips=10000000, reset_on_triple_fault=1 romimage: file=/opt/cross/share/bochs/BIOS-bochs-latest vgaromimage: file=/opt/cross/share/bochs/VGABIOS-lgpl-latest display_library: sdl2 mouse: enabled=0 keyboard: keymap=/opt/cross/share/bochs/keymaps/x11-pc-us.map floppy_bootsig_check: disabled=0 boot: disk ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14 ata0-master: type=disk, mode=flat, path="Image", cylinders=1440, heads=2, spt=80 log: bochsout.txt debug: action=report参数说明:
floppy_bootsig_check: disabled=0:强制校验引导扇区0xAA55签名,避免因mkimage错误导致静默失败。ata0-master: ... cylinders=1440, heads=2, spt=80:精确匹配 1.44MB 软盘几何参数(1440×2×80=1440×160=230400 扇区?不对!实际是 1440×2×80=230400 字节?错!标准 1.44MB=1440×1024=1474560 字节,此处cylinders=1440是 Bochs 伪参数,真实容量由path="Image"文件大小决定)。log: bochsout.txt:输出详细日志,尤其关注>>> BX_DEBUGGER <<<后的寄存器快照。
5.2 GDB 远程调试:在kernel.asm中断点抓取 CR3 加载瞬间
Bochs 支持 GDB stub,可在任意指令处暂停并检查寄存器:
# 启动 Bochs 并监听 GDB bochs -f bochsrc.bxrc -q -r # 新终端连接 GDB(需编译支持 Python 的 gdb) gdb (gdb) target remote localhost:1234 (gdb) symbol-file kernel.bin # 加载符号表 (gdb) b *0x100000 # 在内核入口设断点 (gdb) c # 此时 Bochs 暂停,可查看 CR3 (gdb) info registers cr3 (gdb) x/10xw $cr3 # 查看页目录内容实战技巧:当怀疑页表映射错误时,在enable_paging:后加断点:
enable_paging: mov $page_dir_table_phys, %eax mov %eax, %cr3 mov %cr0, %eax orl $0x80000000, %eax mov %eax, %cr0 # 在此插入 INT3 软中断,触发 GDB 断点 .byte 0xccGDB 连接后执行x/4xw $cr3,若输出全0x00000000,说明page_dir_table_phys地址计算错误;若输出0x00000003(最低两位为11,表示存在且用户可读),则页目录项已设置,需继续检查页表项。
5.3 真机启动排错三板斧
没有 Bochs?用真实 PC(建议使用 USB 软驱或 Legacy BIOS 模式 UEFI 主板):
第一斧:检查引导签名
# 提取 Image 前 512 字节,查看末尾是否为 0x55 0xAA dd if=Image of=bootsect.bin bs=512 count=1 hexdump -C bootsect.bin | tail -n1 # 应输出 000001f0 00 00 00 00 00 00 00 00 00 00 00 00 55 aa |............U.|第二斧:验证 setup.asm 加载地址
setup.asm中mov $0x100000, %eax后,用int 0x10输出调试字符:mov $0x0e, %ah mov $0x53, %al # 'S' int $0x10若真机启动后屏幕显示
S,说明setup已成功加载并执行;若无显示,说明bootsect未正确跳转到setup(检查bootsect.asm中jmp setup_start地址是否为0x90000)。第三斧:内存布局 dump
在kernel.asm开头插入:; dump first 64 bytes of memory at 0x100000 mov $0x100000, %esi mov $0x40, %ecx dump_loop: mov (%esi), %al call print_hex_byte # 自定义打印函数 inc %esi loop dump_loop若输出全
0x00,说明setup未将kernel.bin正确复制到0x100000;若输出乱码,说明复制成功但入口地址错误。
6. 进阶验证:用 QEMU 模拟多核启动与中断嵌套深度测试
6.1 QEMU 启动命令与多核支持
原项目为单核设计,但可通过 QEMU 模拟 SMP 验证中断屏蔽行为:
# 启动双核,启用 GDB stub qemu-system-i386 -s -S -m 32M -hda Image -smp 2,cores=2,threads=1 -no-reboot -d int,cpu_reset-s:开启 GDB server(端口 1234)-S:启动后暂停,等待 GDB 连接-smp 2:模拟双 CPU,触发APIC初始化需求(原项目未实现,但可观察#UD无效指令异常)-d int,cpu_reset:输出中断与复位日志,验证IRQ0(时钟)是否在双核间均衡触发
现象分析:若日志中INT 0x20(时钟中断)仅在 CPU 0 上出现,说明process.c中timer_handler未做 CPU 绑定,需在IDT设置时指定DPL=0并确保iret后CS指向正确代码段。
6.2 中断嵌套深度测试:验证cli/sti临界区有效性
在interrupt.c的timer_handler中插入嵌套计数器:
static int nest_depth = 0; void timer_handler() { nest_depth++; if (nest_depth > 1) { // 连续两次时钟中断未完成,说明 cli 失效 printl("NEST DEPTH ERROR: %d\n", nest_depth); while(1); // 死循环,真机可见 } // ... 原有处理逻辑 ... nest_depth--; }关键修改:在timer_handler开头cli,结尾sti,确保中断处理期间不被重入。若nest_depth达到 2,说明cli未生效——常见原因是timer_handler被编译为call指令而非push %cs; push %ip; ...,需在interrupt.c中用__attribute__((regparm(0)))强制使用栈传参,避免寄存器优化干扰cli位置。
6.3 内存泄漏压力测试:连续 malloc/free 1000 次
在kernel/main.c(若存在)或process.c的init_main_task中添加:
void mem_leak_test() { void* ptrs[1000]; int i; for (i = 0; i < 1000; i++) { ptrs[i] = malloc(1024); // 分配 1KB if (!ptrs[i]) { printl("MALLOC FAIL AT %d\n", i); break; } } for (i--; i >= 0; i--) { free(ptrs[i]); } // 检查空闲链表是否恢复初始状态 check_free_list(); }验证方法:在memory.c的check_free_list()中遍历free_list,统计总空闲页数是否等于total_mem_size / PAGE_SIZE。若数值减少,说明free()未正确合并相邻空闲块,需检查free()中merge_with_prev/next逻辑是否遗漏prev->size += cur->size + sizeof(struct mem_block)。
从那以后我每次调试保护模式切换,都强制在lgdt后插一条mov $0x12345678, %eax,然后用 GDBinfo registers eax确认指令已执行——这比看 Bochs 日志快十倍。真机上没 GDB?那就用int 0x10打印十六进制寄存器值,一行汇编就是你的万用表。希望帮到你。
本文还有配套的精品资源,点击获取