这个前言的目的就是解释清楚理解main运行的一些基本的概念。如下:
很神奇吧,最开始Linus已经考虑了CPU负载均衡这一点了,先别急着惊叹,我来跟你说说是怎么一回事。
基本上现在的Linux里,都有0号进程,进程调度的时候,如果检测到就绪队列为空,就会一直执行0号进程,因此我们也把0号进程称为闲逛进程。当然我们现代操作系统所实现的负载均衡并不只是执行进程0,因为我们目前都是多核CPU,有可能这一个CPU上没有等待执行的进程的,别的CPU上还有呢?这个不是我们今天的重点,这部分的分析,我在前面写进程调度源码分析的时候已经说过了,这个不再赘述。
但是当时Linus实现的CPU负载均衡就只是把进程0放在CPU上执行。
进程0是在什么时候创建的呢?我们前面分析boot的时候提过,boot执行完了以后把控制权交给了init模块(也就是开始执行main.c),这时候是在0特权级下运行的,0特权级也就是最高特权级。main.c执行完了内核各个模块的初始化工作以后,需要切换到3特权级下,等切换完了以后main.c就是以进程0的身份运行的。
我们知道Linux创建进程是用函数fork创建的,执行的操作基本上就是复制父进程的各个资源,例如:子进程复制父进程的栈空间,子进程复制父进程的数据段等等。进程0是没有父进程的,所以进程0是怎么创建的呢?我们在本文第三个部分——分析源码的时候会讲,这里暂且不提。这里只需要记住,进程0负责创建进程1,进程1创建进程2和第一个进程组。
这个没什么要理解的,我直接告诉你进程0在创建进程1也就是我们常说的init进程之前,不能使用栈空间,下面是解释。
前面有一篇讲请求分页机制的博文里面讲过:从内核空间fork的进程,并没有写时复制机制(COW),是创建的时候就直接分配的。所以进程1创建的时候,是直接复制了进程0的用户栈空间,为了避免进程0的栈中有进程1并不想要的多余的信息,在fork之前,进程0不能用自己的栈,这也就意味着,进程0不能调用函数。所有的函数调用都是触发中断来实现的。
exit函数(标准IO库函数)和_exit函数(系统调用)都是实现进程退出功能,具体的操作都是,关闭打开的文件,如果进程还有子进程,子进程被init进程收养,然后返回给父进程一个SIGCHLD信号,调用sys_exit。
不同的是,_exit函数并不会刷标准I/O缓冲。
在讲TSS之前我们先说一说特权级这个问题。特权级是只有在保护模式下才有的概念,设置特权级都是为了限制非法访问的,比如说低特权级的进程访问了高特权级的资源。特权级有4个,分别是0,1,2,3,数字越小特权级越高。在Linux中内核的特权级最高是0,用户特权级最小是3。我们常常会有这样的进程,在用户特权级下运行,但是调用了一个系统调用,需要陷入内核。这时候就需要特权级切换。
TSS(Task State Segment),任务状态段,用于任务切换的时候保存现场信息的数据结构。TSS段有很多的字段,主要可以分为:寄存器保存区域、堆栈指针保存区域和其他的预留区域等等。说起来很复杂,本文并不全部介绍,只是说几个跟我们主题有关的字段——堆栈指针保存区域。
每一个TSS保存的堆栈不能超过四个,这是因为同一个任务在同一个特权级下最多只能有一个堆栈,因为只有四个特权级,所以最多只能有四个堆栈(ss0,sp0,ss1,sp1,ss2,sp2,ss3,sp3)。
我们前面说了特权级切换,特权级切换有两种情况:
* 低特权级向高特权级切换,就像我们刚刚举得系统调用例子
* 高特权级向低特权级切换,我们本文遇到的情况。
低特权级向高特权级切换的时候,CPU自己把当前特权级的堆栈指针(ss和sp)压入高特权级所对应的栈中。比如说我们刚刚的例子中,是从特权级3切换到特权级0,则特权级3的堆栈指针信息压入TSS中的ss0和sp0中。
所以如果执行iret或retf指令从低特权级切换到高特权级的时候,只需要从高特权的堆栈中拿出低特权级对应的堆栈信息即可。
一个或者多个进程组构成一个会话组,会话组ID就是创建会话组的进程(称为会话首进程)的ID,在Linux0.11中每登陆一个用户,init进程创建一个会话组。
每一个会话组可以打开一个终端设备作为控制终端,打开控制终端的进程称为控制进程。一个会话组中有很多个进程组,这些进程组被分为前台进程组和后台进程组。
函数setsid函数可以创建一个会话组,函数原型如下:
pid_t setsid(void);
调用setsid函数的进程创建一个新的会话组,会话组ID既该进程PID,同事也创建一个新的进程组,进程组ID也是该进程的PID。这就要求,调用setsid的进程并不是任何一个进程组的组长。该进程如果之前已经和一个终端关联,setsid函数”切断”这中关联。
setup.s程序读出了系统的一些硬件信息,存在主存里。 main.c的具体功能是:
* 读取根设备
* 读取硬盘信息
* 得到内存大小(包括缓存、内核镜像所占空间和主存在内的全部大小)
* 根据内存大小设置缓冲大小
* 设置分页开始位置
* 主存分页管理
* 陷阱门初始化
* 块设备初始化
* 字符设备初始化
* tty初始化
* 时间初始化
* 调度初始化
* 缓冲管理初始化
* 硬盘和软盘初始化
* 创建进程0(此时0特权级切换为3特权级)
* 进程0创建进程1(init进程)
* 进程1打开标准输入标准输出和标准错误
* 创建进程2执行shell
* 循环创建进程执行登陆shell
我们知道新建进程都是调用fork函数实现的,那下面我们看看第一个进程是怎么创建出来的。
下面我们分析main.c的代码:
static inline _syscall0(int,fork)
static inline _syscall0(int,pause)
static inline _syscall1(int,setup,void *,BIOS)
static inline _syscall0(int,sync)
这几行代码以内嵌宏代码的形式实现了fork、pause、setup和sync函数的调用,我们前面说过了进程0不能使用栈空间,所以意味着进程0在不能直接调用函数。_syscall0通过触发0x80(系统调用)中断实现了函数的调用。
ROOT_DEV = ORIG_ROOT_DEV;
drive_info = DRIVE_INFO;
memory_end = (1<<20) + (EXT_MEM_K<<10);
memory_end &= 0xfffff000;
if (memory_end > 16*1024*1024)
memory_end = 16*1024*1024;
if (memory_end > 12*1024*1024)
buffer_memory_end = 4*1024*1024;
else if (memory_end > 6*1024*1024)
buffer_memory_end = 2*1024*1024;
else /
buffer_memory_end = 1*1024*1024;
main_memory_start = buffer_memory_end;
#ifdef RAMDISK
main_memory_start += rd_init(main_memory_start, RAMDISK*1024);
#endif
先得到根设备的设备号,然后设置内存结束的位置(此时的内存是缓冲加上主存的全部大小),内存大小忽略不到4k字节的内容(一页大小是4k字节)。如果内存大小超过了16M字节则按16M字节计。根据内存的大小设置缓冲区的大小:如果内存大小大于12M,缓冲大小是4M;如果内存大小在6M~12M之间,缓冲大小是2M;否则缓冲大小是1M。
设置主存开始的位置是缓冲结束的位置,从主存开始的位置到主存结束的位置都是要分页管理的。此时我们看到了,缓冲并不需要分页。
最后条件编译的意思是:如果设置了虚拟内存盘,主存大小还要增加。
mem_init(main_memory_start,memory_end);
trap_init();
blk_dev_init();
chr_dev_init();
tty_init();
time_init();
sched_init();
buffer_init(buffer_memory_end);
hd_init();
floppy_init();
sti();
调用各个模块开始内核的初始化工作,这里解释一下为什么我们前面解释用内嵌的汇编实现系统调用,这里却可以调用函数了。我们前面说了特权级,特权级是保护模式下才有的概念。此时计算机是在保护模式下运行的,此时的当前特权级(CPL)是什么呢?我们知道CPL是存储在cs和ss的第0位和第1位。现在看看cs的值是多少?
cs是在什么时候赋值的呢?我们常常看到的汇编代码,第一步都是初始化ds,es,fs等等,并没有初始化cs的。实际上cs和ip都是可以赋值的,但是并不能想ds那样做。跳转指令负责给cs和ip寄存器赋值,我们想知道main的cs是多少,就要知道跳转到main的长跳转指令给cs赋了什么,通过猜测这个跳转指令在head.s中(main之前执行的程序)。分析head.s代码我们发现head把控制权交给main并不是通过长跳转指令,而是通过ret指令从栈中拿出main的地址。直接取得main的地址不太好理解,通过分析head的代码我们知道在head的执行中,特权级并没有发生改变(前面讲TSS的时候我们解释过,修改特权级需要修改TSS的内容),所以main的CPL和head的CPL是相同的。head的cs值是0x08,ss是0x10(cs在setup中,ss在head中设置),所以特权级是0.
其实并不需要这么复杂,启动的时候我们需要对一系列的硬件操作(关中断,开终端,打开8259引脚之类的),这肯定需要高特权级,所以特权级一定是0。解释完了特权级,接下来就是好理解了。
通过前面的一系列解释,main程序运行到现在还是在0特权级下运行的。此时并不是进程0,所以并不用担心弄乱进程0的堆栈这个问题,可以随意调用函数。
这些初始化模块的大致功能是:主存初始化,陷阱门初始化,块设备初始化,字符设备初始化,tty初始化,时间初始化,调度初始化,缓冲管理初始化,硬盘初始化,软盘初始化,开中断。各个模块初始化细节作者后面再写一系列文章展开分析,本文暂且不提。
move_to_user_mode();
if (!fork()) { /* we count on this going ok */
init();
}
for(;;) pause();
这里我们着重介绍函数move_to_user_mode,这个函数执行完了以后程序就以进程0的身份运行了,进程0创建进程1,进程1执行init函数,而进程1的父进程也就是进程0循环执行pause函数。
这里说一个问题,我之前在看的时候,看到这里非常不能理解,为什么pause函数还是要通过触发中断的方式调用,进程1不是已经创建结束了吗?这里需要澄清一个我之前的误会,之前作者以为进程0是在特权级0下运行的,所以进程0创建进程1是在0特权级下进行的。我们知道,在内核态创建的进程并不需要COW机制,是直接分配空间的,所以作者觉得,只要fork函数通过触发中断的形式的调用就可以。因为fork结束了以后,子进程已经有了自己的运行空间了。后来分析了函数move_to_user_mode以后,发现进程0是在3特权级下运行的。所以,进程0创建进程1以后自己调用pause函数为什么还是通过触发中断,虽然在用户空间创建的进程并不会直接分配空间,但是只要父进程要修改自己的内容,还是会出发COW机制,分配空间给子进程啊?
这里main.c程序的main函数已经解释完了,进程0的功能我们也已经分析完了,下面看init进程.
创建进程0这里不是很好理解,作者再啰嗦一次,要理解这个,前面说的TSS的介绍一定要仔细看看。
这个函数的定义锐如下:
#define move_to_user_mode()
__asm__ ("movl %%esp,%%eax\n\t"
"pushl $0x17\n\t"
"pushl %%eax\n\t"
"pushfl\n\t"
"pushl $0x0f\n\t"
"pushl $1f\n\t"
"iret\n"
"1:\tmovl $0x17,%%eax\n\t"
"movw %%ax,%%ds\n\t"
"movw %%ax,%%es\n\t"
"movw %%ax,%%fs\n\t"
"movw %%ax,%%gs"
:::"ax")
这个函数要实现特权级切换,在这个函数之前main在0特权级下运行。这个函数结束以后,进程0在3特权级下运行。我们前面介绍特权级切换的时候介绍,调用iret指令从高特权级切换到低特权级的CPU的操作是从当前任务TSS中特权级3对应的栈中拿出对应的堆栈信息。这个进程是一个特殊的特例,我们需要自己先把这个信息填充进去。
move_to_user_mode函数定义是一端内联汇编,没有输入和输出部分,破坏描述部分是ax。前面的一系列push指令就是实现这个功能,分别的ss入栈,esp入栈,eflags入栈,cs入栈,ip入栈,调用iret指令切换特权级,初始化ds,es,fs,gs。
解释完了move_to_user_mode以后,我们看进程1的执行函数,init函数。代码如下:
void init(void)
{
int pid,i;
setup((void *) &drive_info);
(void) open("/dev/tty0",O_RDWR,0);
(void) dup(0);
(void) dup(0);
printf("%d buffers = %d bytes buffer space\n\r",NR_BUFFERS,
NR_BUFFERS*BLOCK_SIZE);
printf("Free mem: %d bytes\n\r",memory_end-main_memory_start);
setup函数读取硬盘参数和安装根文件,然后打开终端,这是系统打开的第一个文件,得到的文件描述符是0,这表示把标准输入定向到终端文件,两次dup函数分别得到描述符1和描述符2。这就意味着得到了标准输入、标准输出和标准错误。输出一些提示信息。
if (!(pid=fork())) {
close(0);
if (open("/etc/rc",O_RDONLY,0))
_exit(1);
execve("/bin/sh",argv_rc,envp_rc);
_exit(2);
}
if (pid>0)
while (pid != wait(&i))
/* nothing */;
进程1创建进程2,进程2先关闭0,然后打开文件/etc/rc,返回的文件描述符是0,把输入定向到rc文件。然后调用函数execve函数执行shell,传入参数列表和环境变量。进程1阻塞等待进程2结束,进程1有很多个子进程,wait函数只要收到一个进程结束就停止阻塞状态,wait函数并不能直接实现等待指定子进程结束。
while (1) {
if ((pid=fork())<0) {
printf("Fork failed in init\r\n");
continue;
}
if (!pid) {
close(0);close(1);close(2);
setsid();
(void) open("/dev/tty0",O_RDWR,0);
(void) dup(0);
(void) dup(0);
_exit(execve("/bin/sh",argv,envp));
}
while (1)
if (pid == wait(&i))
break;
printf("\n\rchild %d died with code %04x\n\r",pid,i);
sync();
}
_exit(0); /* NOTE! _exit, not exit() */
进程2结束运行了,执行到这里init进程一直循环创建新进程, 创建的子进程关闭从父进程(init进程)继承来的三个文件描述符(这里我猜测是因为子进程要创建一个新的会话组,要切断和其他的终端设备的联系),创建一个新的会话。重新和终端设备建立联系,新进程调用execve函数执行登陆shell。init进程阻塞等待子进程退出,输出一行提示信息,调用sync函数刷新文件系统缓冲,init再去创建一个新进程,执行刚才的操作,构成一个死循环。
这里main.c程序就分析结束了。
这里有一点不理解,为什么新进程创建了一个会话以后还要和一个终端建立联系,后面不是要调用execve函数了吗?子进程的资源不是基本上全部被抛弃了吗?