From 4357e2510ca4e9607285008692fa48d72c64e7b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CLu?= Date: Thu, 16 Feb 2023 11:53:47 +0800 Subject: [PATCH 1/7] Update ch8 answer --- source/chapter8/7answer.rst | 274 ++++++++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) diff --git a/source/chapter8/7answer.rst b/source/chapter8/7answer.rst index 02b33e65..4bb2b7b2 100644 --- a/source/chapter8/7answer.rst +++ b/source/chapter8/7answer.rst @@ -11,7 +11,281 @@ 编程题 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +在Linux环境中,基于pthread线程,有一系列的系统调用实现对应用程序的线程间同步互斥的支持。 +信号量是一种特殊的变量,可用于线程同步。它只取自然数值,并且只支持两种操作:P(SV): 如果信号量SV大于0,将它减一;如果SV值为0,则挂起该线程。V(SV): 如果有其他进程因为等待SV而挂起,则唤醒,然后将SV+1;否则直接将SV+1。其系统调用为: + +- `sem_wait(sem_t *sem)`:以原子操作的方式将信号量减1,如果信号量值为0,则sem_wait将被阻塞,直到这个信号量具有非0值。 +- `sem_post(sem_t *sem)`:以原子操作将信号量值+1。当信号量大于0时,其他正在调用sem_wait等待信号量的线程将被唤醒。 + +互斥量:互斥量又称互斥锁,主要用于线程互斥,不能保证按序访问,可以和条件锁一起实现同步。当进入临界区 时,需要获得互斥锁并且加锁;当离开临界区时,需要对互斥锁解锁,以唤醒其他等待该互斥锁的线程。其主要的系统调用如下: + +- `pthread_mutex_init`: 初始化互斥锁 +- `pthread_mutex_destroy`: 销毁互斥锁 +- pthread_mutex_lock: 以原子操作的方式给一个互斥锁加锁,如果目标互斥锁已经被上锁,pthread_mutex_lock调用将阻塞,直到该互斥锁的占有者将其解锁。 +- `pthread_mutex_unlock`: 以一个原子操作的方式给一个互斥锁解锁。 + + +条件变量:条件变量,又称条件锁,用于在线程之间同步共享数据的值。条件变量提供一种线程间通信机制:当某个共享数据达到某个值时,唤醒等待这个共享数据的一个/多个线程。即,当某个共享变量等于某个值时,调用 signal/broadcast。此时操作共享变量时需要加锁。其主要的系统调用如下: + +- `pthread_cond_init`: 初始化条件变量 +- `pthread_cond_destroy`: 销毁条件变量 +- `pthread_cond_signal`: 唤醒一个等待目标条件变量的线程。哪个线程被唤醒取决于调度策略和优先级。 +- `pthread_cond_wait`: 等待目标条件变量。需要一个加锁的互斥锁确保操作的原子性。该函数中在进入wait状态前首先进行解锁,然后接收到信号后会再加锁,保证该线程对共享资源正确访问。 + +1. `**` 在Linux环境下,请用信号量实现哲学家就餐的多线程应用程序。 +2. `**` 在Linux环境下,请用互斥锁和条件变量实现哲学家就餐的多线程应用程序。 +3. `**` 在Linux环境下,请建立一个多线程的模拟资源分配管理库,可通过银行家算法来避免死锁。 +4. `**` 扩展内核功能,实现读者优先的读写信号量。 +5. `**` 扩展内核功能,实现写者优先的读写信号量。 +6. `***` 扩展内核功能,在内核中支持内核线程。 +7. `***` 进一步扩展内核功能,在内核线程中支持同步互斥机制,实现内核线程用的mutex, semaphore, cond-var。 +8. `***` 扩展内核功能,实现多核支持下的同步互斥机制。 +9. `***` 解决优先级反转问题:实现RM实时调度算法,设计优先级反转的实例,实现优先级天花板和优先级继承方法。 问答题 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + +1. `*` 什么是并行?什么是并发? + +* "并行" 指的是同时进行多个任务。在多 CPU 环境中,计算机具有多个独立的 CPU,可以同时执行多个任务。例如,如果你有两个 CPU,那么它们可以同时运行两个不同的程序,这样它们就是并行的。 +* "并发" 指的是多个任务的同时发生,但它们不一定是同时执行的。在单 CPU 环境中,并发和并行是通过 CPU 快速地在多个任务之间切换来模拟同时发生的效果。例如,如果你在同时运行多个程序,那么 CPU 可以快速地在这些程序之间切换,从而模拟它们同时发生的效果。这种情况下,这些程序是并发的,但不是并行的。 + +2. `*` 为了创造临界区,单核处理器上可以【关中断】,多核处理器上需要使用【自旋锁】。请回答下列问题: + + - 多核上可不可以只用【关中断】? + * 在多核处理器上仅使用关中断(disable interrupt)来实现临界区是不可行的,因为关中断只能保证当前核上的代码不会被中断,但不能保证其他核上的代码不会进入临界区,对共享数据进行修改。 + - 单核上可不可以只用【自旋锁】? + * 单核处理器上可以使用自旋锁来实现临界区,但是并不是必须使用自旋锁,如上述的关中断。 + - 多核上的【自旋锁】是否需要同时【关中断】? + * 对于多核处理器上的自旋锁,通常不需要关中断来创建临界区。相反,自旋锁的实现会使用处理器提供的硬件特性来确保原子性,例如原子操作、内存屏障等。这种方式能够避免全局中断,从而提高系统的性能。 + - [进阶] 假如某个锁不会在中断处理函数中被访问,是否还需要【关中断】? + * 在单核处理器上,如果所有的代码都是在同一个上下文中运行,也就是没有中断或者线程切换的情况下,如果在代码中使用锁来保护共享资源,那么可以使用简单的互斥锁来实现临界区的保护,而不需要关中断。 + * 在多核处理器上,不同的核心可以独立运行不同的线程,彼此之间不会互相干扰。在这种情况下,可以使用自旋锁等更高效的同步机制来实现临界区的保护。如果代码中使用的锁需要在中断处理函数中被访问,那么在多核处理器上需要关中断来保护临界区。在中断处理函数中,由于上下文的切换,可能会发生竞争条件,因此需要通过关中断的方式来避免这种竞争。这样可以保证在中断处理函数执行期间,不会有其他线程在访问共享资源,从而保证临界区的安全性。 + +3. `**` Linux的多线程应用程序使用的锁(例如 pthread_mutex_t)不是自旋锁,当上锁失败时会切换到其它进程执行。分析它和自旋锁的优劣,并说明为什么它不用自旋锁? + +互斥锁和自旋锁的优劣:互斥锁和自旋锁的本质区别在于加锁失败时,是否会释放CPU。互斥锁在加锁失败时,会释放CPU,因此与自旋锁相比它的主要优势在于可以提高处理器的资源利用率,避免CPU空转的现象,但与之带来的是互斥锁的开销更大。这些开销主要包括两次线程上下文切换的成本: + * 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行; + * 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。 + + +不使用自旋锁的原因是: + * 可移植性:pthread_mutex_t是POSIX标准中定义的一种互斥锁,不仅可以在Linux系统上使用,还可以在其他的POSIX兼容系统上使用,提高了应用程序的可移植性。 + * 性能:自旋锁在多核处理器上可以提高并发性能,但是在单核处理器上可能会降低性能,因为自旋锁需要不断地检查锁的状态,如果锁一直处于被占用的状态,就会一直占用处理器时间。而pthread_mutex_t是一种阻塞锁,在锁被占用时,会将线程挂起,让出处理器时间,从而避免了空转浪费处理器资源的情况。 + * 死锁:使用自旋锁需要非常小心,否则容易出现死锁的情况。例如,当一个线程持有一个自旋锁并等待另一个自旋锁时,如果另一个线程持有了这个自旋锁并等待第一个自旋锁,就会出现死锁。而pthread_mutex_t是一种阻塞锁,在锁的等待队列中维护了线程的等待关系,可以避免死锁的情况。 + +4. `***` 程序在运行时具有两种性质:safety: something bad will never happen;liveness: something good will eventually occur. 分析并证明 Peterson 算法的 safety 和 liveness 性质。 + +下面是这两个性质的证明: + +* Safety性质: + 假设同时有两个线程$P_0$和$P_1$,它们都试图进入其临界区,即执行关键代码段。如果两个线程同时进入关键代码段,就会发生竞态条件,可能导致不正确的结果。因此,我们希望确保只有一个线程能够进入其临界区。 + + Peterson算法确保了只有一个线程可以进入其临界区。这是因为,在进入临界区之前,线程必须首先尝试获取锁。如果另一个线程已经获得了锁,则当前线程将被阻塞,直到另一个线程释放锁。因此,只有一个线程可以进入其临界区,这证明了Peterson算法的safety性质。 + +* Liveness性质: + 我们需要证明,如果一个线程尝试进入其临界区,则它最终将能够进入。假设线程$P_0$和$P_1$都试图进入其临界区。如果线程$P_0$先尝试进入其临界区,则线程$P_1$会被阻塞,直到线程$P_0$退出其临界区并释放锁。反之亦然。 + + 假设线程$P_0$试图进入其临界区,但是线程$P_1$已经占用了锁并且正在执行其临界区。线程$P_0$将被阻塞,并等待线程$P_1$释放锁。线程$P_1$将在其临界区内执行,并最终退出其临界区并释放锁。此时,线程$P_0$将获得锁,并能够进入其临界区。同样,如果线程$P_1$试图进入其临界区,那么也将发生类似的过程。 + +因此,Peterson算法保证了线程能够最终进入其临界区,这证明了Peterson算法的liveness性质。 + +1. `*` 信号量结构中的整数分别为+n、0、-n 的时候,各自代表什么状态或含义? + +* +n:还有 n 个可用资源 +* 0:所有可用资源恰好耗尽 +* -n:有n个进程申请了资源但无资源可用,被阻塞。 + +6. `**` 考虑如下信号量实现代码: + +.. code-block:: rust + + class Semaphore { + int sem; + WaitQueue q; + } + Semaphore::P() { + sem --; + if(sem < 0) { + Add this thread to q. + block. + } + } + Semaphore::V() { + sem ++; + if(sem <= 0) { + t = Remove a thread from q; + wakeup(t); + } + } + +假如 P操作或V操作不是原子操作,会出现什么问题?举一个例子说明。上述代码能否运行在用户态?上面代码的原子性是如何保证的? + + 如果P操作或V操作不是原子操作,将无法实现资源的互斥访问。P操作和V操作都是通过关中断来实现的(可以再确认一下这点)。上述代码不能运行在用户态,因为这将带给用户态程序使能/屏蔽中断这种特权,相当于相信应用并放权给它。这会面临和我们引入抢占式调度之前一样的问题:线程可以选择恶意永久关闭中断而独占所有 CPU 资源,这将会影响到整个系统的正常运行。因此,事实上至少在 RISC-V 这样含多个特权级的架构中,这甚至是完全做不到的。 + +7. `**` 条件变量的 Wait 操作为什么必须关联一个锁? + +当调用条件变量的 wait 操作阻塞当前线程的时候,该操作是在管程过程中,因此此时当前线程持有锁。在持有锁的情况下不能陷入阻塞 ,因此在陷入阻塞状态之前当前线程必须先释放锁;当被阻塞的线程被其他线程使用 signal 操作唤醒之后,需要重新获取到锁才能继续执行,不然的话就无法保证管程过程的互斥访问。 + +因此,站在线程的视角,必须持有锁才能调用条件变量的 wait 操作阻塞自身。 + +8. `**` 下面是条件变量的wait操作实现伪代码: + +.. code-block:: rust + + Condvar::wait(lock) { + Add this thread to q. + lock.unlock(); + schedule(); + lock.lock(); + } + +如果改成下面这样: + +.. code-block:: rust + + Condvar::wait() { + Add this thread to q. + schedule(); + } + lock.unlock(); + condvar.wait(); + lock.lock(); + +会出现什么问题?举一个例子说明。 + + 这种情况就是第7题提到的条件变量的wait操作没有关联一个锁。会造成被阻塞的线程被其他线程使用 signal 操作唤醒之后,无法获取锁,从而无法保证管程过程的互斥访问,导致管程失效。 + +9. `*` 死锁的必要条件是什么? + +死锁的四个必要条件: + +* 互斥条件:一个资源每次只能被一个进程使用。 +* 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。 +* 不剥夺条件:进程已获得的资源,在末使用完之前,不能被其他进程强行剥夺。 +* 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。 + +这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。 + +10. `*` 什么是死锁预防,举例并分析。 + +预防死锁只需要破坏死锁的四个必要条件之一即可,例如: + +* 破坏互斥条件 +* 破坏不可剥夺条件: 当进程的新资源不可取得时,释放自己已有的资源,待以后需要时重新申请。 +* 破坏请求并保持条件:进程在运行前一次申请完它所需要的全部资源,在它的资源为满足前,不把它投入运行。一旦投入运行,这些资源都归它所有,不能被剥夺。 +* 破坏循环等待条件:给锁/访问的资源进行排序,要求每个线程都按照排好的顺序依次申请锁和访问资源 + + +11. `**` 描述银行家算法如何判断安全性。 + +* 设置两个向量:工作向量Work,表示操作系统可提供给线程继续运行所需的各类资源数目,它含有m个元素,初始时,Work = Available;结束向量Finish,表示系统是否有足够的资源分配给线程,使之运行完成。初始时 Finish[0..n-1] = false,表示所有线程都没结束;当有足够资源分配给线程时,设置Finish[i] = true。 +* 从线程集合中找到一个能满足下述条件的线程 + +.. code-block:: c + + Finish[i] == false; + Need[i,j] <= Work[j]; + +若找到,执行步骤3,否则,执行步骤4。 + +* 当线程thr[i]获得资源后,可顺利执行,直至完成,并释放出分配给它的资源,故应执行: + +.. code-block:: c + + Work[j] = Work[j] + Allocation[i,j]; + Finish[i] = true; + +跳转回步骤2 + +* 如果Finish[0..n-1] 都为true,则表示系统处于安全状态;否则表示系统处于不安全状态。 + +通过操作系统调度,如银行家算法来避免死锁不是广泛使用的通用方案。因为从线程执行的一般情况上看,银行家算法需要提前获知线程总的资源申请量,以及未来的每一次请求,而这些请求对于一般线程而言在运行前是不可知或随机的。另外,即使在某些特殊情况下,可以提前知道线程的资源申请量等信息,多重循环的银行家算法开销也是很大的,不适合于对性能要求很高的操作系统中。 + +实验练习 +------------------------------- + +实验练习包括实践作业和问答作业两部分。 + + +编程作业 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +银行家算法——分数更新 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. note:: + + 本实验为用户态实验,请在 Linux 环境下完成。 + +背景:在智能体大赛平台 `Saiblo `_ 网站上每打完一场双人天梯比赛后需要用 ELO 算法更新双方比分。由于 Saiblo 的评测机并发性很高,且 ELO 算法中的分值变动与双方变动前的分数有关,因此更新比分前时必须先为两位选手加锁。 + +作业:请模拟一下上述分数更新过程,简便起见我们简化为有 p 位选手参赛(编号 [0, p) 或 [1, p] ),初始分值为 1000 分,有 m 个评测机线程(生产者)给出随机的评测结果(两位不同选手的编号以及胜负结果,结果可能为平局),有 n 个 worker 线程(消费者)获取结果队列并更新数据库(全局变量等共享数据)记录的分数。m 个评测机各自模拟 k 场对局结果后结束线程,全部对局比分更新完成后主线程打印每位选手最终成绩以及所有选手分数之和。 + +上述参数 p、m、n、k 均为可配置参数(命令行传参或程序启动时从stdin输入)。 + +简便起见不使用 ELO 算法,简化更新规则为:若不为平局,当 胜者分数 >= 败者分数 时胜者 +20,败者 -20,否则胜者 +30,败者 -30;若为平局,分高者 -10,分低者+10(若本就同分保持则不变)。 + +消费者核心部分可参考如下伪码: + 获取选手A的锁 + 获取选手B的锁 + 更新A、B分数 + 睡眠 1ms(模拟数据库更新延时) + 释放选手B的锁 + 释放选手A的锁 + +tips: + - 由于 ELO 以及本题中给出的简化更新算法均为零和算法,因此出现冲突后可以从所有选手分数之和明显看出来,正确处理时它应该永远为 1000p + - 将一个 worker 线程看作哲学家,将 worker 正在处理的一场对局的两位选手看作两根筷子,则得到了经典的哲学家就餐问题 + +实现 eventfd +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +在 Linux 中有一种用于事件通知的文件描述符,称为 eventfd 。其核心是一个 64 位无符号整数的计数器,在非信号量模式下,若计数器值不为零,则 `read` 函数会从中读出计数值并将其清零,否则读取失败; `write` 函数将缓冲区中的数值加入到计数器中。在信号量模式下,若计数器值非零,则 `read` 操作将计数值减一,并返回 1 ; `write` 将计数值加一。我们将实现一个新的系统调用: `sys_eventfd2` 。 + +**eventfd**: + + * syscall ID: 290 + * 功能:创建一个 eventfd, `eventfd 标准接口 `_ 。 + * C 接口: ``int eventfd(unsigned int initval, int flags)`` + * Rust 接口: ``fn eventfd(initval: u32, flags: i32) -> i32`` + * 参数: + * initval: 计数器的初值。 + * flags: 可以设置为 0 或以下两个 flag 的任意组合(按位或): + * EFD_SEMAPHORE (1) :设置该 flag 时,将以信号量模式创建 eventfd 。 + * EFD_NONBLOCK (2048) :若设置该 flag ,对 eventfd 读写失败时会返回 -2 ,否则将阻塞等待直至读或写操作可执行为止。 + * 说明: + * 通过 `write` 写入 eventfd 时,缓冲区大小必须为 8 字节。 + * 进程 `fork` 时,子进程会继承父进程创建的 eventfd ,且指向同一个计数器。 + * 返回值:如果出现了错误则返回 -1,否则返回创建成功的 eventfd 编号。 + * 可能的错误 + * flag 不合法。 + * 创建的文件描述符数量超过进程限制 + +.. note:: + 还有一个 `sys_eventfd` 系统调用(调用号 284),与 `sys_eventfd2` 的区别在于前者不支持传入 flags 。 + + Linux 中的原生异步 IO 接口 libaio 就使用了 eventfd 作为内核完成 IO 操作之后通知应用程序的机制。 + + + +实验要求 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- 完成分支: ch8-lab +- 实验目录要求不变。 +- 通过所有测例。 + +问答作业 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +无 + +实验练习的提交报告要求 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* 简单总结本次实验与上个实验相比你增加的东西。(控制在5行以内,不要贴代码) +* 完成问答问题 +* (optional) 你对本次实验设计及难度的看法。 From ecb13323cddade82d4aa1923744c5fbb1129906c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E5=87=AF=E5=A4=AB?= Date: Fri, 17 Feb 2023 21:36:13 +0800 Subject: [PATCH 2/7] Update ch1 answer --- source/chapter1/7exercise.rst | 16 ++++----- source/chapter1/8answer.rst | 62 ++++++++++++++++++++++++++++++----- 2 files changed, 60 insertions(+), 18 deletions(-) diff --git a/source/chapter1/7exercise.rst b/source/chapter1/7exercise.rst index 4f09dc3c..146df1f0 100644 --- a/source/chapter1/7exercise.rst +++ b/source/chapter1/7exercise.rst @@ -23,15 +23,13 @@ 1. `*` 应用程序在执行过程中,会占用哪些计算机资源? 2. `*` 请用相关工具软件分析并给出应用程序A的代码段/数据段/堆/栈的地址空间范围。 -3. `*` 请用分析并给出应用程序C的代码段/数据段/堆/栈的地址空间范围。 -4. `*` 请结合编译器的知识和编写的应用程序B,说明应用程序B是如何建立调用栈链信息的。 -5. `*` 请简要说明应用程序与操作系统的异同之处。 -6. `**` 请基于QEMU模拟RISC—V的执行过程和QEMU源代码,说明RISC-V硬件加电后的几条指令在哪里?完成了哪些功能? -7. `*` RISC-V中的SBI的含义和功能是啥? -8. `**` 为了让应用程序能在计算机上执行,操作系统与编译器之间需要达成哪些协议? -9. `**` 请简要说明从QEMU模拟的RISC-V计算机加电开始运行到执行应用程序的第一条指令这个阶段的执行过程。 -10. `**` 为何应用程序员编写应用时不需要建立栈空间和指定地址空间? -11. `***` 现代的很多编译器生成的代码,默认情况下不再严格保存/恢复栈帧指针。在这个情况下,我们只要编译器提供足够的信息,也可以完成对调用栈的恢复。 +3. `*` 请简要说明应用程序与操作系统的异同之处。 +4. `**` 请基于QEMU模拟RISC—V的执行过程和QEMU源代码,说明RISC-V硬件加电后的几条指令在哪里?完成了哪些功能? +5. `*` RISC-V中的SBI的含义和功能是啥? +6. `**` 为了让应用程序能在计算机上执行,操作系统与编译器之间需要达成哪些协议? +7. `**` 请简要说明从QEMU模拟的RISC-V计算机加电开始运行到执行应用程序的第一条指令这个阶段的执行过程。 +8. `**` 为何应用程序员编写应用时不需要建立栈空间和指定地址空间? +9. `***` 现代的很多编译器生成的代码,默认情况下不再严格保存/恢复栈帧指针。在这个情况下,我们只要编译器提供足够的信息,也可以完成对调用栈的恢复。 我们可以手动阅读汇编代码和栈上的数据,体验一下这个过程。例如,对如下两个互相递归调用的函数: diff --git a/source/chapter1/8answer.rst b/source/chapter1/8answer.rst index 82394f59..eaf75925 100644 --- a/source/chapter1/8answer.rst +++ b/source/chapter1/8answer.rst @@ -132,11 +132,43 @@ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 1. `*` 应用程序在执行过程中,会占用哪些计算机资源? + + 占用 CPU 计算资源(CPU 流水线,缓存等),内存(内存不够还会占用外存)等 + 2. `*` 请用相关工具软件分析并给出应用程序A的代码段/数据段/堆/栈的地址空间范围。 -3. `*` 请用分析并给出应用程序C的代码段/数据段/堆/栈的地址空间范围。 -4. `*` 请结合编译器的知识和编写的应用程序B,说明应用程序B是如何建立调用栈链信息的。 -5. `*` 请简要说明应用程序与操作系统的异同之处。 -6. `**` 请基于QEMU模拟RISC—V的执行过程和QEMU源代码,说明RISC-V硬件加电后的几条指令在哪里?完成了哪些功能? + + 简便起见,我们静态编译该程序生成可执行文件。使用 ``readelf`` 工具查看地址空间: + + .. + Section Headers: + [Nr] Name Type Address Offset + Size EntSize Flags Link Info Align + ... + [ 7] .text PROGBITS 00000000004011c0 000011c0 + 0000000000095018 0000000000000000 AX 0 0 64 + ... + [10] .rodata PROGBITS 0000000000498000 00098000 + 000000000001cadc 0000000000000000 A 0 0 32 + ... + [21] .data PROGBITS 00000000004c50e0 000c40e0 + 00000000000019e8 0000000000000000 WA 0 0 32 + ... + [25] .bss NOBITS 00000000004c72a0 000c6290 + 0000000000005980 0000000000000000 WA 0 0 32 + + 数据段(.data)和代码段(.text)的起止地址可以从输出信息中看出。 + + 应用程序的堆栈是由内核为其动态分配的,需要在运行时查看。将 A 程序置于后台执行,通过查看 ``/proc/[pid]/maps`` 得到堆栈空间的分布: + + .. + 01bc9000-01beb000 rw-p 00000000 00:00 0 [heap] + 7ffff8e60000-7ffff8e82000 rw-p 00000000 00:00 0 [stack] + +3. `*` 请简要说明应用程序与操作系统的异同之处。 + + 这个问题相信大家完成了实验的学习后一定会有更深的理解。 + +4. `**` 请基于QEMU模拟RISC—V的执行过程和QEMU源代码,说明RISC-V硬件加电后的几条指令在哪里?完成了哪些功能? 在 QEMU 源码 [#qemu_bootrom]_ 中可以找到“上电”的时候刚执行的几条指令,如下: @@ -167,11 +199,23 @@ - (我们还没有用到:将 FDT (Flatten device tree) 在物理内存中的地址写入 ``a1``) - 跳转到 ``start_addr`` ,在我们实验中是 RustSBI 的地址 -7. `*` RISC-V中的SBI的含义和功能是啥? -8. `**` 为了让应用程序能在计算机上执行,操作系统与编译器之间需要达成哪些协议? -9. `**` 请简要说明从QEMU模拟的RISC-V计算机加电开始运行到执行应用程序的第一条指令这个阶段的执行过程。 -10. `**` 为何应用程序员编写应用时不需要建立栈空间和指定地址空间? -11. `***` 现代的很多编译器生成的代码,默认情况下不再严格保存/恢复栈帧指针。在这个情况下,我们只要编译器提供足够的信息,也可以完成对调用栈的恢复。(题目剩余部分省略) +5. `*` RISC-V中的SBI的含义和功能是啥? + + 详情见 `SBI 官方文档 `_ + +6. `**` 为了让应用程序能在计算机上执行,操作系统与编译器之间需要达成哪些协议? + + 编译器依赖操作系统提供的程序库,操作系统执行应用程序需要编译器提供段位置、符号表、依赖库等信息。 `ELF `_ 就是比较常见的一种文件格式。 + +7. `**` 请简要说明从QEMU模拟的RISC-V计算机加电开始运行到执行应用程序的第一条指令这个阶段的执行过程。 + + 接第 5 题,跳转到 RustSBI 后,SBI 会对部分硬件例如串口等进行初始化,然后通过 mret 跳转到 payload 也就是 kernel 所在的起始地址。kernel 进行一系列的初始化后(内存管理,虚存管理,线程(进程)初始化等),通过 sret 跳转到应用程序的第一条指令开始执行。 + +8. `**` 为何应用程序员编写应用时不需要建立栈空间和指定地址空间? + + 应用程度对内存的访问需要通过 MMU 的地址翻译完成,应用程序运行时看到的地址和实际位于内存中的地址是不同的,栈空间和地址空间需要内核进行管理和分配。应用程序的栈指针在 trap return 过程中初始化。此外,应用程序可能需要动态加载某些库的内容,也需要内核完成映射。 + +9. `***` 现代的很多编译器生成的代码,默认情况下不再严格保存/恢复栈帧指针。在这个情况下,我们只要编译器提供足够的信息,也可以完成对调用栈的恢复。(题目剩余部分省略) * 首先,我们当前的 ``pc`` 在 ``flip`` 函数的开头,这是我们正在运行的函数。返回给调用者处的地址在 ``ra`` 寄存器里,是 ``0x10742`` 。因为我们还没有开始操作栈指针,所以调用处的 ``sp`` 与我们相同,都是 ``0x40007f1310`` 。 * ``0x10742`` 在 ``flap`` 函数内。根据 ``flap`` 函数的开头可知,这个函数的栈帧大小是 16 个字节,所以调用者处的栈指针应该是 ``sp + 16 = 0x40007f1320``。调用 ``flap`` 的调用者返回地址保存在栈上 ``8(sp)`` ,可以读出来是 ``0x10750`` ,还在 ``flap`` 函数内。 From 8a889cd05394b6954a1b2ac7fc053c468a6b4380 Mon Sep 17 00:00:00 2001 From: haozixu Date: Sun, 19 Feb 2023 00:24:23 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=AC=AC=E5=85=AD?= =?UTF-8?q?=E7=AB=A0=E7=BC=96=E7=A8=8B=E5=AE=9E=E9=AA=8C=E7=9A=84=E5=8F=82?= =?UTF-8?q?=E8=80=83=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/chapter6/5answer.rst | 903 ++++++++++++++++++++++++++++++++++++ 1 file changed, 903 insertions(+) diff --git a/source/chapter6/5answer.rst b/source/chapter6/5answer.rst index dc4c6cce..d5d16cf5 100644 --- a/source/chapter6/5answer.rst +++ b/source/chapter6/5answer.rst @@ -8,6 +8,909 @@ 课后练习 ------------------------------- +编程题 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. `*` 扩展easy-fs文件系统功能,扩大单个文件的大小,支持三级间接inode。 + +在修改之前,先看看原始inode的结构: + +.. code:: rust + + /// The max number of direct inodes + const INODE_DIRECT_COUNT: usize = 28; + + #[repr(C)] + pub struct DiskInode { + pub size: u32, + pub direct: [u32; INODE_DIRECT_COUNT], + pub indirect1: u32, + pub indirect2: u32, + type_: DiskInodeType, + } + + #[derive(PartialEq)] + pub enum DiskInodeType { + File, + Directory, + } + +一个 ``DiskInode`` 在磁盘上占据128字节的空间。我们考虑加入 ``indirect3`` 字段并缩减 ``INODE_DIRECT_COUNT`` 为27以保持 ``DiskInode`` 的大小不变。此时直接索引可索引13.5KiB的内容,一级间接索引和二级间接索引仍然能索引64KiB和8MiB的内容,而三级间接索引能索引128 * 8MiB = 1GiB的内容。当文件大小大于13.5KiB + 64KiB + 8MiB时,需要用到三级间接索引。 + +下面的改动都集中在 ``easy-fs/src/layout.rs`` 中。首先修改 ``DiskInode`` 和相关的常量定义。 + +.. code-block:: rust + :emphasize-lines: 6 + + pub struct DiskInode { + pub size: u32, + pub direct: [u32; INODE_DIRECT_COUNT], + pub indirect1: u32, + pub indirect2: u32, + pub indirect3: u32, + type_: DiskInodeType, + } + +在计算给定文件大小对应的块总数时,需要新增对三级间接索引的处理。三级间接索引的存在使得二级间接索引所需的块数不再计入所有的剩余数据块。 + +.. code-block:: rust + :emphasize-lines: 14 + + pub fn total_blocks(size: u32) -> u32 { + let data_blocks = Self::_data_blocks(size) as usize; + let mut total = data_blocks as usize; + // indirect1 + if data_blocks > INODE_DIRECT_COUNT { + total += 1; + } + // indirect2 + if data_blocks > INDIRECT1_BOUND { + total += 1; + // sub indirect1 + let level2_extra = + (data_blocks - INDIRECT1_BOUND + INODE_INDIRECT1_COUNT - 1) / INODE_INDIRECT1_COUNT; + total += level2_extra.min(INODE_INDIRECT1_COUNT); + } + // indirect3 + if data_blocks > INDIRECT2_BOUND { + let remaining = data_blocks - INDIRECT2_BOUND; + let level2_extra = (remaining + INODE_INDIRECT2_COUNT - 1) / INODE_INDIRECT2_COUNT; + let level3_extra = (remaining + INODE_INDIRECT1_COUNT - 1) / INODE_INDIRECT1_COUNT; + total += 1 + level2_extra + level3_extra; + } + total as u32 + } + +``DiskInode`` 的 ``get_block_id`` 方法中遇到三级间接索引要额外读取三次块缓存。 + +.. code:: rust + + pub fn get_block_id(&self, inner_id: u32, block_device: &Arc) -> u32 { + let inner_id = inner_id as usize; + if inner_id < INODE_DIRECT_COUNT { + // ... + } else if inner_id < INDIRECT1_BOUND { + // ... + } else if inner_id < INDIRECT2_BOUND { + // ... + } else { // 对三级间接索引的处理 + let last = inner_id - INDIRECT2_BOUND; + let indirect1 = get_block_cache(self.indirect3 as usize, Arc::clone(block_device)) + .lock() + .read(0, |indirect3: &IndirectBlock| { + indirect3[last / INODE_INDIRECT2_COUNT] + }); + let indirect2 = get_block_cache(indirect1 as usize, Arc::clone(block_device)) + .lock() + .read(0, |indirect2: &IndirectBlock| { + indirect2[(last % INODE_INDIRECT2_COUNT) / INODE_INDIRECT1_COUNT] + }); + get_block_cache(indirect2 as usize, Arc::clone(block_device)) + .lock() + .read(0, |indirect1: &IndirectBlock| { + indirect1[(last % INODE_INDIRECT2_COUNT) % INODE_INDIRECT1_COUNT] + }) + } + } + +方法 ``increase_size`` 的实现本身比较繁琐,如果按照原有的一级和二级间接索引的方式实现对三级间接索引的处理,代码会比较丑陋。实际上多重间接索引是树结构,变量 ``current_blocks`` 和 ``total_blocks`` 对应着当前树的叶子数量和目标叶子数量,我们可以用递归函数来实现树的生长。先实现以下的辅助方法: + +.. code:: rust + + /// Helper to build tree recursively + /// extend number of leaves from `src_leaf` to `dst_leaf` + fn build_tree( + &self, + blocks: &mut alloc::vec::IntoIter, + block_id: u32, + mut cur_leaf: usize, + src_leaf: usize, + dst_leaf: usize, + cur_depth: usize, + dst_depth: usize, + block_device: &Arc, + ) -> usize { + if cur_depth == dst_depth { + return cur_leaf + 1; + } + get_block_cache(block_id as usize, Arc::clone(block_device)) + .lock() + .modify(0, |indirect_block: &mut IndirectBlock| { + let mut i = 0; + while i < INODE_INDIRECT1_COUNT && cur_leaf < dst_leaf { + if cur_leaf >= src_leaf { + indirect_block[i] = blocks.next().unwrap(); + } + cur_leaf = self.build_tree( + blocks, + indirect_block[i], + cur_leaf, + src_leaf, + dst_leaf, + cur_depth + 1, + dst_depth, + block_device, + ); + i += 1; + } + }); + cur_leaf + } + +然后修改方法 ``increase_size``。不要忘记在填充二级间接索引时维护 ``current_blocks`` 的变化,并限制目标索引 ``(a1, b1)`` 的范围。 + +.. code:: rust + + /// Increase the size of current disk inode + pub fn increase_size( + &mut self, + new_size: u32, + new_blocks: Vec, + block_device: &Arc, + ) { + // ... + // alloc indirect2 + // ... + // fill indirect2 from (a0, b0) -> (a1, b1) + // 不要忘记限制 (a1, b1) 的范围 + // ... + // alloc indirect3 + if total_blocks > INODE_INDIRECT2_COUNT as u32 { + if current_blocks == INODE_INDIRECT2_COUNT as u32 { + self.indirect3 = new_blocks.next().unwrap(); + } + current_blocks -= INODE_INDIRECT2_COUNT as u32; + total_blocks -= INODE_INDIRECT2_COUNT as u32; + } else { + return; + } + // fill indirect3 + self.build_tree( + &mut new_blocks, + self.indirect3, + 0, + current_blocks as usize, + total_blocks as usize, + 0, + 3, + block_device, + ); + +对方法 ``clear_size`` 的修改与 ``increase_size`` 类似。先实现辅助方法 ``collect_tree_blocks``: + +.. code:: rust + + /// Helper to recycle blocks recursively + fn collect_tree_blocks( + &self, + collected: &mut Vec, + block_id: u32, + mut cur_leaf: usize, + max_leaf: usize, + cur_depth: usize, + dst_depth: usize, + block_device: &Arc, + ) -> usize { + if cur_depth == dst_depth { + return cur_leaf + 1; + } + get_block_cache(block_id as usize, Arc::clone(block_device)) + .lock() + .read(0, |indirect_block: &IndirectBlock| { + let mut i = 0; + while i < INODE_INDIRECT1_COUNT && cur_leaf < max_leaf { + collected.push(indirect_block[i]); + cur_leaf = self.collect_tree_blocks( + collected, + indirect_block[i], + cur_leaf, + max_leaf, + cur_depth + 1, + dst_depth, + block_device, + ); + i += 1; + } + }); + cur_leaf + } + +然后修改方法 ``clear_size``。 + +.. code:: rust + + /// Clear size to zero and return blocks that should be deallocated. + /// We will clear the block contents to zero later. + pub fn clear_size(&mut self, block_device: &Arc) -> Vec { + // ... + // indirect2 block + // ... + // indirect2 + // 不要忘记限制 (a1, b1) 的范围 + self.indirect2 = 0; + // indirect3 block + assert!(data_blocks <= INODE_INDIRECT3_COUNT); + if data_blocks > INODE_INDIRECT2_COUNT { + v.push(self.indirect3); + data_blocks -= INODE_INDIRECT2_COUNT; + } else { + return v; + } + // indirect3 + self.collect_tree_blocks(&mut v, self.indirect3, 0, data_blocks, 0, 3, block_device); + self.indirect3 = 0; + v + } + +接下来你可以在 ``easy-fs-fuse/src/main.rs`` 中测试easy-fs文件系统的修改,比如读写大小超过10MiB的文件。 + +2. `*` 扩展内核功能,支持stat系统调用,能显示文件的inode元数据信息。 + +你将在本章的编程实验中实现这个功能。 + +3. `**` 扩展内核功能,支持mmap系统调用,支持对文件的映射,实现基于内存读写方式的文件读写功能。 + +.. note:: 这里只是给出了一种参考实现。mmap本身行为比较复杂,使用你认为合理的方式实现即可。 + +在第四章的编程实验中你应该已经实现了mmap的匿名映射功能,这里我们要实现文件映射。 +`mmap `_ 的原型如下: + +.. code:: c + + void *mmap(void *addr, size_t length, int prot, int flags, + int fd, off_t offset); + +其中 ``addr`` 是一个虚拟地址的hint,在映射文件时我们不关心具体的虚拟地址(相当于传入 ``NULL`` ),这里我们的系统调用忽略这个参数。 ``prot`` 和 ``flags`` 指定了一些属性,为简单起见我们也不要这两个参数,映射的虚拟内存的属性直接继承自文件的读写属性。我们最终保留 ``length`` 、 ``fd`` 和 ``offset`` 三个参数。 + +考虑最简单的一种实现方式:mmap调用时随便选择一段虚拟地址空间,将它映射到一些随机的物理页面上,之后再把文件的对应部分全部读到内存里。如果这段映射是可写的,那么内核还要在合适的时机(比如调用msync、munmap、进程退出时)把内存里的东西回写到文件。 + +这样做的问题是被映射的文件可能很大,将映射的区域全部读入内存可能很慢,而且用户未必会访问所有的页面。这里可以应用按需分页的惰性加载策略:先不实际建立虚拟内存到物理内存的映射,当用户访问映射的区域时会触发缺页异常,我们在处理异常时分配实际的物理页面并将文件读入内存。 + +按照上述方式已经可以实现文件映射了,但让我们来考虑较为微妙的情况。比如以下的Linux C程序: + +.. code:: c + + #include + #include + #include + #include + + int main() + { + char str[] = {"asdbasdq3423423\n"}; + int fd = open("2.txt", O_RDWR | O_CREAT | O_TRUNC, 0664); + if (fd < 0) { + printf("open failed\n"); + return -1; + } + + if (write(fd, str, sizeof(str)) < 0) { + printf("write failed\n"); + return -1; + } + + char *p1 = mmap(NULL, sizeof(str), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + char *p2 = mmap(NULL, sizeof(str), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + printf("p1 = %p, p2 = %p\n", p1, p2); + close(fd); + + p1[1] = '1'; + p2[2] = '2'; + p2[0] = '2'; + p1[0] = '1'; + printf("content1: %s", p1); + printf("content2: %s", p2); + return 0; + } + +一个可能的输出结果如下: + +.. code:: + + p1 = 0x7f955a3cf000, p2 = 0x7f955a3a2000 + content1: 112basdq3423423 + content2: 112basdq3423423 + +可以看到文件的同一段区域被映射到了两个不同的虚拟地址,对这两段虚拟内存的修改全部生效(冲突的修改也是最后的可见),修改后再读出来的内容也相同。这样的结果是符合直觉的,因为底层的文件只有一个(也与 ``MAP_SHARED`` 有关,由于设置 ``MAP_PRIVATE`` 标志不会将修改真正写入文件,我们参考 ``MAP_SHARED`` 的行为)。如果按照上面说的方式将两个虚拟内存区域映射到不同的物理页面,那么对两个区域的修改无法同时生效,我们也无法确定应该将哪个页面回写到文件。这个例子启示我们, **如果文件映射包含文件的相同部分,那么相应的虚拟页面应该映射到相同的物理页** 。 + +不幸的是,现有的 ``MapArea`` 类型只含 ``Identical`` 和 ``Framed`` ,不支持不同的虚拟页面共享物理页,所以我们需要手动管理一些资源。下面的 ``FileMapping`` 结构描述了一个文件的若干段映射: + +.. code:: rust + + pub struct FileMapping { + file: Arc, + ranges: Vec, + frames: Vec, + dirty_parts: BTreeSet, // file segments that need writing back + map: BTreeMap, // file offset -> ppn + } + +其中 ``file`` 代表被映射的文件,你可能会好奇它的类型为什么不是一个文件描述符编号或者 ``Arc`` 。首先mmap之后使用的文件描述符可以立即被关闭而不会对文件映射造成任何影响,所以不适合只存放fd编号;其次mmap通常要求映射的文件是常规文件 (例:映射stdin和stdout毫无意义),这里用 ``Inode`` 来提醒我们这点。 ``ranges`` 里面存放了若干 ``MapRange`` ,每个都用于描述一段映射区域。 ``frames`` 用于管理实际分配的物理页帧。 ``dirty_parts`` 记录了需要回写的脏页,注意它实际上用文件内的偏移来表示。 ``map`` 维护文件内偏移到物理页号的映射。需要注意的是这里记录脏页的方式比较简单,而且也完全没有考虑在进程间共享物理页,你可以使用引用计数等手段进行扩展。 + +.. code:: rust + + #[derive(Clone)] + struct MapRange { + start: VirtAddr, + len: usize, // length in bytes + offset: usize, // offset in file + perm: MapPermission, + } + +``MapRange`` 描述了一段映射区域。 ``start`` 是该区域的起始虚拟地址, ``offset`` 为其在文件中的偏移, ``perm`` 记录了该区域的属性。 + +前面提到过,我们的mmap忽略掉作为hint的 ``addr`` 参数,那这里的虚拟地址填什么呢?一般来说64位架构具有大到用不完的虚拟地址空间,用一个简单的线性分配器随便分配虚拟地址即可。 + +.. code:: rust + + /// Base virtual address for mmap + pub const MMAP_AREA_BASE: usize = 0x0000_0001_0000_0000; // 随便选的基址,挑块没人用的 + + /// A naive linear virtual address space allocator + pub struct VirtualAddressAllocator { + cur_va: VirtAddr, + } + + impl VirtualAddressAllocator { + /// Create a new allocator with given base virtual address + pub fn new(base: usize) -> Self { + Self { + cur_va: base.into(), + } + } + + /// Allocate a virtual address area + pub fn alloc(&mut self, len: usize) -> VirtAddr { + let start = self.cur_va; + let end: VirtAddr = (self.cur_va.0 + len).into(); + self.cur_va = end.ceil().into(); + start + } + + // 不必释放 + } + +然后把 ``VirtualAddressAllocator`` 和 ``FileMapping`` 放进 ``TaskControlBlockInner`` 里。为简单起见,fork时不考虑这两个字段的复制和映射的共享。 + +.. code-block:: rust + :caption: ``os/src/task/task.rs`` + :emphasize-lines: 11,12 + + pub struct TaskControlBlockInner { + pub trap_cx_ppn: PhysPageNum, + pub base_size: usize, + pub task_cx: TaskContext, + pub task_status: TaskStatus, + pub memory_set: MemorySet, + pub parent: Option>, + pub children: Vec>, + pub exit_code: i32, + pub fd_table: Vec>>, + pub mmap_va_allocator: VirtualAddressAllocator, + pub file_mappings: Vec, + } + +下面来添加mmap系统调用: + +.. code:: rust + + /// This is a simplified version of mmap which only supports file-backed mapping + pub fn sys_mmap(fd: usize, len: usize, offset: usize) -> isize { + if len == 0 { + // invalid length + return -1; + } + if (offset & (PAGE_SIZE - 1)) != 0 { + // offset must be page size aligned + return -1; + } + + let task = current_task().unwrap(); + let mut tcb = task.inner_exclusive_access(); + if fd >= tcb.fd_table.len() { + return -1; + } + if tcb.fd_table[fd].is_none() { + return -1; + } + + let fp = tcb.fd_table[fd].as_ref().unwrap(); + let opt_inode = fp.as_any().downcast_ref::(); + if opt_inode.is_none() { + // must be a regular file + return -1; + } + + let inode = opt_inode.unwrap(); + let perm = parse_permission(inode); + let file = inode.clone_inner_inode(); + if offset >= file.get_size() { + // file offset exceeds size limit + return -1; + } + + let start = tcb.mmap_va_allocator.alloc(len); + let mappings = &mut tcb.file_mappings; + if let Some(m) = find_file_mapping(mappings, &file) { + m.push(start, len, offset, perm); + } else { + let mut m = FileMapping::new_empty(file); + m.push(start, len, offset, perm); + mappings.push(m); + } + start.0 as isize + } + +这里面有不少无聊的参数检查和辅助函数,就不详细介绍了。总之这个系统调用实际做的事情只有维护对应的 ``FileMapping`` 结构,实际的工作被推迟到缺页异常处理例程中。 + +.. code-block:: rust + :caption: ``os/src/trap/mod.rs`` + :emphasize-lines: 17 + + #[no_mangle] + /// handle an interrupt, exception, or system call from user space + pub fn trap_handler() -> ! { + set_kernel_trap_entry(); + let scause = scause::read(); + let stval = stval::read(); + match scause.cause() { + Trap::Exception(Exception::UserEnvCall) => { + // ... + } + Trap::Exception(Exception::StoreFault) + | Trap::Exception(Exception::StorePageFault) + | Trap::Exception(Exception::InstructionFault) + | Trap::Exception(Exception::InstructionPageFault) + | Trap::Exception(Exception::LoadFault) + | Trap::Exception(Exception::LoadPageFault) => { + if !handle_page_fault(stval) { + println!( + "[kernel] {:?} in application, bad addr = {:#x}, bad instruction = {:#x}, kernel killed it.", + scause.cause(), + stval, + current_trap_cx().sepc, + ); + // page fault exit code + exit_current_and_run_next(-2); + } + } + Trap::Exception(Exception::IllegalInstruction) => { + // ... + } + Trap::Interrupt(Interrupt::SupervisorTimer) => { + // ... + } + _ => { + panic!( + "Unsupported trap {:?}, stval = {:#x}!", + scause.cause(), + stval + ); + } + } + trap_return(); + } + +我们在这里尝试处理缺页异常,如果 ``handle_page_fault`` 返回 ``true`` 表明异常已经被处理,否则内核仍然会杀死当前进程。 + +.. code-block:: rust + :linenos: + + /// Try to handle page fault caused by demand paging + /// Returns whether this page fault is fixed + pub fn handle_page_fault(fault_addr: usize) -> bool { + let fault_va: VirtAddr = fault_addr.into(); + let fault_vpn = fault_va.floor(); + let task = current_task().unwrap(); + let mut tcb = task.inner_exclusive_access(); + + if let Some(pte) = tcb.memory_set.translate(fault_vpn) { + if pte.is_valid() { + return false; // fault va already mapped, we cannot handle this + } + } + + match tcb.file_mappings.iter_mut().find(|m| m.contains(fault_va)) { + Some(mapping) => { + let file = Arc::clone(&mapping.file); + // fix vm mapping + let (ppn, range, shared) = mapping.map(fault_va).unwrap(); + tcb.memory_set.map(fault_vpn, ppn, range.perm); + + if !shared { + // load file content + let file_size = file.get_size(); + let file_offset = range.file_offset(fault_vpn); + assert!(file_offset < file_size); + + // let va_offset = range.va_offset(fault_vpn); + // let va_len = range.len - va_offset; + // Note: we do not limit `read_len` with `va_len` + // consider two overlapping areas with different lengths + + let read_len = PAGE_SIZE.min(file_size - file_offset); + file.read_at(file_offset, &mut ppn.get_bytes_array()[..read_len]); + } + true + } + None => false, + } + } + +- ``handle_page_fault`` 的9~13行先检查触发异常的虚拟内存页是否已经映射到物理页面,如果是则说明此异常并非源自惰性按需分页(比如写入只读页),这个问题不归我们管,直接返回 ``false``。 +- 接下来的第15行检查出错的虚拟地址是否在映射区域内,如果是我们才上手来处理。 + +在实际的修复过程中: +- 第19行先调用 ``FileMapping`` 的 ``map`` 方法建立目标虚拟地址到物理页面的映射; +- 第20行将新的映射关系添加到页表; +- 第22~35行处理文件读入。注意实际的文件读取只发生在物理页面的引用计数从0变为1的时候,存在共享的情况下再读取文件可能会覆盖掉用户对内存的修改。 + +``FileMapping`` 的 ``map`` 方法实现如下: + +.. code-block:: rust + :linenos: + + impl FileMapping { + /// Create mapping for given virtual address + fn map(&mut self, va: VirtAddr) -> Option<(PhysPageNum, MapRange, bool)> { + // Note: currently virtual address ranges never intersect + let vpn = va.floor(); + for range in &self.ranges { + if !range.contains(va) { + continue; + } + let offset = range.file_offset(vpn); + let (ppn, shared) = match self.map.get(&offset) { + Some(&ppn) => (ppn, true), + None => { + let frame = frame_alloc().unwrap(); + let ppn = frame.ppn; + self.frames.push(frame); + self.map.insert(offset, ppn); + (ppn, false) + } + }; + if range.perm.contains(MapPermission::W) { + self.dirty_parts.insert(offset); + } + return Some((ppn, range.clone(), shared)); + } + None + } + } + +- 第6~9行先找到包含目标虚拟地址的映射区域; +- 第10行计算虚拟地址对应的文件内偏移; +- 第11~20行先查询此文件偏移是否对应已分配的物理页,如果没有则分配一个物理页帧并记录映射关系; +- 第21~23行检查此映射区域是否有写入权限,如果有则将对应的物理页面标记为脏页。这个处理实际上比较粗糙,有些没有被真正写入的页面也被视为脏页,导致最后会有多余的文件回写。你也可以考虑不维护脏页信息,而是通过检查页表项中由硬件维护的 Dirty 位来确定哪些是真正的脏页。 + +修复后用户进程重新执行触发缺页异常的指令,此时物理页里存放了文件的内容,这样用户就实现了以读取内存的方式来读取文件。最后来处理被修改的脏页的同步,给 ``FileMapping`` 添加 ``sync`` 方法: + +.. code-block:: rust + :linenos: + + impl FileMapping { + /// Write back all dirty pages + pub fn sync(&self) { + let file_size = self.file.get_size(); + for &offset in self.dirty_parts.iter() { + let ppn = self.map.get(&offset).unwrap(); + if offset < file_size { + // WARNING: this can still cause garbage written + // to file when sharing physical page + let va_len = self + .ranges + .iter() + .map(|r| { + if r.offset <= offset && offset < r.offset + r.len { + PAGE_SIZE.min(r.offset + r.len - offset) + } else { + 0 + } + }) + .max() + .unwrap(); + let write_len = va_len.min(file_size - offset); + + self.file + .write_at(offset, &ppn.get_bytes_array()[..write_len]); + } + } + } + } + +这个方法将所有潜在的脏物理页内容回写至文件。第10~22行的计算主要为了限制写入内容的长度,以避免垃圾被意外写入文件。 + +剩下的问题是何时调用 ``sync`` 。正常来说munmap、msync是同步点,你可以自行实现这两个系统调用,这里我们把它放在进程退出之前: + +.. code-block:: rust + :caption: ``os/src/task/mod.rs`` + :emphasize-lines: 10-13 + + /// Exit the current 'Running' task and run the next task in task list. + pub fn exit_current_and_run_next(exit_code: i32) { + let task = take_current_task().unwrap(); + // ... + let mut inner = task.inner_exclusive_access(); + // ... + inner.children.clear(); + // deallocate user space + inner.memory_set.recycle_data_pages(); + // write back dirty pages + for mapping in inner.file_mappings.iter() { + mapping.sync(); + } + drop(inner); + // **** release current PCB + // drop task manually to maintain rc correctly + drop(task); + // ... + } + +这样我们就实现了基于内存读写方式的文件读写功能。可以看到mmap不是魔法,内核悄悄帮你完成了实际的文件读写。 + +4. `**` 扩展easy-fs文件系统功能,支持二级目录结构。可扩展:支持N级目录结构。 + +实际上easy-fs现有的代码支持目录的存在,只不过整个文件系统只有根目录一个目录,我们考虑放宽现有代码的一些限制。 + +原本的 ``easy-fs/src/vfs.rs`` 中有一个用于在当前目录下创建常规文件的 ``create`` 方法,我们给它加个参数并包装一下: + +.. code-block:: rust + :caption: ``easy-fs/src/vfs.rs`` + :emphasize-lines: 3,22,51-54,56-59 + + impl Inode { + /// Create inode under current inode by name + fn create_inode(&self, name: &str, inode_type: DiskInodeType) -> Option> { + let mut fs = self.fs.lock(); + let op = |root_inode: &DiskInode| { + // assert it is a directory + assert!(root_inode.is_dir()); + // has the file been created? + self.find_inode_id(name, root_inode) + }; + if self.read_disk_inode(op).is_some() { + return None; + } + // create a new file + // alloc a inode with an indirect block + let new_inode_id = fs.alloc_inode(); + // initialize inode + let (new_inode_block_id, new_inode_block_offset) = fs.get_disk_inode_pos(new_inode_id); + get_block_cache(new_inode_block_id as usize, Arc::clone(&self.block_device)) + .lock() + .modify(new_inode_block_offset, |new_inode: &mut DiskInode| { + new_inode.initialize(inode_type); + }); + self.modify_disk_inode(|root_inode| { + // append file in the dirent + let file_count = (root_inode.size as usize) / DIRENT_SZ; + let new_size = (file_count + 1) * DIRENT_SZ; + // increase size + self.increase_size(new_size as u32, root_inode, &mut fs); + // write dirent + let dirent = DirEntry::new(name, new_inode_id); + root_inode.write_at( + file_count * DIRENT_SZ, + dirent.as_bytes(), + &self.block_device, + ); + }); + + let (block_id, block_offset) = fs.get_disk_inode_pos(new_inode_id); + block_cache_sync_all(); + // return inode + Some(Arc::new(Self::new( + block_id, + block_offset, + self.fs.clone(), + self.block_device.clone(), + ))) + // release efs lock automatically by compiler + } + + /// Create regular file under current inode + pub fn create(&self, name: &str) -> Option> { + self.create_inode(name, DiskInodeType::File) + } + + /// Create directory under current inode + pub fn create_dir(&self, name: &str) -> Option> { + self.create_inode(name, DiskInodeType::Directory) + } + } + +这样我们就可以在一个目录底下调用 ``create_dir`` 创建新目录了(笑)。本质上我们什么也没改,我们再改改其它方法装装样子: + +.. code-block:: rust + :caption: ``easy-fs/src/vfs.rs`` + :emphasize-lines: 7-9,28,41 + + impl Inode { + /// List inodes under current inode + pub fn ls(&self) -> Vec { + let _fs = self.fs.lock(); + self.read_disk_inode(|disk_inode| { + let mut v: Vec = Vec::new(); + if disk_inode.is_file() { + return v; + } + + let file_count = (disk_inode.size as usize) / DIRENT_SZ; + for i in 0..file_count { + let mut dirent = DirEntry::empty(); + assert_eq!( + disk_inode.read_at(i * DIRENT_SZ, dirent.as_bytes_mut(), &self.block_device,), + DIRENT_SZ, + ); + v.push(String::from(dirent.name())); + } + v + }) + } + + /// Write data to current inode + pub fn write_at(&self, offset: usize, buf: &[u8]) -> usize { + let mut fs = self.fs.lock(); + let size = self.modify_disk_inode(|disk_inode| { + assert!(disk_inode.is_file()); + + self.increase_size((offset + buf.len()) as u32, disk_inode, &mut fs); + disk_inode.write_at(offset, buf, &self.block_device) + }); + block_cache_sync_all(); + size + } + + /// Clear the data in current inode + pub fn clear(&self) { + let mut fs = self.fs.lock(); + self.modify_disk_inode(|disk_inode| { + assert!(disk_inode.is_file()); + + let size = disk_inode.size; + let data_blocks_dealloc = disk_inode.clear_size(&self.block_device); + assert!(data_blocks_dealloc.len() == DiskInode::total_blocks(size) as usize); + for data_block in data_blocks_dealloc.into_iter() { + fs.dealloc_data(data_block); + } + }); + block_cache_sync_all(); + } + } + +对一个普通文件的inode调用 ``ls`` 方法毫无意义,但为了保持接口不变,我们返回一个空 ``Vec``。随意地清空或写入目录文件都会损坏目录结构,这里直接在 ``write_at`` 和 ``clear`` 方法中断言,你也可以改成其它的错误处理方式。 + +接下来是实际一点的修改(有,但不多):我们让 ``find`` 方法支持简单的相对路径(不含“.”和“..”)。 + +.. code-block:: rust + :caption: ``easy-fs/src/vfs.rs`` + + impl Inode { + /// Find inode under current inode by **path** + pub fn find(&self, path: &str) -> Option> { + let fs = self.fs.lock(); + let mut block_id = self.block_id as u32; + let mut block_offset = self.block_offset; + for name in path.split('/').filter(|s| !s.is_empty()) { + let inode_id = get_block_cache(block_id as usize, self.block_device.clone()) + .lock() + .read(block_offset, |disk_inode: &DiskInode| { + if disk_inode.is_file() { + return None; + } + self.find_inode_id(name, disk_inode) + }); + if inode_id.is_none() { + return None; + } + (block_id, block_offset) = fs.get_disk_inode_pos(inode_id.unwrap()); + } + Some(Arc::new(Self::new( + block_id, + block_offset, + self.fs.clone(), + self.block_device.clone(), + ))) + } + } + +最后在 ``easy-fs-fuse/src/main.rs`` 里试试我们添加的新特性: + +.. code-block:: rust + :caption: ``easy-fs-fuse/src/main.rs`` + + fn read_string(file: &Arc) -> String { + let mut read_buffer = [0u8; 512]; + let mut offset = 0usize; + let mut read_str = String::new(); + loop { + let len = file.read_at(offset, &mut read_buffer); + if len == 0 { + break; + } + offset += len; + read_str.push_str(core::str::from_utf8(&read_buffer[..len]).unwrap()); + } + read_str + } + + fn tree(inode: &Arc, name: &str, depth: usize) { + for _ in 0..depth { + print!(" "); + } + println!("{}", name); + for name in inode.ls() { + let child = inode.find(&name).unwrap(); + tree(&child, &name, depth + 1); + } + } + + #[test] + fn efs_dir_test() -> std::io::Result<()> { + let block_file = Arc::new(BlockFile(Mutex::new({ + let f = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open("target/fs.img")?; + f.set_len(8192 * 512).unwrap(); + f + }))); + EasyFileSystem::create(block_file.clone(), 4096, 1); + let efs = EasyFileSystem::open(block_file.clone()); + let root = Arc::new(EasyFileSystem::root_inode(&efs)); + root.create("f1"); + root.create("f2"); + + let d1 = root.create_dir("d1").unwrap(); + + let f3 = d1.create("f3").unwrap(); + let d2 = d1.create_dir("d2").unwrap(); + + let f4 = d2.create("f4").unwrap(); + tree(&root, "/", 0); + + let f3_content = "3333333"; + let f4_content = "4444444444444444444"; + f3.write_at(0, f3_content.as_bytes()); + f4.write_at(0, f4_content.as_bytes()); + + assert_eq!(read_string(&d1.find("f3").unwrap()), f3_content); + assert_eq!(read_string(&root.find("/d1/f3").unwrap()), f3_content); + assert_eq!(read_string(&d2.find("f4").unwrap()), f4_content); + assert_eq!(read_string(&d1.find("d2/f4").unwrap()), f4_content); + assert_eq!(read_string(&root.find("/d1/d2/f4").unwrap()), f4_content); + assert!(f3.find("whatever").is_none()); + Ok(()) + } + +如果你觉得这个练习不够过瘾,可以试试下面的任务: + +- 让easy-fs支持包含“.”和“..”的相对路径。你可以在目录文件里存放父目录的inode。 +- 在内核里给进程加上当前路径信息,然后实现chdir和getcwd。当然,也可以顺便补上openat和mkdir。 +- 在easy-fs中实现rename和mv的功能。在目录文件中删掉一些目录项也许要实现 ``decrease_size`` 或者类似删除的东西,但也可以考虑用删除标记这种常见的手段让一个目录项变得“不存在”。 + 问答题 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From fffe2e0f3a23add7f031044bf4b11c076070b6b6 Mon Sep 17 00:00:00 2001 From: Yifan Wu Date: Sat, 4 Mar 2023 21:10:54 +0800 Subject: [PATCH 4/7] update ch4-6 --- source/chapter4/6multitasking-based-on-as.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/source/chapter4/6multitasking-based-on-as.rst b/source/chapter4/6multitasking-based-on-as.rst index 41d581b7..c6e3a695 100644 --- a/source/chapter4/6multitasking-based-on-as.rst +++ b/source/chapter4/6multitasking-based-on-as.rst @@ -693,7 +693,11 @@ vpn.step(); let mut end_va: VirtAddr = vpn.into(); end_va = end_va.min(VirtAddr::from(end)); - v.push(&ppn.get_bytes_array()[start_va.page_offset()..end_va.page_offset()]); + if end_va.page_offset() == 0 { + v.push(&mut ppn.get_bytes_array()[start_va.page_offset()..]); + } else { + v.push(&mut ppn.get_bytes_array()[start_va.page_offset()..end_va.page_offset()]); + } start = end_va.into(); } v From a36d7396ea7184a312e8f63580a344a4db982b6b Mon Sep 17 00:00:00 2001 From: Yifan Wu Date: Tue, 7 Mar 2023 21:07:29 +0800 Subject: [PATCH 5/7] update ch0-2 --- source/chapter0/2os-interface.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/chapter0/2os-interface.rst b/source/chapter0/2os-interface.rst index 180ce891..5efc3934 100644 --- a/source/chapter0/2os-interface.rst +++ b/source/chapter0/2os-interface.rst @@ -196,4 +196,4 @@ API与ABI ret } -这里我们看到,API中的各个参数和返回值分别被RISC-V通用寄存器 `x10` (即存放系统调用号,也保存返回值)、 `x11`(存放 `fd` ) 、 `x12` (存放 `buf` )和 `x17` (存放 `len` )保存。 +这里我们看到,API中的各个参数和返回值分别被RISC-V通用寄存器 `x17` (即存放系统调用号,也保存返回值)、 `x10` (存放 `fd` ) 、 `x11` (存放 `buf` )和 `x12` (存放 `len` )保存。 From c50d9b82c55fe9473f0690d06eb89a06dee05408 Mon Sep 17 00:00:00 2001 From: Yifan Wu Date: Tue, 7 Mar 2023 21:09:10 +0800 Subject: [PATCH 6/7] update ch0-2 --- source/chapter0/2os-interface.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/chapter0/2os-interface.rst b/source/chapter0/2os-interface.rst index 5efc3934..794cf6d7 100644 --- a/source/chapter0/2os-interface.rst +++ b/source/chapter0/2os-interface.rst @@ -196,4 +196,4 @@ API与ABI ret } -这里我们看到,API中的各个参数和返回值分别被RISC-V通用寄存器 `x17` (即存放系统调用号,也保存返回值)、 `x10` (存放 `fd` ) 、 `x11` (存放 `buf` )和 `x12` (存放 `len` )保存。 +这里我们看到,API中的各个参数和返回值分别被RISC-V通用寄存器 `x17` (即存放系统调用号)、 `x10` (存放 `fd` ,也保存返回值) 、 `x11` (存放 `buf` )和 `x12` (存放 `len` )保存。 From 03056137b55366cf88072e36d1b362ac4e97e214 Mon Sep 17 00:00:00 2001 From: Yifan Wu Date: Sat, 11 Mar 2023 11:16:27 +0800 Subject: [PATCH 7/7] fix typos --- source/chapter3/2task-switching.rst | 2 +- source/chapter5/3implement-process-mechanism.rst | 2 +- source/chapter5/4scheduling.rst | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/source/chapter3/2task-switching.rst b/source/chapter3/2task-switching.rst index 779a997b..fe9b2da2 100644 --- a/source/chapter3/2task-switching.rst +++ b/source/chapter3/2task-switching.rst @@ -137,7 +137,7 @@ Trap 控制流在调用 ``__switch`` 之前就需要明确知道即将切换到 # 阶段 [4] ret -我们手写汇编代码来实现 ``__switch`` 。在阶段 [1] 可以看到它的函数原型中的两个参数分别是当前 A 任务上下文指针 ``current_task_cx_ptr`` 和即将被切换到的 B 任务上下文指针 ``next_task_cx_ptr`` ,从 :ref:`RISC-V 调用规范 ` 可以知道它们分别通过寄存器 ``a0/a1`` 传入。阶段 [2] 体现在第 19~27 行,即根据 B 任务上下文保存的内容来恢复 ``ra`` 寄存器、``s0~s11`` 寄存器以及 ``sp`` 寄存器。从中我们也能够看出 ``TaskContext`` 里面究竟包含哪些寄存器: +我们手写汇编代码来实现 ``__switch`` 。在阶段 [1] 可以看到它的函数原型中的两个参数分别是当前 A 任务上下文指针 ``current_task_cx_ptr`` 和即将被切换到的 B 任务上下文指针 ``next_task_cx_ptr`` ,从 :ref:`RISC-V 调用规范 ` 可以知道它们分别通过寄存器 ``a0/a1`` 传入。阶段 [2] 体现在第 19~27 行,即将当前 CPU 状态(包括 ``ra`` 寄存器、 ``s0~s11`` 寄存器以及 ``sp`` 寄存器)保存到 A 任务上下文。相对的,阶段 [3] 体现在第 29~37 行,即根据 B 任务上下文保存的内容来恢复上述 CPU 状态。从中我们也能够看出 ``TaskContext`` 里面究竟包含哪些寄存器: .. code-block:: rust :linenos: diff --git a/source/chapter5/3implement-process-mechanism.rst b/source/chapter5/3implement-process-mechanism.rst index b3cb944f..9cf78c33 100644 --- a/source/chapter5/3implement-process-mechanism.rst +++ b/source/chapter5/3implement-process-mechanism.rst @@ -89,7 +89,7 @@ - 第 11 行我们解析应用的 ELF 执行文件得到应用地址空间 ``memory_set`` ,用户栈在应用地址空间中的位置 ``user_sp`` 以及应用的入口点 ``entry_point`` 。 - 第 12 行我们手动查页表找到位于应用地址空间中新创建的Trap 上下文被实际放在哪个物理页帧上,用来做后续的初始化。 -- 第 16~18 行我们为该进程分配 PID 以及内核栈,并记录下内核栈在内核地址空间的位置 ``kernel_stack_top`` 。 +- 第 16~19 行我们为该进程分配 PID 以及内核栈,并记录下内核栈在内核地址空间的位置 ``kernel_stack_top`` 。 - 第 20 行我们在该进程的内核栈上压入初始化的任务上下文,使得第一次任务切换到它的时候可以跳转到 ``trap_return`` 并进入用户态开始执行。 - 第 21 行我们整合之前的部分信息创建进程控制块 ``task_control_block`` 。 - 第 37 行我们初始化位于该进程应用地址空间中的 Trap 上下文,使得第一次进入用户态的时候时候能正确跳转到应用入口点并设置好用户栈,同时也保证在 Trap 的时候用户态能正确进入内核态。 diff --git a/source/chapter5/4scheduling.rst b/source/chapter5/4scheduling.rst index 5c453b45..14180f55 100644 --- a/source/chapter5/4scheduling.rst +++ b/source/chapter5/4scheduling.rst @@ -315,7 +315,7 @@ MLFQ调度策略的关键在于如何设置优先级。一旦设置进程的好 但这样就彻底解决问题了吗?其实还不够,比如对于优先级低且处于I/O密集型任务的进程,必须等待一段时间后,才能重新加入到最高优先级,才能减少响应时间。难道这样的进程不能不用等待一段时间吗? -而对于长进程,如果又不少长进程位于最低优先级,一下子把它们都提升为最高优先级,就可能影响本来处于最高优先级的交互式进程的响应时间。看来,第5条规则还有进一步改进的空间,提升优先级的方法可以更灵活一些。 +而对于长进程,如果有不少长进程位于最低优先级,一下子把它们都提升为最高优先级,就可能影响本来处于最高优先级的交互式进程的响应时间。看来,第5条规则还有进一步改进的空间,提升优先级的方法可以更灵活一些。 先看长进程,可以发现,所谓长进程“饥饿”,是指它有很长时间没有得到执行了。如果我们能够统计其在就绪态没有被执行的等待时间长度,就可以基于这个动态变量来逐步提升其优先级。比如每过一段时间,查看就绪进程的等待时间(进程在就绪态的等待时间)长度,让其等待时间长度与其优先级成反比,从而能够逐步第动态提升长进程的优先级。