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 sbrkto 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:
readreturns 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 和时分共享没有直接的概念,这部分是由系统的调度策略掌控的;程序对于物理内存没有直接的概念,对内存的操作是通过execsyscall 实现的,如果内存紧张的话,一些进程的数据甚至会被存储在磁盘上
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 之中,就完成了攻击