MIT 6.S081 第二章笔记 | 操作系统结构
操作系统应该实现三个功能:并发、隔离、交互。即能保证多个程序都能分到硬件资源;各个进程之间的内存、指令、数据相互隔离,一个进程崩溃不会影响到其他进程;进程之间能通过受控的接口来进行通信。
操作系统提供了高级别的抽象,来管理硬件资源。例如,用文件描述符来抽象磁盘、内存、管道等资源,用户程序能通过简单的read
、write
、close
来访问所有存储资源,而不用关心是和磁盘、内存、管道、还是标准输入输出交互。
2.2 User mode、 supervisor mode、 machine mode
为了实现进程隔离,\(RISC-V\) CPU在硬件上提供3种执行命令的模式:machine mode, supervisor mode, user mode。
\(machine \space mode\): 机器模式拥有全权限。CPU以机器模式启动。机器模式大多时候用于配置计算机。xv6执行必要的几行指令后就转为监管模式。
\(supervisor \space mode\): 在监管模式下,CPU可以执行特权指令(\(privileged \space instructions\)), 比如中断管理、对存储页表的寄存器进行读写操作、执行系统调用。运行在监管模式也称为运行在内核空间(\(kernel \space space\))。运行在内核空间的程序被称作内核。
\(user \space mode\): 用户模式只能执行用户指令,例如
add
、jump
等简单无害的指令。运行在用户模式也称为运行在用户空间。
运行在用户空间的程序如果执行了特权指令,CPU会转换到特权模式并将该程序强制停止。
2.3 The kernel organization
monolithic kernel: 整个操作系统在kernel中,所有system call都在supervisor mode下运行。xv6是一个monolithic kernel。
micro kernel: 将必须运行在supervisor mode下的操作系统代码压到最小,保证kernel的安全性和简洁,将大部分的操作系统代码执行在user mode下。
宏内核易于设计,但是系统调用较复杂,并且一旦任意一条特权指令出错,整个操作系统都会崩溃。
如下图所示,文件系统作为用户级别的进程执行,用户通过进程间通信请求文件系统的服务。这种运行在用户模式的内核模块称作server
。微内核更轻便、稳定,但是难于设计和实现。
下图列出了 xv6
的所有内核文件和其对应的功能。模块间接口定义在kernel/defs.h
文件中。
2.4 Process overview
隔离的单元叫作进程, 一个进程不能破坏或监听另一个进程的内存、CPU、文件描述符,也不能破坏 kernel 本身。
为了加强隔离,内核为每个进程提供了一块私有、独立的内存,称作地址空间(address space),这让进程认为自己拥有一个独立的机器,而不用和其他进程共享硬件资源。其他的进程不能访问这块内存。
操作系统使用页表(page table)的概念来实现内存独立。页表提供 虚拟地址(RISC-V操作的地址)到物理地址(CPU芯片发送到内存的地址)的映射(或转换)。
xv6 为每一个进程维护一个独立的页表,如下图所示。地址空间从 \(0\)
号地址开始,首先是指令,然后是全局变量(栈空间),之后是进程可以根据需要灵活拓展的堆空间(用于malloc
)。
题外话:操作系统中的堆栈和数据结构中的堆栈没有关系。堆是指在运行时动态分配的空间,栈是在运行前确定的静态空间。
RISC-V使用 \(64\) 位指针,但是 xv6 只使用低 \(38\) 位就够了,因此最大地址是 \(2^{38} - 1 = 0x3fffffffff = MAXVA\)。
xv6
使用struct proc
(声明在kernel/proc.h
)来维护每个进程的状态。进程最重要的几个信息:
1. 页表(p->pagetable
). 2.
进程栈(p->kstack
). 3.
进程运行状态(p->state
)。
每个进程中都有线程(\(thread\)),是执行进程命令的最小单元,可以被暂停和继续。
每个进程有两个栈:用户栈(user stack)和内核栈(kernel stack)。当进程在user space中进行时只使用用户堆栈,当进程进入了内核(比如进行了system call)使用内核堆栈。
操作系统给进程提供了两种假象: 地址空间给进程提供了独自拥有内存的假象、线程给进程提供了独自拥有 CPU 的假象。
2.5 Starting the first process
当RISC-V芯片通电后,它会自动读取 \(ROM\) 的指令初始化自己,并运行引导程序(在
xv6 中为kernel/kernel.ld
)将内核加载入内存中。然后在machine
mode从_entry(kernel/entry.S
)开始运行xv6。bootloader将xv6
kernel加载到0x80000000的物理地址中,因为前面的地址中有I/O设备。
start
函数中,先以machine
mode做了一些配置,然后调用mret
指令跳转到supervisor mode,
并通过修改 PC 寄存器的值跳转到kernel/main.c
。
main
先对一些设备和子系统进行初始化,然source/_posts/2024/MIT-6.S081-lab03.md
进程将要请求的系统调用号写入p->trapframe->a7
,
其中p
为当前进程的struct proc
。并且将参数写入p->trapframe->a0
和其他寄存器。之后进程执行ecall
指令,并保存进程相关信息(其中就包括trapframe
)。然后开始执行syscall(kernel/syscall.c:95)
syscall()
从trapframe->a7
中拿到索引,通过一个函数指针数组syscall[]
(定义在kernel/syscall.c
中)获取对应系统调用的函数指令。然后将系统调用的返回写入p->trapframe->a0
。
系统调用号( \(system \space call \space
number\)
)定义在kernel/syscall.h
中,作为内核找到函数指针的索引。
2.7 System call arguments
对应教材第4章第4节。
trap
相关的代码将用户寄存器保存在当前进程的trapframe
中,内核函数argint
、argaddr
、argfd
从trapframe
中的指定寄存器得到数据,并分别按照整数、地址、文件描述符解析。通过argrow
能方便的读取第\(n\)个寄存器的内容(这些函数都定义在kernel\syscall.c
中)。
大部分参数都通过指针来传递,有时用户程序还会请求内核访问特定内存并写入数据。为了防止用户程序传入恶意参数,内核使用fetchstr
和copyinstr
来实现安全地与用户提供的地址之间传输数据的功能。