操作系统 - MOS 阅读
因为笔者鸽了,所以这里的代码就截止到 lab2 了,lab3 和 lab4 的代码在异常处理流那篇文章中有
MOS 源码解读
[TOC]
boot/
start.S
.data
在这个里面声明了数据段的结构
.data |
可以看到,mCONTEXT,delay,tlbra 都是 .data 段的起始地址。里面的 tlbra 是 tlb return address 的意思,应该是 tlb 缺失异常处理结束后,就会返回这个地址。
_start()
set,如注释所言
/*.set is used to instruct how the assembler works and control the order of instructions */ |
关于设置协处理器装填,有
/* Disable interrupts */ |
该条指令将 CP0_STATUS 寄存器的所有可写位置零。
所以将该寄存器置零的主要作用是:禁用所有中断
mtc0 zero, CP0_WATCHLO |
在 R30XX 系列处理器中并没有实现这两个寄存器。查阅 MIPS32 文档,这两个寄存器实现的是调试功能,置零表示禁用调试功能。
/* disable kernel mode cache */ |
查阅 MIPS32 文档,该操作将 config 寄存器的低三位置为 010. 低三位叫做 K0 域,控制 kseg0 的可缓存性与一致性属性。置为 010 代表 uncached,即不经过 cache。
其实让我们干的只有这件事
/* |
由内存图可以知道,只要把栈指针设置成 0x8040_0000 然后跳到 main 就可以了。设置栈指针是极其有必要的,因为我们后面会调用各种函数,而这些函数存储局部变量的位置就在栈上(下面这幅图画的有些不严谨,栈顶指针应该在栈顶)

后面还有一个经典的刻意死循环
loop: |
然后对于这段代码,我没看懂
.section .data.stk |
我问叶哥哥,叶哥哥说没用,似乎重构的时候把他删了也不影响。
总的来说,这个文件里存了用汇编语言实现的 _start 这个函数。这个函数的功能是设置 CP0 和栈指针,然后跳到 main 中。。
init/
main.c
main()
int main() |
这个函数主要的功能就是调用 mips_init() 函数。很容易理解
init.c
mips_init()
void mips_init() |
这次的内容与 lab 1 时有所不同,因为增加了对存储系统的设置,主要通过三句话:
这句话设置了物理存储(即主存)的诸多参数,比如说物理存储的地址范围,空间,是否有拓展存储。
mips_detect_memory(); |
这句话设置了虚拟存储系统。
mips_vm_init(); |
这句话设置了物理存储系统:
page_init(); |
后面两个函数只是为了检验 lab1 的正确性,不属于操作系统
physical_memory_manage_check(); // 检查虚拟存储系统 |
后面三句话应该只是一种捣乱,目的是为了终止这个函数的运行
panic("^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^"); |
bcopy()
// byte copy |
这是一个按字节拷贝的函数,按字节拷贝的意思就是基本上啥内容都可以拷贝,因为啥内容都可以分割成字节。
函数声明中指明了拷贝源的起始地址 src ,拷贝目标的起始地址 dst ,拷贝的内容的字节数 len 。有趣的是,拷贝源的起始地址用修饰符 const 修饰,这说明其指向的内容是不可以修改的。
// byte copy |
后面拷贝的方法也很有意思,先是 4 个字节 4 个字节的拷贝,然后才是 1 个字节 1 个字节的拷贝,可能这样比较高效吧,不过为什么不用 long long 就可以 8 个字节为单位拷贝了。经过陈同学指正,因为 32 位的系统一个字就是 4 个字节,用 long long 去存取,其实应该就是在汇编层面被拆成了两个指令。
void *max; |
bzero()
// b is the head address of the memory need to be clear to 0 |
参数和方法与 bcopy() 类似,就不说了。
include/
types.h
typedef
主要是完成了数据类型别名的声明,这样可以提高兼容性,而且往往可以通过别名来推断数据的字节数
typedef unsigned __attribute__((__mode__(QI))) u_int8_t; |
最后一个 size_t 可以理解为通用数据类型,大概意思就是要是没事就用他吧。
MIN
MIN 这个宏,写的也很复杂,有一篇文章解释这个
https://www.cnblogs.com/sunyubo/archive/2010/04/09/2282179.html
关于 typeof,它可以取得变量的类型,或者表达式的类型。
对于这句话
typeof(_a) __a = (_a) |
就是声明一个叫做 __a 的变量,他的数据类型与 _a 相同。
ROUND, ROUNDDOWN
下面这两个宏
/* Rounding; only works for n = power of two */ |
会输出 a 在 n 的几倍数之间,比如下面
|
会输出 28 24 因为 25 在 之间。
具体的原理从这里看
~((n)-1)是一串 1 以后有几个 0。比如 4 是 111……1100- 再拿这个数去与 a 进行与操作,就会让低位都变成 0。
offsetof
一个基本的无符号整数的 C / C + + 类型,它是 sizeof 操作符返回的结果类型,该类型的大小可选择。因此,它可以存储在理论上是可能的任何类型的数组的最大大小。
他的意思是
(type *)0将内存空间的 0 转换成需要的结构体指针(type *)0)->member利用这个结构体指针指向某个成员(&((type *)0)->member)取这个成员的地址((size_t)(&((type *)0)->member))将这个成员的地址转化成size_t类型
有如下示例
|
这个会输出 16 (可能与我的电脑有关,理解即可)
static_assert
没看懂,遇到再说
mmu.h
正如这个文件一开始说的,这个文件分为三个部分,第一个部分是一些内存管理的常量,第二个部分是一个虚拟地址空间的分布,第三个部分是一些辅助性的函数
/* |
Memory Constant
首先定义了页面的大小,我们知道无论是虚拟页面,还是物理页框,大小都是相同的,我们看到 MIPS 一页的大小是 4 KB。有意思的是,这个宏的名字的意思是 “Byte Per Page”。因为有两个首字母是 P,所以是 2P
在虚拟内存管理中,我们采用的是两级页表的方式,我们的第一级页表叫做 “Page Dictionary”,一共有 1024 个条目(entry),每个条目都对应一个二级页表。第二级页表叫做 “Page Table”,也有 1024 个条目,每个条目对应一个虚拟页。我们的虚拟地址一共是 32 位,呈现如下结构:

由此我们知道,一个 page directory entry 对应 1 个 page table,一个 page table 又有 1024 个 Page,一个 Page 的大小是 字节。 所以一个 page directory entry 对应的虚拟地址空间的大小就是 PDMAP。
然后我们定义了两个偏移量,这两个偏移量可以方便利用位运算求解各种东西。意义见注释
Index
分别是 Page Dictionary Index 和 Page Table Index。
可以用下面的图来表示

PTE
PTE_ADDR
用于将 pte 前面的物理地址取出来,示意图如下

Page Dictionary/Table Flags

所谓的 flag 就是权限位,一共有 12 位,可以通过给不同的位置 1 来表示物理页的权限,具体解释如下
现在我知道的
有效位,即存在位,当其被置 1 的时候,说明这个物理页框已经与虚拟页之间建立映射
可写位,只有这个位置 1,我们才可以往这个物理页面内写数据。
pte typedef
typedef u_long Pde; |
定义了页表项的本质就是 32 位整型数。
Virtual Space Constant
这些宏主要是描述虚拟地址空间的各个坐标,或者是各个距离用的,最好结合内存图来理解
/* |
这个是内核的起始地址,注意不是 0x8000_0000
这个是用户地址空间的上界,即 user limit。
Address translate
下面两个宏可以完成从 kernel 虚拟地址到物理地址的转换和由物理地址到 kernel 虚拟地址的转换,其实转换的方法很简单,就是监区或者加上 0x80000000 说白了就是最高位置 0。
// translates from kernel virtual address to physical address. |
queue.h
我们用内存管理中的链表举例:

List Declare
LIST HEAD
这个宏很有意思,因为它不是会当一个函数来使,就是完成某个功能,然后给出一个返回值。它的作用是定义一个结构体,这个结构体是链表的表头。
// this macro define a struct |
LIST_ENTRY
与 LIST_HEAD 类似,这个宏也定义了一个结构体,这个结构体里面包含两个指针,就是实现双向链表的那两个指针。
List Find
LIST_FIRST
这里要区分两个概念,一个是链表的头(head),一个是链表的首节点,在前面的图中就可以很好的搞懂
/* |
LIST_NEXT
这个其实跟单向链表那个挺像的,但是就是非得多了一个 field ,这是因为这种结构体把所有的链接指针都分装到了一起,所以不是结构体里面有指针,而是结构体里面有指针结构体,指针结构体里面有指针,所以我们需要用 field 来指定访问哪个指针结构体,比如

我们就需要指定 field = pp_link
List Operate
这些操作只要有了上面那张链表的示意图,就是很好理解的。
在后面的操作中经常出现 do{...}while(0) 结构,那么为什么要使用这种结构呢,是因为宏在展开的时候经常出错,所以我们要用这种结构,具体的原因见这个网址 https://zhuanlan.zhihu.com/p/357839758?utm_source=wechat_session&utm_medium=social&utm_oi=1198576333020692481&utm_campaign=shareo
LIST_INIT
初始化链表的方法就是把链表的首节点赋值为 NULL。
/* |
LIST_INSERT_HEAD
/* |
LIST_INSERT_TAIL
/* |
LIST_INSERT_AFTER
/* |
LIST_INSERT_BEFORE
/* |
LIST_REMOVE
pmap.h
Page
我们首先来看 Page 结构体的定义:
typedef LIST_ENTRY(Page) Page_LIST_entry_t; |
涉及到的宏
Page 这个结构体可以看做是链表的一个节点,其结构如下

那么 Page 到底是什么,我们说这个东西不是一个页面(显然这个东西没有 4KB 大小),他只是一个记录页面信息的东西,如果说得更确切些,它是一个记录物理页框信息的数据结构。他的官方属于叫做内存控制块,我们把空闲的内存控制块放进链表中,当我们需要分配物理内存的时候,就取出链表里的一些内存控制块,表示我们占用这些物理内存。
然后它又定义了一个链表,
LIST_HEAD(Page_list, Page); |
涉及的宏如下
// this macro define a struct |
可以看出这个链表的表头不是原来的 Page 结构体,而是一个新的结构体,结构如下

Map
这里会有一些各种数据结构的转化,这里用一张图总结一下各个数据结构之间的转化关系

extern struct Page *pages; |
printf.h
panic
其实就是一个宏包装了一个函数,使其更加适合标准输出,比如可以直接打印 bug 的文件名,出错的行数
asm.h
stack frame
首先补充一下栈和栈帧的知识:
我们都知道,汇编一旦发生函数调用的时候,一般就会借助内存中的栈结构,我们在学计组的时候,学习到了栈的操作是通过 $sp 寄存器实现的。当栈指针减小的时候,就相当于分配出一块栈空间。反之,则是栈消减了。我们每调用一个函数(其实不严谨,应该是每一个调用别的函数的函数,即非叶子函数),都需要在栈上开辟出一个栈空间来辅助函数的使用(在调用子函数的时候寄存器需要保存)。其实到这里跟我们计组知识是吻合的。
但是这样不利于回溯,栈在我们眼里是同质性的,根本分不清子函数和母函数的界限在哪里。所以我们又规定了一个寄存器 $fp,这个寄存器会指向栈帧的第一个字,这样我们就可以回溯了,具体的示意图如下:

function declare
我们都知道,汇编的函数是用标签声明的,函数的结尾会有一个 jr $ra 。就像这样
sum: |
但是这个依然是很朴素的,因为这个对于编译器很混乱,因为标签不止可以干表示函数的事情,编译器识别不出来这个函数。所以这要求我们写汇编函数的时候对于函数声明这件事情更加复杂一点。
这个函数声明的格式,是在这个文件中定义的,他希望我们写一个函数的时候,呈现这种格式(以上面那个函数为例)
LEAF(sum) |
我们先看 LEAF
/* |
这句话的意思就是定义了一个全局(是对链接器而言的)的符号 symbol
.globl symbol; |
这句话的意思是按照 2 字节的方式填充边界,.align 相当于一个对齐操作符,底下这句话的意思就是按照 个字节进行对齐,注意只对其后面那一个变量
.align 2; |
这句话定义了一个函数,.type 的格式:.type <name> @<type>
.type symbol,@function; |
这条语句说明了函数的开始,但是后面的参数 0 我不知道啥意思
.ent symbol,0; |
下面这条语句才是我们最熟悉的函数标签
symbol: .frame sp,0,ra |
但是后面还是跟了个 .frame ,他的三个参数分别为栈指针,栈帧大小(因为叶子函数没有回溯的必要,所以不需要分配栈帧?),返回寄存器,存储返回地址。
然后再看 NESTED
/* |
会发现与 LEAF 基本上一样,唯一的区别是最后一句
symbol: .frame sp, framesize, rpc |
显然母函数是需要栈帧的。但是返回寄存器变得可调了,我也不知道为啥,可能用的多了?
最后看 end
#define END(function) \ |
这句是函数的结尾
.end function; |
这句不会,摆了
.size function,.-function |
export
asm.h 还涉及一个引入问题 EXPORT。
这是引入一个变量
#define EXPORT(symbol) \ |
这是引入一个函数
cp0regdef.h
CP0reg Declare
SR flags
还定义一些 SR 寄存器的标志位信息
regdef.h
regdef
声明了通用寄存器的别名
|
stackframe.h
Set Interupt
中断可以通过设置 CP0 协处理器中的 SR 寄存器设置,最低一位被称为 IE,当其被置 1 时,启用中断功能,而当其被置 0 的时候,禁用中断,我们可以看下面两个宏
.macro STI |
STI 把 IE 置为 1,所以是开启中断(set interrupt),而 CLI 把 IE 置为 0,所以是清除中断(clear interrupt)。
Save and Restore
当进入中断的时候,或者是进入函数之前,有的时候是有必要保留寄存器中的数据到栈中,当函数调用结束或者异常处理过后,需要恢复寄存器中的数据,因此才有了这些宏
SAVE_ALL 就是保存所有寄存器的值
.macro SAVE_ALL |
里面还嵌套了一个宏,但是我困了,不想分析了,有意思的是,这个宏没有大写
.macro get_sp |
RESTORE_SOME 应该是恢复了某些寄存器的值,没有恢复栈指针寄存器,我不知道为啥。
/* |
还有两个 RESTORE
.macro RESTORE_ALL |
mm/
pmap.c
mips_detect_memory()
/* These variables are set by mips_detect_memory() */ |
maxpa 就是物理地址的上界,可以看到,我们的物理地址的最大值是 0x3FFFFFF 。basemem 是物理内存的大小,所以是 64 MB。extmem 是该是扩展内存,盲猜是加装内存条之类的操作,所以我们是 0。
npage 就是物理页框的数目,其实就是物理内存大小除以页框大小,但是此时计算用到的是位运算,借助了在 mmu.h 中的常量 PGSHIFT。我们有
最后是一个打印信息,打印的信息是内存大小,因为单位是 KB,所以除了 1024。
alloc()
static u_long freemem; |
freemem 是一个地址,这个地址之下都是已经被分配过的虚拟地址,而上面则是没有被分配过的虚拟地址空间,在这个函数里,我们通过对 freemem 进行累加操作,来表示分配一些地址区域。
看一下第一步的判断
extern char end[]; |
这个要结合 tools/scse0_3.lds 理解,scse0_3.lds 的最后一句话声明了一个地址指针 end 它的值就是 0x8040_0000,他代表初始化结束后内核栈的栈顶,我们让 freemem 等于这个值,就说明我们分配的起点就是 0x8040_0000,其实分配虚拟内存空间,就是堆(可能是叫这个名字吧)增长的一个过程。
然后我们来看一下分配是如何操作的:


还是很好理解的。
这个函数只在内存控制块相关数据结构没有建立好之前使用(也就是 pages_init () 之前),当空闲链表的结构建立好了,那么就用 page_alloc () 分配页面。
mips_vm_init()
这个函数还是比较复杂的,我们先来看一下与两级页表相关的部分
// pages is an array. The elememts of it are the physical page frames. |
分配空间用的是 alloc 函数,而这个函数就是一个在虚拟地址空间上工作的函数,而不是在物理地址空间上工作的函数。这两个语句首先分配出一个虚拟页面来存放第一级页表,然后分配了 npage 个(也就是物理页框的数目)个内存控制块大小的空间来放置 Pages 数组。这些语句完成后的虚拟地址空间如图

这有这样,我们才能在最后的输出上看到如下语句

这里涉及一个需要仔细思考的问题,就是对于一个进程来说,他的数据可以存储在哪里?数据其实可以存在三个地方:
- 数据段里,这个相当于是静态的,在进程还没有运行的时候就已经存在了,相当于上图的
Kernel Text中的一部分。在 lab2-extra 中,它让做伙伴系统,但是我当时以为数据只能存储在堆或者栈里,所以一直不敢动笔,实在是哭笑不得。在这个段里存储的数据最大的缺点就是在写程序的时候这部分数据就已经被固定了。这里说的 ” 固定 “ 不是说就是不能修改的,而是对于数据的规模不能修改,其实就是类似于 C 语言的数组,即使可以修改内容,但是不能修改数组的大小。 - 栈上,这个是函数里的局部变量所在的位置,这个段是易失的,只要一个函数运行完了,所以在上面的局部变量都会被销毁,所以基本上是没法用作管理的(因为管理是对进程的管理,一堆随着函数消失就消失的数据,显然是没用的)
- 堆上,这些数据虽然也是在函数内部创建的,但是却不会随着函数的结束而结束,他们的生命周期跟进程一样长(如果不手动释放的话),但是似乎在这里我们使用的时候是有一定限制的,
alloc()是唯一可以使用的函数,但是page_alloc并不是。 堆上的数据是可以随着进程的执行情况而灵活调整的。我们将pages数组放到堆上就很有道理了,因为物理内存的大小是通过mips_detect_memory实现的(不太严谨),所以我们没办法确定pages数组的大小,放在数据段里太过死板了。
page_init()
说白了,page_init () 初始化的 page 就是刚才我们 mips_vm_init () 时用到的物理页。当然还完成了空闲链表的初始化
这个函数又涉及到了一个知识,就是我们是怎么把在虚拟地址空间中的操作系统内核映射(Kseg0)到物理内存中,我们都知道是直接把最高位抹掉。为啥呢,因为也没办法用 MMU 进行映射啊,操作系统还没建立起来的时候,MMU 是没法使用的。那么我们需要分配多少的空间呢?或者说物理页框呢?其实就是我们用了多少的虚拟地址空间,也就是下面的语句:
/* Step 2: Align `freemem` up to multiple of BY2PG. */ |
size 表示我们用过的物理页框的数目,但是我们不能直接使用 freemem 去除 BY2PG ,因为 freemem 不是从 0 开始增长的。这个时候我们可以利用这个宏来先将其转换成物理地址,然后在去做比,就可以得出我们想要的结果了:
// translates from kernel virtual address to physical address. |
对于剩下的内存控制块,我们将他们插入到链表中,当然首先需要初始化链表
/* Step 1: Initialize page_free_list. */ |
page_alloc()
这个函数实现的分配出一个空闲的物理页面,实现的方法就是从空闲内存控制块中挑取头结点并移除,并且把这个物理页框对应的虚拟地址空间清空。
/*Overview: |
其实我们之前也分配出过一些地址空间(在 page_init () 中),但是我们并没有用从链表中取元素的方法,因为当时这个链表还没有建立,所以我们采用的是把已经用到的物理页框就不插入链表的方法。
在这个函数开始的时候有一个判断链表为空的判断,其实这个就表示物理内存空间已经被完全分配完了,那么其实这个时候应该考虑页面置换的问题,比如 LRU 或者 FIFO 策略。但是我们的 MOS 比较简单。一旦物理页框全部被分配,进行新的映射时并不会进行任何的页面置换,而是直接返回错误,这对应 page_alloc 函数中返回的 -E_NO_MEM。
page_free()
首先其实是查看物理页面的引用次数,如果引用次数大于 0,那么就啥也不做。只有引用次数等于 0 的时候。才把这个物理页面对应的内存控制块插入到空闲链表的头部。
/*Overview: |
所以这个函数叫做 “页面释放”,我觉得很好,因为它并不能完全代替删除功能(没有减少引用次数的功能)。
boot_map_segment()
这个函数相当的有趣,他的作用是把一段虚拟地址空间中的一段内容映射到规定的一段物理地址空间上,也就是说,这是一个整块建立映射关系的函数。我们知道,所谓的建立映射关系,就是修改页表项,所以我们的函数这样写
void boot_map_segment(Pde *pgdir, u_long va, u_long size, u_long pa, int perm) |
具体的思路就是将这段虚拟地址划分成多个虚拟页,然后依次将这些虚拟页对应的页表项检索出来,然后修改这些页表项,完成映射。
这里突然想到一件事情,就是对于给定的虚拟地址,只是物理地址不不能确定,但是页表项却是固定的。特定的虚拟地址只能对应特定的页表项(对于同一个页目录),我们的 pgdir_walk 正是基于这个原理实现的。正是因为如此,我们才能完成这个函数,我们修改的只是页表项的内容。
另外还有一个有趣的东西,是这个函数的使用
pages = (struct Page *)alloc(npage * sizeof(struct Page), BY2PG, 1); |
这个函数在 mips_vm_init 中被调用,在高 2G 的空间,alloc 以后其实对应的物理页面已经存储上相应的结构了,这是因为直接抹掉高 3 位就可以完成映射,所以映射已经完成了。那么还要两句 boot_map_segment 干嘛,他其实是在 UPAGES 和 UENVS 上拷贝了一份。也就是说,现在物理地址已经有两个控制块数组了,我们只是把一部分用户空间映射到了这写物理空间上面,先有的物理地址空间,后又的映射关系。这其实是内核对用户的一次分享。
boot_pgdir_walk()
这个函数实现的功能是给定一个虚拟地址,查找他的二级页表项,如果二级页表项不存在,那么就给这个虚拟地址分配一个物理页面(这个由参数控制,如果置 0,那么就是单纯的查找,如果置 1,那么就是查不到就分配页面),这样二级页表项就不是空了,最后返回这个二级页表项。需要注意的是,这里查找出的二级页表项指的是二级页表项在虚拟地址空间中的地址,而不是页表项本身,而是指向这个页表项的一个指针。
/* Overview: |
这个函数可以用如下流程图演示

此外,关于二级页表的查找过程,在此补充

pgdir_walk()
流程基本上与 boot_pgdir_walk () 类似,只有以下区别:
- 返回值不再是虚拟地址对应的二级页表项了,而是状态码,0 表示运行成功,其他表示有故障,二级页表项变成指针修改
- 分配方式不再使用 alloc,而是使用 page_alloc。这是因为这个函数不在启动的时候使用,而是在启动完成后使用,此时已经建立了物理内存管理的机制(空闲链表)
- 需要增加引用次数,之前 boot 中不需要,是因为在 pages_init () 里统一加了
/*Overview: |
page_lookup
讲这个之前,我想对很多东西做一个总结,mm 文件夹下有很多函数,有一个特点是只要是 page 开头的,一般都是与物理内存空间相关的,精确一些,他们在操作 Pages 结构体中的元素,比如
- page_init (): 初始化 pages 结构体数组和链表结构
- page_alloc (Page**): 从链表结构中分出一个
- page_free (Page**): 插入链表
- page_insert (Pde*, Page*, long, long): 建立一个虚拟页面与物理页面的映射关系,一般与
page_alloc成对出现 - page_lookup (Pde*, u_long, Pte**): 查询某个虚拟地址对应的物理页面控制块
- page_decref (Page **): 将该页面的引用次数下降
- page_remove (Pde *, u_long): 将页面映射关系删除掉
- pageout:调用 page_insert 和 page_alloc 来建立一个映射关系
从上面可以看出,page_alloc 和 page_free 是比较基础的函数,他们涉及对空闲页面的操作。page_insert 的功能不完善,他必须在给定具体是哪一个物理页面之后,才可以建立映射关系,换句话说,他干的不是修改 page 的工作,而是修改页表的工作,page_remove 也呈现这个特征。pageout 就比较完善了。
里面还有一些其他函数,大部分与查询页表有关。正是这些函数组成了内存管理。
还是需要强调,这些函数其实内核大部分都不会使用,因为高 2G 的映射关系就是高 3 位抹零,所以似乎在页表结构里登记都不用,因为就算登记了,也不能查,如果高 2 G 也查页表,那么就成了 bootloader 问题了,高 2G 需要借助页表完成转换,页表的登记需要高 2G 有完成转换的能力。
这个函数就是根据 va 将对应的物理页面的控制块查出来。同时还会获得对应的二级页表项,相比与 pgdir_walk 只能查找页表项,他可以完成对于该页表项的相关权限位的检验并且获取到控制块,可以看作是 page_walk 的加强版。
struct Page *page_lookup(Pde *pgdir, u_long va, Pte **ppte) |
tlb_invalidate()
给定特定进程的 context 这个函数可以让这个进程对应页表中的这个虚拟地址对应的 tlb 项失效。
// Overview: |
page_remove()
这个函数的意思是解除虚拟页面到物理页框的映射,也就是说,原来这个虚拟地址所在的虚拟页面在物理内存中是存在的,现在我们要解除这种映射关系。也就说,这个物理页面不在属于这个虚拟页面了。
首先,我们需要根据这个虚拟读地址来找到对应的物理页框的内存控制块和这个二级页表项
Pte *pagetable_entry; |
然后我们需要对内存块进行操作,具体就是减少引用次数,如果需要释放,那么就释放
/* Step 2: Decrease `pp_ref` and decide if it's necessary to free this page. */ |
然后让二级页表项变为 0,这样说明没有办法通过二级页表项查到对应的物理页面了
*pagetable_entry = 0; |
然后让 tlb 对应的项也清零
tlb_invalidate(pgdir, va); |
page_decref()
减少物理页面的引用次数,如果引用次数减为 0,才把这个物理页面对应的内存控制块插入到空闲链表的头部。似乎上面的 page_remove 里可以用这个函数改的更优雅些。
void page_decref(struct Page *pp) |
page_insert()
说是插入页面,其实更应该叫挤占页面。这个函数实现的功能是提供虚拟地址和给定的物理页框(所对应的内存控制块),把虚拟地址所在的虚拟页面映射到这个物理页框中去,并且给这个物理页框设置一些权限。
首先先获得该虚拟地址对应的二级页表项(方便之后处理和修改)
Pte *pgtable_entry; |
然后判断这个二级页表项有没有已经对应到物理页面上了
if (pgtable_entry != 0 && (*pgtable_entry & PTE_V) != 0) |
如果对应上了,那么接着判断已经对应的物理页框是不是我们需要它对应的物理页框
if (pa2page(*pgtable_entry) != pp) |
如果不是,那么就把这原有的映射关系取消掉
page_remove(pgdir, va); |
如果是,那么工作就很简单,基本上马上就能返回了。因为映射关系没有变,所以我们只需要把 TLB 无效掉,这是因为原有的权限可能和这次的权限不同。
tlb_invalidate(pgdir, va); |
然后我们插入一页
pgdir_walk(pgdir, va, 1, &pgtable_entry) |
注意这里不是冗余,上面的 walk 是查询,所以只查询,如果没查到不会创建新的页面,而这次如果没有查到,则会创建新的页面并且修改一级页表。
既然创建的新的,那么就需要设置一下二级页表项的权限
*pgtable_entry = page2pa(pp) | PERM; |
page_out()
这里似乎可以先记录一下自己对于 context 的粗浅理解。context 是进程上下文的意思,当操作系统切换进程的时候,原有进程的虚拟地址映射需要保留,其实就是保留页表系统,context 是一级页表的起始地址。
这个函数主要实现的功能就是给定虚拟地址和特定的进程一级目录地址,为这个进程的该虚拟地址对应的虚拟页面分配一个物理页框。
void pageout(int va, int context) |
tlb_asm.S
tlb_out()
这个函数主要实现的是给定一个 tlb 需要的键值(即表项的高 32 位),把这个键值对应的表项置 0 (就是 64 位全是 0)。
首先我们需要弄懂 tlb 的结构,计组认为的 TLB,是长这样的:

也就是说,TLB 是一个全相连的 cache,既然是全相连,就不由 index 段了。我们用虚拟地址的前 22 位作为 TAG,并行的比较 64 个 TLB 的 line,如果 TAG 相等,就说明找到了,反之,这说明没有找到。
不过这个模型还是有些粗糙的,很多细节并没有说明白。
在操作系统指导书里提到,tlb 构建了一个映射关系,我简化一下,就是 。当然这是对的了,但是这种说法我就弄成了每个 VPN 都会对应一个 PPN,但是其实这种映射关系只有 64 对。而且叫映射似乎就是一下就射过去了,而不是一个并行的比较过程。
其次就是,我们没有了解具体硬件发生了啥,比如 VPN 是怎样被检索的,被检索到的 PPN 放到了哪里,tlb 缺失以后具体怎么填补。都是没有的。这其实跟协处理器有很大关系。
在了解协处理器之前,我们先来看一下 tlb 的表项,他比计组版本要复杂一些,我们以 MOS 中 64MB (也就是共有 页 )的物理内存为例

我们来说明一下这些差别:
- 朴素版的 PPN 只有 14 位,是因为物理页框号可以最少用 14 位表示,但是真实版的 PPN 也与 VPN 相同,是 22 位。可能是考虑到不同电脑上内存不同吧,这估计也是 mips_detect_memory () 这个函数的设置。
- 朴素版一个 entry 是 36 位,而真实版一个 entry 是 64 位。这是因为真实版的标志位更多,所以需要的位数就更多
- 朴素版没有 ASID 段,而真实版有。ASID(address space identifier)应该是用于区分不同进程的一个标识符,因为操作系统可以同时运行多个进程,要是不用 ASID 的话,只要进程一切换,那么 TLB 里的所有内容都需要失效(因为进程切换就以为着虚拟地址和物理地址的映射关系切换),而这样是低效的,因为每次 TLB 中的内容清空,就意味着会发生 64 次的冷缺失。
- 朴素版没有物理地址权限标志位(N,D,V,G),而真实版有。这四个标志位的解释见下表
| 标志位 | 解释 |
|---|---|
| N(Non-cachable) | 当该位置高时,后续的物理地址访存将不通过 cache |
| D(Dirty) | 事实上是可写位。当该位置高时,该地址可写;否则任何写操作都将引发 TLB 异常。 |
| V(Valid) | 如果该位为低,则任何访问该地址的操作都将引发 TLB 异常。 |
| G(Global) | 如果该位置高,那么允许不同的虚拟地址映射到相同的物理地址,可能类似于进程级别的共享 |
总结起来就是真实版的 tlb 建立了一个这样的映射 。
然后我们来解决下一个问题,就是 tlb 怎么用的问题。这是一个我之前忽略的点,因为其实我对于 tlb 的定位并不清楚,我本以为它就好像是一个 cache,是对于程序员是透明的,我就在编程的时候写虚拟地址,然后就有硬件(MMU)拿着这个地址去问 tlb,tb 再做出相关反应,这一切都是我不需要了解的,但是实际上 tlb 的各种操作,都是需要软件协作的。之所以有这个错误认知,是因为似乎在 X86 架构下确实是由硬件干的,但是由于我们的 MIPS 架构,也就是 RISC 架构,所以似乎交由软件负责效率更高一些。
如果 tlb 是程序员可见的,那么我们必然要管理它,那么我们就需要思考怎样管理它?我们管理它的方式就是设置了专门的寄存器和专门的指令。指令用于读或者写 tlb 中的内容,而寄存器则用于作为 CPU 和 tlb 之间沟通的媒介,就好像我们需要用 hi 和 lo 寄存器与乘除单元沟通一样。这些寄存器,都位于 CP0 中
在协处理器里面与 tlb 有关的寄存器如下表:
| 寄存器 | 编号 | 作用 |
|---|---|---|
| EntryHi | 10 | 保存某个 tlb 表项的高 32 位,任何对 tlb 的读写,都需要通过 EntryHi 和 EntryLo |
| EntryLo | 2 | 保存某个 tlb 表项的低 32 位 |
| Index | 0 | 决定索引号为某个值的 tlb 表项被读或者写 |
| Random | 1 | 提供一个随机的索引号用于 tlb 的读写 |
这里再说一下各个寄存器的域
- EntryHi,EntryLo 的域与 tlb 表项完全相同
- Index 的域:

- Random 的域:

与 tlb 相关的指令
| 指令 | 作用 |
|---|---|
| tlbr | 以 Index 寄存器中的值为索引, 读出 TLB 中对应的表项到 EntryHi 与 EntryLo。 |
| tlbwi | 以 Index 寄存器中的值为索引, 将此时 EntryHi 与 EntryLo 的值写到索引指定的 TLB 表项中。 |
| tlbwr | 将 EntryHi 与 EntryLo 的数据随机写到一个 TLB 表项中(此处使用 Random 寄存器来 “随机” 指定表项,Random 寄存器本质上是一个不停运行的循环计数器) |
| tlbp | tlb probe。用于查看 tlb 是否可以转换虚拟地址(即命中与否)根据 EntryHi 中的 Key(包含 VPN 与 ASID),查找 TLB 中与之对应的表项。如果命中,并将表项的索引存入 Index 寄存器。若未找到匹配项,则 Index 最高位被置 1。 |
然后我们来看这个函数的实现
它首先把原来 EntryHi 寄存器里的数据保存了下来(因为一会儿要把我们的键值放进去,怕数据被覆盖了)
mfc0 k1, CP0_ENTRYHI |
然后进行查询,并且判断有没有查询到
mtc0 a0, CP0_ENTRYHI # 把键值移入 EntryHi |
如果没有查到,就只进行如下操作:把原来的 EntryHi 给恢复了,返回
NOFOUND: |
如果查到了,在进行上面的操作之前,还需要进行把这个条目全部清零的操作
mtc0 zero, CP0_ENTRYHI # 将 EntryHi 清零 |
lib/
genex.S
个人感觉是 generate exception 的缩写。在这里突然想到到底需求的代码是没有办法用 C 语言书写的,而必须哟弄汇编语言书写。个人感觉一旦涉及到特定的寄存器了,那么 C 语言就会失效。比如说协处理器里面的寄存器。我们知道异常处理都与这些寄存器相关,所以必须得用汇编语言书写。可以这样表述:
C 语言相较于汇编语言丧失了寄存器级别的控制力。
C 语言实现了变量的抽象。
BUILD_HANDLER
这个代码里涉及了很多汇编的知识,先看代码
.macro BUILD_HANDLER exception handler clear |
首先是引用形参,gcc 不是依靠 % 而是依靠 / 这个符号(与这个现象类似的是注释也不再是 # ,而是 // ),比如说这句话
NESTED(handle_\exception, TF_SIZE, sp) |
如果有一个宏的引用长这样
BUILD_HANDLER tlb do_refill cli |
其实上面那句话就是
NESTED(handle_tlb, TF_SIZE, sp) |
然后我们会看到很多 .set 伪指令,这些伪指令都是为了控制编译器的工作状态的。这里统一总结一下:
| 指令 | 解释 |
|---|---|
| noreorder/reorder | reoder 就是乱序的意思,允许乱序就不让插入 nop,我不知道为啥 |
| volatile/novolatile | 当设置成 volatile 的时候,访存指令之间就不可以调换顺序了,反之,则可以调换顺序 |
| noat/at | $at 寄存器一般(即 at 状态)是归编辑器管理的,是编译器用来辅助宏的实现的,如果用户尝试使用,会报错或者警告。当设置为 noat 状态后,$at 归用户管理,编辑器如果使用,则会报错或者警告 |
| nomacro/macro | 不是禁用宏,而是禁用像 li 这样由其他多条指令组成的指令 |
这个宏声明了一个异常处理函数(Exception Handler),它提炼了一些异常处理函数的共性。
它先声明了一个函数,这个函数是一个嵌套函数,他的名字是 handle_exception
NESTED(handle_\exception, TF_SIZE, sp) |
然后保存了寄存器
SAVE_ALL |
下面这句话设置了中断,但是叶哥哥说没用,因为 R3000 会在陷入中断时在硬件层面设置,不需要软件设置
__build_clear_\clear |
然后跳转到了真正的异常处理程序处
move a0, sp |
然后处理完后,跳转到返回函数处
j ret_from_exception |
这个返回函数应该是在其他文件定义的,因为有如下语句
FEXPORT(ret_from_exception) |
最后我们在这个文件里找到了它应用这个宏声明了三个 handler,如下
BUILD_HANDLER reserved do_reserved cli |
do_refill()
这个函数在 tlb 发生缺失异常的时候,将缺失的物理页面调入 tlb 中,修复异常。
当 tlb 发生缺失的时候,其实对应了两种情况
- 虚拟页没有映射到物理页框上
- 虚拟页面映射到物理页框上,但是物理页框没有放在 tlb 里
对于第二种情况,我们就其实就是可以直接挑一个 tlb 的项,然后覆盖掉就好了,具体的操作就是如下
mtc0 k1,CP0_ENTRYLO0 |
但是如果是第一种情况,那么我们还需要为这个虚拟页分配好物理页框,也就是调用 page_out 函数。
.extern tlbra |
printf.c
myoutput()
myoutput 会打印出给定长度的给定字符串。
static void myoutput(void *arg, char *s, int l) |
因为有 static 关键字,所以只在本文件可见,所以在调用的时候,用了函数指针的操作。奇怪的是,第一个参数 arg 并没有用到,这个在真正调用的时候,传入的是 0。我不太能理解为啥。后面的参数 s 就是需要打印的字符串,l 是字符串的长度,可以看到,就是用一个 for 循环去调用 printcharc 很好理解。
printf()
就是我们用的最熟的那个形式和功能。
void printf(char *fmt, ...) |
因为指导书里介绍过了可变参数列表的内容,这里不再介绍,所以这个函数很显然,而且参数就是我们知道的参数。
_panic()
void _panic(const char *file, int line, const char *fmt, ...) |
在这次作业中没有涉及,但是可以查到他的用处
用于显示内核错误信息并使系统进入死循环
结合这个作用来看,还挺自然的。
print.c
OUTPUT
首先需要了解一下什么是函数指针
函数指针为什么可以存在,我觉得吴哥哥说的在理
C 是高级的汇编语言。
函数是一段代码,代码存在内存中,我们可以用指针访问内存,所以函数指针是可行的。对于函数指针,是可以讲很多的,但是因为我不会,所以就不讲了。
对于函数指针的声明,虽然有经典的一层一层分析,但是我不会写,所以就记一下好了
//函数返回值类型 (* 指针变量名) (函数参数列表); |
对于函数指针的赋值,我也打算记一下就好了
fp = &f; |
对于函数指针的使用
(*fp)(a,b); |
然后来看这个宏
/* private variable */ |
这个宏其实就是用来输出的,这个宏的三个参数就是 myoutput 的三个参数。我们看到在 if 中首先看到的是错误输出语句,就是输出那句 fatal error in lp_Print! 。在 else 中才是正确语句。
PrintChar()
首先是 PrintChar
int PrintChar(char *buf, char c, int length, int ladjust) |
可以看出,他大概会往 buf 中写入 _______C 或者 c_______ (下划线是空格的意思)这样的东西 ,这个写入字符串中的字符由参数 c 控制,字符串的长度由参数 length 控制,决定是空格在前还是在后,由 ladjust 控制。
PrintString()
int PrintString(char *buf, char *s, int length, int ladjust) |
这个函数实现的功能就是把字符串 s 中内容拷贝到 buf 中。同样涉及到一个左对齐还是右对齐的问题,如果 ladjust(left-adjust)大于零,那就进行左对齐,否则右对齐,里面引入了一个 len 就是为了实现这个功能(对齐补空格)。
PrintNum()
int PrintNum(char *buf, unsigned long u, int base, int negFlag, |
这是把一个数拷贝到 buf 中,相当于将数字转换成字符串。我们可以指定基数,还可以指定对齐方式和前导零问题。但是就跟我们在程设中遇到的一样,是需要调转字符串的。
lp_Print()
void lp_Print(void (*output)(void *, char *, int), |
其实我们实现的最重要功能不是打印字符串,这个功能是很好实现的。我们实现的最重要功能是格式化输出,这个功能才是实现的需求,也是难点,所以格式符形式
%[flags][width][.precision][length]specifier |
只要了解就可以了。
tools/
scse0_3.lds
这个链接脚本还是十分重要的。
首先它规定了内核程序的入口函数
ENTRY(_start) |
其次,它规定了内核程序在虚拟地址空间中的分布
SECTIONS |
我们可以根据这个画出一幅图来

这个 end 指针会在分配虚拟内存空间的时候用到,这并不奇怪,因为 end 指针刚好是初始化结束后内核栈的栈顶,而内核栈是向下增长的。
drivers/
dev_cons.h
dev_cons.h 中定义了一些外设虚拟地址常量,这些常量会在 console.c 中被用到(从注释角度来看,应该是不止这个文件会用到),下面是源代码
|
console.c
address macro
从这三个宏(以及上面那个头文件中的宏),我们可以得出 PUTCHAR_ADDRESS 的值是 0xB000_000,HALT_ADDRESS 的值是 0xB000_0010。他们都是虚拟地址。这个地址是很符合常理的,因为这俩地址都属于 kseg1,是留给外设的。唯一需要吐槽的就是 PHYSADDR_OFFSET 这个名字是错的。
putcharc()
void printcharc(char ch) |
前面的 volatile 是一个关键字,它提醒编译器它后面所定义的变量随时都有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址中读取数据。如果没有 volatile 关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序更新了的话,将出现不一致的现象。
然后我们看出就是它往内存中某个特定地址中写了一个字符,这个内存刚好对应外设,所以可以在屏幕上输出(或者其他的,我不清楚具体哪个外设)。
halt()
halt 函数也类似,都是往一个特殊的地址中写了个数据,作用是使程序终止。
void halt(void) |
printstr()
printstr 就是连续调用 printcharc
void printstr(char *s) |