从零写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(十七):每个进程有自己的地址空间

前几章的进程共享同一张页表——所有进程看到的是同一片内存。这意味着进程 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 · 大飞

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