从零写OS(十三):Shell,把所有东西串起来

前十二章,内核从一个 512 字节的 Bootloader 长成了有进程调度、文件系统、VFS 的微型系统。但用户还没有办法和它交互。 这一章做 Shell——一个可以敲命令操作文件的命令行。 先把概念搞清楚 Shell 是普通进程 Shell 不是内核的一部分。它是一个运行在用户态的普通进程,通过系统调用和内核打交道,和 ls、cat 这些程序的地位完全一样。 真实系统里,Shell 用 fork + exec 启动外部命令。我们这里简化:四条命令直接内嵌在 Shell 进程里,不做 fork/exec。 tokenize:命令行的第一步 用户输入一行字符串,Shell 要把它拆成命令名和参数: "write hello.txt world" ↓ tokenize argv[0] = "write" argv[1] = "hello.txt" argv[2] = "world" 做法是遍历字符串,遇到空格就写入 \0 截断,记录每段的起始地址。不需要任何库函数,20 行搞定。 串口 I/O 键盘输入通过串口读取(x86 端口 0x3F8)。QEMU 的 -serial mon:stdio 把宿主机终端直接映射到串口,你在终端敲的每个字符都会被 in 指令读到。 实现了什么 四条命令: 命令 功能 write <文件> <内容> 创建文件并写入内容 read <文件> 读取文件内容并打印 ls 列出根目录所有文件 help 打印帮助 关键代码 readline:读一行,支持 backspace static int readline(char *buf, int maxlen) { int i = 0; while (i < maxlen - 1) { char c = serial_getchar(); if (c == '\r' || c == '\n') { serial_print("\r\n"); break; } if (c == 127 || c == '\b') { if (i > 0) { i--; serial_print("\b \b"); } continue; } buf[i++] = c; char echo[2] = {c, 0}; serial_print(echo); // 回显给用户 } buf[i] = '\0'; return i; } \b \b 是终端删除字符的标准做法:退格、打空格覆盖、再退格。 ...

May 6, 2026 · 2 min · 大飞

从零写OS(十二):VFS,让系统调用不认识具体文件系统

上一章我们写了一个 SimpleFS,可以创建文件、读写内容。但现在有个问题:open() 系统调用直接调的是 fs_create、fs_read 这些 SimpleFS 专属函数。 如果哪天要支持 FAT32,就得去改系统调用的代码。这显然不对。 这一章加一层 VFS(Virtual File System,虚拟文件系统),把系统调用和具体文件系统隔开。 先把概念搞清楚 间接层解耦 软件里有句老话:任何问题都可以通过加一层间接层解决。VFS 就是这层间接层。 用户进程 ↓ open / read / write VFS(统一接口) ↓ ↓ SimpleFS FAT32 系统调用只跟 VFS 说话,VFS 再转发给具体 FS。新增一种文件系统,只需要实现 VFS 要求的那几个函数,不动系统调用层。 file_operations:C 语言的多态 VFS 要求每种文件系统提供一张函数指针表: typedef struct { int (*read) (...); int (*write)(...); uint32_t (*lookup)(...); uint32_t (*create)(...); } FileOps; VFS 调 fops->read(...) 时,实际执行的是哪个函数,取决于挂载时注册的是哪张表。这是 C 语言实现"多态"的标准手法,Linux 内核里到处都是这个模式。 vnode:VFS 的通用 inode 具体 FS 有自己的 inode,VFS 层包一层 vnode,里面放着具体 FS 的 inode 号和对应的函数指针表。对上层完全屏蔽了底层差异。 ...

May 6, 2026 · 2 min · 大飞

从零写OS(十一):文件系统,从磁盘到文件名

到目前为止,内核能跑进程、能做系统调用,但所有数据都在内存里——进程一死,什么都没了。 这一章做文件系统:把数据写到"磁盘"(我们用内存模拟),下次还能读回来。 先把概念搞清楚 动手之前,先理解五件事。 为什么需要文件系统 磁盘本质上就是一个大字节数组。没有文件系统,你只能说"读第 1234 字节",没法说"读 /etc/passwd"。 文件系统做的事就是在这个字节数组上建立一套命名和组织规则,让你可以用路径找到数据,而不是手动记偏移量。 inode:文件名和内容分离 Unix 最重要的设计之一:inode 描述文件内容,目录存文件名,两者分开。 inode 记录的是"这个文件是什么"——大小、权限、数据在磁盘哪几块——但不存文件名。文件名只是一个指向 inode 的标签,存在目录里。 这意味着同一个 inode 可以被多个名字指向,这就是硬链接。重命名文件也不需要移动任何数据,只改目录项。 超级块:文件系统的自我描述 挂载一块磁盘时,内核第一件事是读超级块。超级块告诉内核这个文件系统的结构:inode 区从哪里开始、数据块从哪里开始、总共多少块、还有多少空闲。 超级块损坏 = 整个文件系统不可读。所以 ext4 会在磁盘多个位置备份超级块。 目录是普通文件 目录没有什么神奇的内部结构,它就是一个普通文件,内容是一张表:文件名 → inode 号。 ls 的本质是读这张表然后打印。路径解析 /a/b/c 就是:读根目录找 a 的 inode → 把 a 当目录读,找 b 的 inode → 把 b 当目录读,找 c 的 inode。 空闲管理:位图 磁盘上哪些块被占用、哪些空闲,用位图记录——1 个 bit 对应 1 个块,0 表示空闲,1 表示已用。分配空间就是找第一个 0 位翻成 1。 这五个概念搞清楚,下面的代码就是它们的直接翻译。 文件系统解决什么问题 内存是易失的,文件系统负责两件事: ...

May 6, 2026 · 6 min · 大飞

从零写OS(十):系统调用,用户和内核的边界

前几章的"进程"其实是假的——它们直接跑在内核态,和内核同等权限,可以随意读写任何内存、操作任何硬件。 真实的操作系统里,用户程序跑在用户态(Ring 3),权限受限,不能直接操作硬件。需要内核帮忙时,必须通过系统调用这扇受控的门进入内核,做完事再回去。 这一章实现 syscall / sysret:用户态和内核态之间最快速的切换机制。 两种特权级 x86-64 有 4 个特权级(Ring 0~3),操作系统只用两个: Ring 0(内核态):可以执行任何指令,访问任何地址,操作 CR3、MSR 等特权寄存器 Ring 3(用户态):不能执行特权指令,访问不属于自己的内存会触发 #GP 或 Page Fault CS 段寄存器的低 2 位(CPL,Current Privilege Level)表示当前特权级。syscall 指令把 CPL 从 3 切到 0,sysret 把 CPL 从 0 切回 3。 为什么用 syscall 而不是中断 早期 Linux 用 int 0x80 触发系统调用——软件中断,要保存完整的中断栈帧,走 IDT 查表,开销大。 syscall / sysret 是专门为系统调用设计的快速路径:不走 IDT,入口地址直接写在 MSR 里,省去了大量压栈操作。现代 x86-64 系统全部用这对指令。 配置 MSR MSR(Model Specific Register,型号特定寄存器,CPU 内部的一组控制寄存器,用 rdmsr / wrmsr 访问)控制 syscall 的行为。需要配置 4 个: ...

May 6, 2026 · 3 min · 大飞

从零写OS(九):进程调度,CPU 的分时复用

到目前为止,内核一直是单线程跑到底——一件事没做完,别的什么都不能干。 这一章让内核同时跑多个进程。每隔一段时间,时钟中断打断当前进程,把 CPU 交给下一个,轮流执行。这就是多任务的核心机制。 上下文是什么 进程被打断之后,下次恢复时要从断点继续执行,不能出错。这意味着必须把 CPU 的状态完整保存下来——这份状态就叫上下文(Context)。 x86-64 ABI 规定了 callee-saved 寄存器(被调用者负责保存的寄存器,C 函数调用约定中,这些寄存器的值在函数调用前后必须保持不变):rbx、rbp、r12~r15。加上程序指针 rip 和栈指针 rsp,这些就是切换时需要保存和恢复的全部。 typedef struct { uint64_t r15, r14, r13, r12; uint64_t rbx, rbp; uint64_t rip; // 下次从哪里继续执行 uint64_t rsp; // 栈在哪里 } context_t; 每个进程一份 context,切换时:把当前进程的寄存器存入 ctx,再把下一个进程 ctx 里的值写回寄存器——CPU 就"变身"成另一个进程了。 进程结构 #define MAX_PROCS 8 #define STACK_SIZE 8192 // 每个进程 8KB 内核栈 typedef enum { PROC_UNUSED = 0, PROC_READY, PROC_RUNNING, } proc_state_t; typedef struct { context_t ctx; uint8_t *stack; proc_state_t state; uint32_t pid; } process_t; static process_t procs[MAX_PROCS]; static int current_proc = 0; 状态机很简单:UNUSED(槽位空闲)→ READY(等待调度)→ RUNNING(正在跑)→ 被打断后回到 READY。 ...

May 6, 2026 · 3 min · 大飞

从零写OS(八):堆分配器,内核的 malloc

PMM 负责整页分配,每次给你 4KB。但内核里经常需要分配几十、几百字节的小块——存一个结构体、一条字符串。每次都要一整页,太浪费。 这一章实现 kmalloc / kfree:按需分配任意大小的内存块,用完归还,可以复用。 设计:空闲链表 最经典的堆实现:空闲链表(Free List)。 在堆空间里,每块内存前面加一个块头(header)记录元数据,所有块串成双向链表: typedef struct block { uint64_t size; // 这块数据区的大小(不含 header 本身) uint8_t is_free; // 1=空闲,0=已分配 struct block *next; // 链表后继 struct block *prev; // 链表前驱 } block_t; 内存布局长这样: [block_t header | ← size → 数据区 ][block_t header | ← size → 数据区 ] ... ↑ ↑ kmalloc 返回这个指针之前的地址 下一块 header kmalloc 返回的指针指向数据区起始,而不是 header。kfree 时把指针往前偏移 sizeof(block_t),找回 header,再改 is_free = 1。 堆在哪里 堆从虚拟地址 0x30000 开始,链表头指针存在 0x29000(一个固定位置,方便任何地方都能找到它): #define HEAP_START 0x30000ULL #define HEAP_HEAD_ADDR 0x29000ULL #define heap_head (*(block_t **)HEAP_HEAD_ADDR) 初始化时预先映射 4 页(16KB)作为起始堆空间,不够了再动态扩展。 ...

May 6, 2026 · 3 min · 大飞

从零写OS(七):虚拟内存,给每个程序一个假的地址空间

上一章内核能分配物理页了,但用的全是物理地址。 物理地址有个问题:全局唯一,谁都能访问。进程 A 如果知道进程 B 的物理地址,直接就能读写它的数据。这不行。 解决方案是虚拟内存:每个程序看到的地址都是"假的",CPU 访问时由硬件自动翻译成真实的物理地址。程序互相隔离,谁也看不见谁。 地址翻译的硬件机制:四级页表 x86-64 的地址翻译靠 MMU(Memory Management Unit,内存管理单元,CPU 内部硬件,负责把虚拟地址翻译成物理地址)完成,翻译规则写在页表里,页表的根地址放在 CR3 寄存器里。 一个 64 位虚拟地址被这样拆开: 63 48 47 39 38 30 29 21 20 12 11 0 [ 符号扩展 | PML4_IDX | PDPT_IDX | PD_IDX | PT_IDX | 页内偏移 ] 9 bit 9 bit 9 bit 9 bit 12 bit 翻译过程是四级查表: CR3 → PML4[PML4_IDX] → PDPT[PDPT_IDX] → PD[PD_IDX] → PT[PT_IDX] → 物理页帧 每级页表是一个 512 项的数组,每项 8 字节,刚好占满一个 4KB 页。每项的低 12 位是 flags,高位是下一级页表(或最终物理页)的地址。 ...

May 6, 2026 · 2 min · 大飞

从零写OS(六):内存管理,知道自己有多少地可种

内核跑起来之后,第一个绕不开的问题就是内存。 你不知道这台机器有多少内存,哪些地址段可以用,哪些是硬件保留的。如果随便往一个地址写数据,轻则数据损坏,重则触发异常直接重启。 这一章解决一件事:让内核知道内存的全貌,然后有序地分配和回收物理页。 先问 BIOS:你有多少内存? 我们用 E820(BIOS INT 0x15 AX=E820h,一种标准接口,用来查询物理内存的分布和类型)来探测内存。 这事必须在实模式下做——切换到长模式之后就再也访问不到 BIOS 服务了。所以探测代码放在 boot.asm 里,在进入长模式之前完成。 每次调用 INT 0x15,BIOS 填一条 24 字节的记录: base (8字节) — 这段内存的起始物理地址 length (8字节) — 这段内存的长度(字节) type (4字节) — 类型:1=可用,2=保留,其它=别动 ACPI (4字节) — 扩展属性,一般忽略 循环调用直到 EBX 变成 0,表示枚举完毕。所有记录写入固定地址 0x8000,第一个 4 字节存条目数量。 mov di, MMAP_ADDR + 4 ; 从 0x8004 开始存条目 xor ebx, ebx xor bp, bp ; bp 记条目数量 .e820_loop: mov eax, 0xE820 mov ecx, 24 mov edx, 0x534D4150 ; "SMAP" 魔数,BIOS 验证用 int 0x15 jc .e820_done ; CF=1 表示出错或结束 inc bp add di, 24 test ebx, ebx jz .e820_done ; ebx=0 表示最后一条 jmp .e820_loop .e820_done: mov dword [MMAP_ADDR], ebp ; 把条目数量写到 0x8000 QEMU 上跑出来的内存地图一般长这样: ...

May 6, 2026 · 3 min · 大飞

从零写OS(五):中断,内核的神经系统

键盘按下一个键,CPU 是怎么知道的? 不是轮询——CPU 不会没事一直问"键盘有没有按键"。而是靠中断(Interrupt,硬件或软件触发的信号,让 CPU 暂停当前任务去处理紧急事件)。发生了什么事,硬件主动通知 CPU,CPU 暂停手头的事,跳去处理,处理完再回来。 这一章要让内核有能力响应中断。没有这个机制,内核什么都干不了。 中断的分类 x86 的 256 个中断号分三段: 范围 类型 例子 0~31 CPU 异常 除零、缺页、非法指令 32~47 硬件中断 时钟、键盘 48~255 软件中断 系统调用 CPU 异常是 CPU 自己触发的,比如访问了不该访问的内存,CPU 就抛一个缺页异常(Page Fault,访问未映射的虚拟地址时 CPU 触发的异常,操作系统据此做内存按需分配)。 硬件中断是外设触发的,经过 PIC(Programmable Interrupt Controller,可编程中断控制器,负责管理外设中断请求并转发给 CPU)发给 CPU。 IDT:中断的路由表 要处理中断,首先要告诉 CPU:每种中断发生时,去哪个函数处理? 这张路由表叫 IDT(Interrupt Descriptor Table,中断描述符表,256 项,每项对应一个中断号及其处理函数地址)。跟之前的 GDT 一样,是内存里的一张表,用 lidt 指令告诉 CPU 位置。 每个表项里存的是处理函数的地址,还有权限信息。CPU 一旦收到中断,就查这张表,跳到对应函数去执行。 中断处理的细节 CPU 进入中断处理函数之前,会自动把当前的执行状态压栈:rip(下一条指令地址)、cs、rflags、rsp、ss。处理完之后用 iretq(64 位中断返回指令)恢复状态,接着跑。 有一个坑:某些异常 CPU 会自动压入错误码(比如缺页异常),某些不会(比如除零)。为了让处理函数收到的栈结构一致,对没有错误码的异常,要手动压一个 0 占位。这件事用汇编宏做,每种异常一行,干净利落。 第一个测试:故意除以零 IDT 装好之后,最简单的验证方式是故意触发一个异常,看内核能不能捕获到: ...

May 6, 2026 · 1 min · 大飞

从零写OS(四):汇编交棒给 C,最关键的一跳

前三章全是汇编。到这里,CPU 已经跑在 64 位长模式下了,但还是什么都做不了。 这一章要完成最关键的一步:把控制权从汇编交给 C。之后的内核全部用 C 写,汇编只在最必要的地方出现。 磁盘要怎么布局 512 字节的 Bootloader 放不下 C 内核,所以内核单独编译成一个二进制文件,放在磁盘的后续扇区(扇区,Sector,磁盘读写的最小单位,传统上每个 512 字节): 扇区 0(512B):Bootloader 扇区 1~8 :内核二进制 Bootloader 启动后,用 BIOS 的磁盘读取中断把扇区 1 开始的内容读到内存地址 0x10000,然后跳过去执行。 一个容易踩的坑:内核入口不在 0x10000 把内核编译好、加载到 0x10000,然后 call 0x10000——看起来没问题,实际上很可能直接崩。 用 objdump(反汇编工具,可以查看二进制文件的结构和函数地址)一查才发现:kernel_main 的实际地址是 0x10109,不是 0x10000。0x10000 开头是另一个函数 outb,跳过去执行完全是错的。 解决方案:加一个 entry.asm 作为内核最开头,内容只有一行:跳转到 kernel_main。然后在链接脚本(Linker Script,告诉链接器如何拼装各个目标文件、每段放在哪个地址)里把 entry.o 排在最前面,保证 _start 恰好落在 0x10000。 这样 Bootloader 跳到 0x10000,第一条指令就是 jmp kernel_main,稳了。 还有一个坑:栈 进入 64 位模式之后,rsp(栈顶指针寄存器)是未初始化的。C 函数一用栈就崩。 在跳入内核之前,要先设好栈: mov rsp, 0x90000 选 0x90000 是因为这块内存没有被占用,往下增长也不会覆盖内核代码。 内核编译参数 内核不能用普通方式编译,要加几个关键选项: ...

May 6, 2026 · 1 min · 大飞
京ICP备14031575号-3