UnixV6分析(3) 进程--启动与数据结构
25 May 2013 by LelouchHe
闲话
上次说这次预备着把进程讲完,顺便把大部分汇编代码搞定.但是后来细细一想,觉得理想有些颇为宏大了.进程作为OS的一个重要概念,要是能在一篇博客中说完,真是不能理解,更何况还有一堆的汇编要理解.
所以现在决定要慢慢的将这一部分.
首页要讲的就是启动与数据结构
好啦,闲话莫提
启动
硬件启动
OS的生命源于启动的一刻,而这一刻大致上都是相似的.
现代OS的启动大致流程如下:
- 硬件接收到启动的命令,利用存在ROM里的BIOS简单的IO功能,将启动分区中特殊的一个boot段内容读取到内存中,然后执行其代码(把pc指向其内存初始地址即可)
- boot段一般很小,所以不可能是全部的OS,大部分情况下只能是loader,比如我们熟悉的grub就是这个时候参与进来的.loader会读取(仍然利用BIOS,此时高级功能还没有)启动文件,从中找到真正的OS程序文件,把程序文件读入内存,然后执行其代码.
- 现在执行的就是真正的OS代码了,这才是真正OS启动的开始,我们后叙.
其实可以看到,第1步的硬件启动是不区分2/3的区别的,对于硬件而言,只要是可执行的代码,存储在约定好的位置(又见约定),就可以被拉入内存运行(有时应该还有大小限制)
2/3的区别主要在于间接层的引入,通常boot段的约束很多(大小,功能之类),所以一般是loader,很早的os可能也会从这里启动.引入loader的另一个功能是做多启动,比如现在的双系统等,这个小功能是在loader阶段实现的
但是要注意,不是所有的启动都是这三段,第2步其实是可以扩充的,而且在很多情况下,确实需要这种特定的扩充.此时的结构就是如下:
硬件 –> loader1(ROM) –> loader2(特定分区) –> loader3(特定OS) –> OS
不过具体到我们的v6来说,启动还是比较简单的,我们按照上面的图来说:
- 硬件启动,执行ROM里的loader
- loader从特定分区(主分区,#0分区)查找OS程序文件(/unix),并装入内存(从地址0开始)
- 执行OS程序文件
构建运行环境
硬件地址一览
运行环境包括很多方面,其中之一就是各种寄存器的使用和内存的布局,这里面涉及到很多硬件交互.拜pdp11-40设计所赐,这些硬件都在内存地址的高4KB中,我们就来看一下会使用到的各常量值(1444-1455行):
PS = 177776
SSR0 = 177572
SSR2 = 177576
KISA0 = 172340 / Kernel I-space Sector Address 第0个kernel的PAR
KISA6 = 172354
KISD0 = 172300 / Kernel I-space Sector Discriptor 第0个kernel的PDR
MTC = 172522 / 后叙
UISA0 = 177640 / User I-space Sector Address 第0个user的PAR 同于UISA(306行)
UISA1 = 177642
UISD0 = 177600 / User I-space Sector Discriptor 第0个user的PDR 同于 UISD(304行)
UISD1 = 177602
IO = 7600 / IO起始的块位置,即地址0760000开始的
这些其实都能从pdp11-40的手册里看到(但是为啥我就找不到呢???),以后出现这些符号时,我们就不一一解释了(后叙的符号我们后叙)
设置kernel寄存器
本质来讲,OS其实也是一个进程,唯一的区别在于普通进程是OS装载并执行的,而OS进程则是硬件装载并执行.OS可以在普通进程运行之前构建好运行环境,但硬件则不会给OS这样的便利,所以OS的一开始需要给自己构建合适的环境,以便在一无所有(OS启动之初)的时候,执行各种复杂的功能.
v6进程的执行从地址0开始(loader把整个unix文件装载到地址0处,见507行),从这里跳转到522行,接着又跳转到真正的开始阶段start(612行)
同时,我们也能看到跳过去的这几行的内容,从上一篇中我们就知道了,这是中断向量表,我们分析过512-518行的普通中断向量,讲到特殊硬件驱动时会谈到525-577行的驱动中断,这些后叙.
真正的启动从start开始,如下(612行):
start:
bit $1, SSR0 / 检验SSR0的最低位是否为1
bne start / 如果是,则跳转回start
reset
如果看了第一章,就知道SSR0其实代表了pdp11-40的SSRO寄存器的位置(见1445行),其地址为177572(还记得地址最高4KB的地址变换规则么?).此处其实是错误验证,确保SSR0确实进行了清空,否则就会死循环,直到清空位置.这同样也是约定,而且保证启动确实正常执行了.
reset指令就是再次的清空各种状态值,包括SSR0.如果各位还记得,SSR0中最低1位表示是否开启扩展内存模式,0表示不开启.那么现在,我们使用的就是基本内存模式,除去最高4KB被映射为各种硬件地址外,所有的地址都是实打实的物理内存地址.记住这点.
我们继续往下看:
mov $KISA0, r0
mov $KISD0, r1
mov $200, r4
clr r2
mov $6, r3
1:
mov r2, (r0)+
mov $77406, (r1)+
add r4, r2
sob r3, 1b
很简单的汇编代码,其实就是在对kernel的PAR和PDR赋值.现在我们仍处在基本内存模式,但最后会转入扩展内存模式,所以必须先设置好对应的段寄存器值(user的对应值在不同user下不同,所以现在不设置)
此处我们设置了kernel的前6个段寄存器(0-5),映射着物理内存地址的前0140000B(前48K),各段长度限制为020000B(8K),无修改(W=0),高位扩展(ED=0),可读写(ACF=11)
接下来我们处理PAR/PDR6
mov $_end + 63, r2 / _end表示装载后数据段+bss段长度(由于地址从0开始,也是末尾地址); _edata表示的仅是数据段
ash $-6, r2
bic $!1777, r2
mov r2, (r0)+
mov $USIZE - 1 \ < 8 | 6, (r1)+
之所以特殊设置PAR/PDR6,是因为该段寄存器有一个特殊的目的,保存属于用户进程的信息,每个用户进程都会在kernel内存空间中有这样的一个段,里面都是该进进程的相关信息,包括最为重要的kernel栈.所以后面可以看到,当切换用户进程时,该段寄存器的值会发生变化,但kernel还是可以把该段寄存器当作当前用户进程信息来引用,就不用担心切换的问题.
我们有一些特殊的全局变量来引用这里,如_u
(1441行),其值为0140000(恰好是本段的虚拟起始地址);USIZE(1442行),其值为16(注意,这个是十进制,后面有’.’),表示了本段的大小;_ka6(1460行),其值为KISA6,为该段寄存器的虚拟硬件地址.这也是为什么我们上面定义的常量值中除了起始KISA0外,还有KISA6的原因,因为它很重要,要经常用到.
再来看这段代码,就比较简单了.那段看似复杂的运算,其实在找_end之后空闲内存中按block对齐的位置,然后设置好PAR和PDR(长度限制为1K,无修改,高位扩展,可读写)
接下来我们设置PAR/PDR7
mov $IO, (r0)+
mov $77406, (r1)+
我们把前文的IO(07600)赋值到PAR7,这样对该段的存取就自动映射到了最高地址,这也就说明,PAR/PDR7是用于IO硬件交互的,普通的程序代码数据不会在本段中出现.
076正好同基本内存模式下的最高8K一致,因此硬件地址在扩展内存模式下的值是一样的(这是出于v6的专门设计),所以我们才能直接使用和基本内存模式下一样的硬件地址,如_ka6,我们直接引用它来修改kernel的S6,来完成进程的切换
这样,我们就把8个kernel段寄存器设置完毕了,可以看到,v6对这些寄存器的使用是不同的,前6个主要是kernel直接使用的,S6主要用于表示当前用户进程信息,S7用于映射IO交互.
现在我们来看一下目前的内存使用情况
我们可以清楚的看到,虽然设置了6个段,48K的空间可以使用,但是kernel不一定能使用了这么多.在设置S6的时候,我们使用了_end
来表明整个os的末尾,S6直接来使用这个地址来映射.
同样我们也能看到,S6有效的只有前1K的大小,但S6的地址空间仍然占据了8K的大小.这个原因很隐晦,我们来分析下虚拟地址到物理地址的阿映射.虚拟地址的高3位作为段寄存器选择符,可以选择最多8个段寄存器,这就决定了每个段寄存器可以映射的范围,比如s6的映射范围为0140000-0157777,S7的映射范围就是0160000-0177777.这样就明白了,即使我们能使用的只有1K(像S6一样),但是其在虚拟地址的映射范围,仍然横跨8K.
与此同时,我们也可以看到,这样的话,基本内存模式和扩展内存模式下,IO部分的地址是一致的(二者在统一范围内,而且映射到统一物理内存中).前面的常量标志既可以看作是物理内存地址(基本内存模式下),也可以看作虚拟地址(扩展内存模式下)
也许有疑问,不同的段有重合,这样做没有问题么?自然,如果有重合,也只能说明kernel没有用到那么多内存,自然也不可能产生访问那些地址的指令和数据,我们自然不用担心重合的问题.
下面我们开启扩展内存模式,万事具备,只欠东风了
mov $_u + [USIZE * 64.], sp
inc SSR0
我们把kernel的sp设置为S6的最高位置(还记得_u
和USIZE么?你看,kernel的栈在这里),然后开启扩展内存模式.
需要记住的一点是,开启扩展内存模式之后,虚拟地址仍然是连续的,虽然它们可能在物理内存地址上不连续(我们看到了,S0-S5是连续的,S6在OS数据段后,S7直接是最高区域),这同样限制了OS可以使用的内存大小(同一时间只有6 * 8K + 1K,当然,你可以换啊换啊换之类的)
在这之后,我们就开始使用虚拟地址了,由于kernel段寄存器的设置,我们的kernel可以同刚才一样的使用(因为前6个段是从0开始连续的48K)
初始状态清零
接下来我们把涉及到的其余数据清零(651行开始):
mov $_edata, r0 / kernel的代码可以直接使用,虚拟和物理地址一致
1:
clr (r0)+
cmp r0, $_end
blo 1b
mov $_u, r0 / _u使用虚拟地址,跨出了kernel代码范围
1:
clr (r0)+
cmp r0, $_u + [USIZE * 64.] / 可以看到,kernel栈也清零了
blo 1b
然后就是要跳转到真正的初始函数位置了(668行):
mov $30000, PS / 当前为kernel模式
jsr pc, _main / 跳转到main
mov $170000, -(sp) / 设置前/现模式为user
clr -(sp) / 清零
rtt / 跳转,ps=0170000,pc=0
其实本进程永远不会从main返回的,它会作为调用进程一直运行到os挂掉.会返回的其实是这个进程开的新进程的kernel部分–init(对,就是那个init进程,一切进程的父进程),返回后会直接跳转到0来执行user代码,这个代码就是init进程的user部分,然后init就可以执行我们的初始化东东了.
main的作用
本章我们不讨论main的具体代码,因为这依赖于很多其他相关函数,我们会在下一篇中重点介绍,这里仅简单的说下main的功能
main主要是手动的初始化进程项和系统的各种硬件,包括文件,中断,时钟等,然后fork出处是进程init,其返回后执行其在user下的代码(即1516行的icode)
数据结构
这里我们不纠结于进程和程序文件的区别,我们把它们当作一个概念,这里想区分的其实是进程的不同状态.
每个进程有两个模式状态,一部分在user模式下,使用普通的用户态代码和数据,另一部分则是当进程通过系统调用进入的kernel模式,使用kernel的特权代码和数据.
user模式比较好理解,毕竟我们平时的开发都在这个方面,一旦我们进行了系统调用(直接,或者通过程序库),我们转而进入kernel模式,此时执行的是预先在内存中的固定代码,os只不过是在代表该进程进行一些特权处理而已.
当然,kernel模式下不一定执行的都是本进程的东东,比如本进程运行时发生了中断,或者被main进程调度了,此时的中断程序和调度程序会在本进程的kernel模式下运行(也就是被kernel借用来干kernel自己的事情了),所以我们也不要把kernel模式就当作完全属于一个进程的.user模式和kernel模式可以想象成不同的分工,user模式就是进程干自己的活,kernel模式则是将活交给kernel来做,kernel也许很不专心,会做一些别的事情.
关于进程最重要的数据结构有两个,一个是proc,用于表示活动进程信息,proc组成的数组是常驻内存的,给kernel用于调度等有限的目的,结构很小,一个是user,用于表示进程的kernel部分,这里面有进程的全部信息(当然,也有指向的proc),每个时候只有一个user结构可以存取,即上文说过的_u
指向的kernel的S6
proc
proc的结构如下(358行开始):
struct proc
{
char p_stat; // 运行状态,是否运行中
char p_flag; // 属性,swap/调度/lock/trace
char p_pri; // 优先级,越小优先级越高
char p_sig; // 收到的信号
char p_uid; // 用户id
char p_time; // 调度运行时间
char p_cpu; // 调度cpu使用率
char p_nice; // 调用的nice值(越大对其他进程越nice)
char p_ttyp; // 控制终端
int p_pid; // 进程id
int p_ppid; // 父进程id
int p_addr; // 程序镜像地址
int p_size; // 程序镜像大小(以64B为单位)
int p_wchan; // 等待的事件
int *p_textp; // text段的指针
} proc[NPROC];
可以看到,proc结构里大多数都和调度有关,这样我们调度的时候,就没有必要获取到进程的全部信息,就可以调度(其实我们也获取不到,除了当前进程,其余的不在我们可寻址范围内)
同样,os内部有一个同样名为proc的数组,大小为NPROC(见144行,值为50),这样就限制了在os内可同时运行的进程数.当然,这个数目有些小,但对于只有些许内存的pdp11-40,已经足够了.
user
user结构保存的是全部的进程信息,这些信息并不常驻内存,同一时刻应该只能有一个user结构的存在.这个结构就保存在kernel模式下的S6中的低地址处,同当前进程的kernel栈在同一段中(S6的大小为USIZE * 64, 1K),我们引用时,有一个特定的全局变量_u
这部分内容成为ppda(per process data),后面我们会经常碰到的
struct user
{
int u_rsav[2]; // r5,r6
int u_fsav[25]; // 浮点寄存器
char u_segflg; // IO标志
char u_error; // error code
char u_uid; // 有效uid,比如setd等操作
char u_gid; // 有效gid
char u_ruid; // 真正uid
char u_rgid; // 有效gid
int u_procp; // 指向所属proc的指针
char *u_base; // IO缓冲区地址
char *u_count; // 剩余IO字节
char *u_offset[2]; // 当前IO文件的偏移(一次只能一个文件)
int *u_cdir; // 当前路径节点
char u_dbuf[DIRSIZ]; // 当前路径名
char *u_dirp; // 当前使用的inode节点
struct { // 当前路径入口
int u_ino;
char u_name[DIRSIZ];
} u_dent;
int *u_pdir; // 父目录节点指针
int u_uisa[16]; // user par,40只有8个
int u_uisd[16]; // user pdr,40只有8个
int u_ofile[NOFILE]; // 打开文件集
int u_arg[5]; // 传递的参数
int u_tsize; // text段大小(以64B为单位)
int u_dsize; // data段大小(以64B为单位)
int u_ssize; // stack段大小(以64B为单位)
int u_sep; // 是否区分I/D空间,40不区分,为0
int u_qsav[2]; // 退出和中断标志
int u_ssav[2]; // swap标志
int u_signal[NSIG]; // 进程处理的信号集
int u_utime; // user time
int u_stime; // sys time
int u_cutime[2]; // 子进程user time,模拟32位数
int u_cstime[2]; // 子进程sys time,模拟32位数
int *u_ar0; // user模式下保存的r0地址,可以修改做返回值
int u_prof[4]; // 各种统计信息
char u_intflg; // 是否被中断标志
// 剩下的就是kernel栈了
} u;
这个结构的内容注释里写的很清楚,我这里相当于翻译了一下,增加了些简单注解.这个结构为了兼容40和70,所以有很多地方和我们想的不一样,我做了特别说明.
我们同样定义了一个全局变量,标志我们目前使用的cpu类型.当然,这个是从配置里获取的,不需要我们手动生成(其实很多全局配置信息都是自动生成的,不需要手动录入).具体见1461行:
_cputype:40.
这个全局变量在这里定义,但其位置是指定好的,见1440行:
.globl _u
_u = 140000
USIZE = 64.
u的位置恰好是S6的虚拟地址(当然,这一切都不是恰好,是安排),我们也看到了USIZE的定义
总结
这里我们看到了关于启动的过程和进程的相关数据结构,特别的讲解了kernel的内存使用情况,包括各段寄存器的分配和内存地址的使用.
下一篇,我们继续讲解有关进程的东东,包括入口函数main和其他相关函数,重点是新进程的建立和进程的调度实现,这个比较trick,所以大家一起用心了.