xv6 第二章_操作系统架构
第二章 操作系统架构
操作系统必须满足三个要求:多路复用、隔离和交互
- 多路复用:即使进程数量比硬件处理器多,操作系统也必须确保每个进程都能运行。
- 隔离:一个进程有故障或者错误,不应该影响到其他进程。
- 交互:进程之间可以进行刻意的交互;如通过管道实现。
一、抽象系统资源
假如不抽象硬件资源,每个应用程序能够直接使用硬件资源。这种情况好处是每个应用程序能够直接和硬件交互,应用程序自己决定如何使用硬件。
但是如果多个应用程序同时运行,就必须考虑它们对硬件资源的使用顺序和占用情况,如何协调多个应用程序的运行是一件极其麻烦的事情。
一些嵌入式设备或者实时操作系统就是这样组织的。
更好的办法是禁止应用程序直接访问敏感的硬件资源,而是将资源抽象为服务。
应用程序请求这些服务,而服务由操作系统来执行并返回结果。
这样用户程序就不用考虑服务是如何实现的,只需要使用即可;至于多个应用程序如何运行,也是操作系统来协调组织。
Unix进程使用
exec来构建它们的内存映像,而不是直接与物理内存交互。
操作系统来决定将一个进程放在内存中的哪里。
exec系统调用依赖操作系统的文件系统,使用户能把可执行程序作为普通文件存储、管理并方便地运行。
Unix进程之间的许多交互形式都是通过文件描述符实现的。
文件描述符不仅抽象了许多细节(例如,管道或文件中的数据存储在哪里),而且还以简化交互的方式进行了定义。
例如,如果流水线中的一个应用程序失败了,内核会为流水线中的下一个进程生成文件结束信号(EOF)。
示例:
cat nofile.txt | grep hello假如
cat nofile.txt失败了(文件不存在),cat程序退出时,它的输出端(即管道的写端)关闭。
当内核检测到这个 pipe 的所有写端都关闭时,会在管道的读端返回 EOF 信号,这样grep程序发现没有数据可读,也会结束执行。
二、用户态、核心态和系统调用
应用程序出错不应该影响操作系统或者其他应用程序;操作系统必须保证应用程序不能修改操作系统的数据结构和指令。
这就是操作系统需要做到的隔离。
CPU 为隔离提供硬件支持。
RISC-V 架构的 CPU 有三种执行模式:
- 机器模式(Machine Mode)
- 管理模式(Supervisor Mode)
- 用户模式(User Mode)
想要调用内核函数的应用程序必须切换到内核。
RISC-V 提供一个特殊指令 ecall,将 CPU 从用户模式切换到管理模式,并在内核指定的入口点进入内核。
用户程序调用 ecall,只是向内核请求服务。
三、内核组织
- 宏内核(Monolithic Kernel):
所有的系统调用都以管理模式运行;但用户态与内核态之间的切换会带来一定开销。 - 微内核(Microkernel):
大部分系统调用在用户态下执行,只有少部分最基础的系统调用在内核态执行。
四、Xv6代码架构
Xv6 源码位于 kernel 目录下,按照模块化的概念划分文件:
| 文件 | 描述 |
|---|---|
| bio.c | 文件系统的磁盘块缓存 |
| console.c | 连接到用户的键盘和屏幕 |
| entry.S | 首次启动指令 |
| exec.c | exec() 系统调用 |
| file.c | 文件描述符支持 |
| fs.c | 文件系统 |
| kalloc.c | 物理页面分配器 |
| kernelvec.S | 处理来自内核的陷入指令以及计时器中断 |
| log.c | 文件系统日志记录以及崩溃修复 |
| main.c | 在启动过程中控制其他模块初始化 |
| pipe.c | 管道 |
| plic.c | RISC-V 中断控制器 |
| printf.c | 格式化输出到控制台 |
| proc.c | 进程和调度 |
| sleeplock.c | 让出 CPU 的锁 |
| spinlock.c | 不让出 CPU 的锁 |
| start.c | 早期机器模式启动代码 |
| string.c | 字符串和字节数组库 |
| swtch.c | 线程切换 |
| syscall.c | 分发系统调用 |
| sysfile.c | 文件相关的系统调用 |
| sysproc.c | 进程相关的系统调用 |
| trampoline.S | 用于在用户和内核之间切换的汇编代码 |
| trap.c | 对陷入指令和中断进行处理并返回的 C 代码 |
| uart.c | 串口控制台设备驱动程序 |
| virtio_disk.c | 磁盘设备驱动程序 |
| vm.c | 管理页表和地址空间 |
五、进程概述
Xv6(和其他 Unix 操作系统一样)中的隔离单位是一个进程。
内核用来实现进程的机制包括:
- 用户/管理模式标志
- 地址空间
- 线程的时间切片

其中 trampoline 和 trapframe 位于每个进程用户内存空间的顶层,各占一页。
虽然这两页映射在每个进程的虚拟地址空间中,但其内容由内核管理。
- Trampoline 页(跳板页)
包含用户态和内核态切换时执行的汇编代码。
每个进程的虚拟地址空间都映射相同的 trampoline 物理页,因此该页是所有进程共享的。 - Trapframe 页(陷入帧)
当用户程序陷入内核时,内核需要一个地方保存用户寄存器的值,以便执行完后恢复现场。
每个进程都有自己的 trapframe 结构,但用户程序不能访问该页,只有内核才有读写权限。
为什么 trampoline 和 trapframe 放在进程用户地址空间的顶部?
- 固定位置方便切换,不需要查表。
- 每个进程切换时只需切换页表,不需更改映射逻辑。
每个进程都有一个执行线程来执行指令。
用户页表由内核分配并存放在内核控制的物理内存中,用于描述用户虚拟空间的映射关系,但用户程序无法访问或修改它。
六、启动 XV6 和第一个进程(代码)
使用 GDB 调试从 xv6 上电到启动第一个进程的过程。
当 RISC-V 计算机上电时,它会初始化自己并运行一个存储在只读内存中的引导加载程序。
引导加载程序将 xv6 内核加载到内存中,然后在机器模式下从 _entry(kernel/entry.S) 开始运行 xv6。
此时页式硬件处于禁用模式,虚拟地址直接映射到物理地址,即使用的是物理地址。
- 引导加载程序将内核加载到物理地址 0x80000000
- 内核从
_entry开始执行 - 每个 CPU 都会从
_entry开始执行 _entry为每个 CPU 设置栈指针sp,然后跳转到start()(kernel/start.c)
start() 函数执行一些仅在机器模式下允许的配置,然后切换到管理模式,并跳转到 main() 函数(kernel/start.c)执行。
在 start() 中,会:
- 禁用虚拟地址转换(将页表寄存器
satp设置为 0) - 将所有中断和异常委托给管理模式
main() 函数对内核进行初始化,并初始化第一个用户进程 init。
然后 initcode.S 通过 exec 执行 init 进程(user/init.c),
init 进程会 fork 出 shell 进程,此时 shell 就运行起来了,
后续的用户进程都由 shell 来 fork。
进程树如下:
└─ [kernel] ← 内核启动(main函数)└─ initcode (user/initcode.S)└─ init (user/init.c)└─ sh (user/sh.c)├─ cat (user/cat.c)├─ echo (user/echo.c)├─ ls (user/ls.c)├─ grep (user/grep.c)├─ kill (user/kill.c)├─ rm (user/rm.c)├─ sleep (user/sleep.c)└─ ...(用户运行的其他命令)
七、真实世界
许多 Unix 系统都采用宏内核。
虽然 Linux 的一些操作系统功能作为用户级服务器运行(如窗口系统),但它仍属于宏内核架构。
而 L4、Minix 和 QNX 的内核被组织成一个带有多个服务器的微内核,这种设计在嵌入式设备中得到了广泛应用。
xv6 不支持一个进程创建多个线程。
七、真实世界
许多 Unix 系统都采用宏内核。
虽然 Linux 的一些操作系统功能作为用户级服务器运行(如窗口系统),但它仍属于宏内核架构。
而 L4、Minix 和 QNX 的内核被组织成一个带有多个服务器的微内核,这种设计在嵌入式设备中得到了广泛应用。
xv6 不支持一个进程创建多个线程。
但现代操作系统都支持在一个进程中创建多个线程。
