xv6book Notes(C1-4)
一些细节和思考:
Q: wait for reading the source code and thinking
A: after reading the source code and thinking
Chapter 1
1.1
close syscall just "Release the file fd", don't really operate on the file itself.
and correspandingly, the open syscall just ask for a fd and return
fd isn't something new, just the an "alias" or "abstraction" of file store location in real world
Q: Is fd a key-value mapping of inode which the kernel mainteins?
wait syscall's full def
int wait(int* status);
// wait for a child to exit; exit status in *status, returns child PID
exit syscall
int exit(int status);
// terminate the current process; status reported to wait().No return
// return 0 conventionally to indicate success, 1 to indicate failure
Q: Is no return means the end of the process? FurtherMore, after exit syscall, OS is doing what during waiting(or scheding)? release resources, specific?
wait return -1 when the process has no children(when return? killed or exited)
exec syscall
"replacing the calling process's memory with a new memory image loaded file stored in the file sys"
not magic, the file must have some specific format to indicate which part of the file holds instruction, which part of the file holds data... etc
xv6 use ELF format
Link: Chapter 3
the pipe implement in suer shell
assume that we have pipe syscall already
cmd, after parse, has been given a cmd type
case PIPE:
pcmd = (struct pipecmd *)cmd;
// pipecmd is just
/*
struct pipecmd{
int* type; // enum actually
cmd* leftcmd;
cmd* right;
// Q: cmd only has an element "type", why?
}
*/
if (pipe(p) < 0)
panic("pipe");
if (fork1() == 0) {
close(1);
// close the write port
dup(p[1]);
// duplicate the write port of the pipe, now it will occupy the 1
// close the other pipe fd
close(p[0]);
close(p[1]);
runcmd(pcmd->left);
// now run the cmd
}
if (fork1() == 0) {
// same
close(0);
dup(p[0]);
close(p[0]);
close(p[1]);
runcmd(pcmd->right);
}
close(p[0]);
close(p[1]);
wait(0);
wait(0);
break;
Attention: It calls twice fork
. The left of pipe and the right of pipe are both the children of shell.
And there' s a recursion here(runcmd
):
the cmd struct only needs an element "type" because the cmd runs only when its type is "EXECCMD"
struct execcmd {
int type;
char *argv[MAXARGS];
char *eargv[MAXARGS];
};
The entire shell actual only has such a simple core in main function
// Read and run input commands.
while (getcmd(buf, sizeof(buf)) >= 0) {
// ...
if (fork1() == 0) // fork a child process
runcmd(parsecmd(buf)); // run cmd
wait(0); // wait for the child be killed or exit
}
// never return
So that's a reason why Unix(xv6) separate fork
and exec
. We need the possibility to execute some code between fork and exec, like implementing pipe(doing some operation on fd)
Also, another reason is to waste the resources: Based on COPY-ON-WRITE
xv6 allocates the memory of user implicitly using fork and exec
user process can also explicitly call sbrk
to ask for more memory
1.2
A file descriptor is a small integer representing a kernel-managed object that a process may read/write to
source:
- open: file, dir, device
- create: pipe
- dup
An abstraction of a file, device, pipe, making them all look like streams of bytes
The shell ensures three fd open(which is the shell process's stdin, stdout and stderr) (console)
There's an offset binding with each fd, making it possible to write code like
if(fork()==0){
write(fd, "Hello ", 6);
}
else{
wait(0);
write(fd, "world", 6);
}
fork && dup will copy the fd and the offset
dup is used to implement the shell command like this
[somecommand] 2>&1
which means redirect the err message to the stdout
It can be implemented by
close(2);
fd = dup(1);// so it copies the 1's fd and offset, and occupy the loc of 2(stderr).
1.3
A pipe is a kernel buffer that exposed to process as a pair of file discriptors
If there is no data available, the read side of a pipe will be blocked. A read
on a pipe waits:
- write data
- the ref count of the write fd of the pipe is 0
So it's very important to close write fd in the child process
if one of wc's fd refers to the pipe, the wc would never see the end-of-file
验证:
#include <stdio.h>
#include <unistd.h>
int main() {
// wc the std input
int p[2];
char *argv[2];
argv[0] = "wc";
argv[1] = NULL;
pipe(p);
if (fork() == 0) {
// child process
printf("switched to child process\n");
close(0);
dup(p[0]);
close(p[0]);
close(p[1]); // 此处若注释......
execvp(argv[0], argv);
} else {
printf("switched to parent process\n");
close(p[0]);
write(p[1], "hello world\n", 11);
close(p[1]);
}
return 0;
}
结果是
❯ gcc pipe.c
❯ ./a.out
switched to parent process
switched to child process
0 2 11
如果将close(p[1])
注释掉,不会输出 0 2 11, 因为 wc 根本没看到 end-of-file(管道写端引用计数不为 0),确实如此
这里其实有一些有趣的事情:
- 代码之中没有使用
wait
,因而不保证父子进程的先后顺序;但是,如果先切换到子进程,会因为管道内部没有可读的数据而堵塞,再次发生进程调度。 - 可能还有疑问,如果父进程在 write 中间发生进程切换呢?实际上是不会发生的,也就是说
write
是 atomic operation, 在 Unix 和 Linux 系统中,对于管道和普通文件的 write 操作,如果写入的数据量小于 PIPE_BUF(通常是 4096 字节),那么 write 操作是原子的
read the code of sh.c 100
Thus, the shell may create the tree of process; The leaves of tree are command and the interior nodes are processes that wait the left and the right children complete.
书下面几段对这种 fork 两次,左右中的执行顺序的 design 也做了很有意思的探讨
pipe's advtanges over temp file(why echo hi | wc
is better than echo hi > tmp; wc < tmp
)
- pipes clean themselves up
- pipes can pass arbitrarily long streams of data, while tmp file need enough space in disk and may be slower
- pipes allows paralel execution(recall the pipe tree)
- pipe's blocking reads
1.4
file system: a tree starting at root
tree node: data file: uninterpreted byte arrays
dir: a named ref of data files and other dir
chdir
and open
syscall
create new files or dirs?
open("path", O_CREATE)
mkdir("path")
mknod("path", major-dev-number, minor-dev-number)
mknod 这个 syscall 比较特殊,可以用来创建命名管道 FIFO 或者设备文件
命名管道就是带名字的管道
设备文件分成两种
在 Unix 和 Linux 系统中,设备被表示为设备文件,这是一种特殊的文件类型。设备文件分为两种:字符设备文件和块设备文件。
- 字符设备文件:提供不带缓冲的、串行的数据访问,应用程序读写的是设备提供的原始数据。键盘和鼠标等输入设备通常是字符设备。
- 块设备文件:提供带缓冲的、随机访问的数据访问,数据以块为单位进行读写。硬盘和光驱等存储设备通常是块设备。
设备文件与普通文件的主要区别在于,设备文件提供了一种访问硬件设备的接口。当你对设备文件进行读写操作时,实际上是在与相应的设备进行通信。
主设备号和次设备号是设备文件的重要属性,它们用于标识系统中的设备。
- 主设备号:用于标识设备的类型或设备驱动。例如,所有的 SCSI 硬盘都有相同的主设备号。
- 次设备号:用于标识同一类型的设备中的具体设备。例如,系统中的第一个 SCSI 硬盘的次设备号是 0,第二个 SCSI 硬盘的次设备号是 1,以此类推。
设备号是设备驱动程序使用的,应用程序通常不需要直接处理设备号。当你打开一个设备文件时,系统会根据设备文件的设备号找到相应的设备驱动,然后由设备驱动处理你的读写请求。(也就是说内核维护 了一个设备号->驱动的 jump table)
文件和文件名在文件系统之中是两个东西:
- 文件 file: 是存储在磁盘上的 metadata,或者叫 inode
- 文件名:是对文件的一个 link 或者 ref, 文件名是由文件夹 dir 来维护的一个 dir entry
struct dirent {
ushort inum;
char name[DIRSIZ];
};
#define T_DIR 1 // Directory
#define T_FILE 2 // File
#define T_DEVICE 3 // Device
struct stat {
int dev; // File system's disk device
uint ino; // Inode number
short type; // Type of file
short nlink; // Number of links to file
uint64 size; // Size of file in bytes
};
file 是由一些类型信息、权限信息、引用计数等信息+data 组成的(其中的 data 就是 inode)
struct file {
enum { FD_NONE, FD_PIPE, FD_INODE, FD_DEVICE } type;
int ref; // reference count
char readable;
char writable;
struct pipe *pipe; // FD_PIPE
struct inode *ip; // FD_INODE and FD_DEVICE
uint off; // FD_INODE
short major; // FD_DEVICE
};
// in-memory copy of an inode
struct inode {
uint dev; // Device number
uint inum; // Inode number
int ref; // Reference count
struct sleeplock lock; // protects everything below here
int valid; // inode has been read from disk?
short type; // copy of disk inode
short major;
short minor;
short nlink;
uint size;
uint addrs[NDIRECT+1];
};
link
: 给指定的 inode 加上另一个名字
open("a", O_CREATE);
link("a", "b"); // 现在名字“b”也指向“a”对应的inode了
unlink
: 删除一个名字
一个有趣的应用是创建临时文件
open("temp", O_CREATE|O_RDWR);
unlink("temp");
此时只有 open 返回的或者其它地方的拿到的 fd 还能访问临时文件
当这个进程退出,回收 fd 的时候,temp 这个文件的引用计数为 0,会被清除
一个小细节,shell 大部分的命令都是放在其他文件之中调用来实现的,即 fork+exec,但 cd 是在 shell 里面的
int main(void) {
static char buf[100];
int fd;
// Ensure that three file descriptors are open.
while ((fd = open("console", O_RDWR)) >= 0) {
if (fd >= 3) {
close(fd);
break;
}
}
// Read and run input commands.
while (getcmd(buf, sizeof(buf)) >= 0) {
if (buf[0] == 'c' && buf[1] == 'd' && buf[2] == ' ') {
// Chdir must be called by the parent, not the child.
buf[strlen(buf) - 1] = 0; // chop \n
if (chdir(buf + 3) < 0)
fprintf(2, "cannot cd %s\n", buf + 3);
continue;
}
if (fork1() == 0)
runcmd(parsecmd(buf));
wait(0);
}
exit(0);
}
因为 cd 的时候是 shell 进程 cd 而不是子进程 cd
1.5
Unix syscall interface standard: POSIX(Portable Operating System Interface)
lab01 Utils
boot xv6:照做即可,或参照踩坑记录
sleep: 知道 user.h 是 syscall 定义的地方即可,可以了解一下 syscall 是啥意思
pingpong: 注意 child process 和 parent process 的收发顺序,不要让两个管道都 block 了,除此之外,注意打印的 fd 是什么
参考代码实现
#include "kernel/stat.h"
#include "kernel/types.h"
#include "user/user.h"
int main() {
// ping-pong: two thread byte communication
// note the pipe is unidirectional
int p2c_pipe[2];
int c2p_pipe[2];
pipe(p2c_pipe);
pipe(c2p_pipe);
if ((fork()) == 0) {
// child process
close(0);
dup(p2c_pipe[0]);
close(p2c_pipe[0]);
close(p2c_pipe[1]);
const char *send = "pong\n";
char *received = malloc(5);
int pid = getpid();
read(0, received, 5);
fprintf(1, "%d: received %s", pid, received);
write(c2p_pipe[1], send, sizeof(send)); // write back
free(received);
} else {
close(0);
dup(c2p_pipe[0]);
close(c2p_pipe[0]);
close(c2p_pipe[1]);
// keep the STDOUT's fd: 1
const char *send = "ping\n";
write(p2c_pipe[1], send, sizeof(send));
char *received = malloc(5);
int pid = getpid();
read(0, received, 5);
fprintf(1, "%d: received %s", pid, received);
free(received);
}
exit(0);
}
primes: 这个 lab 有一些难度
在第一次做的时候其实没搞懂
首先要理解题目意思:背景材料给出了这样的素数并行筛法
p = get a number from left neighbor
print p
loop:
n = get a number from left neighbor
if (p does not divide n)
send n to right neighbor
其中每一次筛之间都是管道,而每一轮筛本身是一个进程
也就是说我们要在某个循环之中,完成 "从上一个管道的输入读取上一次筛剩下的数" 到 "采用读取到的第一个数作为新的筛子,并将它打印" 到 “新建管道,向管道里面写入数据”
而最关键的其实在于:
- 我们怎么样去指示读到的数是不是第一个,怎样判定前面的数已经读完和整个方法已经结束
- 我们怎么处理新建管道和新建子进程,包括它们之间的先后顺序
- 每个子进程都有自己的堆和栈,所以上述的标识符应该要么是本地量,要么是管道这种全局维护的
还有就是注意提示,例如:
- 提示里面说了
Be careful to close file descriptors that a process doesn't need, because otherwise your program will run xv6 out of resources before the first process reaches 35.
这实际上说明了当你的输出里面$
符号夹在输出中间的时候,是因为你的祖先进程没有等到所有的子进程都结束就退出了(所以 xv6 shell 认为要执行下一次命令输入,输出了$
) - Hint:
read
returns zero when the write-side of a pipe is closed. 这一条实际上提示了循环的写法
下面给出个人实现:
#include "kernel/stat.h"
#include "kernel/types.h"
#include "user/user.h"
int main() {
int curNumber = -1;
int p[2];
pipe(p);
int cnt = 0;
int div = 2;
if (fork() == 0) {
close(0);
dup(p[0]);
close(p[0]);
close(p[1]);
int newpipe[2];
int ret = 0;
int flag = 0; // if there are primes in the pipe.
// now pipe input is fd: 0
while (1) {
ret = read(0, &curNumber, sizeof(int));
// fprintf(1, "cnt=%d, curNumber=%d\n", cnt, curNumber);
// all commented fprintf are some log info
cnt++;
if (cnt == 1) {
div = curNumber; // the prime to div
pipe(newpipe); // new pipe
}
if (ret == 0) {
// the parent process has written all the numbers and exit
if (flag == 0) {
// no prime left
// fprintf(1, "over\n");
close(p[0]);
close(p[1]);
close(newpipe[0]);
close(newpipe[1]);
break;
} else if (fork() == 0) {
// the new child process(next stage)
// reset cnt and flag
cnt = 0;
flag = 0;
close(0);
dup(newpipe[0]);
close(newpipe[0]);
close(newpipe[1]);
} else {
close(0); // release the pipe and process
close(1);
close(p[0]);
close(p[1]);
wait(0); // wait for the latest process to exit, a wait "stack"
exit(0);
}
} else {
if (cnt == 1) {
// fprintf(1, "process: %d\n", getpid());
fprintf(1, "prime %d\n", curNumber);
}
if (curNumber % div != 0) {
write(newpipe[1], &curNumber, sizeof(int));
flag = 1;
}
}
}
}
close(p[0]);
for (int i = 2; i <= 35; i++) {
write(p[1], &i, sizeof(int));
}
close(p[1]);
wait(0);
exit(0);
}
实际上是,先创建 pipe,然后把数据放在 pipe 里面,等到 read 返回一个 0(父进程写完,管道读完,管道写端已经没有引用)的时候,去 fork 一个进程,然后“承接”这个管道的数据
find:
根据它的 hint 去看 ls.c,依葫芦画瓢即可
xargs:
也是理解就不难,注意不需要自己写管道和执行分词之类,只需要读输入即可
Chapter 2
An operating system must fulfill three requirements: multiplexing, isolation and interaction
xv6 runs on a multi-core RISC-V microprocessor and is written in "LP64"C
The CPU in a complete computer is surrounded by support hardware, much of it in the form of I/O interfaces
in xv6, this includes a RAM, a ROM containg boot code, a serial connection to the user's board/screen, and a disk for storage
2.1 Abstracting physical resources
why?
为什么操作系统不做成一个库?——事实上在嵌入式和一些实时系统里面是这样的
也就是说,为什么要提供强隔离性
Such a cooperative time-sharing scheme may be OK if all applications trust each other and have no bugs.It's more typical for applications to not trust each other, and to have bugs
为了实现强隔离性,避免程序对于硬件资源的直接访问是很有帮助的,也就是说操作系统需要将 resource 抽象成 service
例如在 Unix 里面,程序对磁盘没有直接的概念,读写是通过文件系统和 pathname 完成的;程序对于 CPU 和时分共享没有直接的概念,这部分是由系统的调度策略掌控的;程序对于物理内存没有直接的概念,对内存的操作是通过exec
syscall 实现的,如果内存紧张的话,一些进程的数据甚至会被存储在磁盘上
2.2 User mode, supervisor mode, and syscalls
为了维持强隔离性,用户程序必然不能改写系统程序,这是由 CPU 来实现的
硬件上提供三种 mode: machine mode, supervisor mode and user mode
其中 machine mode 在经过一些初始的引导和配置计算机的代码之后就转到 supervisor mode
然后,也是 CPU 硬件,提供了一些特权指令,如果在用户态执行了特权指令就会
"then the CPU doesn't execute the instruction, but switch to supervisor-mode code that can terminate the application"
更具体来说,CPU 会产生一个异常,然后会发生中断,根据中断向量表,跳转到对应的代码处执行
Q:代码怎么知道要终止哪个进程呢?
A:RISCV 之中有一堆中断相关的寄存器,在 xv6 这种模拟器之中是 stvec, sepc, scause,stval......这些寄存器保存了中断发生的地址,中断的原因(中断类型+编号),中断处理器状态信息,.....这是其一;中断内部一般不发生进程调度(保证了进程没有发生变化),而操作 系统是知道当前进程的(xv6 之中 proc.c 的 myproc)
通常情况下,在处理中断的过程中,操作系统不会进行进程调度。中断处理程序(ISR)的目的是快速响应硬件事件,如 I/O 完成、定时器到期等。为了保持系统的响应性,中断处理程序需要尽可能短且高效,以便尽快完成中断处理并返回到被中断的进程。
中断处理程序执行期间,CPU 处于内核模式(或称为特权模式),此时操作系统会暂停当前用户进程的执行,保存其上下文,然后执行中断处理程序。在中断处理程序执行期间,其他进程的调度和执行被暂停,直到中断处理完成。
一旦中断处理程序执行完毕,操作系统会恢复之前被中断的进程的上下文,然后继续执行该进程。这个过程称为上下文切换(Context Switching),它涉及到将 CPU 的控制权从中断处理程序切换回用户进程。
在某些情况下,如果中断处理程序执行时间较长,可能会影响系统的实时性。为了解决这个问题,操作系统可能会采用中断嵌套(Nested Interrupts)或使用软中断(Soft Interrupts)等技术来处理更复杂的中断处理逻辑,同时允许其他进程继续执行。但这些技术通常用于特定的场景,如实时操作系统(RTOS)或需要处理大量中断的系统。在大多数通用操作系统中,中断处理仍然是一个快速且不涉及进程调度的过程。
在 Linux 之中,中断可以被优先级更高的中断中断,形成中断嵌套
Linux 将中断处理程序分为上下两部分,需要紧急处理立即执行的归为上半部,不那么紧急的归为下半部。
这便涉及到了开关中断的问题。开中断,即 EFLAGS 的 IF 位置 1,表示允许响应中断;关中断,即 EFLAGS 的 IF 位置 0,表示不允许响应中断。
**1、**上半部分是刻不容缓的,需要立即执行的部分,所以要在关中断的状态下执行。
**2、**而下半部分不那么紧急,在开中断的情况下进行,如果此时有新的中断发生,当前中断处理程序便会换下 CPU,CPU 会另寻时间重新调度,完成整个中断处理程序。
而对于这样的中断嵌套,实际上是保存了前面中断的信息,也就是中断栈,在过程之中不会切到正常运行的进程
用户级指令构成了 user space,再加上特权级构成了 kernel space
运行再 kernel space 的 software 就是 kernel
用户程序不能直接执行特权指令,CPU 提供了特殊的指令(在 RISCV 里面是 ecall)来将 user mode 切换到 supervisor mode 并enter the kernel at an entry point specified by kernel(这就是 syscall)
当切换到 supervisor mode 的时候,kernel 会对 syscall 提供的 argument 做 validation,以此来决定后续的操作
2.3 Kernel organization
monolithic kernel and micro kernel: 宏内核和微内核
OS 首先要解决的设计是,有多少 OS 的部分运行在 supervisor mode?
让全部的 OS 运行在 supervisor mode 上是宏内核,例如 Linux 的思路,好处是效率(例如内核内部各模块之间的通信更简单了),坏处是对代码的更高要求(内核代码数量的膨胀,一旦内核错误就必须重启电脑)
新兴的微内核思路是 OS 把文件系统等大量部分作为 server 运行在用户态,只将必要的少量代码和 server 之间通信的代码放在 supervisor mode 下面运行,这样的好处有比如 supervisor mode 下面的代码此时少到可以做程序安全性验证
Fig2.2 xv6 kernel 各文件的用途
(各个文件近似模块化,各个模块之间的接口定义在 kernel/defs.h)
2.4 Code: xv6 organization
2.5 Process overview
The unit of isolation in xv6 (as in other Unix operating systems)is a process. The process abstraction prevents one process from wrecking or spying on other process's memory, CPU, fd, etc
The process provides the illusion to a program that it has its own private machine.(e.g own memory system(address space), own CPU, own disk)
kernel/proc.h
// Saved registers for kernel context switches.
struct context {
uint64 ra;
uint64 sp;
// callee-saved
uint64 s0;
uint64 s1;
uint64 s2;
uint64 s3;
uint64 s4;
uint64 s5;
uint64 s6;
uint64 s7;
uint64 s8;
uint64 s9;
uint64 s10;
uint64 s11;
};
// Per-CPU state.
struct cpu {
struct proc *proc; // The process running on this cpu, or null.
struct context context; // swtch() here to enter scheduler().
int noff; // Depth of push_off() nesting.
int intena; // Were interrupts enabled before push_off()?
};
extern struct cpu cpus[NCPU];
// per-process data for the trap handling code in trampoline.S.
// sits in a page by itself just under the trampoline page in the
// user page table. not specially mapped in the kernel page table.
// the sscratch register points here.
// uservec in trampoline.S saves user registers in the trapframe,
// then initializes registers from the trapframe's
// kernel_sp, kernel_hartid, kernel_satp, and jumps to kernel_trap.
// usertrapret() and userret in trampoline.S set up
// the trapframe's kernel_*, restore user registers from the
// trapframe, switch to the user page table, and enter user space.
// the trapframe includes callee-saved user registers like s0-s11 because the
// return-to-user path via usertrapret() doesn't return through
// the entire kernel call stack.
struct trapframe {
/* 0 */ uint64 kernel_satp; // kernel page table
/* 8 */ uint64 kernel_sp; // top of process's kernel stack
/* 16 */ uint64 kernel_trap; // usertrap()
/* 24 */ uint64 epc; // saved user program counter
/* 32 */ uint64 kernel_hartid; // saved kernel tp
/* 40 */ uint64 ra;
/* 48 */ uint64 sp;
/* 56 */ uint64 gp;
/* 64 */ uint64 tp;
/* 72 */ uint64 t0;
/* 80 */ uint64 t1;
/* 88 */ uint64 t2;
/* 96 */ uint64 s0;
/* 104 */ uint64 s1;
/* 112 */ uint64 a0;
/* 120 */ uint64 a1;
/* 128 */ uint64 a2;
/* 136 */ uint64 a3;
/* 144 */ uint64 a4;
/* 152 */ uint64 a5;
/* 160 */ uint64 a6;
/* 168 */ uint64 a7;
/* 176 */ uint64 s2;
/* 184 */ uint64 s3;
/* 192 */ uint64 s4;
/* 200 */ uint64 s5;
/* 208 */ uint64 s6;
/* 216 */ uint64 s7;
/* 224 */ uint64 s8;
/* 232 */ uint64 s9;
/* 240 */ uint64 s10;
/* 248 */ uint64 s11;
/* 256 */ uint64 t3;
/* 264 */ uint64 t4;
/* 272 */ uint64 t5;
/* 280 */ uint64 t6;
};
enum procstate { UNUSED, SLEEPING, RUNNABLE, RUNNING, ZOMBIE };
// Per-process state
struct proc {
struct spinlock lock;
// p->lock must be held when using these:
enum procstate state; // Process state
struct proc *parent; // Parent process
void *chan; // If non-zero, sleeping on chan
int killed; // If non-zero, have been killed
int xstate; // Exit status to be returned to parent's wait
int pid; // Process ID
// these are private to the process, so p->lock need not be held.
uint64 kstack; // Virtual address of kernel stack
uint64 sz; // Size of process memory (bytes)
pagetable_t pagetable; // User page table
struct trapframe *trapframe; // data page for trampoline.S
struct context context; // swtch() here to run process
struct file *ofile[NOFILE]; // Open files
struct inode *cwd; // Current directory
char name[16]; // Process name (debugging)
};
-
每个 process 有自己的 pagetable, which defines each process's address space
-
At the top of the address space xv6 reserves a page for a trampoline and a map mapping the process's trapframe(详细的解释留待 Chapter4 补). Xv6 use these two pages to transition into the kernel and back;
-
process vs thread: thread**线程是“give the process an illusion of its own CPU”**对 CPU 的抽象,所以当我们讨论进程的切换和调度的时候,实际上是在讨论不同进程之间 thread 的状态的变化,切换进程就是将这个进程的 thread 挂起(suspend),再切换到另一个进程的某个线程执行。xv6 之中一个 process 只有一个 thread,但是现代的系统有多个 thread 来利用多核 cpu(Linux 的 thread 概念和 Windows 又有一些不同,这里是 Linux 的 thread 概念)
-
每个进程有自己的 kernel stack 和 user stack,当 syscall/IO 等,需要进入 kernel 的时候,内核的代码放在 kernel stack 里面执行,user stack 是维持不动的(实际上在 ecall 指令发起的时候,代码就不在用户态了,那执行从用户态->内核态这部分代码也要使用寄存器,如何保存原有寄存器的值呢?这会在 lab4 之中讲解,大体来说是使用特殊的额外寄存器(存放 trapframe 基址)和对应的特殊指令,先把 a0 上的值和额外寄存器的值做交换,然后我们就有了一个可用的寄存器,同时在额外寄存器上保留了原来的地址,然后通过 a0 相对寻址,就可以将其他寄存器的值保存在 trapframe 的 page 上,最后从额外寄存器读回来 a0 并保存)
2.6 Code: starting xv6, the first process and system call
启动 xv6 的过程概述:
- When the risc-v computer powers on, it initializes itself and start boot loader in read-only memory.The boot loader load xv6 kernel into memory.(这部分应该是 qemu 模拟的)
- in machine mode, the CPU executes xv6 at _entry(kernel/entry.S)
# qemu -kernel loads the kernel at 0x80000000
# and causes each CPU to jump there.
# kernel.ld causes the following code to
# be placed at 0x80000000.
.section .text
_entry:
# set up a stack for C.
# stack0 is declared in start.c,
# with a 4096-byte stack per CPU.
# sp = stack0 + (hartid * 4096)
la sp, stack0
li a0, 1024*4
csrr a1, mhartid
addi a1, a1, 1
mul a0, a0, a1
add sp, sp, a0
# jump to start() in start.c
call start
spin:
j spin
其中有三个注意的地方:一是从 0x80000000 开始是因为 0x0 到 0x80000000 留下来做 IO 用了;二个是 start 函数“performs some configuration in machine mode and then switches to supervisor mode”;三是此时页表是被禁用的,都是 direct-mapping
start 还干了启用中断,启用时钟,设置返回到 main 等操作
// entry.S jumps here in machine mode on stack0.
void
start()
{
// set M Previous Privilege mode to Supervisor, for mret.
unsigned long x = r_mstatus();
x &= ~MSTATUS_MPP_MASK;
x |= MSTATUS_MPP_S;
w_mstatus(x);
// set M Exception Program Counter to main, for mret.
// requires gcc -mcmodel=medany
w_mepc((uint64)main); // 返回到main(设置pc)
// disable paging for now.
w_satp(0);
// delegate all interrupts and exceptions to supervisor mode.
w_medeleg(0xffff);
w_mideleg(0xffff);
w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE);
// ask for clock interrupts.
timerinit();
// keep each CPU's hartid in its tp register, for cpuid().
int id = r_mhartid();
w_tp(id);
// switch to supervisor mode and jump to main().
asm volatile("mret");
}
然后进入到 main
#include "types.h"
#include "param.h"
#include "memlayout.h"
#include "riscv.h"
#include "defs.h"
volatile static int started = 0;
// start() jumps here in supervisor mode on all CPUs.
void
main()
{
if(cpuid() == 0){
consoleinit();
printfinit();
printf("\n");
printf("xv6 kernel is booting\n");
printf("\n");
kinit(); // physical page allocator
kvminit(); // create kernel page table
kvminithart(); // turn on paging
procinit(); // process table
trapinit(); // trap vectors
trapinithart(); // install kernel trap vector
plicinit(); // set up interrupt controller
plicinithart(); // ask PLIC for device interrupts
binit(); // buffer cache
iinit(); // inode cache
fileinit(); // file table
virtio_disk_init(); // emulated hard disk
userinit(); // first user process
__sync_synchronize();
started = 1;
} else {
while(started == 0)
;
__sync_synchronize();
printf("hart %d starting\n", cpuid());
kvminithart(); // turn on paging
trapinithart(); // install kernel trap vector
plicinithart(); // ask PLIC for device interrupts
}
scheduler();
}
先是一些设置的初始化
初始化完毕后,调用 userinit()进入第一个进程
// Set up first user process.
void
userinit(void)
{
struct proc *p;
p = allocproc();
initproc = p;
// allocate one user page and copy init's instructions
// and data into it.
uvminit(p->pagetable, initcode, sizeof(initcode));
p->sz = PGSIZE;
// prepare for the very first "return" from kernel to user.
p->trapframe->epc = 0; // user program counter
p->trapframe->sp = PGSIZE; // user stack pointer
safestrcpy(p->name, "initcode", sizeof(p->name));
p->cwd = namei("/");
p->state = RUNNABLE;
release(&p->lock);
}
其中
// a user program that calls exec("/init")
// od -t xC initcode
uchar initcode[] = {
0x17, 0x05, 0x00, 0x00, 0x13, 0x05, 0x45, 0x02,
0x97, 0x05, 0x00, 0x00, 0x93, 0x85, 0x35, 0x02,
0x93, 0x08, 0x70, 0x00, 0x73, 0x00, 0x00, 0x00,
0x93, 0x08, 0x20, 0x00, 0x73, 0x00, 0x00, 0x00,
0xef, 0xf0, 0x9f, 0xff, 0x2f, 0x69, 0x6e, 0x69,
0x74, 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00
};
是这玩意的二进制
# Initial process that execs /init.
# This code runs in user space.
#include "syscall.h"
# exec(init, argv)
.globl start
start:
la a0, init
la a1, argv
li a7, SYS_exec
ecall
# for(;;) exit();
exit:
li a7, SYS_exit
ecall
jal exit
# char init[] = "/init\0";
init:
.string "/init\0"
# char *argv[] = { init, 0 };
.p2align 2
argv:
.long init
.long 0
然后通过调用 SYS_exec 进行 syscall 再次进入内核态执行 init.c,启动 shell,the system is up
// init: The initial user-level program
#include "kernel/types.h"
#include "kernel/stat.h"
#include "kernel/spinlock.h"
#include "kernel/sleeplock.h"
#include "kernel/fs.h"
#include "kernel/file.h"
#include "user/user.h"
#include "kernel/fcntl.h"
char *argv[] = { "sh", 0 };
int
main(void)
{
int pid, wpid;
if(open("console", O_RDWR) < 0){
mknod("console", CONSOLE, 0);
open("console", O_RDWR);
}
dup(0); // stdout
dup(0); // stderr
for(;;){
printf("init: starting sh\n");
pid = fork();
if(pid < 0){
printf("init: fork failed\n");
exit(1);
}
if(pid == 0){
exec("sh", argv);
printf("init: exec sh failed\n");
exit(1);
}
for(;;){
// this call to wait() returns if the shell exits,
// or if a parentless process exits.
wpid = wait((int *) 0);
if(wpid == pid){
// the shell exited; restart it.
break;
} else if(wpid < 0){
printf("init: wait returned an error\n");
exit(1);
} else {
// it was a parentless process; do nothing.
}
}
}
}
后面还要回来加深理解,还是迷迷糊糊的
2.7 Security Model & 2.8 Real World
闲聊段
Chapter 3: Page tables
3.1 Paging Hardware
page table: allow xv6 to isolate different address space to multiplex them onto a single physical memory
xv6 performs a few tricks: mapping the same memory(a trampoline page)in several address space, and guarding kernel and user stacks with an unmapped page
xv6: Sv39 RISCV
Fig 3.1
VA:从高位到低位 25 位额外位(不参与转 换), 27 位页表 index, 12 位 Offset
PTE: 10 位可拓展位,3 级页表 * 9 位 index(512^3^条),每条 PTE 44 位 PPN(Physical Page Number),10 位标志位 Flag
PA: 44 位 PPN+12 位 Offset
三级页表:
Pros:空间效率,只有被使用的页(VALID 标志位为 1),才会被分配内存,相当于页表树上,许多叶子的深度都只有 1 或 2
Cons:一条指令必须在 PTE 之中走三次(通过移位操作),降低了一部分时间效率
补偿:TLB(Translation Look-Aside Buffer)
important Fig 3.2 detail
告诉硬件启用页表:kernel 把最高级页表的基址存入 satp register
每个 CPU 都有自己的 satp,所以不同 CPU 可以运行不同的进程(各自有自己的地址空间和页表)
每个 process(包括内核)读写的都是 VA,VA 通过进程自己的页表转换成 PA,再去读写实际的物理存储
内核应该保持对整个物理存储的操控权,所以整个物理的存储空间都被映射到了内核的页表之中,而 xv6 为了实现的简洁性,基本都是直接映射,也就是 VA=PA,这样在内核之中读写 VA 就等同于读写 PA 了。
3.2 Kernel address space
内核总要知道各个设备的 PA 和它自己页表 VA 的关系,这个实际上是一种配置或者约定(QEMU 的配置)
这里
The kernel gets at RAM and memory-mapped device registers using "direct mapping".That is, mapping the resources at virtual addresses that are equal to physical addresses
简化内核读写的代码,比如 fork 实际上给子进程申请到的是 PA(物理内存),但是 内核之后就直接把它当作是虚拟地址在对他进行代码操作了
在 fork 调用的 uvmcopy 之中
if ((mem = kalloc()) == 0)
goto err; // 这里的mem是PA(kalloc返回的地址)
memmove(mem, (char *)pa, PGSIZE); // 这里就直接把mem当VA了(一个进程自己“看到”的是VA)
两个不是直接映射的内存页
-
The trampoline page: 它在每一个进程的虚拟内存上都占据了最顶端的一页,所以在内核的 VA 地址空间之中,它两次映射到同一个 PA,一次是内核的 kernel text 代码段,另一次是 kernel 的 VA 的最顶端
-
The guard page: 每个进程的内核栈下面有一个 guard page,它的 PTE_V 设置为了 0,防溢出,如果栈溢出了就会产生 pagefault
此时内核栈对应的 PA 也被两个 VA 映射,一个是 kernel data 段的直接映射;另一个是 VA 顶部的 Kstack 段
为什么要维持这样两个映射是因为,如果只有 kernel data 段的直接映射,那我们想要加上 guard page,就必须要将这一段设置成 invalid,也就是取消 guard page 的映射;那莫名其妙地,RAM 里面的某部分物理内存对内核就不能用了
所以要扣扣搜搜,两个映射之后,内核的保护页不放在实际的 kernel data 段,而是放在 VA 的 top 段,而并没有实际“对应”的物理页,省下了不少页面的内存,也保证了物理页的连续性
(本质上就是:
-
想保证 kernel data/text 段是直接映射的
-
想保证物理内存 RAM 的使用是连续的
-
想要加 guard page
如果没有两次映射,12 和 3 不能同时成立;
)
3.3 Code:creating an address space
kvm 相关的函数建立在 kernel 的直接映射之上
例如 walk 里面
if (!alloc || (pagetable = (pde_t *)kalloc()) == 0) // 这里的pagetable是PA
return 0;
memset(pagetable, 0, PGSIZE); // 传进入当函数参数的是VA
什么时候页表被启用呢?kvminithart 函数被调用的时候
kinit(); // physical page allocator
kvminit(); // create kernel page table
kvminithart(); // turn on paging
也就是我们先 kvminit()初始化 kernel pagetable(把整个 RAM 全部映射到 kernel pagetable)
对照上面的图理解下面的代码
/*
* the kernel's page table.
*/
pagetable_t kernel_pagetable;
extern char etext[]; // kernel.ld sets this to end of kernel code.
extern char trampoline[]; // trampoline.S
/*
* create a direct-map page table for the kernel.
*/
void kvminit() {
kernel_pagetable = (pagetable_t)kalloc();
memset(kernel_pagetable, 0, PGSIZE);
// uart registers
kvmmap(UART0, UART0, PGSIZE, PTE_R | PTE_W);
// virtio mmio disk interface
kvmmap(VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
// CLINT
kvmmap(CLINT, CLINT, 0x10000, PTE_R | PTE_W);
// PLIC
kvmmap(PLIC, PLIC, 0x400000, PTE_R | PTE_W);
// map kernel text executable and read-only.
kvmmap(KERNBASE, KERNBASE, (uint64)etext - KERNBASE, PTE_R | PTE_X);
// map kernel data and the physical RAM we'll make use of.
kvmmap((uint64)etext, (uint64)etext, PHYSTOP - (uint64)etext,
PTE_R | PTE_W);
// map the trampoline for trap entry/exit to
// the highest virtual address in the kernel.
kvmmap(TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
}
然后启用页表
void kvminithart() {
w_satp(MAKE_SATP(kernel_pagetable)); // write the stap register
sfence_vma(); // clean TLB, 这个函数实际上是对RISCV指令sfence.vma的模拟
}
实际的 RISCV CPU 还会有一些地址空间标识符,使进程切换的时候不需要冲洗整个 TLB
3.4 Physical memory allocation
3.5 Code: Physical memory allocator
维护了一个 struct run 的链表 freelist,然后每次分配就是取链表的第一个元素,free 就是将该页连接到链表末尾
每次分配和 free 都会 acquire spinlock,来维持并发的 robust
3.6 Process address space
讲完了内核空间的处理方法,接下来讲 user space,也就是各个用户进程
主要是用户进程的栈,在 xv6 之中就是简单的 single page 的实现,参见 exec.c
这个 page 上从顶到底放着
- argument 0~N (N 个字符串,argv[]的值)
- address of argument (N 个 pointer)
- argc
- return PC
- ...(其他)
然后这个 stack page 下面还有一个 guard page,它的 PTE_V 是 0,用于在 stack overflow 的时候产生 pagefault
Q:也就是 xv6 的用户程序的栈的大小最多只有 4kb?
A:测试了一下,是的
在用户程序里面加上
int a[100000];
for (int i = 0; i < 100000; i++)
a[i] = i;
fprintf(1, "a:alloc %d pages.\n", sizeof(a) / PGSIZE);
fprintf(1, "the address of a is %p", a);
就会出现
$ sleep
usertrap(): unexpected scause 0x000000000000000f pid=4
sepc=0x000000000000002e stval=0xfffffffffffa1540
scause 寄存器记录了这次异常的原因
查询riscv 手册可知, 000f 是 Store/AMO pagefault,验证了我们栈溢出的猜想
AMO page fault(Atomic Memory Operation page fault)是指在 RISC-V 架构中,当执行原子内存操作(如
amoadd
、amoxor
等)时,由于访问的虚拟地址对应的物理页没有映射到物理内存(即没有对应的物理页帧),或者访问的页没有写权限,而触发的页错误(Page Fault)异常。在 RISC-V 中,原子内存操作(AMO)是一种特殊的内存访问指令,它允许在多处理器系统中同步地执行原子操作,如加法、减法、交换等,而不需要额外的同步机制。这些操作通常用于实现锁和其他并发控制结构。
当处理器尝试执行 AMO 指令时,如果遇到以下情况之一,就会触发 AMO page fault:
- 虚拟地址未映射:处理器尝试访问的虚拟地址在当前进程的页表中没有找到对应的物理地址映射。
- 写权限不足:即使虚拟地址已经映射到物理内存,但该页没有写权限(在页表项中,写权限位 W 被设置为 0)。
在发生 AMO page fault 时,处理器会将控制权交给操作系统的中断处理程序(在 RISC-V 中称为中断向量),操作系统需要处理这个异常。处理程序会检查导致异常的虚拟地址,如果地址合法,操作系统可能会从磁盘或其他存储介质中加载缺失的页到物理内存,并更新页表以赋予相应的权限。如果地址不合法,操作系统可能会终止相关进程。
AMO page fault 是 RISC-V 架构中处理并发和同步问题的一部分,它确保了在多处理器系统中原子操作的正确性和一致性。
但有个奇怪的地方,如果没有
fprintf(1, "the address of a is %p", a);
这一行
实际上不会触发, 查看了 fprintf 内部,并没有什么奇怪的地方
怀疑是如果不调用 fprintf 的话,编译器优化了这一段无用代码?
3.7 Code:sbrk
上面固定了 Stack 是一个 PAGE 之后有一个好处就是大大简化了后面的内存分配
之前看得不仔细,没看到栈只有一页,一直没太搞懂为啥它的 growproc 给了一个 sz 作为参数,一个进程的 sz(如果是传统的栈从上往下增长,堆从下往上增长的模型)本身就令人迷惑,allocproc 的时候尚可解释为是堆+栈+其他,dealloc 的时候如何根据减小的 sz 直接确定释放的页呢?还有就是它的 sz 是如何动态监控栈的增长的呢?
答案是根本没有监控,上面的 text 段是代码文本,data 段是全局变量,都是编译时确定的东西,而 stack 只有一个 page,guard page 也只有一个 page,也就是说运行前,堆底部有多少个 page 已经被指定,并且栈在堆下面(看图),我们的内存分配变成只对堆操作了,这个 sz 也变成了很直接的从底部向上数的 page 数量(看源码可以知道,trampoline page 和 trapframe page 没有被计入 p->sz 里面,而是被放在 kalloc 的 kernel 的空间里面,不需要 user process 知道相关的信息)
这也是页表的另一个作用体现的时候:
xv6 的所有内存分配都是通过 kalloc()实现的,如何知晓 user process 是否有权释放某个物理空间呢?又或者说怎么维护哪个物理空间是被哪个进程拿到的呢?就是通过页表,在释放空间之前进行此进程的页表上到底有没有这个合法 PA 的检查(uvmunmap)
if ((pte = walk(pagetable, a, 0)) == 0)
panic("uvmunmap: walk");
if ((*pte & PTE_V) == 0)
panic("uvmunmap: not mapped");
if (PTE_FLAGS(*pte) == PTE_V)
panic("uvmunmap: not a leaf");
3.8 Code:exec
exec.c 源码
#include "types.h"
#include "param.h"
#include "memlayout.h"
#include "riscv.h"
#include "spinlock.h"
#include "proc.h"
#include "defs.h"
#include "elf.h"
static int loadseg(pde_t *pgdir, uint64 addr, struct inode *ip, uint offset, uint sz);
int
exec(char *path, char **argv)
{
char *s, *last;
int i, off;
uint64 argc, sz = 0, sp, ustack[MAXARG+1], stackbase;
struct elfhdr elf;
struct inode *ip;
struct proghdr ph;
pagetable_t pagetable = 0, oldpagetable;
struct proc *p = myproc();
begin_op();
if((ip = namei(path)) == 0){
end_op();
return -1;
}
ilock(ip);
// Check ELF header
if(readi(ip, 0, (uint64)&elf, 0, sizeof(elf)) != sizeof(elf))
goto bad;
if(elf.magic != ELF_MAGIC)
goto bad;
if((pagetable = proc_pagetable(p)) == 0)
goto bad;
// Load program into memory.
for(i=0, off=elf.phoff; i<elf.phnum; i++, off+=sizeof(ph)){
if(readi(ip, 0, (uint64)&ph, off, sizeof(ph)) != sizeof(ph))
goto bad;
if(ph.type != ELF_PROG_LOAD)
continue;
if(ph.memsz < ph.filesz)
goto bad;
if(ph.vaddr + ph.memsz < ph.vaddr)
goto bad;
uint64 sz1;
if((sz1 = uvmalloc(pagetable, sz, ph.vaddr + ph.memsz)) == 0)
goto bad;
sz = sz1;
if(ph.vaddr % PGSIZE != 0)
goto bad;
if(loadseg(pagetable, ph.vaddr, ip, ph.off, ph.filesz) < 0)
goto bad;
}
iunlockput(ip);
end_op();
ip = 0;
p = myproc();
uint64 oldsz = p->sz;
// Allocate two pages at the next page boundary.
// Use the second as the user stack.
sz = PGROUNDUP(sz);
uint64 sz1;
if((sz1 = uvmalloc(pagetable, sz, sz + 2*PGSIZE)) == 0)
goto bad;
sz = sz1;
uvmclear(pagetable, sz-2*PGSIZE);
sp = sz;
stackbase = sp - PGSIZE;
// Push argument strings, prepare rest of stack in ustack.
for(argc = 0; argv[argc]; argc++) {
if(argc >= MAXARG)
goto bad;
sp -= strlen(argv[argc]) + 1;
sp -= sp % 16; // riscv sp must be 16-byte aligned
if(sp < stackbase)
goto bad;
if(copyout(pagetable, sp, argv[argc], strlen(argv[argc]) + 1) < 0)
goto bad;
ustack[argc] = sp;
}
ustack[argc] = 0;
// push the array of argv[] pointers.
sp -= (argc+1) * sizeof(uint64);
sp -= sp % 16;
if(sp < stackbase)
goto bad;
if(copyout(pagetable, sp, (char *)ustack, (argc+1)*sizeof(uint64)) < 0)
goto bad;
// arguments to user main(argc, argv)
// argc is returned via the system call return
// value, which goes in a0.
p->trapframe->a1 = sp;
// Save program name for debugging.
for(last=s=path; *s; s++)
if(*s == '/')
last = s+1;
safestrcpy(p->name, last, sizeof(p->name));
// Commit to the user image.
oldpagetable = p->pagetable;
p->pagetable = pagetable;
p->sz = sz;
p->trapframe->epc = elf.entry; // initial program counter = main
p->trapframe->sp = sp; // initial stack pointer
proc_freepagetable(oldpagetable, oldsz);
return argc; // this ends up in a0, the first argument to main(argc, argv)
bad:
if(pagetable)
proc_freepagetable(pagetable, sz);
if(ip){
iunlockput(ip);
end_op();
}
return -1;
}
// Load a program segment into pagetable at virtual address va.
// va must be page-aligned
// and the pages from va to va+sz must already be mapped.
// Returns 0 on success, -1 on failure.
static int
loadseg(pagetable_t pagetable, uint64 va, struct inode *ip, uint offset, uint sz)
{
uint i, n;
uint64 pa;
if((va % PGSIZE) != 0)
panic("loadseg: va must be page aligned");
for(i = 0; i < sz; i += PGSIZE){
pa = walkaddr(pagetable, va + i);
if(pa == 0)
panic("loadseg: address should exist");
if(sz - i < PGSIZE)
n = sz - i;
else
n = PGSIZE;
if(readi(ip, 0, (uint64)pa, offset+i, n) != n)
return -1;
}
return 0;
}
它先后干了这么几件事
-
使用
namei
和文件系统交互,拿到了path
文件的inode
-
检查文件的 ELF 编码(使用 int(4 Byte) ELF magic number, '0x7F','E','L','F'),并从文件之中先读取相关数据,例如文件的 offset 和结束位置、物理地址等
-
特别地,有
if(ph.vaddr + ph.memsz < ph.vaddr) goto bad;
的检查; -
检查完毕后,使用
loadseg
将文件的内容读入内核数据(进程相关)和准备的页表 -
再去按照 user space 去分配 user process 的一些 page,并将前面约定的一些 args 的参数以约定顺序填入,并填好进程新的 sz,按照文件的 elf 给的数据填程序的入口(main),初始化用户栈指针
-
如果到最后也没出错,就更新整个进程(commit image);否则撤销前面的操作。这也就是保证相关操作的原子性
ps 关于有if(ph.vaddr + ph.memsz < ph.vaddr) goto bad;
的检查,书上说了不少
如果没有这个检查,那么攻击者可以伪造一个 ph.vaddr 和 ph.memsz,使得它们加起来溢出,从而达到 0x1000 这种 kernel space;而又因为 exec 是 syscall,原来没有权限的用户进程现在有了权限,exec 会使用 loadseg 将 elf 文件里面的内 容写到特定的 kernel space 之中,就完成了攻击
这段代码值得多看,很漂亮
3.9 Real world
现实世界的 OS 有以下 xv6 没有的机制:
- 使用 pagefault 配合 paging 来进行权限保护(xv6 只使用 paging)
- kernel 位置的随机化来防范攻击(xv6 的 kernel 是 direct-map+ 0x80000000 的固定 RAM)
- RISCV 提供的物理页的硬件保护
- 超级页(super page),来提高大块页的页表相关操作效率(比如内核)
- 更精巧的内存分配策略:例如处理不同大小的内存请求,给不同大小的内存块;而不是 xv6 始终固定给 PAGESIZE 的倍数(这样对于频繁小请求表现不好)
Chapter 4: Traps and system calls
ps(看代码时可能有用):hartid 是 hardware thread id 的缩写,在 xv6 之中是 cpu 的 id,tp 寄存器里面会有它的值
控制流在三种情况下可能发生转移:
- syscall
- exception:something illegeal
- device interrupt: indicate the device needs attention(like the finish of IO)
统称这三种(系统调用,异常,中断)为 trap,在 xv6 之中,所有的 trap 都是 kernel 处理的
RISCV 文档给出的定义见下:
We use the term exception to refer to an unusual condition occurring at run time associated with an instruction in the current RISC-V thread. We use the term trap to refer to the synchronous transfer of control to a trap handler caused by an exceptional condition occurring within a RISC-V thread. Trap handlers usually execute in a more privileged environment. We use the term interrupt to refer to an external event that occurs asynchronously to the current RISC-V thread. When an interrupt that must be serviced occurs, some instruction is selected to receive an interrupt exception and subsequently experiences a trap.
trap 的一般流程是:
- 转移控制权到 kernel
- kernel 保存用户进程寄存器状态和其它状态以便返回
- 调用合适的 handler code
- 返回
不同的 trap 最好有不同的 handler code,比如 user space 的 trap, kernel 的 trap,timer interrupt 等等
4.1 RISC-V trap machinery
kernel 怎么知道如何处理 trap 呢?
RISCV 有许多特殊的寄存器(kernel/riscv.h)用于记录和 trap 有关的信息、
- stvec:The kernel write the address of its trap handler here
- sepc: When(pc) a trap occurs
- scause: A number describe the reason of trap
- sscratch: 用于保存上下文的额外寄存器
- sstatus: 其中的 SIE bit 标志着是否允许设备中断,其中的 SPP bit 标志着中断是从 user mode 还是 super mode 触发的(同时这也决定了 sret 会回到什么 mode)
具体细节:
- If the trap is a device interrupt and the sstatus SIE bit is clear, don't do any of the following*
- Disable interrupts by clearing the SIE bit in sstatus
- Copy the pc to sepc
- Save the current mode (user or supervisor mode) in SPP bit in sstatus
- Set scause to reflect the trap's cause
- Set the mode to supervisor
- Copy the stvec to the pc
- Start executing at new pc
注意 CPU没做切换内核页表,没做使用内核栈,没做保存和更新 pc 之外的寄存器,这些都留给 kernel software 去完成,简化设计,留下灵活性
4.2 Traps from user space
我们不想让用户代码介入到这里的 user/kernel 切换,否则有可能会破坏安全性。所以这意味着,trap 中涉及到的硬件和内核机制不能依赖任何来自用户空间东西。比如说我们不能依赖 32 个用户寄存器,它们可能保存的是恶意的数据,所以,XV6 的 trap 机制不会查看这些寄存器,而只是将它们保存起来。
trap 代码执行流程
syscall:以 write 为例
write->查找该函数->usys.pl 产生的汇编(.global write)
#!/usr/bin/perl -w
# Generate usys.S, the stubs for syscalls.
print "# generated by usys.pl - do not edit\n";
print "#include \"kernel/syscall.h\"\n";
sub entry {
my $name = shift; # shift是perl脚本之中第一个参数的意思
print ".global $name\n";
print "${name}:\n";
print " li a7, SYS_${name}\n";
print " ecall\n";
print " ret\n";
}
entry("write");
// ......
->ecall->trampoline.S
Q:这一步是怎么过去的?(下面整个流程挺长的)
我查询了一下 RISC 特权指令文档
The ECALL instruction is used to make a request to the supporting execution environment, which is usually an operating system. The ABI for the system will define how parameters for the environment request are passed, but usually these will be in defined locations in the integer register file.
文档说让 OS 设计者自己决定怎么传参数
lec4 里面说 ecall 干了三件事(这是硬件支持的)
- 将代码从 user mode 改到 supervisor mode
- 将 pc 保存到 sepc
- 跳转到 stvec 指向的指令
那 stvec 指向哪里呢?
根据我们 Chapter 2 里面讲的那个流程往下找,entry.S->start()->main->userinit (注意 ecall 需要在 user space 调用)
-> allocproc->proc_pagetable
// Create a user page table for a given process,
// with no user memory, but with trampoline pages.
pagetable_t proc_pagetable(struct proc *p) {
pagetable_t pagetable;
// An empty page table.
pagetable = uvmcreate();
if (pagetable == 0)
return 0;
// map the trampoline code (for system call return)
// at the highest user virtual address.
// only the supervisor uses it, on the way
// to/from user space, so not PTE_U.
if (mappages(pagetable, TRAMPOLINE, PGSIZE, (uint64)trampoline,
PTE_R | PTE_X) < 0) {
uvmfree(pagetable, 0);
return 0;
}
// map the trapframe just below TRAMPOLINE, for trampoline.S.
if (mappages(pagetable, TRAPFRAME, PGSIZE, (uint64)(p->trapframe),
PTE_R | PTE_W) < 0) {
uvmunmap(pagetable, TRAMPOLINE, 1, 0);
uvmfree(pagetable, 0);
return 0;
}
return pagetable;
}
这里已经发现每个 proc 都会在 alloc 的时候有 TRAMPOLINE 的 PTE 了,那到底是在哪里改变了 stvec 呢?
void trapinit(void) { initlock(&tickslock, "time"); }
// set up to take exceptions and traps while in the kernel.
void trapinithart(void) { w_stvec((uint64)kernelvec); }
//
// handle an interrupt, exception, or system call from user space.
// called from trampoline.S
//
void usertrap(void) {
int which_dev = 0;
if ((r_sstatus() & SSTATUS_SPP) != 0)
panic("usertrap: not from user mode");
// send interrupts and exceptions to kerneltrap(),
// since we're now in the kernel.
w_stvec((uint64)kernelvec);
struct proc *p = myproc();
// save user program counter.
p->trapframe->epc = r_sepc();
if (r_scause() == 8) {
// system call
if (p->killed)
exit(-1);
// sepc points to the ecall instruction,
// but we want to return to the next instruction.
p->trapframe->epc += 4;
// an interrupt will change sstatus &c registers,
// so don't enable until done with those registers.
intr_on();
syscall();
} else if ((which_dev = devintr()) != 0) {
// ok
} else {
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
p->killed = 1;
}
if (p->killed)
exit(-1);
// give up the CPU if this is a timer interrupt.
if (which_dev == 2)
yield();
usertrapret();
}
//
// return to user space
//
void usertrapret(void) {
struct proc *p = myproc();
// we're about to switch the destination of traps from
// kerneltrap() to usertrap(), so turn off interrupts until
// we're back in user space, where usertrap() is correct.
intr_off();
// send syscalls, interrupts, and exceptions to trampoline.S
w_stvec(TRAMPOLINE + (uservec - trampoline));
// set up trapframe values that uservec will need when
// the process next re-enters the kernel.
p->trapframe->kernel_satp = r_satp(); // kernel page table
p->trapframe->kernel_sp = p->kstack + PGSIZE; // process's kernel stack
p->trapframe->kernel_trap = (uint64)usertrap;
p->trapframe->kernel_hartid = r_tp(); // hartid for cpuid()
// set up the registers that trampoline.S's sret will use
// to get to user space.
// set S Previous Privilege mode to User.
unsigned long x = r_sstatus();
x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode
x |= SSTATUS_SPIE; // enable interrupts in user mode
w_sstatus(x);
// set S Exception Program Counter to the saved user pc.
w_sepc(p->trapframe->epc);
// tell trampoline.S the user page table to switch to.
uint64 satp = MAKE_SATP(p->pagetable);
// jump to trampoline.S at the top of memory, which
// switches to the user page table, restores user registers,
// and switches to user mode with sret.
uint64 fn = TRAMPOLINE + (userret - trampoline);
((void (*)(uint64, uint64))fn)(TRAPFRAME, satp);
}
很阴险啊,这个 stvec 是在 forkret 调用的 userret 里面改掉的
userinit 并没有改掉 stvec
具体地说,回看 init.c 的代码
int main(void) {
// ...
for (;;) {
printf("init: starting sh\n");
pid = fork();
if (pid < 0) {
printf("init: fork failed\n");
exit(1);
}
if (pid == 0) {
exec("sh", argv);
printf("init: exec sh failed\n");
exit(1);
}
// ...
shell 实际上是 init 进程 fork 的子进程
有趣的是,看 syscall.c 可以发现,kernel/proc 里面的 fork 函数实际上是“sys_fork”,用户的 syscall fork 只不过是直接再调用一次 fork 罢了
// A fork child's very first scheduling by scheduler()
// will swtch to forkret.
void forkret(void) {
static int first = 1;
// Still holding p->lock from scheduler.
release(&myproc()->lock);
if (first) {
// File system initialization must be run in the context of a
// regular process (e.g., because it calls sleep), and thus cannot
// be run from main().
first = 0;
fsinit(ROOTDEV);
}
usertrapret();
}
这个 forkret 在只在 allocproc 里面被调用(fork 和 uvminit 调用了 allocproc)
memset(&p->context, 0, sizeof(p->context));
p->context.ra = (uint64)forkret;
p->context.sp = p->kstack + PGSIZE;
init 进程是不会返回的,但是在 scheduler 里面,会调用 swtch()
p->state = RUNNING;
c->proc = p;
swtch(&c->context, &p->context);
然后 swtch 会调用下面的 swtch.S
# Context switch
#
# void swtch(struct context *old, struct context *new);
#
# Save current registers in old. Load from new.
.globl swtch
# a0 是c->context的地址,a1是p->context的地址
swtch:
# 把当前cpu的寄存器数据存下来
sd ra, 0(a0)
sd sp, 8(a0)
sd s0, 16(a0)
sd s1, 24(a0)
sd s2, 32(a0)
sd s3, 40(a0)
sd s4, 48(a0)
sd s5, 56(a0)
sd s6, 64(a0)
sd s7, 72(a0)
sd s8, 80(a0)
sd s9, 88(a0)
sd s10, 96(a0)
sd s11, 104(a0)
# 加载调度的p的寄存器数据
ld ra, 0(a1)
ld sp, 8(a1)
ld s0, 16(a1)
ld s1, 24(a1)
ld s2, 32(a1)
ld s3, 40(a1)
ld s4, 48(a1)
ld s5, 56(a1)
ld s6, 64(a1)
ld s7, 72(a1)
ld s8, 80(a1)
ld s9, 88(a1)
ld s10, 96(a1)
ld s11, 104(a1)
ret
# 此时,ra已经被改掉!!!
# 回去的位置就是forkret了
init 调度到 shell 时,swtch.S ret 的时候就跳转到了 forkret
然后 forkret->usertrapret->w_stvec
也就是说,第一次的 usertrapret 是在 usertrap 前面执行的!
所以,绕了这么大一圈回来
lec4 里面说 ecall 干了三件事(这是硬件支持的)
- 将代码从 user mode 改到 supervisor mode
- 将 pc 保存到 sepc
- 跳转到 stvec 指向的指令
在 下面那个 usertrapret 之中,stvec 改为指向 trampoline.S 的 uservec,并且指定了 usertrap 位置等信息
// send syscalls, interrupts, and exceptions to trampoline.S
w_stvec(TRAMPOLINE + (uservec - trampoline));
// set up trapframe values that uservec will need when
// the process next re-enters the kernel.
p->trapframe->kernel_satp = r_satp(); // kernel page table
p->trapframe->kernel_sp = p->kstack + PGSIZE; // process's kernel stack
p->trapframe->kernel_trap = (uint64)usertrap;
p->trapframe->kernel_hartid = r_tp(); // hartid for cpuid()
所以这就是注释之中re-enter
的意思
再后面就是 lec 里面说的,再后面的 syscall
ecall 跳转到 stvec(uservec),trampoline.S 的最后跳转到 TRAMPOLINE + 16
参考 trapframe
struct trapframe {
/* 0 */ uint64 kernel_satp; // kernel page table
/* 8 */ uint64 kernel_sp; // top of process's kernel stack
/* 16 */ uint64 kernel_trap; // usertrap(), 就在上面p->trapframe->kernel_trap = (uint64)usertrap;
// ...
}
就是跳转到了 usertrap
然后 usertrap->syscall()->根据 a7 找到 sys_write()
之后再通过 usertrapret->userret->sret 返回用户空间,继续执行指令
sret 是我们在 kernel 中的最后一条指令,当我执行完这条指令:
程序会切换回 user mode
SEPC 寄存器的数值会被拷贝到 PC 寄存器(程序计数器)
重新打开中断
ps1 如果在 kernel space 里面调用 ecall 会怎么样?
在 lab3 之中,经常能看到在 userinit 成功启动之前,内核就出错了,但是我们依然能得到一些打印信息
首先说说为什么能够打印,
从 printf 一步步往里面看,找到 console.c 里面的
//
// send one character to the uart.
// called by printf, and to echo input characters,
// but not from write().
//
// 注意没有用write!!
void
consputc(int c)
{
if(c == BACKSPACE){
// if the user typed backspace, overwrite with a space.
uartputc_sync('\b'); uartputc_sync(' '); uartputc_sync('\b');
} else {
uartputc_sync(c);
}
}
也就是说 xv6 只是将数据发送到 UART 的 control register(qemu 模拟且约定位置),而 qemu 负责将 UART 的地址上的数据输出到终端
在 xv6 操作系统中,打印操作是通过 UART(Universal Asynchronous Receiver/Transmitter,通用异步收发器)的控制寄存器实现的。当 xv6 需要打印字符时,它会将字符写入 UART 的数据寄存器。UART 硬件会自动将数据寄存器中的字符发送出去。
在 QEMU 模拟器中,UART 被模拟为一个虚拟设备。当 xv6 将字符写入 UART 数据寄存器时,QEMU 会捕获这个操作,并将字符输出到宿主机的终端或者其他输出设备。这样,虽然 xv6 认为它是在操作 UART 硬件,但实际上它的输出被 QEMU 重定向到了宿主机的终端
在 kernel/main.c 的
trapinit(); // trap vectors
trapinithart(); // install kernel trap vector
里面
void trapinit(void) { initlock(&tickslock, "time"); }
// set up to take exceptions and traps while in the kernel.
void trapinithart(void) { w_stvec((uint64)kernelvec); } // 初始化stvec为kernelvec
kernelvec 里面又有啥呢?
.globl kerneltrap
.globl kernelvec
.align 4
kernelvec:
// make room to save registers.
addi sp, sp, -256
// save the registers.
sd ra, 0(sp)
sd sp, 8(sp)
sd gp, 16(sp)
sd tp, 24(sp)
sd t0, 32(sp)
sd t1, 40(sp)
sd t2, 48(sp)
sd s0, 56(sp)
sd s1, 64(sp)
sd a0, 72(sp)
sd a1, 80(sp)
sd a2, 88(sp)
sd a3, 96(sp)
sd a4, 104(sp)
sd a5, 112(sp)
sd a6, 120(sp)
sd a7, 128(sp)
sd s2, 136(sp)
sd s3, 144(sp)
sd s4, 152(sp)
sd s5, 160(sp)
sd s6, 168(sp)
sd s7, 176(sp)
sd s8, 184(sp)
sd s9, 192(sp)
sd s10, 200(sp)
sd s11, 208(sp)
sd t3, 216(sp)
sd t4, 224(sp)
sd t5, 232(sp)
sd t6, 240(sp)
// call the C trap handler in trap.c
call kerneltrap // 这里call,ret是伪指令,被汇编器转换成jal和jalr
// restore registers.
ld ra, 0(sp)
ld sp, 8(sp)
ld gp, 16(sp)
// not this, in case we moved CPUs: ld tp, 24(sp)
ld t0, 32(sp)
ld t1, 40(sp)
ld t2, 48(sp)
ld s0, 56(sp)
ld s1, 64(sp)
ld a0, 72(sp)
ld a1, 80(sp)
ld a2, 88(sp)
ld a3, 96(sp)
ld a4, 104(sp)
ld a5, 112(sp)
ld a6, 120(sp)
ld a7, 128(sp)
ld s2, 136(sp)
ld s3, 144(sp)
ld s4, 152(sp)
ld s5, 160(sp)
ld s6, 168(sp)
ld s7, 176(sp)
ld s8, 184(sp)
ld s9, 192(sp)
ld s10, 200(sp)
ld s11, 208(sp)
ld t3, 216(sp)
ld t4, 224(sp)
ld t5, 232(sp)
ld t6, 240(sp)
addi sp, sp, 256
// return to whatever we were doing in the kernel.
sret
#
# machine-mode timer interrupt.
#
其中 kerneltrap
// interrupts and exceptions from kernel code go here via kernelvec,
// on whatever the current kernel stack is.
void kerneltrap() {
int which_dev = 0;
uint64 sepc = r_sepc();
uint64 sstatus = r_sstatus();
uint64 scause = r_scause();
if ((sstatus & SSTATUS_SPP) == 0)
panic("kerneltrap: not from supervisor mode");
if (intr_get() != 0)
panic("kerneltrap: interrupts enabled");
if ((which_dev = devintr()) == 0) { // 这里是我们常见报错的地方
printf("scause %p\n", scause);
printf("sepc=%p stval=%p\n", r_sepc(), r_stval());
panic("kerneltrap");
}
// give up the CPU if this is a timer interrupt.
if (which_dev == 2 && myproc() != 0 && myproc()->state == RUNNING)
yield();
// the yield() may have caused some traps to occur,
// so restore trap registers for use by kernelvec.S's sepc instruction.
w_sepc(sepc);
w_sstatus(sstatus);
}
devintr,由于我们代码写错的 scause 比如页表的错误(0x000000000000000f),既不是 0x8 开头的外部设备中断,也不是计时器中断,就会触发 panic
// check if it's an external interrupt or software interrupt,
// and handle it.
// returns 2 if timer interrupt,
// 1 if other device,
// 0 if not recognized.
int devintr() {
uint64 scause = r_scause();
if ((scause & 0x8000000000000000L) && (scause & 0xff) == 9) {
// this is a supervisor external interrupt, via PLIC.
// irq indicates which device interrupted.
int irq = plic_claim();
if (irq == UART0_IRQ) {
uartintr();
} else if (irq == VIRTIO0_IRQ) {
virtio_disk_intr();
} else if (irq) {
printf("unexpected interrupt irq=%d\n", irq);
}
// the PLIC allows each device to raise at most one
// interrupt at a time; tell the PLIC the device is
// now allowed to interrupt again.
if (irq)
plic_complete(irq);
return 1;
} else if (scause == 0x8000000000000001L) {
// software interrupt from a machine-mode timer interrupt,
// forwarded by timervec in kernelvec.S.
if (cpuid() == 0) {
clockintr();
}
// acknowledge the software interrupt by clearing
// the SSIP bit in sip.
w_sip(r_sip() & ~2);
return 2;
} else {
return 0;
}
}
而如果 devintr 没有问题,通过 sepc 再次改变了控制流
那 sepc 现在在哪里呢?前面保存的原来的 pc,也就是回到原来代码继续执行了
ps2 (ecall 的时候)为什么是 a7?
The ECALL instruction is used to make a request to the supporting execution environment, which is usually an operating system. The ABI for the system will define how parameters for the environment request are passed, but usually these will be in defined locations in the integer register file.
文档说让 OS 设计者自己决定怎么传参数
而 riscv-gnu-toolchain 和 Linux 的做法(设计)是
a0~a5 可以放参数,a7 放 syscall number,所以单纯是一个约定问题
static uint64 argraw(int n) {
struct proc *p = myproc();
switch (n) {
case 0:
return p->trapframe->a0;
case 1:
return p->trapframe->a1;
case 2:
return p->trapframe->a2;
case 3:
return p->trapframe->a3;
case 4:
return p->trapframe->a4;
case 5:
return p->trapframe->a5;
}
panic("argraw");
return -1;
}
由于 riscv 没有硬件指令上强制 ecall 的时候更新页表
也就是说执行 ecall 前后,都还是 user pagetable
所以 stvec 的地址必须在 user space 的页表上存在
但 trap handler 需要在 kernel space 运行
所以 stvec 的地址必须在 kernel space 也存在,并且两者(user space && kernel space)统一
这就是为什么 xv6 设计了一个在 kernel space 和 user space 都在同一个位置的 TRAMPOLINE 页(看 Chapter 3 的图)
lec6 的学生提问很有意思
学生提问:这个问题或许并不完全相关,read 和 write 系统调用,相比内存的读写,他们的代价都高的多,因为它们需要切换模式,并来回捣腾。有没有可能当你执行打开一个文件的系统调用时, 直接得到一个 page table 映射,而不是返回一个文件描述符?这样只需要向对应于设备的特定的地址写数 据,程序就能通过 page table 访问特定的设备。你可以设置好限制,就像文件描述符只允许修改特定文件一样,这样就不用像系统调用一样在用户空间和内核空间来回捣腾了。
Robert 教授:这是个很好的想法。实际上很多操作系统都提供这种叫做内存映射文件(Memory-mapped file access)的机制,在这个机制里面通过 page table,可以将用户空间的虚拟地址空间,对应到文件内容,这样你就可以通过内存地址直接读写文件。实际上,你们将在 mmap 实验中完成这个机制。对于许多程序来说,这个机制的确会比直接调用 read/write 系统调用要快的多。
lec6 在这里也插入了一段设计相关的
所以你现在就会问,为什么 ecall 不多做点工作来将代码执行从用户空间切换到内核空间呢?为什么 ecall 不会保存用户寄存器,或者切换 page table 指针来指向 kernel page table,或者自动的设置 Stack Pointer 指向 kernel stack,或者直接跳转到 kernel 的 C 代码,而不是在这里运行复杂的汇编代码?
实际上,有的机器在执行系统调用时,会在硬件中完成所有这些工作。但是 RISC-V 并不会,RISC-V 秉持了这样一个观点:ecall 只完成尽量少必须要完成的工作,其他的工作都交给软件完成。这里的原因是,RISC-V 设计者想要为软件和操作系统的程序员提供最大的灵活性,这样他们就能按照他们想要的方式开发操作系统。所以你可以这样想,尽管 XV6 并没有使用这里提供的灵活性,但是一些其他的操作系统用到了。
举个例子,因为这里的 ecall 是如此的简单,或许某些操作系统可以在不切换 page table 的前提下,执行部分系统调用。切换 page table 的代价比较高,如果 ecall 打包完成了这部分工作,那就不能对一些系统调用进行改进,使其 不用在不必要的场景切换 page table。
某些操作系统同时将 user 和 kernel 的虚拟地址映射到一个 page table 中,这样在 user 和 kernel 之间切换时根本就不用切换 page table。对于这样的操作系统来说,如果 ecall 切换了 page table 那将会是一种浪费,并且也减慢了程序的运行。
或许在一些系统调用过程中,一些寄存器不用保存,而哪些寄存器需要保存,哪些不需要,取决于于软件,编程语言,和编译器。通过不保存所有的 32 个寄存器或许可以节省大量的程序运行时间,所以你不会想要 ecall 迫使你保存所有的寄存器。
最后,对于某些简单的系统调用或许根本就不需要任何 stack,所以对于一些非常关注性能的操作系统,ecall 不会自动为你完成 stack 切换是极好的。
所以,ecall 尽量的简单可以提升软件设计的灵活性。
接下来详细讲 trap 的过程
通过 ecall 我们达到了 trampoline.S
回到 XV6 和 RISC-V,现在程序位于 trampoline page 的起始,也是 uservec 函数的起始。我们现在需要做的第一件事情就是保存寄存器的内容。在 RISC-V 上,如果不能使用寄存器,基本上不能做任何事情。所以,对于保存这些寄存器,我们有什么样的选择呢?
在一些其他的机器中,我们或许直接就将 32 个寄存器中的内容写到物理内存中某些合适的位置。但是我们不能在 RISC-V 中这样做,因为在 RISC-V 中,supervisor mode 下的代码不允许直接访问物理内存。所以我们只能使用 page table 中的内容,但是从前面的输出来看,page table 中也没有多少内容
虽然 XV6 并没有使用,但是另一种可能的操作是,直接将 SATP 寄存器指向 kernel page table,之后我们就可以直接使用所有的 kernel mapping 来帮助我们存储用户寄存器。这是合法的,因为 supervisor mode 可以更改 SATP 寄存器。但是在 trap 代码当前的位置,也就是 trap 机制的最开始,我们并不知道 kernel page table 的地址。并且更改 SATP 寄存器的指令,要求写入 SATP 寄存器的内容来自于另一个寄存器。所以,为了能执行更新 page table 的指令,我们需要一些空闲的寄存器,这样我们才能先将 page table 的地址存在这些寄存器中,然后再执行修改 SATP 寄存器的指令。
关于寄存器在 xv6 之中是怎么保存和加载的,是使用了 csrrw 这个指令和 sscratch 这个额外寄存器,前面提过一些,具体可以看
lec6 之中还有一个有趣的讨论
另一个问题是,为什么这些寄存器保存在 trapframe,而不是用户代码的栈中?这个问题的答案是,我们不确定用户程序是否有栈,必然有一些编程语言没有栈,对于这些编程语言的程序,Stack Pointer 不指向任何地址。当然,也有一些编程语言有栈,但是或许它的格式很奇怪,内核并不能理解。比如,编程语言以堆中以小块来分配栈,编程语言的运行时知道如何使用这些小块的内存来作为栈,但是内核并不知道。所以,如果我们想要运行任意编程语言实现的用户程序,内核就不能假设用户内存的哪部分可以访问,哪部分有效,哪部分存在。所以内核需要自己管理这些寄存器的保存,这就是为什么内核将这些内容保存在属于内核内存的 trapframe 中,而不是用户内存
然后 lec6 在讲 usertrap 函数的时候有几个值得注意的点
接下来我们要保存用户程序计数器,它仍然 保存在 SEPC 寄存器中,但是可能发生这种情况:当程序还在内核中执行时,我们可能切换到另一个进程,并进入到那个程序的用户空间,然后那个进程可能再调用一个系统调用进而导致 SEPC 寄存器的内容被覆盖。所以,我们需要保存当前进程的 SEPC 寄存器到一个与该进程关联的内存中,这样这个数据才不会被覆盖。这里我们使用 trapframe 来保存这个程序计数器。
XV6 会在处理系统调用的时候使能中断,这样中断可以更快的服务,有些系统调用需要许多时间处理。中断总是会被 RISC-V 的 trap 硬件关闭,所以在这个时间点,我们需要显式的打开中断。
最后总结一下,系统调用被刻意设计的看起来像是函数调用,但是背后的 user/kernel 转换比函数调用要复杂的多。之所以这么复杂,很大一部分原因是要保持 user/kernel 之间的隔离性,内核不能信任来自用户空间的任何内容。
另一方面,XV6 实现 trap 的方式比较特殊,XV6 并不关心性能。但是通常来说,操作系统的设计人员和 CPU 设计人员非常关心如何提升 trap 的效率和速度。必然还有跟我们这里不一样的方式来实现 trap,当你在实现的时候,可以从以下几个问题出发:
硬件和软件需要协同工作,你可能需要重新设计 XV6,重新设计 RISC-V 来使得这里的处理流程更加简单,更加快速。
另一个需要时刻记住的问题是,恶意软件是否能滥用这里的机制来打破隔离性。
4.3 Code: Calling system calls
4.4 Code: System call arguments
由上所述,调用 syscall 的时候,实际上内核中真正执行逻辑的 sys_xxx 函数需要从 trapframe 而不是寄存器里面取参数
这对应 syscall.c 的 argint, argaddr,argfd 等辅助函数
对于别的参数,只是繁琐与否的问题;对于指针参数,有两个挑战:
- kernel pagetable 和 user pagetable 不同
- 如何避免恶意指针(例如,指向某个内核特定区域;空指针)
fetchstr 是一个很好的示范
int fetchstr(uint64 addr, char *buf, int max) {
struct proc *p = myproc();
int err = copyinstr(p->pagetable, buf, addr, max);
// 这里传入p->pagetable 而不是使用kernel->pagetable
if (err < 0)
return err;
return strlen(buf);
}
这样从 user space 里面,我们得到了物理地址 pa
又因为 kernel 是 direct-mapped 并且已经映射了所有 pa,直接当作 kernel 的 va 就行
4.5 Traps from kernel space
在 kernel 的 trap 有以下的优势:
- 此时的页表已经是 kernel pagetable,不需要切换
- 此时我们可以确信有一个 kernel stack(正如上面讨论的,user process 可能没有 stack,所以必须要放在 trapframe 这个实际上是 kernel 的内存里面),那么 trap 的过程之中,可以直接将寄存器存储在内核栈上
不同的硬件线程(cpu)当然有不同的 kernel stack,这使得 kernel trap 的进程调度更加方便
鉴于 kerneltrap 之中 yield 放弃自己的 cpu(发生进程调度)之后,sepc 可能被修改,所以在 kerneltrap 的 local variable(在本 cpu 的 kernel stack 里面)存储了uint sepc;
yield()结束后使用 w_sepc(sepc)重新加载自己的 trap 返回值
在 usertrap 之中,先是 ecall 进入 trampoline.S 的 uservec,再进入 usertrap
void usertrap(void) {
int which_dev = 0;
if ((r_sstatus() & SSTATUS_SPP) != 0)
panic("usertrap: not from user mode");
// send interrupts and exceptions to kerneltrap(),
// since we're now in the kernel.
w_stvec((uint64)kernelvec);
struct proc *p = myproc();
// save user program counter.
p->trapframe->epc = r_sepc();
if (r_scause() == 8) {
// system call
if (p->killed)
exit(-1);
// sepc points to the ecall instruction,
// but we want to return to the next instruction.
p->trapframe->epc += 4;
// an interrupt will change sstatus &c registers,
// so don't enable until done with those registers.
intr_on();
syscall();
} else if ((which_dev = devintr()) != 0) {
// ok
} else {
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
p->killed = 1;
}
if (p->killed)
exit(-1);
// give up the CPU if this is a timer interrupt.
if (which_dev == 2)
yield();
usertrapret();
}
在这里改成w_stvec((uint64)kernelvec);
切换成 kernel 的 stvec 是因为此时在内核态了,如果在处理 trap 的中间再发生 trap(虽然这里关闭了中断但还有其他可能的 trap),此时应该是 kernel trap
然后后面 ret 的时候再改回 uservec,这样反复横跳,达成稳定的循环
即使关闭了中断,处理器仍然可以响应一些特定的 trap。例如,处理器可以响应同步异常,如除以零、访问无效内存地址等。这些异常是由执行指令本身引起的,与是否开启中断无关。此外,处理器也可以响应一些非屏蔽中断(non-maskable interrupts),这些中断是由严重的硬件错误引起的,如电源故障或内存错误,它们不能被普通的中断关闭指令阻止。
而关闭中断是因为此时在 kernel space,再 ecall 的话就寄了
4.6 Page-fault exceptions
xv6 对 exception 的处理非常的简单粗暴:
如果是 user process 触发的 exception,kernel 就会杀掉这个 process
如果是 kernel 触发的 exception,就会触发 panic
成熟的 OS 有 COW(Copy On Write)等机制,RISCV 实际上能分辨 page fault 是从 load,store 还是 instruction page fault
实现 COW 还需要有 book keeping 机制,此时判断一个物理页是否被释放,就需要额外的一些计数机制,一个物理页可以在多个页表之中出现(出现的数量依赖于 fork 和 page fault,exec 和 exit)
另一个非常重要的优化是 COW 机制下,如果只有一个 pagetable 持有对某个物理页的引用的话,完全可以不 copy 这个物理页到进程的 user memory
pagetable 和 pagefault 结合在一起,能整出一些有趣的机制,比如 lazy allocation
当一个进程调用 sbrk 去尝试取得更多的内存的时候,内核只增长进程的 size,但不实际 alloc 物理页,也不更新 PTE
就行 COW 一样,触发 page fault 的时候才去实际 alloc
这个 feature 的好处比初步设想的还多:比如对于大的内存申请,lazy allocation 能使得内存的增长是随时间慢慢增长的(而不是一口气分配一个大内存);又比如对于大内存申请,OS 实际可以一次 page fault 给 a batch of pages,而不是一个一个 page alloc
还有一个广泛使用的 feature 是 demand paging
xv6 是 eagerly 加载的,也就是说会一口气加载整个内核的.text 和.data 段到内存之中
而现代 OS 使用 demand paging,在加载的时候只是简单的创建 pagetable,并将 PTE 设置成 invalid,然后等到访问了这个 PTE,触发 page fault 的时候才会加载数据
还有就是 paging in disk,只在内存里面放最常用的数据和代码段,剩下的存到 disk 里面(称之为 page out)并设置 PTE 为 invalid,然后触发 page fault 之后再从 disk 里面加载回来(page in)
Linux 里面还有比如将一个稀疏大矩阵全部映射到几个 page 的 page fault trick,多种 trick 综合作用之下,使得 Linux 进程占用的 VA 大小反而一般远远大于实际物理实际空间
而使用 demand paging,lazy alloction 等的另一个好处是:由于现代计算机应用基本都不是空间友好的,都会尽可能取得更多的算力和空间资源来保证响应等特性,所以再大的 RAM 也会出现内存不足,需要频繁 evict 内存页的情况,而 evict 是一个开销不小的操作,alloc 的更多,evict 的也更多,lazy 而不是 eagar 的策略优势就越明显
4.7 Real World
xv6 整个 trapframe 和 TRAMPOLINE 的复杂设计实际是 RISCV “指令(ecall)尽可能做少的工作”的设计原则推动的
但实际上,一个最简单消除这个复杂转换的机制就是把 kernel pagetable 复制到每一个 user pagetable,然后只是用权限位来控制权限
这样的好处有很多,syscall 的代码大大简化,kernel 可以直接解引用 user pointer,etc;许多生产级操作系统采用了这种设计
xv6 之所以设计成 trapframe 这样是为了保证代码的安全性以及避免考虑上述的 kernel PTE 和 user PTE 如何避免重叠的问题。
生产级的 OS 之中,不仅要有前面的 COW, demand paging, paging in disk,lazy allocation,还要有 mmap, cache buffer 等许多机制,以及爆内存时候的妥善处理方法(xv6 如果爆内存了就直接返回 error 或者 kill 应用程序)