
1. 这不是“跑个Hello World”而是一次对操作系统内核骨架的亲手触摸你点开这个标题大概率是正在啃《操作系统》课本、被实验报告压得喘不过气的计算机专业学生或是刚刷完翁恺C语言课、想试试“把代码写进机器底层”的自学爱好者。别急着关掉——这确实不是教你怎么装Ubuntu也不是教你用VSCode配个C环境。它讲的是如何用不到500行C代码搭起一个能启动、能响应键盘、能执行简单命令的微型操作系统内核。核心关键词就四个操作系统、linux-0.11、bochs、mysh。它们不是孤立的名词而是一条清晰的实践链路linux-0.11是我们解剖的“标本”它比现代Linux精简百倍却保留了进程调度、内存管理、文件系统等核心骨架bochs是我们的“无菌手术台”一个纯软件实现的x86模拟器不依赖物理硬件也不碰真实硬盘所有操作都在内存里安全复现mysh是我们亲手缝上的第一块“皮肤”一个极简的shell让你敲下ps就能看到当前进程敲fork就能亲眼见证一个新进程诞生而C语言就是这整台手术唯一的手术刀——没有C的封装糖衣没有Python的胶水便利只有指针、结构体、汇编嵌入和对内存地址赤裸裸的操控。我带过三届操作系统实验课最常听到的抱怨是“书上讲中断向量表我连中断发生时CPU到底跳到哪都不知道。” 这个实验的价值就在于把所有抽象概念钉死在可调试、可打断、可单步跟踪的代码行上。你不需要成为Linus但当你亲手让printk(Hello, Kernel!)在黑屏上闪出第一行字时那种对“程序如何真正开始运行”的顿悟是任何PPT和PDF都无法替代的。适合谁大二刚学完C指针和汇编基础的同学想摆脱“只会调库”困境、真正理解main()之前发生了什么的开发者还有那些被“从零手搓OS”口号吸引、但卡在第一步编译失败的探索者。它不承诺让你写出商业级OS但它保证你会彻底明白操作系统不是魔法它是一堆精心组织的C函数、一段段硬编码的汇编指令以及对CPU硬件特性的绝对服从。2. 实验设计逻辑为什么选linux-0.11而不是从头造轮子2.1 选择linux-0.11在“可理解性”与“真实性”之间找到黄金平衡点很多人一上来就想“从零开始”结果三天后卡在实模式到保护模式的GDT全局描述符表加载上怀疑人生。我试过自己写bootloader也试过直接啃Linux 5.x源码结论很明确真正的学习效率不在于起点有多“原始”而在于中间路径是否足够透明、足够短、足够可调试。linux-0.11完美契合这个标准。它发布于1991年代码量仅约1万行核心kernel目录下所有关键模块——boot/下的启动扇区、init/main.c的初始化流程、kernel/fork.c的进程创建、fs/read_write.c的文件读写——全部用C和少量内联汇编写成注释虽少但逻辑直白。更重要的是它的硬件抽象层极薄没有复杂的ACPI电源管理没有PCIe设备枚举甚至没有硬盘驱动只支持软盘镜像所有I/O都通过最原始的端口读写inb/outb或BIOS中断int 0x13完成。这意味着当你在kernel/asm.s里看到mov ax, #0x0000你不需要查Intel手册第几卷因为这就是实模式下设置数据段寄存器的唯一方式。相比之下现代Linux内核动辄千万行光是drivers/目录就比整个0.11还大且大量使用宏、模板、异步框架初学者看一眼struct device定义就可能迷失在层层嵌套的typedef里。而0.11的struct task_struct就明明白白躺在include/linux/sched.h里17个字段每个都对应一个进程的真实状态state运行/睡眠、pid进程号、counter时间片计数器、tss任务状态段。你改一行重新编译bochs里跑起来ps命令输出立刻变化——这种即时反馈是构建底层直觉的基石。这不是“简化版教学OS”它是真实历史中第一个能跑起来的Linux它的bug和设计妥协本身就是操作系统演化的活化石。2.2 bochs为什么不用QEMU或VirtualBox选模拟器本质是在“速度”和“可控性”之间做取舍。QEMU快但它是动态二进制翻译你单步调试时看到的可能是它生成的中间代码而非你写的原始汇编VirtualBox更接近真实硬件但一旦内核崩溃它直接蓝屏你连错误寄存器值都抓不到。bochs完全不同——它是一个100%用C写的解释型模拟器。它把每一条x86指令都拆解成C函数来模拟cpu_execute()函数里case 0x89:对应mov指令case 0x0f:对应各种扩展指令。这意味着当你在bochs里按ccontinue运行再按sstep单步你看到的永远是你源码里那一行mov %eax, %ds而不是一堆不可读的机器码。我带学生做实验时最常演示的场景是在kernel/system_call.s的sys_fork入口处打个断点然后在mysh里敲fork。bochs会停在那行pushl %ebp上此时你可以用info registers命令清清楚楚看到%eax里是2fork的系统调用号%esp指向用户栈顶%cs是0x0f用户代码段%ss是0x17用户数据段。这些寄存器状态就是操作系统切换上下文的全部秘密。bochs还自带强大的日志功能-log参数可以记录所有内存访问、中断触发、特权级切换。有一次一个学生总也搞不定execve系统调用我让他开-log结果日志里赫然显示page fault at 0x00000000——他忘了给新进程的argv[0]分配内存。这种级别的可观测性在其他模拟器里要么需要复杂插件要么根本不存在。当然bochs慢启动一个0.11内核要10秒但这10秒是你思考“CPU此刻在做什么”的黄金时间。真正的工程能力从来不是跑得最快而是错得最明白。2.3 mysh一个shell为何是理解OS的终极钥匙很多人觉得shell只是个命令行界面是个“应用层工具”。但在0.11里mysh通常指实验配套的极简shell如shell.c是内核与用户世界的唯一桥梁它的每一行代码都在诠释操作系统的核心契约。先看它的启动init/main.c里move_to_user_mode()之后fork()创建第一个子进程父进程execve(/bin/sh, argv, envp)子进程则execve(/etc/rc, argv, envp)。注意这里没有systemd没有init.d只有最原始的execve——它把磁盘上的/bin/sh文件一个ELF可执行文件读入内存解析其段信息设置好%cs/%ds段寄存器然后jmp *%eip跳转过去。mysh本身就是一个不断循环的C程序while(1) { printf(# ); readline(buf); parse(buf, argv); if (builtin(argv)) run_builtin(argv); else { pid fork(); if (pid 0) execve(argv[0], argv, envp); else waitpid(pid, status, 0); } }。这段代码浓缩了操作系统四大支柱fork体现进程抽象复制父进程地址空间execve体现程序加载覆盖当前地址空间waitpid体现进程同步父进程阻塞等待子进程结束readline和printf则依赖底层的sys_read/sys_write系统调用最终调用tty_write将字符发往串口或显示器。当你亲手修改mysh让它支持管道|你就必须深入kernel/pipe.c理解pipe()系统调用如何创建一对共享的内存缓冲区当你给它加上后台作业你就得研究kernel/signal.c里SIGCHLD信号的发送与捕获机制。mysh不是玩具它是操作系统功能的最小完备集。它逼你直面一个问题当用户敲下ls内核究竟做了什么答案不在man ls里而在sys_execve的137行代码里在fs/open.c的namei()路径解析里在mm/memory.c的do_mmap()内存映射里。mysh就是那把打开这扇门的钥匙。3. 核心细节拆解从编译到启动每一步都在对抗硬件的“任性”3.1 编译链为什么必须用gcc-2.95而不是你的VSCode里最新版GCC这是实验里第一个也是最大的坑。几乎所有人在第一次编译时都会遇到undefined reference to memset或segmentation fault。原因很简单linux-0.11的ABI应用二进制接口和现代GCC完全不兼容。0.11要求所有函数调用使用cdecl调用约定参数从右往左压栈由调用者清理栈而现代GCC默认用fastcall或更复杂的规则0.11的struct内存对齐是1字节#pragma pack(1)现代GCC默认是4或8字节最致命的是0.11内核代码里大量使用__attribute__((packed))和__volatile__这些老式语法在新版GCC里已被废弃或语义变更。gcc-2.95是Linus当年用的版本它生成的.o文件其符号表格式、重定位信息、甚至.text段的起始地址都与0.11的链接脚本kernel/Makefile严丝合缝。我实测过用gcc-4.8编译boot/bootsect.s生成的bootsect.o大小是128字节而用gcc-2.95编译大小是512字节——正好是软盘引导扇区的大小。这是因为gcc-2.95会自动在末尾填充0x00直到512字节而新版GCC不会。解决方法不是“降级系统GCC”而是用Docker隔离环境docker run -it --rm -v $(pwd):/work -w /work ubuntu:16.04 bash -c apt update apt install -y gcc-2.95 make。Ubuntu 16.04是最后一个官方支持gcc-2.95的发行版。另外as86汇编器和ld86链接器也必须用配套版本它们负责处理实模式下的16位段地址cs:ip而现代as/ld只认32位平坦内存模型。记住一个铁律编译工具链不是越新越好而是越“古”越准。就像修一辆1920年的福特T型车你得用那个年代的扳手而不是今天的电动扭矩扳手。3.2 启动流程从BIOS的0x7c00到内核的0x00001000地址空间的三次跃迁0.11的启动是一场精密的地址空间接力赛共分三棒第一棒BIOS与bootsect0x7c00PC加电后BIOS将软盘第一个扇区512字节读入内存物理地址0x7c00然后jmp 0x7c00。boot/bootsect.s的使命就是这一棒。它只有200多行汇编干三件事1把自己从0x7c00挪到0x90000为后续加载腾地方2把软盘第二扇区setup.s读到0x902003把软盘第三扇区开始的system模块即整个内核读到0x10000。这里的关键是0x7c00这个地址——它不是随意定的而是IBM PC BIOS的硬编码约定。如果你在bochs里用dump_memory命令查看0x7c00会看到0x7c00到0x7dff全是bootsect.s的机器码而0x7e00开始就是setup.s的数据。bootsect最后jmp 0x90200把控制权交给setup.s。第二棒setup.s与实模式准备0x90200setup.s的任务是“搭台”。它读取BIOS提供的硬件参数内存大小、硬盘参数然后最关键的一步关闭A20地址线。早期8086 CPU只有20根地址线最大寻址1MB0x00000-0x0fffff。80286有24根线但为了兼容BIOS默认屏蔽第21根线A20导致地址0x1000001MB以上被“折叠”回0x00000。setup.s通过向键盘控制器发送命令强制开启A20让CPU能真正访问1MB以上的内存。接着它把system模块从0x10000复制到0x00000物理内存0地址并jmp 0x0000进入第三棒。第三棒head.s与保护模式切换0x00000这才是真正的内核入口。head.s首先初始化GDT全局描述符表定义了四个段描述符0x0000空描述符、0x0008代码段基址0限长4GB特权级0、0x0010数据段同上、0x0018任务状态段TSS。然后执行lgdt gdt_desc加载GDTmov %cr0, %eax; orl $0x1, %eax; mov %eax, %cr0——这是开启保护模式的“魔法指令”把CR0寄存器的PEProtection Enable位置1。CPU立刻切换到32位模式%cs变成0x0008%eip从16位跳转到32位开始执行startup_32标签后的C代码。此时0x00000不再是物理地址而是线性地址通过分页机制映射到物理内存。head.s最后jmp init/main.c的start_kernel()整个操作系统就此苏醒。理解这三次地址跃迁你就明白了为什么bootsect里mov ax, #0x07c0而head.s里mov eax, #0x00000000——它们操作的是完全不同的地址空间层级。3.3 mysh的系统调用如何用一行int 0x80撬动整个内核mysh里最神奇的一行莫过于sys_write的调用asm volatile (int $0x80 : a (__res) : 0 (__NR_write), b ((long)(fd)), c ((long)(buf)), d ((long)(count)));。这行内联汇编就是用户态与内核态的“海关”。int 0x80触发软中断CPU立刻1保存当前%cs/%eip/%eflags到内核栈2从IDT中断描述符表第0x80项获取新的%cs/%eip指向system_call入口3切换到内核栈TSS里定义的ss0/sp04执行system_call汇编函数。system_call干的事很干净把用户传来的%eax系统调用号、%ebx第一个参数、%ecx第二个、%edx第三个压入内核栈然后call *sys_call_table(,%eax,4)——根据%eax的值跳转到sys_call_table数组里对应的函数。比如%eax4sys_write就跳到sys_write函数。sys_write再调用fs/read_write.c里的sys_write最终落到fs/buffer.c的bread/bwrite把数据写入缓冲区再由blk_drv/hd.c的hd_request()发往硬盘控制器。整个过程mysh只负责把参数准备好int 0x80一敲剩下的全由内核接管。这也是为什么mysh可以这么小——它不实现任何I/O只做“请求转发”。你可以在mysh里加一句printf(syscall number: %d\n, __NR_write);编译运行看到输出syscall number: 4这就是write在0.11系统调用表里的编号。理解int 0x80就理解了所有系统调用的统一入口也理解了为什么fork、execve、open这些看似迥异的操作底层都走同一套中断机制。4. 5分钟速通实操从零到# ps手把手带你跑通核心流程4.1 环境准备三步搭建纯净实验沙盒提示所有操作均在Ubuntu 20.04 LTS下验证Windows用户请使用WSL2macOS用户请用Docker Desktop。第一步获取并解压实验材料wget https://ftp.gnu.org/gnu/gcc/gcc-2.95.3/gcc-2.95.3.tar.gz tar -xzf gcc-2.95.3.tar.gz git clone https://github.com/mengning/linux-0.11.git cd linux-0.11注意不要用GitHub上某些魔改版务必用mengning维护的官方镜像它已预置bochs配置和mysh源码。第二步编译专用GCC工具链耗时约15分钟cd ../gcc-2.95.3 ./configure --prefix/opt/gcc-2.95 --enable-languagesc make -j$(nproc) sudo make install export PATH/opt/gcc-2.95/bin:$PATH验证gcc-2.95 --version应输出2.95.3。此步骤不可跳过否则后续所有编译必败。第三步安装bochs并配置sudo apt install bochs bochs-sdl # 将linux-0.11目录下的bochsrc.bak复制为bochsrc cp bochsrc.bak bochsrc # 修改bochsrc确保diskc: fileImage, cyl200, heads16, spt36 # 这行定义了虚拟软盘镜像Image文件需存在Image文件是编译生成的目前为空下一步就会生成。4.2 编译与镜像生成让代码变成可启动的“软盘”注意make命令必须在linux-0.11根目录执行且全程使用gcc-2.95。# 清理旧文件 make clean # 关键指定编译器 make CCgcc-2.95 # 此命令会依次执行 # 1. 编译bootsect.s - bootsect.o # 2. 编译setup.s - setup.o # 3. 编译所有kernel/*.c - kernel/*.o # 4. 链接生成system模块 # 5. 将bootsect/setup/system拼合成Image文件即软盘镜像成功标志根目录下出现Image文件大小为1.44MB1474560字节。用file Image检查应显示DOS/MBR boot sector。如果报错as86: command not found说明as86未安装sudo apt install dev86即可。4.3 启动与调试在bochs里见证内核诞生# 启动bochs bochs -f bochsrc -q-q参数跳过交互式配置直接运行。首次启动会弹出图形窗口显示黑屏和光标闪烁。此时内核已在后台运行但mysh尚未启动。按下CtrlAlt2进入bochs调试模式输入c # 运行至内核启动完成 s # 单步执行观察每条指令 info registers # 查看寄存器状态 x/10i $eip # 查看当前指令附近10条汇编当看到屏幕出现#提示符说明mysh已就绪。此时你已成功启动一个真实运行的Linux内核。4.4 mysh实战用五个命令穿透操作系统五层肌理命令1ps—— 直观感受进程抽象敲入ps输出类似PID STATE CNT PRI SIGNAL UID GID COMMAND 1 RUNNABLE 10 15 0 0 0 init 2 RUNNABLE 10 15 0 0 0 sh 3 SLEEPING 0 15 0 0 0 shPID1是init进程PID2是当前myshPID3是myshfork出的子进程用于执行ps本身。STATE列告诉你进程当前状态CNT是时间片剩余SIGNAL是待处理信号。这背后是kernel/sched.c里show_state()函数遍历task[]数组的结果。命令2fork—— 亲手制造一个进程敲fork屏幕一闪返回#。再敲ps你会发现多了一个PID4的SLEEPING进程。fork系统调用在kernel/fork.c里实现它copy_process()分配新task_struct复制父进程的页表、文件描述符、信号处理函数最后wake_up_process()唤醒新进程。fork返回两次父进程得子PID子进程得0——这是fork最反直觉也最精妙的设计。命令3cat /proc/version—— 理解/proc虚拟文件系统/proc不是真实磁盘文件而是内核在内存中动态生成的“窗口”。cat /proc/version会触发fs/proc/base.c里的proc_version_read()它直接拼接字符串Linux version 0.11 (rootlocalhost) (gcc version 2.95.3) #1 Mon Jan 1 00:00:00 CST 1991并返回。这证明内核可以“假装”有文件系统只为方便用户查询状态。命令4kill 2—— 感受信号机制的威力kill 2向myshPID2发送SIGKILL。kernel/signal.c里do_signal()捕获该信号执行force_sig()最终调用do_exit()终止进程。你会发现#提示符消失bochs窗口回到黑屏。此时ps已无输出——进程被彻底回收。信号是进程间异步通信的基石。命令5exec /bin/date—— 见证程序替换的瞬间exec不创建新进程而是用/bin/date程序完全替换当前mysh进程的内存空间。kernel/exec.c里do_execve()先free_page_tables()释放原进程页表再read_executable()读取date的ELF头load_aout_binary()将其代码段、数据段加载到内存最后start_thread()设置好%eip指向date的_start。敲完exec /bin/date屏幕上立刻打印当前日期时间然后bochs退出——因为date执行完就exit()了没有mysh再接管控制台。这就是exec的“以旧换新”。5. 常见问题与排查技巧实录那些让我熬夜到凌晨三点的坑5.1 编译阶段undefined reference to memcpy类错误这是新手最高频问题90%源于工具链不匹配。典型报错kernel/kernel.o: In function copy_mem: init/main.c:123: undefined reference to memcpy排查思路nm kernel/kernel.o | grep memcpy—— 查看目标文件里是否有memcpy符号。若无说明编译时没链接libc。grep -r memcpy kernel/—— 发现kernel/ksyms.c里有EXPORT_SYMBOL(memcpy)但lib/string.c里memcpy函数被#ifdef __KERNEL__包裹而gcc-2.95默认不定义此宏。终极解法在Makefile里找到CFLAGS行添加-D__KERNEL__CFLAGS -Wall -O -fstrength-reduce -fomit-frame-pointer -fcombine-regs \ -mstring-insns -nostdinc -I$(INC) -D__KERNEL__同时确保lib/string.c顶部有#include linux/config.h且config.h里定义了CONFIG_ARCH_ISA。这是0.11特有的“条件编译开关”漏掉任何一个memcpy就不会被编译进去。5.2 启动阶段bochs黑屏不动或报PANIC: read beyond end of image黑屏不动通常是bootsect没正确加载setup.s或system。用bochs调试bochs -f bochsrc -q # 启动后按CtrlAlt2进入debugger u 0x7c00 100 # 反汇编0x7c00开始的100条指令确认是否有mov $0x90200, %ax x/10xw 0x90200 # 查看0x90200处内存应有setup.s的机器码如0x0000, 0x0000...若0x90200全是0x00说明bootsect的read指令失败。检查bochsrc里floppy_boots参数是否设为1且Image文件路径正确。PANIC: read beyond end of image则表明Image文件损坏或大小不对重新make生成。5.3 运行阶段mysh启动后立即Segmentation Fault这通常发生在mysh尝试execve时。根本原因是/bin/sh文件不存在或格式错误。0.11的/bin/sh是一个静态链接的ELF文件必须用gcc-2.95编译cd tools gcc-2.95 -static -o sh sh.c # -static是关键避免动态链接库依赖 strip sh # 去除调试符号减小体积 cp sh ../ImageRoot/bin/sh # ImageRoot是制作软盘镜像的根目录然后重新make生成Image。strip命令必不可少因为0.11的execve无法处理带调试段的ELF文件。5.4 调试阶段bochs里s单步却跳进一片空白内存这是bochs的“指令缓存”陷阱。bochs为提升速度会缓存已解码的指令。当你修改了源码并重新编译但没重启bochs它仍在执行旧的缓存指令。铁律每次修改代码、重新make后必须CtrlC退出bochs再bochs -f bochsrc -q全新启动。或者在debugger里输入lblist breakpoints确认断点位置再rrun前用flush命令清空指令缓存。5.5 经验总结三个保命技巧永远用git diff对比在linux-0.11目录下git status随时查看你改了哪些文件。git diff include/linux/sched.h能快速定位task_struct改动是否影响进程调度。善用objdump反汇编objdump -d kernel/system_call.o system_call.asm把编译后的目标文件转成汇编对照源码看int 0x80后CPU到底跳去了哪。printf是你的朋友但要谨慎在kernel/printk.c里加printk(DEBUG: pid%d\n, current-pid);但记得注释掉所有printk调用后再make否则内核会因无限递归而崩溃——printk本身会调用sys_write而sys_write又可能触发printk。我在实验室墙上贴着一张纸上面写着“操作系统没有玄学只有地址、寄存器和时序。每一次panic都是硬件在给你上课。” 这个实验的价值不在于你最终跑出了多少个命令而在于你敢于面对segmentation fault时不再慌张地百度错误码而是打开bochs输入info registers盯着%eip和%esp冷静地问自己“此刻CPU的指令指针指向哪它的栈顶在哪里这个地址是我代码里的还是内存越界了” 当你能这样思考时你已经站在了操作系统工程师的门槛上。