从零写OS(二十一):文件描述符 fd

前几章的用户程序如果想读文件,得直接传 inode 号给内核——read(ino, offset, buf, len)。这意味着用户程序要自己记当前读到哪了,而且根本不知道文件名,只有一个数字。 这一章引入 文件描述符(fd),让用户程序用熟悉的方式操作文件: int fd = open("hello.txt"); int n = read(fd, buf, 64); close(fd); fd 是什么 fd 是一个进程内的整数,通常从 0 开始分配(0=stdin,1=stdout,2=stderr,之后是普通文件)。 它背后有三层: 进程 fd_table[] 内核 open_file 磁盘 fd=3 ──────────→ { ino=42, offset=0 } ──→ hello.txt fd=4 ──────────→ { ino=43, offset=0 } ──→ readme.txt fd 本身只是下标,真正存 offset 的是内核里的"打开文件"结构。这样的好处: offset 由内核自动推进,用户不用管 fork 后父子可以共享同一个打开文件(共享 offset) 文件、管道、设备用同一套接口,背后实现不同 实现:每个进程一张 fd 表 在 process_t 里加一个数组: typedef struct { // ... int32_t fd_table[PROC_MAX_FD]; // fd → vfs_fd,-1 表示空槽 } process_t; fd_table[fd] 存的是 VFS 层的内部 fd(VFile 数组下标)。 ...

May 14, 2026 · 2 min · 大飞

从零写OS(二十):wait / exit 完整语义

上一章实现了 exec(),用户程序能跑起来了。但进程退出时只是把进程表项清掉,父进程拿不到退出码,也没有办法等待。这一章实现完整的 exit / wait 语义。 为什么进程退出后不能直接消失 直觉上,进程退出就应该立刻清掉进程表项。但这里有个问题:父进程怎么拿到子进程的退出码? 如果进程表项直接清掉,退出码就丢了。父进程调用 wait() 的时机可能比子进程 exit() 晚,也可能早——无论哪种情况,父进程都需要能读到那个退出码。 Linux 的解法是引入**僵尸(zombie)**状态:进程 exit() 后不立即消失,保留退出码,等父进程 wait() 读取后才彻底释放。 这就是为什么 ps 输出里偶尔会出现状态为 Z 的进程——它已经退出了,只是在等父进程来"收尸"。 进程状态机的变化 原来进程只有三个状态:UNUSED、READY、RUNNING。这一章加两个: UNUSED → READY → RUNNING ↓ exit() ZOMBIE ← 已退出,等父进程 wait ↑ 唤醒 BLOCKED ← 父进程 wait 时挂起 调度器只挑 READY 的进程运行,ZOMBIE 和 BLOCKED 自然就不会被调度到,不需要改调度器的逻辑。 exit 和 wait 的实现思路 exit 做三件事: 把退出码存到进程表项,状态改为 ZOMBIE 如果父进程正在 BLOCKED 等待,把它改回 READY(唤醒) 切回内核栈和内核页表,hlt 循环等待被回收 wait 做的事: 扫描进程表,找有没有自己的子进程且状态是 ZOMBIE 找到了:读退出码,把它设为 UNUSED,返回退出码 没找到但有活着的子进程:挂起等待(ring3)或返回 -2 让调用方重试(ring0) 没有子进程:返回 -1 parent_pid 需要在 exec / fork 时记下来,这样 exit 才知道该唤醒谁,wait 才知道哪些进程是自己的子进程。 ...

May 13, 2026 · 2 min · 大飞

从零写OS(十九):exec 与用户程序

之前所有代码都跑在内核态(ring0)。这一章第一次让用户程序在 ring3 里运行——加载 ELF 文件,跳到用户态执行,用户程序通过 syscall 和内核通信。 这是操作系统最核心的一个边界:内核和用户程序之间的隔离。 exec 做什么 exec 的语义是"用一个新程序替换当前进程": 从文件系统读 ELF 文件 解析 ELF header,找到各个段的加载地址 创建新的用户页表,把各段加载进去 分配用户栈 设置进程的 rip = 入口地址,rsp = 栈顶,cs/ss = 用户段,放入就绪队列 调度器下次选中这个进程,就会跳到用户态开始执行。 进入用户态:iretq 第一次进入用户态不能用 sysret——没有对应的 syscall。用 iretq: static void enter_usermode(uint64_t rip, uint64_t cs, uint64_t rflags, uint64_t rsp, uint64_t ss) { __asm__ volatile( "push %4\n" // ss "push %3\n" // rsp "push %2\n" // rflags(IF=1,开中断) "push %1\n" // cs(0x23,ring3 代码段) "push %0\n" // rip(entry point) "iretq" :: "r"(rip), "r"(cs), "r"(rflags), "r"(rsp), "r"(ss) ); } iretq 弹出这 5 个字段,CPU 检查 cs 的 RPL 发现是 ring3,自动完成特权级切换。 ...

May 9, 2026 · 2 min · 大飞

从零写OS(十八):fork 与 Copy-on-Write

上一章每个进程有了自己的地址空间。这一章实现 fork()——创建一个子进程,继承父进程的全部内存。 最朴素的 fork 实现 fork 的语义是"完整复制当前进程"。最直接的做法:遍历父进程页表,找到每一个物理页,分配新页,复制 4096 字节内容,给子进程建新映射。 能用,但很浪费。大多数 fork() 之后会紧接着 exec()——旧内存根本用不上,全拷了白拷。进程堆如果有几十 MB,每次 fork 都要等好几毫秒。 Copy-on-Write:先共享,写了再分 CoW 的思路是:fork 时不复制,让父子共享同一批物理页;等到谁要写,再给他一份新的。 实现上分两步: 第一步:fork 时打标记 遍历父进程用户页表,对每一页做两件事: 清掉 WRITABLE 位,变成只读 打上 PAGE_COW 标志(借用 x86 页表的 bit 9,这一位 CPU 不使用,留给软件自定义) 子进程页表复制同一个物理页地址,同样只读 + CoW。 fork 后: 父进程 → PT → 物理页 0xA000 (只读, CoW) ↑ 共享 子进程 → PT → 物理页 0xA000 (只读, CoW) 注意一个关键细节:PDPT/PD/PT 这三级结构页必须为子进程单独分配。如果父子共用同一棵结构树,后续 vmm_map_page 修改子进程时会把父进程的 PT 一起改掉,隔离失效。数据页可以共享,结构页不能。 另一个细节:改完父进程页表后必须刷新 TLB。否则 CPU 缓存里还是旧的可写映射,父进程写该页不会触发 fault,CoW 形同虚设。 ...

May 8, 2026 · 2 min · 大飞

从零写OS(十七):每个进程有自己的地址空间

前几章的进程共享同一张页表——所有进程看到的是同一片内存。这意味着进程 A 知道进程 B 的地址,就能直接读写它的数据。一个 bug 就能破坏整个系统。 这一章解决这个问题:给每个进程一张自己的页表,互相看不见彼此的内存。 切换页表就是切换世界 x86-64 的虚拟地址翻译规则写在页表里,页表的根地址放在 CR3 寄存器里。 这意味着:写 CR3 就是切换地址空间。进程 A 跑的时候 CR3 指向 A 的页表,进程 B 跑的时候 CR3 指向 B 的页表。同一个虚拟地址 0x400000,在 A 里翻译到物理页 X,在 B 里翻译到物理页 Y——两边完全隔离,互不干扰。 切换进程时,只需要一行: void vmm_switch(uint64_t *pml4) { __asm__ volatile ("mov %0, %%cr3" :: "r"((uint64_t)pml4) : "memory"); } 硬件帮我们做了全部翻译工作。 创建新页表 每个进程需要自己的 PML4。但不能从空白开始——内核代码的映射必须保留,否则切过去之后 CPU 取不到内核指令,立刻 Page Fault。 做法:把内核的 kernel_pml4 整张复制一份作为起点,然后再往里加用户空间的映射。 uint64_t *vmm_create_page_table() { uint64_t *new_pml4 = (uint64_t *)pmm_alloc(); for (int i = 0; i < 512; i++) new_pml4[i] = kernel_pml4[i]; // 继承内核映射 return new_pml4; } 往指定页表里建映射 之前的 map_page 只能操作当前 CR3 指向的页表。现在需要给进程建映射,但不想先切换过去,所以新接口接受一个显式的 pml4 参数: ...

May 7, 2026 · 2 min · 大飞

从零写OS(十六):ELF 加载器,运行第一个用户程序

这一章做完,我们的系统就有了完整的用户程序执行链路:写一个独立的程序,编译成 ELF,放到磁盘上,Shell 里输入 run hello.elf,内核加载并执行它。 ELF 是什么 ELF(Executable and Linkable Format)是 Linux 可执行文件的格式。你编译出来的每个程序、/bin/ls、/usr/bin/python 都是 ELF 文件。 结构很简单: ELF Header → 魔数、架构、入口地址 Program Headers → 每个段加载到内存哪里、从文件哪里读 .text → 代码 .data → 数据 .bss → 未初始化数据(文件里不占空间,加载时清零) 加载一个 ELF,本质上就是:按 Program Header 的指示,把文件里的数据复制到内存里,然后跳到入口地址。 先写用户程序 用户程序不能调内核函数,只能通过 syscall 和内核通信: BITS 64 section .text global _start _start: mov rax, 1 ; syscall 1 = print lea rdi, [rel msg] ; RIP 相对寻址 syscall mov rax, 2 ; syscall 2 = exit syscall section .data msg: db "Hello from ELF!", 13, 10, 0 链接时指定加载地址 0x400000(避开内核占用的低地址区域): ENTRY(_start) SECTIONS { . = 0x400000; .text : { *(.text) } .data : { *(.data) } } ELF 加载器 加载流程: ...

May 6, 2026 · 2 min · 大飞

从零写OS(十五):挂载 ext2,读真实文件系统

前几章的文件系统是存在内存里的——重启数据就没了,文件名也是硬编码的。这一章做真实的:挂载一个 ext2 磁盘镜像,让 Shell 能读取里面的文件。 上一章已经有了 ATA 驱动,能按扇区号读磁盘。现在的问题是:磁盘上的数据是怎么组织的? ext2 的结构 ext2 是 Linux 最经典的文件系统,ext3/ext4 都是在它基础上演化来的。理解 ext2,基本上就理解了现代文件系统的核心思路。 磁盘从偏移 1024 字节开始是 Superblock,存整个文件系统的基本参数:block 大小是多少、有多少 inode、magic number 是 0xEF53(用来确认这确实是 ext2)。 接下来是 Group Descriptor,告诉你 inode table 在哪个 block。 然后才是真正的数据区:inode table 和数据块。 Inode 是文件的"身份证"。每个文件有一个唯一的 inode 号,inode 里存着文件大小、权限,以及最重要的——i_block[0..11],12 个直接指向数据块的指针。想读文件内容,就顺着这些指针去读对应的数据块。 目录也是文件,它的数据块里存的是一条条 ext2_dir_entry:每条记录包含 inode 号、文件名长度、文件名。ls 就是读根目录的数据块,遍历这些记录。 读文件的完整路径 Superblock → 拿到 block_size、inodes_per_group GroupDesc → 拿到 inode table 的起始 block 号 Inode[ino] → 拿到文件大小和 i_block[] DataBlock → 真正的文件内容 读目录(ls)就在最后一步多做一件事:把数据块里的 ext2_dir_entry 链遍历一遍。 每一步都需要从磁盘读若干个扇区——这正是上一章 ATA 驱动的用武之地。 ...

May 6, 2026 · 1 min · 大飞

从零写OS(十四):ATA 驱动,让内核能读磁盘

前面的文件系统都是存在内存里的——重启数据就没了。要读真实磁盘,得先搞清楚操作系统怎么和磁盘"说话"。这一章做 ATA 磁盘驱动。 磁盘和内核怎么通信 硬盘插在主板上,操作系统通过 ATA 协议和它通信。ATA 协议规定了一组固定的 x86 I/O 端口,内核用 in/out 指令直接操作这些端口,就能控制磁盘。 这叫 PIO 模式(Programmed I/O)——CPU 亲自一个字搬一个字地读数据。慢,但实现只需要几十行代码,是学习的最佳起点。 真实生产内核用 DMA(磁盘直接写内存,CPU 不搬数据),那是后话。 磁盘的最小单位:扇区 磁盘被切成 512 字节的扇区,每个扇区有一个编号,叫 LBA(Logical Block Address),从 0 开始数。 LBA=0 → 前 512 字节(Boot Sector) LBA=1 → 512~1023 字节 LBA=2 → 1024~1535 字节(ext2 Superblock 就在这里) ... 要读文件系统里偏移 1024 字节的内容,就是读 LBA = 1024 / 512 = 2。 ATA 端口 ATA 协议设计于 1980 年代,把控制磁盘的所有操作映射到一组固定的 I/O 端口号上——这是当时 PC 硬件的惯例,如今这些端口号已经写死在无数设备里,成了不能改的"历史遗产"。 Primary ATA 控制器的端口: 端口 用途 0x1F0 数据(读写 16-bit) 0x1F2 要读几个扇区 0x1F3 LBA[7:0] 0x1F4 LBA[15:8] 0x1F5 LBA[23:16] 0x1F6 选盘 + LBA[27:24] 0x1F7 命令(写)/ 状态(读) 状态寄存器的两个关键位: ...

May 6, 2026 · 2 min · 大飞

从零写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 · 大飞
京ICP备14031575号-3