您的位置:首页 > 其它

进程实现原理

2016-02-08 18:44 218 查看
引文:进程是操作系统最重要的基础知识之一,本文基于linux 0.11内核,重点介绍进程的实现原理以及分析进程间的调度问题。

problem:

Linux内核是如何初始化操作系统,并开始运行第一个程序呢?



我们都知道,系统启动过程为:bootsect.s —>setup.s —>head.s。姑且不去讨论这些汇编源程序的功能,假设操作系统的pc指正已经运行到了head.s 处的部分代码,这里做下仔细的研究。

目标代码如下(linux/boot/head.s):

17:startup_32:
18:    movl $0x10,%eax
......
48:    call check_x87
49:    jmp after_page_tables #跳转到135行
......
135:after_page_tables:
136:    pushl $0
137:    pushl $0
138:    pushl $0
139:    pushl $L6
140:    pushl $_main
141:    jmp setup_paging #跳转到198行
142:L6:
143:    jmp L6;
......
198:setup_paging:
199:    movl $1024*5,%ecx
......
217:    movl %eax,%cr0
218:    ret


忽略其他细节问题,当head程序执行到第49行时,将跳转到135行代码出运行。在第135行代码处,便是head.s调用init中main函数的核心。回顾c函数与汇编之间相互调用的知识可知,内核栈中存在:



当代码继续跳转后,会进入198行代码,直到程序运行至ret,调用返回。此时,内核栈中指向main函数将弹栈,pc指正自动指向main函数地址入口出,从而开始执行main函数。其次,传入main函数中的三个参数均为0(初始化main函数,无需传参,因此这里默认为0)。

目标代码如下(linux/init/main.c):

void main(void){
.......
move_to_user_mode();//移到用户模式下执行  什么叫做移动到用户模式下?未解 o(︶︿︶)o
if(!fork()){
init();
}
for(;;)
__asm__("int $0x80"::"a"(__NR_pause):"ax");
}


main函数是系统创建的第一个进程,作为第一个进程,它有自己的堆栈段,数据段,代码段等。在进入用户模式下后,便调用了fork函数来创建新的子进程,创建完进程后,立马进入暂停态。而第二个进程将在init()函数下继续运行。init()函数中,便是用来调用linux的shell脚本程序,从而可以与用户进行交互。那么问题来了,fork是如何做到进程的创建的呢?系统如何让两个进程或多个进程并发的执行呢?进程实现的原理是什么?

进程实现的原理

一个最简单的想法,或者说是最初的想法便是让pc指针分时的进行地址跳转,这的确是最核心最正确的解决办法,可具体该怎样实现?在用户态下,用户进行函数调用,是需要把函数参数,函数地址,局部变量等进行压栈的。而函数调用其实本事就是个让pc指针变化的事。请看图:



A函数运行一段时间后,必然会转入B函数,且调用Yield函数,在进行地址跳转之前,先将该程序下两个地址进行压栈。而一旦调Yield函数,做的操作便是,将A程序的用户栈和将被调用的B程序的用户栈进行切换。Yield的具体实现也正是这么做的,交换了两个线程控制块。由于现在esp指向了C函数的入口地址,操作系统便开始执行C函数了,同样一旦运行到D函数,再次调用Yield,进行用户栈切换,这时,当前esp=1000,当Yield函数调用结束,即ret返回,自动弹栈。PC=204,返回A程序执行。至此,两个线程便能交替的执行任务了。



注意:Liunx内核中,并没有实现线程的机制,但为了阐述进程的实现原理,理解线程的实现原理是必要的。也正如我们在操作系统学到的那样:

线程是独立调度的基本单位,用户级线程没有进入系统内核,调用计算机资源,仅仅在用户态下运行即可。

进程之所以是资源分配的基本单位,除了要完成用户态栈的切换,还需要内核栈的切换,其次进程的创建fork函数是系统调用,它将通过中断进入系统,调度计算机的资源。

所以,通过上述总结,进程调度的实现原理就是在线程调度的基础上,加上了内核栈与用户栈的关联步骤,并且在内核态进行两个栈的切换,即PCB的切换。来分析代码咯!

目标代码(linux/kernel/sys_call.s):

_system_call:
cmpl $nr_system_calls-1,%eax # 调用号如果超出范围的话就在eax 中置-1 并退出。
ja bad_sys_call
push %ds # 保存原段寄存器值。
push %es
push %fs
pushl %edx # ebx,ecx,edx 中放着系统调用相应的C 语言函数的调用参数。
pushl %ecx # push %ebx,%ecx,%edx as parameters
pushl %ebx # to the system call
movl $0x10,%edx # set up ds,es to kernel space
mov %dx,%ds # ds,es 指向内核数据段(全局描述符表中数据段描述符)。
mov %dx,%es
movl $0x17,%edx # fs points to local data space
mov %dx,%fs # fs 指向局部数据段(局部描述符表中数据段描述符)。
# 下面这句操作数的含义是:调用地址 = _sys_call_table + %eax * 4。参见列表后的说明。
# 对应的C 程序中的sys_call_table 在include/linux/sys.h 中,其中定义了一个包括72 个
# 系统调用C 处理函数的地址数组表。
call _sys_call_table(,%eax,4)
pushl %eax # 把系统调用号入栈。
movl _current,%eax # 取当前任务(进程)数据结构地址??eax。
# 下面97-100 行查看当前任务的运行状态。如果不在就绪状态(state 不等于0)就去执行调度程序。
# 如果该任务在就绪状态但counter[??]值等于0,则也去执行调度程序。
cmpl $0,state(%eax) # state
jne reschedule
cmpl $0,counter(%eax) # counter
je reschedule


在系统调用这章,详细讨论了sys_call_table的作用。今天我们便要研究透这段代码(这里插段自己学习的经验总结,遇到源码看不懂的问题,没关系,遇到知识瓶颈再正常不过了,不要花太多时间硬啃,可以放一放。随着你每天日积月累的总结,当时看不懂不代表现在看不懂,当再次回顾代码时,可以把新学的内容套用上,便能慢慢研究透源代码了)。

代码4-9行,进行了一系列的压栈操作,这便是所谓的对当前操作系统状态进行一个保存,记录当前进程运行的信息。内核栈如下:



小伙伴可能要问了,4-9行明明没有ss:sp、EFLAGS、ret的内容啊,怎么会出现在内核栈呢。还记得前文所述的进程与线程的区别嘛?进程是通过中断来完成创建的,因此在调用fork函数时,便自动产生软中断,一旦中断发生,便由硬件自动返程ss:sp、EFLAGS、ret的压栈。



仔细看上图,ss:sp指向的便是进程在用户态下的用户栈地址。这也就实现了进程的内核栈与用户栈的关联。

注意:

ds,es,fs为当前进程的数据段。

edx,ecx,ebx是系统调用规定的必传参数,ebx为系统调用的第一个参数,以此类推,edx为第三个参数。

代码10-14进行内核态的切换,寄存器指向内核数据段

切换到内核态后,便可以执行sys_fork函数了。同样在该目标代码中:

.align 2
_sys_fork:
call _find_empty_process
testl %eax,%eax
js lf
push %gs
pushl %esi
pushl %edi
pushl %ebp
pushl %eax
call _copy_process
addl $20,%esp
l:  ret


首先调用find-empty-process函数,具体代码不再阐述了,可以翻看linux内核剖析。具体位置在linux/kernel/fork.c下,该函数的主要作用为:last_pid与当前系统内的进程号进行比较,如果当前进程号=last_pid且当前任务处于运行状态,则last_pid++;直到找到一个可用的进程号,然后返回,此时eax存放的内容便是last_pid。

函数返回可能会发生两种情况:

当eax 为负数时,直接执行ret函数,sys_fork函数调用返回

当eax为正数时,把寄存器gs、esi、edi、ebp、eax的内容压栈,并调用copy_process函数。

目标代码(linux/kernel/fork.c):

int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
long ebx,long ecx,long edx,
long fs,long es,long ds,
long eip,long cs,long eflags,long esp,long ss)
{
struct task_struct *p;
int i;
struct file *f;
p = (struct task_struct *) get_free_page();
if (!p)
return -EAGAIN;
task[nr] = p;
*p = *current;    /* NOTE! this doesn't copy the supervisor stack */
p->state = TASK_UNINTERRUPTIBLE; //进程在建立过程中为防止被调度,设置为不可中断等待。
p->pid = last_pid; //进程的id
p->father = current->pid; //进程的父进程id
p->counter = p->priority; //进程的优先级,这个只能通过sys_nice来修改。
......
p->tss.eax=0            //请思考作用
......
set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss));
set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt));
p->state = TASK_RUNNING;    /* do this last, just in case 到此进程准备完毕,可以运行,则状态修改为就绪 */

return last_pid; //返回新建子进程的ID
}


代码请注意:p->tss.eax=0,作用是什么?

copy_process函数所传入的参数对应为当前系统内核栈的参数,即nr=(%eax),ebp=(%ebp)….依此类推ss=(%ss)。该函数的主要作用是:

创建新的PCB,并且给该进程控制块分配新的物理内存

初始化PCB的内容,并且将父进程的内核栈信息全部复制给子进程的内核栈。

函数成功调用后,最终返回系统进程调用号。至此,子进程如果不出意外便算创建成功了。函数返回后,便又回到了sys_call的代码段,我们继续分析接下来的代码。

需要了解的是,子进程虽然被创建了,但目前处于就绪状态,它只是存在于内存的某处,并没有开始执行调度。因此,此时copy-process返回的便是子进程的pid,显然eax=pid且 !0。值得注意的是,现在这个状态还是父进程的运行态。它将执行一系列符合条件的进程调度操作,如果幸运的话,可能也不会执行调度,直接弹栈,中断返回。

目标代码如下(linux/init/main.c):

void main(void){
.......
move_to_user_mode();//移到用户模式下执行  什么叫做移动到用户模式下?未解 o(︶︿︶)o
if(!fork()){
init();
}
for(;;)
__asm__("int $0x80"::"a"(__NR_pause):"ax");
}


还记得操作系统初始化的这段代码嘛,这里便有一个fork调用,接着上面的分析,调用fork后,父进程的pid!=0,所以将跳过if语句往下执行,这里让父进程通过sys_pause强制暂停了。再次进入中断后,操作系统开始寻找内核里存在的就绪进程,这时候发现有一个新的就绪态的子进程在默默的等待,因此通过jne reschedule执行函数的调度。reschedule将接着继续调用schedule。

目标代码(linux/kernel/sched.c):

void schedule(void){
int i,next,c;
.......
switch_to(next);
}




进入调度函数后,先忽略前述的某些优先级调度算法,直接转入switch_to函数,你会发现它是通过建立映象来完成内核栈的切换的。而内核栈切换是进程调度的核心的核心,switch_to代码的工作原理是通过TR寄存器来找到当前的tss段,当寄存器存放的内容发生改变时,便会导致TR指向的tss的内容写入CPU的所有寄存器当中,而将原来的寄存器的内容保存在原来的tss中。

代码分析:

将任务n的tss描述赋值给edx寄存器

将edx寄存器的低16位内容,传给临时变量tmp.b

执行长跳转ljmp,ljmp可分为两步:

将寄存器的内容写入当前进程的tss当中去,并且把原tss的描述符传给临时变量转给tmp.a,即给cpu寄存器拍了个照,留存下来。

将tmp.b指向的新tss的内容全部映射到寄存器中,从而完成切换。

switch_to(n)一旦完成切换,内核栈的内容便是子进程的内容了。还记得fork出p->tss.eax为什么等于0了嘛,此时切换的子进程tss.eax=0,那就意味着swicth_to后,cpu寄存器的eax=0,那么等中断返回后,!fork()条件就变成了真!还记得子进程是复制了父进程内核栈的信息了嘛,所以当子进程中断返回后,不就是返回到if后面一条语句执行吗!so,子进程便能在操作系统开始它自己的工作了。。。o(∩_∩)o
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: