从零写OS(二十三):procfs 进程文件系统

上一章有了 pipe 和 fd,现在进程可以互相传数据了。但还有一个问题:用户程序没有办法查询"系统里现在有哪些进程"——这类信息只存在内核里,外面拿不到。 加专用 syscall 当然可以,但 Linux 选择了一个更优雅的方案:procfs。 一切皆文件 procfs 的思路是:把内核状态伪装成文件,挂在 /proc/ 目录下。 /proc/0/status → 进程 0 的信息 /proc/1/status → 进程 1 的信息 用户程序用完全一样的 open/read/close 就能读到,不需要学新 API: int fd = open("/proc/1/status"); read(fd, buf, 128); // buf 里是 "pid: 1\nstate: running\nparent: 0\n" 这就是 Linux “一切皆文件” 哲学的体现——统一接口,背后实现可以完全不同。 虚拟文件和真实文件的区别 普通文件的 read:从磁盘读数据。 procfs 文件的 read:内核现场生成内容,没有磁盘,没有 inode,数据就是 procs[] 数组里的字段格式化出来的字符串。 open("/proc/1/status") → vfs_open 识别 "/proc/" 前缀 → procfs_open 解析 pid,格式化状态字符串存入内核 buf → 返回 VFile(type=VFILE_PROC) read(fd, buf, len) → vfs_read 识别 VFILE_PROC → 从内核 buf 按 offset 拷贝给用户 实现 procfs 内核结构 typedef struct { int used; uint32_t pid; char buf[256]; // open 时格式化好的字符串 uint32_t buf_len; uint32_t offset; // 支持分段 read } proc_fd_t; open 时一次性生成完整字符串,后续 read 只是按 offset 切片拷贝——和普通文件行为完全一致,可以多次 read。 ...

May 14, 2026 · 2 min · 大飞

从零写OS(二十二):管道 pipe 与 dup2

上一章实现了文件描述符,进程可以用 open/read/close 访问 ext2 文件。但两个进程之间还没有办法通信。shell pipeline cmd1 | cmd2 的本质是:cmd1 的输出直接流进 cmd2 的输入,中间不落磁盘。 这一章实现 pipe 和 dup2,打通进程间通信的最小路径。 pipe 是什么 pipe 是内核里的一块环形字节缓冲区。系统调用 pipe(fds) 返回两个文件描述符: fds[0] = 读端 fds[1] = 写端 写进程 → fds[1] → [内核 ring buffer 256字节] → fds[0] → 读进程 和普通文件一样,管道也走 fd → VFS → 底层数据 这三层,只不过底层数据不是磁盘,而是内核里的 pipe_t。 dup2 是什么 dup2(oldfd, newfd) 让 newfd 指向和 oldfd 同一个内核文件描述符,如果 newfd 已经打开则先关掉它。 shell pipeline 的核心操作就是 dup2 + exec: // 子进程(cmd1),stdout → pipe 写端 dup2(fds[1], 1); close(fds[0]); close(fds[1]); exec("cmd1"); // cmd1 以为自己在写 stdout,实际写进了管道 // 父进程(cmd2),stdin → pipe 读端 dup2(fds[0], 0); close(fds[0]); close(fds[1]); // 关掉写端,否则 cmd2 永远等不到 EOF exec("cmd2"); pipe 的 EOF 语义 read 管道什么时候返回 0(EOF)? ...

May 14, 2026 · 2 min · 大飞

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