OS实验
OS实验
OS采用MIPS32的相关补充
MIPS Calling Convention
一个栈帧一般由5个部分组成:
- 用于本地数据存储的部分,函数会为此区域内所有局部变量保留足够存储空间,包括要在函数调用前后保留的任何临时寄存器值的空间
- (Pad,空白填充)作用是为了确保栈帧总大小是8字节的倍数,确保上方局部数据存储部分是双字对齐,与64位体系结构的双字读取指令兼容。不一定存在,需要根据具体情况给出分配。一般来说是一个字,因为假若原本的字的个数是偶数,则字节数一定能整除8(一个字4个字节的话);若为奇数,只需要Pad填充一个字即可得到偶数字,因此至多只需要一个Pad。
- 返回地址部分,即存储
$ra
值的部分。只有在函数内部需要调用其他函数的时候才需要。 - 保留寄存器部分,保存当前子函数被调用时,当前子函数需要保存的寄存器(如:$S0 到 $s7)的值,在当前函数中就可以随意使用这些寄存器了,注意:在当前子函数返回前需要将相应寄存器的值给返回回去。这样一来父函数认为完全没有发生改变。
- 参数部分,父函数调用子函数时会将子函数需要的参数传入此处,实现对函数调用时的传参。注意:由于$a0-$a3只要保留栈帧空间,即参数部分的4个字的大小,也就是只要有传参,那么就一定会有4个字空间作为参数部分,参数少于4多余的闲置,参数多于4则根据需求扩展参数部分。
这5个部分(如果同时存在)则就按当前顺序排列。
此外并不是所有函数的栈帧都需要这5个部分,若不需要则直接省略。
下列三种函数,以及相应的例子:
简单叶函数:不调用任何其他子函数,不用栈帧,不改sp属于是一股清流了。
有局部数据的叶函数:有栈空间的叶函数(不需要、不调用其他函数),栈帧可用于本地数据或寄存器值的保存。
非叶函数:函数内调用其他函数,基本上包含栈帧的所有5个部分,Pad不一定,因为可以本身就是8的倍数了。
例:
简单叶函数:
1 |
|
有局部数据或涉及到寄存器值的改变的叶函数:
1 |
|
a complicated nonleaf function
1 |
|
栈帧如下图:
此处的例子及图片均来自于Microsoft Word - MIPS Calling Conventions Summary.doc.
MIPS Directives & Macro
.align
使下面紧跟着的数据进行地址对齐,
例如:
1 |
|
.globl
、.extern
.globl
:将符号定义为具有对其他模块可见的全局符号
.extern
:要对另一个模块中的全局符号的引用(即对外部符号的引用)
Tips: 所有对标签引用都会被自动认为是在引用全局符号,因此在引用另一模块的全局标签时,没有加
.extern
的必要,但如果引用另一模块中的全局变量,需要添加。注意:是.globl而不是.global
.set
设置汇编器的工作方式。一般,汇编器尝试通过重新排列指令来填充分支指令和存取指令造成的空闲时间。
.set noreorder
和.set reorder
:告知汇编器是否重新对指令进行顺序进行排序,前者需手动填充延迟槽,后者下汇编器会自动调度指令至延迟槽。
.set at
和.set noat
:前者,$at
为汇编器保留用于实现扩展指令,后者则不使用。
LEAF
、NESTED
、END
三个宏
LEAF
1 |
|
NESTED
1 |
|
END
1 |
|
C语言拾遗
从源代码到可执行文件
总述
对C语言而言,从源代码到可执行文件大致要经过4个步骤:预处理->编译->汇编->链接。每个阶段都调用独立的程序。
于C程序和GNU编译工具而言,预处理阶段会以源文件为输入,调用cpp生成预处理文件,编译阶段会以预处理后的文件为输入,调用cc1生成汇编文件,汇编阶段以汇编文件为输入,调用as生成目标文件,最后以一个或多个目标文件为输入,调用Id生成可执行文件。
gcc仅是一个总的调度程序,调用这些真正执行处理的程序,将这些程序封装起来提供一个较简明的操作界面。
在编译时可以用--verbose
让gcc打印出详细的编译信息。
预处理
1 |
|
选项-E
使gcc只进行预处理。
预处理主要在处理源码中的以#
开始的预处理指令,主要处理规则是:
- 删除所有的
#define
,展开相应的宏 - 处理所有条件编译指令,如
#if
,#ifdef
等 - 处理
#include
预处理指令,将被包含的文件内容插入到该预处理指令的位置
编译
1 |
|
仅从预处理文件生成汇编代码
汇编
1 |
|
汇编码转为机器码,生成目标文件。
链接
将多个目标文件的代码段放在一起、数据段放在一起,和写好的库函数(如scanf
和printf
)一起组成可执行文件。
C语言中变量的存储类别
由关键字(auto
、static
、extern
等)和变量声明位置(函数内或外)指定变量的存储类别。
存储期
其用于描述一个对象在内存中的生命周期,注意这里的对象与OO中的不一样,指的是连续的一片内存空间,有起始地址与大小两个属性,相对应的还有标识符的概念,也即用于作为变量名的字符串,可以经由标识符来访问或修改对象。
四种存储期:静态存储期、自动、动态分配(malloc)、线程~
- 函数内用
static
定义的或函数外定义的变量有静态存储期,它所对应的对象始终不变,值不初始化则为0.全局变量都有静态存储期。 - 函数内不用
static
定义的变量有自动存储期属性。程序执行到该变量声明的时候会创建变量对应的对象,在执行到该变量作用域结束时会释放掉该对象,随后该对象所占据的内存空间可做他用。在C中可用auto
显示指出变量有自动存储期,它与cpp中的auto
不同,cpp中是自动类型推导。
作用域
块作用域
块是{}括起来的代码区,在块内定义或者函数中的参数都具有块作用域。
文件作用域
函数外定义,一般全局变量拥有。
链接
表示变量是否可在别的文件(非定义变量文件)中使用。
内部链接
只可在定义的文件中使用,例如:使用static
声明的全局变量。
外部链接
可在所有文件中使用,全局变量(不被static
定义的)默认外部链接。
无链接
就是没链接属性,如:函数中定义的变量。与内部链接不同的是无法将其转化为外部或内部链接。而内外部链接可以相互转化。
如何在文件中使用另一个文件中具有外部链接属性的变量或函数呢?
答案是在本文件中对该变量或函数先采用引用式声明即用extern关键字修饰,然后再使用即可。
gcc foo.c main.c
可以被拆为编译(gcc -c foo.C main.c
)和链接(ld foo.o main.o
),前者生成带有.o
后缀的目标文件,后者链接两个目标文件,生成名为a.out
的可执行文件。
定义式和引用式声明
前者会创建对象,而后者不会,对于后者编译器会假设变量的定义再别处,一般以是否使用extern
关键字修饰做区分。
对于引用式声明的变量,在编译其所处于的程序时,编译器会假定变量的定义在别处,在生成的.o
文件中会存储地址尚未确定的信息,在链接时由链接器去寻找这些变量可能被定义的地方并且只承认具有外部链接属性的同名变量,static
修饰的免谈了。
函数的存储类别
主要体现在链接属性,外部或内部,同变量。
预处理指令
宏定义:#define
变量式、函数式(较复杂)。但其实本质上都是简单的文本替换。
函数式宏
但正因为如此,函数式宏定义的参数无类型,不做参数检查,若发生类型匹配错误,会在编译期报错而非预处理期。
tips:定义函数式宏定义时要勤加括号!!!
尽量避免宏参数中还带有运算符,由于简单的文本替换,运算结果会重复多次,导致最终答案出现偏差。
在MOS内核代码中,函数式宏定义写成如下形式:
1 |
|
用宏参数创建字符串:#
运算符
#
作为一个预处理运算符,用于创建字符串,如:
1 |
|
可以看到 #
运算符会自动把传入的参数用双引号括起来成为一个字符串,并且实参中的连续多个空格会被替换成一个空格。
例:
1 |
|
预处理器粘合剂:##
运算符
将前后两个预处理符号连接成一个,#
只限于函数式,但##
两者均可。
例:
1 |
|
因为宏只能做到文本替换,因此这里替换后可以是常数粘合常数成一个数字,也可以是粘合成一个变量名或函数名等,一般不能粘合成一个字符串之类的,因为引号具有一定的特殊性。
变参宏:...
(注意这里的三个点不是中文下的省略号,而应该是英文下句号的三次重复)和__VA_ARGS__
函数式宏还可带可变参数,格式如下:
1 |
|
扩展语法,若##
运算符用在__VA_ARGS__
前面,除了起到连接作用外,当__VA_ARGS__
为空参时,##
会把其前面的,
吃掉。这一特性常用来封装一些打印函数来进行内核调式,例如:
1 |
|
#undef
#undef <macro>
用于取消对macro
的定义,若之前并没有定义则忽略掉,否相取消之且之后可以对取消的赋新值。
注意:若宏以头文件的方式引入,则
#define
语句在文件中的位置取决于#include
指令的位置。
文件包含: #include
把被包含文件全部内容替换到#include
处。
预处理器会通过以下规则在文件系统中寻找该文件:
- 如果文件名在尖括号中,那么预处理器会在标准包含目录中查找该文件,在 Linux 系统中这个目录通常默认为
/usr/include
,我们最常用的stdio.h
就在这里。在使用 gcc 编译源代码时,可以使用-I dir
参数将dir
加入标准包含目录。 - 对于用引号包含的头文件,预处理器会首先查找包含头文件的
.c
文件所在的目录,然后查找标准包含目录。
条件预处理指令
#ifndef
与#endif
:
示例:
1 |
|
其实ifndef
就是if not defined
,也就是假若之前并没有定义过_STDIO_H
这个宏,在ifndef
和endif
之间就会定义这个宏,否则就不会进行重定义。
上述代码出现在头文件<stdio.h>的开头,为的是避免重复包含头文件的内容,避免重复定义,这叫做Header Guard。
其他的还有#ifdef
、#else
、#if
、#elif
、#error
等是一样的用法。
typedef
区别于#define
,它是给类型一个别名,在编译阶段完成,具有作用域,命名风格是以_t
结尾,如integer_t
或ptr_t
此外,typedef
定义的别名在声明中不能和unsigned
等其他类型说明符一同出现。
GDB
GDB:GNU Debugger,适用于C、C++、Go、Rust等语言的调试器。
GDB的安装
可以先通过$gdb -v
查看当前Linux环境是否有安装,否则可以通过下列命令完成安装:
1 |
|
加载程序
以一个简单程序为例:
1 |
|
通过如下命令,可以得到名为add
的可执行文件:
1 |
|
但这种方法产生的目标程序会去掉很多运行时不需要的信息,使得无法进行调试,加上-g
选项,将保留许多额外的信息给调试器使用。
tips:不启用和启用
-g
选项编译的两种产物,分别是目标程序的Release版和Debug版。难怪github上都是release版。
1 |
|
此后就可以让GDB大展手脚了。兄弟,GDB有两种方式可以让你大开眼界。
直接在命令中指定,进入GDB交互界面:
$ gdb add
.直接运行GDB进入交互界面,通过其的
file <executable>
指令加载可执行文件。1
2$ gdb
(gdb) file add
当出现下列内容,说明加载成功:
Reading symbols from adds...
之后敲下$ (gdb) run
并且等到光标一直在跳动,就可以进行程序的标准输入,检查输出了。这里的gdb大小括号,是指终端自带,不信你运行以下试试。
输入quit
就可以关闭gdb啦!!!
传入参数
以echo
程序为例:
1 |
|
根据仿造程序处的指令得到echo
程序的Debugger版的可执行文件。不过由于再运行echo
程序时要传入参数,此时gdb仍然是有两种方法:
对应于命令
./echo 'hello, world!'
(如果这里不用单引号括起来,那么一连串的输入不带空格的也是可以的,否则会报错,被认为不是字符串),gdb命令为:$ gdb --args ./echo 'hello, world!'
在gdb交互界面处设置参数:
1
2
3$ gdb
(gdb) file ./echo
(gdb) set args 'hello, world!'上述操作后,再
$ (gdb) run
就可以运行了。tips:事实上
set args [arg]...
和run
两指令可以合为一条run [arg]...
一步一步来:追踪程序运行
以计算斐波那契数列程序为例:
1 |
|
编译后使用gdb执行可执行文件。
进入gdb后,使用start
指令会使程序停在main
函数起始处,会得到如下的内容:
1 |
|
类似于run
,start
指令也可加参数:start [arg]...
想要一步步执行程序,需要使用step
指令,如还需执行下一步,不用重复输入step
,只需按回车即可。
此外,在函数内部跳出当前函数的指令是finish
,相当于步出指令。以及用于执行当前所在行,跳过当前行中的任何函数调用的指令是next
。
用continue
指令是继续程序的正常运行,程序会继续运行直至退出。
用kill
指令可杀死程序进程。
上述指令其实就对应了编译器编译时候的那些按键:
设置断点
普通断点
还是以斐波那契数列为例:
1 |
|
为了定位到我们想要的代码的位置,可以使用list [position]
指令查看源代码,其使用形式共有5种:list
(显示当前运行位置的源代码)、list <line number>
、list <file>:<line number>
、list <function name>
和 list <file>:<function name>
,使用一次 list
之后也可以再通过回车查看源代码的后续内容.
通过list
指令得知相应代码在第几行后,可以使用break
指令来设置断点,类似于list
指令break
也有5种使用形式。
比如此处(gdb) break 28
在28行打断点。
之后使用run 10
运行可执行文件,由于断点的存在会在28行停下来。
之后就可以像之前调试一样使用指令去调试,但是由于断点会一直存在(就像人的成见一样),因此,即使输入continue
让程序自执行,也会在经过一次循环后又在断点处重新停下。
要避免断点处暂停或者误打断点,可以删除或暂时禁用断点。
操作如下:先用info breakpoints
查看断点位置,其中信息明细分别是断点编号(Num)、断点类型(Type)、临时断点还是永久断点(Disp)、当前状态(启用还是禁用)(Enb)、位置(Add)、断电当前状态(What,作用的行号、已命中次数等)
1 |
|
若不需要这个断点,可直接删除,使用delete breakpoints [num]...
指令即可。
这里的num是断点的编号哟,就是断点信息的Num那一列。
临时断点
由于start
指令本质上是在main
函数处添加一个断点,并运行程序,但运行start
指令后得到的断点信息却与普通的并不相同:
1 |
|
因为这里是临时断点,不是普通断点,程序只暂停运行一次后就删除了。
设置临时断点的指令是tbreak
,用法和break
一致。
因此
start
指令等价于tbreak main
和run [arg]...
两条指令。
条件断点
满足一点条件才中断程序运行的断点,相应的指令是break [position] if <condition>
,其中[position]
部分与break
指令的一致,而**<condition>
则是一个在断点上下文符合语义的表达式**,例如,在a
、b
都被定义的前提下,a >= b + 1
就可以作为<condition>
。
触类旁通一下,条件临时断点就是
tbreak [position] if <condition>
。
使用condition <num> <condition>
指令可以修改断点条件,可将普通断点变为条件断点
Tips:假若出现了遍历链表或使用迭代器这类没有显式计数的循环,应该如何设置”在第K此循环暂停”的断点呢?可以的,兄弟,可以的,可以用
ignore <num> <times>
指令,其可以表示忽略某一断点前<times>
次的访问,同时也可用上临时断点,相应指令为:tbreak <position>
和ignore <num> <K-1>
.
太多断点怎么办呢?总不能一个一个delete吧,使用clear <position>
即可删除某一位置的所有断点。
观察点
为了在设计规模较大、涉及大量内存操作的程序,指定一个表达式不用指定位置即可,gdb会持续监视其值,发生变化时,程序就停止,因此观察点本质上是设定了条件而没设定位置的特殊条件断点,其条件就是表达式的取值发生变化,有点像verilog里的monitor?,设置观察点的指令为watch <expression>
,<expression>
是当前上下文中合法的表达式
同理,观察点也可以用disable breakpoints
(禁用指定断点)、
enable breakpoints(启用指定断点)
、delete breakpoints
等指令。
此外,gdb中还有读观察点和访问观察点,前者使用指令rwatch <expression>
,当程序中出现读取目标表达式的值的操作,就停止程序运行;后者使用指令awatch <expression>
,当程序中出现读取或写入目标表达式的值的操作时停止,使用方式与观察点类似。
观察点常用于调试内存问题,此时表达式常与指针或地址有关。
一些例子:
1 |
|
上述代码中,mysterious_func函数调用free
释放了pptr
,指向的内存,却并没有将其置NULL。
导致程序之后的if
判断成立下的语句访问了野指针,编译与执行可以通过,只是取得了一个无意义的数据,但在操作系统中发生的话,就会产生内核错误等严重问题。设定观察点就可以找到异常值的位置。
此外,还有可能遇到的是数组越界的问题:
1 |
|
上述代码在i
为9是,++i变成10访问arr[10]
发生数组越界。因此我们只要监视程序何时访问了给定数组之后的地址即可,因此使用访问观察点awatch
,在本例中也就是awatch arr[10]
。
查看运行时数据
除了之前介绍的程序流debug,当然还有数据流这个重要的方式。
backtrace
:用于查看到目前位置的函数调用信息。
以上文的斐波那契数列的例子为例:
1 |
|
利用上述命令到达fibo
函数调用最深处。
然后利用backtrace
即可获得所有调用栈信息:
1 |
|
不过这些信息只有函数的参数信息,我们是否束手无策了?,不会的,兄弟,不会的,可以通过print <expression>
指令完成对于符合上下文的特定表达式的值打印。
由于C中并不记录数组长度,因此有特定的语法来进行对相应值的输出。以
main
函数中的argv
参数为例,获取数组中指定范围内的值需要使用形如arr[<index>]@<len>
的语法,表示显示数组中从<index>
开始长度为<len>
的范围内各元素的值。例如:(gdb) print argv[0]@2
为了获取其他参数,可以先通过up [steps]
、down [steps]
和frame [level]
来移动到栈的不同位置,前两者主要控制在栈帧间移动,[steps]
是移动的次数。
Tips:这里的
up
是向调用者方向走,down
则向被调用者移动,也许是因为程序栈空间向低地址增长,被调用者的栈帧在调用者”之下”导致的。
而frame [level]
指令是直接根据层次跳转到指定的栈帧,这里的层次指的是backtrace
指令输出中位于各栈帧左侧的标号,也就是#号右边的数字。
经历过CO的你一定知道,指令也是数据。因此在gdb中可以将代码的机器码转换成的汇编码显示出来。
使用disassemble [position]
反汇编,具体形式有:disassemble
、disassemble <function>
、disassemble address
、disassemble <start>,<end>
等。
1 |
|
注意:左侧出现的箭头表示PC寄存器的位置,或者是函数返回的地址。
修改运行时程序(高级)
通过上文的大量debug方式定位了bug之后,是否能不再退出gdb去源代码修改,而立马修改?当然可以,用set variable
指令即可,使用set variable <variable> = <expression>
指令,注意set
后跟的variable
是语法要求,而再之后的<variable>
是相应要修改的变量名。例如:(gdb) set variable dangerous_global = 5
除此之外,假若定位的时候,已经去执行了错误的分支,那该怎么办呢?要删号了?兄弟,别慌,我还有return [expression]
指令没说呢!无论你现在身处函数何处,使用它都可以让你立即返回,并且将返回值设置为[expression]
的结果,简直是及时止损神器。
这时候你或许又有烦恼了,bur,剩下的程序都没给我执行,怎么可能返回正确的值?别慌,兄弟,还有招。
jump <position>
指令,position
可以是行号或指令地址(嘻嘻,别忘了我们还可以反汇编啊!!!),jump
指令可允许随意跳转到任意代码处。
Tips:jump后并不会暂停,而会自动运行,除非提前打断点。
你或许会似曾相识,这这这不就goto
吗,效果大致一样,但是jump
更加强大,因为它可以直接跳到不同函数。
指令缩写
哈哈,没想到吧!
敲了那么多breakpoints
,一个b
就可以了!!!
指令 | 缩写 | 用途 |
---|---|---|
file <executable> |
加载程序 | |
run |
r |
运行程序 |
set args [arg]... |
设置程序的参数 | |
run [arg]... |
r |
以设定的参数运行程序 |
quit |
q |
退出 GDB |
start |
启动程序并使其停在 main 函数起始处 |
|
start [arg]... |
以设定的参数启动程序并使其停在 main 函数起始处 |
|
step |
s |
执行下一步程序,包括进入函数 |
finish |
fin |
跳出当前函数 |
next |
n |
执行单行程序,不进入函数 |
continue |
c |
继续执行函数 |
kill |
k |
杀死程序进程 |
list [position] |
l |
显示指定位置源代码 |
break [position] |
b |
在指定位置设置断点 |
info breakpoints |
i b |
显示所有断点信息 |
disable breakpoints [num]... |
disable b |
禁用指定断点 |
enable breakpoints [num]... |
enable b |
启用指定断点 |
delete breakpoints [num]... |
d |
删除指定断点 |
tbreak [position] |
tb |
在指定位置设置临时断点 |
break [position] if <condition> |
b..if |
在指定位置设置条件断点 |
tbreak [position] if <condition> |
tb...if |
在指定位置设置条件临时断点 |
condition <num> <condition> |
cond |
修改断点的条件 |
ignore <num> <times> |
ig |
忽略断点前指定次数的访问 |
clear <position> |
cl |
删除指定位置的所有断点 |
watch <expression> |
wa |
为指定表达式设置观察点 |
rwatch <expression> |
rw |
为指定表达式设置读观察点 |
awatch <expression> |
aw |
为指定表达式设置访问观察点 |
backtrace |
bt |
查看调用栈信息 |
up [steps] |
向调用者方向移动栈帧 | |
down [steps] |
向被调用者方向移动栈帧 | |
frame [level] |
f |
移动到指定的栈帧 |
print <expression> |
p |
查看表达式的值 |
disassemble [position] |
disas |
查看对应位置的汇编码 |
set variable <variable> = <expression> |
set var |
修改变量的值 |
return [expression] |
ret |
立即从函数中以指定的返回值返回 |
jump <position> |
j |
无条件跳转到程序的指定位置 |
TUI
由于上机只能使用CLI,但是不能对着源代码调试的CLI着实令人头大,刚好存在一款只需要文本环境即可显示的TUI界面,而GDB刚好也提供这种界面,只要在运行时加上参数-tui
即可,用GDB TUI调试fibo
只要用gdb -tui ./fibo
即可。
注意,在实验中可以在开启GDB
后使用layout asm
命令指定以asm
(也就是汇编程序的形式)的TUI
方式进行调试,用来展现当前运行所处位置 (si
命令是汇编程序下的单步运行
)。而使用layout src
可以在源代码上进行调试
QEMU模拟器
QEMU(Quick Emulator):一个通用的开源的机器仿真和虚拟化工具,可提供跨体系结构的硬件模拟,支持x86、ARM、MIPS等。
此处主要介绍QEMU的系统仿真模式,其能够模拟处理器的执行过程及各种硬件设备的行为,因而提供包括处理器、内存和外设在内的整机虚拟模型。
工作原理
虚拟化技术
硬件虚拟化,隐藏真实的物理硬件,由软件模拟出特定的硬件环境,此过程中产生的硬件环境是虚拟机,实现虚拟化的程序是虚拟机管理程序,这本质上是一种中间件。
借此可以屏蔽硬件差异,实现单台物理机上运行许多不同的操作系统环境,同时其产生的硬件环境易于在不同设备间迁移,利于系统管理和维护。
第一类虚拟机管理程序
直接运行在硬件之上,虚拟机管理程序占据类似操作系统的位置,如下图(a),主要用在企业数据中心或服务器中使用,常见产品有KVM、VMWare ESXi等。
第二类虚拟机管理程序
运行在操作系统上,是 操作系统中的应用程序,其中称运行该虚拟机管理程序的操作系统所处的机器是宿主机,而管理程序中的虚拟机为客户机,如下图(b),因为其采取了软件模拟处理器、解释执行机器码的方式,因此也被称为模拟器。常在个人计算机上使用,以便能运行虚拟机同时执行其他进程,常见的产品包括 VMware Workstation、Oracle VirtualBox 等等,其中也包括 QEMU。
QEMU
与其他第二类虚拟机不同的是,其实现的是指令集架构级别的虚拟化,因此其能在一台机器上运行不同体系架构的程序。
QEMU采用的是动态二进制翻译机制,一种中间代码为中介,将客户机架构的机器码运行时翻译为宿主机架构的机器码形式,并将翻译结果交由宿主机的处理器直接执行,性能不错,被称为TCG(Tiny Code Generator)虚拟化加速方式。
此外,QEMU还可使用其他虚拟机管理程序加速,最常使用的是KVM(Kernel Virtual Machine),一个Linux内核驱动模块,采用了硬件辅助的虚拟化技术,能使Linux主机成为一个虚拟机管理程序,这两者配合QEMU负责I/O虚拟化,KVM负责处理器和内存虚拟化,相得益彰。
图片来自 Andrew S. Tanenbaum - Modern Operating Systems (4th Edition)
使用
以在QEMU中执行MIPS裸机环境下的Hello,world
程序来了解内核程序在QEMU的运行。
裸机结构如下:
1 |
|
先是链接脚本bare.lds
文件,其用于定义程序使用的内存分布,编译阶段需要用到。
1 |
|
因为QEMU并不是完全裸机环境,程序实际上由QEMU内置的Bootloader加载而来的,Bootloader也已定义了处理器使用的虚拟地址空间(如此处的0x80000000),若不使用链接脚本而直接编译,将使用与Bootloader不兼容的地址空间,会导致报错。
然后是start.S
文件,一个MIPS汇编文件,包含了裸机程序入口_start
:
1 |
|
最后是含有裸机程序实现逻辑的minimal_hello_world.c 文件:
1 |
|
之后便可以编译文件了,只需使用交叉编译器mips-linux-gnu-gcc
执行如下命令:
1 |
|
此处若换行输入的话
\
是必须的,不然一个Enter
下去,终端就当命令吃下去了,其实也可以全部写在一行,但是之间需要通过空格间隔。
编译产生的目标程序为hello_world.elf
。接着用QEMU运行。
简要介绍一下QEMU命令都有啥?所有的QEMU命令都为
qemu-*
的形式。而对于某一体系架构下的模拟,需使用qemu-system-*
命令。例如对于小端序的mips
架构,命令就为qemu-system-mipsel
,此外还提供了其他的一些命令行工具,如qemu-img
是用于创建、转换和修改磁盘镜像的命令。
使用qemu-system-mipsel
运行我们的目标文件,运行如下命令,可得到Hello, world!
输出:
1 |
|
QEMU中GDB调试
首先为了支持GDB,编译的时候需要加上-g
也就是把Release版改成Debug版:
1 |
|
接着在原有的运行命令基础上还需要加入-s
、-S
两个选项,前者用于等待GDB连接到1234端口,而后者用于让模拟器不要一开始就启动处理器。
没想到吧!GDB和QEMU靠远程连接完成协作,这种远程调试的方式不同与平常,但能让我们调试位于远程主机上的程序,适应不同的调试情况(如现在)。
启动QEMU,为了在同一个终端中运行这两个玩意,QEMU需在后台运行,因此必须将其标准输入重定向为/dev/null
。
1 |
|
此时使用ps
命令可以查看当前终端下运行的进程,即可看到刚启动的QEMU。
1 |
|
由于实验环境是x86架构,但调试的程序是mips架构,而调试程序应当像编译程序或模拟程序一样,使用特定架构的编译器和模拟器。
这就不得不提到gdb
的多架构版本gdb-multiarch
,不同于交叉编译器,gdb-multiarch
一次性提供了对很多架构的支持,包括mips(使用gdb-multiarch
和set architecture
命令可以看到其支持的架构有200多种)。
gdb-multiarch
用法与gdb
相同。
现运行gdb-multiarch hello_world.elf
进入调试环境,别忘了远程调试这回事吼,这行命令运行后让还未连接到QEMU的1234端口,需再使用target remote <address>
指令,比如此时就是:(gdb) target remote localhost:1234
,这样之后就成功进入了QEMU运行环境中。
其实
gdb
命令提供了-ex <command>
选项来在启动GDB后执行指定的指令,因此可使用gdb-multiarch hello_world.elf -ex “target remote localhost:1234”
.
与上文的纯GDB不同的是,在QEMU调试时不能使用run
或start
,其余调试手段同纯GDB。
这里需要提醒的是,退出前请记得一定要使用kill
指令杀死QEMU进程,若直接退出,仅是GDB进程同QEMU进程分离,这时QEMU进程还会继续运行,且持续占用1234端口,导致无法调试新的QEMU进程。
就算退出前没杀死,也莫慌,还有机会哟!!!
使用
ps -ef | grep qemu
查找所有QEMU进程,例:
1
2
3$ ps -ef | grep qemu
yourname 717 246 0 13:43 pts/3 00:00:00 qemu-system-mipsel -s -S -m 64 -nographic -M malta -no-reboot -kernel hello_world.elf
yourname 1118 246 0 13:49 pts/3 00:00:00 grep --color=auto qemu这里的第二列是进程的标识符(PID),因此此时只要手动结束相应进程(使用
kill -9 <pid>
)即可。使用
kill -9 717
即可杀死QEMU进程,当然这样可能还是过于麻烦,因此究极短小命令:pkill -9 qemu
可以将查找QEMU进程的PID的操作和发送SIGKILL
信号的操作结合起来,此时只需要一条指令即可。
kill
命令用于向<pid>
指定的进程发送信号,-9
指的是发送第9号信号,使用kill -l
可以查看所有信号的名称与编号。
kernel、boot和printf
kernel(编译获取OS内核文件)
MOS顶层Makefile
1 |
|
你或许会有疑问:明明并没有定义MAKE
、CC
等变量,怎么就引用了呢?难道是外部”库”在发挥作用?是的,代码首行的include include.mk
引用了外部”库”:
1 |
|
不过MAKE
是Makefile
中预定义的变量,并不在这里定义。
ELF——操作系统内核的本质(解析内核文件)
源代码文件需要经过编译和链接两个阶段,才可得到可执行文件。其中,由于编译后完整程序的地址布局并未确定,需要在链接阶段,由链接器将所有目标文件连接在一起,并填写具体地址等信息,形成可执行文件。
但是,链接器怎么知道具体位置的?其实是依靠目标文件中代码各个段的具体信息来进行链接的。ELF(Executable and Linkable Format)正是Unix
系统上常用的一种目标文件格式,此外可执行文件和库的文件格式都是ELF。.o
文件就是ELF
包含的三种文件类型的一种,称为可重定位文件,此外的两种分别为可执行文件和共享对象文件,这两者都要求链接器对.o
文件进行处理才可生成。
Tips:可以通过
file
命令来获取文件的类型。例如:
1
2git@23371355:~ $ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
ELF
主要分5部分:
1.ELF
头,含有程序基本信息,如体系结构和OS,同时包含节头表和段头表相对文件的偏移量。
2.段头表(程序头表),包含程序各段信息,需在运行时使用。
3.节头表,包含程序各节信息,需在编译和链接时使用。
4.段头表中每一个表项,记录该段数据载入内存时目标位置等用于指导应用程序加载的各类信息。
5.节头表中每个表项,记录该节数据程序代码段、数据段等各段内容,记录的是该节数据的文件内偏移和地址信息等,主要在链接时使用。
其实,段头表和节头表指向的地方一样,因此,它们只是程序数据的两种视图?(双重人格):
1.使用节头表来组成.o
文件,参与可执行文件和可共享文件的链接。
2.段头表来组成可执行或可共享文件,运行时为加载器提供信息。
ELF
文件头代码:
1 |
|
通过阅读相关代码可知,ELF
文件头实际上存了关于ELF
文件信息的结构体,其中存储的魔数是有效ELF
的通行证,是用来判断文件类型的唯一依据。此外,其中的节头表入口偏移e-shoff
是用来定位节头表第一项的地址的,用ELF
的文件头地址加上入口偏移得到的就是其第一项的地址。
有一种随编译工具链提供的工具readelf
用法为readelf [option(s)] <elf-file(s)>
,来解析ELF
文件信息。可以通过readelf --help
来了解其功能。
通过readelf -l xxx
来解析ELF
文件的内容:
1 |
|
其中只需关心,Offset
:该段数据相对ELF
文件的偏移;VirtAddr
:该段最终需要被加载到内存的哪个位置(最为重要,是QEMU
模拟器加载内核时锚定的将地址,其正确性是内核代码、数据等能加载到相应位置的前提所在);FileSiz
:该段数据在文件中的长度;MenSiz
:该段数据在内存中所应当占的大小。 Section to Segment mapping
表明每个段各自含有的节。
MemSiz
永远>=FileSiz
,假如前者大于后者,则OS在加载程序是会先将文件中记录的数据加载到对应得VirtAddr
处,此后,向内存填0直至该段在内存中大小达到MemSiz
。那什么时候前者会大于后者呢?在C语言中未初始化得全局变量,OS需为其分配内存,但其却不需要被初始化成特定数据,因此,在可执行文件中只记录其要占用内存,即只记了MemSiz
,文件中却没数据(FileSiz),这样一来,前者就大于后者了,这也是C语言中全局变量有默认值0的原因,即OS在加载时将这些全局变量占用内存都填0处理了。
MIPS内存布局——内核运行的正确位置(加载内核至正确的位置)
地址
与处理器发往总线的访存地址往往不同的是,程序使用的地址是虚拟地址、程序地址或逻辑地址,但前者的地址是物理地址。直接的转换通过MMU。对32位处理器而言,虚拟地址空间一般为4GB(2^32^B)。
在MIPS体系结构中,虚拟地址空间被分为4个大区域:
其中:
1.kuseg 0x0000000-0x7fffffff(2GB)
:用户态下唯一可用地址空间(内核态也可用),即MIPS约定用户内存空间,需经过MMU中的TLB进行虚实地址转换,这段地址存区都要经过cache
。
2.kseg0 0x80000000-0x9fffffff(512MB)
:内核态下可用,MMU将虚地址最高位清零(& 0x7fffffff)得到物理地址来访存,即其被连续映射到物理地址的低512MB空间,对其的存取经过cache
。
3.kseg1 0xA0000000-0xBfffffff(512MB)
:类似kseg0
,其在内核态下可用,MMU将虚地址高三位清零(& 0x1fffffff)得实地址访存,同样也被映射到实地址低512MB,但对其存取不经过cache
,往往使用MMIO技术来在这段地址上访问外设。
4.kseg2 0xC0000000-0xffffffff(1GB)
:只在内核态下使用且需MMU中TLB将虚地址转为实地址,对其的存取经过cache
。
由于TLB需要OS来管理,因此载入内核时,不能选需要TLB转换的虚地址空间。则内核只能放在kseg0
或kseg1
中。而后者不经过cache
,一般来说,利用MMIO访问外设时才使用kseg1
,因此将内核的.text
、.data
、.bss
段放在前者,将bootloader
放在后者,这样一来载入内存前在kseg1
中的bootloader
会进行cache
初始化工作,使得kseg0
可以进行存取。
需要注意的是,kuseg
、kseg0
、kseg1
、kseg2
位于不同的虚拟地址空间,但都映射到同一个物理地址空间,不同的只在于映射方式和访问权限。
内存布局大致如下,其中KERNBASE
是内核镜像起始地址:
1 |
|
Linker Script——控制内核加载地址
为了解决不同平台的ABI不一致带来的链接器通用性差的问题,Linker Script
诞生了。其记录了各个节应如何映射到段,即各个段应被加载到的位置。
采用ld --verbose
可以查看默认的链接脚本。
此处补充一下ELF文件中节的概念,链接过程中,目标文件被看作是节的集合,并用节头表来描述各个节的组织,即节记录了链接过程中所需的必要信息。
.text
(保存可执行文件的操作指令,包含了可执行文件中的代码)、.data
(保存已初始化全局变量和静态变量)、.bss
(保存未初始化全局变量和静态变量,包含未初始化的全局变量和静态变量)三个节最为重要。
一个通过Linker Script
调整各节位置的例子:(下列代码编写在.lds
文件中)
1 |
|
从零开始搭建MOS
从make开始
主要就是make
到底make
了啥?
_start函数
start.S
函数代码如下:
1 |
|
printk函数
由于C语言的标准库是建立在OS之上的,而失去了标准库的C语言将寸步难行,因此,在开发OS的过程中需要所有需要的东西都得自行编写。
下列三个文件:kern/printk.c
(实现了printk
,但实则只是将输出字符的函数和接受的输出参数传递给vprintfmt
函数),lib/print.c
(实现了vprintfmt
函数,实现格式化输出)和kern/machine.c
(复负责往QEMU控制台输出字符,实则读写某个特殊内存地址)三个文件将实现从内核将信息输出到控制台。
先看看kern/printk.c
内部的printk
函数:
1 |
|
你或许会疑惑了这va_list
、va_start
、va_end
都是些啥啊?或许你在python
里用过拥有变长参数的函数:
1 |
|
那么比python
还早诞生的C语言当然也有此类概念。
简而言之,当函数参数列表末尾有省略号(...
,是英文的三个.哈,可不是中文的…),该函数就拥有了变长的参数表(其实就是支持了不定数参数传入,确实方便不少)。
这里为了定位变长参数表的起始位置,函数需有至少一个固定参数,且变长参数必须在参数表末尾。
在stdarg.h
头文件中为处理变长参数表定义了一组宏和变量类型:
1.va_list
,变长参数表的变量类型
2.va_start(va_list ap, lastarg)
,用于初始化变长参数表的宏
3.va_arg(va_list ap, 类型)
,用于取变长参数表下一个参数的宏
4.va_end(va_list ap)
结束使用变长参数表的宏
在拥有变长参数表的函数中使用变长参数表前,要先声明一个类型为va_list
的变量ap
,然后用va_start
进行一次初始化:
1 |
|
然后还是在kern/printk.c
中,来看看outputk()
函数:
1 |
|
printcharc()
函数在kern/machine.c
中可以一探究竟:
1 |
|
最后就是实际实现printk
功能的在lib/print.c
的void vprintfmt(fmt_callback_t out, void * data, const char * fmt, va_list ap)
函数,其本质上就是通过解析fmt
格式字符串进行格式化输出,输出格式串为%[flags][width][length]<specifier>
的情况。
此处的vprintfmt
是一个公共链接库函数,用来解析格式化字符串,通过调用回调函数out
完成输出,如此一来,就实现了解析逻辑和输出逻辑的解耦(OO还在追我),提升了代码的可维护性。这样一来vprintfmt()
上层的printk()
等函数可以通过传入不同的回调函数实现不同的输出行为。此外,data
参数是回调函数out
需要的额外上下文信息,是需要被vprintfmt
函数按原样传入out
,可为输出的目标内存地址。
内存管理
MOS设计中,两级页表是作为一种内核数据结构放置于内存中,用户程序需访问虚地址时(经由中断机制),内核负责将对应页表填入TLB。
实现lab2后,一次CPU访存将变为:
4Kc访存流程
4Kc是实验中所用的CPU(还是32位MIPS微系统)
CPU发出地址
CPU运行时通过访存指令发出访存请求,进行内存读写操作。
但在实际程序中,访存、跳转等指令及用于取指的PC寄存器中的访存目标地址都是虚拟地址,我们常见常新的C语言程序中也常通过指针解引用进行访存,这里指针的值被视为虚拟地址,编译后生成相应的访存地址。
虚地址映射到实地址
4Kc上,软件访存的虚地址会先被MMU硬件映射到实地址,再用实地址来访问内存或外设,相应的映射与寻址规则参考之前的内存布局:
虚拟地址 | 转换成实地址方式 | 存放内容 |
---|---|---|
0x80000000~0x9fffffff (kseg0) | 将虚地址最高位置0得到实地址,需要通过cache访存 | 内核代码与数据 |
0xa0000000~0xbfffffff (kseg1) | 将虚地址最高3位置0得实地址,不通过cache访存 | 可用于访问外设 |
0x00000000~0x7fffffff (kuseg) | 需通过TLB转换成实地址,再经过cache访存 | 用户程序代码与数据 |
4Kc中,使用MMU完成上述映射,其通过硬件TLB来完成,但TLB需由软件进行填写,即OS内核负责维护TLB中数据。所有对低2GB空间(kuseg)的内存访存操作都要经过TLB。
内核程序启动
此前介绍的内核程序启动函数mips_init(u_int argc, char **argv, char **penv, u_int ram_low_size)
(这些参数由bootloader传递给内核:bootloader加载内核时在跳转到入口前,会按MIPS ABI调用约定将参数填入a0-a3寄存器供内核使用,其中的ram_low_size制定了硬件可用内存大小)需要分别调用三个函数:
mips_detect_memory(u_int _memsize)
:探测硬件可用内存,并对和内存管理相关的变量进行初始化。”开机”后,OS先会探测硬件可用内存,但其实bootloader传入的ram_low_size就是直接可用的系统可用物理内存大小。其中需初始化变量有:memsize
(总物理内存对应字节数)、npage
(总物理页数)。mips_vm_init()
,为内存管理机制做准备,建立一些用于管理数据结构,OS探测完可用内存后,开始建立内存管理机制(这将用到alloc
函数,其功能就是用来在建立页式内存管理前来分配内存空间的)。其使用1
2
3
4
5
6
7
8
9void mips_vm_init() {
/* Allocate proper size of physical memory for global array `pages`,
* for physical memory management. Then, map virtual address `UPAGES` to
* physical address `pages` allocated before. For consideration of alignment,
* you should round up the memory size before map. */
pages = (struct Page *)alloc(npage * sizeof(struct Page), PAGE_SIZE, 1);
printk("to memory %x for struct Pages.\n", freemem);
printk("pmap.c:\t mips vm init success\n");
}alloc
函数为Page
结构体按页分配物理内存。page_init()
:在kern/pamp.c
中,初始化pages
数组中的Page
(分页机制里的页?)结构体及空闲链表。alloc
函数(跳板机版本)如下:有意思的是,指导书给的版本有亿点小不同,首先是函数声明处,指导书还有1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31void *alloc(u_int n, u_int align, int clear) {
extern char end[];
u_long alloced_mem;
/* Initialize `freemem` if this is the first time. The first virtual address that the
* linker did *not* assign to any kernel code or global variables. */
if (freemem == 0) {
freemem = (u_long)end; // end
}
/* Step 1: Round up `freemem` up to be aligned properly */
freemem = ROUND(freemem, align);
/* Step 2: Save current value of `freemem` as allocated chunk. */
alloced_mem = freemem;
/* Step 3: Increase `freemem` to record allocation. */
freemem = freemem + n;
// Panic if we're out of memory.
panic_on(PADDR(freemem) >= memsize);
/* Step 4: Clear allocated chunk if parameter `clear` is set. */
if (clear) {
memset((void *)alloced_mem, 0, n);
}
/* Step 5: return allocated chunk. */
return (void *)alloced_mem;
}
/* End of Key Code "alloc" */static
修饰。此外,在Step 3之后的panic_on()处,指导书给的是如下一个条件判断:处于好奇我去找了一下跳板机上给的这个1
2
3if(PADDR(freemem) >= memsize) {
panic("out of memory");
}panic_on
函数,发现其在./include/printK.h
中:其中1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17void _panic(const char *, int, const char *, const char *, ...)
#ifdef MOS_HANG_ON_PANIC
__attribute__((noreturn))
#endif
;
#define panic(...) _panic(__FILE__, __LINE__, __func__, __VA_ARGS__)
#define panic_on(expr) \
do { \
int _r = (expr); \
if (_r != 0) { \
panic("'" #expr "' returned %d", _r); \
} \
} while (0)
#endif /* _printk_h_ */panic_on
是预处理包装的一个条件判断”函数”,当传入的值转化为int
类型后不为0,就会触发panic()
另一个宏定义的”函数”,进而将其中的参数以变长参数形式触发_panic(__FILE__, __LINE__, __func__, __VA_ARGS__)
函数,此处只有该函数的声明,具体的定义在./kern/panic.c
函数中:这里的1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51void _panic(const char *file, int line, const char *func, const char *fmt, ...) {
u_long sp, ra, badva, sr, cause, epc;
asm("move %0, $29" : "=r"(sp) :);
asm("move %0, $31" : "=r"(ra) :);
asm("mfc0 %0, $8" : "=r"(badva) :);
asm("mfc0 %0, $12" : "=r"(sr) :);
asm("mfc0 %0, $13" : "=r"(cause) :);
asm("mfc0 %0, $14" : "=r"(epc) :);
printk("panic at %s:%d (%s): ", file, line, func);
va_list ap;
va_start(ap, fmt);
vprintfmt(outputk, NULL, fmt, ap);
va_end(ap);
printk("\n"
"ra: %08x sp: %08x Status: %08x\n"
"Cause: %08x EPC: %08x BadVA: %08x\n",
ra, sp, sr, cause, epc, badva);
#if !defined(LAB) || LAB >= 3
extern struct Env envs[];
extern struct Env *curenv;
extern struct Pde *cur_pgdir;
if ((u_long)curenv >= KERNBASE) {
printk("curenv: %x (id = 0x%x, off = %d)\n", curenv, curenv->env_id,
curenv - envs);
} else if (curenv) {
printk("curenv: %x (invalid)\n", curenv);
} else {
printk("curenv: NULL\n");
}
if ((u_long)cur_pgdir >= KERNBASE) {
printk("cur_pgdir: %x\n", cur_pgdir);
} else if (cur_pgdir) {
printk("cur_pgdir: %x (invalid)\n", cur_pgdir);
} else {
printk("cur_pgdir: NULL\n", cur_pgdir);
}
#endif
#ifdef MOS_HANG_ON_PANIC
while (1) {
}
#else
halt();
#endif
}alloc
函数是分配n字节的空间并返回初始的虚拟地址,同时按align
字节对齐(保证align可整除初始虚拟地址),若clear
为真,对应内存空间值清零。但是此时并未建立内存管理机制,内核又是怎么操作内存呢?
还记得内存布局时提到的kseg0
的作用吗?——其存放了内核的.text
、.data
、.bss
段,OS通过可以访问(虚地址最高位清零得到的就是对应实地址)该段地址直接操作物理内存,进而逐步建立内存管理机制。
接下来逐行解析alloc
函数:
extern char end[]
:一个定义在文件外的变量,在kernel.lds
中:1
2. = 0x80400000;
end = . ;由于其在
kseg0
段,因此其对应的实地址为0x400000
,该实地址前,放着OS内核代码和定义的全局变量或数组。此后从该实地址开始分配物理内存,来建立管理内存的数据结构。u_long alloced_mem
存放”已分配物理内存空间的首地址“的变量。接下来的条件判断中
freemem
是一个全局变量,初始值为0,第一次调用该函数时将其赋值为end。
其表明小于其对应的物理内存都被分配完了。(不包含,也就是从当前值开始的物理内存空间是free的)freemem = ROUND(freemem, align)
,其中ROUND(a,n)是一个定义在include/types.h
的宏,将返回((a/n)(向上取整)*n),也就是将a按n向上对齐,其中n为2的非负整数次幂,align也是非负整数次幂。也就是找到freemem上最小的、按align对齐的初始虚拟地址,中间未用到的地址空间全放掉。与
ROUND
相对的还有将a按n向下对齐的ROUNDDOWN(a,n)
alloced_mem = freemem
是其前者赋值为”将要分配的存储空间的起始地址”。freemem = freemem + n
,就是代表空闲的物理内存地址要从此后长度n后的再开始,便于下一次的分配空闲空间。if(PADDR(freemem) >= maxpa) {...}
中的PADDR(x)
是一个返回虚拟地址x的实地址的宏,其要求x
一定为kseg0
中的虚地址,此段代码就是检查分配的空间是否超出最大物理地址。与之相对的还有
KADDR(x)
返回实地址对应的在kseg0
上的虚地址。if(clear) {...}
:为真清空此部分内存最后返回初始化的虚地址。
物理内存管理
MOS中内存管理采用页式管理,使用链表法管理空闲物理页框。
链表宏
定义在include/queue.h
的链表宏实现了双向链表功能:
- LIST_HEAD(name, type),创建一个名为
name
链表的头部结构体,包含一个指向type类型结构体的可指向链表首个元素的指针。 - LIST_ENTRY(type),作为一个特殊的类型出现,例:
LIST_ENTRY(Page) a;
,其本质是一个包括**指向下一个元素的指针le_next
和指向前一个元素链表项le_next
的指针le_prev
**的链表项,作为指针的指针,le_prev
便利于当删除一个元素时更改前一个元素链表项的le_next
。 - LIST_EMPTY(head),判断head是否为空。
- LIST_FIRST(head),返回head对应链表的首个元素
- LIST_INIT(head),初始化head
- LIST_NEXT(elm, field),返回指针elm指向的元素在对应链表中的下一个元素的指针,定义如下:
#define LIST_NEXT(elm, field) ((elm)->field.le_next)
,因此elm
指向的结构体包含一个名为field
的字段,该字段存储该节点的le_next
和le_prev
的值,类型是一个链表项LIST_ENTRY(type)
,此后的field
同义。 - LIST_INSERT_AFTER(listelm, elm, field),将
elm
插到已有元素listelm
后 - LIST_INSERT_BEFORE(listelm, elm, field),插到前
- LIST_INSERT_HEAD(head, elm, field),插到head对应的链表头部
- LIST_REMOVE(elm, field),删除elm
页控制块
MOS中维护npage
个页控制块即Page
结构体,每一块一一对应一页物理内存。Page
与对应物理页面地址转换可用include/pmap.h
中的page2pa
和pa2page
两个函数。
空闲物理页对应的Page
结构体全插入一个链表中,其为空闲链表(page_free_list)。
一个进程需要内存时,需将空闲链表头部的页控制块对应的那一页物理内存分配出去,并将该页控制块从空闲块中删去。
一页物理内存被使用完毕时(即引用次数为0),将其对应页控制块重新插入空闲链表头部。
1 |
|
页式内存管理中,一个物理页可被多个虚拟页映射,以达到内存共享的目的。
譬如在fork
过程中,子进程的代码段可与父进程共享以节省内存空间,此时pp_ref
会增加,而COW
页在复制时,会通过unmap
函数减少pp_ref
来实现系统及时回收内存。
其余重要函数
在pmap.h
中还有四个以page_
开头的函数:
1 |
|
page_init()
做了:- 利用链表相关宏初始化
page_free_list
来存储”未被使用”页控制块。 - 将已被OS使用的空间的地址标记进行页对齐。因为后续分配内存时都以页为单位,因此物理地址需按页对齐,也就是分配页时分配一整个页的,并不因为没有用满而将剩下的分配给别的进程。
- 给已用空间对应所有物理页面的页控制块的引用次数全置1,可通过已建立的页控制块数组访问。
- 将剩下物理页面引用次数置0,并将其对应页控制块插入
page_free_list
中。
- 利用链表相关宏初始化
page_alloc(struct Page **pp)
将page_free_list
空闲链表头部页控制块对应的物理页分配出去,将其从空闲链表中移除,并清空数据,再将pp
指向的空间赋值为这个页控制块的地址(指针的指针)。page_decref(struct Page *pp)
将pp对应页控制块(Page)引用(pp_ref)减1,若减到0则调用page_free
重置为空闲页面。当且仅当用户程序主动放弃某页、用户程序执行完退出回收所有页面时,会调用该函数。page_free(struct Page * pp)
将pp指向的页控制块重新插入page_free_list
中。
虚拟内存管理
MOS中通过两级页表结构对位于kuseg
的虚拟地址进行地址转换。
两级页表结构
第一级表为页目录(Page Directory)、第二级为页表(Page Table),下用PD
代一级页表,PT
代二级页表。由于多级的存在,虚拟地址的虚拟页号分为了一二级两部分,比如:对32位,若一个页的大小为4KB,则11-0位为页内偏移,21-12位为二级页表项偏移,31-22位为一级偏移。include/mmu.h
中定义了快速获取相应位的宏:PDX(va)
(获取虚地址va的31-22位)和PTX(va)
(获取21-12).
MIPS 4Kc发出地址均为虚地址,程序只能通过映射来访问实地址,对页表操作时硬件处于内核态,因此使用KADDR
可获得位于kseg0
中虚地址。
MOS中,哪级页表结构都一致,不过每个页表项记录的物理页号含义不同一般来说一级页表项记录的是对应二级页表基地址的物理页号,而二级页表项记录的是真实的物理页号。每个页表有1024个32位地页表项,其中都有20位物理页号和12位标志位,12位标志位包含6位硬件标志位和6位软件~。前者用于存入EntryLo寄存器,给硬件使用。而后者不会存入TLB
中,只给软件使用(如PTE_COW
和PTE_LIBRARY
不对应任何硬标位,仅在页表部分操作中被OS使用)。当页表项通过EntryLo
存入TLB
时,会右移6位,移出软标位,仅填入前26位。每个页表占4KB,与一个物理页大小相同。
由于一个页表项可由一个32位整型表示,因此使用Pde
和Pte
表示一二级页表项类型,都是u_long
,因此,对于一个Pde *
类型指针pgdir
,pgdir + i
即可得到相对于一级页表基地址i个页表项的页表项地址。tlb_invalidate
函数用来删除特定虚地址的映射,每当页表被修改就要调用该函数保证下次访问相应虚地址时一定触发TLB重填,保证访存的正确性。
在MOS中没有页面替换一说,当所有物理页被分配完,还有新的物理页分配请求时会直接报错。
与页表相关函数
先介绍一下前面提到的页表项的12位标志位中较为常用的:
PTE_V
,有效位,为1说明页表项有效。PTE_D
,可写位,为1说明允许经由页表项对物理页进行写操作。PTE_G
,全局位,为1则TLB仅由虚页号匹配表项,不匹配ASID
。PTE_C_CACHEABLE
,可缓存位,配置对应页面访问属性为可缓存。PTE_COW
,写时复制位。PTE_LIBRARY
,共享页面位。
二级页表检索函数
int pgdir_walk(Pde *pgdir, u_long va, int create, Pte **ppte)
,将一级页表基地址pgdir
对应的两级页表结构中的va
虚地址所在的二级页表项的指针存储在ppte
指向空间上。若create!=0
且对应二级页表不存在,则会使用page_alloc
分配一页物理内存用于存放二级页表,满了直接报错。
增加地址映射函数
int page_insert(Pde *pgdir, u_int asid, struct Page * pp, u_long va, u_int perm)
将一级页表基地址pgdir
对应的两级页表结构中虚地址va
映射到页控制块pp
对应的物理页,并将权限设置为perm
寻找映射的物理地址函数
struct Page * page_lookup(Pde * pgdir, u_long va, Pte **ppte)
,返回一级页表基地址pgdir
对应两级页表结构中虚地址va
映射的物理页面的页控制块,同时将ptte
指向的空间设为对应的二级页表项地址。
取消地址映射函数
void page_remove(Pde * pgdir, u_int asid, u_long va)
删除一级页表基地址pgdir
对应两级页表结构中va
对于物理地址的映射,若存在,则相应物理页面引用次数自减一。
访问内存与TLB重填
TLB相关前置知识
先介绍4Kc中与内存管理相关的CP0寄存器:
序号 | 名称 | 用途 |
---|---|---|
8 | BadVaddr | 保存引发地址异常的虚地址 |
10、2、3 | EntryHi、EntryLo0、EntryLo1 | 所有读写TLB操作都经过这三个寄存器 |
0 | Index | TLB读写相关需要用到 |
1 | Random | 随机填写TLB表项时需要用到 |
每个TLB
由一组Key
和两组Data
组成。
EntryHi、EntryLo0、EntryLo1都为CP0寄存器,分别对应TLB的Key和两组Data,但不是TLB本身。
其中EntryLo0、EntryLo1有完全相同的位结构,前者存储Key对应偶页,后者存储奇页。
4Kc中奇偶页的设计,即用
EntryHi
中的VPN高19位和ASID作为Key,一次找到两个Data(一对相邻页面的两个页表项),并用VPN中的最低1位在两个Data中选择命中结果。因此对TLB维护(无效化、重填)时,除维护目标页面,还要维护目标页面的相邻页面。
相应结构如下:
- EntryHi
- VPN:Virtual Page Number
TLB缺失(CPU发虚地址,TLB查找对应物理地址未查到)时,EntryHi中VPN自动(由硬件)填充为对应虚地址虚页号。
检索或填充TLB表项时,由软件将VPN段填充为对应虚地址。 - ASID:Address Space IDentifier
区分不同地址空间的标识,由于同一虚地址在不同地址空间中通常映射到不同物理地址,因此查找TLB表项时,除了要提供 VPN还有ASID。
- VPN:Virtual Page Number
- EntryLo
- PFN:Physical Frame Number
软件通过填写PFN,再使用TLB写指令,才可将此时EntryHi中的Key与EntryLo中的Data写入TLB。 - C:Cache Coherency Attributes
标识页面的可缓冲性,使用。PTE_C_CACHEABLE 与
PTE_C_UNCACHEABLE宏来填充。 - D:Dirty
可写位,为1时可写,否则将任何对其的写操作都将触发异常。 - V:Valid
有效位 - G:Global
为1,则CPU发出的虚地址只用与表项的VPN匹配,这样即可说明匹配成功,不用检查ASID是否匹配。TLB组成,4Kc中,一个完整的TLB由
Joint TLB(JTLB)
、Instruction micro TLB(ITLB)
和Data micro TLB(DTLB)
组成,其中JTLB
为TLB
主要单元,而后两者由硬件维护其与JTLB
之间的一致性。
TLB相关指令
- PFN:Physical Frame Number
tlbr
:以Index
寄存器中值为索引,读出TLB中对应表项到EtryHi
与EntryLo1
、EntryLo0
中。tlbwi
,以Index
寄存器值为索引,将EtryHi
与EntryLo1
、EntryLo0
值写入索引指定TLB
表项中。tlbwr
,将EtryHi
与EntryLo1
、EntryLo0
的数据随机写到一个TLB
表项中(使用Random
进行随机指定表项)tlbp
,根据EtryHi
中的(VPN
+ASID
),查找TLB
中与之对应的表项,并将其索引存入Index
寄存器中,若未找到,则Index最高位置1。
软件操作TLB流程通常为先填写CP0寄存器再使用TLB相关指令。
TLB维护流程
实验使用的MPIS 4Kc
的MMU
中只有TLB
,在用户地址空间访存时,虚地址到物理地址的转换均通过TLB
进行。先用VPN
和ASID
在TLB
中查询该虚地址对应的物理页号,若存在对应的TLB
表项(包括G
为0,虚页号和AISD
都相同以及G
为1,虚页号相同的两种情况)时,得到物理地址;若查不到,产生TLB Miss
异常,跳转到异常处理程序,在相应物理地址处,重填TLB
。
由于若TLB
中暂存了页表中某一虚地址对应的页表项内容,之后OS更新了页表项,但没更新TLB
,会导致可能访问到错误的物理页面,所以需要维护TLB:1.更新页表占虚地址对应页表项同时将旧表项无效化;2.下次访问该虚地址时硬件触发TLB重填异常,由OS重填TLB。
TLB旧表项无效化
通过tlb_invalidate()
函数删除特定虚地址在TLB
中的旧表项,其主要依靠在kern/tlb_asm.S
中的tlb_out
函数,tlb_out
被调用时,a0
寄存器中存放传入的参数,值为旧表项的Key
(VPN
+ASID
),使用mtc0
将Key
写入EntryHi
,再用tlbp
根据EntryHi
中Key
查找相应旧表项,将其索引存入Index
,索引值>=0,向EtryHi
与EntryLo1
、EntryLo0
中写入0,之后使用tlbwi
指令,将EtryHi
与EntryLo1
、EntryLo0
的值写入索引指定表项。此时旧表项key
和Data
清零,已无效。
TLB重填
由do_tlb_refill
函数完成,因为4Kc中有奇偶页设计,其需要重填触发异常的页面及邻居页面。重填时先将两个页面对应页表项写入EntryLo
寄存器,再填入TLB
。
大体流程如下:
- 从
BadVAddr
中取出引出缺失的虚地址。 - 从
EntryHi
的0-7位取出当前进程的ASID
。 - 先在栈上为返回地址、待填入TLB页表项及函数参数传递预留空间。以存储奇偶页表项地址、触发异常虚地址和
ASID
为参数,调用_do_tlb_refill
函数,其完成了依据虚地址和ASID查找页表并将对应奇偶页表项写回第一个参数指定地址。 - 页表项存入
EntryLo0和EntryLo1
,执行tlbwr
将EtryHi
与EntryLo1
、EntryLo0
随机写入TLB一个页表项中。
当page_llokup()
函数在页表中找不到对应表项时,可通过passive_alloc()
函数进行处理。若虚地址合法,可为其申请二级页表物理页面(page_alloc
),并将虚地址映射到物理页面(page_insert)上。
进程与异常
进程
每个进程都是一个实体,有地址空间,包括代码段、数据段和堆栈。程序是无生命实体,只由OS赋予生命,由进程来执行。
进程控制块
进程控制块(PCB):用于管理进程数据结构,来描述进程变化过程,是系统感知进程存在的唯一标志,与进程一一对应。
PCB由一个Env
结构体实现:
1 |
|
采用调度队列env_sched_list
和空闲队列env_free_list
来管理PCB,前者便于管理已分配PCB和对进程调度,采用的是类似的TAILQ
结构。后者用来存储空闲的PCB,采用的是lab2的LIST
结构。
段地址映射
env_init
函数最后,使用page_alloc
函数为模板页表base_pgdir
分配一页物理内存,并将其转换为虚地址,用map_segment
函数在该页表中将内核数组pages
和envs
映射到用户空间的UPAGES
和UENVS
处,供用户程序读取。
段地址映射函数void map_segment(Pde *pgdir, u_int asid, u_long pa, u_long va, u_long size, u_int perm)
在一级页表基地址pgdir
对应得两级页表结构中做段地址映射,将虚段地址[va, va + size)映射为物理段地址[pa, pa + size),由于是按页映射,则size
必须为页大小整数倍,相关页表项权限位置为perm
。
进程的标识
用Env
结构体的env_id
域来标识进程,包括进程虚拟地址空间。此外还有一个env_asid
域记录进程的ASID
,进程虚拟地址空间的标识,那么这两者有何区别呢?简单的来说,后者是为了解决原来切换页表时全部无效化TLB页表项带来的频繁TLB Miss问题,便于TLB标识虚拟地址空间。而前者是进程的总标识,只在进程完全结束后才被释放供其他进程使用,而后者是需要等到进程被销毁或者其对应TLB被清空时,其ASID就可被分配给其他进程,两者的释放时机略有不同,可以理解为ASID就是为了实现切换页表TLB不全清空而生。
设置进程控制块
利用env_free_list
创建进程:
- 从
env_free_list
中申请一个空闲PCB。 - 手动初始化PCB。
- 为新进程初始化页目录。
- 将该PCB从空闲链表中去除,用于使用。
其中手动初始化PCB,用到了MPIS 4Kc
中Status
寄存器的几个重要的位:
- IE位表示中断是否开启,1开启,否则不开启。
- EXL位和UM位代表了CPU当前的运行状态,只有EXL为0且UM为1时,CPU处于用户态,其他情况下均处于内核态。异常发生时,EXL位置1,由异常处理程序处理后续。当
eret
指令执行时,EXL位自动置0。如下代码在每个进程每一次被调度时都会执行:
1
2>RESTORE_ALL #一个用于完成对寄存器状态恢复的宏,TF中记录的Status寄存器值将会在其中被写入CPU的Status寄存器中。
>eret
加载二进制镜像
要正确加载一个ELF文件到内存中,只需将其中所有需要加载的程序段加载到相应虚地址上。相关解析ELF
文件代码大部分内容函数和类型声明如下:
1 |
|
创建进程
这是指在OS内核初始化时直接创建,而非通过fork
等系统调用创建。
创建过程极为简单:1.分配一个新Env结构体。2.设置PCB。3.将程序载入到目标进程地址空间中。
当然这里还需要提及两个宏:
1 |
|
用于拼接变量名,例如:通过ENV_CREATE_PRIORITY(user_bare_loop, 1)和ENV_CREATE_PRIORITY(user_bare_loop, 2)得到内核中的binary_user_bare_loop_start
数组和binary_user_bare_loop_size
变量。
进程运行与切换
通过env_run()
函数来实现,其功能大致如下:
- 保存当前进程上下文信息,一般保存在
env_tf
成员变量中,实验中存放寄存器状态的地方是KSTACKTOP
以下的一个sizeof(TrapFrame)
大小的区域中,将后者拷贝到前者,就可达到保存进程上下文的效果。 - 切换
curenv
为即将运行进程e
。 - 设置全局变量
cur_pgdir
为当前进程页目录地址。 - 调用
env_pop_tf
函数,恢复现场,异常返回。
中断与异常
处理流程
处理异常大致流程:
- 设置
EPC
指向异常返回的地址。 - 置
EXL
位,强制CPU进入内核态(进入特权模式)并禁止中断,可不能中断套娃。 - 设置
Cause
寄存器,记录异常发生原因。 - CPU开始从异常入口处取指,此后交由异常处理程序处理。
异常分发
异常分发程序将检测发生了哪种异常,并调用相应的异常处理程序,一般被要求固定在某个物理地址上,确保正确的跳转。
实验的异常分发程序如下:
1 |
|
SAVE_ALL
代码如下:
1 |
|
此外,.text.exc_gen_entry
段和.text.tlb_miss_entry
段需被linker Script
当置到特定位置,分别放置在0x80000180
和0x80000000
处,都是异常处理程序入口。实验系统中CPU发生异常(除用户态地址的TLB Miss
异常)会自动跳转到前者,否则跳转到后者。
异常向量组
exception_handlers
称为异常向量组:
1 |
|
GNU C
的扩展语法:[first ... last] = value
,用于对数组某个区间上的元素赋同值,重叠的允许覆盖:
例如:int arr[5] = {[0 ... 3] = 1, [2 ... 4] = 2}; <=> int arr[5] = {1, 1, 2, 2, 2}
一些异常如下:
- 0号异常 处理函数为
handle_int
,中断,由时钟中断、控制台中断等造成。 - 1号异常 处理函数
handle_mod
,存储异常,存储操作时该页被标记为只读。 - 2号异常
handle_tlb
,TLB load
异常 - 3号异常
handle_tlb
,TLB store
异常 - 8号异常
handle_sys
,系统调用,用户进程执行syscall
指令陷入内核。
时钟中断
中断处理流程:
- 异常分发,判断出当前异常为中断异常,随后进入相应的中断处理程序。
- 中断处理程序进一步判断Cause寄存器中由几号中断位引发的中断,然后进入不同的中断服务函数。
- 中断处理完成,通过
ret_from_exception
函数恢复现场,继续执行。
时钟中断出现原因:与时间片轮转调度算法密切相关。MOS通过硬件定时器产生的时钟中断来判断一个进程的时间片结束。当时间片结束时当前进程挂起,MOS从调度队列中选取合适的进程运行。MOS利用CP0中内置的Timer产生时钟中断。
具体有两种控制Timer
的寄存器,Count
与Compare
寄存器,前者按某种仅与CPU流水线频率相关频率自增,后者保持不变。当两者值相等且不为0时,触发时钟中断。RESET_KCLOCK
将Count
清零,将Compare
置为期望的计时器周期数。MOS中,时钟中断初始化发生在调度执行每一个进程前,也就是env_pop_tf
调用RESET_KCLOCK
,又在RESTORE_aLL
中恢复Status
寄存器,开启中断。
时钟中断一产生会触发4KC硬件的异常中断处理流程,地址指向0x80000180
,跳转到相应代码段执行,对时钟引起的中断,通过.text_exc_gen_entry
代码段分发,调用handle_int
函数处理,其根据Cause
值判断是否为7号中断位引发的时钟中断,是则执行timer_irq
,跳转到schedule
执行。
进程调度
算法:用N*TIMER_INTERVAL
来量化时间片长度,此处的N
是进程的优先级,遍历调度链表找出当前的最优被调度进程。schedule
函数被调用时,当前正运行进程在curenv
中,其剩余时间片长度存储在count
中,当下列情况发生时,进行进程切换:
- 尚未调度过任何进程,即
curenv
为NULL
. - 当前进程已用完时间片,
count
== 0. - 当前进程不再就绪,被阻塞或退出.
yield
参数指定须发生切换.
无需切换时,只需让count
自减一,调用env_run
函数,继续执行当前进程,发生切换时,还需判断当前进程是否仍就绪,若是将其移至调度链表末尾,之后选中调度链表首部进程来调度运行,count
设置为其优先级。
TLB Miss异常的产生与处理
产生
4Kc的MMU对kuseg
段的地址映射,仅由TLB完成。正常情况下,TLB命中虚地址的访存过程全由硬件完成,OS不插手,但是当TLB在转换时发现TLB中还没对应的映射项目,就会产生一个TLB Miss异常。硬件会打断无法继续进行的访存操作,陷入内核并跳转执行OS的对应异常处理程序(tlb_miss_entry
),由OS查找页表,填上缺失的TLB
项,再从异常返回,重执行打断的访存。
相应的异常处理程序为handle_tlb
,其通过宏BUILD_HANDLER
包装了do_tlb_refill
函数完成TLB重填:
硬件:发生TLB Miss
时,CPU将引发地址装入BadVAddr
中、虚页号填入EntryHi
中的VPN
域,Cause
中的ExcCode
域写为TLBL
(读请求)或TLBS
(写请求)。
软件:从BadVAddr
中取出引发地址,在cur_pgdir
中查找该地址对应的物理地址与权限位,然后将相应物理页号和权限位填入EntryLo
的PFN
域和权限位,用tlbwr
将EntryHi
和EntryHi
中相应内容随机写入TLB中的一项中,最后调用ret_from_exception
从异常返回。
系统调用与fork
系统调用
系统调用大致流程
为了安全地为用户进程提供受限的系统调用机制,OS设计了一系列内核空间中的函数来执行相应操作。
通过puts()
函数在Linux中的执行过程,可以看出:
- 存在一些只能由内核完成的操作,如读写设备、创建进程、对I/O的操作等。
- C标准库中某些函数的实现依赖OS。
- 通过执行汇编指令
syscall
,用户进程可陷入内核态,请求相应服务。 - 通过系统调用陷入时需在用户态与内核态间进行数据传递与保护。
此外,由于直接调用系统调用过于麻烦,因此诞生了一系列用户空间的API定义,如POSIX和C标准库等,可以简单地理解为相应的中介,需要调用系统函数时调用API即可。
下通过user/lib/debugf.c
中的debugf
函数代码学习具体流程:
1 |
|
系统调用号:
syscall_*
函数同内核中的sys_*
函数一一对应,前者是在用户空间中最接近的内核函数,在用户态调用,后者是内核中系统调用的具体实现部分,在内核态中执行。所有
syscall_*
函数中都调用了msyscall
函数,其第一个参数是一个与调用名类似的宏,也就是系统调用号,系统调用号是内核区分不同系统调用的唯一依据。msyscall
函数除系统调用号参数外,还有5个参数,至于为什么是5个?因为最多参数的系统调用所需参数数量(。此处通过栈帧的方式从用户态传递入内核态。该函数没有**局部变量,也就是其不需要分配栈帧,只需执行自陷指令陷入内核并在处理后正常返回即可,syscall_mem_map
函数要5个参数)syscall
**指令不允许在延迟槽中使用。
通过syscall
指令陷入内核态后,CPU将PC寄存器指向作为do_syscall
函数的包装的handle_sys
函数的入口地址处,
1 |
|
对于陷入内核有几点需要补充:
- 陷入内核并不是从一个函数跳转到另一个,代码使用的
$sp
是内核空间的栈指针。 - 从用户态切换到内核态,内核需要先将原用户进程的运行现场保存到内核空间中,例如:
SAVE_ALL
宏。 - 保存完现场后栈指针指向保存的
Trapframe
,我们需要借助该结构体获取用户态中传递来的值,例如:用户态下$a0寄存器的值保存在内核栈的TF_REG4(sp)中。 - 内核在
handle_
开头的包装函数中调用实际进行异常处理的C函数时,栈指针将作为参数传递给该C函数。 - 可以通过
struct Trapframe *
获取用户态现场中的参数。
基础系统调用函数
sys_mem_alloc
函数
分配内存,用户程序可给该程序所允许的虚存空间显式地分配实际物理内存。站在编写者的视角也就是编写的程序在内存中申请了一片空间。而对于OS,则是一个进程请求将其运行空间中的某段地址与实际物理内存形成了映射。此处注意需要检查虚地址的合法性。
sys_mem_map
函数
- 将源进程地址空间中相应内存映射到目标进程的相应地址空间的相应虚内存中,也就是这两者共享一页的物理内存。
- 操作逻辑大致如下:
- 找到两个进程(使用
envid2env
函数)。 - 获取源进程的虚拟页面对应的实际物理页面(用
page_lookup
函数)。 - 将该物理页同目标进程的相应地址进行映射(用
page_insert
函数)。 - 完成上述操作后检查虚地址合法性。
- 找到两个进程(使用
sys_mem_unmap
函数
对标sys_mem_map
函数,该系统调用是用来解除某个进程地址空间虚内存和物理内存间的映射关系。
sys_yield(void)
函数
其用来实现用户进程对CPU的放弃,从而去调度其他进程。
小结
进程和内核间并非对立,在内核处理进程发起的系统调用时,并未切换地址空间(页目录地址),也不用将进程上下文保存到进程控制块中,只是切换到内核态下执行了某些内核代码,也就是处理系统调用时的内核仍代表当前进程,这也是系统调用、TLB缺失等同步异常与时钟中断等异步异常间的本质区别。
进程间通信机制(IPC)
作为微内核最重要的机制之一,IPC的目的是使两个进程间可通信,其需通过系统调用实现、还与进程的数据、页面等信息有关。
简单的来说,通信就是一种交换数据,由于不同进程的地址空间间相互独立,因此想要传递数据,只要将一个地址空间中的东西传给另一个即可。
由于所有进程共享一个主要是kseg0
的内核空间,因此可借助其来完成交换数据,其中发送方将数据以系统调用形式存放在PCB中,接收方同样以系统调用的方式在PCB中找到对应的数据,读取并返回。
因此在struct Env
结构体中新定义了以下5个变量:
1 |
|
其中两个重要函数为int sys_ipc_recv(u_int dstva)
函数和int sys_ipc_try_send(u_int envid, u_int value, u_int srcva, u_int perm)
函数。
前者用来接受消息:
- 先给自身的
env_ipc_recving
置位,表示准备接受消息了。 - 给
env_ipc_dstva
赋值,表明要将接受的页面与dstva
完成映射。 - 阻塞当前进程,注意此处需要手动将其从调度队列中删除,为了保证调度队列的一致性,因此在
schedule()
函数运行时对于当前进程阻塞的情况不需要去手动删除了。 - 放弃CPU(同时重新进行调度),等待发送方传递数据。
后者来发送消息:
- 根据
envid
找到相应进程,若对应进程为可接收状态,则发送成功。 - 否则返回异常值
-E_IPC_NOT_RECV
,表示目标进程未处于接收状态。 - 清除接收进程接收状态,填入数据到PCB,传递物理页映射关系。
- 修改PCB中进程状态,使接受数据进程可继续运行。
- 当传入的
srcva
不为0时,会利用page_insert来完成curenv中的相应物理页与e中的接受虚地址e->env_ipc_dstva的映射关系的建立,不过,在用户程序中会大量使用srcva == 0
的系统调用来只进行value
的传递,而不用传递相应物理页面.
Fork
fork
一个进程经过调用fork()
函数后将分叉成两个,其中新产生的是原进程的子进程,其开始运行时大部分上下文状态与原进程相同(包括程序和运行时现场),也就是其会从父进程产生子进程的fork()
语句后继续运行,fork()
返回值是0;而旧进程是父进程,其中fork()
调用返回值是子进程的env_id
,且其一定大于0;fork
失败的话,子进程不会被创建而父进程将获得小于0的fork
返回值.
子进程与父进程近乎一致,有何意义?这样一来通信将方便很多,因为子进程中仍能读取原属于父进程的部分数据,父进程也可根据fork
返回的子进程id
调用其他系统接口控制子进程行为.
此外在linux中,exec
的一系列系统调用同fork
经常一同使用,其可使进程抛弃现有的程序和运行现场,执行一个新的程序.若在进程中调用exec
,进程的地址空间(及在内存中持有的所有数据)都被重置,新程序的二进制镜像被加载到其代码段,也就是完全换了一个全新进程.
写时复制机制
为了父子进程能共用尽可能多的物理内存,引入写时复制技术(COW):fork
时只将地址空间中所有可写页标记为写时复制页;根据标记,当父子进程对写时复制页进行写入时,产生异常;OS去处理该异常:1).为当前进程试图写入虚地址重新分配物理页;2).新页面复制原有页内容;3).返回用户程序(放心去写吧);处理完即可对新物理页进行写入;
通过设置标志位来完成”写时复制机制”,其中PTE_D
是Dirty
位,表示当前页面是否可写,为0则不可写;为了区分”只读”页和”写时复制”页,新增一个PTE_COW
表示当前页面是否是”写时复制”页,因为”写时复制”页的PTE_D
位也应该是0.
fork返回值
fork
是一个用户态函数,其中使用了若干原子的系统调用来完成期望功能,最核心的是用于创建新进程的syscall_exofork()
,其返回值就是fork()
对应的返回值,比如子进程就是0,父进程就是子进程id
.fork
目的是使父子进程处于几乎相同的运行状态,可以认为返回用户态时父子进程都经历了相同的恢复运行现场的过程,不过,父进程从系统调用返回时恢复,而子进程则是进程被调度时恢复.恢复后,父子进程都会从内核返回到msyscall
函数中,其中$v0
寄存器的值也就是存储的返回值将不同,用于区分父子进程.
在syscall_exofork()
函数中需要在分配新PCB(作为子进程)后保存运行现场于其中,修改返回值为0,由于子进程并未充分准备好于是还得阻塞住.
地址空间的准备
用户进程在运行时入口会将一个用户空间中的指针变量struct Env * env
指向当前PCB,对fork
后的进程,其有一个与父进程不同的PCB,因此在其第一次被调度时(此时仍在fork
函数中)要对env
指针进行更新,使其仍指向当前PCB:1.由于对子进程来说syscall_exofork
返回的是0,因此需要通过一个系统调用来获取自身的env_id
;2.根据获取env_id
计算下标将对应PCB指针赋给env
.
此外,父进程还要将地址空间中要与子进程共享的页面映射给子进程,这要遍历父进程的大部分用户空间页,使用duppage()
函数实现,该函数对于可写入的页页表项还需要在父子进程中加上PTE_COW
标志位,并取消PTE_D
实现写时复制保护.
页写入异常
与此前的TLB缺失异常的异常处理(handle_tlb
)类似,写时复制特性依靠页写入异常(TLB mod,handle_mod
)进行处理.
由于发生页写入异常的有可能是正常栈的页面,因此用户进程需要一个单独的栈来执行页写入异常处理程序,(异常处理栈),其栈顶对应的是UXSTACKTOP
.此外,由于内核还要知晓进程自身的处理函数所在地址,其地址存在于PCB中的env_user_tlb_mod_entry
中,这个需要父进程提前通过系统调用设置.
大致流程如下:
- 触发页写入异常,陷入
handle_mod
,跳转到do_tlb_mod
函数. do_tlb_mod
函数负责将当前现场保存在异常处理栈中,设置$a0
和EPC
寄存器,使异常恢复后能以异常处理栈中保存现场(Trapframe
)为参数,跳转到env_ussser_tlb_mod_entry
中存储的用户异常处理函数地址.- 复原到用户态,跳转到用户异常处理函数,由用户程序完成写时复制等自定义处理.
具体的cow
处理是由cow_entry
实现的,而该函数在进行cow
处理后会使用系统调用syscall_set_trapframe
恢复保存好的现场,包括sp
和PC
寄存器,使用户程序恢复运行。
1 |
|
文件系统
文件系统是OS为了便于管理和访问存放在外部存储设备上的数据,其中,文件是数据存储和访问的基本单位。
概述
文件系统是一种存储和组织数据的方法,便于访问和查找数据,其使用文件和树型目录的逻辑抽象屏蔽底层硬盘和光盘等物理设备基于数据块进行存储和访问的复杂性。用户并不关心具体的底层实现,可以方便的调用和写入相应的文件。
一般文件系统常基于硬盘和光盘等块存储设备,并维护文件在设备中的物理位置,此外还有某些仅是一种访问数据的接口,实际数据在内存中或通过网络协议(NFS、SMB、FTP)提供。
广义上,一切带标识、逻辑上有完整意义的字节序列都可称作”文件”。文件系统将外设抽象为文件来统一管理外设,实现对数据的存储、组织、访问和修改等操作。
文件系统主要包括以下几个部分:
- 外部存储设备驱动 为了将按一定顺序读写设备寄存器来实现对外部设备的操作转化为有通用、明确语义的接口,必须实现相应的驱动程序,一种过去主流的硬盘接口——IDE(集成驱动器电子设备),其用户态驱动程序通过系统调用的方式陷入内核,对磁盘镜像进行读写操作。
- 文件系统结构 此部分是实现模拟磁盘的驱动程序及磁盘上和OS中的文件系统结构和相关函数。
- 文件系统的用户接口,要求为用户提供接口和机制使用户能使用文件系统,通过一个用户态的文件系统服务来实现。文件描述符(FCB)来表示一个进程打开的文件。
IDE磁盘驱动
为了在磁盘等外设上实现文件系统,需要为其编写驱动程序。
内存映射I/O(MMIO)
近乎每种外设都通过读写设备上寄存器进行数据通信,这些寄存器称为I/O端口,包括控制寄存器、状态寄存器和数据寄存器,映射到指定物理地址空间,如:MALTA中,console
设备映射到0x180003F8
,IDE控制器映射到0x180001F0
等。
以串口设备驱动为例,MALTA提供的console
设备是一个典型的NS16550设备,基地址为0x180003F8
.
设备寄存器映射如下表:
偏移 | 效果 |
---|---|
0x00 | 读:非阻塞的读取一个字符,若没未读取字符返回上次读取的结果 |
0x00 | 写:输出一个字符ch |
0x05 | 读:获取设备状态,返回1说明设备上有未读取的数据就绪可用 |
向内存的(0x180003F8+0xA0000000)
)写入字符,即可在shell
中显示输出。
这也就是kern/machine.c
中printcharc
函数的实现:
1 |
|
此次编写的IDE磁盘驱动程序在用户空间,因此对于设备的读写要通过系统调用(sys_write_dev 和 sys_read_dev)实现,否则会引发报错。
IDE磁盘
MALTA的PIIX4控制器提供了对磁盘读写的基本单位——扇区的基本方法,读写其特定寄存器可实现对扇区的读写,相应基地址为0x180001F0
,相应寄存器功能如下表:
偏移 | 功能 | 位宽 |
---|---|---|
0x0 | 读/写:向磁盘读写数据,从0字节开始 | 4B |
0x1 | 读:设备错误信息;写:设置IDE命令的特定参数 | 1B |
0x2 | 写:设置一次需操作的扇区数量 | 1B |
0x3 | 写:设置目标扇区号的[7:0]位(LBAL) | 1B |
0x4 | 写:设置目标扇区号的[15:8]位(LBAM) | 1B |
0x5 | 写:设置目标扇区号的[23:16]位(LBAH) | 1B |
0x6 | 写:设置目标扇区号的[27:24]位(CHS/LBA),配置扇区寻址模式和要操作的磁盘编号(磁盘号) | 1B |
0x7 | 读:获取设备状态;写:配置设备工作状态 | 1B |
采用逻辑块寻址(LBA)方式进行扇区寻址,此模式下,磁盘被视为线性的字节序列,每个扇区都有唯一编号,设置目标扇区编号即可完成寻址。
驱动程序编写
调用read_sector()
和write_sector()
将磁盘扇区数据读到外设缓冲区中或反过来写入。类似控制台串口,磁盘操作中所有地址操作都要将物理地址转换为虚地址。
由于IDE外设一般不能立即完成数据操作,因此需要CPU反复检查IDE状态并等待操作完成,通过如下的wait_ide_ready()
函数来完成:
1 |
|
IDE就绪后,即可进行读写操作。
- 设置操作扇区数。
- 设置扇区号[7:0]位
- 设置[15:8]位
- [23:16]位
- [27:24]位,设置扇区寻址模式和磁盘号
- 设置IDE设备状态为读或写
- 等待IDE设备就绪
- 读取或写入数据
- 检查IDE设备状态
- 完成
分别设置是由于IDE设备无法一次性写入操作扇区号,需单独设置扇区号的各个位。
读取和写入数据时,由于实验的IDE设备每次仅能操作4B,因此需要通过循环完成。
例如:
1 |
|
文件系统结构
磁盘文件系统布局
图中的Block
是磁盘块,不同于扇区,是虚拟概念,是OS与磁盘交互的最小单位。类似于内存管理中页和段的概念,OS将相邻扇区重组成磁盘块操作,减小了寻址的困难。
图中可知,MOSOS将磁盘一开始的一个磁盘块当作引导扇区和分区表使用(MBR?),接下来一个磁盘块作为超级块来描述文件系统基本信息,如魔数、磁盘大小及根目录位置等。
MOS中super block
结构体如下:
1 |
|
此处采用位图(Bitmap)管理空闲的磁盘资源,即用一个二进制位bit标识磁盘中一个磁盘块的使用情况(1空闲,0占用)。tools/fsformat.c
用于创建符号定义的文件系统镜像,可将多个文件按内核定义的文件系统写入到磁盘镜像中,观察头文件和tool/Makefile
可知该C文件使用Linux
下的gcc
编译器编译,非交叉编译器,生成的fsformat
运行在Linux
宿主机上,专用于创建磁盘镜像文件,其中fs.img
可模拟于真实磁盘文件设备的交互。
写入文件前,利用其中的init_disk()
函数将所有块标为空闲块:
1 |
|
文件系统详细结构
文件控制块结构体如下:
1 |
|
普通文件的FCB
中指向的磁盘块存储着文件内容,而目录文件指向的存储目录下各个文件对应的FCB
。
当查找某个文件时,先从super block
读取根目录的FCB
,然后沿着目标路径,依次查看当前目录所包含文件是否与下一级目标文件同名,进而查找到最终的目标文件。
块缓存
借助虚存实现磁盘块缓存。由于对磁盘I/O操作单次只能读取少量数据,同时频繁读写磁盘将拖慢系统,使用缓存可通过将数据暂存于内存,减少磁盘访问次数,加快数据读取。
文件系统服务为一个用户进程,其将DISKMAP 到 DISKMAP+DISKMAX 这一段虚存地址空间(0x10000000-0x4FFFFFFF) 作为缓冲区,当磁盘读入内存时,用来映射相关的页。
1 |
|
fs/fs.c
中的map_block
函数和unmap_block
函数实现了当把一个磁盘块中内容载入内存中为之分配对应物理内存和结束使用某一磁盘块释放对应物理内存的操作。read_block
和write_block
函数用于读写磁盘块。前者将指定编号磁盘块读入内存,其先检查该磁盘块是否已在内存中,不在则先分配物理内存,然后调用ide_read
读取磁盘上数据到对应虚存地址上。file_get_block
将某个指定的文件指向的磁盘读入内存:先用file_map_block
为将读入内存的磁盘块分配物理内存,再使用read_block
将磁盘内容一块为单位读入内存相应位置。
文件系统的用户接口
为了便于用户操作文件系统,还要向用户提供相关使用接口。
文件描述符
fd
——File Discripter
、系统提供给用户的用于在描述索引表中索引的整数。在进行I/O编程时,用open
、close
、write
和read
来进行文件操作时处理的都是fd
。
用户进程尝试打开文件时,文件系统服务进程需要一个fd
来存储文件基本信息和用户进程中文件的状态,且fd
还有描述用户对文件操作的作用。用户进程发生打开文件请求时,文件系统进程将基本信息记在内存中,再由OS将用户进程请求地址映射到相应存储了fd
的物理页上,因此一个fd
至少独占一页。用户进程获取了文件基本信息后再向文件系统发送请求将文件内容映射到指定内存空间中。
文件系统服务
文件系统服务通过IPC形式为其他进程提供服务。
具体:内核始运行时启动文件系统服务进程ENV_CREATE(fs_serv)
,进行文件操作时,使用ipc_send
、ipc_recv
和fs_serv
交互。fs/serv.c
中服务进程主函数调用serve_init
准备好全局文件打开记录表opentab
,再调用fs_init
初始化文件系统,其先通过读取super block
内容获取磁盘基本信息,再检查磁盘能否正常读写,最后调用read_bitmap
检查位图是否正确。此后,调用serve
开始文件系统服务,等待其他程序请求。
文件系统支持的请求类型:
1 |
|
用户程序发生相应请求时,请求内容放在对应结构体中进行消息传递,fs_serv
进程收到进行的IPC
请求后,根据传递的请求类型和必要参数执行相应的文件操作,最后将结果通过IPC
反馈回用户程序。
管道
作为一种典型的进程间单向通信方式,管道分为有名和无名两种,无名的常用于父子进程间。Unix
系统中,管道由int pipe(int fd[2])
函数创建,成功创建返回0,fd
用于保存读写端的文件描述符,fd[0]
是读端,另一个则是写端。
本质上,管道是一种只存在于内存中的文件,父进程调用pipe
打开的两个文件描述符:一读、一写,映射到同一片内存区域,fork()
后,子进程复制两个文件描述符,从而在父子进程间形成了四个指向同一片内存区域的文件描述符,父子进程可根据需要关掉不用的一个,进而实现父子进程间的单向通信管道。
mos中的pipe
mos中的pipe使用逻辑与UNIX
中相同,在pipe
读写过程中,要保证写端对pipe
的写入对读端可见,即保证父子进程通过pipe访问的内存相同,在pipe
的生成函数中,先分配两个文件描述符fd0
和fd1
并分配空间,然后给前者对应的虚地址分配一页物理内存,再将后者对应的虚地址映射到这页物理内存。
通过置共享位(PTE_LIBRARY
)的方式,保持该页共享可写的状态,使父子进程对修改结果相互可见。
pipe的读写
pipe
结构体定义如下:
1 |
|
若fork
结束后,子进程先运行。时钟中断在close(p[1])
与read
间后,父进程开始执行。
父进程在close(p[0])
过程中,删除了p[0]
对pipe
的映射,但未解除对p[0]
的映射,此时若时钟中断产生,进程调度子进程运行。
此时各页引用情况:pageref(p[0]) = 2
(父进程未解除对p[0]
的映射,父子各一个),pageref(p[1]) = 1
(子进程已关闭了p[1]
,但父进程还有),而pageref(pipe) = 2
(子进程中p[0]
引用了,父进程中p[0]
解除了映射,但是还有p[1]
引用了)。
此后,子进程运行read
,先判断写者是否关闭,发现pageref(pipe) == pageref(p[0])
,写端关闭,但是其实都还没开始,则子进程退出,出现未写入的情况。
让我们仔细研究_pipe_is_closed
的函数逻辑:
- 写端关闭<=>
pageref(p[0]) == pageref(pipe)
- 读端关闭<=>
pageref(p[1]) == pageref(pipe)
对于第一个条件,写端关闭时,显然有pageref(p[0]) == pageref(pipe)
成立,但由于存在进程切换,无法保证上述等式成立时,写端关闭。显然其逆否命题对于我们来说更好处理:当写端未关闭时,保证pageref(p[0]) != pageref(pipe)。
此前的预想之外的情况出现的关键在于:pipe
的引用次数总比fd
要高。当close
到一半时,若先解除pipe
的映射,后解除fd
的映射,就会使得引用次数更高的pipe
的引用次数与fd
相同,导致出现了写端关闭的假象。但是假如调换解除顺序,先解除引用次数更少的fd
的映射,再是pipe
,使前者引用次数先-1。那么由于引用次数本身的大小关系,两个unmap
间,pageref(pipe) > pageref(fd)
就将永远成立,而此时即使发生时钟中断,也不会影响_pipe_is_closed
函数的返回值正确与否。
除了上述问题,或许你也会有这样的疑问:对于pageref
的调用一定正确的吗?也就是pp_ref
读取同步问题。
举个例子:
pipe(p);
if (fork() == 0) {
close(p[1]);
read(p[0], buf, sizeof buf);
}
else {
close(p[0]);
write(p[1], "Hello", 5);
}
假设fork
结束后,子进程先执行,执行完close(p[1])
后,接着read
,由于此时pipe
的buf
为空,因此read
函数要判断父进程的写端是否关闭,进入到_pipe_is_closed
函数,pageref(fd)
为2(父子进程都打开了p[0]
),此时时钟中断发生。
父进程运行,先close(p[0])
,再向buf
写数据,假设由于数据较多,写一半发生了中断,切换到子进程。
此时子进程获得的pageref(pipe)
值为2(父进程开了p[1]
,子进程开了p[0]
),引用值相等,误认为父进程写端已关闭,子进程错误退出。fd
作为共享变量,在子进程中获得pageref(fd)
的值后并没有由于父进程对fd
的修改而同步,成了”脏数据”。为了保证读的同步,在父进程修改后,应该重新读取引用次数,确认两次读取间进程并没有切换后,才能返回正确结果。
在实验中,通过env_runs
变量来实现,其记录了一个进程env_run
的次数,这样就可以通过判断某个操作前后env_runs
的次数是否改变来确定在这个操作是否是在同一次进程调用中被执行的,进而实现了同步读。
相关函数
创建管道函数
int pipe(int pfd[2])
.
步骤大致如下:
- 创建并分配两个
fd
:fd0
、fd1
,并分配相应的文件描述符空间。 - 给
fd0
对应数据区域分配一页空间,将fd1
对应数据区域映射到该物理页上,该页的内容为pipe
结构体。 - 将读端
fd0
的权限设置为只读,fd1
只写,并通过函数传入参数将这两个文件描述符的编号返回。
注意此处的权限位都需要包含不会触发父子进程间修改页面内容导致的写时复制机制的PTE_LIBRARY
位,便于父子进程间传递信息。
两个查询管道是否关闭的函数
static int _pipe_is_closed(struct Fd *fd, struct Pipe *p)
与int pipe_is_closed(int fdnum)
,其中判断的主要逻辑在前者,后者是对于前者的封装,其具体实施逻辑在此前已经讲述过了。此处不再赘述,不过由于可能的中断,因此需要通过判断获取引用次数操作前后的env->env_run
是否相等来判断是否是在统一进程调度下进行的。
读管道函数
static int pipe_read(struct Fd *fd, void *vbuf, u_int n,u_int offset)
,其从fd
对应的管道文件数据buf
中读至多n
B到vbuf
对应虚地址中,并返回读到的字节数。
读取过程可能遇到:
- buf不为空:按顺序读取buf内容,直到buf为空或达到
n
B。 - buf为空:使用 _pipe_is_closed 函数查询管道的写端是否已经关闭,若已关闭,说明读入完成,函数返回 0;若没有关闭,则使用 syscall_yield() 进行等待,直到管道关闭或缓冲区不为空。
写管道函数
static int pipe_write(struct Fd *fd, const void *vbuf, u_int n, u_int offset)
与读类似,不过此处的n
是需要写入恰好n
B,非buf
上限,因此完成全部n
B写入前,若buf
满了,则用syscall_yield()
等待,并非返回,直到buf
不满或pipe
关闭。
关闭管道函数
static int pipe_close(struct Fd *fd)
关闭管道的一端。
shell
shell分为两大类:
- 图形界面 shell(Graphical User Interface shell 即 GUI shell)。例如:应用最为广泛的是微软 Windows 系列操作系统的 Windows Explorer,也包括广为人知的 Linux 操作系统的XWindow Manager(BlackBox 和 FluxBox),以及功能更强大的 CDE、GNOME、KDE 和 XFCE等。
- 命令行式 shell(Command Line Interface shell ,即 CLI shell),也就是我们 MOS 操作系统最后即将实现的 shell 模式。
完善spawn
函数
该函数帮助调用文件系统的可执行文件并执行。
其流程如下:
- 从文件系统打开对应的文件(二进制 ELF,在实验中是*.b );
- 申请新的进程控制块;
- 将目标程序加载到子进程的地址空间中,并为它们分配物理页面;
- 为子进程初始化地址空间。对于栈空间,由于
spawn
需要将命令行参数传递给用户程序,所以要将参数也写入用户栈中; - 设置子进程的寄存器(栈指针 sp 和用户程序入口 EPC);
- 将父进程的共享页面映射到子进程的地址空间中;
- 上述操作执行完后,设置子进程可执行。
具体如何为子进程初始化栈空间,都在user/lib/spawn.c
中的init_stack
函数中。
由于无法直接操作子进程栈空间,因此spawn()
函数(spawn.c
中的另一个函数)先将要准备的参数填充到本进程的UTEMP
页面处,此后再将UTEMP
映射到子进程栈空间中。具体操作如下: - 先将
argc
个字符串填到栈上,切记在字符串末尾加上\0
表结束,然后将argc + 1
个指针填到栈上,第argc + 1
个指针指的是第一个空字符串用来表示参数的结束。 - 再将
argc
和argv
填到栈上,argv
指向那argc + 1
个字符指针。
子进程栈空间示意如下:
解释shell命令
在user
目录下提供了ls.c
、cat.c
等用户程序,而Shell
程序sh.c
调用上面此前提及的spawn
函数,其读取相应的可执行文件,并加载到新进程中运行。
下探讨如何实现管道和重定向机制:
- 对于重定向符号’<’和’>’,读下一个单词,打开其代表的文件后将其赋值给标准输入或标准输出。
- 对于管道符号’|’,先建立
pipe
,后fork
:父进程将管道写者复制给标准输出,后关闭父进程读写者,执行|
左边命令输出到管道写端;子进程将管道读者复制给标准输入,从管道读取数据,关闭进程读写者,继续读下一个单词,并执行其代表的命令。
进程空间中,文件、管道、控制台和文件描述符都以共享页面的形式存在。
具体的shell启动执行过程如下:
内核启动的进程user/icode.b
调用spawn
创建init.b
进程。init.b
先打开控制台作为0和1号文件描述符,即进程的标准输入和标准输出,然后调用spawn
创建sh.b
进程,即shell
。通过共享页面机制,fork
和spawn
创建的子进程继承了父进程的文件描述符,因此shell
进程以及其此后为了执行命令而fork
出的子进程都仍可以通过标准输入输出操作控制台,与用户交互。shell
进程创建的子shell
进程负责解析命令行命令并通过spawn
生成可执行程序进程(对应的*.b
文件)。解析命令行命令时,子shell
将重定向的文件和管道等文件通过dup
复制到子shell
的标准输入或输出,然后spawn
将标准输入和输出通过共享内存映射给可执行程序,从而使可执行程序(*.b
)可从控制台、文件和管道等位置输入和输出数据。
相关函数
初始化栈空间函数
int init_stack(u_int child, char **argv, u_int *init_esp)
初始化子进程栈空间,达到向子进程主函数传递参数的目的。由于父进程不能直接操控子进程栈空间,因此要先将参数填充到当前进程的TMPPAGE
,然后将其映射到子进程栈空间中。
spawn
函数
int spawn(char *prog, char **argv)
,类似于fork
都是产生一个子进程,但不同的是spawn
产生的是不再执行与父进程相同的程序而是装载新的ELF
文件、执行新程序的子进程。
大致流程如下:
- 使用
open
打开即将装载ELF
文件的prog
。 - 使用
syscall_exofork
为子进程申请一个PCB
。 - 使用
init_stack
为子进程初始化栈空间,将要传递的参数argv
传入子进程。 - 使用
elf_load_seg
将ELF
各段加载进子进程。 - 设置子进程运行现场寄存器,
tf->cp0_epc
设为程序入口点,tf->regs[29]
设为装载参数后的栈顶指针,使子进程唤醒时以正确状态运行。 - 将父进程共享页面映射给子进程,与
fork
不同,这里只映射共享页面。 - 使用
syscall_set_env_status
唤醒子进程。
sh.c
中的main
int main(int argc, char **argv)
,shell
进程的主函数,是启动shell
进程的第一步进入的函数,主体为死循环:
- 调用
readline
读入用户输入命令。 fork
出一个子进程。- 子进程执行用户命令,执行结束后子进程结束,父进程等待。
- 父进程等待子进程结束,返回循环开头,读入下一个命令。
命令读入函数
void readline(char *buf, u_int n)
从标准输入读入用户命令,保存在buf
中。
命令解析函数
int _gettoken(char *s, char **p1, char **p2)
和int gettoken(char *s, char **p1)
。gettoken
接收readline
读入的命令字符串作为传入参数s
。此两者负责s
分割,提取命令中基本单元——特殊符号或单词,过滤空白字符。gettoken
是对_gettoken
的封装。
cmd命令解析与执行函数
void parsecmd(char **argv, int *rightpipe)
和 user/sh.c 中的 void runcmd(char *s)
。
前一个函数解析用户输入命令,通过调用gettoken
将命令解析成若干词法单元,保存在argv
中,并在遇到<
或>
或|
等时特殊处理。解析完一条命令包含所有词法单元后,结束该函数,运行runcmd
。需要注意:命令中包含|
时,需创建pipe
并fork
一个子shell
,子进程调用parsecmd
解析pipe
右侧命令,父进程返回runcmd
执行左侧命令,此时rightpipe
记录子进程进程号即解析并执行pipe
右侧命令的进程号,用于pipe
左侧命令进程等待管道右侧命令进程执行完毕后释放。runcmd
执行解析后的每一条命令,通过argv
判断命令种类,调用spawn
产生子进程并装载命令对应的ELF
文件,子进程执行命令,父进程等待子进程结束后,结束进程。