进程实现原理

来源:互联网 发布:数据输入 编辑:程序博客网 时间:2024/06/06 15:45

引文:进程是操作系统最重要的基础知识之一,本文基于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_x8749:    jmp after_page_tables #跳转到135行......135:after_page_tables:136:    pushl $0137:    pushl $0138:    pushl $0139:    pushl $L6140:    pushl $_main141:    jmp setup_paging #跳转到198行142:L6:143:    jmp L6;......198:setup_paging:199:    movl $1024*5,%ecx......217:    movl %eax,%cr0218:    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,%espl:  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中。
代码分析:

  1. 将任务n的tss描述赋值给edx寄存器
  2. 将edx寄存器的低16位内容,传给临时变量tmp.b
  3. 执行长跳转ljmp,ljmp可分为两步:
    1. 将寄存器的内容写入当前进程的tss当中去,并且把原tss的描述符传给临时变量转给tmp.a,即给cpu寄存器拍了个照,留存下来。
    2. 将tmp.b指向的新tss的内容全部映射到寄存器中,从而完成切换。

switch_to(n)一旦完成切换,内核栈的内容便是子进程的内容了。还记得fork出p->tss.eax为什么等于0了嘛,此时切换的子进程tss.eax=0,那就意味着swicth_to后,cpu寄存器的eax=0,那么等中断返回后,!fork()条件就变成了真!还记得子进程是复制了父进程内核栈的信息了嘛,所以当子进程中断返回后,不就是返回到if后面一条语句执行吗!so,子进程便能在操作系统开始它自己的工作了。。。o(∩_∩)o

0 0