500行C代码手写Linux内核:linux-0.11+bochs+mysh实战
2026/9/18 3:16:57 网站建设 项目流程

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里按c(continue)运行,再按s(step)单步,你看到的永远是你源码里那一行mov %eax, %ds,而不是一堆不可读的机器码。我带学生做实验时,最常演示的场景是:在kernel/system_call.ssys_fork入口处打个断点,然后在mysh里敲forkbochs会停在那行pushl %ebp上,此时你可以用info registers命令,清清楚楚看到%eax里是2(fork的系统调用号),%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体现进程同步(父进程阻塞等待子进程结束);readlineprintf则依赖底层的sys_read/sys_write系统调用,最终调用tty_write将字符发往串口或显示器。当你亲手修改mysh,让它支持管道|,你就必须深入kernel/pipe.c,理解pipe()系统调用如何创建一对共享的内存缓冲区;当你给它加上后台作业&,你就得研究kernel/signal.cSIGCHLD信号的发送与捕获机制。mysh不是玩具,它是操作系统功能的最小完备集。它逼你直面一个问题:当用户敲下ls,内核究竟做了什么?答案不在man ls里,而在sys_execve的137行代码里,在fs/open.cnamei()路径解析里,在mm/memory.cdo_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与bootsect(0x7c00)
PC加电后,BIOS将软盘第一个扇区(512字节)读入内存物理地址0x7c00,然后jmp 0x7c00boot/bootsect.s的使命就是这一棒。它只有200多行汇编,干三件事:1)把自己从0x7c00挪到0x90000(为后续加载腾地方);2)把软盘第二扇区(setup.s)读到0x90200;3)把软盘第三扇区开始的system模块(即整个内核)读到0x10000。这里的关键是0x7c00这个地址——它不是随意定的,而是IBM PC BIOS的硬编码约定。如果你在bochs里用dump_memory命令查看0x7c00,会看到0x7c000x7dff全是bootsect.s的机器码,而0x7e00开始就是setup.s的数据。bootsect最后jmp 0x90200,把控制权交给setup.s

第二棒:setup.s与实模式准备(0x90200)
setup.s的任务是“搭台”。它读取BIOS提供的硬件参数(内存大小、硬盘参数),然后最关键的一步:关闭A20地址线。早期8086 CPU只有20根地址线,最大寻址1MB(0x00000-0x0fffff)。80286有24根线,但为了兼容,BIOS默认屏蔽第21根线(A20),导致地址0x100000(1MB)以上被“折叠”回0x00000setup.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加载GDT,mov %cr0, %eax; orl $0x1, %eax; mov %eax, %cr0——这是开启保护模式的“魔法指令”,把CR0寄存器的PE(Protection Enable)位置1。CPU立刻切换到32位模式,%cs变成0x0008%eip从16位跳转到32位,开始执行startup_32标签后的C代码。此时,0x00000不再是物理地址,而是线性地址,通过分页机制映射到物理内存。head.s最后jmp init/main.cstart_kernel(),整个操作系统,就此苏醒。理解这三次地址跃迁,你就明白了为什么bootsectmov ax, #0x07c0,而head.smov 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/sp0);4)执行system_call汇编函数。system_call干的事很干净:把用户传来的%eax(系统调用号)、%ebx(第一个参数)、%ecx(第二个)、%edx(第三个)压入内核栈,然后call *sys_call_table(,%eax,4)——根据%eax的值,跳转到sys_call_table数组里对应的函数。比如%eax=4sys_write),就跳到sys_write函数。sys_write再调用fs/read_write.c里的sys_write,最终落到fs/buffer.cbread/bwrite,把数据写入缓冲区,再由blk_drv/hd.chd_request()发往硬盘控制器。整个过程,mysh只负责把参数准备好,int 0x80一敲,剩下的全由内核接管。这也是为什么mysh可以这么小——它不实现任何I/O,只做“请求转发”。你可以在mysh里加一句printf("syscall number: %d\n", __NR_write);,编译运行,看到输出syscall number: 4,这就是write在0.11系统调用表里的编号。理解int 0x80,就理解了所有系统调用的统一入口,也理解了为什么forkexecveopen这些看似迥异的操作,底层都走同一套中断机制。

4. 5分钟速通实操:从零到# ps,手把手带你跑通核心流程

4.1 环境准备:三步搭建纯净实验沙盒

提示:所有操作均在Ubuntu 20.04 LTS下验证,Windows用户请使用WSL2,macOS用户请用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-languages=c 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: file="Image", cyl=200, heads=16, spt=36 # 这行定义了虚拟软盘镜像,Image文件需存在

Image文件是编译生成的,目前为空,下一步就会生成。

4.2 编译与镜像生成:让代码变成可启动的“软盘”

注意:make命令必须在linux-0.11根目录执行,且全程使用gcc-2.95

# 清理旧文件 make clean # 关键!指定编译器 make CC=gcc-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.44MB(1474560字节)。用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尚未启动。按下Ctrl+Alt+2,进入bochs调试模式,输入:

c # 运行至内核启动完成 s # 单步执行,观察每条指令 info registers # 查看寄存器状态 x/10i $eip # 查看当前指令附近10条汇编

当看到屏幕出现#提示符,说明mysh已就绪。此时,你已成功启动一个真实运行的Linux内核。

4.4 mysh实战:用五个命令,穿透操作系统五层肌理

命令1:ps—— 直观感受进程抽象
敲入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 sh

PID=1init进程,PID=2是当前myshPID=3myshfork出的子进程(用于执行ps本身)。STATE列告诉你进程当前状态,CNT是时间片剩余,SIGNAL是待处理信号。这背后,是kernel/sched.cshow_state()函数遍历task[]数组的结果。

命令2:fork—— 亲手制造一个进程
fork,屏幕一闪,返回#。再敲ps,你会发现多了一个PID=4SLEEPING进程。fork系统调用在kernel/fork.c里实现:它copy_process(),分配新task_struct,复制父进程的页表、文件描述符、信号处理函数,最后wake_up_process()唤醒新进程。fork返回两次:父进程得子PID,子进程得0——这是fork最反直觉也最精妙的设计。

命令3:cat /proc/version—— 理解/proc虚拟文件系统
/proc不是真实磁盘文件,而是内核在内存中动态生成的“窗口”。cat /proc/version会触发fs/proc/base.c里的proc_version_read(),它直接拼接字符串"Linux version 0.11 (root@localhost) (gcc version 2.95.3) #1 Mon Jan 1 00:00:00 CST 1991"并返回。这证明,内核可以“假装”有文件系统,只为方便用户查询状态。

命令4:kill 2—— 感受信号机制的威力
kill 2mysh(PID=2)发送SIGKILLkernel/signal.cdo_signal()捕获该信号,执行force_sig(),最终调用do_exit()终止进程。你会发现#提示符消失,bochs窗口回到黑屏。此时ps已无输出——进程被彻底回收。信号,是进程间异步通信的基石。

命令5:exec /bin/date—— 见证程序替换的瞬间
exec不创建新进程,而是用/bin/date程序完全替换当前mysh进程的内存空间。kernel/exec.cdo_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'

排查思路

  1. nm kernel/kernel.o | grep memcpy—— 查看目标文件里是否有memcpy符号。若无,说明编译时没链接libc
  2. grep -r "memcpy" kernel/—— 发现kernel/ksyms.c里有EXPORT_SYMBOL(memcpy),但lib/string.cmemcpy函数被#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.ssystem。用bochs调试:

bochs -f bochsrc -q # 启动后按Ctrl+Alt+2进入debugger u 0x7c00 100 # 反汇编0x7c00开始的100条指令,确认是否有mov $0x90200, %ax x/10xw 0x90200 # 查看0x90200处内存,应有setup.s的机器码(如0x0000, 0x0000...)

0x90200全是0x00,说明bootsectread指令失败。检查bochsrcfloppy_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生成Imagestrip命令必不可少,因为0.11的execve无法处理带调试段的ELF文件。

5.4 调试阶段:bochss单步,却跳进一片空白内存

这是bochs的“指令缓存”陷阱。bochs为提升速度,会缓存已解码的指令。当你修改了源码并重新编译,但没重启bochs,它仍在执行旧的缓存指令。铁律:每次修改代码、重新make后,必须Ctrl+C退出bochs,再bochs -f bochsrc -q全新启动。或者,在debugger里输入lb(list breakpoints)确认断点位置,再r(run)前用flush命令清空指令缓存。

5.5 经验总结:三个保命技巧

  1. 永远用git diff对比:在linux-0.11目录下,git status随时查看你改了哪些文件。git diff include/linux/sched.h能快速定位task_struct改动是否影响进程调度。
  2. 善用objdump反汇编objdump -d kernel/system_call.o > system_call.asm,把编译后的目标文件转成汇编,对照源码看int 0x80后CPU到底跳去了哪。
  3. 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的指令指针指向哪?它的栈顶在哪里?这个地址,是我代码里的,还是内存越界了?” 当你能这样思考时,你已经站在了操作系统工程师的门槛上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询