Arch Linux 引导修复实录：一次硬盘故障后的恢复过程
起因 如图所示，我的硬盘出现了故障。开机后系统报错提示 3F0：Operating System Not Found，显然是启动盘已经损坏。进一步检查后确认，一块 SSD 硬盘损坏，导致无法引导系统。值得幸运的是，我的 Arch Linux 系统本身并不在这块硬盘上。但遗憾的是，GRUB 引导程序是安装在这块出问题的硬盘上的。更换硬盘后，虽然系统数据仍在，但失去了引导项，自然也就无法启动 Arch Linux。修复思路本质上，这是一个典型的引导损坏问题。解决方法是通过 Live 系统重新安装 GRUB，并生成引导配置。以下是完整的修复过程，包含了很多网上教程略过的细节，尤其是涉及 btrfs 文件系统的挂载方式。修复步骤1. 进入 Arch Linux Live 环境使用 Arch Linux 安装镜像制作启动 U 盘，进入 Live 系统。2. 挂载原系统分区我的 Arch Linux 安装在 btrfs 文件系统中，因此挂载方式与 ext4 略有不同：mount -t btrfs -o subvol=@ /dev/nvme1n1p3 /mnt # 挂载根子卷（@ 为子卷名） mount /dev/nvme0n1p1 /mnt/boot # 挂载 boot 分区确保 /mnt 和 /mnt/boot 分别挂载到系统根目录和引导分区。3. 切换到原系统环境使用 arch-chroot 切换到已挂载的系统环境：arch-chroot /mnt此时已获得对原系统的完全控制权限。4. 重装 GRUB 引导程序执行以下命令将 GRUB 安装到 EFI 分区：grub-install --target=x86_64-efi --efi-directory-id=ARCH5. 安装微码与内核镜像（关键步骤）很多教程忽略了这一点：如果是全新硬盘或曾经清理过 /boot，需要重新安装微码和内核镜像：pacman -S intel-ucode pacman -S linux建议使用 iPhone 或其他智能手机通过 USB 开启热点，使系统在 Live 环境下能够联网，避免手动配置网络。6. 生成 GRUB 配置文件grub-mkconfig -o /boot/grub/grub.cfg生成日志中应能看到已检测到内核镜像文件。此时如果系统存在双系统（如 Windows 安装在另一块硬盘上），但未被识别，不用担心，等会重复生成 GRUB 配置即可。处理 Windows 启动项未识别问题首次生成 GRUB 配置时，如未识别 Windows 启动项，可在图形界面环境下重新生成一次：确保 /boot 或 /boot/efi 正确挂载；再次执行：sudo grub-mkconfig -o /boot/grub/grub.cfg这里我是没有找到 Windows 的，因为我的 Windows 装在另一块硬盘上，没有检测到，我们通过引导程序进入 Linux 图形化界面之后再引导一遍即可。当然需要先将已经挂载的盘符解除挂载 umount 再重启。为了再次引导并且能够找到 Windows 启动程序，我们需要再次把 boot 分区挂载到/mnt/efi，再执行，结果如下。再重启就可以看到熟悉的 grub 引导界面了，可以成功引导进入 Arch Linux 和 Windows。此时应能识别到 Windows 启动项，重启后即可在 GRUB 界面中看到熟悉的双系统启动菜单。经验总结我之前对于挂载 mount 其实一直不太理解，现在学了操作系统的文件系统的设计和实现之后，然后现在又操作了一波，mount 就是把某个盘符，挂载到某个目录下，我们通过被挂载的目录可以直接访问被挂载的目录。Arch Linux 出意外了可以通过一块 Arch Linux 启动盘，通过挂载系统盘再 arch-chroot，获取到对 Arch Linux 的控制权。
参考文章：扩展阅读：RISC-V 架构与内核启动 · GitBook我们在 rCore 的第一步是能够在没有任何操作系统的裸机上运行我们的内核，并能够把控制权转交给 Rust 方便我们编写内核源代码，下面一起来看看为了达到这个目的需要做些什么吧。启动 QemurCore 编译内核源代码时设置的目标平台是 riscv64gc-unknown-none-elf，我们想要启动我们的内核的话，需要一台 RISC-V 架构的机器来运行我们的内核，我们可以使用 Qemu 来模拟一台。启动的时候需要给定一些启动参数，可以指定我们这台模拟的机器有些什么东西，比如下面是一条启动 qemu 并加载我们的内核的指令。qemu-system-riscv64 \ -machine virt \ -nographic \ -bios ../bootloader/rustsbi-qemu.bin \ -device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000这里介绍一些启动参数：-nographic 表示模拟器不需要提供图形界面，而只需要对外输出字符流。-bios 设置 Qemu 模拟器开机时用来初始化的引导加载程序（bootloader），它是运行在 M 态的程序，负责初始化以及启动内核（指会跳转到内核所在地址），默认会被加载到内存中 0x80000000 这个地址。默认情况下， Qemu 会使用 OpenSBI 启动，但 rCore-Tutorial 在这里指定了使用 RustSBI 启动。-device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA) 指定了内核加载的位置是 KERNEL_ENTRY_PA，即 0x80200000，内核镜像会被放置在内存的这个位置。内核启动过程Mret: 从 M 态返回到特权级更低的模式。Ecall 陷入特权级更高的模式。这里解释一下 Qemu 模拟的机器启动以后到底做了些什么事情：第一阶段，将必要的文件载入到 Qemu 物理内存之后，Qemu CPU 的程序计数器（PC, Program Counter）会被初始化为 0x1000， CPU 从物理地址 0x1000 开始执行一段硬件（ Qemu 模拟出的）上的引导代码，此时 CPU 位于 M 态。第二阶段，Qemu 的第一阶段固定跳转到 0x80000000 ，RustSBI 已经被加载到这里，开始执行 RustSBI 的初始化代码，此时 CPU 位于 M 态。第三阶段，通过 mret 跳转到 0x80200000 执行内核的第一条代码，同时 CPU 切换到 S 态。0x80200000 这个地址是直接编码在 SBI 里的，会固定跳转到该地址。我们已经编写好的内核的第一条代码是位于 os/src/entry.asm 中的 _start 符号所在的位置。不过连接器默认的内存布局不能按照我们的想法把第一条指令加载到正确的地方，我们可以通过 链接脚本 (Linker Script) 调整链接器的行为，使得最终生成的可执行文件的内存布局符合 Qemu 的预期，即内核第一条指令的地址应该位于 0x80200000 。// os/src/entry.asm .section .text.entry .globl _start _start: la sp, boot_stack_top call rust_main .section .bss.stack .globl boot_stack_lower_bound boot_stack_lower_bound: .space 4096 * 16 .globl boot_stack_top boot_stack_top:第 2 行表明我们希望将第 2 行后面的内容全部放到一个名为 .text.entry 的段中，将其命名为 .text.entry 从而区别于其他 .text 的目的在于我们想要确保该段被放置在相比任何其他代码段更低的地址上。这样，作为内核的入口点，这段指令才能被最先执行。下面是链接脚本，第 12 行我们将包含内核第一条指令的 .text.entry 段放在最终的 .text 段的最开头，同时注意到在最终内存布局中代码段 .text 又是先于任何其他段的。因为所有的段都从 BASE_ADDRESS 也即 0x80200000 开始放置，这就能够保证内核的第一条指令正好放在 0x80200000 从而能够正确对接到 Qemu 上。OUTPUT_ARCH(riscv) ENTRY(_start) BASE_ADDRESS = 0x80200000; SECTIONS { . = BASE_ADDRESS; skernel = .; stext = .; .text : { *(.text.entry) . = ALIGN(4K); strampoline = .; *(.text.trampoline); . = ALIGN(4K); *(.text .text.*) } . = ALIGN(4K); etext = .; srodata = .; .rodata : { *(.rodata .rodata.*) *(.srodata .srodata.*) } . = ALIGN(4K); erodata = .; sdata = .; .data : { *(.data .data.*) *(.sdata .sdata.*) } . = ALIGN(4K); edata = .; sbss_with_stack = .; .bss : { *(.bss.stack) sbss = .; *(.bss .bss.*) *(.sbss .sbss.*) } . = ALIGN(4K); ebss = .; ekernel = .; /DISCARD/ : { *(.eh_frame) } }函数调用 将控制权交给Rust从 CPU 可以执行我们编写的内核的第一条指令开始，我们就等于是说已经把框架搭好了，内核已经执行我们的指令了，然而，我们无论如何也不想仅靠手写汇编代码的方式编写我们的内核，绝大部分功能我们都想使用 Rust 语言来实现。我们需要设置栈空间，来在内核内使能函数调用，随后直接调用使用 Rust 编写的内核入口函数，从而控制权便被移交给 Rust 代码。关于函数调用与栈的具体细节可以参考这个链接：为内核支持函数调用与栈总之，在进行函数调用的时候，我们通过 jalr 指令保存返回地址并实现跳转；而在函数即将返回的时候，则通过 ret 伪指令回到跳转之前的下一条指令继续执行。这样，RISC-V 的这两条指令就实现了函数调用流程的核心机制。编译器是独立编译每个函数的，因此一个函数并不能知道它所调用的子函数修改了哪些寄存器。而站在一个函数的视角，在调用子函数的过程中某些寄存器的值被覆盖的确会对它接下来的执行产生影响。我们需要为函数保护好现场，我们把在控制流转移前后需要保持不变的寄存器集合称之为 函数调用上下文 (Function Call Context) 。由于每个 CPU 只有一套寄存器，我们若想在子函数调用前后保持函数调用上下文不变，就需要物理内存的帮助。在调用子函数之前，我们需要在栈 保存 (Save) 函数调用上下文中的寄存器；而在函数执行完毕后，我们会从内存中同样的区域读取并 恢复 (Restore) 函数调用上下文中的寄存器。这一工作是由子函数的调用者和被调用者（也就是子函数自身）合作完成。函数调用上下文中的寄存器被分为如下两类：被调用者保存 (Callee-Saved) 寄存器 ：由被调用的函数来保证在调用前后，这些寄存器保持不变；被调用的函数想要用的话，要先保存这些寄存器！调用者保存 (Caller-Saved) 寄存器 ：由发起调用的函数来保证在调用前后，这些寄存器保持不变。也就是说被调用的函数可以随意更改这些寄存器！这些寄存器通常都是对恢复环境没啥意义的临时寄存器，可能也就是做做计算，如果调用者觉得值有意义的话需要自己保存。调用者和被调用者实际上只需分别按需保存调用者保存寄存器和被调用者保存寄存器> 的一个子集。编译器在进行后端代码生成时，知道在这两个场景中分别有哪些值得保存的寄存器。调用规范我们已经知道了函数调用控制流需要保存函数上下文，然而，对于函数调用实现时的一些规范或者说约定，不同的指令集不同的语言都有不同的实现。调用规范 (Calling Convention) 约定在某个指令集架构上，某种编程语言的函数调用如何实现。它包括了以下内容：函数的输入参数和返回值如何传递；函数调用上下文中调用者/被调用者保存寄存器的划分；其他的在函数调用流程中对于寄存器的使用方法。比如，ra ( x1 ) return address 是被调用者保存的。被调用者函数可能也会调用函数，在调用之前就需要修改 ra 使得这次调用能正确返回。因此，每个函数都需要在开头保存 ra 到自己的栈帧中，并在结尾使用 ret 返回之前将其恢复。栈帧是当前执行函数用于存储局部变量和函数返回信息的内存结构。下图为一个可能的函数的栈帧内容。分配并启用栈我们在 entry.asm 中分配启动栈空间，并在控制权被转交给 Rust 入口之前将栈指针 sp 设置为栈顶的位置。这样 Rust 代码在进行函数调用和返回的时候就可以正常在启动栈上分配和回收栈帧了。 .section .text.entry .globl _start _start: la sp, boot_stack_top call rust_main .section .bss.stack .globl boot_stack_lower_bound boot_stack_lower_bound: .space 4096 * 16 .globl boot_stack_top boot_stack_top:总结要在一个 RISC-V 机器上运行我们的内核，首先内核要正确加载到物理内存中的约定好的位置，PC 要能够有机会指向我们编写的内核可执行文件，可以被 CPU 执行。当然我们也要正确控制内核的内存布局，确保我们编写的计划作为内核的第一条指令能够和 qemu 开始执行内核指令时的地址对齐。之后再使能函数调用，将控制权交给 rust，我们可以使用高级语言方便的编写内核代码了。
进程概念在rCore关于进程这一部分之前，我们有 任务 的概念，即 正在执行的程序 ，主角是程序。而相比于 任务 ， 进程 (Process) 的含义是 在操作系统管理下的程序的一次执行过程。打一个比方，可执行文件本身可以看成一张编译器解析源代码之后总结出的一张记载如何利用各种硬件资源进行一轮生产流程的 蓝图 。而内核的一大功能便是作为一个硬件资源管理器，它可以随时启动一轮生产流程（即执行任意一个应用），这需要选中一张蓝图（此时确定执行哪个可执行文件），接下来就需要内核按照蓝图上所记载的对资源的需求来对应的将各类资源分配给它，让这轮生产流程得以顺利进行。当按照蓝图上的记载生产流程完成（应用退出）之后，内核还需要将对应的硬件资源回收以便后续的重复利用。因此，进程就是操作系统选取某个可执行文件并对其进行一次动态执行的过程。相比可执行文件，它的动态性主要体现在：1.它是一个过程，从时间上来看有开始也有结束；2.在该过程中对于可执行文件中给出的需求要相应对 硬件/虚拟资源 进行 动态绑定和解绑 。数据结构进程标识符同一时间存在的所有进程都有一个唯一的进程标识符，它们是互不相同的整数，这样才能表示表示进程的唯一性。这里我们使用 RAII 的思想，将其抽象为一个 PidHandle 类型，当它的生命周期结束后对应的整数会被编译器自动回收。当然我们也需要一个使用简单栈式分配策略的进程标识符分配器 PidAllocator。pub struct PidHandle(pub usize); struct PidAllocator { current: usize, recycled: Vec<usize>, }内核栈就像之前为每个 task 提供一个内核栈一样，我们也需要为每个进程提供一个内核栈。可以想想内核地址空间布局，有一片连续的空间用于放置每个进程的内核栈。在没有进程之前我们将每个应用的内核栈按照人为给定的应用编号从小到大的顺序将它们作为逻辑段从高地址到低地址放在内核地址空间中，且两两之间保留一个守护页面使得我们能够尽可能早的发现内核栈溢出问题。现在引入了进程的概念后我们将应用编号替换为进程标识符决定每个进程内核栈在地址空间中的位置。这里的实现关键是实现 kstack_alloc() 用于申请内核栈空间，在 new 或 fork 创建新进程的时候会用到，其中需要实现 kernel_stack_position() 去获取该进程对应内核栈在内核地址空间中的位置，获取到位置后需要在内核地址空间中建立映射。/// Kernel stack for a process(task) pub struct KernelStack(pub usize); /// allocate a new kernel stack pub fn kstack_alloc() -> KernelStack { let kstack_id = KSTACK_ALLOCATOR.exclusive_access().alloc(); let (kstack_bottom, kstack_top) = kernel_stack_position(kstack_id); KERNEL_SPACE.exclusive_access().insert_framed_area( kstack_bottom.into(), kstack_top.into(), MapPermission::R | MapPermission::W, ); KernelStack(kstack_id) } /// Return (bottom, top) of a kernel stack in kernel space. /// 根据进程标识符计算内核栈在内核地址空间中的位置 pub fn kernel_stack_position(app_id: usize) -> (usize, usize) { let top = TRAMPOLINE - app_id * (KERNEL_STACK_SIZE + PAGE_SIZE); let bottom = top - KERNEL_STACK_SIZE; (bottom, top) } Task Control Block在初始化之后就不再变化的元数据：直接放在任务控制块中。在运行过程中可能发生变化的元数据：则放在 TaskControlBlockInner 中，将它再包裹上一层 UPSafeCell<T> 放在任务控制块中。保证外层只能获取任务控制块的不可变引用，若想修改里面的部分内容的话这需要 UPSafeCell<T> 所提供的内部可变性。进程控制块的本体是被放到内核堆上面的, 子进程的进程控制块并不会被直接放到父进程控制块中，因为子进程完全有可能在父进程退出后仍然存在。 pub struct TaskControlBlock { // immutable pub pid: PidHandle, pub kernel_stack: KernelStack, // mutable inner: UPSafeCell<TaskControlBlockInner>, } pub struct TaskControlBlockInner { pub trap_cx_ppn: PhysPageNum, pub base_size: usize, pub task_status: TaskStatus, pub task_cx: TaskContext, pub memory_set: MemorySet, pub parent: Option<Weak<TaskControlBlock>>, pub children: Vec<Arc<TaskControlBlock>>, pub exit_code: i32, }TaskManager负责管理所有的进程，我们不直接将任务控制块放到 TaskManager 里面，而是将它们放在内核堆上，在任务管理器中仅存放他们的引用计数智能指针，这也是任务管理器的操作单位。pub struct TaskManager { ready_queue: VecDeque<Arc<TaskControlBlock>>, }Processor pub struct Processor { // 正在执行的任务 current: Option<Arc<TaskControlBlock>>, // 空闲控制流的上下文 idle_task_cx: TaskContext, }Processor 是描述CPU 执行状态 的数据结构。任务调度的 idle 控制流运行在这个 CPU 核的启动栈上在内核初始化完毕之后，会通过调用 run_tasks 函数来进入 idle 控制流功能是尝试从任务管理器中选出一个任务来在当前 CPU 核上执行。在这个 loop 里面不停地去问调度器（fetch_task）有没有新的任务至于下一个任务是什么，只跟调度器有关系图为run_tasks模型。pub fn run_tasks() { loop { let mut processor = PROCESSOR.exclusive_access(); // Round robin 轮转调度 if let Some(task) = fetch_stride_least_task() { // 得到 __switch 的第一个参数，也就是当前 idle 控制流的 task_cx_ptr let idle_task_cx_ptr = processor.get_idle_task_cx_ptr(); // access coming task TCB exclusively let mut task_inner = task.inner_exclusive_access(); // 获取任务块内部的 next_task_cx_ptr 作为 __switch 的第二个参数 let next_task_cx_ptr = &task_inner.task_cx as *const TaskContext; task_inner.task_status = TaskStatus::Running; // release coming task_inner manually drop(task_inner); // release coming task TCB manually processor.current = Some(task); // release processor manually drop(processor); unsafe { __switch(idle_task_cx_ptr, next_task_cx_ptr); } } else { warn!("no tasks available in run_tasks"); } } }当一个应用用尽了内核本轮分配给它的时间片或者它主动调用 yield 系统调用交出 CPU 使用权之后，内核会调用 schedule 函数来切换到 idle 控制流并开启新一轮的任务调度。pub fn schedule(switched_task_cx_ptr: *mut TaskContext) { let mut processor = PROCESSOR.exclusive_access(); let idle_task_cx_ptr = processor.get_idle_task_cx_ptr(); drop(processor); unsafe { __switch( switched_task_cx_ptr, idle_task_cx_ptr, ); } }进程管理机制的设计与实现第一个进程的创建内核手动调用 TaskControlBlock::new 来创建一个进程控制块，创建完成后调用 task 的任务管理器 manager 子模块提供的 add_task 接口，将其加入到任务管理器中的就绪队列中。这里的关键实现是，TaskControlBlock:: new ，需要传入 ELF 可执行文件的数据切片作为参数作为 TCB 的构造函数，第一个进程就是通过这个构造的。/// At present, it is only used for the creation of initproc pub fn new(elf_data: &[u8]) -> Self { // memory_set with elf program headers/trampoline/trap context/user stack let (memory_set, user_sp, entry_point) = MemorySet::from_elf(elf_data); let trap_cx_ppn = memory_set .translate(VirtAddr::from(TRAP_CONTEXT_BASE).into()) .unwrap() .ppn(); // alloc a pid and a kernel stack in kernel space let pid_handle = pid_alloc(); let kernel_stack = kstack_alloc(); let kernel_stack_top = kernel_stack.get_top(); // push a task context which goes to trap_return to the top of kernel stack let task_control_block = Self { pid: pid_handle, kernel_stack, inner: unsafe { UPSafeCell::new(TaskControlBlockInner { trap_cx_ppn, base_size: user_sp, task_cx: TaskContext::goto_trap_return(kernel_stack_top), task_status: TaskStatus::Ready, memory_set, parent: None, children: Vec::new(), exit_code: 0, fd_table: vec![ // 0 -> stdin Some(Arc::new(Stdin)), // 1 -> stdout Some(Arc::new(Stdout)), // 2 -> stderr Some(Arc::new(Stdout)), ], signals: SignalFlags::empty(), signal_mask: SignalFlags::empty(), handling_sig: -1, signal_actions: SignalActions::default(), killed: false, frozen: false, trap_ctx_backup: None, heap_bottom: user_sp, program_brk: user_sp, }) }, }; // prepare TrapContext in user space let trap_cx = task_control_block.inner_exclusive_access().get_trap_cx(); *trap_cx = TrapContext::app_init_context( entry_point, user_sp, KERNEL_SPACE.exclusive_access().token(), kernel_stack_top, trap_handler as usize, ); task_control_block }进程调度机制这里的关键是 suspend_current_and_run_next 函数的实现，可以暂停当前任务并切换到下一个任务。当应用调用 sys_yield 主动交出使用权、本轮时间片用尽或者由于某些原因内核中的处理无法继续的时候，就会在内核中调用此函数触发调度机制并进行任务切换。进程生成机制想要生成进程关键是两个系统调用的实现：fork 和 exec。进程资源回收机制这里的关键是进程什么时候结束，我们已经编写好的析构函数实现彻底回收掉它占用的所有资源，包括：内核栈和它的 PID 还有它的应用地址空间存放页表的那些物理页帧等等。
记2023年秋冬季开源操作系统训练营一二阶段
背景我是一名计算机专业的学生，现在进入大三上学期，学校已经安排了操作系统课程，但老师教授的偏理论性缺少实践的过程。我本来已经在学习MIT 6.S081操作系统课程，朋友推荐了rCore，采用Rust语言编写的操作系统课程，觉得非常棒交流环境很好而且是国产然后配套资源也很全，就报名学习了。一起学rCore吧！我也认为在本科阶段，就需要认真学习理解计算机核心课程包括操作系统，应该学习计算机底层知识去了解探索计算机科学领域的魅力和故事，打下扎实基础，毕竟是计算机专业的本科生，大学毕业后可能没有足够的时间与精力去做这些事情。收获我觉得rCore tutorial最赞的方面就是每一章都实现了一个操作系统，并且按照操作系统的发展历史由简单到负责，非常符合认知水平，一章章地看下去让我对于操作系统的发展和实现过程有了很深刻的印象。我大概陈述一下我对每个操作系统的认识吧：从把操作系统放在qemu上运行开始，并提供一些printf和shutdown的函数调用，这时候的操作系统以函数库的形式存在。这里关键实现是需要支持函数调用操作，需要维护函数调用上下文。这样好麻烦，每次都要输入一整个程序到计算机然后运行，再计算机等待输入程序再运行。批处理系统出来了，可以一次性将多个程序打包到一起输入计算机。而当一个程序运行结束后，计算机会自动加载下一个程序到内存并开始执行。这里开始有了操作系统的概念。这里关键是AppManager的实现，管理所有的应用程序。但这么多应用一起执行，我们不希望相互影响，更不能影响内核代码，也不希望一个应用崩溃了就整个系统崩溃了，就引入了特权级机制，给os和应用程序不同的特权级，这里需要硬件软件一起实现，硬件控制User特权级下不能执行内核的代码，软件调整特权级需要通过_alltrap保存trapContext，同时找到内核态的traphandle的入口点，开始执行内核态的代码，之后再通过_restore恢复trapContext。但是这些应用是逐个执行的，还是独占了计算机资源，它们在进行IO操作的时候CPU是空闲的，就引入了多道批处理系统，一次性把多个应用程序加载进入内存，在计算机中以task的形式存在，这里关键是TaskManager的实现管理所有的task，同时为每一个task实现一个TaskControlBlock，存储控制信息。这里也需要顺便实现调度算法调度各个task。这个时候task一旦被调度开始执行，os只能等待操作系统主动放弃cpu，但是有些应用不自觉会长时间占着，也对我们的task不公平，这时候需要计算机来来控制，分时系统的概念形成了。这里的实现关键是定时发送时间中断使用户程序陷入trap交出CPU。最后为了进一步提升操作系统的隔离性和防御性引入了地址空间和进程的抽象。实验总结lab11.sys_taskinfo目标是查找每个 task 的各个系统调用的次数和距离 task 第一次被调用的时间差。这个很好实现，第一眼就应该想到显然需要把这两个值放到 task 对应的 TCB 里。Task 第一次被调度的时候记录时间并保存下来。Task 每次进入 trap_handle 的时候更新 syscall 次数。写值的时候直接在 unsafe 块内向指针指向的地方写就行。lab21.兼容 sys_taskinfo 和 sys_time因为引入了地址空间，我们在内核态看到的用户程序传来的虚拟地址在内核的地址空间中没有被映射，所以我们需要去通过当前 task 的地址空间的 memory set 去获取到具体的物理地址再写入。2.sys_mmap需要页面对齐！！！指 len 长度应当修改为 4096 的整数倍。可以直接调用 insert_framed_area 建立映射，这里也会一起申请好 pagetable。判断地址有没有被建立映射，可以去遍历当前地址空间的 VPNRange。3.sys_munmap这里要 len 正确才行！我们不对齐 len，len 错误的话直接返回错误。物理方面需要去移除输入的页面的 areas。软件方面需要取消建立的映射。lab31.兼容之前的代码之前 task 的功能被拆分到了 task 和 process，稍微改动即可。2.syscall spawn参考 fork 和 exec，很容易实现，关键是要创建新的进程并加入调度。3.stride 调度算法一种优先级调度算法，维护一个优先级和步长就行。每次调度的时候不再从 task 队首选择，而是选择当前步长最小的进行调度，并更新步幅。总结与展望前前后后花了几个礼拜的时间从学习rust开始，到配置qemu和docker环境，开始看rcore文档，开始写lab，痛苦debug，对所有权冲突焦虑不解，对概念理解不透彻。但还是一步一步实现了从0到1，最重要最后看到自己的代码成功跑起来实现了功能，作为操作系统的一部分的那一刻真的很兴奋。我已经被操作系统的魅力折服了！^_^而且这次训练营有时间要求，有压力，我看rcore的速度加快了很多，如果没有训练营这样的大环境和时间要求在的话我是不能够以这样的速度完成的。我目前只完成了ch5，但是剩下的部分也会稳扎稳打完成的，希望三阶段对操作系统能有一个更全面的理解和认识，自己能够顺利完成三阶段。最后感谢一起组队的同学，感谢组织者和助教还有讲师，讲得很合理清晰有趣，感谢社区环境和提供的全面资源，感谢操作系统训练营这个大平台。
🌲长青 #OS #Rust这里是 rCore-Tutorial-Book-v3 3.6.0-alpha.1 文档的阅读笔记。解释一下文章标题: Bare Metai (裸机平台)，在裸机平台上的软件没有传统操作系统支持。我们的目标是在这样一个裸机平台上编写一个应用程序，功能是打印出 HelloWorld！应用程序执行环境与平台支持编程工作能够如此方便简洁并不是理所当然的，实际上有着多层硬件和软件工具和支撑环境隐藏在它背后，才让我们不必付出那么多努力就能够创造出功能强大的应用程序。生成应用程序二进制执行代码所依赖的是以 编译器 为主的 开发环境 ；运行应用程序执行码所依赖的是以 操作系统 为主的 执行环境 。本章主要是讲解如何设计和实现建立在裸机上的执行环境，并让应用程序能够在这样的执行环境中运行。从而让同学能够对应用程序和它所依赖的执行环境有一个全面和深入的理解。本章设计实现了一个支持显示字符串应用的简单操作系统–“三叶虫”操作系统 – LibOS，它的形态就是一个函数库，给应用程序提供了显示字符串的函数。库操作系统（Library OS，LibOS）LibOS 以函数库的形式存在，为应用程序提供操作系统的基本功能。它最早来源于 MIT PDOS 研究小组在1996年左右的 Exokernel（外核）操作系统结构研究。Frans Kaashoek 教授的博士生 Dawson Engler 提出了一种与以往操作系统架构大相径庭的 Exokernel（外核）架构设计 1 ，即把传统的单体内核分为两部分，一部分以库操作系统的形式（即 LibOS）与应用程序紧耦合以实现传统的操作系统抽象，并进行面向应用的裁剪与优化；另外一部分（即外核）仅专注在最基本的安全复用物理硬件的机制上，来给 LibOS 提供基本的硬件访问服务。这样的设计思路可以针对应用程序的特征定制 LibOS ，达到高性能的目标。这种操作系统架构的设计思路比较超前，对原型系统的测试显示了很好的性能提升。但最终没有被工业界采用，其中一个重要的原因是针对特定应用定制一个LibOS的工作量大，难以重复使用。人力成本因素导致了它不太被工业界认可。应用程序执行环境计算机科学中遇到的所有问题都可通过增加一层抽象来解决。All problems in computer science can be solved by another level of indirection。– 计算机科学家 David Wheeler现代编译器工具集（以 C 或 Rust 编译器为例）的主要工作流程如下:源代码（source code） –> 预处理器（preprocessor） –> 宏展开的源代码宏展开的源代码 –> 编译器（compiler） –> 汇编程序汇编程序 –> 汇编器（assembler）–> 目标代码（object code）目标代码 –> 链接器（linker） –> 可执行文件（executables）目标平台与目标三元组为什么需要知道目标平台？编译器在将源代码通过编译、链接得到可执行文件的时候需要知道程序要在哪个 平台 (Platform) 上运行才能最后生成可执行文件。这里平台主要是指 CPU 类型、操作系统类型和标准运行时库的组合。如果用户态基于的内核不同，会导致系统调用接口不同或者语义不一致；如果底层硬件不同，对于硬件资源的访问方式会有差异。特别是如果 ISA 不同，则向软件提供的指令集和寄存器都不同。编程语言怎么处理目标平台！Rust编译器通过 目标三元组 (Target Triplet) 来描述一个软件运行的目标平台。我们选择 riscv64gc-unknown-none-elf 目标平台。这其中的 CPU 架构是 riscv64gc CPU 厂商是 unknown 操作系统是 none elf 表示没有标准的运行时库（表明没有任何系统调用的封装支持），但可以生成 ELF 格式的执行程序。Rust 标准库与核心库编程语言的标准库比如Rust 语言标准库–std 或三方库的某些功能会直接或间接的用到操作系统提供的系统调用。但目前我们所选的目标平台不存在任何操作系统支持。幸运的是，Rust 有一个对 Rust 语言标准库–std 裁剪过后的 Rust 语言核心库 core。core库是不需要任何操作系统支持的移除标准库依赖不是说修改编译器编译选项就可以完成针对不同执行环境的编译的，我们需要修改我们的代码改造哪些需要用到之前依赖的执行环境，当然在你没有改造完成之前你也不会编译成功的😂。提供 panic_handler 功能应对致命错误在使用 Rust 编写应用程序的时候，我们常常在遇到了一些无法恢复的致命错误（panic），导致程序无法继续向下运行。这时手动或自动调用 panic! 宏来打印出错的位置，让软件能够意识到它的存在，并进行一些后续处理。 panic! 宏最典型的应用场景包括断言宏 assert! 失败或者对 Option::None/Result::Err 进行 unwrap 操作。所以Rust编译器在编译程序时，从安全性考虑，需要有 panic! 宏的具体实现。更底层的核心库 core 中只有一个 panic! 宏的空壳，并没有提供 panic! 宏的精简实现。因此我们需要自己先实现一个简陋的 panic 处理函数，这样才能让“三叶虫”操作系统 – LibOS的编译通过。移除 main 函数语言标准库和三方库作为应用程序的执行环境，需要负责在执行应用程序之前进行一些初始化工作，然后才跳转到应用程序的入口点（也就是跳转到我们编写的 main 函数）开始执行。事实上 start 语义项代表了标准库 std 在执行应用程序之前需要进行的一些初始化工作。由于我们禁用了标准库，编译器也就找不到这项功能的实现了。最简单的解决方案就是压根不让编译器使用这项功能。我们在 main.rs 的开头加入设置 #![no_main] 告诉编译器我们没有一般意义上的 main 函数，并将原来的 main 函数删除。在失去了 main 函数的情况下，编译器也就不需要完成所谓的初始化工作了。介绍执行环境 Qemu——内核执行的基础接下来两节我们将进行构建“三叶虫”操作系统的第二步，即将我们的内核正确加载到 Qemu 模拟器上，使得 Qemu 模拟器可以成功执行内核的第一条指令!想要加载内核的话，我们首先需要了解执行平台的相关资料，把我们的内核放到哪里才能被正确执行？怎么把 qemu 的控制权转移到我们的内核。Qemu 的工作模式在 Qemu 模拟的 virt 硬件平台上，物理内存的起始物理地址为 0x80000000 ，物理内存的默认大小为 128MiB。在本书中，我们只会用到最低的 8MiB 物理内存，对应的物理地址区间为 [0x80000000,0x80800000) 。Qemu 模拟的启动流程则可以分为三个阶段：第一个阶段由固化在 Qemu 内的一小段汇编程序负责；第二个阶段由 bootloader 负责；第三个阶段则由内核镜像负责。第一阶段：将必要的文件载入到 Qemu 物理内存之后，Qemu CPU 的程序计数器（PC, Program Counter）会被初始化为 0x1000 ，因此 Qemu 实际执行的第一条指令位于物理地址 0x1000 ，接下来它将执行寥寥数条指令并跳转到物理地址 0x80000000 对应的指令处并进入第二阶段。第二阶段：由于 Qemu 的第一阶段固定跳转到 0x80000000 ，我们需要将负责第二阶段的 bootloader rustsbi-qemu.bin 放在以物理地址 0x80000000 开头的物理内存中，这样就能保证 0x80000000 处正好保存 bootloader 的第一条指令。在这一阶段，bootloader 负责对计算机进行一些初始化工作，并跳转到下一阶段软件的入口，在 Qemu 上即可实现将计算机控制权移交给我们的内核镜像 os.bin 。这里需要注意的是，对于不同的 bootloader 而言，下一阶段软件的入口不一定相同，而且获取这一信息的方式和时间点也不同：入口地址可能是一个预先约定好的固定的值，也有可能是在 bootloader 运行期间才动态获取到的值。我们选用的 RustSBI 则是将下一阶段的入口地址预先约定为固定的 0x80200000 ，在 RustSBI 的初始化工作完成之后，它会跳转到该地址并将计算机控制权移交给下一阶段的软件——也即我们的内核镜像。第三阶段：为了正确地和上一阶段的 RustSBI 对接，我们需要保证内核的第一条指令位于物理地址 0x80200000 处。为此，我们需要将内核镜像预先加载到 Qemu 物理内存以地址 0x80200000 开头的区域上。一旦 CPU 开始执行内核的第一条指令，证明计算机的控制权已经被移交给我们的内核，也就达到了本节的目标。程序内存布局 Memory Layout在我们将源代码编译为可执行文件之后，它就会变成一个看似充满了杂乱无章的字节的一个文件。但我们知道这些字节至少可以分成代码和数据两部分，在程序运行起来的时候它们的功能并不相同：代码部分由一条条可以被 CPU 解码并执行的指令组成，而数据部分只是被 CPU 视作可读写的内存空间。已初始化数据段保存程序中那些已初始化的全局数据，分为 .rodata 和 .data 两部分。.rodata 存放只读的全局数据，通常是一些常数或者是常量字符串等；.data 存放可修改的全局数据。未初始化数据段 .bss 保存程序中那些未初始化的全局数据，通常由程序的加载者代为进行零初始化，即将这块区域逐字节清零；堆 （heap）区域用来存放程序运行时动态分配的数据，如 C/C++ 中的 malloc/new 分配到的数据本体就放在堆区域，它向高地址增长；栈 （stack）区域不仅用作函数调用上下文的保存与恢复，每个函数作用域内的局部变量也被编译器放在它的栈帧内，它向低地址增长。编译流程从源代码得到可执行文件的编译流程可被细化为多个阶段（虽然输入一条命令便可将它们全部完成）：编译器 (Compiler) 将每个源文件从某门高级编程语言转化为汇编语言，注意此时源文件仍然是一个 ASCII 或其他编码的文本文件；汇编器 (Assembler) 将上一步的每个源文件中的文本格式的指令转化为机器码，得到一个二进制的 目标文件 (Object File)；链接器 (Linker) 将上一步得到的所有目标文件以及一些可能的外部目标文件链接在一起形成一个完整的可执行文件。汇编器输出的每个目标文件都有一个独立的程序内存布局，它描述了目标文件内各段所在的位置。而链接器所做的事情是将所有输入的目标文件整合成一个整体的内存布局。在此期间链接器主要完成两件事情：第一件事情是将来自不同目标文件的段在目标内存布局中重新排布。如下图所示，在链接过程中，分别来自于目标文件 1.o 和 2.o 段被按照段的功能进行分类，相同功能的段被排在一起放在拼装后的目标文件 output.o 中。注意到，目标文件 1.o 和 2.o 的内存布局是存在冲突的，同一个地址在不同的内存布局中存放不同的内容。而在合并后的内存布局中，这些冲突被消除。第二件事情是将符号替换为具体地址。这里的符号指什么呢？我们知道，在我们进行模块化编程的时候，每个模块都会提供一些向其他模块公开的全局变量、函数等供其他模块访问，也会访问其他模块向它公开的内容。要访问一个变量或者调用一个函数，在源代码级别我们只需知道它们的名字即可，这些名字被我们称为符号。 取决于符号来自于模块内部还是其他模块，我们还可以进一步将符号分成内部符号和外部符号。然而，在机器码级别（也即在目标文件或可执行文件中）我们并不是通过符号来找到索引我们想要访问的变量或函数，而是直接通过变量或函数的地址。例如，如果想调用一个函数，那么在指令的机器码中我们可以找到函数入口的绝对地址或者相对于当前 PC 的相对地址。那么，符号何时被替换为具体地址呢？因为符号对应的变量或函数都是放在某个段里面的固定位置（如全局变量往往放在 .bss 或者 .data 段中，而函数则放在 .text 段中），所以我们需要等待符号所在的段确定了它们在内存布局中的位置之后才能知道它们确切的地址。当一个模块被转化为目标文件之后，它的内部符号就已经在目标文件中被转化为具体的地址了，因为目标文件给出了模块的内存布局，也就意味着模块内的各个段的位置已经被确定了。然而，此时模块所用到的外部符号的地址无法确定。我们需要将这些外部符号记录下来，放在目标文件一个名为符号表（Symbol table）的区域内。由于后续可能还需要重定位，内部符号也同样需要被记录在符号表中。外部符号需要等到链接的时候才能被转化为具体地址。假设模块 1 用到了模块 2 提供的内容，当两个模块的目标文件链接到一起的时候，它们的内存布局会被合并，也就意味着两个模块的各个段的位置均被确定下来。此时，模块 1 用到的来自模块 2 的外部符号可以被转化为具体地址。同时我们还需要注意：两个模块的段在合并后的内存布局中被重新排布，其最终的位置有可能和它们在模块自身的局部内存布局中的位置相比已经发生了变化。因此，每个模块的内部符号的地址也有可能会发生变化，我们也需要进行修正。上面的过程被称为重定位（Relocation），这个过程形象一些来说很像拼图：由于模块 1 用到了模块 2 的内容，因此二者分别相当于一块凹进和凸出一部分的拼图，正因如此我们可以将它们无缝地拼接到一起。在 Qemu 上执行内核的第一条指令我们先编写好内核第一条指令并嵌入到内核项目中。编写第一条指令# os/src/entry.asm .section .text.entry .globl _start _start: li x1, 100第 2 行表明我们希望将第 2 行后面的内容全部放到一个名为 .text.entry 的段中。一般情况下，所有的代码都被放到一个名为 .text 的代码段中，这里我们将其命名为 .text.entry 从而区别于其他 .text 的目的在于我们想要确保该段被放置在相比任何其他代码段更低的地址上。这样，作为内核的入口点，这段指令才能被最先执行。第 3 行我们告知编译器 _start 是一个全局符号，因此可以被其他目标文件使用。符号 _start 的地址即为第 5 行的指令所在的地址。调整内核布局与 Qemu 正确对接内核第一条指令（实践篇） - rCore-Tutorial-Book-v3 3.6.0-alpha.1 文档由于链接器默认的内存布局并不能符合我们的要求，为了实现与 Qemu 正确对接，我们可以通过 链接脚本 (Linker Script) 调整链接器的行为，使得最终生成的可执行文件的内存布局符合 Qemu 的预期，即内核第一条指令的地址应该位于 0x80200000 。我们修改 Cargo 的配置文件来使用我们自己的链接脚本 os/src/linker.ld 而非使用默认的内存布局。手动加载内核可执行文件上面通过链接得到的文件完全符合我们对于内存布局的要求，但是我们不能将其直接提交给 Qemu ，因为它除了实际会被用到的代码和数据段之外还有一些多余的元数据，这些元数据无法被 Qemu 在加载文件时利用，且会使代码和数据段被加载到错误的位置。我们需要丢弃内核可执行文件中的元数据得到内核镜像。rust-objcopy --strip-all target/riscv64gc-unknown-none-elf/release/os -O binary target/riscv64gc-unknown-none-elf/release/os.bin为内核提供函数调用——将控制权交给Rust上一节我们成功在 Qemu 上执行了内核的第一条指令，它是我们在 entry.asm 中手写汇编代码得到的。然而，我们无论如何也不想仅靠手写汇编代码的方式编写我们的内核，绝大部分功能我们都想使用 Rust 语言来实现。不过为了将控制权转交给我们使用 Rust 语言编写的内核入口函数，我们确实需要手写若干行汇编代码进行一定的初始化工作。这些汇编代码放在 entry.asm 中，并在控制权被转交给内核相关函数前最先被执行，但它们的功能会更加复杂。首先需要设置栈空间，来在内核内使能函数调用，随后直接调用使用 Rust 编写的内核入口函数，从而控制权便被移交给 Rust 代码。函数调用与栈从汇编指令的级别看待一段程序的执行，假如 CPU 依次执行的指令的物理地址序列为 {an}，那么这个序列会符合怎样的模式呢？ CPU 一条条连续向下执行指令，即满足递推公式 an+1 = an + L，这里我们假设该平台的指令是定长的且均为 L 字节（常见情况为 2/4 字节）。当位于物理地址 an 的指令是一条跳转指令的时候，该模式就有可能被破坏。跳转指令对应于我们在程序中构造的 控制流 (Control Flow) 的多种不同结构，比如分支结构（如 if/switch 语句）和循环结构（如 for/while 语句）。用来实现上述两种结构的跳转指令，只需实现跳转功能，也就是将 pc 寄存器设置到一个指定的地址即可。另一种控制流结构：函数调用 (Function Call)。调用的时候，需要有一条指令跳转到被调用函数的位置。函数调用的返回跳转是跳转到一个 _运行时确定_ （确切地说是在函数调用发生的时候）的地址。编译器是独立编译每个函数的，因此一个函数并不能知道它所调用的子函数修改了哪些寄存器。而站在一个函数的视角，在调用子函数的过程中某些寄存器的值被覆盖的确会对它接下来的执行产生影响。我们将由于函数调用，在控制流转移前后需要保持不变的寄存器集合称之为 函数调用上下文 (Function Call Context) 。由于每个 CPU 只有一套寄存器，我们若想在子函数调用前后保持函数调用上下文不变，就需要物理内存的帮助。在调用子函数之前，我们需要在物理内存中的一个区域栈Stack 保存 (Save) 函数调用上下文中的寄存器；而在函数执行完毕后，我们会从内存中同样的区域读取并 恢复 (Restore) 函数调用上下文中的寄存器。实际上，这一工作是由子函数的调用者和被调用者（也就是子函数自身）合作完成。函数调用上下文中的寄存器被分为如下两类：被调用者保存(Callee-Saved) 寄存器 ：被调用的函数可能会覆盖这些寄存器，需要被调用的函数来保存的寄存器，即由被调用的函数来保证在调用前后，这些寄存器保持不变；调用者保存(Caller-Saved) 寄存器 ：被调用的函数可能会覆盖这些寄存器，需要发起调用的函数来保存的寄存器，即由发起调用的函数来保证在调用前后，这些寄存器保持不变。RISC-V 架构上的 C 语言调用规范|寄存器组|保存者|功能| |---|---|---| |a0~a7（ x10~x17 ）|调用者保存|用来传递输入参数。其中的 a0 和 a1 还用来保存返回值。| |t0~t6 ( x5~x7,x28~x31 )|调用者保存|作为临时寄存器使用，在被调函数中可以随意使用无需保存。| |s0~s11 ( x8~x9,x18~x27 )|被调用者保存|作为临时寄存器使用，被调函数保存后才能在被调函数中使用。|分配并使用启动栈# os/src/entry.asm .section .text.entry .globl _start _start: la sp, boot_stack_top call rust_main .section .bss.stack .globl boot_stack_lower_bound boot_stack_lower_bound: .space 4096 * 16 .globl boot_stack_top boot_stack_top:栈是从高地址向低地址增长。因此，最开始的时候栈为空，栈顶和栈底位于相同的位置，我们用更高地址的符号 boot_stack_top 来标识栈顶的位置。同时，我们用更低地址的符号 boot_stack_lower_bound 来标识栈能够增长到的下限位置，它们都被设置为全局符号供其他目标文件使用。如下图所示：内核调用 RustSBI 服务之前我们对 RustSBI 的了解仅限于它会在计算机启动时进行它所负责的环境初始化工作，并将计算机控制权移交给内核。但实际上作为内核的执行环境，它还有另一项职责：即在内核运行时响应内核的请求为内核提供服务。当内核发出请求时，计算机会转由 RustSBI 控制来响应内核的请求，待请求处理完毕后，计算机控制权会被交还给内核。总结
我先来说一下我自己对于虚拟内存或者页表的认知吧。当我还是个学生并第一次听到学到这个词时，我认为它还是很直观简单的。这能有多难呢？无非就是个表单，将虚拟地址和物理地址映射起来，实际可能稍微复杂一点，但是应该不会太难。可是当我开始通过代码管理虚拟内存，我才知道虚拟内存比较棘手，比较有趣，功能也很强大。存在某种形式的映射关系；并且映射关系对于实现隔离性来说有帮助。地址空间 Address Spaces当然一切操作的目的都是为了实现操作系统的隔离性。如果我们不做任何工作，默认情况下我们是没有内存隔离性的，内存泄露问题很容易发生。所以，我们想要某种机制，能够将不同程序之间的内存隔离开来，这样类似的事情就不会发生。一种实现方式是地址空间（Address Spaces）。给包括内核在内的所有程序专属的地址空间。所以，当我们运行cat时，它的地址空间从0到某个地址结束。当我们运行Shell时，它的地址也从0开始到某个地址结束。内核的地址空间也从0开始到某个地址结束。如果 cat 程序想要向地址1000写入数据，那么 cat 只会向它自己的地址1000，而不是 Shell 的地址1000写入数据。所以，基本上来说，每个程序都运行在自己的地址空间，并且这些地址空间彼此之间相互独立。在这种不同地址空间的概念中，cat 程序甚至都不具备引用属于 Shell 的内存地址的能力。 这是我们想要达成的终极目标，因为这种方式为我们提供了强隔离性，cat 现在不能引用任何不属于自己的内存。所以现在我们的问题是如何在一个物理内存上，创建不同的地址空间，因为归根到底，我们使用的还是一堆存放了内存信息的DRAM芯片。页表 Page Table 创建不同的地址空间创建不同的地址空间的最常见的方法，同时也是非常灵活的一种方法就是使用页表（Page Tables）。页表是在硬件中通过处理器和内存管理单元（Memory Management Unit）实现。CPU 正在执行指令，对于任何一条带有地址的指令，其中的地址应该认为是虚拟内存地址而不是物理地址。虚拟内存地址会被转到内存管理单元（MMU，Memory Management Unit）。内存管理单元会将虚拟地址翻译成物理地址。之后这个物理地址会被用来索引物理内存，并从物理内存加载，或者向物理内存存储数据。为了能够完成虚拟内存地址到物理内存地址的翻译，MMU 会有一个表单，表单中，一边是虚拟内存地址，另一边是物理内存地址。通常来说，内存地址对应关系的表单也保存在内存中。所以 CPU 中需要有一些寄存器用来存放表单在物理内存中的地址。 现在，在内存的某个位置保存了地址关系表单，我们假设这个位置的物理内存地址是0x10。那么在 RISC-V 上一个叫做 SATP 的寄存器会保存地址0x10。这样，CPU 就可以告诉 MMU，可以从哪找到将虚拟内存地址翻译成物理内存地址的表单。MMU 并不会保存 page table，它只会从内存中读取 page table，然后完成翻译!每个应用程序都有自己独立的表单，并且这个表单定义了应用程序的地址空间。所以当操作系统将CPU从一个应用程序切换到另一个应用程序时，同时也需要切换SATP寄存器中的内容，从而指向新的进程保存在物理内存中的地址对应表单。表单是如何工作的以 page 为粒度对于每个虚拟地址，在表单中都有一个条目，如果我们真的这么做，表单会有多大？原则上说，在 RISC-V 上会有多少地址，或者一个寄存器可以保存多少个地址？寄存器是 64 bit 的，所以有多少个地址呢？是的，2^64 个地址，所以如果我们以地址为粒度来管理，表单会变得非常巨大。实际情况不可能是一个虚拟内存地址对应 page table 中的一个条目。为每个page创建一条表单条目，所以每一次地址翻译都是针对一个page。而RISC-V中，一个page是4KB，也就是4096Bytes。这个大小非常常见，几乎所有的处理器都使用4KB大小的page或者支持4KB大小的page。虚拟内存地址都是64bit，这也说的通，因为 RISC-V 的寄存器是64bit 的。但是实际上，在我们使用的 RSIC-V 处理器上，并不是所有的64bit 都被使用了，也就是说高25bit 并没有被使用。这样的结果是限制了虚拟内存地址的数量，虚拟内存地址的数量现在只有2^39个，大概是512GB。当然，如果必要的话，最新的处理器或许可以支持更大的地址空间，只需要将未使用的25bit 拿出来做为虚拟内存地址的一部分即可。在剩下的39bit中，有27bit被用来当做index，12bit被用来当做offset。offset必须是12bit，因为对应了一个page的4096个字节。page中的每个字节都可以被offset索引到。物理内存地址是56bit，其中44bit是物理page号（PPN，Physical Page Number），剩下12bit是offset完全继承自虚拟内存地址（也就是地址转换时，只需要将虚拟内存中的27bit翻译成物理内存中的44bit的page号，剩下的12bitoffset直接拷贝过来即可）。每个 page 表应该有多大？这个 page table 最多会有2^27个条目（虚拟内存地址中的 index 长度为27），如果每个进程都使用这么大的 page table，进程需要为 page table 消耗大量的内存，很快物理内存就会耗尽。所以实际上，硬件并不是按照这里的方式来存储 page table。从概念上来说，你可以认为 page table 是从0到2^27，但是实际上并不是这样。实际中，page table 是一个多级的结构。虚拟内存地址中的27bit 的 index，实际上是由3个9bit 的数字组成（L2，L1，L 0）。每个数字都负责索引一个级别的 page directory。一个directory是4096Bytes，就跟page的大小是一样的。Directory中的一个条目被称为PTE（Page Table Entry）是64bits，就像寄存器的大小一样，也就是8Bytes。所以一个Directory page有512个条目。索引流程SATP寄存器会指向最高一级的page directory的物理内存地址，之后我们用虚拟内存中index的高9bit用来索引最高一级的page directory，这样我们就能得到一个PPN，也就是物理page号。这个PPN指向了中间级的page directory。当我们在使用中间级的page directory时，我们通过虚拟内存地址中的L1部分完成索引。接下来会走到最低级的page directory，我们通过虚拟内存地址中的L0部分完成索引。在最低级的page directory中，我们可以得到对应于虚拟内存地址的物理内存地址。在最高级的page directory中的PPN，包含了下一级page directory的物理内存地址，依次类推。在最低级page directory，我们还是可以得到44bit的PPN，这里包含了我们实际上想要翻译的物理page地址，然后再加上虚拟内存地址的12bit offset，就得到了56bit物理内存地址。从某种程度上来说，与之前一种方案还是很相似的，除了实际的索引是由3步，而不是1步完成。这种方式的主要优点是，如果地址空间中大部分地址都没有使用，你不必为每一个 index 准备一个条目。举个例子，如果你的地址空间只使用了一个 page，4096Bytes，现在，你需要多少个page table entry，或者page table directory来映射这一个page？
为了修复输入法选中框中 emoji 符号不能正常显示的问题，我再次调整了我的输入法配置，也对 Linux 下的输入法配置有了比较清晰的理解，方便我日后再次配置，便在这里记录下来。名词定义明白三个概念，什么是 fcitx5，rime 和 rime-ice。fcitx5 是一个输入法框架，通常用于 Linux 操作系统上，用于支持多种不同语言和输入法的输入。配置文件在 /home/melody/.config/fcitx5/conf/RIME | 中州韵输入法引擎 是一个跨平台的输入法算法引擎，可以运行在输入法框架之上，需要进行各种配置后才可以使用的比较舒服。rime-ice 则是一个 RIME 的配置文件仓库。配置文件在 /home/${USER}/.local/share/fcitx5/rime/安装GitHub - Mark24Code/rime-auto-deploy: Make using Rime easy. 一个自用的脚本，帮助无痛快速安装、部署 Rime 输入法（中州韵、小狼毫，鼠须管）以及部署配置。推荐使用这个脚本一键无痛安装。具体使用方法看脚本文档即可，我在安装过程中没有遇到大问题，即使你不是第一次使用 rime，脚本可以自动备份旧的配置文件并且安装新的配置好的文件。配置文件说实话，rime 的配置文件真的非常的混乱，即使是在已经配置好的文件中，还好每个文件中的注释都非常清晰易懂。里面的文件主要有三个类型。XXX. custom. yaml 一些功能配置的模板文件XXX. Schema. Yaml 各个输入法方案的配置文件，不需要使用的模式可以直接删除。配置文件格式 按键对应也包含在里面XXX. Yaml 自动生成的一些文件，修改这里的文件是没有用的。我主要进行了以下配置：一、设置<>键进行翻页在 default. Custom. Yaml 中添加如下代码，重新部署输入法即可实现<>翻页。key_binder: bindings: - { when: has_menu, accept: less, send: Page_Up } - { when: has_menu, accept: greater, send: Page_Down }二、设置模糊音自动配置脚本配置的完整 rime，模糊音居然是自动开启了 nl 模糊，🤔🤔🤔使用体验非常不好，里觉得合理吗同样在模糊音配置文件 rime_ice.custom.yaml 中注释掉对应部分即可。三、配置输入法主题风格，字体rime-ice 的配置自带了很多皮肤字体设定，但是在 fcitx5 中都没有生效。fcitx 5 需要去框架配置中修改皮肤设定，使用 yay 下载皮肤之后按照配置文件中说明的使用即可。字体我推荐霞鹜文楷~非常美丽：D推荐使用这个皮肤GitHub - hosxy/Fcitx5-Material-Color: 一款使用Material Design 配色的 fcitx5 皮肤，喜欢的话给个 star 吧 ヾ(≧へ≦)〃 😉四、修复输入框中 emoji 无法正常显示的问题这个问题才是我这次配置输入法的主要目的！首先发现有同样问题的 issue arch + fcitx5 不显示emoji？ · Issue #50 · fkxxyz/rime-cloverpinyin · GitHub，惊喜！有救了，大家果然给出了解决方案。Fontconfig and Noto Color Emoji | dotfiles 104542 – Color emojis are not displayed104542 – Color emojis are not displayed按照解决方案中的描述，需要修改 /etc/fonts/conf.d/68-color-emoji.conf 这个配置文件，我欣喜的去找，发现我的目录下没有这个配置文件。但是很显然这个配置文件不是自己直接创建就可以使用的类型。我的解决问题之路陷入了迷茫期。这期间反复尝试过了重新安装 emoji 字体，都没有生效，直到我在无意间尝试安装 emoji 的时候发现了这个仓库，救命稻草啊！安装完成之后，果然我也有了需要修改的配置文件，直接修改，再重启输入法，问题解决了！ 总结这样看下来其实并不难，自己主动去配置经历一遍就好了，Linux 下配置是最方便的了！关键是要对自己配置的东西知其然，希望我在下次配置 Linux 输入法的时候不会连配置文件都找不到，不会缕不清配置文件中的关系。
操作系统课程刚学完了信号量机制，通过写这篇文章梳理巩固我自己对于信号量机制的理解，同时记录对一道例题的思考过程，希望能对信号量机制的运用有更加深刻的印象。信号量机制我们从信号量机制的设立意义开始思考，操作系统分时复用，多个进程并发运行，这些进程之间主要存在两种关系。一种是计算机存在一些临界资源公共资源，不同进程对其的访问应当是互斥的，这些进程之间是互斥竞争关系。另一种是当多个进程协作完成作业时，这些进程之间由于一些特定因果关系存在一定的先后关系，此时进程间为同步协作关系。我们希望并发运行的多个进程能够有条不紊的运行，如果获取不到需要的资源或者因为因果关系得不到满足需要阻塞自己，在需求可以被满足的时候被唤醒，那么必须引入进程同步机制。信号量机制就是一个可行的卓有成效的进程同步技术。进程之间使用一个公共信号来表示资源的数量。可以理解为将资源抽象为信号量，举个例子当一个进程想要使用某个临界资源时，先在信号量大于 0获取到资源对应的信号量即可以使用资源，获取到信号量后将信号量减一，如果资源数量≤ 0 了其他进程就不可使用了需要被堵塞，这样就确保了互斥访问。进程不直接接触临界资源，而是先获取信号量，再去获取资源，实现了进程同步！ 不过这也需要确保不同进程对信号量的修改是互斥的才有意义，否则我们的信号量也不可靠，对于信号量的操作采取原语指令原子操作，即执行时不可被打断，这样能够保证某个进程在修改信号量的时候其他进程不可以对其进行修改。对信号量的操作P (wait) 操作，获取某个信号量，如果没有需要的资源会自行堵塞直到被 V 操作唤醒。获取的是互斥关系的信号量指锁定了对应的临界资源！获取同步关系的信号量对应着临界资源的数量减少。V (signal) 操作，释放了某种资源即释放了信号量。这也是信号量机制实现的关键部分！在题目解答里面有更具体的说明。题目有A、B两人通过信箱进行辩论，每个人都从自己的信箱中取得对方的问题。将答案和向对方提出的新问题组成一个邮件放入对方的邮箱中。假设A的信箱最多放M个邮件，B的信箱最多放N个邮件。初始时A的信箱中有x个邮件（0<x<M），B的信箱中有y个邮件（0<y<N）。辩论者每取出一个邮件，邮件数减1。A和B两人的操作过程描述如下：cobegin { A { while(True) { 从A的信箱中取出一个邮件; 回答问题并提出一个新问题; 将新邮件放入B的信箱; } } B { while(True) { 从B的信箱中取出一个邮件; 回答问题并提出一个新问题; 将新邮件放入A的信箱; } } } coend当信箱不为空时，辩论者才能从信箱中取邮件，否则等待。当信箱不满时，辩论者才能将新邮件放入信箱，否则等待。请添加必要的信号量和P、V［或wait()、signal()］操作，以实现上述过程的同步。要求写出完整的过程，并说明信号量的含义和初值。解答解决信号量机制涉及到的问题最重要的是两个过程。关系分析，就是找出多个进程间的关系。信号量的设置以及在合适的位置加入对应的操作。关系分析第一步，将 A 和 B 两位同学的动作过程理解为 2 个不同的进程！我们去思考进程之间的关系。第二步，思考：有哪些临界资源？即不能同时使用的资源？A 信箱，B 信箱。第三步，进程之间有无因果同步关系？试着从 A 和 B 的动作中去分析关系。想想 A 和 B 有什么行为（原子行为）。A：从 A 信箱中拿信，阅读，写回信，投信到 B 信箱。这要看就很清楚了，A 拿信和投信显然和 B 存在同步关系，显然阅读和写回信和 B 进程没什么关系，自己可以独立完成，这里就不涉及到同步关系。A 只有在 A 信箱中有信时才可以从 A 信箱中拿信。B 同理。A 写完信，当 B 信箱中有空位时 A 才可以把信投入 B 信箱。B 同理。信号量设置信号量初始化我们给两个互斥竞争资源设置互斥信号量 mutexA = 1 mutexB = 1！只有一个资源，只能一个进程使用！给同步协作关系设置同步信号量，拿信需要的资源是信封量，我们设置为 fullA fullB，根据题意分别设置初值为 x 和 y。投信需要的资源是空位量，根据题意我们设置为 emptyA emptyB，根据题意分别设置初值为 M - x， N - y。适当的位置加入对信号量的操作接下来需要考虑的是在什么时候加入信号量的操作。这里我觉得是最简单的一步。只需要在进程处理过程中遇到对于有同步关系的操作时加入信号量操作即可。比如题目中，从 A 的信箱中取出一个邮件操作：A 的信箱有邮件，wait (fullA) 即可，去邮箱里那涉及到临界资源 A 邮箱， wait (mutexA) ，关键是记得 signal 操作也就是给信号，表示自己释放了某种资源，拿完信后应该有空位出来需要 signal (emptyA)，用完信箱需要 signal (mutexA) 。这里释放信号量也是信号量机制的关键部分。这一步值得注意的地方是先 wait (fullA) 先等有信了，再去锁定邮箱，即 wait (mutexA)。 A { while(True) { 从A的信箱中取出一个邮件; //A的信箱有邮件，则去邮箱里拿邮件 回答问题并提出一个新问题; //不涉及到进程同步关系，A进程自己就可以完成 将新邮件放入B的信箱; // B的邮箱有空位，把邮件放进B的邮箱里 } } 完整答案
来来回回在三个系统上都安装了一遍pytorch，其实很简单，先安装显卡驱动，再安装CUDA，最后安装pytorch就好。pytorch 的安装需要去官网根据配置需要复制对应的下载指令。（推荐 conda 新建一个pytorch环境安装）教程已经写得很好了，就记录一些自己踩的坑吧显卡驱动非 OPEN安装系统recommend 的 open 版本驱动比如我的是 525-open 后，如果检测不到的话，可以换成对应版本的非 open 其驱动。它是 Linux 的当然推荐开源了环境变量配置在自己的终端文件中太蠢了，当时，只知道一步一步跟着教程走。比如我使用的是 zsh 配置文件就应该放在 vim ~/. zshrc 而不是死死的按照教程的 vim ~/.bashrcbash - shopt command not found in .bashrc after shell updation - Stack Overflow验证是否安装成功验证是否启用了 cuda 加速。如果启用了的话运行结果显然应该是 true。import torch torch.cuda.is_available()参考链接Ubuntu搭建Pytorch，就这一篇就够了\_ubuntu pytorch\_Chengyunlai的博客-CSDN博客新装UBUNTU 22.04安装Nvidia GPU版Pytorch完整教程 | GTrush的个人博客
算法题解：给表达式树添加合适的括号
Problems请编写程序将表达式树按中缀表达式输出，并填加必要的括号，要求括号不能冗余，即保证正确运算次序所需的最少括号。如 a∗(b+c) 和 a+(b−c) 中的括号是必要的，而 a+(b∗c) 的括号则是冗余的。假定表达式树中的运算均为二元运算，只涉及加、减、乘、除运算。输入格式:输入为一行字符串，表示带空指针信息的表达式树先根序列，空指针信息用 # 表示，操作数为 a - z 的小写字母，运算符为+、-、$*$、/。输出格式:输出为一行字符串，表示填加必要括号后的中缀表达式。输入样例1:*a##+b##c##输出样例1:a*(b+c)输入样例2:+a##*b##c##输出样例2:a+b*cSolutions先序表达式建树这个 so easy 咯。输出表达式并添加合适的括号法一？思路一只是输出表达式的话中序遍历即可，要加上括号，那么我在中序遍历的时候判断一下，如果当前是运算符并且运算符优先级大于孩子节点优先级的话，就加上括号，很好，想法是可行的，就差代码实现了。这是我当时的代码无修，写法有错误鸭！而且很混乱，该骂！居然还过了一个测试点！void inOrder(node root) { if (root) { inOrder(root->lc); cout << root->data; bool fg = false; if (root->data >= 'a' && root->data <= 'z') { } else { if (root->lc && root->rc) { char lc = root->lc->data; char rc = root->rc->data; char sub; if (!(lc >= 'a' && lc <= 'z')) { sub = lc; } else if (!(rc >= 'a' && rc <= 'z')) { sub = rc; } else { sub = 'a'; } if (sub != 'a') { if (mp[sub] <= mp[root->data]) { fg = true; } } } } if (fg) { cout << "("; } inOrder(root->rc); if (fg) { cout << ")"; } } }思路一代码实现纠正我自己又改了一遍，我觉得这样写是没有问题的。void inOrder(node root) { if (root) { bool fgl = false, fgr = false; if (root->data == '*' || root->data == '/') { if (root->lc && (root->lc->data == '+' || root->lc->data == '-')) { fgl = true; } if (root->rc && (root->rc->data == '+' || root->rc->data == '-')) { fgr = true; } } if (fgl) cout << "("; inOrder(root->lc); if (fgl) cout << ")"; cout << root->data; if (fgr) cout << "("; inOrder(root->rc); if (fgr) cout << ")"; } }换一种有返回值的递归方式这个想法来源于 ChatGPT，对于递归到底应该怎么写我还是不够清楚，或者说在紧急时刻我写不好。==函数的作用即实现题目的要求，实现表达式树的带括号中缀表达式，参数是一棵树，返回值是一个字符串==，我们当然可以假设已经实现了左右子树的带括号序列，再来处理当前节点，在已经有左右字串的基础上加括号就很好理解了。string to_infix(node root) { if (!root) return ""; string str_left = to_infix(root->lc); string str_right = to_infix(root->rc); string value_str(1, root->data); if (root->data == '*' || root->data == '/') { if (root->lc && (root->lc->data == '+' || root->lc->data == '-')) { str_left = "(" + str_left + ")"; } if (root->rc && (root->rc->data == '+' || root->rc->data == '-')) { str_right = "(" + str_right + ")"; } } return str_left + value_str + str_right; }