XV6 LAB

Lab2: system calls

前置知识

首先还是学习前置的知识并且阅读源代码

proc结构体用于维护每个进程的状态等进程相关的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Per-process state
struct proc {
struct spinlock lock;// 自旋锁(1)
// 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(2)
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)
};

知识点1 锁
自旋锁

自旋锁具有忙等待的特性,一个线程获取了一个自旋锁后,另外一个线程期望获取该自旋锁,获取不到,只能够原地“打转”(忙等待)
所以自旋锁不应该被长时间的持有,否则会导致CPU资源的过度消耗 自旋锁常用于中断上下文,因为在中断上下文中不可以使用会导致睡眠的锁

为什么在中断上下文之中不可以睡眠:首先睡眠的含义是将进程置于“睡眠”状态,在这个状态的进程不能被调度执行。 然后,在一定的时机,这个进程可能会被重新置为“运行”状态,从而可能被调度 执行。 可见,“睡眠”与“运行”是针对进程而言的,代表进程的task_struct结构记录着进程的状态。 内核中的“调度器”通过task_struct对进 程进行调度。 但是,中断上下文却不是一个进程,它并不存在task_struct,所以它是不可调度的。 所以,在中断上下文不能睡眠

锁的分类
  1. 悲观锁和乐观锁

    悲观锁是一种基于悲观态度的数据并发控制机制,用于防止数据冲突。它采取预防性的措施,在修改数据之前将其锁定,并在操作完成后释放锁定,以确保数据的一致性和完整性。悲观锁通常用于并发环境下的数据库系统,是数据库本身实现锁机制的一种方式。在悲观锁的机制下,当一个使用者要修改某个数据时,首先会尝试获取该数据的锁。如果锁已经被其他使用者持有,则当前使用者会被阻塞,直到对应的锁被释放。这种悲观的态度认为数据冲突是不可避免的,因此在修改数据之前先锁定数据,以防止冲突的发生。

    乐观锁是一种基于版本控制的并发控制机制。在乐观锁的思想中,认为数据访问冲突的概率很低,因此不加锁直接进行操作,但在更新数据时会进行版本比对,以确保数据的一致性。乐观锁的原理主要基于版本号或时间戳来实现。在每次更新数据时,先获取当前数据的版本号或时间戳,然后在更新时比对版本号或时间戳是否一致,若一致则更新成功,否则表示数据已被其他线程修改,更新失败。

    e.g. CAS(Compare and Swap)

    如上图中,主存中保存V值,线程中要使用V值要先从主存中读取V值到线程的工作内存A中,然后计算后变成B值,最后再把B值写回到内存V值中。多个线程共用V值都是如此操作。CAS的核心是在将B值写入到V之前要比较A值和V值是否相同,如果不相同证明此时V值已经被其他线程改变,重新将V值赋给A,并重新计算得到B,如果相同,则将B值赋给V。值得注意的是CAS机制中的这步步骤是原子性的(从指令层面提供的原子操作),所以CAS机制可以解决多线程并发编程对共享变量读写的原子性问题。

​ 悲观锁适合写操作多的场景,乐观锁适合读操作多的场景,乐观锁的吞吐量会比悲观锁多

  1. 公平锁和非公平锁

    公平锁与非公平锁区别知乎专栏

    公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。

    • 优点:所有的线程都能得到资源,不会饿死在队列中。
    • 缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。

    非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。

    • 优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
    • 缺点:可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。
  2. 独享锁(互斥锁)和共享锁

    独享锁(互斥锁):独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排他锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。

    共享锁:共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。 独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

    AQS概述

  3. 可重入锁和不可重入锁

    可重入锁:当线程获取某个锁后,还可以继续获取它,可以递归调用,而不会发生死锁;

    不可重入锁:与可重入相反,获取锁后不能重复获取,否则会死锁(自己锁自己)。

  4. 自旋锁,分段锁,死锁

    自旋锁不会发生线程状态的切换:一直处于用户态,减少了线程上下文切换的消耗,缺点是循环会消耗CPU

    分段锁: 并不是具体的一种锁,只是一种锁的设计,将数据分段上锁,把锁进一步细粒度化,可以提升并发量。当操作不需要更新整个数组的时候,就仅针对数组中的一项进行加锁操作

    死锁:两个或两个以上的线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法让程序进行下去

知识点2 chan

chan 是一个指向任意类型数据的指针。它通常用于标识进程正在等待的具体资源或条件。当一个进程调用 sleep 函数时,它会传递一个 chan 参数,这个参数可以是任何类型的指针,比如指向某个文件、设备、内存区域等的指针。

chan 的主要作用是在唤醒进程时使用。当其他进程(或中断处理程序)完成了一定的工作,需要唤醒因等待该资源而休眠的进程时,它会调用相应的唤醒函数,并传入相同的 chan 值。操作系统内核会检查所有处于睡眠状态的进程,如果发现有进程的 chan 与传入的 chan 匹配,则将该进程从睡眠状态唤醒。

通过这种方式,chan 能够确保只有那些真正等待特定资源或事件的进程会被唤醒,从而提高了系统的效率和响应性。

后面的lab里面应该有所涉及,不过也可以提前看看这篇文章

RISC-V计算机上电的时候会初始化自己并且运行一个存储在ROM中的引导加载程序,将xv6的kernel加载到内存中。然后在机器模式下中央处理器从_entry开始运行xv6。起始位置为0x80000000,因为0x0:0x80000000包含I/O设备

_entry的指令设置了一个栈区,这样xv6就可以运行C代码。Xv6在start. c (kernel/start.c:11)文件中为初始栈stack0声明了空间。由于RISC-V上的栈是向下扩展的,所以_entry的代码将栈顶地址stack0+4096加载到栈顶指针寄存器sp中。现在内核有了栈区,_entry便调用C代码start(kernel/start.c:21)。

entry.S

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
	# 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 // load address 将sp加载为stack0的地址
li a0, 1024*4 // load immediate 将a0设置为4096(4KB)
csrr a1, mhartid // 从 mhartid 寄存器读取当前硬件线程ID(HART ID),并将其存储在 a1 中。
addi a1, a1, 1 // 保证栈偏移量不为0,不能向负地址减少
mul a0, a0, a1 // 计算出了当前CPU的栈偏移量
add sp, sp, a0 // 将计算出的栈偏移量加到栈指针 sp 上,从而为每个CPU分配独立的栈空间
# jump to start() in start.c
call start
spin:
j spin // 说明start程序返回了,那么进入无限循环,防止CPU继续执行无效指令

start.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include "types.h"
#include "param.h"
#include "memlayout.h"
#include "riscv.h"
#include "defs.h"

void main();
void timerinit();

// entry.S needs one stack per CPU.
__attribute__ ((aligned (16))) char stack0[4096 * NCPU];

// scratch area for timer interrupt, one per CPU.
uint64 mscratch0[NCPU * 32];

// assembly code in kernelvec.S for machine-mode timer interrupt.
extern void timervec();

// 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();// 读取mstatus寄存器的值
x &= ~MSTATUS_MPP_MASK;//清除MPP字段,表示机器模式之前的特权模式
x |= MSTATUS_MPP_S;//设置MPP字段为S(Supervisor mode)
w_mstatus(x);//写会mstatus

// set M Exception Program Counter to main, for mret.
// requires gcc -mcmodel=medany
w_mepc((uint64)main);//将 mepc 寄存器设置为 main 函数的地址。mepc 寄存器用于存储异常返回时的程序计数器(PC)值

// disable paging for now.
w_satp(0);//禁用虚拟地址转换

// delegate all interrupts and exceptions to supervisor mode.
w_medeleg(0xffff);//medeleg 寄存器用于指定哪些异常类型应该由监督模式处理
w_mideleg(0xffff);//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");
//mret执行返回,返回到先前状态,由于start函数将前模式改为了管理模式且返回地址改为了main,因此mret将返回到main函数,并以管理模式运行
}

main(kernel/main.c:11)初始化几个设备和子系统后,便通过调用userinit (kernel/proc.c:212)创建第一个进程,第一个进程执行一个用RISC-V程序集写的小型程序:initcode. S (**user/initcode.S:**1),它通过调用exec系统调用重新进入内核。正如我们在第1章中看到的,exec用一个新程序(本例中为 /init)替换当前进程的内存和寄存器。一旦内核完成exec,它就返回/init进程中的用户空间。如果需要,init(user/init.c:15)将创建一个新的控制台设备文件,然后以文件描述符0、1和2打开它。然后它在控制台上启动一个shell。系统就这样启动了。

initcode. S

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Initial process that execs /init.
# This code runs in user space.

#include "syscall.h"

# exec(init, argv)
.globl start //将start声明为全局符号,使其在链接是可见
start://程序入口
la a0, init // 将 init 字符串的地址加载到寄存器 a0 中
la a1, argv // 将 argv 数组的地址加载到寄存器 a1 中
li a7, SYS_exec // 将SYS_exec系统调用号加载到寄存器中
ecall // 触发系统调用,执行exec

# for(;;) exit();
exit:
li a7, SYS_exit
ecall
jal exit

# char init[] = "/init\0";
init: //init 标签指向字符串 /init\0 的起始地址
.string "/init\0"

# char *argv[] = { init, 0 };
.p2align 2 //将 argv 数组的地址对齐到4字节边界
argv:
.long init // init字符串的地址作为第一个元素
.long 0 // 0作为第二个元素表示参数列表的结束

源代码阅读

  • 系统调用的用户空间代码在user/user.huser/usys.pl中。
  • 内核空间代码是kernel/syscall.hkernel/syscall.c
  • 与进程相关的代码是kernel/proc.hkernel/proc.c

user/user.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
struct stat;
/*
这个其实在很早之前就看了一遍了,但是再看一遍也无妨
#define T_DIR 1 // Directory
#define T_FILE 2 // File
#define T_DEVICE 3 // Device

struct stat {
int dev; // 文件系统所在的磁盘设备编号,标识文件所属的文件系统所在的物理设备
uint ino; // Inode number
short type; // Type of file
short nlink; // Number of links to file,硬链接就是文件系统中对于同一个inode的引用
// 类似inode为3的文件可以有一个名字是a,另一个名字是b,都指向同一个文件
uint64 size; // Size of file in bytes
};
*/
struct rtcdate;//用于存储实时钟(RTC,Real-Time Clock)的时间和日期信息,通常用于记录当前的时间和日期
/*
struct rtcdate {
uint second;
uint minute;
uint hour;
uint day;
uint month;
uint year;
};
*/
// system calls
int fork(void); // 创建一个新的进程,返回值是pid
int exit(int) __attribute__((noreturn)); // 不会返回,状态码表示退出状态
int wait(int*); // 等待子进程结束并且获取其退出状态,出错返回-1
int pipe(int*); // 出错返回-1
int write(int, const void*, int);
int read(int, void*, int);
int close(int);
int kill(int);
int exec(char*, char**);
int open(const char*, int);
int mknod(const char*, short, short);//mknod(const char *path, short mode, short dev)
//创建特殊文件(如设备文件),path 是文件路径,mode 是文件类型和权限,dev 是设备号。
int unlink(const char*);//删除文件或者符号链接
int fstat(int fd, struct stat*);//获取文件描述符fd对应的文件的状态信息,存储在st中
int link(const char*, const char*);//link(const char *oldpath, const char *newpath)
//创建硬链接,oldpath 是现有文件路径,newpath 是新链接路径。
int mkdir(const char*);
int chdir(const char*);//改变当前工作目录
int dup(int);//复制文件描述符,返回一个新的文件描述符
int getpid(void);
char* sbrk(int);
/*
char* sbrk(int increment)
@brief 调整数据段的大小,increment 是增加或减少的字节数。
@return 成功返回新的数据段起点地址,出错返回 (char*)-1。
*/
int sleep(int);
int uptime(void);//获取系统自启动以来的运行时间

// ulib.c 标准库函数
int stat(const char*, struct stat*);//通过文件路径获取文件状态信息
char* strcpy(char*, const char*);
void *memmove(void*, const void*, int);//复制多少个字节到另一个区域,允许重叠
char* strchr(const char*, char c);//在一个字符串中寻找字符c的第一次出现
int strcmp(const char*, const char*);
/*
@return 负数 s1<s2
0 s1=s2
正数 s1>s2
*/
void fprintf(int, const char*, ...);、、写入文件
void printf(const char*, ...);
char* gets(char*, int max);//从标准输入读取一行字符串,最多读取max个字符
uint strlen(const char*);
void* memset(void*, int, uint);
void* malloc(uint);//返回指向分配内存的指针
void free(void*);
int atoi(const char*);
int memcmp(const void *, const void *, uint);//比较内存区域的前n个字节
void *memcpy(void *, const void *, uint);

user/usys.pl

.pl文件是Perl脚本语言的文件(Perl是UNIX的通用脚本语言)

脚本语言其实就是类似于Python一样的由解释器直接进行解释而并不需要经过编译再进行执行的语言

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#!/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 {//entry子程序接收一个参数$name,即系统调用的名字。它生成对应的汇编代码
my $name = shift;
print ".global $name\n"; //声明符号为全局可见,以便其他文件可以引用
print "${name}:\n";//定义一个标签表示函数入口点
print " li a7, SYS_${name}\n"; //加载系统调用编号到寄存器a7
print " ecall\n";//发出系统调用指令
print " ret\n";//返回到调用者
}
// 对于每一个列出的系统调用名称,调用一次entry子程序来生成相应的汇编代码
entry("fork");
entry("exit");
entry("wait");
entry("pipe");
entry("read");
entry("write");
entry("close");
entry("kill");
entry("exec");
entry("open");
entry("mknod");
entry("unlink");
entry("fstat");
entry("link");
entry("mkdir");
entry("chdir");
entry("dup");
entry("getpid");
entry("sbrk");
entry("sleep");
entry("uptime");

kernel/syscall.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// System call numbers 给每个系统调用分配一个数字
#define SYS_fork 1
#define SYS_exit 2
#define SYS_wait 3
#define SYS_pipe 4
#define SYS_read 5
#define SYS_kill 6
#define SYS_exec 7
#define SYS_fstat 8
#define SYS_chdir 9
#define SYS_dup 10
#define SYS_getpid 11
#define SYS_sbrk 12
#define SYS_sleep 13
#define SYS_uptime 14
#define SYS_open 15
#define SYS_write 16
#define SYS_mknod 17
#define SYS_unlink 18
#define SYS_link 19
#define SYS_mkdir 20
#define SYS_close 21

kernel/syscall.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
#include "types.h"
#include "param.h"
#include "memlayout.h"
#include "riscv.h"
#include "spinlock.h"
#include "proc.h"
#include "syscall.h"
#include "defs.h"

int
fetchaddr(uint64 addr, uint64 *ip)
{
struct proc *p = myproc();
if(addr >= p->sz || addr+sizeof(uint64) > p->sz)
return -1;
if(copyin(p->pagetable, (char *)ip, addr, sizeof(*ip)) != 0)
return -1;
return 0;
}

// Fetch the nul-terminated string at addr from the current process.
// Returns length of string, not including nul, or -1 for error.
int
fetchstr(uint64 addr, char *buf, int max)
{
struct proc *p = myproc();
int err = copyinstr(p->pagetable, buf, addr, max);
if(err < 0)
return err;
return strlen(buf);
}

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;
}

// Fetch the nth 32-bit system call argument.
int
argint(int n, int *ip)
{
*ip = argraw(n);
return 0;
}

// Retrieve an argument as a pointer.
// Doesn't check for legality, since
// copyin/copyout will do that.
int
argaddr(int n, uint64 *ip)
{
*ip = argraw(n);
return 0;
}

// Fetch the nth word-sized system call argument as a null-terminated string.
// Copies into buf, at most max.
// Returns string length if OK (including nul), -1 if error.
int
argstr(int n, char *buf, int max)
{
uint64 addr;
if(argaddr(n, &addr) < 0)
return -1;
return fetchstr(addr, buf, max);
}
// extern 关键字表示这些函数是在其他地方定义的,当前文件只需要知道它们的存在和签名即可
// 此处extern只是对于变量的声明而不是定义,因为只能有一次定义
// 将变量/函数声明为extern就可以在另一个文件中用extern定义变量/函数,并且在所提前声明的文件中使用
extern uint64 sys_chdir(void);
extern uint64 sys_close(void);
extern uint64 sys_dup(void);
extern uint64 sys_exec(void);
extern uint64 sys_exit(void);
extern uint64 sys_fork(void);
extern uint64 sys_fstat(void);
extern uint64 sys_getpid(void);
extern uint64 sys_kill(void);
extern uint64 sys_link(void);
extern uint64 sys_mkdir(void);
extern uint64 sys_mknod(void);
extern uint64 sys_open(void);
extern uint64 sys_pipe(void);
extern uint64 sys_read(void);
extern uint64 sys_sbrk(void);
extern uint64 sys_sleep(void);
extern uint64 sys_unlink(void);
extern uint64 sys_wait(void);
extern uint64 sys_write(void);
extern uint64 sys_uptime(void);

// static表示数组只在当前文件中可见
// 下面声明了一个函数指针数组syscalls , 每个元素都是一个返回值为uint64类型值的函数指针
static uint64 (*syscalls[])(void) = {
// syscall为数组名
// [] 表示这是一个数组
// (*syscalls[]) 表示数组的每个元素都是一个函数指针,类似int* a[]
// 函数指针形如 return_type (*pointer_name)(parameter_list);
// (void) 表示这些函数指针指向的函数不接受任何参数
[SYS_fork] sys_fork,//将sys_fork的地址赋值给syscalls数组中索引为SYS_for的位置
//syscall 函数接收系统调用号 num 和最多六个参数 a0 到 a5,这些参数会被传递给相应的系统调用处理函数
[SYS_exit] sys_exit,
[SYS_wait] sys_wait,
[SYS_pipe] sys_pipe,
[SYS_read] sys_read,
[SYS_kill] sys_kill,
[SYS_exec] sys_exec,
[SYS_fstat] sys_fstat,
[SYS_chdir] sys_chdir,
[SYS_dup] sys_dup,
[SYS_getpid] sys_getpid,
[SYS_sbrk] sys_sbrk,
[SYS_sleep] sys_sleep,
[SYS_uptime] sys_uptime,
[SYS_open] sys_open,
[SYS_write] sys_write,
[SYS_mknod] sys_mknod,
[SYS_unlink] sys_unlink,
[SYS_link] sys_link,
[SYS_mkdir] sys_mkdir,
[SYS_close] sys_close,
};

void
syscall(void)
{
int num;
struct proc *p = myproc();

num = p->trapframe->a7;
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
p->trapframe->a0 = syscalls[num]();
} else {
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->trapframe->a0 = -1;
}
}
Memory Management Unit (MMU)是虚拟内存管理的关键,页表是MMU的核心组成部分,它维护了虚拟地址与物理地址之间的映射关系。

fetchaddr()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Fetch the uint64 at addr from the current process.
// 从当前进程的地址空间读取一个位于addr位置的uint64类型的数据
int
fetchaddr(uint64 addr, uint64 *ip)
{
struct proc *p = myproc();//proc是用于维护每个进程的状态等进程相关的信息的结构
//确保addr不超过进程的内存大小,确保从 addr 开始的 sizeof(uint64) 字节都在进程的内存范围内
if(addr >= p->sz || addr+sizeof(uint64) > p->sz) // sz为以字节为单位进程内存的大小
return -1;
//从进程的地址空间中复制 sizeof(uint64) 字节的数据到内核空间的 ip 指针所指向的位置
if(copyin(p->pagetable, (char *)ip, addr, sizeof(*ip)) != 0)
return -1;
return 0;
}

myproc()函数

1
2
3
4
5
6
7
8
9
//Return the current struct proc *, or zero if none.
struct proc*
myproc(void) {
push_off();//关闭中断,保证操作的原子性,防止被其他进程抢占
struct cpu *c = mycpu();
struct proc *p = c->proc;
pop_off();//恢复中断
return p;
}

push_off()和pop_off()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void
push_off(void)
{
int old = intr_get();//获取当前的中断状态

intr_off();//关闭中断,保证不会被强抢占
if(mycpu()->noff == 0)//noff计数器为0表示是第一次调用push_off()
mycpu()->intena = old;//将当前的中断状态保存到mycpu()->intena中
mycpu()->noff += 1;//增加嵌套计数器
}
void
pop_off(void)
{
struct cpu *c = mycpu();
if(intr_get()) //如果此时是允许中断状态,说明push_off没有正确的关闭中断
panic("pop_off - interruptible");
if(c->noff < 1)//说明pop_off调用次数超过push_off,出现了问题
panic("pop_off");
c->noff -= 1;
if(c->noff == 0 && c->intena)//恢复最初的状态,如果最初保存的状态是允许中断就恢复中断,否则保持不允许中断的状态
intr_on();
}

copyin()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

// Copy from user to kernel.
// Copy len bytes to dst from virtual address srcva in a given page table.
// Return 0 on success, -1 on error.
/*
@param
pagetable:用户空间的页表
dst:内核空间的目标地址
srcva:用户空间的源地址(此处是一个虚拟地址virtual address)
len:要复制的字节数
*/
int
copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len)
{
uint64 n, va0, pa0;
//n:当前页中要复制的字节数。
//va0:当前页的起始虚拟地址。(va: virtual address)
//pa0:当前页的物理地址。(pa:physical address)

while(len > 0){
va0 = PGROUNDDOWN(srcva);//当前页的起始虚拟地址

pa0 = walkaddr(pagetable, va0);//根据va0查找当前页的物理地址

if(pa0 == 0)//页表中没有找到对应的物理地址
return -1;
n = PGSIZE - (srcva - va0);//当前页中从 srcva 开始的剩余字节数
if(n > len)//如果剩余字节数大于要复制的总字节数 len,则只复制 len 个字节
n = len;
//memmove 函数将从物理地址 (pa0 + (srcva - va0)) 开始的 n 个字节复制到内核空间的 dst 地址
//这里的(pa0 + (srcva - va0))其实就是将物理地址pa0加上了页内偏移量(srcva-va0)
//这就是计组中对于拼接高52位页表项得到的物理地址高位和低12位页内偏移量得到实际物理地址的方法
//具体应该在虚拟内存章节
memmove(dst, (void *)(pa0 + (srcva - va0)), n);

len -= n;
dst += n;
srcva = va0 + PGSIZE;
/*
减少剩余字节数:len -= n 减少剩余要复制的字节数
移动目标指针:dst += n 移动目标指针到下一个位置,因为已经复制了n个字节,所以对应的目标地址也改变
移动源指针:srcva = va0 + PGSIZE 移动源指针到下一页的起始地址
*/
}
return 0;
}

PGROUNDDOWN&PGROUNDUP的解释

#define PGROUNDUP(sz) (((sz)+PGSIZE-1) & ~(PGSIZE-1))
#define PGROUNDDOWN(a) (((a)) & ~(PGSIZE-1))
考虑PGROUNDDOWN(a)
PGSIZE通常是一个2的幂次,xv6中是4096->2^12,即
原数二进制表示: 0001_0000_0000_0000
~(PGSIZE-1))则为:1111_0000_0000_0000

将a与之相 & 其实就是保留了高四位
可以认为是将一个页中表示 页内地址 的低12位都置为0,所以就是页的第0个地址,也就是页起始地址
因为每一个页大小为2^12所以从第LSB向左数的第13位开始其实就类似于计组一样,在这里我把它当做类似于页号一样的东西,会发现其实第一个页起始地址高四位是0001,剩下都是0,而第二个页开始就是0010,后面都是0,高四位其实就是(页号-1),和计组有相似之处。

e.g.

​ PGROUNDUP(620) ==> ((620 + (1024 -1)) & ~(1023)) ==> 1024
​ 地址 620 被四舍五入为 1024。
​ 同样PGROUNDDOWN考虑:
​ PGROUNDDOWN(2400) ==> (2400 & ~(1023)) ==> 2048
​ 地址 2400 向下舍入为 2048。
​ 附赠stackoverflow的回答:https://stackoverflow.com/questions/43289022/what-do-pgroundup-and-pgrounddown-in-xv6-mean

walkaddr()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Look up a virtual address, return the physical address,
// or 0 if not mapped.
// Can only be used to look up user pages.
// 查找虚拟地址对应的物理地址
@param
pagetable:页表的根节点
va:要查找的虚拟地址
uint64
walkaddr(pagetable_t pagetable, uint64 va)
{
pte_t *pte; // 指向页表项的指针
uint64 pa; // 物理地址
//#define MAXVA (1L << (9 + 9 + 9 + 12 - 1))
if(va >= MAXVA)//确保虚拟地址在有效的范围之内
return 0;
//walk 函数根据虚拟地址 va 和页表 pagetable 查找对应的页表项
pte = walk(pagetable, va, 0);
if(pte == 0)//没有找到对应的页表项
return 0;
if((*pte & PTE_V) == 0)//PTE_V是页表中的有效位(valid),确保该页表项是有效的
return 0;
if((*pte & PTE_U) == 0)//PTE_U是页表项中的用户访问位。如果 PTE_U 为 0,表示该页表项不能被用户访问
return 0;
pa = PTE2PA(*pte);
// #define PTE2PA(pte) (((pte) >> 10) << 12)
// 将页表项中的物理地址部分提取出来得到实际的物理地址
// 页表项中的低10位用于表示属性,所以先清除,保留52位的物理地址部分
// 然后左移12位用于对齐到页边界,因为低12位表示页内偏移量
// 在计组中提到物理地址和虚拟地址的页内偏移量是相同的
return pa;
}

walk()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// Return the address of the PTE in page table pagetable
// that corresponds to virtual address va. If alloc!=0,
// create any required page-table pages.
//
// The risc-v Sv39 scheme has three levels of page-table
// pages. A page-table page contains 512 64-bit PTEs.
// A 64-bit virtual address is split into five fields:
// 39..63 -- must be zero.
// 30..38 -- 9 bits of level-2 index. 因为512=2^9所以需要9位保存页表索引
// 21..29 -- 9 bits of level-1 index.
// 12..20 -- 9 bits of level-0 index.
// 0..11 -- 12 bits of byte offset within the page.
/*
@param
pagetable:页表的根节点
va:要查找的虚拟地址
alloc:是否在找不到页表项时创建新的页表页
*/
pte_t *
walk(pagetable_t pagetable, uint64 va, int alloc)
{
if(va >= MAXVA)//确保虚拟地址在有效的范围内
panic("walk");

/*
有三级页表,分别对应L2,L1和L0层
L2 层索引:用于查找 L1 页表的基地址
L1 层索引:用于查找 L0 页表的基地址
L0 层索引:用于查找物理页框的基地址
*/
for(int level = 2; level > 0; level--) {
pte_t *pte = &pagetable[PX(level, va)];//计算当前层次的页表索引,获取对应的的页表项指针
/*
// extract the three 9-bit page table indices from a virtual address.
#define PXMASK 0x1FF // 9 bits 用于提取最右边的9位(低9位)
#define PXSHIFT(level) (PGSHIFT+(9*(level))) 计算虚拟地址宏对应层次的页表索引的偏移量
这里所说的“偏移量”是位数的偏移量,并且后面提到的参数在代码段最上方的注释中有写明
PGSHIFT是页内偏移的位移量,为12位
9*(level)g根据层次level计算额外的位移量,因为每一级页表索引占用9位
#define PX(level, va) ((((uint64) (va)) >> PXSHIFT(level)) & PXMASK)
将虚拟地址右移特定位数以使得所需的对应页表索引位位于最低的9位
然后与PXMASK相 & 得到最低的9位得到页表索引
*/
if(*pte & PTE_V) {
pagetable = (pagetable_t)PTE2PA(*pte);
// #define PTE2PA(pte) (((pte) >> 10) << 12)
} else {
if(!alloc || (pagetable = (pde_t*)kalloc()) == 0)
// 使用kalloc分配新的页表页
return 0;
memset(pagetable, 0, PGSIZE);//初始化新的页表页
*pte = PA2PTE(pagetable) | PTE_V;//将新分配的页表页的物理地址转换为页表项格式,并设置有效位 PTE_V
// shift a physical address to the right place for a PTE.
//#define PA2PTE(pa) ((((uint64)pa) >> 12) << 10)
// 先去除页内偏移量然后预留10位来表示属性信息
}
}
// 遍历完成后,pagetable 指向 L0 层的页表,返回对应的页表项指针
return &pagetable[PX(0, va)];//返回 L0 层的页表项指针
}

fetchchstr()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
// Fetch the nul-terminated string at addr from the current process.
// 获取一个以nul结尾的字符串
// Returns length of string, not including nul, or -1 for error.
int
fetchstr(uint64 addr, char *buf, int max)
{
struct proc *p = myproc();
int err = copyinstr(p->pagetable, buf, addr, max);
// copyinstr()函数的逻辑和上面copyin函数的逻辑基本相同,只添加了一个是否发现'\0'字符的判断
if(err < 0)
return err;
return strlen(buf);//返回字符串的长度
}

argraw()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 从当前进程的陷阱帧中获取系统调用参数的原始值
// 参考:https://zhuanlan.zhihu.com/p/634222367
// 陷阱帧包含从内核返回到当前进程时恢复用户模式所需的所有寄存器信息,以便处理器可以像陷阱启动时一样继续运行
static uint64
argraw(int n)
{
struct proc *p = myproc();
//返回相应的寄存器值,寄存器 a0 到 a5 通常用于传递系统调用的参数
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;
}

argint()函数

1
2
3
4
5
6
7
8
9
10
// Fetch the nth 32-bit system call argument.
// 取回 第n个 32位的系统调用的参数
// e.g. ssize_t sys_write(int fd, const void *buf, size_t count);
// n=0是第一个参数fd,n=1...以此类推
int
argint(int n, int *ip)
{
*ip = argraw(n);
return 0;
}

argaddr()函数

1
2
3
4
5
6
7
8
9
// Retrieve an argument as a pointer. 将参数作为指针值返回
// Doesn't check for legality, since 不检查指针的合法性,因为copin、copyout函数会进行检查
// copyin/copyout will do that.
int
argaddr(int n, uint64 *ip)
{
*ip = argraw(n);
return 0;
}

argstr()函数

1
2
3
4
5
6
7
8
9
10
11
12
// Fetch the nth word-sized system call argument as a null-terminated string.
// 需要注意这里是将参数作为一个和字长的长度相同的字符串
// Copies into buf, at most max.
// Returns string length if OK (including nul), -1 if error.
int
argstr(int n, char *buf, int max)
{
uint64 addr;
if(argaddr(n, &addr) < 0) // 检查是否合法
return -1;
return fetchstr(addr, buf, max);
}

syscall()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void
syscall(void)
{
int num;
struct proc *p = myproc();

num = p->trapframe->a7; // 从陷阱帧中获取系统调用号
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
// #define NELEM(x) (sizeof(x)/sizeof((x)[0]))
// 检查系统调用号是否在有效范围内并且检查syscalls数组中对应项是否为非空
p->trapframe->a0 = syscalls[num]();
//调用系统调用并且将返回值保存在陷阱帧的a0寄存器中
} else {
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->trapframe->a0 = -1;//表示系统调用失败
}
}

kernel/proc.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
// 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.
// 每个CPU的状态
struct cpu {
struct proc *proc; // The process running on this cpu, or null.
struct context context; // swtch() here to enter scheduler().
// 在 swtch() 函数中使用这个变量来进入调度器
int noff; // Depth of push_off() nesting. 计数器
int intena; // Were interrupts enabled before push_off()?
// 其实就是保存中断嵌套中第一次中断前是否允许中断
};

extern struct cpu cpus[NCPU];

// 每个进程的数据,用于 trampoline.S 中的中断处理代码。
// 存储在一个单独的页面中,位于 trampoline 页面下方,在用户页表中。不在内核页表中特别映射。
// sscratch 寄存器指向这里。
// uservec 在 trampoline.S 中保存用户寄存器到 trapframe,
// 然后从 trapframe 初始化寄存器,包括 kernel_sp, kernel_hartid, kernel_satp,并跳转到 kernel_trap。
// usertrapret() 和 userret 在 trampoline.S 中设置 trapframe 的 kernel_* 字段,
// 恢复用户寄存器从 trapframe,切换到用户页表,并进入用户空间。
// trapframe 包括被调用者保存的用户寄存器(如 s0-s11),因为通过 usertrapret() 返回用户路径时,
// 不会返回整个内核调用堆栈。
/*
uservec 是一个汇编函数,位于 trampoline.S 文件中。它负责保存用户态寄存器到 trapframe,然后初始化内核态寄存器(如 kernel_sp, kernel_hartid, kernel_satp),并跳转到 kernel_trap 函数
usertrapret() 和 userret 也是汇编函数,位于 trampoline.S 文件中。它们负责设置 trapframe 的内核字段,恢复用户态寄存器,切换到用户页表,并重新进入用户态
*/
struct trapframe {
/* 0 */ uint64 kernel_satp; // 内核页表的地址
/* 8 */ uint64 kernel_sp; // 进程的内核栈顶地址
/* 16 */ uint64 kernel_trap; // usertrap() 函数地址,用于处理用户态的中断
/* 24 */ uint64 epc; // 保存的用户程序计数器,中断发生时的指令地址
/* 32 */ uint64 kernel_hartid; // 保存的内核 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 };
// 进程的不同状态 未使用;等待;准备好运行,等待CPU时间片;正在执行;僵尸进程
// 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)
};

user/proc.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include "types.h"
#include "param.h"
#include "memlayout.h"
#include "riscv.h"
#include "spinlock.h"
#include "proc.h"
#include "defs.h"

struct cpu cpus[NCPU];

struct proc proc[NPROC];

struct proc *initproc;

int nextpid = 1;
struct spinlock pid_lock; // 自旋锁(是互斥锁),用于保护进程id的分配
// 保证pid的唯一性,也防止两个进程回收同一个pid,也防止竞争条件

extern void forkret(void);
static void wakeup1(struct proc *chan);
static void freeproc(struct proc *p);

extern char trampoline[]; // trampoline.S

procinit()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// initialize the proc table at boot time. 在启动时初始化进程表
void
procinit(void)
{
struct proc *p;

initlock(&pid_lock, "nextpid");//初始化pid_lock这个锁,并且起名便于调试
// struct proc proc[NPROC];
// p作为一个指针指向proc数组中的所有进程,从而初始化所有进程
for(p = proc; p < &proc[NPROC]; p++) {
initlock(&p->lock, "proc");

// Allocate a page for the process's kernel stack.
// Map it high in memory, followed by an invalid
// guard page.
char *pa = kalloc();
if(pa == 0) //分配内存页失败
panic("kalloc");
uint64 va = KSTACK((int) (p - proc)); // 计算第k个进程栈的虚拟地址
// p-proc就是两者的地址差
/*
// map kernel stacks beneath the trampoline,
// each surrounded by invalid guard pages.
#define KSTACK(p) (TRAMPOLINE - ((p)+1)* 2*PGSIZE)
TRAMPOLINE - ((p)+1) * 2 * PGSIZE
((p)+1) * 2 * PGSIZE 计算的是从trampoline地址向下偏移的距离
2 * PGSIZE 表示每个内核栈前后各有一个无效的保护页。
TRAMPOLINE - ((p)+1) * 2 * PGSIZE 计算的是第(p)个进程的内核栈地址
*/
kvmmap(va, (uint64)pa, PGSIZE, PTE_R | PTE_W);//读和写权限
p->kstack = va;//给所有进程分配进程栈
}
kvminithart();
}

initlock()函数

1
2
3
4
5
6
7
void
initlock(struct spinlock *lk, char *name)
{
lk->name = name;// 给锁赋予名字
lk->locked = 0;
lk->cpu = 0;// 暂时没有cpu在占用这个锁
}

kalloc()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Allocate one 4096-byte page of physical memory.
// Returns a pointer that the kernel can use.
// Returns 0 if the memory cannot be allocated.
void *
kalloc(void)
{
struct run *r;
/*
struct run {
struct run *next;
};
其实就是一个链表,不过没有数据域,每个节点代表一个可用的内存页
为什么不需要数据域呢,因为只需要通过run结构体来管理和链接这些内存页
每个内存页的前几个字节用于存储run结构体,其余部分用于存储实际的数据
内存页的实际数据存储在run结构体之后的部分,可以认为是隐式的数据域
*/

acquire(&kmem.lock); // 获取kmem的自旋锁
/*
struct {
struct spinlock lock; // 保证同一时间内只有一个线程可以对自由列表进行访问
struct run *freelist; // 自由列表的头部,自由列表就是内核空间中还可以被分配的内存页
} kmem; // kernel memory
*/
r = kmem.freelist; 头指针
if(r)
kmem.freelist = r->next; // 移除第一个节点,将自由列表中的未分配内存页进行分配
release(&kmem.lock); // 释放锁

if(r)
memset((char*)r, 5, PGSIZE); // fill with junk
// 如果 r 不为空,将分配的内存页填充为垃圾数据(初始化)
return (void*)r; // 返回指向分配的内存页的指针,为0就是分配失败
}

acquire()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
//Acquire the lock.
// Loops (spins) until the lock is acquired.
void
acquire(struct spinlock *lk)
{
push_off(); // disable interrupts to avoid deadlock.
if(holding(lk)) // 查看是否自身已经持有锁
/*
// Check whether this cpu is holding the lock.
// Interrupts must be off.
int
holding(struct spinlock *lk)
{
int r;
r = (lk->locked && lk->cpu == mycpu());
return r;
}
*/
panic("acquire");

// On RISC-V, sync_lock_test_and_set turns into an atomic swap:
// a5 = 1 要设置的值
// s1 = &lk->locked 锁的变量状态的地址
// amoswap.w.aq a5, a5, (s1)
while(__sync_lock_test_and_set(&lk->locked, 1) != 0)
;
/*
如果锁已经被其他 CPU 持有(lk->locked 为 1),则返回 1,表示获取锁失败,继续自旋
如果锁未被持有(即 lk->locked 为 0),则设置 lk->locked 为 1,表示获取锁成功,退出循环
*/

// Tell the C compiler and the processor to not move loads or stores
// past this point, to ensure that the critical section's memory
// references happen strictly after the lock is acquired.
// On RISC-V, this emits a fence instruction.
__sync_synchronize();
//用于插入完整内存屏障指令,防止 CPU 乱序执行指令
//这个函数没有参数,只是作为一个内存屏障指令的占位符存在,可以防止编译器优化代码,同时保证 CPU 按照指定的顺序执行指令
// Record info about lock acquisition for holding() and debugging.
lk->cpu = mycpu();
}

release()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void
release(struct spinlock *lk)
{
if(!holding(lk))
panic("release");

lk->cpu = 0;

// Tell the C compiler and the CPU to not move loads or stores
// past this point, to ensure that all the stores in the critical
// section are visible to other CPUs before the lock is released,
// and that loads in the critical section occur strictly before
// the lock is released.
// On RISC-V, this emits a fence instruction.
__sync_synchronize();

// Release the lock, equivalent to lk->locked = 0.
// This code doesn't use a C assignment, since the C standard
// implies that an assignment might be implemented with
// multiple store instructions.
// On RISC-V, sync_lock_release turns into an atomic swap:
// s1 = &lk->locked
// amoswap.w zero, zero, (s1)
__sync_lock_release(&lk->locked);

pop_off();
}

kvmmap函数()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// add a mapping to the kernel page table. 向内核页表添加一个映射
// only used when booting.
// does not flush TLB or enable paging. 不刷新TLB或启动分页
/*
@param
kernel_pagetable: 内核页表
va: 虚拟地址
sz: 映射区域的大小
pa: 物理地址
perm: 访问权限
*/
void
kvmmap(uint64 va, uint64 pa, uint64 sz, int perm)
{
if(mappages(kernel_pagetable, va, sz, pa, perm) != 0)
panic("kvmmap");
}

mappages()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Create PTEs for virtual addresses starting at va that refer to
// physical addresses starting at pa. va and size might not
// be page-aligned. Returns 0 on success, -1 if walk() couldn't
// allocate a needed page-table page.
int
mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm)
{
uint64 a, last;
pte_t *pte;

a = PGROUNDDOWN(va); // 向下对齐到页边界
last = PGROUNDDOWN(va + size - 1); // 将最后一个字节的地址向上对齐到页边界
// 因为有可能size比较大,需要横跨多个页表
for(;;){ //循环映射,直到映射完全
if((pte = walk(pagetable, a, 1)) == 0)//创建或者查找指向虚拟地址a的页表项,查找到的一定是有效的
return -1;//失败
if(*pte & PTE_V)//如果页表项已经存在并且有效
panic("remap");
*pte = PA2PTE(pa) | perm | PTE_V; // 物理地址转变为页表项并且设置vaild bit和权限
if(a == last)//到了最后一个页
break;
a += PGSIZE;//虚拟地址移动到下一个页表
pa += PGSIZE;//物理地址移动到下一个页
}
return 0;
}

kvminithart()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// Switch h/w page table register to the kernel's page table,
// and enable paging.
// 切换硬件页表寄存器到内核页表,并且启动分页
void
kvminithart()//Kernel Virtual Memory Initialize Hart(Hardware Thread)
//初始化内核虚拟内存的硬件线程
{
w_satp(MAKE_SATP(kernel_pagetable));// 切换硬件页表寄存器到内核的页表
/*
// supervisor address translation and protection;
// holds the address of the page table.
// 监督地址翻译和保护,储存页表的地址
static inline void
w_satp(uint64 x)
{
asm volatile("csrw satp, %0" : : "r" (x));// ->汇编代码
//asm关键字用于在CPP中直接嵌入汇编代码
//csrw是控制/状态寄存器写指令,satp是目标寄存器,%0是占位符,CSR(控制及状态寄存器)
//satp 寄存器用于存储当前使用的页表的基址和其他相关信息
//volatile代表告诉编译器不要优化掉这条指令
}
*/
sfence_vma();//刷新TLB(translation lookaside buffer)
//TLB在计组中有提到过,用于加速虚拟地址到物理地址的转换,在MMU内部保存最近访问的页对应表项的小表
/*
在这里啰嗦一点计组的内容,在进行地址转换的时候,虚拟地址的除了offset域的高地址位相当于页表中的页表号
页表的结构是 控制位|存储器中的页帧
页表号代表储存在页表的第几个位置,页表在这里就像一个数组,页表号是下标,储存的值就是实际的页帧
虚页一共有多少页,页表中就有多少个pte
由于我们知道,不同的进程都有自己不同的页表来实现隔离,所以在查找的时候
首先要知道当前进程页表的起始地址(存储在页表基址存储器中),加上虚拟页号就是该虚拟页号
在页表中对应的实际地址(类似数组中首地址加偏移量就是该项的地址),对应就可以在该地址读取出
对应的物理页帧了
但在TLB中是不同的
TLB的结构是 虚拟页号|控制位|存储器中的页帧
所以在TLB中寻址的时候是不可以直接对应到TLB的某个位置直接读取对应的页帧的
需要顺序进行搜索然后比对虚拟页号,直到找到对应的页号并且查看控制位是否有效决定是
miss->从页表中查找并更新刷新TLB
hit->直接获取物理页帧地址
所以TLB实际上相当于使用了全相联映射技术,需要一个个比对直到查找到对应的信息
*/
//比如在多级的页表映射中进行逐级查找(前面的代码中有体现)是非常麻烦的
//所以TLB缓存虚拟地址和其映射的物理地址,根据虚拟地址查找cache
//TLB其实不需要存储虚拟地址和物理地址的低12位(因为低12位都一样,是页内偏移量)即不需要offset域
//当切换到新的页表时,需要刷新TLB以确保新的映射生效
//在修改页表条目后,也要刷新TLB以确保后续的内存访问使用最新的映射
/*
// flush the TLB.
static inline void
sfence_vma()
{
// the zero, zero means flush all TLB entries.
asm volatile("sfence.vma zero, zero");
}
*/
}

cpuid()函数

1
2
3
4
5
6
7
8
9
10
// Must be called with interrupts disabled,
// to prevent race with process being moved
// to a different CPU. 必须禁用中断,防止进程被调度到另一个cpu上
int
cpuid()
{
int id = r_tp();//读取tp寄存器的值
// tp寄存器用于存储线程指针,指向当前线程的上下文信息(context)
return id;
}

由于后面的函数太多了,所以比较简单的函数就不再作说明

allocpid()函数

1
2
3
4
5
6
7
8
9
10
11
int
allocpid() {
int pid;

acquire(&pid_lock);//必须要上锁,
pid = nextpid;
nextpid = nextpid + 1;
release(&pid_lock);

return pid;
}

allocproc()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/ Look in the process table for an UNUSED proc.
// If found, initialize state required to run in the kernel,
// and return with p->lock held.
// If there are no free procs, or a memory allocation fails, return 0.
//用于在进程表中查找一个未使用的进程结构,并初始化该进程所需的状态,以便其能够在内核中运行
static struct proc*
allocproc(void)
{
struct proc *p;
// 在进程表中查找一个未使用的进程结构
for(p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);//获取进程锁
if(p->state == UNUSED) {//找到未使用的进程
goto found;
} else {
release(&p->lock);//释放
}
}
return 0;

found:
p->pid = allocpid();//分配新的pid

// Allocate a trapframe page.
if((p->trapframe = (struct trapframe *)kalloc()) == 0){
// kalloc()只是分配一个可以使用的kernel中的物理页,返回的是(void*)
// 所以用于作为页还是陷阱帧都是可以的
release(&p->lock);
return 0;
}

// An empty user page table.
p->pagetable = proc_pagetable(p);//这个函数在后面有详细的分析,用于创建一个新的用户列表
if(p->pagetable == 0){
freeproc(p);// 创建页表失败,释放进程资源
release(&p->lock);
return 0;
}

// Set up new context to start executing at forkret,
// which returns to user space.
// 设置新的上下文,从forkret开始执行,返回到用户空间
memset(&p->context, 0, sizeof(p->context));// 将上下文结构清零
p->context.ra = (uint64)forkret;// 设置返回地址为forkret,也就是内核返回到用户空间的入口点
// forkret 是一个特殊的函数,它在新创建的子进程第一次被调度器调度时调用
// forkret 负责释放进程锁、初始化文件系统(如果需要)以及最终返回用户态
// 确保新进程在第一次被调度时能够正确地初始化并返回用户态
p->context.sp = p->kstack + PGSIZE;//设置堆栈指针为内核栈的顶部
// 时刻需要记住内存空间的堆栈模型,栈一定是从高地址向低地址进行增长的

return p;
}

freeproc()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// free a proc structure and the data hanging from it,
// including user pages.
// p->lock must be held.
static void
freeproc(struct proc *p)
{
if(p->trapframe)
kfree((void*)p->trapframe);//释放陷阱帧中的所有内容,将其转换为空闲的可用的页面
p->trapframe = 0;
if(p->pagetable)
proc_freepagetable(p->pagetable, p->sz);//释放进程页表中的内容
p->pagetable = 0;
p->sz = 0;
p->pid = 0; // 清除进程 ID
p->parent = 0; // 清除父进程指针
p->name[0] = 0; // 清除进程名称
p->chan = 0; // 清除通道指针
p->killed = 0; // 清除杀死标志
p->xstate = 0; // 清除扩展状态
p->state = UNUSED; // 设置进程状态为未使用
}

kfree()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/ Free the page of physical memory pointed at by v,
// which normally should have been returned by a
// call to kalloc(). (The exception is when
// initializing the allocator; see kinit above.)
// 一般用于释放由kalloc分配的物理内存页面
void
kfree(void *pa)
{
struct run *r;
// 检查传入的地址是否合法
// 首先地址必须是页对齐的,地址必须是PGSIZE的倍数
// 地址必须在end和PHYSTOP之间,其中end是内核结束地址,PHYSTOP是物理内存的上限
// the kernel expects there to be RAM
// for use by the kernel and user pages
// from physical address 0x80000000 to PHYSTOP.
// #define KERNBASE 0x80000000L
// 硬件设备通常会被映射到较低的地址空间范围,所以在这里内核加载到0x80000000处
// #define PHYSTOP (KERNBASE + 128*1024*1024) // 内存为128MB
// KERNBASE到PHYSTOP是内核和用户页面的物理地址范围
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");

// Fill with junk to catch dangling refs.
//将释放的页面填充为全1,在后续使用时更容易发现悬空引用(即对已释放内存的非法访问)
memset(pa, 1, PGSIZE);

r = (struct run*)pa;//将释放的页面地址转换为run类型
// 前文有提到过每个内存页的前几个字节用于存储run结构体,其余部分用于存储实际的数据
// 所以内存页的开始地址可以认为指向一个页,也可以认为指向一个run结构体,其地址都是相同的

acquire(&kmem.lock);
r->next = kmem.freelist;//将释放后变为空闲的页面链接到空闲内存页的链表头部
kmem.freelist = r;//更改头指针
release(&kmem.lock);
}

freepagetable()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Free a process's page table, and free the
// physical memory it refers to.
// 释放一个进程的页表,并且同时释放其指向的物理内存
void
proc_freepagetable(pagetable_t pagetable, uint64 sz)
{
uvmunmap(pagetable, TRAMPOLINE, 1, 0);//uvm应该是user virtual memory
// 解除pagetable到TRAMPOLINE地址开始,一共1页的映射关系(也就是去除指向其的表项)
// 去除表项可以通过walk函数查找到,walk()返回虚拟地址对应的pte并且可以选择是否在未找到时创建
// 最后一个参数表示是否需要将指向的物理内存也释放
// TRAMPOLINE是一个特殊的虚拟地址,用于在用户空间和内核空间之间进行切换
// TRAMPOLINE页被映射到虚拟地址空间的顶端
// 1表示解除映射的页数,0表示不需要释放物理页面
uvmunmap(pagetable, TRAPFRAME, 1, 0);//释放陷阱帧
// uvmunmap的逻辑很简单,就是根据最后一个参数决定使用kfree对于指向的物理内存进行释放
// 然后将页表项置为0
uvmfree(pagetable, sz);
}

uvmfree()函数

1
2
3
4
5
6
7
8
9
// Free user memory pages,
// then free page-table pages.
void
uvmfree(pagetable_t pagetable, uint64 sz)
{
if(sz > 0)
uvmunmap(pagetable, 0, PGROUNDUP(sz)/PGSIZE, 1); // 释放用户内存页面
freewalk(pagetable);// 释放页表页面
}

freewalk()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Recursively free page-table pages.
// All leaf mappings must already have been removed.
void
freewalk(pagetable_t pagetable)
{
// there are 2^9 = 512 PTEs in a page table.
for(int i = 0; i < 512; i++){
pte_t pte = pagetable[i];//获取第i个页表项
if((pte & PTE_V) && (pte & (PTE_R|PTE_W|PTE_X)) == 0){//是否是中间节点且有效
//PTE_R指示这一页物理帧是否能被读。
//PTE_W指示这一页物理帧是否能被写。
//PTE_X指示这一页物理帧是否能被CPU看待并转换成指令来执行
//(pte & (PTE_R|PTE_W|PTE_X)) == 0说明这个 PTE 指向一个更低级别的页表
//通常中间节点页表项的PTE_R、PTE_W和PTE_X位都为 0
uint64 child = PTE2PA(pte);// 将pte转换为物理地址
freewalk((pagetable_t)child);// 递归释放子页表
pagetable[i] = 0;
} else if(pte & PTE_V){
panic("freewalk: leaf");//PTE是有效的但不是指向子页表
}
}
//如果是一个叶子节点就可以直接在此处释放,所以在上面不做处理
kfree((void*)pagetable);// 释放当前页表所占有的物理内存
}

initcode[]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// a user program that calls exec("/init")
// od -t xC initcode
/*
我们总是需要有一个用户进程在运行,这样才能实现与操作系统的交互,
所以这里需要一个小程序来初始化第一个用户进程。
这个小程序定义在initcode中,通过exec调用init程序
init会为用户空间设置好一些东西,比如配置好console,调用fork,并在fork出的子进程中执行shell
*/
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
};

userinit()函数,这个很重要

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 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));//initcode用于执行"/init"程序,如上
p->sz = PGSIZE;// 将进程的大小设置为一个页

// prepare for the very first "return" from kernel to user.
p->trapframe->epc = 0; // user program counter 用户程序入口为0
p->trapframe->sp = PGSIZE; // user stack pointer 指向页面顶部

safestrcpy(p->name, "initcode", sizeof(p->name));// 为进程命名
p->cwd = namei("/");// 表示进程的当前工作目录是根目录(cwd= current work directory)
// namei函数查找并返回根目录的 inode 结构

p->state = RUNNABLE;

release(&p->lock);
}

uvminit()函数 user virtual memory init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Load the user initcode into address 0 of pagetable,
// for the very first process.
// sz must be less than a page.
void
uvminit(pagetable_t pagetable, uchar *src, uint sz)
{
char *mem;

if(sz >= PGSIZE)
panic("inituvm: more than a page");
mem = kalloc();// 分配一个页面大小的内存块
memset(mem, 0, PGSIZE);
mappages(pagetable, 0, PGSIZE, (uint64)mem, PTE_W|PTE_R|PTE_X|PTE_U);
// 将内存块映射到进程的页表中,设置一些权限标志
memmove(mem, src, sz);//将initcode复制到分配的内存块中
}

fork()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// Create a new process, copying the parent.
// Sets up child kernel stack to return as if from fork() system call.
int
fork(void)
{
int i, pid;
struct proc *np;
struct proc *p = myproc();

// Allocate process.
if((np = allocproc()) == 0){
return -1;
}

// Copy user memory from parent to child.
if(uvmcopy(p->pagetable, np->pagetable, p->sz) < 0){//将父进程的用户内存复制到子进程的页表中
freeproc(np);// 如果复制失败,释放新进程
release(&np->lock);// 释放锁,因为在allocproc()函数中会自动加锁,所以在这里是隐式的
return -1;
}
np->sz = p->sz;

np->parent = p;// 设置子进程的父进程

// copy saved user registers. 复制陷阱帧
*(np->trapframe) = *(p->trapframe);

// Cause fork to return 0 in the child. a0为返回值寄存器,保证子程序的fork返回值为0
np->trapframe->a0 = 0;

// increment reference counts on open file descriptors.
for(i = 0; i < NOFILE; i++)//最多16个文件描述符
if(p->ofile[i])
np->ofile[i] = filedup(p->ofile[i]);
// 遍历父进程的文件描述符数组,如果文件描述符有效,则调用 filedup 函数增加引用计数
/*
// Increment ref count for file f.
struct {
struct spinlock lock;
struct file file[NFILE];
} ftable;
struct file*
filedup(struct file *f)
{
acquire(&ftable.lock);
if(f->ref < 1)// 说明文件描述符无效
panic("filedup");
f->ref++;
release(&ftable.lock);
return f;
}
*/
// 并将文件描述符复制到子进程的文件描述符数组中
np->cwd = idup(p->cwd);// 复制父进程的当前工作目录

safestrcpy(np->name, p->name, sizeof(p->name));// 复制进程名

pid = np->pid;//获取子进程的进程标识符,pid用全局变量nextpid进行维护

np->state = RUNNABLE;

release(&np->lock);

return pid;// 返回子进程的pid
}

reparent()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Pass p's abandoned children to init.
// Caller must hold p->lock.
// 用于把所有孤儿进程托管到init进程下,不会的导致其他问题但是会占用进程表导致进程表满
void
reparent(struct proc *p)
{
struct proc *pp;

for(pp = proc; pp < &proc[NPROC]; pp++){
// 检查当前进程 pp 的父进程是否为 p。
// 这里没有对 pp->lock 进行加锁,因为加锁可能会导致死锁。
// 具体来说,如果 pp 或其子进程也在 exit 函数中并且即将尝试锁定 p,则会导致死锁
if(pp->parent == p){
// pp->parent can't change between the check and the acquire()
// because only the parent changes it, and we're the parent.
acquire(&pp->lock);
pp->parent = initproc;
// we should wake up init here, but that would require
// initproc->lock, which would be a deadlock, since we hold
// the lock on one of init's children (pp). this is why
// exit() always wakes init (before acquiring any locks).
// 比如说如果initproc需要获取pp->lock那么就会出问题,因为initproc会等待 pp->lock
// 而我们又在等待initproc->lock
// 因此,唤醒 initproc 的操作通常在exit函数中进行,且在获取任何锁之前
release(&pp->lock);

/*
不在检查pp->parent时加锁,以避免与pp或其子进程在exit函数中尝试锁定p时产生死锁。
在修改pp->parent时加锁,确保修改操作的原子性和一致性。
唤醒initproc的操作在exit函数中进行,且在获取任何锁之前,以避免死锁
*/
}
}
}

exit()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
// Exit the current process.  Does not return.退出当前进程但是不返回
// An exited process remains in the zombie state一个退出了的进程在僵尸状态
// until its parent calls wait().直到其父亲调用了wait
/* @param
status:进程退出时的状态值,即在使用时给它一个无符号的整型数,该数将会作为进程的退出状态
并且要在0-255范围内,否则将自动默认为未定义退出状态值
*/

void
exit(int status)
{
struct proc *p = myproc();

if(p == initproc)// initproc不应该被退出
panic("init exiting");

// Close all open files.
for(int fd = 0; fd < NOFILE; fd++){
if(p->ofile[fd]){
struct file *f = p->ofile[fd];
fileclose(f);
p->ofile[fd] = 0;
}
}

begin_op();// 首先需要保证文件系统的操作是安全的
iput(p->cwd);// 释放当前工作目录(减少一次引用,如果减少到0那么inode会被回收)
end_op();
p->cwd = 0;

// we might re-parent a child to init. we can't be precise about
// waking up init, since we can't acquire its lock once we've
// acquired any other proc lock. so wake up init whether that's
// necessary or not. init may miss this wakeup, but that seems
// harmless.
// 获取initproc的锁,唤醒initproc,以便它可以接收孤儿进程
// 由于并发控制的需要,一旦获取了某个进程的锁,就不能再获取其他进程的锁,以避免死锁
// 因此,在已经获取了当前进程或其他进程的锁的情况下,无法精确地获取 init 进程的锁
acquire(&initproc->lock);
wakeup1(initproc);// wakeup1专门用于唤醒slepping in wait()的进程p,使用前必须获取p的lock
release(&initproc->lock);
/* initproc的主要职责是初始化用户态环境,并启动其他必要的系统进程和服务
因此,initproc会有很多子进程,这些子进程通常包括系统服务、守护进程和其他用户态程序
initproc在以下情况下可能会进入睡眠状态:
1.等待子进程退出: 在调用wait系统调用时。
2.等待系统事件: 在等待特定事件发生时。
3.空闲状态: 在没有任务需要处理时。

*/

// 获取 p->parent 的副本,以确保我们解锁的是同一个父进程。
// 以防我们的父进程在我们等待父进程锁时将我们交给 init。
// 这样做可能会与一个正在退出的父进程产生竞争条件,
/*
竞争条件: 在获取父进程锁的过程中,父进程可能会退出
如果父进程在当前进程等待锁时退出,可能会导致当前进程的父进程引用发生变化
当前进程 p 正在等待获取父进程 original_parent 的锁
在等待期间,父进程 original_parent 退出,并将当前进程 p 重新分配给 initproc
当前进程 p 终于获取到了锁,但此时父进程已经不再是原来的 original_parent,而是 initproc
*/
// 但结果只会是一个无害的虚假唤醒,唤醒一个已经退出或错误的进程;
// 进程结构体永远不会被重新分配为其他用途。
// 为了确保在后续操作中锁定的是正确的父进程,即使父进程在这段时间内已经被重新分配给initproc
acquire(&p->lock);
struct proc *original_parent = p->parent;
release(&p->lock);

// we need the parent's lock in order to wake it up from wait().
// the parent-then-child rule says we have to lock it first.
acquire(&original_parent->lock); // 获取父进程的锁,以便可以安全地唤醒父进程


acquire(&p->lock);

// Give any children to init.
reparent(p);// 将p的子进程重新分配给initproc
// 因为p此刻马上要退出了.所以其还没有退出的子进程就会变成孤儿进程,需要进行托管


// Parent might be sleeping in wait().
wakeup1(original_parent);// 唤醒父进程,使其从wait函数中返回

// 设置当前进程的退出状态和状态为僵尸状态
p->xstate = status;
p->state = ZOMBIE;

release(&original_parent->lock);

// Jump into the scheduler, never to return.
sched();
// 跳转到调度器,让出CPU。理论上,调度器会安排其他进程运行
// 而当前进程会保持在僵尸状态,直到其父进程调用wait将其清理。
panic("zombie exit");
}

fileclose()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// Close file f.  (Decrement ref count, close when reaches 0.)
// 关闭文件f,减少文件的引用计数直到到达0的时候才真正关闭
struct file {
enum { FD_NONE, FD_PIPE, FD_INODE, FD_DEVICE } type;
// 文件描述符未使用,管道,文件或目录,设备
int ref; // reference count
char readable;// 1/0
char writable;
struct pipe *pipe; // FD_PIPE 指向管道的指针
struct inode *ip; // FD_INODE and FD_DEVICE 指向inode/设备的指针
uint off; // FD_INODE 文件的当前偏移量
short major; // FD_DEVICE 设备的主设备号
};
struct pipe {
struct spinlock lock;
char data[PIPESIZE];
uint nread; // number of bytes read
uint nwrite; // number of bytes written
int readopen; // read fd is still open
int writeopen; // write fd is still open
};
void
fileclose(struct file *f)
{
struct file ff;

acquire(&ftable.lock);//获取锁保证操作的一致性
if(f->ref < 1) // 已经关闭
panic("fileclose");
if(--f->ref > 0){ // 说明还有其他进程在引用
release(&ftable.lock);
return;
}
// 否则说明没有进程在引用该文件,进行关闭操作
ff = *f;
f->ref = 0;
f->type = FD_NONE;// 表示文件已经关闭
release(&ftable.lock);

if(ff.type == FD_PIPE){
pipeclose(ff.pipe, ff.writable);
} else if(ff.type == FD_INODE || ff.type == FD_DEVICE){
begin_op();
iput(ff.ip);// 调用 iput 函数释放文件的 inode 引用
end_op();
}
}

pipeclose()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void
pipeclose(struct pipe *pi, int writable)// wirtable用于决定关闭读端还是写端
{
acquire(&pi->lock);
if(writable){
pi->writeopen = 0;
wakeup(&pi->nread);
//唤醒所有等待读取的进程。这些进程可能会因为写端关闭而结束等待状态
} else {
pi->readopen = 0;
wakeup(&pi->nwrite);
//唤醒所有等待写的进程。这些进程可能会因为读端关闭而结束等待状态
}
if(pi->readopen == 0 && pi->writeopen == 0){
release(&pi->lock);
kfree((char*)pi);
// 如果两端都已经关闭,释放管道的锁,并调用 kfree 函数释放管道占用的内存
} else
release(&pi->lock);// 否则仅释放锁
}

wakeup()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Wake up all processes sleeping on chan.
// Must be called without any p->lock.
// 唤醒所有在chan上等待的进程,进程必须无锁
void
wakeup(void *chan)
{
struct proc *p;

for(p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);
if(p->state == SLEEPING && p->chan == chan) {// 其等待的通道是否与传入的chan匹配
p->state = RUNNABLE;
}
release(&p->lock);
}
}

begin_op()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// called at the start of each FS system call.
// 控制对文件系统的并发访问,并确保日志空间不会被耗尽
void
begin_op(void)
{
acquire(&log.lock);// 获取日志的锁
while(1){
if(log.committing){ // 如果有日志正在提交那么就将当前线程sleep
sleep(&log, &log.lock);
} else if(log.lh.n + (log.outstanding+1)*MAXOPBLOCKS > LOGSIZE){
// this op might exhaust log space; wait for commit.
// log.lh.n表示当前已使用的日志空间大小,log.outstanding是当前正在处理但未提交的操作数量
// MAXOPBLOCKS定义了一个操作可能需要的最大日志块数,而LOGSIZE则是整个日志空间的大小
sleep(&log, &log.lock);// 日志空间不足
} else {
log.outstanding += 1;
release(&log.lock);
break;
}
}
}

end_op()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// called at the end of each FS system call.
// commits if this was the last outstanding operation.
// 在所有正在进行的操作完成后触发一次日志提交
void
end_op(void)
{
int do_commit = 0;

acquire(&log.lock);
log.outstanding -= 1; // 减少正在进行的操作计数
if(log.committing) // 不应该在一个日志提交过程中尝试结束另一个操作
panic("log.committing");
if(log.outstanding == 0){
do_commit = 1;
log.committing = 1;// 开始提交
} else {
// begin_op() may be waiting for log space,
// and decrementing log.outstanding has decreased
// the amount of reserved space.
// 如果还有其他操作正在进行,唤醒可能因日志空间不足而等待的线程
// 因为减少log.outstanding可能会释放足够的日志空间
wakeup(&log);
}
release(&log.lock);

if(do_commit){
// call commit w/o holding locks, since not allowed
// to sleep with locks.
// 这里不持有任何锁,因为commit函数可能需要睡眠,而持有锁时不能睡眠
commit();// 进行日志提交
acquire(&log.lock); // 在日志提交完成后重新获取锁
log.committing = 0; // 标记日志提交结束
wakeup(&log);// 唤醒可能因等待日志提交结束而阻塞的线程
release(&log.lock);
}
}

产生死锁的四个必要条件:

(1) 互斥条件:一个资源每次只能被一个进程使用。
(2) 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
(3) 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
(4) 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

持有锁时不能睡眠->对应第(2)条规则

“一旦获取了某个进程的锁,就不能再获取其他进程的锁,以避免死锁”主要是为了防止循环等待,例如,进程 A 持有锁 L1 并试图获取锁 L2,而进程 B 持有锁 L2 并试图获取锁 L1,这就形成了一个死锁环,导致死锁

唤醒父进程: 当一个子进程退出时,需要唤醒其父进程,使父进程从 wait 系统调用中返回。为了确保在唤醒父进程时不会出现竞争条件,需要获取父进程的锁。这确保了在唤醒父进程之前,父进程的状态不会被其他线程修改。

父进程-子进程规则 (Parent-Then-Child Rule):

锁的获取顺序: 在多线程或多进程环境中,为了避免死锁,通常会有一个明确的锁获取顺序。这里的“父进程-子进程规则”是指在处理父进程和子进程的关系时,必须先获取父进程的锁,然后再获取子进程的锁。

原因: 这个规则有助于避免死锁。如果两个进程(或线程)在不同的顺序下获取相同的锁,可能会导致循环等待,进而引发死锁。例如:

  • 进程 A 持有子进程的锁,试图获取父进程的锁。

  • 进程 B 持有父进程的锁,试图获取子进程的锁。

  • 这种情况下,进程 A 和 B 都会陷入等待状态,形成死锁

wait()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// Wait for a child process to exit and return its pid.
// Return -1 if this process has no children.
int
wait(uint64 addr)
{
struct proc *np;
int havekids, pid;
struct proc *p = myproc();

// hold p->lock for the whole time to avoid lost
// wakeups from a child's exit().
// 获取当前进程 p 的锁,确保在整个 wait 函数执行过程中不会丢失来自子进程 exit 的唤醒信号
acquire(&p->lock);

for(;;){
// Scan through table looking for exited children.
// 无限循环不断检查是否有子进程退出
havekids = 0;// 现在无子进程
for(np = proc; np < &proc[NPROC]; np++){
// this code uses np->parent without holding np->lock.
// acquiring the lock first would cause a deadlock,
// since np might be an ancestor, and we already hold p->lock.
// 根据父子进程规则,必须先获取父进程的锁才能获取子进程的锁,所以在读取过程中不可以加锁
if(np->parent == p){
// np->parent can't change between the check and the acquire()
// because only the parent changes it, and we're the parent.
// 在下一行代码的acquire()和上面的检查之间np的父进程不会变化
// 因为只有父进程可以对其进行修改,而p就是其父进程
// 这里应该是用于说明在下面acquire()的锁就是上面检查到的np,其父进程不会改变
acquire(&np->lock);
havekids = 1; // 表示当前进程有子进程
if(np->state == ZOMBIE){
// Found one.
pid = np->pid;
if(addr != 0 && copyout(p->pagetable, addr, (char *)&np->xstate,
sizeof(np->xstate)) < 0) {
// 如果addr不为0,使用copyout将子进程的退出状态复制到用户空间地址addr
// 如果参数addr的值不是NULL,wait就会把子进程退出时的状态取出并存入其中,这是一个整数值
// 如果 copyout 失败,释放所有锁并返回 -1
release(&np->lock);
release(&p->lock);
return -1;
}
// 调用freeproc释放子进程的资源
freeproc(np);
release(&np->lock);
release(&p->lock);
return pid;// 释放锁,返回子进程的pid

}
release(&np->lock);
}
}

// No point waiting if we don't have any children.
// 当前进程没有子进程,或者当前进程被杀死,释放当前进程的锁并返回-1
if(!havekids || p->killed){
release(&p->lock);
return -1;
}

// Wait for a child to exit.
// 调用sleep使当前进程进入睡眠状态,等待子进程退出
sleep(p, &p->lock); //DOC: wait-sleep
}
}

secheduler()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// Per-CPU process scheduler.
// Each CPU calls scheduler() after setting itself up.
// Scheduler never returns. It loops, doing:
// - choose a process to run.
// - swtch to start running that process.
// - eventually that process transfers control
// via swtch back to the scheduler.
// 每个CPU在初始化完成后都会调用这个调度器。调度器的主要任务是在所有进程中选择一个可运行的进程
// 并切换到该进程执行,直到该进程自愿放弃CPU控制权或被中断打断,再回到调度器继续选择下一个可运行的进程
void
scheduler(void)
{
struct proc *p;
struct cpu *c = mycpu();

c->proc = 0; // 初始化为0表示现在没有运行任何进程
for(;;){ // 使用一个无限循环来不断选择和运行进程
// Avoid deadlock by ensuring that devices can interrupt.
// 启用中断,确保设备可以中断当前进程,避免死锁
intr_on();

int found = 0; // 表示是否找到了可运行的进程
for(p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);// 先获取锁防止别的进程进行修改
if(p->state == RUNNABLE) {
// Switch to chosen process. It is the process's job
// to release its lock and then reacquire it
// before jumping back to us.
p->state = RUNNING;
c->proc = p;// 将当前CPU的当前进程指针c->proc设置为该进程p
swtch(&c->context, &p->context);// 使用swtch函数切换到该进程的上下文,开始执行该进程
/*
CPU 上下文切换就是先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来
然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务
而这些保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来
这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行
切换上下文 == 运行新的程序
*/
// 在xv6中是由一个汇编语言程序写成的
// void swtch(struct context *old, struct context *new)
// 后面的是要切换去运行的进程,前面的是现在正在运行的进程

// Process is done running for now.
// It should have changed its p->state before coming back.
// 当进程执行完毕后,返回到调度器,将 c->proc 设置为 0
c->proc = 0;

found = 1;// 找到了可运行的进程
}
release(&p->lock);
}
if(found == 0) {
intr_on();
asm volatile("wfi");
//如果没有找到可运行的进程,再次启用中断。
//使用 wfi 指令(Wait For Interrupt)使CPU进入低功耗模式,等待中断
}
}
}

sleep()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// Atomically release lock and sleep on chan.
// Reacquires lock when awakened. 当被唤醒的时候需要锁
// 因为我们需要在更改当前进程的状态之前确保持有 p->lock,以避免竞争条件
void
sleep(void *chan, struct spinlock *lk)
{
struct proc *p = myproc();

// Must acquire p->lock in order to
// change p->state and then call sched.
// Once we hold p->lock, we can be
// guaranteed that we won't miss any wakeup
// (wakeup locks p->lock),
// so it's okay to release lk.
// 持有锁可以保证我们不错过任何wakeup,因为wakeup需要获取进程的锁
if(lk != &p->lock){ //DOC: sleeplock0
acquire(&p->lock); //DOC: sleeplock1
release(lk);
//如果传入的锁 lk 不是当前进程的锁 p->lock,则需要先获取当前进程的锁 p->lock
}

// Go to sleep.
p->chan = chan;
p->state = SLEEPING;

sched();// 调用 sched 函数,让出CPU,允许调度器选择其他进程运行

// Tidy up.
p->chan = 0;// 清理当前进程的等待通道 p->chan,将其设为 0

// Reacquire original lock.
if(lk != &p->lock){
release(&p->lock);
acquire(lk);
/*
如果传入的锁 lk 不是当前进程的锁 p->lock,则需要重新获取传入的锁 lk。
释放当前进程的锁 p->lock,然后重新获取传入的锁 lk,确保在返回时恢复原始的锁状态
例如,某个资源的锁可能由多个进程共享,而不仅仅是当前进程的锁

在某些情况下,进程可能需要等待某个资源的释放,而这个资源的锁是由其他进程管理的
通过传入这个资源的锁,可以在等待期间释放该锁,允许其他进程访问和操作该资源
*/
}
}

sched()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 切换到调度器。必须只持有 p->lock 这是为了确保在切换上下文的时候当前进程的状态不会被其他线程修改
// 并且已经改变了 proc->state。保存和恢复 intena 是因为 intena 是这个
// 内核线程的属性,而不是这个 CPU 的属性。理想情况下应该是
// proc->intena 和 proc->noff,但这会在少数持有锁但没有进程的地方导致问题。
// intena是用于存储多层中断嵌套中最初始是否允许中断嵌套的信息
// noff用于中断嵌套的深度计数,表示中断被关闭且没有进行恢复的次数
/*
用于将当前进程的控制权交还给调度器。
sched 函数主要用于在进程需要暂停执行时,保存当前进程的上下文,并恢复调度器的上下文,
以便调度器可以选择另一个进程运行
*/
void
sched(void)
{
int intena;
struct proc *p = myproc();

if(!holding(&p->lock))// 确保当前进程持有自己的锁 p->lock
// 这是因为在调用 sched 之前,进程应该已经将自己的状态设置为 RUNNABLE 或 SLEEPING
// 并且持有自己的锁,以确保线程安全
panic("sched p->lock");
if(mycpu()->noff != 1)
/*
当noff为1时,表示当前中断已经被关闭了一次,并且没有其他层的中断关闭
在调用sched()时,中断状态是确定的,不会被其他中断处理程序干扰
如果noff大于1,可能意味着当前代码段已经在某个中断处理程序中,或者在某个临界区内
在这种情况下,调用sched()可能会导致嵌套的上下文切换,进一步增加系统的复杂性和不稳定性
*/
panic("sched locks");
if(p->state == RUNNING)
panic("sched running");
if(intr_get())// 确保当前没有中断发生
panic("sched interruptible");

intena = mycpu()->intena;// 保存当前CPU的中断使能状态
swtch(&p->context, &mycpu()->context);// 切换上下文
mycpu()->intena = intena;// 恢复保存的中断使能状态intena
}

关于为什么中断处理过程中不可以进行进程切换

forkret()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// A fork child's very first scheduling by scheduler()
// will swtch to forkret.
// 是在新创建的子进程第一次被调度器调度时调用
void
forkret(void)
{
static int first = 1;
// static关键字使得first变量在整个程序的生命周期中只初始化一次,并且在每次调用forkret时都保留其值。
// firs 变量用于标记是否是第一次调用 forkret

// Still holding p->lock from scheduler.
release(&myproc()->lock);// 释放当前进程的锁 p->lock,以便其他线程可以访问该进程的数据结构
// 在调用 forkret 时,当前进程仍然持有 p->lock(即当前进程的锁)
// 这是因为在调度器 scheduler 中调用 swtch 切换到新进程时,锁还没有释放

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;// 将 first 设为 0,表示已经初始化过文件系统
fsinit(ROOTDEV);// 调用 fsinit(ROOTDEV) 初始化文件系统
// 文件系统初始化需要在普通进程的上下文中进行,因为它可能调用 sleep 等函数
// 而这些函数不能在 main 函数中直接调用
}

usertrapret();
// 调用 usertrapret 函数,返回用户态
// usertrapret 是一个汇编函数,用于从内核态返回用户态,恢复用户的上下文并继续执行用户程序
}

接下来就是具体的任务了,源码的阅读到此为止