实验代码记录:https://github.com/anda522/XV6-LAB/tree/main
课程教材翻译:https://xv6.dgs.zone/ ,对理解感觉很重要
xv6系统为 RISC-V
知识记录
- 编译操作
make qemu
:编译
./grade-lab-util sleep
: 测试是否通过(在本地用户目录测试)
- argc指的是什么
int main(int argc, char **argv)
在程序入口中,argc指的是命令行中提供的参数(包括程序本身的名称)
./program arg1 arg2 arg3
上述命令执行后:
argc = 4, argv = ["./program", "arg1", "arg2", "arg3"]
- 退出qemu:Ctrl-a + x
1 Unix utilities
Knowledge
操作系统结构:
-
用户空间程序:正在运行的程序,他们处于同一个空间
-
Kernel:Kernel程序只有一个,维护数据管理每一个用户空间进程,维护硬件资源
Kernel中存在各种服务:文件系统、进程管理系统、访问控制等等
应用程序访问Kernel通过 系统调用 来完成,Kernel具有对硬件操作的特殊权限。
管道 pipe
特性:
- 管道具有先进先出(FIFO)的特性。数据写入管道的一端,从管道的另一端读出时是按写入顺序读取的。
- 父进程在写入管道后,会通过 read 操作等待子进程的响应。该 read 操作会被阻塞,直到有数据可读。
Lab
/user
目录为用户区代码,所有用户区相关的函数都在此处。
/kernel
目录为内核区代码,所有内核区相关的操作都在此处,如系统调用实现,内存映射,中断处理等。
如要使用 sleep
命令时,需要将 sleep
程序添加进 Makefile
文件的 UPROGS
中,将其纳入编译范围,否则操作系统将不能识别你的命令。后续程序同理。
UPROGS
列表:添加在之中说明用户区可以执行对应的可执行程序,例如在命令行执行对应命令。而其他无需生成可执行程序的系统调用可以不用添加在其中。
参考:
2 System calls
Knowledge
系统调用
// 所有相关的系统调用
int fork(void);
int exit(int) __attribute__((noreturn));
int wait(int*);
int pipe(int*);
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);
int unlink(const char*);
int fstat(int fd, struct stat*);
int link(const char*, const char*);
int mkdir(const char*);
int chdir(const char*);
int dup(int);
int getpid(void);
char* sbrk(int);
int sleep(int);
int uptime(void);
部分系统调用解释:
exit(int)
:导致进程停止执行并释放资源。exit
接受一个整数状态参数,通常0表示成功,1表示失败。
fork(void)
:拷贝当前进程的内存,并创建一个新的进程,这里的内存包含了进程的指令和数据。
之后就拥有了一个完全一样内存的进程,在原始进程中 fork()
系统调用返回大于 0
的数,这个数为子类的 PID
, 在子进程中,返回的数为 0
。
exec(char*, char**)
:以新的进程代替原来的进程,该系统调用不会返回,因为exec会完全替换当前进程的内存,相当于当前进程不复存在了。并没有创建新的进程,还是原来进程的 PID
。
exec
有两个参数:可执行文件的文件名和字符串参数数组。
wait(int*)
:等待之前创建的子进程退出。如果当前进程有任何子进程,并且其中一个已经退出了,那么wait会返回。
wait
系统调用返回当前进程的已退出(或已杀死)子进程的PID,并将子进程的退出状态复制到传递给wait
的地址;如果调用方的子进程都没有退出,那么wait等待一个子进程退出。如果调用者没有子级,wait
立即返回-1。如果父进程不关心子进程的退出状态,它可以传递一个0地址给wait
。
write(fd, buf, n)
:将 buf
中的 n
字节写入文件描述符,并返回写入的字节数。只有发生错误时才会写入小于 n
字节的数据。
read(fd, buf, n)
:从文件描述符读取最多 n
字节,将它们复制到 buf
,并返回读取的字节数。
文件描述符是一个小整数(small integer),表示进程可以读取或写入的由内核管理的对象,进程从文件描述符0读取(标准输入),将输出写入文件描述符1(标准输出),并将错误消息写入文件描述符2(标准错误)
系统调用执行过程
系统调用在内核区,执行时需要从用户态过渡到核心态,CPU提供一个特殊的指令,将CPU从用户模式切换到管理模式,并在内核指定的入口点进入内核(RISC-V为此提供ecall
指令)。
一个进程可以通过执行RISC-V的ecall
指令进行系统调用,该指令提升硬件特权级别,并将程序计数器(PC)更改为内核定义的入口点,入口点的代码切换到内核栈,执行实现系统调用的内核指令,当系统调用完成时,内核切换回用户栈,并通过调用sret
指令返回用户空间,该指令降低了硬件特权级别,并在系统调用指令刚结束时恢复执行用户指令。
Lab
3 Page tables
Knowledge
Xv6为每个进程维护一个单独的页表,定义了该进程的地址空间。一个进程的虚拟地址空间分布如下。首先是指令,然后是全局变量,然后是栈区,最后是一个堆区域。有许多因素限制了进程地址空间的最大范围: RISC-V上的指针有64位宽;硬件在页表中查找虚拟地址时只使用低39位;xv6只使用这39位中的38位。因此,最大地址是 2^38-1=0x3fffffffff
,即MAXVA
(定义在kernel/riscv.h:348)在地址空间的顶部,xv6为trampoline
(用于在用户和内核之间切换)和映射进程切换到内核的trapframe
分别保留了一个页面。
Lab
argaddr()
和 argint()
函数都是从用户栈空间中提取参数。当用户调用系统调用时,需要将参数传给内核。参数通常会放到用户的栈中。
walk
函数在操作系统中通常用于页表的遍历和访问。在虚拟内存管理中,页表是一种数据结构,用于将虚拟地址映射到物理地址。walk
函数的主要作用是在给定的页表中查找特定虚拟地址对应的页表项。
copyout
函数通常用于从内核空间将数据复制到用户空间。
PTE_A
的默认值是第六位
参考:
[1] MIT 6.S081 2021: Lab page tables
[2] MIT 6.S081 Lab Pgtbl 实验 (非常详细)
4 Traps
Knowledge
Trap机制:用户空间和内核空间的切换,目的是实现操作系统的安全和隔离
用户空间和内核空间的切换发生的场景:
- 程序执行系统调用
- 程序出现页面错误、除以零等错误
- 触发中断导致程序需要响应内核设备驱动
Lab
proc
结构体包含了进程中的所有信息,可以通过 myproc()
获取当前运行的进程的结构体。
5 Copy-on-Write
Knowledge
Lab
- 我们很有可能需要标记当前的 PTE 为 COW 内存页映射,因此我们可以使用 RSW(reserved for software)标识位来实现:
-
如果 COW 下的缺页中断产生了,但是也没有足够的物理内存空间,那么进程应当被杀死。
-
设置引用计数,将物理内容RAM划分成
(PHYSTOP - KERNBASE) / PGSIZE
个页面,对每个页面设置一个引用计数(代表页面被引用的次数,当引用计数变为0时,就要释放对应的空间)。
6 Multithreading
Knowledge
Xv6有两种类型的锁:自旋锁(spinlocks)和睡眠锁(sleep-locks)。
自旋锁是一种锁机制,当一个线程(或CPU核心)尝试获取一个已经被其他线程持有的锁时,它不会立即阻塞,而是在当前位置“自旋”(即忙碌等待),直到锁被释放。
自旋锁的一个缺点是,一个持有自旋锁的进程 不能让出(yield)CPU, 然而我们希望持有锁的进程等待磁盘I/O的时候其他进程可以使用CPU。
Xv6以睡眠锁(sleep-locks)的形式提供了这种锁。acquiresleep
(kernel/sleeplock.c:22) 在等待时让步CPU, 让线程进入睡眠状态,而不是让线程在CPU上空转。
Lab
线程切换实现
- 切换线程时需要先保存现有线程的上下文信息(函数调用后返回的地址
ra
是下一条指令的地址 ,栈顶指针sp
,各个参数)以及调用栈。
线程结构体中需要声明一个上下文,以满足线程切换需要。
切换前,保存寄存器信息至上下文结构体中;新切换的线程需要将上下文信息加载到寄存器中。
a0
通常用于传递第一个参数
在 thread_switch
汇编代码中,该函数有两个参数
第一个参数:(uint64)&t->context
,为上下文地址,存于 a0
寄存器中
第一个参数:(uint64)&next_thread->context
,为上下文地址,存于 a1
寄存器中
汇编最后的 ret
指的是返回到 ra
对应的地址
exit(0)
一般代表正常退出,exit(-1)
一般代表异常退出
C语言多线程
此时使用C语言的pthread来实现多线程。
#include <pthread.h>
pthread_mutex_t lock; // declare a lock
pthread_mutex_init(&lock, NULL); // initialize the lock
pthread_mutex_lock(&lock); // acquire lock
pthread_mutex_unlock(&lock); // release lock
pthread_create() // 创建一个线程
pthread_join() // 等待线程执行完成
多线程哈希表实现安全存取:此例子先进行多线程存储,存储完毕后再进行取值操作。
加锁方式:直接对存储操作加锁,哈希冲突时用的是拉链法,存储时是使用的头插法,当有相同的key同时进行存储操作时,next的更新可能就只有最后一个生效,从而漏掉一个值。所以对每个桶加锁,put操作时获取到对应的哈希值后,直接对该哈希值对应的桶加锁。
同步屏障
实现功能:所有参与线程都要到达同一个位置。
// 使当前线程等待在条件变量cond中,并释放mutex锁
pthread_cond_wait(&cond, &mutex); // go to sleep on cond, releasing lock mutex, acquiring upon wake up
// 唤醒所有在条件变量cond中休眠的线程
pthread_cond_broadcast(&cond); // wake up every thread sleeping on cond
// 唤醒在条件变量中等待的第一个线程
pthread_cond_signal
条件变量是一种同步机制,允许线程在某些条件成立之前挂起执行,常用于线程之间的协调和通信。
条件重检:即使线程被唤醒,它们通常也需要重新检查条件变量所保护的条件是否仍然有效。这是因为在它们获得锁并检查条件之前,条件可能已经变得不满足。
实验内容:多个线程,每个线程都有一个循环,希望让所有线程都执行完第 i
轮循环后再执行下一轮循环。
pthread_cond_wait(&cond, &mutex)
语句解析
该语句执行后,释放锁,然后挂起;当有其他进程唤醒条件变量时,会重新获取锁,然后从该条语句下一句开始执行。
static void
barrier()
{
pthread_mutex_lock(&bstate.barrier_mutex);
if (++bstate.nthread < nthread) {
pthread_cond_wait(&bstate.barrier_cond, &bstate.barrier_mutex);
} else {
bstate.nthread = 0;
bstate.round++;
pthread_cond_broadcast(&bstate.barrier_cond);
}
pthread_mutex_unlock(&bstate.barrier_mutex);
}
可以进一步去看看C++线程池实现:https://github.com/anda522/ThreadPool
C++11多线程的用法:https://wyqz.top/p/2668140628.html
7 Net(Hard)
Knowledge
Lab
任务说明:E1000网络设备位于xv6中,xv6 IP为10.0.2.2,xv6通过E1000向局域网(运行xv6的计算机,IP为10.0.2.2)发送数据包。任务需要完成e1000的发送数据和接受数据的函数,也就是驱动,最终能够发送和接受数据。
volatile变量:当一个变量被声明为volatile
时,编译器会假设这个变量的值可能在任何时候被改变,因此每次使用这个变量时,编译器都会从内存中重新读取它的值,而不是使用寄存器中的值或进行优化。这确保了代码的执行不会由于编译器的优化而忽略变量的潜在变化。(比如中断程序可能会改变这个值)
static变量:
- 文件作用域:声明在函数外部,便具有文件作用域,只在文件内可见,其他文件无法访问。这个和全局变量不同。
- 生命周期内,保持值的变量:在程序运行整个期间保持其值,在作用域之外也不会销毁。
- 初始化:只在程序启动时初始化一次。