avatar

Chen Kunpeng

Tongji University. Focusing on Trustworthy AI & Federated Learning.

【UNIX V6++】如果我是贝尔实验室的一名研究员……

历经无数个日夜的构思与代码编写,我们的 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
#define PS 0177776

3. 进程管理:系统的灵魂

进程是操作系统的灵魂。为了兼顾效率和内存限制,我们将进程上下文拆分成了两个核心结构体:procuser

3.1. proc 与 user 结构体

proc 结构体常驻物理内存,即使进程被 Swap 出去,它也依然在内存中。它保存了调度器最关心的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct proc {
char p_stat; /* 进程状态 */
char p_flag; /* 进程标志位 */
char p_pri; /* 调度优先级,越小优先级越高 */
char p_sig; /* 接收到的信号 */
char p_uid; /* 用户 ID */
char p_time; /* 驻留内存/交换区的时间,用于调度计算 */
char p_cpu; /* CPU 占用率 */
char p_nice; /* 调度偏移量 */
int p_ttyp; /* 控制终端 */
int p_pid; /* 进程 ID */
int p_ppid; /* 父进程 ID */
int p_addr; /* 数据段在内存或交换区的地址 */
int p_size; /* 数据段大小 (*64 字节) */
int p_wchan; /* 进程休眠等待的事件地址 */
int *p_textp; /* 指向共享代码段 text 结构体的指针 */
} proc[NPROC];

#define NPROC 50 /* 系统最大支持 50 个并发进程 */

user 结构体(全局变量 u)包含了进程打开的文件描述符、目录上下文、各类内核栈信息等。由于它比较庞大,当内存紧张时,user 结构体会随着进程的数据段一起被 Swap 到磁盘上。

3.2. 进程状态与标志

系统中进程的生命周期通过 p_statp_flag 精确控制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* p_stat 状态枚举 */
#define SSLEEP 1 /* 高优先级休眠(不可被信号中断) */
#define SWAIT 2 /* 低优先级休眠(可被中断) */
#define SRUN 3 /* 就绪/可执行状态 */
#define SIDL 4 /* 进程创建中 */
#define SZOMB 5 /* 僵尸状态 */
#define SSTOP 6 /* 被跟踪(trace)暂停 */

/* p_flag 标志位 */
#define SLOAD 01 /* 核心数据位于内存中 */
#define SSYS 02 /* 系统进程(如调度进程 proc[0]),免于被 swap */
#define SLOCK 04 /* 调度锁,临时禁止 swap */
#define SSWAP 010 /* 数据已经被换出到磁盘 */
#define STRC 020 /* 处于被跟踪状态 */

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() 的核心逻辑如下:

  1. 遍历 proc 数组,找出一个空槽位(p_stat == NULL)。
  2. 生成一个全局唯一的新 PID (mpid++)。
  3. 拷贝父进程的 proc 信息给子进程。
  4. 增加打开文件句柄(u_ofile)和共享代码段的引用计数。
  5. 极其关键的一步:为子进程分配数据段内存。如果物理内存不足,它甚至会触发 xswap 将父进程换出,以此腾出空间完成拷贝。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* ... (节选自 newproc) ... */
if(a2 == NULL) {
/* 处理内存不够的情况:将父进程暂时换出 */
rip->p_stat = SIDL;
rpp->p_addr = a1;
savu(u.u_ssav);
xswap(rpp, 0, 0);
rpp->p_flag =| SSWAP;
rip->p_stat = SRUN;
} else {
/* 内存充足,直接拷贝数据段 */
rpp->p_addr = a2;
while(n--)
copyseg(a1++, a2++);
}

3.5. 进程调度与 Swap 机制

进程调度由 swtch() 函数完成。它的逻辑十分直接:遍历所有状态为 SRUN 且处于物理内存中 (SLOAD) 的进程,挑出 p_pri 值最小(优先级最高)的执行。

当内存耗尽时,0 号进程(也就是 sched() 守护进程)就会苏醒。它使用 First Fit 算法管理物理内存 (coremap) 和交换空间 (swapmap)。如果需要换入某个进程但内存不足,sched() 会无情地挑选出那些处于休眠状态(SWAIT)、且驻留内存时间足够长的进程,通过 xswap() 将其踢到磁盘上,从而盘活整个系统。

4. 异常处理:Panic 与 Idle

操作系统难免会遇到无法挽回的内核态错误。此时,我们会调用 panic() 抛出一条简短但致命的字符串。

1
2
3
4
5
6
7
8
9
panic(s)
char *s;
{
panicstr = s;
update();
printf("panic: %s\n", s);
for(;;)
idle();
}

idle() 函数会将 CPU 优先级降到最低 (spl 0),执行硬件级 wait 指令挂起 CPU,等待下一次中断的降临——这通常意味着只能拔电源重启了。

5. 中断与优先级

硬件不会主动配合软件的节奏,因此我们高度依赖中断机制。无论是块设备 I/O 完成、终端敲击键盘、还是时钟滴答,都会触发硬件中断。

为了防止内核在处理关键数据结构时被打断造成竞态条件,我们利用 PSW 寄存器设定了 0~7 的中断屏蔽等级。例如,执行关键的进程调度前,通常会调用 spl6() 屏蔽绝大多数中断,处理完毕后再调用 spl0() 恢复响应。

6. 管道 (Pipes):数据流转

最后,不得不提的是管道机制。在没有管道之前,如果要把前一个程序的输出交接给下一个程序,只能这样:

1
2
a1 > tmp
a2 < tmp

这不仅需要反复读写磁盘,还极大地浪费了存储空间。管道的引入,本质上是分配一块环形的内存缓冲区。两个进程一个往里写,一个往外读,当缓冲区满或空时,进程会优雅地通过 sleepwakeup 进行同步。这是最低投入、最高产出的进程间通信方案,也是 UNIX “一切皆数据流”哲学的绝佳体现。