历经无数个日夜的构思与代码编写,我们的 UNIX V6++ 操作系统终于迎来了它的雏形。在这篇文章中,我将和大家分享 UNIX V6++ 的架构设计,希望这篇文章能为你带来启发。UNIX V6 原始参考源码可以在这里获得:Research-V6-Snapshot-Development
1. UNIX V6++ 功能概述
在设计之初,我们确立了系统的功能边界,力求用最简洁的代码实现最高效的运转。系统主要涵盖以下模块:
- 设备管理:区分块设备和字符设备,统一 I/O 接口。
- 系统调用:用户态与内核态的桥梁。
- 进程管理:支持进程创建(
fork)与复杂的进程调度。 - 文件系统与交换空间:管理持久化数据与内存置换。
- 系统初始化:从引导启动到
init进程的建立。 - 多用户支持:基于 UID/GID 的权限隔离。
- 中断处理:响应外部硬件与内部异常。
- Shell 与 管道:提供强大的命令行交互与进程间通信能力。
2. PDP-11/40 硬件基础:一切的起点
操作系统离不开底层的硬件支持。UNIX V6 深度依赖于 PDP-11/40 的体系结构。PDP-11 最优雅的设计之一就是其 UNIBUS 总线 架构。通过 UNIBUS,外设寄存器被直接映射到了内存地址空间中。这意味着我们不需要特殊的 I/O 指令,直接通过普通的内存读写指令就能操作硬件。UNIBUS 的地址总线宽度为 18bit(最大寻址 256KB),其中内存最高端的 8KB 空间被专门保留用于映射 I/O 寄存器。
2.1. 寄存器设计
PDP-11 提供了 7 个 16 位的通用寄存器(r0 ~ r7),并在硬件层面上严格区分了内核态和用户态:
| 寄存器 | 说明 |
|---|---|
| r0, r1 | 存储运算结果和函数的返回值 |
| r2, r3, r4 | 本地处理(程序自由使用的通用寄存器) |
| r5 | 帧指针 (Frame Pointer) |
| r6 (SP) | 栈指针。硬件上区分内核模式和用户模式,切换模式时硬件会自动切换对应的 SP |
| r7 (PC) | 程序计数器 (Program Counter) |
此外,还有一个至关重要的 PSW(处理器状态字) 寄存器,它不仅保存了条件码,还决定了当前的运行特权级。
| 比特位 | 说明 |
|---|---|
| 0~3 | 条件码:借位(0)、溢出(1)、零位(2)、负位(3) |
| 4 | 陷入 (Trap) 标志 |
| 5~7 | 处理器优先级 (0~7) |
| 12~13 | 处理器先前模式 |
| 14~15 | 处理器当前模式 (00: 内核模式, 11: 用户模式) |
在我们的源码中,PSW 被映射到了特定的内存地址 0177776(八进制):
1 |
3. 进程管理:系统的灵魂
进程是操作系统的灵魂。为了兼顾效率和内存限制,我们将进程上下文拆分成了两个核心结构体:proc 和 user。
3.1. proc 与 user 结构体
proc 结构体常驻物理内存,即使进程被 Swap 出去,它也依然在内存中。它保存了调度器最关心的信息:
1 | struct proc { |
而 user 结构体(全局变量 u)包含了进程打开的文件描述符、目录上下文、各类内核栈信息等。由于它比较庞大,当内存紧张时,user 结构体会随着进程的数据段一起被 Swap 到磁盘上。
3.2. 进程状态与标志
系统中进程的生命周期通过 p_stat 和 p_flag 精确控制:
1 | /* p_stat 状态枚举 */ |
3.3. 内存分配与虚拟地址映射
进程的内存被划分为代码段和数据段。为了节省宝贵的内存,多个运行相同程序的进程(如打开多个 shell)会共享同一个代码段。这一机制通过全局的 text[] 数组实现。
借助于 PDP-11 的 MMU (内存管理单元),进程拥有独立的 16 位(64KB)虚拟地址空间,而底层物理地址为 18 位(256KB)。MMU 通过 APR (Active Page Register) 将这两者映射起来。每页最大 8KB,通过 PAR (基地址) 和 PDR (访问控制) 组合,既实现了地址隔离,又保证了内存安全性。
3.4. 进程的诞生:fork 与 newproc
UNIX 使用父子树状结构管理进程。fork 也许是 UNIX 最具艺术感的设计之一——它被调用一次,却在父子进程中分别返回不同的值。
在内核态,fork 最终调用 newproc()。newproc() 的核心逻辑如下:
- 遍历
proc数组,找出一个空槽位(p_stat == NULL)。 - 生成一个全局唯一的新 PID (
mpid++)。 - 拷贝父进程的
proc信息给子进程。 - 增加打开文件句柄(
u_ofile)和共享代码段的引用计数。 - 极其关键的一步:为子进程分配数据段内存。如果物理内存不足,它甚至会触发
xswap将父进程换出,以此腾出空间完成拷贝。
1 | /* ... (节选自 newproc) ... */ |
3.5. 进程调度与 Swap 机制
进程调度由 swtch() 函数完成。它的逻辑十分直接:遍历所有状态为 SRUN 且处于物理内存中 (SLOAD) 的进程,挑出 p_pri 值最小(优先级最高)的执行。
当内存耗尽时,0 号进程(也就是 sched() 守护进程)就会苏醒。它使用 First Fit 算法管理物理内存 (coremap) 和交换空间 (swapmap)。如果需要换入某个进程但内存不足,sched() 会无情地挑选出那些处于休眠状态(SWAIT)、且驻留内存时间足够长的进程,通过 xswap() 将其踢到磁盘上,从而盘活整个系统。
4. 异常处理:Panic 与 Idle
操作系统难免会遇到无法挽回的内核态错误。此时,我们会调用 panic() 抛出一条简短但致命的字符串。
1 | panic(s) |
idle() 函数会将 CPU 优先级降到最低 (spl 0),执行硬件级 wait 指令挂起 CPU,等待下一次中断的降临——这通常意味着只能拔电源重启了。
5. 中断与优先级
硬件不会主动配合软件的节奏,因此我们高度依赖中断机制。无论是块设备 I/O 完成、终端敲击键盘、还是时钟滴答,都会触发硬件中断。
为了防止内核在处理关键数据结构时被打断造成竞态条件,我们利用 PSW 寄存器设定了 0~7 的中断屏蔽等级。例如,执行关键的进程调度前,通常会调用 spl6() 屏蔽绝大多数中断,处理完毕后再调用 spl0() 恢复响应。
6. 管道 (Pipes):数据流转
最后,不得不提的是管道机制。在没有管道之前,如果要把前一个程序的输出交接给下一个程序,只能这样:
1 | a1 > tmp |
这不仅需要反复读写磁盘,还极大地浪费了存储空间。管道的引入,本质上是分配一块环形的内存缓冲区。两个进程一个往里写,一个往外读,当缓冲区满或空时,进程会优雅地通过 sleep 和 wakeup 进行同步。这是最低投入、最高产出的进程间通信方案,也是 UNIX “一切皆数据流”哲学的绝佳体现。