OS实验

OS采用MIPS32的相关补充

MIPS Calling Convention

the stack frame

一个栈帧一般由5个部分组成:

  • 用于本地数据存储的部分,函数会为此区域内所有局部变量保留足够存储空间,包括要在函数调用前后保留的任何临时寄存器值的空间
  • (Pad,空白填充)作用是为了确保栈帧总大小是8字节的倍数,确保上方局部数据存储部分是双字对齐,与64位体系结构的双字读取指令兼容不一定存在,需要根据具体情况给出分配。一般来说是一个字,因为假若原本的字的个数是偶数,则字节数一定能整除8(一个字4个字节的话);若为奇数,只需要Pad填充一个字即可得到偶数字,因此至多只需要一个Pad。
  • 返回地址部分,即存储$ra值的部分。只有在函数内部需要调用其他函数的时候才需要。
  • 保留寄存器部分,保存当前子函数被调用时,当前子函数需要保存的寄存器(如:$S0 到 $s7)的值,在当前函数中就可以随意使用这些寄存器了,注意:在当前子函数返回前需要将相应寄存器的值给返回回去。这样一来父函数认为完全没有发生改变。
  • 参数部分,父函数调用子函数时会将子函数需要的参数传入此处,实现对函数调用时的传参。注意:由于$a0-$a3只要保留栈帧空间,即参数部分的4个字的大小,也就是只要有传参,那么就一定会有4个字空间作为参数部分,参数少于4多余的闲置,参数多于4则根据需求扩展参数部分。

这5个部分(如果同时存在)则就按当前顺序排列。

此外并不是所有函数的栈帧都需要这5个部分,若不需要则直接省略。

下列三种函数,以及相应的例子:

简单叶函数:不调用任何其他子函数,不用栈帧,不改sp属于是一股清流了

有局部数据的叶函数:有栈空间的叶函数(不需要、不调用其他函数),栈帧可用于本地数据或寄存器值的保存。

非叶函数:函数内调用其他函数,基本上包含栈帧的所有5个部分,Pad不一定,因为可以本身就是8的倍数了。

例:

简单叶函数:

1
2
3
int g( int x, int y ) { 
return (x + y);
}

有局部数据或涉及到寄存器值的改变的叶函数:

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
#int g( int x, int y ) { 
# int a[32];
# ... (calculate using x, y, a);
# return a[0];
#}

a leaf function just with the usage of local data
#the stack frame just have the local data section
g: #start of prologue
addiu $sp,$sp,(-128) # push stack frame
# end of prologue
. . .
# calculate using $a0, $a1 and a
# array a is stored at addresses
# 0($sp) to 124($sp)
lw $v0,0($sp) # result is a[0]
# start of epilogue
addiu $sp,$sp,128 # pop stack frame
# end of epilogue
jr $ra # return


a leaf function with the local data and saved registers
g:
# start of prologue
addiu $sp,$sp,(-144) # push stack frame
sw $s0,0($sp)
sw $s1,4($sp)
# save value of $s0
# save value of $s1
sw $s3,8($sp) # save value of $s3
# end of prologue
# start of body
. . .
# calculate using $a0, $a1 and a
# array a is stored at addresses
# 16($sp) to 140($sp)
lw $v0,16($sp) # result is a[0]
# end of body
# start of epilogue
lw $s0,0($sp) # restore value of $s0
lw $s1,4($sp) # restore value of $s1
lw $s3,8($sp) # restore value of $s3

addiu $sp, $sp, 144 #pop stack frame
#end of epilogue

jr $ra #return
#在这里由于函数内部计算过程会用到三个寄存器的值,因此需要提前将其压入栈中,除此之外还有数组a的32个字的内容压入栈中,为了满足8字节对齐要求,在栈帧存储本地数据和存储寄存器部分之间还需要插入一个的Pad来对齐。

a complicated nonleaf function

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#int g( int x, int y ) { 
# int a[32];

# ... (calculate using x, y, a);

# a[1] = f(y,x,a[2],a[3],a[4]);
# a[0] = f(x,y,a[1],a[2],a[3]);
# return a[0];
#}

g:
# start of prologue
addiu $sp,$sp,(-168) # push stack frame

sw $ra,32($sp) # save the return address

sw $s0,20($sp) # save value of $s0
sw $s1,24($sp) # save value of $s1
sw $s3,28($sp) # save value of $s3
# end of prologue

# start of body
. . . # calculate using $a0, $a1 and a
# array a is stored at addresses
# 40($sp) to 164($sp)

# save $a0 and $a1 in caller’s stack frame
sw $a0,168(sp) # save $a0 (variable x)
sw $a1,172(sp) # save $a1 (variable y)

# first call to function f
lw $a0,172(sp) # arg0 is variable y
lw $a1,168(sp) # arg1 is variable x
lw $a2,48(sp) # arg2 is a[2]
lw $a3,52(sp) # arg3 is a[3]
lw $t0,56(sp) # arg4 is a[4]
sw $t0,16(sp) # BUT it is passed on the stack!
jal f # call f

sw $v0,44(sp) # store value of f into a[1]

# second call to function f
lw $a0,168(sp) # arg0 is variable x
lw $a1,172(sp) # arg1 is variable y
lw $a2,44(sp) # arg2 is a[1]
lw $a3,48(sp) # arg3 is a[2]
lw $t0,52(sp) # arg4 is a[3]
sw $t0,16(sp) # BUT it is passed on the stack!
jal f
sw $v0,40(sp)
# store value of f into a[0]
# load return value of g into $v0
lw $v0,40($sp) # result is a[0]
# end of body
# start of epilogue
lw $s0,20($sp) # restore value of $s0
lw $s1,24($sp) # restore value of $s1
lw $s3,28($sp) # restore value of $s3
lw $ra,32($sp) # restore the return address
addiu $sp,$sp,168 # pop stack frame
# end of epilogue
jr $ra # return


#这样一个复杂的栈帧一般就需要所有栈帧的5个部分,函数内计算时需要的本地数据如数组和需要调用寄存器都需要提前压入栈中,除此之外由于函数内部需要调用的函数的参数比4个更多因此需要扩展相应参数部分至5个字,因为要调用函数所以还需要返回,一个字用来存储$ra,此番操作下来了总字节数为奇数乘以4不为8的倍数,所以还需要填充一个Pad来满足需求。

栈帧如下图:

nonleaf function stack frame

此处的例子及图片均来自于Microsoft Word - MIPS Calling Conventions Summary.doc.

MIPS Directives & Macro

.align

使下面紧跟着的数据进行地址对齐,

例如:

1
2
3
4
5
6
7
8
9
#使下面大小为一个字的变量var按4字节对齐,跟着的参数x代表以2^x字节对齐
.align 2 # align to 4-byte boundary (2^2)
var: .word 0

#该指令还可以通过指定一个零对齐,即以1字节对齐,来覆盖.half、.word等自动对齐特性
# 例:
.half 3
.align 0 #关闭自动对齐
.word 100 #word紧贴着half

.globl.extern

.globl:将符号定义为具有对其他模块可见的全局符号

.extern:要对另一个模块中的全局符号的引用(即对外部符号的引用)

Tips: 所有对标签引用都会被自动认为是在引用全局符号,因此在引用另一模块的全局标签时,没有加.extern的必要,但如果引用另一模块中的全局变量,需要添加

注意:是.globl而不是.global

.set

设置汇编器的工作方式。一般,汇编器尝试通过重新排列指令来填充分支指令和存取指令造成的空闲时间。

.set noreorder.set reorder:告知汇编器是否重新对指令进行顺序进行排序,前者需手动填充延迟槽,后者下汇编器会自动调度指令至延迟槽。

.set at.set noat:前者,$at为汇编器保留用于实现扩展指令,后者则不使用。

LEAFNESTEDEND三个宏

LEAF
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define LEAF(symbol)     #括号中的symbol类似函数的参数,作用类似于编译时在宏中会将symbol替换为实际传入的文本,即函数名
.globl symbol; #标记symbol为对其他模块可见的全局符号,使标签对链接器可见,使其他文件中可以调用我们使用宏定义声明的函数
.align 2; #使下面的symbol标签按4 Byte对齐,使可以使用jal指令跳转到这个函数(末尾拼接两位0)
.type symbol,@function; #设置symbol标签为函数标签
.ent symbol; #标记每个函数开头,与.end配对使用,使可在debug时查看调用链
symbol: \
.frame sp,0,ra
#.frame是一个用于定义函数栈帧的调试指令,常与.ent和.end一起使用,它用来描述栈帧结构明确如$sp偏移量、$fp的位置和$ra等的栈帧布局、为调试器提供额外栈帧信息。
#语法:.frame 寄存器(常为$fp), 栈帧大小(字节为单位), 返回寄存器(常为$ra)


#LEAF宏的使用示例:
LEAF(msyscall)
// Just use 'syscall' instruction and return.
syscall
jr ra
END(msyscall)
NESTED
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#define NESTED(symbol, framesize, rpc)      #同LEFA的区别在于LEAF被调用时没分配栈帧空间记录自己的“运行状态”,而NESTED则分配了栈帧空间记录运行状态
.globl symbol; \
.align 2; \
.type symbol,@function; \
.ent symbol,0; \
symbol: \
.frame sp, framesize, rpc


#NESTED使用实例:
NESTED(handle_int, TF_SIZE, zero)
mfc0 t0, CP0_CAUSE
mfc0 t2, CP0_STATUS
and t0, t2
andi t1, t0, STATUS_IM7
bnez t1, timer_irq
timer_irq:
li a0, 0
j schedule
END(handle_int)
END
1
2
3
#define END(function)                       \
.end function; #.end为了结束先前由LEAF或NESTED开启的.ent,标记symbol函数的结束
.size function,.-function #标记了function符号占用的存储空间大小,设置为.-function,其中.代表当前地址,用当前位置地址减去function标签处的地址即可计算出符号占用的空间大小。

C语言拾遗

从源代码到可执行文件

总述

一个C语言从源代码到可执行文件

对C语言而言,从源代码到可执行文件大致要经过4个步骤:预处理->编译->汇编->链接。每个阶段都调用独立的程序。

于C程序和GNU编译工具而言,预处理阶段会以源文件为输入调用cpp生成预处理文件,编译阶段会以预处理后的文件为输入调用cc1生成汇编文件,汇编阶段以汇编文件为输入调用as生成目标文件,最后以一个或多个目标文件为输入调用Id生成可执行文件

gcc仅是一个总的调度程序,调用这些真正执行处理的程序,将这些程序封装起来提供一个较简明的操作界面。

在编译时可以用--verbose让gcc打印出详细的编译信息。

预处理

1
$ gcc -E hello.c -o hello.i

选项-E使gcc只进行预处理。

预处理主要在处理源码中的以#开始的预处理指令,主要处理规则是:

  • 删除所有的#define,展开相应的宏
  • 处理所有条件编译指令,如#if#ifdef
  • 处理#include预处理指令,将被包含的文件内容插入到该预处理指令的位置

编译

1
$ gcc -S hello.i -o hello.s

仅从预处理文件生成汇编代码

汇编

1
$ gcc -c hello.s -o hello.o

汇编码转为机器码,生成目标文件。

链接

将多个目标文件的代码段放在一起、数据段放在一起,和写好的库函数(如scanfprintf)一起组成可执行文件。

C语言中变量的存储类别

由关键字(autostaticextern等)和变量声明位置(函数内或外)指定变量的存储类别。

存储期

其用于描述一个对象在内存中的生命周期,注意这里的对象与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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define MACRO_NAME(para1, para2) \
do { \
express1; \
express2; \
} while(0)

\\不用{}括起来可能会出现没带{}的语句块,导致两个表达式没在同一个块中执行
\\使用do{……}while(0)的原因是使用{……};可能会终止当前语句块同下一条语句块的连接如:
if(n > 0)
{
expressio1;
expression2;
}; //这里的if会被终止,无法同else配对
else
break;
用宏参数创建字符串:#运算符

#作为一个预处理运算符,用于创建字符串,如:

1
2
3
4
#define STR(s)  #s
printf(STR(hello world));
//预处理后
printf("hello world");

可以看到 # 运算符会自动把传入的参数用双引号括起来成为一个字符串,并且实参中的连续多个空格会被替换成一个空格。

例:

1
2
3
4
#define PSQR(x)  printf(" The square of " #x " is %d",((x)*(x))) //若想要达到输出中是自变量本身的话,那么这里printf内的双引号是必须的,其实这里用双引号本质上就是将其前后的两个format闭合,也就是是达到这样的效果:printf("The square of" "(这里是x本身,或者可以理解成#x去掉双引号的东东)" "is %d", ((x) * (x)))  也就是这里的printf()函数中的format由三段双引号组成,经过实验证明是可行的
int y = 5
PSQR(y)
// 输出:the square of y is 25
预处理器粘合剂:##运算符

将前后两个预处理符号连接成一个,#只限于函数式,但##两者均可。

例:

1
2
3
4
5
6
7
//变量式
#define XNAME(n) x ## n
// XNAME(4) 将展开为 x4

//函数式
#define CONCAT(a, b) a##b
// CONCAT(con, cat) 展开为 concat

因为宏只能做到文本替换,因此这里替换后可以是常数粘合常数成一个数字,也可以是粘合成一个变量名或函数名等,一般不能粘合成一个字符串之类的,因为引号具有一定的特殊性。

变参宏:...(注意这里的三个点不是中文下的省略号,而应该是英文下句号的三次重复)和__VA_ARGS__

函数式宏还可带可变参数,格式如下:

1
2
3
4
5
#define showlist(...) printf(#__VA_ARGS__)
showlist(The first, second, and the third items.);
//实际效果就是实参中的…的几个参数被看成一个参数替换到宏定义中__VA_ARGS__所在的地方。
//由于同时存在#运算符,因此上一行代码的预处理结果就是:
printf("The first, second, and the third items.");

扩展语法,若##运算符用在__VA_ARGS__前面,除了起到连接作用外,当__VA_ARGS__为空参时,##会把其前面的,掉。这一特性常用来封装一些打印函数来进行内核调式,例如:

1
2
3
4
#define DEBUGP(format, ...) printf(format, ##  __VA_ARGS__)

// DEBUGP("info no. %d", 1) 会展开为 printf("info no. %d", 1)
// DEBUGP("info") 会展开为 printf("info"),注意展开式中的宏定义中的 format 后的逗号没有了。
#undef

#undef <macro>用于取消对macro的定义,若之前并没有定义则忽略掉,否相取消之且之后可以对取消的赋新值。

注意:若宏以头文件的方式引入,则#define语句在文件中的位置取决于#include指令的位置。

文件包含: #include

把被包含文件全部内容替换到#include处。

预处理器会通过以下规则在文件系统中寻找该文件:

  • 如果文件名在尖括号中,那么预处理器会在标准包含目录中查找该文件,在 Linux 系统中这个目录通常默认为 /usr/include,我们最常用的 stdio.h 就在这里。在使用 gcc 编译源代码时,可以使用 -I dir 参数将 dir 加入标准包含目录。
  • 对于用引号包含的头文件,预处理器会首先查找包含头文件的.c 文件所在的目录,然后查找标准包含目录。

条件预处理指令

#ifndef#endif

示例:

1
2
3
4
5
#ifndef _STDIO_H
#define _STDIO_H 1

...
#endif

其实ifndef就是if not defined,也就是假若之前并没有定义过_STDIO_H这个宏,在ifndefendif之间就会定义这个宏,否则就不会进行重定义。

上述代码出现在头文件<stdio.h>的开头,为的是避免重复包含头文件的内容,避免重复定义,这叫做Header Guard

其他的还有#ifdef#else#if#elif#error等是一样的用法。

typedef

区别于#define,它是给类型一个别名,在编译阶段完成,具有作用域,命名风格是以_t结尾,如integer_tptr_t

此外,typedef定义的别名在声明中不能和unsigned等其他类型说明符一同出现。

GDB

GDB:GNU Debugger,适用于C、C++、Go、Rust等语言的调试器。

GDB的安装

可以先通过$gdb -v查看当前Linux环境是否有安装,否则可以通过下列命令完成安装:

1
2
$ apt-get update
$ apt-get install gdb

加载程序

以一个简单程序为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// adds.c
#include <stdio.h>

int main() {
int n;
scanf("%d", &n);

int c = 0;
for (int i = 1; i <= n; i++) {
c += i;
printf("%d, ", c);
}
printf("\n");

return 0;
}

通过如下命令,可以得到名为add的可执行文件:

1
$ gcc add.c -o add

但这种方法产生的目标程序会去掉很多运行时不需要的信息,使得无法进行调试,加上-g选项,将保留许多额外的信息给调试器使用

tips:不启用和启用-g选项编译的两种产物,分别是目标程序的Release版和Debug版。难怪github上都是release版。

1
$ gcc -g add.c -o add

此后就可以让GDB大展手脚了。兄弟,GDB有两种方式可以让你大开眼界

  1. 直接在命令中指定,进入GDB交互界面:$ gdb add.

  2. 直接运行GDB进入交互界面,通过其的file <executable>指令加载可执行文件。

    1
    2
    $ gdb 
    (gdb) file add

当出现下列内容,说明加载成功:

Reading symbols from adds...

之后敲下$ (gdb) run并且等到光标一直在跳动,就可以进行程序的标准输入,检查输出了。这里的gdb大小括号,是指终端自带,不信你运行以下试试。

输入quit就可以关闭gdb啦!!!

传入参数

echo程序为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// echo.c
#include <stdio.h>
#include <stdlib.h>

void usage() {
printf("usage: echo <string>\n");
exit(-1);
}

int main(int argc, char *argv[]) {
if (argc != 2) {
usage();
}

printf("%s\n", argv[1]);
return 0;
}

根据仿造程序处的指令得到echo程序的Debugger版的可执行文件。不过由于再运行echo程序时要传入参数,此时gdb仍然是有两种方法:

  1. 对应于命令./echo 'hello, world!'(如果这里不用单引号括起来,那么一连串的输入不带空格的也是可以的,否则会报错,被认为不是字符串),gdb命令为:$ gdb --args ./echo 'hello, world!'

  2. 在gdb交互界面处设置参数:

    1
    2
    3
    $ gdb
    (gdb) file ./echo
    (gdb) set args 'hello, world!'

    上述操作后,再$ (gdb) run就可以运行了。

    tips:事实上set args [arg]...run两指令可以合为一条run [arg]...

一步一步来:追踪程序运行

以计算斐波那契数列程序为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// fibo.c
#include <assert.h>
#include <stdio.h>

int fibo(int n) {
assert(n > 0);
if (n <= 2) {
return 1;
} else {
return fibo(n - 1) + fibo(n - 2);
}
}

int main(int argc, char *argv[]) {
int n = 10;
int fibo_n = fibo(n);
printf("fibo(%d) = %d\n", n, fibo_n);

return 0;
}

编译后使用gdb执行可执行文件。

进入gdb后,使用start指令会使程序停在main函数起始处,会得到如下的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(gdb) start
Temporary breakpoint 1 at 0x11eb: file fib.c, line 15.
Starting program: /home/git/dir/fib
warning: Error disabling address space randomization: 不允许的操作

This GDB supports auto-downloading debuginfo from the following URLs:
<https://debuginfod.ubuntu.com>
Enable debuginfod for this session? (y or [n]) n
Debuginfod has been disabled.
To make this setting permanent, add 'set debuginfod enabled off' to .gdbinit.
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Temporary breakpoint 1, main (argc=1, argv=0x7ffdff09f4b8) at fib.c:15
15 int n = 10;

类似于runstart指令也可加参数:start [arg]...

想要一步步执行程序,需要使用step指令,如还需执行下一步,不用重复输入step,只需按回车即可。

此外,在函数内部跳出当前函数的指令是finish,相当于步出指令。以及用于执行当前所在行,跳过当前行中的任何函数调用的指令是next

continue指令是继续程序的正常运行,程序会继续运行直至退出

kill指令可杀死程序进程

上述指令其实就对应了编译器编译时候的那些按键:

设置断点

普通断点

还是以斐波那契数列为例:

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
// fibo.c
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>

int fibo(int n) {
assert(n > 0);
if (n <= 2) {
return 1;
} else {
return fibo(n - 1) + fibo(n - 2);
}
}

void usage() {
printf("usage: fibo <number>");
exit(-1);
}

int main(int argc, char *argv[]) {
if (argc != 2) {
usage();
}

int n = atoi(argv[1]);

for (int i = 1; i <= n; i++) {
int fibo_i = fibo(i);
printf("fibo(%d) = %d\n", i, fibo_i);
}

return 0;
}

为了定位到我们想要的代码的位置,可以使用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
2
3
4
(gdb) info breakpoints
Num Type Disp Enb Address What
1 breakpoint keep y 0x00005568632f0280 in main at fib.c:28
breakpoint already hit 1 time

若不需要这个断点,可直接删除,使用delete breakpoints [num]...指令即可。

这里的num是断点的编号哟,就是断点信息的Num那一列。

临时断点

由于start指令本质上是在main函数处添加一个断点,并运行程序,但运行start指令后得到的断点信息却与普通的并不相同:

1
2
3
4
5
6
7
8
9
(gdb) start 10
Temporary breakpoint 2 at 0x5568632f0251: file fib.c, line 21.
Starting program: /home/git/dir/fib 10
warning: Error disabling address space randomization: 不允许的操作
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Temporary breakpoint 2, main (argc=2, argv=0x7fff908ca508) at fib.c:21
21 if (argc != 2) {

因为这里是临时断点,不是普通断点,程序只暂停运行一次后就删除了。

设置临时断点的指令是tbreak,用法和break一致。

因此start指令等价于tbreak mainrun [arg]...两条指令。

条件断点

满足一点条件才中断程序运行的断点,相应的指令是break [position] if <condition>其中[position]部分与break指令的一致,而**<condition>则是一个在断点上下文符合语义的表达式**,例如,在ab都被定义的前提下,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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// watch.c
#include <stdio.h>
#include <stdlib.h>

void mysterious_func(int **pptr) {
free(*pptr);
// *pptr = NULL;
}

int main() {
int *ptr = NULL;

ptr = malloc(sizeof(int));
*ptr = 10;
printf("value is %d\n", *ptr);

mysterious_func(&ptr);
if (ptr != NULL) {
printf("value is %d\n", *ptr);
}

return 0;
}

上述代码中,mysterious_func函数调用free释放了pptr,指向的内存,却并没有将其置NULL。

导致程序之后的if判断成立下的语句访问了野指针,编译与执行可以通过,只是取得了一个无意义的数据,但在操作系统中发生的话,就会产生内核错误等严重问题。设定观察点就可以找到异常值的位置。

此外,还有可能遇到的是数组越界的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// watch2.c
#include <stdio.h>

int careless_sum(int arr[], int n) {
int i = 0, total = 0;
while (i < n) {
total += arr[++i];
// total += arr[i++];
}
return total;
}

int main() {
int arr[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
printf("total = %d\n", careless_sum(arr, 10));
return 0;
}

上述代码在i为9是,++i变成10访问arr[10]发生数组越界。因此我们只要监视程序何时访问了给定数组之后的地址即可,因此使用访问观察点awatch,在本例中也就是awatch arr[10]

查看运行时数据

除了之前介绍的程序流debug,当然还有数据流这个重要的方式。

backtrace:用于查看到目前位置的函数调用信息。

以上文的斐波那契数列的例子为例:

1
2
3
4
5
6
7
8
9
10
$ gdb fibo
(gdb) start 10
(gdb) break 28 if i == 10
(gdb) continue
(gdb) tbreak fibo if n == 1
(gdb) continue
Continuing.

Temporary breakpoint 3, fibo (n=1) at fibo.c:7
7 assert(n > 0);

利用上述命令到达fibo函数调用最深处。

然后利用backtrace即可获得所有调用栈信息:

1
2
3
4
5
6
7
8
9
10
11
(gdb) backtrace
#0 fibo (n=1) at fib.c:7
#1 0x0000558d77c9a210 in fibo (n=3) at fib.c:11
#2 0x0000558d77c9a201 in fibo (n=4) at fib.c:11
#3 0x0000558d77c9a201 in fibo (n=5) at fib.c:11
#4 0x0000558d77c9a201 in fibo (n=6) at fib.c:11
#5 0x0000558d77c9a201 in fibo (n=7) at fib.c:11
#6 0x0000558d77c9a201 in fibo (n=8) at fib.c:11
#7 0x0000558d77c9a201 in fibo (n=9) at fib.c:11
#8 0x0000558d77c9a201 in fibo (n=10) at fib.c:11
#9 0x0000558d77c9a28a in main (argc=2, argv=0x7ffc9adb5f58) at fib.c:28

不过这些信息只有函数的参数信息,我们是否束手无策了?不会的,兄弟,不会的,可以通过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]反汇编,具体形式有:disassembledisassemble <function>disassemble addressdisassemble <start>,<end>等。

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
(gdb) disassemble
Dump of assembler code for function main:
0x000055c7caef423e <+0>: endbr64
0x000055c7caef4242 <+4>: push %rbp
0x000055c7caef4243 <+5>: mov %rsp,%rbp
0x000055c7caef4246 <+8>: sub $0x20,%rsp
0x000055c7caef424a <+12>: mov %edi,-0x14(%rbp)
0x000055c7caef424d <+15>: mov %rsi,-0x20(%rbp)
=> 0x000055c7caef4251 <+19>: cmpl $0x2,-0x14(%rbp)
0x000055c7caef4255 <+23>: je 0x55c7caef4261 <main+35>
0x000055c7caef4257 <+25>: mov $0x0,%eax
0x000055c7caef425c <+30>: call 0x55c7caef4218 <usage>
0x000055c7caef4261 <+35>: mov -0x20(%rbp),%rax
0x000055c7caef4265 <+39>: add $0x8,%rax
0x000055c7caef4269 <+43>: mov (%rax),%rax
0x000055c7caef426c <+46>: mov %rax,%rdi
0x000055c7caef426f <+49>: call 0x55c7caef40a0 <atoi@plt>
0x000055c7caef4274 <+54>: mov %eax,-0x8(%rbp)
0x000055c7caef4277 <+57>: movl $0x1,-0xc(%rbp)
0x000055c7caef427e <+64>: jmp 0x55c7caef42ad <main+111>
0x000055c7caef4280 <+66>: mov -0xc(%rbp),%eax
0x000055c7caef4283 <+69>: mov %eax,%edi
0x000055c7caef4285 <+71>: call 0x55c7caef41a9 <fibo>
0x000055c7caef428a <+76>: mov %eax,-0x4(%rbp)
0x000055c7caef428d <+79>: mov -0x4(%rbp),%edx
0x000055c7caef4290 <+82>: mov -0xc(%rbp),%eax
0x000055c7caef4293 <+85>: mov %eax,%esi
0x000055c7caef4295 <+87>: lea 0xd89(%rip),%rax # 0x55c7caef5025
0x000055c7caef429c <+94>: mov %rax,%rdi
0x000055c7caef429f <+97>: mov $0x0,%eax
0x000055c7caef42a4 <+102>: call 0x55c7caef4080 <printf@plt>
0x000055c7caef42a9 <+107>: addl $0x1,-0xc(%rbp)
0x000055c7caef42ad <+111>: mov -0xc(%rbp),%eax
0x000055c7caef42b0 <+114>: cmp -0x8(%rbp),%eax
0x000055c7caef42b3 <+117>: jle 0x55c7caef4280 <main+66>
0x000055c7caef42b5 <+119>: mov $0x0,%eax
0x000055c7caef42ba <+124>: leave
0x000055c7caef42bb <+125>: ret
End of assembler dump.

注意:左侧出现的箭头表示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
2
3
4
.
├── bare.lds
├── minimal_hello_world.c
└── start.S

先是链接脚本bare.lds文件,其用于定义程序使用的内存分布,编译阶段需要用到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//bare.lds
OUTPUT_ARCH(mips)
ENTRY(_start)
SECTIONS {
. = 0x80000000; //这里将程序配置在0x80000000开始的一段地址上
.text : {
*(.text)
}
.data : {
*(.data)
}
bss_start = .;
.bss : {
*(.bss)
}
}

因为QEMU并不是完全裸机环境,程序实际上由QEMU内置的Bootloader加载而来的,Bootloader也已定义了处理器使用的虚拟地址空间(如此处的0x80000000),若不使用链接脚本而直接编译,将使用与Bootloader不兼容的地址空间,会导致报错。

然后是start.S文件,一个MIPS汇编文件,包含了裸机程序入口_start

1
2
3
4
5
.text
.globl _start
_start: #设置了栈指针寄存器,然后跳转至main
li $sp, 0x80400000
j main

最后是含有裸机程序实现逻辑的minimal_hello_world.c 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// minimal_hello_world.c
void printch(char ch) { *((volatile char *)(0xB80003f8U)) = ch; }

void print(char *str) {
while (*str != '\0') {
printch(*str);
str++;
}
}

void main() {
print("Hello, world!\n");
while (1) {
}
}

之后便可以编译文件了,只需使用交叉编译器mips-linux-gnu-gcc执行如下命令:

1
2
3
4
5
6
7
$ mips-linux-gnu-gcc \
-EL \
-nostdlib \
-T bare.lds \
-o hello_world.elf \
minimal_hello_world.c \
start.S

此处若换行输入的话\是必须的,不然一个Enter下去,终端就当命令吃下去了,其实也可以全部写在一行,但是之间需要通过空格间隔。

编译产生的目标程序为hello_world.elf。接着用QEMU运行。

简要介绍一下QEMU命令都有啥?所有的QEMU命令都为qemu-*的形式。而对于某一体系架构下的模拟,需使用qemu-system-*命令。例如对于小端序的mips架构,命令就为qemu-system-mipsel,此外还提供了其他的一些命令行工具,如qemu-img是用于创建、转换和修改磁盘镜像的命令。

使用qemu-system-mipsel运行我们的目标文件,运行如下命令,可得到Hello, world!输出:

1
2
3
4
5
6
7
8
9
$ qemu-system-mipsel \  #指定架构为mips,且为小端存储模式
-m 64 \ #(注释,下同)用于指定虚拟机内存大小
-nographic \ #表示模拟中不使用图形界面,使用串口输出
-M malta \ #用于指定要模拟的目标机器,此处模拟MIPS Malta
-no-reboot \ #虚拟机在请求重启时会直接退出而非重启
-kernel hello_world.elf #指定要启动的内核,此处是 hello_world.elf
#此处还可以通过-device选项设定仿真设备
Hello, world!
#程序在输出Hello,world!后会进入死循环,此时先按下ctrl+A,随后单独按下X,即可退出模拟。

QEMU中GDB调试

首先为了支持GDB,编译的时候需要加上-g也就是把Release版改成Debug版:

1
2
3
4
5
6
7
8
$ mips-linux-gnu-gcc \
-g \ #就是多了这一行
-EL \
-nostdlib \
-T bare.lds \
-o hello_world.elf \
minimal_hello_world.c \
start.S

接着在原有的运行命令基础上还需要加入-s-S两个选项,前者用于等待GDB连接到1234端口,而后者用于让模拟器不要一开始就启动处理器

没想到吧!GDB和QEMU靠远程连接完成协作,这种远程调试的方式不同与平常,但能让我们调试位于远程主机上的程序,适应不同的调试情况(如现在)。

启动QEMU,为了在同一个终端中运行这两个玩意,QEMU需在后台运行,因此必须将其标准输入重定向为/dev/null

1
2
3
4
5
6
7
8
9
10
$ qemu-system-mipsel \
-s \ #等待GDB连接到1234端口
-S \ #让模拟器不要一开始就启动处理器
-m 64 \
-nographic \
-M malta \
-no-reboot \
-kernel hello_world.elf \
< /dev/null \ #重定向
&

此时使用ps命令可以查看当前终端下运行的进程,即可看到刚启动的QEMU。

1
2
3
4
5
$ ps
PID TTY TIME CMD
1685 pts/0 00:00:00 bash
1728 pts/0 00:00:00 qemu-system-mip
1734 pts/0 00:00:00 ps

由于实验环境是x86架构,但调试的程序是mips架构,而调试程序应当像编译程序或模拟程序一样,使用特定架构的编译器和模拟器

这就不得不提到gdb的多架构版本gdb-multiarch不同于交叉编译器,gdb-multiarch一次性提供了对很多架构的支持,包括mips(使用gdb-multiarchset 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调试时不能使用runstart,其余调试手段同纯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
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
include include.mk

terget_dir := terget #MOS构建目标所在的目录
mos_elf := $(target_dir)/mos # 最终需要生成的 ELF 可执行文件
user_disk := $(target_dir)/fs.img # MOS 文件系统使用的磁盘镜像文件
link_script := kernel.lds #用于指导链接器将多个.o文件链接成目标可执行文件的脚本

modules := lib init kern # 需要生成的子模块,定义了内核所包含的所有模块
objects := $(addsuffix /*.o, $(modules)) # 遍历需要生成的目标文件,代表要编译出内核所依赖的所有目标文件
QEMU_FLAGS := -cpu 4Kc -m 64 -nographic -M malta \
$(shell [ -f '$(user_disk)' ] && \
echo '-drive id=ide,file=$(user_disk),if=ide,format=raw') \
-no-reboot # QEMU 运行参数
#\代表当前行并未结束,下一行内容同该行连在一起,使文件具有可读性

.PHONY: all $(modules) clean
#.PHONY表示其后的目标不受修改时间的约束,即一旦该规则被调用,无视make工具编译时有关时间戳的性质,无论依赖文件是否被修改,一定保证其被执行

all: $(mos_elf) # “最终目标”,代表整个项目,表明构建整个项目依赖于构建好内核可执行文件$(mos_elf)

$(mos_elf): $(modules) # 调用链接器 $(LD) 链接所有目标文件,表明构建$(mos_elf)依赖于构建所有模块
$(LD) $(LDFLAGS) -o $(mos_elf) -N -T $(link_script) $(objects) #调用了链接器将此前构建各模块产生的所有.o文件在linker_script指导下链接在一起,产生最终的mos可执行文件

$(modules): # 进入各个子目录进行 make
$(MAKE) --directory=$@

clean:
for d in $(modules); do \
$(MAKE) --directory=$$d clean; \
done; \
rm -rf *.o *~ $(mos_elf)

run:
$(QEMU) $(QEMU_FLAGS) -kernel $(mos_elf)

你或许会有疑问:明明并没有定义MAKECC等变量,怎么就引用了呢?难道是外部”库”在发挥作用?是的,代码首行的include include.mk引用了外部”库”:

1
2
3
4
5
6
7
8
			include.mk
CROSS_COMPILE := mips-linux-gnu- #是实际使用的编译器和链接器等工具的前缀,或说是交叉编译器的具体位置
CC := $(CROSS_COMPILE)gcc
CFLAGS += --std=gnu99 -EL -G 0 -mno-abicalls -fno-pic \
-ffreestanding -fno-stack-protector -fno-builtin \
-Wa,-xgot -Wall -mxgot -mno-fix-r4000 -march=4kc
LD := $(CROSS_COMPILE)ld
LDFLAGS += -EL -G 0 -static -n -nostdlib --fatal-warnings

不过MAKEMakefile中预定义的变量,并不在这里定义。

ELF——操作系统内核的本质(解析内核文件)

源代码文件需要经过编译和链接两个阶段,才可得到可执行文件。其中,由于编译后完整程序的地址布局并未确定,需要在链接阶段,由链接器将所有目标文件连接在一起,并填写具体地址等信息,形成可执行文件。

但是,链接器怎么知道具体位置的?其实是依靠目标文件中代码各个段的具体信息来进行链接的。ELF(Executable and Linkable Format)正是Unix系统上常用的一种目标文件格式,此外可执行文件和库的文件格式都是ELF.o文件就是ELF包含的三种文件类型的一种,称为可重定位文件,此外的两种分别为可执行文件共享对象文件,这两者都要求链接器对.o文件进行处理才可生成

Tips:可以通过file命令来获取文件的类型。例如:

1
2
git@23371355:~ $ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

ELF文件结构

ELF主要分5部分:

1.ELF头,含有程序基本信息,如体系结构OS,同时包含节头表和段头表相对文件的偏移量

2.段头表(程序头表),包含程序各段信息,需在运行时使用

3.节头表,包含程序各节信息,需在编译和链接时使用。

4.段头表中每一个表项,记录该段数据载入内存时目标位置等用于指导应用程序加载的各类信息

5.节头表中每个表项,记录该节数据程序代码段、数据段等各段内容,记录的是该节数据的文件内偏移和地址信息等,主要在链接时使用。

其实,段头表和节头表指向的地方一样,因此,它们只是程序数据的两种视图?(双重人格):

1.使用节头表来组成.o文件,参与可执行文件和可共享文件的链接

2.段头表来组成可执行或可共享文件,运行时为加载器提供信息。

ELF文件头代码:

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/* The ELF file header.  This appears at the start of every ELF file.  */

#define EI_NIDENT (16)

typedef struct {
unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */
Elf32_Half e_type; /* Object file type */
Elf32_Half e_machine; /* Architecture */
Elf32_Word e_version; /* Object file version */
Elf32_Addr e_entry; /* Entry point virtual address */
Elf32_Off e_phoff; /* Program header table file offset */
Elf32_Off e_shoff; /* Section header table file offset */
Elf32_Word e_flags; /* Processor-specific flags */
Elf32_Half e_ehsize; /* ELF header size in bytes */
Elf32_Half e_phentsize; /* Program header table entry size */
Elf32_Half e_phnum; /* Program header table entry count */
Elf32_Half e_shentsize; /* Section header table entry size */
Elf32_Half e_shnum; /* Section header table entry count */
Elf32_Half e_shstrndx; /* Section header string table index */
} Elf32_Ehdr;

/* Fields in the e_ident array. The EI_* macros are indices into the
array. The macros under each EI_* macro are the values the byte
may have. */

#define EI_MAG0 0 /* File identification byte 0 index */
#define ELFMAG0 0x7f /* Magic number byte 0 */

#define EI_MAG1 1 /* File identification byte 1 index */
#define ELFMAG1 'E' /* Magic number byte 1 */

#define EI_MAG2 2 /* File identification byte 2 index */
#define ELFMAG2 'L' /* Magic number byte 2 */

#define EI_MAG3 3 /* File identification byte 3 index */
#define ELFMAG3 'F' /* Magic number byte 3 */

/* Section segment header. */
typedef struct {
Elf32_Word sh_name; /* Section name */
Elf32_Word sh_type; /* Section type */
Elf32_Word sh_flags; /* Section flags */
Elf32_Addr sh_addr; /* Section addr */
Elf32_Off sh_offset; /* Section offset */
Elf32_Word sh_size; /* Section size */
Elf32_Word sh_link; /* Section link */
Elf32_Word sh_info; /* Section extra info */
Elf32_Word sh_addralign; /* Section alignment */
Elf32_Word sh_entsize; /* Section entry size */
} Elf32_Shdr;

/* Program segment header. */

typedef struct {
Elf32_Word p_type; /* Segment type */
Elf32_Off p_offset; /* Segment file offset */
Elf32_Addr p_vaddr; /* Segment virtual address */
Elf32_Addr p_paddr; /* Segment physical address */
Elf32_Word p_filesz; /* Segment size in file */
Elf32_Word p_memsz; /* Segment size in memory */
Elf32_Word p_flags; /* Segment flags */
Elf32_Word p_align; /* Segment alignment */
} Elf32_Phdr;
/* End of Key Code "readelf-struct-def" */

通过阅读相关代码可知,ELF文件头实际上存了关于ELF文件信息的结构体,其中存储的魔数是有效ELF的通行证,是用来判断文件类型的唯一依据。此外,其中的节头表入口偏移e-shoff是用来定位节头表第一项的地址的,用ELF的文件头地址加上入口偏移得到的就是其第一项的地址。

有一种随编译工具链提供的工具readelf用法为readelf [option(s)] <elf-file(s)>,来解析ELF文件信息。可以通过readelf --help来了解其功能。

通过readelf -l xxx来解析ELF文件的内容:

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
Elf 文件类型为 EXEC (可执行文件)
Entry point 0x8049750
There are 8 program headers, starting at offset 52

程序头:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000000 0x08048000 0x08048000 0x001e0 0x001e0 R 0x1000
LOAD 0x001000 0x08049000 0x08049000 0x657c0 0x657c0 R E 0x1000
LOAD 0x067000 0x080af000 0x080af000 0x30a80 0x30a80 R 0x1000
LOAD 0x0989e8 0x080e09e8 0x080e09e8 0x03524 0x062a0 RW 0x1000
NOTE 0x000134 0x08048134 0x08048134 0x00044 0x00044 R 0x4
TLS 0x0989e8 0x080e09e8 0x080e09e8 0x0000c 0x0002c R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x10
GNU_RELRO 0x0989e8 0x080e09e8 0x080e09e8 0x02618 0x02618 R 0x1

Section to Segment mapping:
段节...
00 .note.gnu.build-id .note.ABI-tag .rel.plt
01 .init .plt .text .fini
02 .rodata .eh_frame .gcc_except_table
03 .tdata .init_array .fini_array .data.rel.ro .got .got.plt .data .bss
04 .note.gnu.build-id .note.ABI-tag
05 .tdata .tbss
06
07 .tdata .init_array .fini_array .data.rel.ro .got

其中只需关心,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个大区域:

MIPS内存布局

其中:

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转换的虚地址空间。则内核只能放在kseg0kseg1中。而后者不经过cache,一般来说,利用MMIO访问外设时才使用kseg1,因此将内核的.text.data.bss段放在前者,将bootloader放在后者,这样一来载入内存前在kseg1中的bootloader会进行cache初始化工作,使得kseg0可以进行存取。

需要注意的是,kusegkseg0kseg1kseg2位于不同的虚拟地址空间,但都映射到同一个物理地址空间,不同的只在于映射方式和访问权限。

内存布局大致如下,其中KERNBASE是内核镜像起始地址:

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
/*
o 4G -----------> +----------------------------+------------0x100000000
o | ... | kseg2
o KSEG2 -----> +----------------------------+------------0xc000 0000
o | Devices | kseg1
o KSEG1 -----> +----------------------------+------------0xa000 0000
o | Invalid Memory | /|\
o +----------------------------+----|-------Physical Memory Max
o | ... | kseg0
o KSTACKTOP-----> +----------------------------+----|-------0x8040 0000-------end
o | Kernel Stack | | KSTKSIZE /|\
o +----------------------------+----|------ |
o | Kernel Text | | PDMAP
o KERNBASE -----> +----------------------------+----|-------0x8002 0000 |
o | Exception Entry | \|/ \|/#此处是预留的异常处理空间
o ULIM -----> +----------------------------+------------0x8000 0000-------
o | User VPT | PDMAP /|\
o UVPT -----> +----------------------------+------------0x7fc0 0000 |
o | pages | PDMAP |
o UPAGES -----> +----------------------------+------------0x7f80 0000 |
o | envs | PDMAP |
o UTOP,UENVS -----> +----------------------------+------------0x7f40 0000 |
o UXSTACKTOP -/ | user exception stack | PTMAP |
o +----------------------------+------------0x7f3f f000 |
o | | PTMAP |
o USTACKTOP ----> +----------------------------+------------0x7f3f e000 |
o | normal user stack | PTMAP |
o +----------------------------+------------0x7f3f d000 |
a | | |
a ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
a . . |
a . . kuseg
a . . |
a |~~~~~~~~~~~~~~~~~~~~~~~~~~~~| |
a | | |
o UTEXT -----> +----------------------------+------------0x0040 0000 |
o | reserved for COW | PTMAP |
o UCOW -----> +----------------------------+------------0x003f f000 |
o | reversed for temporary | PTMAP |
o UTEMP -----> +----------------------------+------------0x003f e000 |
o | invalid memory | \|/
a 0 ------------> +----------------------------+ ----------------------------
o
*/

Linker Script——控制内核加载地址

为了解决不同平台的ABI不一致带来的链接器通用性差的问题,Linker Script诞生了。其记录了各个节应如何映射到段,即各个段应被加载到的位置

采用ld --verbose可以查看默认的链接脚本。

此处补充一下ELF文件中节的概念,链接过程中,目标文件被看作是节的集合,并用节头表来描述各个节的组织,即节记录了链接过程中所需的必要信息。.text(保存可执行文件的操作指令,包含了可执行文件中的代码)、.data(保存已初始化全局变量和静态变量)、.bss(保存未初始化全局变量和静态变量,包含未初始化的全局变量和静态变量)三个节最为重要。

一个通过Linker Script调整各节位置的例子:(下列代码编写在.lds文件中)

1
2
3
4
5
6
7
8
9
10
11
SECTIONS
{
. = 0x10000; #用作定位计数器,根据输出节的大小增长,在SECTIONS命令开始时值为0,通过设置其可设置接下来节的起始地址。
.text : { *(.text) }
. = 0x8000000;
.data : { *(.data) }
.bss : { *(.bss) }
#此处的*是通配符,用来匹配所有目标文件,其右侧()中表示对应文件中相应的节,例如此处就是将所有输入文件中的.bss节放到输出的.bss节中,即指定的内存位置处
}
#通过上述方式在生成程序中各节位置就被调整到指定的地址上,由于段是由节组合来的,因此最终段的地址也被相应调整
#此外在Linker Script中还可通过ENTRY(symbol)来设置程序入口

从零开始搭建MOS

从make开始

主要就是make到底make了啥?

_start函数

start.S函数代码如下:

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
#include <asm/asm.h>
#include <mmu.h>

.text
EXPORT(_start) #将_start函数导出作为一个符号,使链接器可寻。
.set at #允许使用at寄存器
.set reorder #允许对后续代码进行重排序
/* Lab 1 Key Code "enter-kernel" */
/* clear .bss segment */
la v0, bss_start
la v1, bss_end
clear_bss_loop:
beq v0, v1, clear_bss_done
sb zero, 0(v0)
addiu v0, v0, 1
j clear_bss_loop
/* End of Key Code "enter-kernel" */

clear_bss_done:
/* disable interrupts */
mtc0 zero, CP0_STATUS #禁用了外部中断

/* hint: you can refer to the memory layout in include/mmu.h */
/* set up the kernel stack 将sp寄存器设置到内核栈空间位置上*/
/* Exercise 1.3: Your code here. (1/2) */
la sp, KSTACKTOP
#lui sp, 0x8040
#li sp, 0x80400000
/* jump to mips_init 跳转到mips_init函数*/
/* Exercise 1.3: Your code here. (2/2) */
/*此处通过jal或j等跳转指令跳转到函数符号对应的地址,
也即指令的存储地址来实现调用函数,
链接时,链接器会对目标文件中的符号进行重定位,
使跳转指令中的地址指向正确的函数,从而实现跨文件的函数调用*/
jal mips_init

printk函数

由于C语言的标准库是建立在OS之上的,而失去了标准库的C语言将寸步难行,因此,在开发OS的过程中需要所有需要的东西都得自行编写。

下列三个文件:kern/printk.c(实现了printk,但实则只是将输出字符的函数和接受的输出参数传递给vprintfmt函数),lib/print.c(实现了vprintfmt函数,实现格式化输出)和kern/machine.c(复负责往QEMU控制台输出字符,实则读写某个特殊内存地址)三个文件将实现从内核将信息输出到控制台

先看看kern/printk.c内部的printk函数:

1
2
3
4
5
6
void printk(const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
vprintfmt(outputk, NULL, fmt, ap);
va_end(ap);
}

你或许会疑惑了这va_listva_startva_end都是些啥啊?或许你在python里用过拥有变长参数的函数:

1
def function(*para):

那么比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
2
3
4
5
6
7
va_list ap;
va_start(ap, lastarg)
#这里的lastarg是函数最后一个命名的形参,初始化后,每次可用va_arg宏来获取一个形参,该宏也同时会修改ap使得下次被调用将返回当前获取参数的下一个参数,近似为pop的功能
#例:
int num;
num = va_arg(ap, int);
#所有参数处理完毕后,退出函数前,需调用va_end宏来结束变长参数表的使用

然后还是在kern/printk.c中,来看看outputk()函数:

1
2
3
4
5
6
void outputk(void *data, const char *buf, size_t len) {
for (int i = 0; i < len; i++) {
printcharc(buf[i]);
}
}
#实际上是通过调用printcharc()这个函数来输出一个字符串

printcharc()函数在kern/machine.c中可以一探究竟:

1
2
3
4
5
6
7
8
9
void printcharc(char ch) {
if (ch == '\n') {
printcharc('\r');
}
while (!(*((volatile uint8_t *)(KSEG1 + MALTA_SERIAL_LSR)) & MALTA_SERIAL_THR_EMPTY)) {
}
*((volatile uint8_t *)(KSEG1 + MALTA_SERIAL_DATA)) = ch;
}
#实际上就是对某一内存地址写了一个字节

最后就是实际实现printk功能的在lib/print.cvoid 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访存将变为:
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
    9
     void 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
    31
    void *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
    3
    if(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
    17
    void _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
    51
    void _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函数:

  1. extern char end[]:一个定义在文件外的变量,在kernel.lds中:

    1
    2
    . = 0x80400000;
    end = . ;

    由于其在kseg0段,因此其对应的实地址为0x400000,该实地址前,放着OS内核代码和定义的全局变量或数组。此后从该实地址开始分配物理内存,来建立管理内存的数据结构

  2. u_long alloced_mem存放”已分配物理内存空间的首地址“的变量。

  3. 接下来的条件判断中freemem是一个全局变量,初始值为0,第一次调用该函数时将其赋值为end
    其表明小于其对应的物理内存都被分配完了。(不包含,也就是从当前值开始的物理内存空间是free的)

  4. 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)

  5. alloced_mem = freemem是其前者赋值为”将要分配的存储空间的起始地址”。

  6. freemem = freemem + n,就是代表空闲的物理内存地址要从此后长度n后的再开始,便于下一次的分配空闲空间。

  7. if(PADDR(freemem) >= maxpa) {...}中的PADDR(x)是一个返回虚拟地址x的实地址的宏,其要求x一定为kseg0中的虚地址,此段代码就是检查分配的空间是否超出最大物理地址

    与之相对的还有KADDR(x)返回实地址对应的在kseg0上的虚地址。

  8. if(clear) {...}:为真清空此部分内存

  9. 最后返回初始化的虚地址。

物理内存管理

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_nextle_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中的page2papa2page两个函数。
空闲物理页对应的Page结构体全插入一个链表中,其为空闲链表(page_free_list)。
一个进程需要内存时,需将空闲链表头部的页控制块对应的那一页物理内存分配出去,并将该页控制块从空闲块中删去。
一页物理内存被使用完毕时(即引用次数为0),将其对应页控制块重新插入空闲链表头部。

1
2
3
4
5
6
7
8
9
10
11
12
typedef LIST_ENTRY(Page) Page_LIST_entry_t; //一个指向链表项结构体的指针类型,即pp_link为相应的链表项
struct Page {
Page_LIST_entry_t pp_link; /* free list link */

// Ref is the count of pointers (usually in page table entries)
// to this page. This only holds for pages allocated using
// page_alloc. Pages allocated at boot time using pmap.c's "alloc"
// do not have valid reference count fields.

u_short pp_ref; //代表这一页物理内存被引用次数,等于映射为物理页的虚拟页数,
//当其为0时代表该pp_ref物理页未被引用,可被分配
};

页式内存管理中,一个物理页可被多个虚拟页映射,以达到内存共享的目的。
譬如在fork过程中,子进程的代码段可与父进程共享以节省内存空间,此时pp_ref会增加,而COW页在复制时,会通过unmap函数减少pp_ref来实现系统及时回收内存。

其余重要函数

pmap.h中还有四个以page_开头的函数:

1
2
3
4
5
6
void page_init(void); //初始化物理页面管理
void *alloc(u_int n, u_int align, int clear);

int page_alloc(struct Page **pp); //分配物理页面
void page_free(struct Page *pp); //回收物理页面到空闲页面链表
void page_decref(struct Page *pp); //减少物理页面引用
  • page_init()做了:
    1. 利用链表相关宏初始化page_free_list来存储”未被使用”页控制块。
    2. 将已被OS使用的空间的地址标记进行页对齐。因为后续分配内存时都以页为单位,因此物理地址需按页对齐,也就是分配页时分配一整个页的,并不因为没有用满而将剩下的分配给别的进程。
    3. 给已用空间对应所有物理页面的页控制块的引用次数全置1,可通过已建立的页控制块数组访问。
    4. 将剩下物理页面引用次数置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_COWPTE_LIBRARY不对应任何硬标位,仅在页表部分操作中被OS使用)。当页表项通过EntryLo存入TLB时,会右移6位,移出软标位,仅填入前26位。每个页表占4KB,与一个物理页大小相同
由于一个页表项可由一个32位整型表示,因此使用PdePte表示一二级页表项类型,都是u_long,因此,对于一个Pde *类型指针pgdirpgdir + 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组成。

EntryHiEntryLo0EntryLo1都为CP0寄存器,分别对应TLB的Key和两组Data,但不是TLB本身。

其中EntryLo0、EntryLo1有完全相同的位结构,前者存储Key对应偶页,后者存储奇页

4Kc中奇偶页的设计,即用EntryHi中的VPN高19位和ASID作为Key,一次找到两个Data(一对相邻页面的两个页表项),并用VPN中的最低1位在两个Data中选择命中结果。因此对TLB维护(无效化、重填)时,除维护目标页面,还要维护目标页面的相邻页面。
相应结构如下:
EntryHi&EntryLo

  • EntryHi
    • VPN:Virtual Page Number
      TLB缺失(CPU发虚地址,TLB查找对应物理地址未查到)时,EntryHi中VPN自动(由硬件)填充为对应虚地址虚页号
      检索或填充TLB表项时,由软件将VPN段填充为对应虚地址。
    • ASID:Address Space IDentifier
      区分不同地址空间的标识,由于同一虚地址在不同地址空间中通常映射到不同物理地址,因此查找TLB表项时,除了要提供 VPN还有ASID。
  • 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)组成,其中JTLBTLB主要单元,而后两者由硬件维护其与JTLB之间的一致性。
      TLB相关指令

  • tlbr:以Index寄存器中值为索引,读出TLB中对应表项到EtryHiEntryLo1EntryLo0中。
  • tlbwi,以Index寄存器值为索引,将EtryHiEntryLo1EntryLo0值写入索引指定TLB表项中。
  • tlbwr,将EtryHiEntryLo1EntryLo0的数据随机写到一个TLB表项中(使用Random进行随机指定表项)
  • tlbp,根据EtryHi中的(VPN+ASID),查找TLB中与之对应的表项,并将其索引存入Index寄存器中,若未找到,则Index最高位置1
    软件操作TLB流程通常为先填写CP0寄存器再使用TLB相关指令。

TLB维护流程

实验使用的MPIS 4KcMMU中只有TLB,在用户地址空间访存时,虚地址到物理地址的转换均通过TLB进行。先用VPNASIDTLB中查询该虚地址对应的物理页号,若存在对应的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),使用mtc0Key写入EntryHi,再用tlbp根据EntryHiKey查找相应旧表项,将其索引存入Index,索引值>=0,向EtryHiEntryLo1EntryLo0中写入0,之后使用tlbwi指令,将EtryHiEntryLo1EntryLo0的值写入索引指定表项。此时旧表项keyData清零,已无效。

TLB重填

do_tlb_refill函数完成,因为4Kc中有奇偶页设计,其需要重填触发异常的页面及邻居页面。重填时先将两个页面对应页表项写入EntryLo寄存器,再填入TLB
大体流程如下:

  1. BadVAddr中取出引出缺失的虚地址。
  2. EntryHi的0-7位取出当前进程的ASID
  3. 先在栈上为返回地址、待填入TLB页表项及函数参数传递预留空间。以存储奇偶页表项地址、触发异常虚地址和ASID为参数,调用_do_tlb_refill函数,其完成了依据虚地址和ASID查找页表并将对应奇偶页表项写回第一个参数指定地址
  4. 页表项存入EntryLo0和EntryLo1,执行tlbwrEtryHiEntryLo1EntryLo0随机写入TLB一个页表项中。

    page_llokup()函数在页表中找不到对应表项时,可通过passive_alloc()函数进行处理。若虚地址合法,可为其申请二级页表物理页面(page_alloc),并将虚地址映射到物理页面(page_insert)上。

进程与异常

进程

每个进程都是一个实体,有地址空间,包括代码段、数据段和堆栈。程序是无生命实体,只由OS赋予生命,由进程来执行。

进程控制块

进程控制块(PCB):用于管理进程数据结构,来描述进程变化过程,是系统感知进程存在的唯一标志,与进程一一对应

PCB由一个Env结构体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct Env {
struct Trapframe env_tf; // saved context (registers) before switching,保存当前进程上下信息对应结构体Trapframe,发生调度时或陷入内核时,会将当时进程上下文环境保存在该变量中
LIST_ENTRY(Env) env_link; // intrusive entry in 'env_free_list',构造空闲进程链表env_free_list
u_int env_id; // unique environment identifier,进程号
u_int env_asid; // ASID of this env
u_int env_parent_id; // env_id of this env's parent,父进程进程号
u_int env_status; // status of this env,进程控制块状态,ENV_FREE(空闲,未被使用即该块处于空闲链表中)、ENV_NOT_RUNNABLE(阻塞,一定条件下进入就绪状态)、ENV_RUNNABLE(执行或就绪,可能是正运行或正等待)
Pde *env_pgdir; // page directory,进程页目录内核虚拟地址
TAILQ_ENTRY(Env) env_sched_link; // intrusive entry in 'env_sched_list',构造调度队列env_sched_list
u_int env_pri; // schedule priority,该进程的优先级
// Lab 4 IPC
u_int env_ipc_value; // the value sent to us
u_int env_ipc_from; // envid of the sender
u_int env_ipc_recving; // whether this env is blocked receiving
u_int env_ipc_dstva; // va at which the received page should be mapped
u_int env_ipc_perm; // perm in which the received page should be mapped
// Lab 4 fault handling
u_int env_user_tlb_mod_entry; // userspace TLB Mod handler
// Lab 6 scheduler counts
u_int env_runs; // number of times we've been env_run'ed
};

采用调度队列env_sched_list和空闲队列env_free_list来管理PCB,前者便于管理已分配PCB和对进程调度,采用的是类似的TAILQ结构。后者用来存储空闲的PCB,采用的是lab2的LIST结构。

段地址映射

env_init函数最后,使用page_alloc函数为模板页表base_pgdir分配一页物理内存,并将其转换为虚地址,用map_segment函数在该页表中将内核数组pagesenvs映射到用户空间的UPAGESUENVS处,供用户程序读取。
段地址映射函数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创建进程:

  1. env_free_list中申请一个空闲PCB。
  2. 手动初始化PCB。
  3. 为新进程初始化页目录。
  4. 将该PCB从空闲链表中去除,用于使用。

4Kc中的Status寄存器
其中手动初始化PCB,用到了MPIS 4KcStatus寄存器的几个重要的位:

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
// lib/elfloader.c
const Elf32_Ehdr *elf_from(const void *binary, size_t size);
int elf_load_seg(Elf32_Phdr *ph, const void *bin,elf_mapper_t map_page, void *data);
//map_page和data用于接受一个自定义回调函数(前者)和需传递给回调函数的额外参数(后者),当该函数解析到一个需加载到内存中的页面时,会将有关信息作为参数传给回调函数,由其完成加载
//从ph中获取va(当前段被加载到的虚地址)、sgsize(段在内存的大小)、bin_size(段在文件中大小)和perm,完成:
// 1.加载段所有数据(bin)中的所有内容至内存(va);2.若段在文件中大小小于新分配页面大小,余下部分由0填充

// kern/env.c
static void load_icode(struct Env *e, const void *binary, size_t size);
//负责加载可执行文件binary到进程e内存中,其调用的elf_from函数完成对ELF文件头的解析,elf_load_seg负责将ELF文件的一个segment加载到内存。
//从ELF文件中解析出每个segment段头ph,及其数据在内存中的起始位置bin,再由elf_load_seg函数将参数指定的程序段加载到地址空间
static int load_icode_mapper(void *data, u_long va, size_t offset, u_int perm, const void *src, size_t len);
//回调函数map_page的具体实现

创建进程

这是指在OS内核初始化时直接创建,而非通过fork等系统调用创建。
创建过程极为简单:1.分配一个新Env结构体。2.设置PCB。3.将程序载入到目标进程地址空间中。
当然这里还需要提及两个宏:

1
2
3
4
5
6
7
8
9
10
11
12
13
#define ENV_CREATE_PRIORITY(x, y)                                                                  \
({ \
extern u_char binary_##x##_start[]; \
extern u_int binary_##x##_size; \
env_create(binary_##x##_start, (u_int)binary_##x##_size, y); \
})

#define ENV_CREATE(x) \
({ \
extern u_char binary_##x##_start[]; \
extern u_int binary_##x##_size; \
env_create(binary_##x##_start, (u_int)binary_##x##_size, 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()函数来实现,其功能大致如下:

  1. 保存当前进程上下文信息,一般保存在env_tf成员变量中,实验中存放寄存器状态的地方是KSTACKTOP以下的一个sizeof(TrapFrame)大小的区域中,将后者拷贝到前者,就可达到保存进程上下文的效果。
  2. 切换curenv为即将运行进程e
  3. 设置全局变量cur_pgdir为当前进程页目录地址。
  4. 调用env_pop_tf函数,恢复现场,异常返回。

中断与异常

处理流程

处理异常大致流程:

  1. 设置EPC指向异常返回的地址。
  2. EXL位,强制CPU进入内核态(进入特权模式)并禁止中断,可不能中断套娃
  3. 设置Cause寄存器,记录异常发生原因。
  4. CPU开始从异常入口处取指,此后交由异常处理程序处理。

异常分发

异常分发程序将检测发生了哪种异常,并调用相应的异常处理程序,一般被要求固定在某个物理地址上,确保正确的跳转
实验的异常分发程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.section .text.exc_gen_entry
exc_gen_entry:
SAVE_ALL #将当前上下文保存到内核异常栈中
/*
* Note: When EXL is set or UM is unset, the processor is in kernel mode.
* When EXL is set, the value of EPC is not updated when a new exception occurs.
* To keep the processor in kernel mode and enable exception reentrancy,
* we unset UM and EXL, and unset IE to globally disable interrupts.
*/
mfc0 t0, CP0_STATUS
and t0, t0, ~(STATUS_UM | STATUS_EXL | STATUS_IE) #清除Status寄存器值中的UM、EXL、IE位,使CPU保持内核态、关中断并允许嵌套异常
mtc0 t0, CP0_STATUS #写入Status寄存器中
/* Exercise 3.9: Your code here. */
mfc0 t0, CP0_CAUSE
andi t0, 0x7c #取Cause寄存器中的2-6位,即异常的类型码
lw t0, exception_handlers(t0) #以异常码为索引在exception_handlers数组里找对应中断处理函数
jr t0 #跳转到相应的中断处理程序处

SAVE_ALL代码如下:

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
51
52
53
54
55
56
57
58
59
60
61
.macro SAVE_ALL
.set noat
.set noreorder
mfc0 k0, CP0_STATUS
andi k0, STATUS_UM #检查待处理异常是否在内核态触发(异常重入),也就是通过判断UM位是否为0判断,0即代表异常重入
beqz k0, 1f #异常重入下,sp寄存器存储指针已指向内核异常栈
move k0, sp #非异常重入需要手动置k0
/*
* If STATUS_UM is not set, the exception was triggered in kernel mode.
* $sp is already a kernel stack pointer, we don't need to set it again.
*/
li sp, KSTACKTOP
1:
subu sp, sp, TF_SIZE
sw k0, TF_REG29(sp)
mfc0 k0, CP0_STATUS
sw k0, TF_STATUS(sp)
mfc0 k0, CP0_CAUSE
sw k0, TF_CAUSE(sp)
mfc0 k0, CP0_EPC
sw k0, TF_EPC(sp)
mfc0 k0, CP0_BADVADDR
sw k0, TF_BADVADDR(sp)
mfhi k0
sw k0, TF_HI(sp)
mflo k0
sw k0, TF_LO(sp)
sw $0, TF_REG0(sp)
sw $1, TF_REG1(sp)
sw $2, TF_REG2(sp)
sw $3, TF_REG3(sp)
sw $4, TF_REG4(sp)
sw $5, TF_REG5(sp)
sw $6, TF_REG6(sp)
sw $7, TF_REG7(sp)
sw $8, TF_REG8(sp)
sw $9, TF_REG9(sp)
sw $10, TF_REG10(sp)
sw $11, TF_REG11(sp)
sw $12, TF_REG12(sp)
sw $13, TF_REG13(sp)
sw $14, TF_REG14(sp)
sw $15, TF_REG15(sp)
sw $16, TF_REG16(sp)
sw $17, TF_REG17(sp)
sw $18, TF_REG18(sp)
sw $19, TF_REG19(sp)
sw $20, TF_REG20(sp)
sw $21, TF_REG21(sp)
sw $22, TF_REG22(sp)
sw $23, TF_REG23(sp)
sw $24, TF_REG24(sp)
sw $25, TF_REG25(sp)
sw $26, TF_REG26(sp)
sw $27, TF_REG27(sp)
sw $28, TF_REG28(sp)
sw $30, TF_REG30(sp)
sw $31, TF_REG31(sp)
.set at
.set reorder
.endm

此外,.text.exc_gen_entry段和.text.tlb_miss_entry段需被linker Script当置到特定位置,分别放置在0x800001800x80000000处,都是异常处理程序入口。实验系统中CPU发生异常(除用户态地址的TLB Miss异常)会自动跳转到前者,否则跳转到后者。

异常向量组

exception_handlers称为异常向量组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
extern void handle_int(void);
extern void handle_tlb(void);
extern void handle_sys(void);
extern void handle_mod(void);
extern void handle_reserved(void);

void (*exception_handlers[32])(void) = {
[0 ... 31] = handle_reserved,
[0] = handle_int,
[2 ... 3] = handle_tlb,
#if !defined(LAB) || LAB >= 4
[1] = handle_mod,
[8] = handle_sys,
#endif
};

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_tlbTLB load异常
  • 3号异常 handle_tlbTLB store异常
  • 8号异常 handle_sys,系统调用,用户进程执行syscall指令陷入内核。

时钟中断

中断处理流程:

  1. 异常分发,判断出当前异常为中断异常,随后进入相应的中断处理程序。
  2. 中断处理程序进一步判断Cause寄存器中由几号中断位引发的中断,然后进入不同的中断服务函数。
  3. 中断处理完成,通过ret_from_exception函数恢复现场,继续执行。
    时钟中断出现原因:与时间片轮转调度算法密切相关。MOS通过硬件定时器产生的时钟中断来判断一个进程的时间片结束。当时间片结束时当前进程挂起,MOS从调度队列中选取合适的进程运行。MOS利用CP0中内置的Timer产生时钟中断
    具体有两种控制Timer的寄存器,CountCompare寄存器,前者按某种仅与CPU流水线频率相关频率自增,后者保持不变。当两者值相等且不为0时,触发时钟中断
    RESET_KCLOCKCount清零,将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中,当下列情况发生时,进行进程切换

  • 尚未调度过任何进程,即curenvNULL.
  • 当前进程已用完时间片,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中查找该地址对应的物理地址与权限位,然后将相应物理页号和权限位填入EntryLoPFN域和权限位,用tlbwrEntryHiEntryHi中相应内容随机写入TLB中的一项中,最后调用ret_from_exception从异常返回

系统调用与fork

系统调用

系统调用大致流程

为了安全地为用户进程提供受限的系统调用机制,OS设计了一系列内核空间中的函数来执行相应操作。

通过puts()函数在Linux中的执行过程,可以看出:

  1. 存在一些只能由内核完成的操作,如读写设备、创建进程、对I/O的操作等
  2. C标准库中某些函数的实现依赖OS。
  3. 通过执行汇编指令syscall,用户进程可陷入内核态,请求相应服务。
  4. 通过系统调用陷入时需在用户态与内核态间进行数据传递与保护。

此外,由于直接调用系统调用过于麻烦,因此诞生了一系列用户空间的API定义,如POSIX和C标准库等,可以简单地理解为相应的中介,需要调用系统函数时调用API即可。

下通过user/lib/debugf.c中的debugf函数代码学习具体流程:

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
static void vdebugf(const char *fmt, va_list ap) {
struct debug_ctx ctx;
ctx.pos = 0;
vprintfmt(debug_output, &ctx, fmt, ap);
debug_flush(&ctx);
}

void debugf(const char *fmt, ...) {
va_list ap;
va_start(ap, fmt); //将参数解析成字符串
vdebugf(fmt, ap); //通过调用debug_output函数将字符串输出,debug_output函数通过debug_flush函数调用了syscall_print_cons函数使系统陷入内核态,而在后者中,调用了msyscall函数陷入内核态
//内核态中异常被分发到handle_sys函数,系统调用所需信息传入内核
//内核去得信息后,执行对应内核空间的系统调用函数(`sys_*`,*代表可以匹配任何字符,相应函数在syscall_all.c中)
//系统调用完成后,返回用户态,将返回值“传递”回用户态,回到用户程序debugf处
va_end(ap);
}

static void debug_flush(struct debug_ctx *ctx) {
if (ctx->pos == 0) {
return;
}
int r;
if ((r = syscall_print_cons(ctx->buf, ctx->pos)) != 0) {
user_panic("syscall_print_cons: %d", r);
}
ctx->pos = 0;
}

static void debug_output(void *data, const char *s, size_t l) {
struct debug_ctx *ctx = (struct debug_ctx *)data;

while (ctx->pos + l > BUF_LEN) {s
size_t n = BUF_LEN - ctx->pos;
memcpy(ctx->buf + ctx->pos, s, n);
s += n;
l -= n;
ctx->pos = BUF_LEN;
debug_flush(ctx);
}
memcpy(ctx->buf + ctx->pos, s, l);
ctx->pos += l;
}

系统调用号

  1. syscall_*函数同内核中的sys_*函数一一对应,前者是在用户空间中最接近的内核函数,在用户态调用,后者是内核中系统调用的具体实现部分,在内核态中执行。

  2. 所有syscall_*函数中都调用了msyscall函数,其第一个参数是一个与调用名类似的宏,也就是系统调用号系统调用号是内核区分不同系统调用的唯一依据。msyscall函数除系统调用号参数外,还有5个参数,至于为什么是5个?因为最多参数的系统调用所需参数数量(syscall_mem_map函数要5个参数)。此处通过栈帧的方式从用户态传递入内核态。该函数没有**局部变量,也就是其不需要分配栈帧,只需执行自陷指令陷入内核并在处理后正常返回即可,syscall**指令不允许在延迟槽中使用。

通过syscall指令陷入内核态后,CPU将PC寄存器指向作为do_syscall函数的包装的handle_sys函数的入口地址处,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.macro BUILD_HANDLER exception handler
NESTED(handle_\exception, TF_SIZE + 8, zero)
move a0, sp
addiu sp, sp, -8
jal \handler
addiu sp, sp, 8
j ret_from_exception
END(handle_\exception)
.endm
...
#if !defined(LAB) || LAB >= 4
BUILD_HANDLER mod do_tlb_mod
BUILD_HANDLER sys do_syscall
#endif

对于陷入内核有几点需要补充:

  1. 陷入内核并不是从一个函数跳转到另一个,代码使用的$sp是内核空间的栈指针。
  2. 从用户态切换到内核态,内核需要先将原用户进程的运行现场保存到内核空间中,例如:SAVE_ALL
  3. 保存完现场后栈指针指向保存的Trapframe,我们需要借助该结构体获取用户态中传递来的值,例如:用户态下$a0寄存器的值保存在内核栈的TF_REG4(sp)中
  4. 内核在handle_开头的包装函数中调用实际进行异常处理的C函数时,栈指针将作为参数传递给该C函数。
  5. 可以通过struct Trapframe * 获取用户态现场中的参数。

基础系统调用函数

sys_mem_alloc函数

分配内存,用户程序可给该程序所允许的虚存空间显式地分配实际物理内存。站在编写者的视角也就是编写的程序在内存中申请了一片空间。而对于OS,则是一个进程请求将其运行空间中的某段地址与实际物理内存形成了映射。此处注意需要检查虚地址的合法性。

sys_mem_map函数
  1. 将源进程地址空间中相应内存映射到目标进程的相应地址空间的相应虚内存中,也就是这两者共享一页的物理内存。
  2. 操作逻辑大致如下:
    • 找到两个进程(使用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
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Env {
...
//进程传递的具体数值
u_int env_ipc_value; // the value sent to us
//发送方ID
u_int env_ipc_from; // envid of the sender
//接收方接收数据状态,1表示等待接受、0表示不可接受
u_int env_ipc_recving; // whether this env is blocked receiving
//接收到的页面需与自身的哪个需页面完成映射
u_int env_ipc_dstva; // va at which the received page should be mapped
//传递页面的权限位设置
u_int env_ipc_perm; // perm in which the received page should be mapped
...
}

其中两个重要函数为int sys_ipc_recv(u_int dstva)函数和int sys_ipc_try_send(u_int envid, u_int value, u_int srcva, u_int perm)函数。
前者用来接受消息:

  1. 先给自身的env_ipc_recving置位,表示准备接受消息了。
  2. env_ipc_dstva赋值,表明要将接受的页面与dstva完成映射。
  3. 阻塞当前进程,注意此处需要手动将其从调度队列中删除,为了保证调度队列的一致性,因此在schedule()函数运行时对于当前进程阻塞的情况不需要去手动删除了。
  4. 放弃CPU(同时重新进行调度),等待发送方传递数据。

后者来发送消息:

  1. 根据envid找到相应进程,若对应进程为可接收状态,则发送成功。
  2. 否则返回异常值-E_IPC_NOT_RECV,表示目标进程未处于接收状态。
  3. 清除接收进程接收状态,填入数据到PCB,传递物理页映射关系。
  4. 修改PCB中进程状态,使接受数据进程可继续运行。
  5. 当传入的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_DDirty位,表示当前页面是否可写,为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中,这个需要父进程提前通过系统调用设置.
大致流程如下:

  1. 触发页写入异常,陷入handle_mod,跳转到do_tlb_mod函数.
  2. do_tlb_mod函数负责将当前现场保存在异常处理栈中,设置$a0EPC寄存器,使异常恢复后能以异常处理栈中保存现场(Trapframe)为参数,跳转到env_ussser_tlb_mod_entry中存储的用户异常处理函数地址.
  3. 复原到用户态,跳转到用户异常处理函数,由用户程序完成写时复制等自定义处理.

具体的cow处理是由cow_entry实现的,而该函数在进行cow处理后会使用系统调用syscall_set_trapframe恢复保存好的现场,包括spPC寄存器,使用户程序恢复运行。

1
static void 

文件系统

文件系统是OS为了便于管理和访问存放在外部存储设备上的数据,其中,文件是数据存储和访问的基本单位

概述

文件系统是一种存储和组织数据的方法,便于访问和查找数据,其使用文件和树型目录的逻辑抽象屏蔽底层硬盘和光盘等物理设备基于数据块进行存储和访问的复杂性。用户并不关心具体的底层实现,可以方便的调用和写入相应的文件。
一般文件系统常基于硬盘和光盘等块存储设备,并维护文件在设备中的物理位置,此外还有某些仅是一种访问数据的接口,实际数据在内存中或通过网络协议(NFS、SMB、FTP)提供
广义上,一切带标识、逻辑上有完整意义的字节序列都可称作”文件”。文件系统将外设抽象为文件来统一管理外设,实现对数据的存储、组织、访问和修改等操作

文件系统主要包括以下几个部分:

  1. 外部存储设备驱动 为了将按一定顺序读写设备寄存器来实现对外部设备的操作转化为有通用、明确语义的接口,必须实现相应的驱动程序,一种过去主流的硬盘接口——IDE(集成驱动器电子设备),其用户态驱动程序通过系统调用的方式陷入内核,对磁盘镜像进行读写操作
  2. 文件系统结构 此部分是实现模拟磁盘的驱动程序及磁盘上和OS中的文件系统结构和相关函数
  3. 文件系统的用户接口,要求为用户提供接口和机制使用户能使用文件系统,通过一个用户态的文件系统服务来实现。文件描述符(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.cprintcharc函数的实现:

1
2
3
4
5
6
7
8
9
10
11
12
void printcharc(char ch) {
if (ch == '\n') {
printcharc('\r');
}
while (!(*((volatile uint8_t *)(KSEG1 + MALTA_SERIAL_LSR)) & MALTA_SERIAL_THR_EMPTY)) {
}
//KSEG1 + MALTA_SERIAL_DATA,其中KSEG1为0xA0000000、MALTA_SERIAL_DATA为0x180003F8,此处定位写入地址
//强制转化为char型指针
//用volatile限定的原因是若不使用,编译器可能会对该地址数据的访问做优化,使得对I/O设备地址访问不直接与设备交互
//这与预期每次操作都直接与设备进行交互不符
*((volatile uint8_t *)(KSEG1 + MALTA_SERIAL_DATA)) = ch;
}

此次编写的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
2
3
4
5
6
7
8
9
10
11
12
13
static uint8_t wait_ide_ready() {
uint8_t flag;
while (1) {
//通过调用syscall_read_dev()系统调用将磁盘状态值写入到flag中
panic_on(syscall_read_dev(&flag, MALTA_IDE_STATUS, 1));
//判断当前IDE设备是否就绪
if ((flag & MALTA_IDE_BUSY) == 0) {
break;
}
syscall_yield();
}
return flag;
}

IDE就绪后,即可进行读写操作。

  1. 设置操作扇区数。
  2. 设置扇区号[7:0]位
  3. 设置[15:8]位
  4. [23:16]位
  5. [27:24]位,设置扇区寻址模式和磁盘号
  6. 设置IDE设备状态为读或写
  7. 等待IDE设备就绪
  8. 读取或写入数据
  9. 检查IDE设备状态
  10. 完成

分别设置是由于IDE设备无法一次性写入操作扇区号,需单独设置扇区号的各个位。

读取和写入数据时,由于实验的IDE设备每次仅能操作4B,因此需要通过循环完成。

例如:

1
2
3
4
5
6
7
8
for(int i = 0; i < SECT_SIZE; i += 4) {
//内核态下读取diskno号磁盘上的secno号扇区数据到dst处
memcpy(dst + i; (void *)(MALTA_IDE_DATA + 0xA0000000), 4);
/
//内核态下写入src指向扇区数据到diskno磁盘的
memcpy((void *)(MALTA_IDE_DATA + 0xA0000000), src + i, 4);

}

文件系统结构

磁盘文件系统布局

磁盘空间布局图
图中的Block是磁盘块,不同于扇区,是虚拟概念,是OS与磁盘交互的最小单位。类似于内存管理中页和段的概念,OS将相邻扇区重组成磁盘块操作,减小了寻址的困难。
图中可知,MOSOS将磁盘一开始的一个磁盘块当作引导扇区和分区表使用(MBR?),接下来一个磁盘块作为超级块来描述文件系统基本信息,如魔数、磁盘大小及根目录位置等
MOS中super block结构体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//user/include/fs.h
struct Super {
u_int s_magic; //魔数,用于标识文件系统的常量
u_int s_nblocks; //当前文件系统磁盘块数目
struct File s_root; //根目录,其中其f_type为FTYPE_DIR,f_name为"/"
}

struct File {
char f_name[MAXNAMELEN]; // filename
uint32_t f_size; // file size in bytes
uint32_t f_type; // file type
uint32_t f_direct[NDIRECT];
uint32_t f_indirect;

struct File *f_dir; // the pointer to the dir where this file is in, valid only in memory.
char f_pad[FILE_STRUCT_SIZE - MAXNAMELEN - (3 + NDIRECT) * 4 - sizeof(void *)];
} __attribute__((aligned(4), packed));

此处采用位图(Bitmap)管理空闲的磁盘资源,即用一个二进制位bit标识磁盘中一个磁盘块的使用情况(1空闲,0占用)。
tools/fsformat.c用于创建符号定义的文件系统镜像,可将多个文件按内核定义的文件系统写入到磁盘镜像中,观察头文件和tool/Makefile可知该C文件使用Linux下的gcc编译器编译,非交叉编译器,生成的fsformat运行在Linux宿主机上,专用于创建磁盘镜像文件,其中fs.img可模拟于真实磁盘文件设备的交互。
写入文件前,利用其中的init_disk()函数将所有块标为空闲块:

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
void init_disk() {
int i, diff;
// Step 1: Mark boot sector block.
disk[0].type = BLOCK_BOOT;
// Step 2: Initialize boundary.
//nbitblock是为了用bitmap标识正磁盘所有块使用情况所需的磁盘块数量。
nbitblock = (NBLOCK + BLOCK_SIZE_BIT - 1) / BLOCK_SIZE_BIT;
nextbno = 2 + nbitblock;
// Step 2: Initialize bitmap blocks.
for (i = 0; i < nbitblock; ++i) {
disk[2 + i].type = BLOCK_BMAP;
}
for (i = 0; i < nbitblock; ++i) {
//此处0xff代表1,设置磁盘块处于空闲状态
memset(disk[2 + i].data, 0xff, BLOCK_SIZE);
}
//若位图仍有剩余,不能将最后一块位图块中靠后的一部分内容标记为空闲,因为这些位对应磁盘块压根不存在,不可用
//此部分需要设为0
if (NBLOCK != nbitblock * BLOCK_SIZE_BIT) {
diff = NBLOCK % BLOCK_SIZE_BIT / 8;
memset(disk[2 + (nbitblock - 1)].data + diff, 0x00, BLOCK_SIZE - diff);
}
// Step 3: Initialize super block.
disk[1].type = BLOCK_SUPER;
super.s_magic = FS_MAGIC;
super.s_nblocks = NBLOCK;
super.s_root.f_type = FTYPE_DIR;
strcpy(super.s_root.f_name, "/");
}

文件系统详细结构

文件控制块结构体如下:

1
2
3
4
5
6
7
8
9
10
struct File {
char f_name[MAXNAMELEN]; //文件名称,其最大长度MAXNAMELEN为128
uint32_t f_size; //文件大小,字节为单位
uint32_t f_type; //文件类型,分为普通文件(FTYPE_REG)和目录(FTYPE_DIR)两种
uint32_t f_direct[NDIRECT]; //文件的直接指针,每个文件控制块有10个来记录文件的数据块在磁盘上的位置
uint32_t f_indirect; //指向一个间接磁盘块来存储指向文件内容的磁盘块的指针,为了简化计算,不使用间接磁盘块的前十个指针

struct File *f_dir; //指向文件所属的文件目录
char f_pad[FILE_STRUCT_SIZE - MAXNAMELEN - (3 + NDIRECT) * 4 - sizeof(void *)]; //为了让整数个文件结构体占用一个磁盘块来填充结构体中剩下的字节
} __attribute__((aligned(4), packed));

普通文件的FCB中指向的磁盘块存储着文件内容,而目录文件指向的存储目录下各个文件对应的FCB
当查找某个文件时,先从super block读取根目录的FCB,然后沿着目标路径,依次查看当前目录所包含文件是否与下一级目标文件同名,进而查找到最终的目标文件。

块缓存

借助虚存实现磁盘块缓存。由于对磁盘I/O操作单次只能读取少量数据,同时频繁读写磁盘将拖慢系统,使用缓存可通过将数据暂存于内存,减少磁盘访问次数,加快数据读取。
文件系统服务为一个用户进程,其将DISKMAP 到 DISKMAP+DISKMAX 这一段虚存地址空间(0x10000000-0x4FFFFFFF) 作为缓冲区,当磁盘读入内存时,用来映射相关的页。

1
2
#define DISKMAP 0x10000000
#define DISKMAX 0x40000000

fs/fs.c中的map_block函数和unmap_block函数实现了当把一个磁盘块中内容载入内存中为之分配对应物理内存和结束使用某一磁盘块释放对应物理内存的操作。
read_blockwrite_block函数用于读写磁盘块。前者将指定编号磁盘块读入内存,其先检查该磁盘块是否已在内存中,不在则先分配物理内存,然后调用ide_read读取磁盘上数据到对应虚存地址上。
file_get_block将某个指定的文件指向的磁盘读入内存:先用file_map_block为将读入内存的磁盘块分配物理内存,再使用read_block将磁盘内容一块为单位读入内存相应位置。

文件系统的用户接口

为了便于用户操作文件系统,还要向用户提供相关使用接口。

文件描述符

fd——File Discripter、系统提供给用户的用于在描述索引表中索引的整数。在进行I/O编程时,用openclosewriteread来进行文件操作时处理的都是fd
用户进程尝试打开文件时,文件系统服务进程需要一个fd来存储文件基本信息和用户进程中文件的状态,且fd还有描述用户对文件操作的作用。用户进程发生打开文件请求时,文件系统进程将基本信息记在内存中,再由OS将用户进程请求地址映射到相应存储了fd的物理页上,因此一个fd至少独占一页。用户进程获取了文件基本信息后再向文件系统发送请求将文件内容映射到指定内存空间中。

文件系统服务

文件系统服务通过IPC形式为其他进程提供服务。
具体:内核始运行时启动文件系统服务进程ENV_CREATE(fs_serv),进行文件操作时,使用ipc_sendipc_recvfs_serv交互。fs/serv.c中服务进程主函数调用serve_init准备好全局文件打开记录表opentab,再调用fs_init初始化文件系统,其先通过读取super block内容获取磁盘基本信息,再检查磁盘能否正常读写,最后调用read_bitmap检查位图是否正确。此后,调用serve开始文件系统服务,等待其他程序请求。
文件系统支持的请求类型:

1
2
3
4
5
6
7
8
9
10
enum {
FSREQ_OPEN,
FSREQ_MAP,
FSREQ_SET_SIZE,
FSREQ_CLOSE,
FSREQ_DIRTY,
FSREQ_REMOVE,
FSREQ_SYNC,
MAX_FSREQNO,
};

用户程序发生相应请求时,请求内容放在对应结构体中进行消息传递,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的生成函数中,先分配两个文件描述符fd0fd1并分配空间,然后给前者对应的虚地址分配一页物理内存,再将后者对应的虚地址映射到这页物理内存。
通过置共享位(PTE_LIBRARY)的方式,保持该页共享可写的状态,使父子进程对修改结果相互可见。

pipe的读写

pipe结构体定义如下:

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
struct Pipe {
u_int p_rpos; // read position,可以理解成读指针,只有读者可读
u_int p_wpos; // write position,可理解为写指针,只有写者可写
u_char p_buf[PIPE_SIZE]; // data buffer,PIPE_SIZE值为32,这个缓冲区实际上是一个类似环形缓冲区,因此下一个要读或写的位置i实际上为i%PIPE_SIZE
};
```
读者从管道读数据时,将`p_buf[p_rpos%PIPE_SIZE]`中数据拷贝走,此后读指针自增1,但需要注意,**`buf`可能还未填入数据,因此若管道数据为空,即`p_rpos>=p_wpos`时,应进程切换到写者。**
类似的,写者将数据写入`p_buf[p_wpos%PIPE_SIZE]`中后写指针自增1,但是也要注意缓冲区**溢满**情况,即写者只能在`p_wpos-p_rpos<PIPE_SIZE`时运行,否则只能挂起,等读者进程运行。
此外,为了避免由于不知道另一端进程是否关闭导致的死循环问题,读写者进程都需要对另一端的状态进行判断:**当出现buf空或满时,根据另一端是否关闭了来判断是否返回,关闭则返回0;否则切换进程运行**。
`MOS`中,使用`static int _pipe_is_closed(struct Fd *fd, struct Pipe *p)`判断管道另一端是否已关闭。
实际上,对一个匿名管道来说,分配了三页的空间:一页读数据的文件描述符`rfd`,一页写文件符`wfd`,剩下一页被两个`fd`共享的缓冲区`pipe`。显然,`pipe`将被读写者引用2次,因此有`pageref(rfd)+pageref(wfd)=pageref(pipe)`成立。
通过上述恒等式即可知晓另一端是否已关闭,因为当另一端关闭了,其对应页的`pp_ref`将是0,也就是`pageref(xfd)`将为0.因此就有`pageref(rfd) = pageref(pipe)`(对读者来说)成立,写者同理。
### pipe关闭的正确判断
由于抢占式进程管理,当多个进程共享同一变量时,不同执行顺序可能产生完全不同的结果,导致结果的不确定性,因此对于管道的共享性质,需要十分警惕,确保诸如`_pipe_is_closed`用于判断管道另一端是否关闭的返回结果是否正确。
进程通过`pipe_close`关闭管道端口,函数实质为通过两次`unmap`解除文件描述符`fd`和缓冲区`pipe`之间的映射。不过由于进程切换,并不能确保两次`unmap`可在同一进程时间片内执行完,因为这之间很可能由于切换而被打断,因此,`fd`和对`pipe`的`pp_ref`不能保证同步写入,将影响判断`_pipe_is_closed`返回正确性。
举个例子:
```c
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间后,父进程开始执行。
父进程在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的函数逻辑:

  1. 写端关闭<=>pageref(p[0]) == pageref(pipe)
  2. 读端关闭<=>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,由于此时pipebuf为空,因此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]).
步骤大致如下:

  1. 创建并分配两个fdfd0fd1,并分配相应的文件描述符空间。
  2. fd0对应数据区域分配一页空间,将fd1对应数据区域映射到该物理页上,该页的内容为pipe结构体。
  3. 将读端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中读至多nB到vbuf对应虚地址中,并返回读到的字节数。
读取过程可能遇到:

  1. buf不为空:按顺序读取buf内容,直到buf为空或达到nB。
  2. buf为空:使用 _pipe_is_closed 函数查询管道的写端是否已经关闭,若已关闭,说明读入完成,函数返回 0;若没有关闭,则使用 syscall_yield() 进行等待,直到管道关闭或缓冲区不为空。

写管道函数

static int pipe_write(struct Fd *fd, const void *vbuf, u_int n, u_int offset)
与读类似,不过此处的n是需要写入恰好nB,非buf上限,因此完成全部nB写入前,若buf满了,则用syscall_yield()等待,并非返回,直到buf不满或pipe关闭。

关闭管道函数

static int pipe_close(struct Fd *fd)关闭管道的一端。

shell

shell分为两大类:

  1. 图形界面 shell(Graphical User Interface shell 即 GUI shell)。例如:应用最为广泛的是微软 Windows 系列操作系统的 Windows Explorer,也包括广为人知的 Linux 操作系统的XWindow Manager(BlackBox 和 FluxBox),以及功能更强大的 CDE、GNOME、KDE 和 XFCE等。
  2. 命令行式 shell(Command Line Interface shell ,即 CLI shell),也就是我们 MOS 操作系统最后即将实现的 shell 模式。

完善spawn函数

该函数帮助调用文件系统的可执行文件并执行。
其流程如下:

  1. 从文件系统打开对应的文件(二进制 ELF,在实验中是*.b );
  2. 申请新的进程控制块;
  3. 将目标程序加载到子进程的地址空间中,并为它们分配物理页面;
  4. 为子进程初始化地址空间。对于栈空间,由于 spawn 需要将命令行参数传递给用户程序,所以要将参数也写入用户栈中;
  5. 设置子进程的寄存器(栈指针 sp 和用户程序入口 EPC);
  6. 将父进程的共享页面映射到子进程的地址空间中;
  7. 上述操作执行完后,设置子进程可执行。
    具体如何为子进程初始化栈空间,都在user/lib/spawn.c中的init_stack函数中。
    由于无法直接操作子进程栈空间,因此spawn()函数(spawn.c中的另一个函数)先将要准备的参数填充到本进程的UTEMP页面处,此后再将UTEMP映射到子进程栈空间中。具体操作如下:
  8. 先将argc个字符串填到栈上,切记在字符串末尾加上\0表结束,然后将argc + 1个指针填到栈上,第argc + 1个指针指的是第一个空字符串用来表示参数的结束。
  9. 再将argcargv填到栈上,argv指向那argc + 1个字符指针。
    子进程栈空间示意如下:

解释shell命令

user目录下提供了ls.ccat.c等用户程序,而Shell程序sh.c调用上面此前提及的spawn函数,其读取相应的可执行文件,并加载到新进程中运行。
下探讨如何实现管道和重定向机制:

  1. 对于重定向符号’<’和’>’,读下一个单词,打开其代表的文件后将其赋值给标准输入或标准输出。
  2. 对于管道符号’|’,先建立pipe,后fork:父进程将管道写者复制给标准输出,后关闭父进程读写者,执行|左边命令输出到管道写端;子进程将管道读者复制给标准输入,从管道读取数据,关闭进程读写者,继续读下一个单词,并执行其代表的命令。
    进程空间中,文件、管道、控制台和文件描述符都以共享页面的形式存在。
    具体的shell启动执行过程如下:
    内核启动的进程user/icode.b调用spawn创建init.b进程。init.b先打开控制台作为0和1号文件描述符,即进程的标准输入和标准输出,然后调用spawn创建sh.b进程,即shell。通过共享页面机制,forkspawn创建的子进程继承了父进程的文件描述符,因此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文件、执行新程序的子进程。
大致流程如下:

  1. 使用open打开即将装载ELF文件的prog
  2. 使用syscall_exofork为子进程申请一个PCB
  3. 使用init_stack为子进程初始化栈空间,将要传递的参数argv传入子进程。
  4. 使用elf_load_segELF各段加载进子进程。
  5. 设置子进程运行现场寄存器,tf->cp0_epc设为程序入口点,tf->regs[29]设为装载参数后的栈顶指针,使子进程唤醒时以正确状态运行。
  6. 将父进程共享页面映射给子进程,与fork不同,这里只映射共享页面。
  7. 使用syscall_set_env_status唤醒子进程。

sh.c中的main

int main(int argc, char **argv)shell进程的主函数,是启动shell进程的第一步进入的函数,主体为死循环:

  1. 调用readline读入用户输入命令。
  2. fork出一个子进程。
  3. 子进程执行用户命令,执行结束后子进程结束,父进程等待。
  4. 父进程等待子进程结束,返回循环开头,读入下一个命令。

命令读入函数

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。需要注意:命令中包含|时,需创建pipefork一个子shell,子进程调用parsecmd解析pipe右侧命令,父进程返回runcmd执行左侧命令,此时rightpipe记录子进程进程号即解析并执行pipe右侧命令的进程号,用于pipe左侧命令进程等待管道右侧命令进程执行完毕后释放
runcmd执行解析后的每一条命令,通过argv判断命令种类,调用spawn产生子进程并装载命令对应的ELF文件,子进程执行命令,父进程等待子进程结束后,结束进程。

shell相关函数调用关系