从零编写操作系统 22 - 文件描述符:把名字、游标与资源寿命分开
第 21 篇已经能在 FAT16 根目录中查找文件,并按指定 offset 读取字节。这个接口仍然不适合直接交给用户程序:调用者每次都要提供文件元数据和位置,内核也无法回答某个进程打开了哪些资源、两个调用是否应当共享读取位置,以及进程异常退出后由谁释放资源。 本篇增加两级句柄结构。每个进程拥有 8 个文件描述符槽,槽中保存对全局 open-file 对象的引用;open-file 对象保存类型、读写能力、引用计数、文件 offset 和 FAT 元数据。FAT 文件与物理终端由同一组 open/read/fd_write/close 系统调用访问,但各自拒绝不支持的操作。 实验不只验证 README.TXT 能读出来。两次独立打开必须拥有不同 offset,父子进程显式映射同一个对象时必须共享 offset;正常退出和页故障退出都要在成为 ZOMBIE 前关闭 fd。另一个镜像连续制造六次 ELF 分配失败和一次内核栈失败,检查暂存引用是否全部回滚。终端实验则通过 QEMU HMP 注入真实 PS/2 按键,确认用户进程在 int 0x80 内阻塞,PIT 和 idle 仍能继续...
从零编写操作系统 21 - 只读 FAT16:从扇区追到文件字节
第 20 篇已经能从 ATA 设备读出指定 LBA 的 512 字节,但用户程序仍然来自内核里的 ELF 字节数组。块设备只回答“某个扇区里是什么”;要回答“README.TXT 在哪里、有多长、怎样读出来”,还需要文件系统层。 本篇在已有 ATA PIO 接口上增加只读 FAT16。内核从 MBR 取第一分区,校验 BPB(BIOS Parameter Block)并计算卷布局,在固定根目录中查找 8.3 短名,再沿 FAT 簇链读取文件。测试文件故意存在 2 -> 9 -> 4 三个不连续簇中,因此“刚好读到一段连续数据”不能冒充 FAT 实现。 本篇还真实撞到了旧的 64 KiB 内核文件上限。扩容同时修改了镜像保留区、头部生产器、stage2 读取与校验、链接边界和失败镜像,并用精确 128 KiB/128 KiB+1 的实验区分成功边界和拒绝边界。 FAT16 分区需要真实的数据簇 原镜像只有 4096 个扇区,FAT 分区预定从 LBA 2048 开始。后半段总共只有 2048 个扇区,即使每簇只用一个扇区,扣除引导扇区、FAT 和根目录后也不可能得到 ...
从零编写操作系统 20 - ATA PIO 块设备:把扇区读成可信的数据
第 19 篇的程序仍然来自内核中的 ELF 字节数组。即使磁盘镜像已经装着启动扇区、第二阶段和内核,保护模式内核也不会读盘;它只能使用启动阶段提前搬进内存的内容。下一篇要解析 FAT16,本篇必须先建立一个更窄的基础:给定一个合法 LBA,可靠地取回恰好 512 字节。 本篇增加 primary master 的 ATA PIO 只读驱动。它用 IDENTIFY 取得设备容量,用 READ SECTOR(S) 一次读取一个扇区,区分参数错误、设备错误和等待超时。命令已经发出后若状态序列失效,通道会进入失败状态;在实现复位协议以前,驱动不会假装设备已经恢复。 实验实际读取 MBR、LBA 2047 和最后一个扇区 LBA 4095,另用三个独立镜像验证 BSY 不结束、DRQ 不出现,以及 QEMU 对容量外 LBA 的真实 ERR/ABRT 响应。所有镜像都从 BIOS 完整启动,编译成功不算设备实验通过。 BIOS 读盘不能直接变成内核块设备 第二阶段仍在实模式时,可以调用 BIOS int 0x13 把内核搬到内存。进入保护模式后,当前内核没有虚拟 8086 模式,也没有切...
从零编写操作系统 19 - 创建与回收进程:让退出状态有地方可去
第 18 篇已经能把独立链接的 ELF 装进私有地址空间,但程序仍由内核演示代码直接创建,结束后也由演示代码强制清理。用户程序不能启动另一个程序,不能主动退出,更拿不到子进程的退出原因。 本篇在第 17 篇的私有系统调用 ABI 上增加 spawn、exit 和 wait。创建成功的进程得到单调递增的 PID;正常退出和用户故障都会留下状态;父进程取得状态后才最终释放子进程的地址空间与内核栈。父进程先退出时,PID 0 接管并回收孤儿。 实验不只验证“创建后能运行”。正常镜像会执行两轮指定等待、十次坏状态指针、槽位复用和孤儿回收;失败镜像会依次切断 ELF 的 7 个分配点和第 8 个内核栈分配点。两条路径最后都必须恢复到实验开始时的空闲页数。 任务能切换,不等于进程能结束 第 13 篇建立的任务只有 RUNNABLE、RUNNING、BLOCKED 和 DEAD。教学演示知道每个任务何时结束,也知道应该释放哪两张栈页,所以任务退出后直接进入 DEAD,bootstrap 随后清理即可。 用户进程多了两个问题。父进程需要知道子进程是正常 exit(7),还是因为页故障被内核终止...
从零编写操作系统 18 - ELF 加载器:把文件中的段变成用户地址空间
第 17 篇的用户程序仍是一段写在内核工程里的汇编字节。代码地址、数据地址和入口都由内核事先知道,这种方式适合验证 ring 3 和系统调用,却不能装载独立编译的程序。 本篇让 GCC 和 GNU ld 生成一个真正的 ELF32/i386 可执行文件。内核不再假定入口和段地址,而是解析 ELF header 与 program header,把 PT_LOAD 映射进新的用户地址空间,再从文件声明的入口执行。用户程序会检查 .data、跨页 BSS 和 C 栈,最后复用第 17 篇的 write 输出结果。 加载器的输入不能因为“由自己的构建系统生成”就跳过检查。实验会在客体内构造 34 类错误 ELF,并把内存分配失败依次放在 7 张地址空间页和第 8 张任务内核栈上。任何失败都必须恢复资源,任务只有在所有步骤成功后才能变成 RUNNABLE。 从固定字节到可执行文件 第 16、17 篇的地址空间布局固定为一页代码、一页数据和一页栈。任务入口固定为 0x40000000,内核还会在用户 IRQ 返回前写入固定的数据页。这些探针证明了权限切换,却把“测试程序长什么样”和“地址...
从零编写操作系统 17 - 系统调用:把用户参数安全地带进内核
第 16 篇已经把任务降到 ring 3,并用页表阻止用户程序改写内核。隔离建立以后,用户程序也失去了直接输出、访问端口和控制调度器的能力。 本篇增加一个 DPL3 的 int 0x80 门和两项自定义系统调用:write 把用户字节写到控制台,yield 请求调度。难点集中在边界状态:CPU 怎样换到当前任务的内核栈,内核怎样检查一段可能跨页、回绕或指向 supervisor 页的地址,以及失败后怎样回到同一用户程序继续执行。 实验会故意传入第二页缺失的缓冲区。预置在第一页末尾的 LEAKFAIL 不能出现在串口里;否则说明输出在完整校验之前已经发生。 系统调用是受控的权限切换 用户态不能通过普通 call 跳进内核。call 只改变 EIP 和栈上的返回地址,不会把 CPL3 变成 CPL0,也不会从 TSS 取出内核栈。 int 0x80 走 IDT 门。处理器检查这个门是否存在、门的 DPL 是否允许软件调用,再完成权限切换和现场保存。第 16 篇建立的 TSS.ESP0 因而不只是 IRQ 的入口栈,也成为系统调用入口栈。 1234567891011121314151...
从零编写操作系统 16 - 进入用户态:让错误程序无法直接改内核
第 15 篇已经能让任务等待、唤醒和接受时钟抢占,但所有任务仍在 ring 0,共享内核页表与执行权限。 一个错误指针可以改写内核数据,一条 CLI 可以阻止普通中断进入。 独立任务栈与轮转调度都不能代替访问权限。 本篇为两个用户任务建立独立地址空间,经 IRETD 进入 ring 3,让它们在相同虚拟地址递增各自的计数。 三轮实验分别让其中一个任务写内核数据、执行 CLI、执行 OUT,要求错误任务停止,而另一个继续增长。 用户代码仍是内嵌的手写机器指令;本篇没有 ELF 加载器、系统调用或用户自行退出接口,有限实验由内核监督收尾。 CPU 权限和页面权限分别限制什么 保护模式的当前权限级 CPL 由当前代码段选择子的低两位反映。 此前内核使用 CS=0x08、SS=0x10,运行在 CPL0;用户代码将使用 CS=0x1b、SS=0x23,运行在 CPL3。 这些数值同时包含 GDT 项索引和请求权限级,不能把低两位当作可以随意改写的普通配置开关。 分页的 U/S 位决定页面是否允许用户访问。 即使代码运行在 ring 3,若页目录和页表错误地把内核区域都标成 user,它...
从零编写操作系统 15 - 等待与唤醒:任务为什么不能一直轮询
第 14 篇已经能用时钟轮转两个计算任务,但没有输入时,键盘消费者没有可处理的数据。 反复取得时间片再检查空队列,只会重复得到同一个结果。 本篇增加 BLOCKED 状态:消费者登记等待条件后退出就绪集合,生产者改变条件时再把它变回 RUNNABLE。 关键问题发生在检查条件与登记等待之间。 若输入恰好在这段间隙到达,生产者可能发现没有等待者,随后消费者却仍按旧结果进入阻塞,形成“条件已经满足,任务仍然睡着”的丢失唤醒。 实验用真实 PIT 中断分别制造错误顺序和受保护顺序,再把同一套等待接口接到定时睡眠、idle 与持续运行的键盘任务。 阻塞任务暂时退出就绪集合 RUNNABLE 表示可以执行,BLOCKED 表示正在等某项外部条件。 两者都可能保留完整调用栈,但调度器只从 RUNNABLE 中选下一个任务。 时钟每次到来不应把所有 BLOCKED 任务重新置为就绪,否则每个任务仍要定期醒来轮询。 123456789运行中的消费者 │ 条件不满足:登记等待,保存调用续点 ▼BLOCKED ── 生产者改变条件并唤醒 ──→ RUNNABLE ...
从零编写操作系统 14 - 抢占调度:时间片结束后怎样换一个任务
第 13 篇能够保存两条调用链,但 A 一旦进入不含 yield 的无限循环,已经就绪的 B 就无法运行。 本篇把第 08 篇的 PIT 时钟接入同一个调度器,让两个没有主动让出点的计算任务都取得进展。 时间片采用最小规则:每次时钟中断提出一次轮转请求,允许调度时在中断处理出口切换。 切换函数仍然只保存四个被调用者保存寄存器。 被时钟打断的完整寄存器与 EFLAGS 则留在原任务的中断栈帧里,等该任务恢复以后由 IRETD 还原。 实验另外检查允许中断但禁止抢占、真正关闭中断、嵌套恢复,以及屏蔽 IRQ0 后另一个任务停滞的区别。 时钟可以打断调用约定没有覆盖的位置 普通 task_yield() 是编译器可见的函数调用。 编译器会按 ABI 处理跨调用仍然有效的数据,第 13 篇因此只需在切换函数中保存 EBP、EBX、ESI、EDI 和栈位置。 时钟中断却可能发生在加法与后续条件跳转之间,也可能发生在 EAX 刚算出一个值、尚未写回内存的时候。 这种位置没有普通函数调用,不能允许中断处理随意丢弃 EAX、ECX、EDX 或条件标志。 若只保存四个寄存器,两个计数任务可能仍偶...
从零编写操作系统 13 - 保存与恢复执行现场:先实现协作式任务
第 12 篇已经能按字节分配内核对象,但执行路径仍沿着同一份调用栈推进。 普通函数 A 调用 B 后,只有 B 返回,A 才能继续;如果 B 一直计算,其他工作就无法通过这条调用链取得 CPU。 本篇为两个内核任务各分配一张栈页,让任务在 task_yield() 处暂停,并在以后从同一次调用之后继续。 正常实验要求 A、B 各完成 128 轮计数,跨切换保留局部数组、被调用者保存寄存器和各自的中断允许状态,退出后归还两张栈页。 另一个镜像让 A 永远不调用 yield,检查 B 虽然已经就绪,计数仍然停在零。 这套实现只在单 CPU、ring 0、普通函数调用边界切换,没有时钟抢占,也没有独立用户地址空间。 暂停的调用链需要独立栈 函数调用会把返回位置放到栈上,局部变量与被保存的寄存器也可能占用栈空间。 若把一个执行中的函数暂停,再让另一个函数在同一片空间从头建立调用栈,前一个函数恢复时就可能读到被覆盖的数据。 独立栈使两条尚未结束的调用链能够同时保存在内存里;单 CPU 在任意时刻仍只执行其中一条。 第 10 篇的 page_alloc() 每次返回一张 4096 字节物理...

