Home

UnixV6分析(3) 进程--启动与数据结构

25 May 2013 by LelouchHe

闲话

上次说这次预备着把进程讲完,顺便把大部分汇编代码搞定.但是后来细细一想,觉得理想有些颇为宏大了.进程作为OS的一个重要概念,要是能在一篇博客中说完,真是不能理解,更何况还有一堆的汇编要理解.

所以现在决定要慢慢的将这一部分.

首页要讲的就是启动与数据结构

好啦,闲话莫提

启动

硬件启动

OS的生命源于启动的一刻,而这一刻大致上都是相似的.

现代OS的启动大致流程如下:

  1. 硬件接收到启动的命令,利用存在ROM里的BIOS简单的IO功能,将启动分区中特殊的一个boot段内容读取到内存中,然后执行其代码(把pc指向其内存初始地址即可)
  2. boot段一般很小,所以不可能是全部的OS,大部分情况下只能是loader,比如我们熟悉的grub就是这个时候参与进来的.loader会读取(仍然利用BIOS,此时高级功能还没有)启动文件,从中找到真正的OS程序文件,把程序文件读入内存,然后执行其代码.
  3. 现在执行的就是真正的OS代码了,这才是真正OS启动的开始,我们后叙.

其实可以看到,第1步的硬件启动是不区分2/3的区别的,对于硬件而言,只要是可执行的代码,存储在约定好的位置(又见约定),就可以被拉入内存运行(有时应该还有大小限制)

2/3的区别主要在于间接层的引入,通常boot段的约束很多(大小,功能之类),所以一般是loader,很早的os可能也会从这里启动.引入loader的另一个功能是做多启动,比如现在的双系统等,这个小功能是在loader阶段实现的

但是要注意,不是所有的启动都是这三段,第2步其实是可以扩充的,而且在很多情况下,确实需要这种特定的扩充.此时的结构就是如下:

硬件 –> loader1(ROM) –> loader2(特定分区) –> loader3(特定OS) –> OS

不过具体到我们的v6来说,启动还是比较简单的,我们按照上面的图来说:

  1. 硬件启动,执行ROM里的loader
  2. loader从特定分区(主分区,#0分区)查找OS程序文件(/unix),并装入内存(从地址0开始)
  3. 执行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交互.

现在我们来看一下目前的内存使用情况

kernel使用内存情况

我们可以清楚的看到,虽然设置了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,所以大家一起用心了.