Skip to main content

NJU PA3(半完结)

前言:时光冉冉,想起pa2.2做完才12月初,居然拖了这么久才做完pa2,主要是中间有很多事,12月做完pa2.2,就花了几乎一个月的时间通过ysyx入学答辩,1月又需要准备期末考试,2月又是HGAME,中间还得抽出时间做NPC相关的,好在3月疯狂赶进度把单周期做出来了,然后把pa2.3也整完了(这两个我只花了一周的时间嘿嘿嘿),主要是中间这段时间我真的成长了不少,特别是RTFSC的水平和debug的能力

PA3.1

pa3.1仿佛很简单的样子,一天学习,另一天写代码就基本写完了

我打算借此博客梳理一下,也就是这个必答题,不过我不会说的那么详细,毕竟我不是南大的学生hhh

yield test实际上就是一个操作系统了,准确来说是操作系统的视角的程序,其中hello-intr程序是其中作为用户视角存在的,首先os进行CTE初始化,看一下汇编

我之所以称其为os,因为他做了某些特权操作,没错就是设置入口地址,对吧,假如是用户程序能随意设置入口地址那不就乱套了,并且设置了回调函数,可以发现里面有架构相关的代码,所以他被做成了这个am函数接口,紧接着调用hello-intr这个用户函数,用户函数没有用任何特权指令,只是最后调用了一个am接口yield来抽象不同架构,rv就是ecall,ecall指令出发一个异常,调用一个异常处理函数isa_raise_intr()函数,该异常处理函数设置mcause等csr寄存器,把pc先保存至mepc,并跳转至之前设置的入口地址,紧接着开始上下文入栈,最后将栈指针传给一个am中的接口函数(也就是把上下文的指针传参),这个函数主要是做一些架构相关的事情,比如通过这整个上下文中的mcause寄存器识别究竟是什么事件导致的异常,总之操作解析上下文的操作是在这个函数做的,为的是架构解耦,将上下文的一些信息包装到event结构体,之后调用回调函数,回调函数就只通过event结构体进行操作,回调函数检测到事件是自陷,于是输出y,最后上下文出栈,mret指令返回mepc+4的位置(注意,这里的mepc的设置应该是操作系统做的,不是mret指令本身的操作,换句话说,就不是硬件为你设置,而是软件设置,具体是需要在am那个函数做的),最后回到用户级程序hello-str

上述并不难理解,我之前有做过CTF pwn相关的学习,所以对我还是不难的,不过,这一套流程又让我整个体系清晰了不少,虽然我没做x86的pa,但是我pwn经常需要和x86打交道,所以合理迁移,在x86程序中,调用syscall指令之后,也是触发异常->跳转至相关入口地址->保存上下文->通过异常号进行功能选择->恢复上下文->跳回来。EZ

PA3.2

loader

如果说os的第一个任务是为用户程序提供自陷操作,那第二个就是有加载程序的能力了

之前写ftrace是解析elf的section header,而这次写loader就是解析elf的program header实现加载

这个的话并不是很难,只是有点麻烦,具体来说就是通过框架代码给你的ramdisk_read函数首先读出EHDR,在ehdr中找到phdr的偏移,再在phdr中解析offset,vaddr,memsz,filesz,把相应offset的地方写入到vaddr,写filesz大小,最后补0,也就是bss段的地址,最后返回e_entry(好像叫这个名字)就是程序入口

syscall

syscall是一种特殊的自陷,需要与EVENT_YIELD区分开来,区分的方式是通过调用ecall时的a7寄存器来设置mcause为EVENT-SYSCALL(异常号),并将ENENT_SYSCALL传给nanos-lite,nanos-lite再根据a7(第一个寄存器)进行系统调用号的识别(系统调用号),也就是其实经过了两次事件分发

简单来说,dummy程序调用syscall实际上经历了,ecall(软件) -> mcause设置(硬件) -> 异常号处理(OS) -> 系统调用号处理(OS)

具体到nemu和nanos来说,就是_syscall_(dummy)->ecall(libos)->isa_raise_intr(nemu)->trap.S(am)->__am_irq_handle(am)->do_event(nanos-lite)->do_syscall(nanos-lite)->yield(am)->…..(返回)->dummy

所以之后的任务只需要在nemu中设置好异常号,nanos对异常号进行第一次事件分发,如果异常号是系统调用则进行第二次事件分发即可,主要是中间多了一层am,可能会增加理解的困难,不过要明白,am也是在操作系统层面上的(因为操作系统就是一个am程序),只是操作系统中那些isa相关的部分和isa不相关的部分进行了解耦,从而才形成了am这一个抽象层,所以nanos不应该直接访问mcause等寄存器,并且对于通用寄存器的访问也必须经过am的接口,从而保证nanos可以在多个架构运行,总之这就是am程序的思想,只要是需要访问硬件就需要经过am的接口,或者说硬件需要访问操作系统(也就是异常),也需要先经过am,过滤掉isa相关的部分

草,之后的write被坑了,因为printf内部会调用malloc的,哎其实我想到的,可是讲义没提我就也有点怀疑自己了,所以你必须先实现后面的再来整这个hello.c

PA3.2总结

小总结一下,hello world程序的真正的运行(不是am程序的hello world),实际上跨越了很多抽象层,粗略来说,就是首先hello.c编译成ELF文件,然后操作系统(就是一个和Mario相似的am程序)被加载到nemu(硬件)的内存中,之后restart pc == 0x80000000,开始从操作系统的第一条指令开始执行(据我了解,真实情况应该是从firmware开始执行再将os加载进来,但是模拟器就不用管固件之类的啦),操作系统首先打印南大的logo😂,然后通过am接口进行device初始化,之后再设置mtvec保证异常处理机制可以正常运行,之后调用之前写的loader,根据ELF的描述信息(Phdr)将ELF加载到nemu的某一块内存,也就是0x83000000,之后程序跳转至用户程序的起始地址(e_entry定义),这个时候就会发现,之后想再回到nanos-lite的相关代码就只能通过中断或者异常了,也就是现在的ecall指令,假如程序始终不调用ecall指令那么nanos-lite的代码就再也不会被运行到,彻底成为一个死程序了(实际上真实的环境还存在时钟中断),但是程序若是不和操作系统交互就只能做一些计算指令,所以程序需要通过一种特殊的异常:syscall来重回操作系统,由于需要保证1,程序上下文不会被破坏 2,操作系统能明白程序的全部上下文,于是异常处理机制就是先在硬件上设置mcause异常号,然后跳转至mtvec(从安全的角度来说,普通程序绝对不会存在能修改mtvec的指令,所以入口地址是唯一的),之后有软件(am的CTE扩展)来先保存上下文(trap.S)并将异常号进行提取并打包,之后进入nanos-lite(其实从一个角度来说,之前的保存上下文的操作就是操作系统了,只是这里为了架构解耦把他看成是两个层次),nanos-lite根据异常号进行第一次事件分发,如果检测到是EVENT_SYSCALL就调用相应的函数进行处理(do_syscall),如果是其他的事件就调用其他的异常处理函数进行处理,之后加入是系统调用异常那就还需要第二次事件分发,也就是在do_syscall函数中通过解析用户程序的上下文(就是之前保存到栈上的context结构体,就是3.1要我们整理的结构体),来分析用户程序需要的究竟是什么系统调用,也就是根据系统调用号(一般在a7寄存器中)进行第二次事件分发,调用相应的系统调用处理函数,并将参数传给这个处理函数,一般是a0,a1,a2,然后返回值保存在a0中(也就是修改栈上的数据,之后pop出来),这个系统调用处理函数才真正叫做操作系统的独有能力(超越了用户程序的能力)(我个人感觉,操作系统的powerful就体现在这里),在不同的系统调用处理函数中,这个am程序(nanos-lite)开始使用am的TRM和IOE扩展接口和硬件进行交互,比如write系统调用处理函数和硬件nemu的串口进行通信(我现在也只做到了这里….也就是用户程序暂时只有TRM的功能)

也就是用户程序想用printf输出hello wrold到屏幕上时,首先进行libc printf的处理(之前看过一点源码,printf在libc中的调用链也是极长的),最后printf通过libc的write进行处理,write则是调用_write,这个函数就不属于libc了,他由操作系统提供,也就是libos,libos将_write再封装,直到最后能以一个完美的上下文进行ecall指令,ecall指令通过异常处理机制进入os内部的sys_write函数(就是传说的系统调用处理函数),他通过putch输出,而putch是HSL(hardware abstraction layer),对硬件的抽象,实际上是在0xa00003f8进行写操作,从而在硬件nemu中调用回调函数(serial_io_handler),最后模拟输出。

如果再简练一点,就是,printf(libc)->…->write(libc)->_write(libos)->ecall(nemu intr)->do_syscall(nanos-lite)->…->printf(libc)

一个完整的hello world就这么呈现在我们面前,我们终于可以很自信地大声说出:“Hello World!”

实际上可以很清楚的猜测出pa3.3的任务了,pa3.1是实现自陷指令,pa3.2是加载一个用户程序,并通过自陷指令实现syscall,那pa3.3就一定是加载多个程序,而这才是操作系统大放异彩的地方

PA3.3

Simple File System

说实话,在写这个之前,我都不知道fs是什么,但是pa给了我很明确的需求导向,再加上讲义的通俗的讲解,还好是写出来了,我写完到这个file-test时对file-system有一些粗浅的理解了,首先是文件在磁盘上的存储方式,nanos-lite就是简单的将各种文件打包起来,其实这所谓的打包就是仅仅将各种不同的文件一个一个放在一起,然后根据打包的文件用wc命令来将各种信息输出至一个.h文件中(余老师太厉害了,这个shell命令反正我是看不懂,更别提会用了🤯),从而将文件在ramdisk的存储方式记录在一张表中(具体来说是一个结构体数组),文件系统不仅包括这个文件记录表,还有对文件的一系列操作,文件系统向上提供接口,内部提供对fd和pathname的转换(这里简单的实现成数组下标和值的转化),接口就包括open啊之类的这些,具体来说syscall需要这些接口,loader也需要这些接口,loader的那个任务就是通过pathname调用fs_open打开特定的ELF文件进行load到内存中

感觉这个文件系统确实很简单,没有目录感觉很奇怪hhh

从之做到这里我对于文件系统的理解就是1.文件记录表(一个结构体数组,记录了文件在磁盘中的位置,大小,名字等等)2.一系列对文件的操作,比如fs_open..,但肯定还有其他复杂的东西

文件系统就像是操作系统中对于磁盘的抽象(向上提供接口)与管理(向下通过驱动程序读取磁盘),所以就会发现除了在fs.c中,操作系统的其他部分都不需要使用ramdisk_read和write函数了

Everything is a file

在写gettimeofday系统调用时gpt给了我一个安全提示,我觉得很有意思,便想记录下来,gettimeofday是通过给user的指针指向的内存传值,也就是说,这中间是内核的数据传输至用户的内存,内核数据和用户数据之间的传输,安全问题的来源一般是对用户过于信任,假如没有对用户给的指针地址进行检测,就会在数据传输的过程中发生意外事件,即使进行了检测,也有可能遭受到攻击,比如时间窗口攻击,或者syscall时向内核传输过多的数据,导致的缓冲区溢出攻击,特权升级攻击,数据篡改攻击,R2U攻击等等,内核安全看来也是一个很好的研究方向啊hhh(😈,

写VGA时遇见了一个小问题,fb_write只有offset和len两个信息,需要通过这两个信息计算出需要写的xy以及wh,xy很好算,用offset就可以,但是只是用一个len却是算不出wh,我的想法就是一行行写入肯定没问题,就是约定在NDL中每次只read 1xw 个字节,但是这里其实我感觉很奇怪,libc这么想方设法减少对syscall的使用,但是这里每写一行就需要一个syscall,假如h是100,就需要100次syscall,如果运行更多渲染的视频,恐怕需要更多的syscall

至于画布居中,只需要一点初中平面几何的知识即可

(logo真帅,可惜不是南大的学生,没有认同感,我之后也要设计我自己的logo,然后做属于我自己的生态!!)

总之将外设抽象成文件,在用户层就可以通过简单的对文件进行的操作来读写设备内容了,也不需要管设备之间的差异,只要能写一个支持的驱动程序然后接入文件系统就可以了,这个驱动程序具体来说必须满足read,write函数调用的接口,也就是将offset,len,buf转化为对设备的特定操作,也就是nanos/src/device.c做的事情了

精彩纷呈的应用程序

定点算术

经过对定点算数半个小时的不懈学习,总算搞懂了一点点,浮点数是由符号,阶码,尾数组成,这个阶码就决定了一个浮点数小数点的位置,但是定点数的小数点固定,例如23.8的定点数通过将小数乘2的8次方来进行放大,超过8位的数就是该数的整数部分,打个比方,1(d)也就是0000 0001(b)将其乘2的8次方就是1 0000 0000(b),0x100(h),若是有小数部分,比如1.2乘2^8就是1 0011 0011,后8位就是相应小数的映射,形象点来说就是将1分为256(2^8)份,每一份是1/256的大小,0.2就占0x33/256的大小,1.2就占0x133/256的大小,虽然感觉依然没有很理解😥,但是基本上就是这么回事吧。

这个问题我一开始脑抽了,这个其实是一个宏,宏的结果会在编译时就求值所以当然不会引入浮点指令

每次这种神奇的小蓝框都让我这个小菜鸡学到了很多😘,LD_REWLOAD是一种trick,设置这个环境变量(或者运行时设置这个变量)为自己写的share lib那么程序就会优先在这个路径中寻找,利用这个trick,可以实现修改库函数的操作,也就是自己写一个符号相同的库函数,在你写的函数中你可以先将这个不存在的路径转化为真实存在的路径,再用dlsym和RTLD_NEXT找到真正库的这个函数,调用这个真正的库函数,实现修改库函数的操作。

sdlmini

这个就是根据手册实现一些sdl的api,要注意看仔细一点

我这个游戏竟然会触发我lseek的检测边界的check,我把这个check注释掉竟然就可以运行了,感觉有点懵

好累💤(趁着这个博客抒发一下,感觉最近真的有些丧失动力了,不知道为什么,我记得就在10天以前开始做pa2.3和单周期NPC的时候很有冲劲,做的也是飞快,中间的一个hgame final一下子打断了我的状态,因为我感觉pwn真的好难,我觉得我什么都不会(相比协会的几位大佬),当我尝试去学习比如一些linux相关知识等等(dbus,systemd什么的),企图去看懂协会大佬们在打的aliyunCTF的文档和wp的时候,我仿佛又回到了高中/初中那段自学黑客技术的那段时间,一是特别吃力,概念不明白,代码不会写(好多陌生的语言,让人难以有去学习的冲动),有一种怀疑人生的感觉,再加上pa3.3感觉突然上来的强度,并且做pa的积极性也被CTF有所分散,之前当我有这种depressed的感觉的时候,我通常会去看jyy的视频,jyy永远可以给你一种力量,告诉你计算机世界没有难的地方,你总能通过RTFM和coding来获得所有所需要的知识,虽然get到了这股power,可是感觉依然动力不足,就像老式汽车一脚油门,仅仅只是可以暂时性的提速,靠着这提速和对跑仙剑的一丝执着,最终好在跑起了仙剑,我想我是否需要休息一下,或许暂时离开这个计算机的世界,我记得我高中就是这么做的,一段时间的修养往往能唤起我对计算机更大的热爱,可是我甚至不知道离开计算机世界我该去向何方,我对玩游戏现在可能兴致也不大,也没有其他的领域让我感兴趣)

太酷了!

Nterm代码阅读

正在上c++课,那么就干一点和c++相关的事吧,其实我根本不会c++

感觉看不太懂,哭了。emmm,他主要是在nterm.h中定义了一个terminal对象,在term.cpp中是对象中方法的实现,builtin-sh中是对shell的实现(解析命令,刷新终端),main.cpp中实现了很多需要sdl的函数并实例化了一个terminal对象。

最主要的两个就是terminal和shell的实现,shell的实现并不难,就是解析命令,在上面包括pa1都实现过了(strtok….),我粗略的看了一下,发现terminal的实现似乎就是对对象中的一些参数进行改变(比如cursor),然后再通过refresh_terminal函数进行刷新到屏幕上

这种共600行的程序读懂大致的思路很简单,但是我发现了解所有的细节对于我来说可能还是一件不容易的事,当然,借口是有的,我不熟悉c++,以及我不懂bdf,不熟悉sdl….,terminal和shell我以后肯定会自己写一个的

PA3总结

终极拷问虽然现在似乎可以回答了,但其实还是不够,原因有二,1,模拟器多少不是真正的硬件,等我做完NPC的B阶段,我觉得我可以站在真正的硬件角度来完善我的回答。2,nanos-lite操作系统还是太弱了,和现代os的工作方式其实还是很有不同的,或许我可以在完成pa4之后完善的我的回答。

回答:

TODO(这是一个大工程啊hhh)