0%

2025OS训练营二阶段记录

2025OS训练营二阶段记录

3.18-3.21

这段时间是在开营前,我在用rcorebook学习,这几天完成了ch1-ch3的学习。

ch1

环境配置:我用的是ubuntu20.04,按照教程来配置还是相当容易的,没有碰到什么问题。

个人基础:我是在寒假接触自制操作系统的,看的一本书叫做《操作系统真象还原》,这本书用c语言和汇编实现了一个x86的32的操作系统。书的内容很多,对于基础盲区也讲解很全,作者从MBR到Bios、bootloader再到用显存来实现print,操作系统的必要组件。所以我学起来还没那么吃力。

riscv我没有接触过,有一些x86的汇编先不系统学习还是可以看得懂的,毕竟rcore没有涉及太多汇编内容。

rust的编译工具安装、编译方便在开始都让我非常惊喜。内核的第一条指令涉及到一个linker.ld,这个文件就是链接脚本,通过分析内容可以很容易理解程序的内存分布,首先是固定的内存入口,其次是 .data .bss .rodata等段的排布很清晰。

简单学习了下gdb调试和远程连接,反编译反汇编工具。

第一次接触rust的外部链接extern “C”,使用声明函数的方式来找到链接脚本确定的内存区域起始符号。

rustsbi这个东西真的很方便,当然也是riscv架构的标准。像打印字符这种函数都实现在里面,要不然需要用汇编来自己实现。对sbi进行封装后很容易就能实现write函数和print宏。

ch2

这一章是写一个批处理系统,系统在运行前就明确了所有要运行的app,这些app都按顺序执行直到最后一道,然后系统panic结束。应用的内存地址都是在运行前要手动计算确定的。riscv架构从大到小分为0,1,2,3四个特权级,还提供了ecall,eret用来切换特权级。这个批处理系统分成了用户态和内核态,ecall就是用来从用户态进入内核态,eret用来返回用户态。ecall属于riscv规定的一种异常,而操作系统属于在riscv的s模式特权级,在用户态ecall就会触发陷入机制进入s模式特权级,用sret来返回u特权级。进入s模式后会调用二进制接口,也称为系统调用。

系统调用的意义在于很多操作交给用户来做非常不安全,系统稳定性难以保证,所以需要划分出内核态用户态,内核根据需求做出安全的系统调用操作。

程序的入口准确来说是_start就是linker里的start符号位置,这里我们不能使用原生架构编译工具,而是使用交叉工具链,所以rust std内容都用不了,main函数也不能完成作为程序入口的作用。在rust声明外部链接符号就要在link操作的宏。

创建系统调用的触发接口,使用内敛汇编调用ecall传入系统调用号和参数,在操作系统就会捕捉异常,判断类型为ecall后根据对应系统调用号即可处理系统调用,完成系统调用后返回用户态。

这里的测试没有实现具体的系统调用,而是用了linux相同的系统调用好在linux环境进行测试,测试正常。

rcore写了build.rs用来构建linker.ld链接脚本,app需要使用路径方式进行导入,每个app的入口地址和执行顺序挂钩。使用的测试用例是在linux系统调用环境下编译的,编译的结果是elf的,需要使用工具来将elf转换成bin二进制文件。在设计上rcore创建了UnSafeCell用来在单核上可以安全使用全局可变借用。

load_app根据固定的标号入口地址来从内存里找到app的代码段,加载到APP_BASE_ADDR,这个地址是个固定值。

1
2
3
4
5
6
批处理操作系统为了建立好应用程序的执行环境,需要在执行应用程序之前进行一些初始化工作,并监控应用程序的执行,具体体现在:
当启动应用程序的时候,需要初始化应用程序的用户态上下文,并能切换到用户态执行应用程序;
当应用程序发起系统调用(即发出 Trap)之后,需要到批处理操作系统中进行处理;
当应用程序执行出错的时候,需要到批处理操作系统中杀死该应用并加载运行下一个应用;
当应用程序执行结束的时候,需要到批处理操作系统中加载运行下一个应用(实际上也是通过系统调用 sys_exit 来实现的)。
这些处理都涉及到特权级切换,因此需要应用程序、操作系统和硬件一起协同,完成特权级切换机制。

系统调用和异常都涉及到了切换特权级,特权级的切换最重要的就是保存上下文。因为不管是trap还是异常都是停止执行当前的程序,转去执行系统调用或者异常处理,这时候就需要保存当前应用的寄存器环境,这个就是上下文,将这些寄存器按固定顺序保存到用户栈,在ret时再逆顺序恢复寄存器。

1
2
3
4
sstatus 的 SPP 字段会被修改为 CPU 当前的特权级(U/S)。
sepc 会被修改为 Trap 处理完成后默认会执行的下一条指令的地址。
scause/stval 分别会被修改成这次 Trap 的原因以及相关的附加信息。
CPU 会跳转到 stvec 所设置的 Trap 处理入口地址,并将当前特权级设置为 S ,然后从Trap 处理入口地址处开始执行。

这些是riscv用到的硬件辅助寄存器。

rcore使用汇编实现了alltrap和restore,用来保存和恢复寄存器,需要注意sp寄存器,sp寄存器的值用到sscratch,这个寄存器用来交换内核栈sp值,还要注意处理当前执行的位置。

traphandler中根据scause的结果,对不同的异常进行处理,包括ecall,runnext等操作。

ch3

这一章要创建一个多道程序处理系统,需要实现分时调用就必须要将应用都加载到内存,这也意味着也要为当前每个应用都分配独立栈空间。所以不能像上节一样都加载到base地址,需要确保应用占用的空间不会重叠。切换任务必然也要保存上下文,与系统调用不同的是,任务切换的操作保存寄存器恢复寄存器都是在用户态。rcore根据sbi实现了gettime,设定计时器后,计时器到时触发异常,traphandler捕捉到后执行任务切换操作。

任务切换最重要的是switch操作,在执行之前已经将下个任务的栈顶地址保存起来,保存当前寄存器环境后,导入下个任务的寄存器环境。yeild操作为计时器中断触发切换任务的系统调用。

3.24-3.25

关于trace的系统调用实现,需要判断request来实现不同功能. 在request为0时:需要将id转换成const u8 再解引用,再转换为isize返回;在request为1时:要将id转换成mut u8,再将data转换成u8,复制给id转换后指针的解引用,返回0;

当request为2时: 想要获取每个任务的某个系统调用次数,并且考虑到批处理系统会进行任务切换,所以在切换到其他任务时也要保存着其他刮起的人物的调用次数,所以我就在syscall/mod.rs中实现了个全局变量SYSCALL_COUNT,类型为Mutex<BTreeMap<usize, BTreeMap<usize, usize>>>,Mutex包裹是因为他是全局变量(rust的要求? 按理说批处理单进程用不到锁,但是我编译没过就加上了.),外层BTree key为app_id,value为内层BTree,内层BTree为(sys_id, count).这样就可以通过app_id来得到所有系统调用的次数.syscall_count方法在syscall里用来计数,insert_syscall_count为当前任务初始化记录,已有记录则返回,delete_syscall_count在进程退出时删除记录,get_syscall_count就用来在sys_trace里获取当前任务的系统调用次数.

3.25-3.26

在sys_get_time和sys_trace实现最主要的是将虚拟地址转换成物理地址,因为内核不能访问应用的虚拟地址.sys_get_time首先获取应用虚拟页表,将ts转换成虚拟地址,记录偏移,通过页表转换后可以得到对应的物理页表项,将表项加上偏移就得到了对应ts的物理地址,然后转换成mut指针,将获取的timeval复制给该地址.判断跨页是要在ts地址上加上timeval长度得到截至地址,与start%Pagesize后判断是否相等,不相等则要处理跨页,还是计算出物理地址分别在对应部分拷贝该部分的数据.

sys_trace相比上次只需要处理0,1的情况,要想办法通过虚拟地址读取或写入,同样转换为物理地址就可以将其转换成指针.在获取物理页表项时要注意判断页表项的有效性 可读性 可写性.

mmap就是要为应用程序申请一块动态区域,这个就要在memset上处理,本来我是准备将内存加入areas的,但是在写unmap时判断vpn来删除有些困难,就考虑重新为mmap的内存重新建立映射结构.处理时就是要几个点注意,1:判断port是否全0或者无效位有非0, 2:判断start是否页表对齐,没有对齐就返回, 3:PTEFlags不仅要加上port的标志,还需要有效位V和用户位U, 4:计算虚拟页表起始地址,然后对每个页循环遍历, 5: 判断虚拟页表有效性,有效则返回(mapped), 6: 申请物理页建立映射.

unmap要判断虚拟页表无效性,然后循环遍历每个vpn在虚拟页表中被unmap,并移除mmap_set对应项.

3.26-3.28

关于spawn,相比fork就是不要复制父进程的内存布局,将传进来的字符串指针转换成string,通过这个名字获取应用elf_data,后面就对TCB进行初始化,和TCB::new一样,注意三点,父进程设置为当前进程;将该进程加入父进程的child列表;将该进程加入运行队列.

关于stride,我定义了两个常量BASE_STRIDE=91,BIG_STRIDE=99991.在TCBinner里加入两个量:pass=0,prio=1,封装updatepass:

pass += (BASE_STRIDE / prio) % BIG_STRIDE

留出相关接口.再到run_task调用updatepass, 在fatch里对ready_queue取出最小pass的TCB.

3.28-3.30

关于link,与linux的ln命令类似,对文件创建链接相当于在操作系统层面增加个INode,但是磁盘里共用统一片块,那相当于拥有相同的inode编号,我们先通过原文件名称获取编号,已知所有文件都在根目录。找到编号后就可以得到块位置id和offset,在创建inode时需要。接下来修改目录项,需要先扩展目录项的存储区域,后写入目录项。返回一个新的inode。

关于unlink,通过name找到根节点后计算目录项数目,循环读取根节点每个文件对比名称,找到index后,把末尾的目录项换到index处,减小目录大小。

stat就连两个项需要获取,ino就是blockid,nlink需要通过块位置来对目录里所有文件的块位置进行比较,相同计数加1.在返回时我和gettime的处理一样,将虚拟地址转换成物理地址后再转换成可变指针,将stat结构体指针放进去。

3.30-4.3

这节还挺费劲的。刚开始看了很久没发现是银行家算法,不知道如何来抽象这些资源。

首先就是清楚是要以每个进程为单位来进行资源管理,所以在pcb inner里添加了死锁检测模块。模块数据一维为线程tid,二维为资源rid,模块需要实现几个功能,isunsafe就是银行家算法用来查看当前状态是否安全,tryallocate在检查unsafe同时通过tid和rid处理减小available和need,增加allocation;setneed需要在设置资源前调用,在分配失败时会保留,在分配成功会减小。release在up时增加available和减小allocation;检测开关只需要设置enabled。在创建线程和创建资源时需要更新数据结构,在添加一个线程时会为二维结构添加资源数的全为0的向量,在添加mutex时avail则push 1,sem则push 初始化值。在down时首先判断是否打开了检测,打开了就首先setneed,然后判断unsafe如果不安全则直接返回dead,安全则尝试分配(会失败),然后调用down;up时判断检测打开之后就调用release,release判断了当前分配数是否为0。