【Mit6.s081】课程记录

实验代码记录: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 列表:添加在之中说明用户区可以执行对应的可执行程序,例如在命令行执行对应命令。而其他无需生成可执行程序的系统调用可以不用添加在其中。

参考:

[1] MIT 6.S081 Lab Util 实验

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变量:

  • 文件作用域:声明在函数外部,便具有文件作用域,只在文件内可见,其他文件无法访问。这个和全局变量不同。
  • 生命周期内,保持值的变量:在程序运行整个期间保持其值,在作用域之外也不会销毁。
  • 初始化:只在程序启动时初始化一次。

   转载规则


《【Mit6.s081】课程记录》 行码棋 采用 知识共享署名 4.0 国际许可协议 进行许可。
 上一篇
折腾长久,终退役-致我的ACM生涯 折腾长久,终退役-致我的ACM生涯
先留坑,打完终幕场便填上,静候。 从2023-11-20建文件,打完最后一场济南就算正式退了,打完比赛还要立马交一个实训课的证明,还需搞一下,所以先起个草稿,打算写一篇正式的退役记来记录和纪念我这跌跌撞撞、无所成绩的ACM生涯。 对我的
2023-11-22 2024-02-20
下一篇 
代码中的技巧或习惯 代码中的技巧或习惯
1.需要一个数组或者字符串的目前的元素和前一个元素做相关运算时: 直接遍历这个序列,当 i 等于0时不满足条件,只有i 等于 1时才会执行if语句 for(int i = 0; i < len; ++i) { if( i &
2023-11-06 2024-02-20
  目录