结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

Posted waaq

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程相关的知识,希望对你有一定的参考价值。

1 实验要求:

结合中断上下文切换和进程上下文切换分析Linux内核一般执行过程

  • 以fork和execve系统调用为例分析中断上下文的切换
  • 分析execve系统调用中断上下文的特殊之处
  • 分析fork子进程启动执行时进程上下文的特殊之处
  • 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程

完成一篇博客总结分析Linux系统的一般执行过程,以期对Linux系统的整体运作形成一套逻辑自洽的模型,并能将所学的各种OS和Linux内核知识/原理融通进模型中。

2 用户态、内核态

内核态:cpu可以访问内存的所有数据,包括外围设备,例如硬盘,网卡,cpu也可以将自己从一个程序切换到另一个程序。

用户态:只能受限的访问内存,且不允许访问外围设备,占用cpu的能力被剥夺,cpu资源可以被其他程序获取。

用户态切换到内核态的 3 种方式:

(1)系统调用(一种特殊的中断)

这是用户态进程主动要求切换到内核态的一种方式。用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作。例如 fork()就是执行了一个创建新进程的系统调用。系统调用的机制和新是使用了操作系统为用户特别开放的一个中断来实现,如 Linux 的 int $0x80/syscall中断。

(2)异常

当 cpu 在执行运行在用户态下的程序时,发生了一些没有预知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关进程中,也就是切换到了内核态,如缺页异常。

(3)外围设备的中断

当外围设备完成用户请求的操作后,会向 CPU 发出相应的中断信号,这时 CPU 会暂停执行下一条即将要执行的指令而转到与中断信号对应的处理程序去执行,如果前面执行的指令时用户态下的程序,那么转换的过程自然就会是 由用户态到内核态的切换。如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后边的操作等。

内核切换操作概述:
    从触发方式上看,可以认为纯在前述3种不同的类型,但是从最终实际完成由用户态到内核态的切换操作上来说,涉及的关键步骤是完全一致的,没有任何区别,都相当于执行了一个中断响应的过程,因为系统调用实际上最终是中断机制实现的,而异常和中断的处理机制基本上也是一致的。关于中断处理机制的细节合步骤这里不做过多分析,涉及到有用户态切换到内核态的步骤主要包括:    
【1】从当前进程的描述符中提取其内核栈的ss0及esp0信息    
【2】使用ss0和esp0指向的内核栈将当前进程的cs,eip,eflags,ss,esp信息保存起来,这个过程也完成了由用户栈到内核栈的切换过程,同时保存了被暂停执行的程序的下一条指令。   
【3】将先前又中断向量检索得到的中断处理程序的cs,eip信息装入相应的寄存器,开始执行中断处理程序,这时就转到内核态的程序执行了。

3 中断上下文和进程上下文切换

    内核空间和用户空间是操作系统重要的理论知识,用户程序运行在用户空间,内核功能模块运行在内核空间,二者是空间是不能互相访问的,内核空间和用户空间指其代码和数据存放内存空间。用户态的程序要想访问内核空间,须使用系统调用。当用户空间的应用程序通过系统调用进入内核空间时,就会涉及到上下文的切换。用户空间和内核空间具有不同的地址映射、通用寄存器和专用寄存器组以及堆栈区,而且用户空间的进程要传递很多变量、参数给内核,内核也要保存用户进程的一些寄存器、变量等,以便系统调用结束后回到用户空间继续执行。

进程上下文:就是一个进程传递给内核的那些参数和CPU的所有寄存器的值、进程的状态以及堆栈中的内容,也就进程在进入内核态之前的运行环境。所以在切换到内核态时需要保存当前进程的所有状态,即保存当前进程的上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。

中断上下文:硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理,中断上下文就可以理解为硬件传递过来的这些参数和内核需要保存的一些环境(主要是被中断的进程的环境)。

  上下文简单说来就是一个环境,相对于进程而言,就是进程执行时的环境。相对于中断而言就是中断执行时的环境。  

 一个进程的上下文可以分为三个部分:用户级上下文、寄存器上下文以及系统级上下文。

    (1)用户级上下文: 正文、数据、用户堆栈以及共享存储区;
    (2)寄存器上下文: 通用寄存器、程序指令指针寄存器(EIP)、处理器状态寄存器(EFLAGS)、当前程序的栈顶指针(ESP);
    (3)系统级上下文: 进程控制块task_struct、内存管理信息(mm_struct、vm_area_struct、pgd、pte)、内核栈。

进程上下文切换分为进程调度时和系统调用时两种切换,消耗资源不同:

进程调度时,进行进程切换就是上下文切换(context switch).操作系统必须对上面提到的全部信息进行切换,新调度的进程才能运行。

系统调用时,进行的模式切换(mode switch)与进程切换比较起来容易很多,而且节省时间,因为模式切换最主要的任务只是切换进程寄存器上下文的切换。 

中断和中断返回有CPU上下文的切换,中断上下文的切换还是在同一个进程中的

进程上下文的切换,是从一个进程的内核堆栈切换到另一个进程的内核堆栈

5 通过fork系统调用分析中断上下文和子进程启动时进程上下文切换的特殊之处

库函数fork是⽤户态创建⼀个⼦进程的系统调⽤API接⼝。既涉及中断上下文切换有设计进程上下文切换。

 先来看这么一个小程序:

#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
int main(int argc, char * argv[])
{
    int pid;
    printf("parent1\\n");
    /* fork another process */
    pid = fork();
    printf("parent2\\n");    # fork()子程序在这里开始执行
    if (pid < 0)
    {
        /* error occurred */
    }
    else if (pid == 0)
    {
        /* child process */
        printf("child\\n");
    }
    else
    {
        /* parent process */
        printf("parent3\\n");
    }
}

 

编译运行的执行结果如下:

   

fork在正常执⾏后,if条件判断中除了if (pid < 0)异常处理没被执⾏,else if (pid == 0)和else两段代码都被执⾏了,这看起来确实匪夷所思。

实际上fork系统调⽤把当前进程⼜复制了⼀个⼦进程,也就⼀个进程变成了两个进程,两个进程执⾏相同的代码,只是fork系统调⽤在⽗进程和⼦进程中的返回值不同。可是从Shell终端输出信息看两个进程是混合在⼀起的,会让⼈误以为if语句的执⾏产⽣了错误。其实是if语句在两个进程中各执⾏了⼀次,由于判断条件不同,输出的信息也就不同。⽗进程没有打破if else的条件分⽀的结构,在⼦进程⾥⾯也没有打破这个结构,只是在Shell命令⾏下好像两个都输出了,好像打破了条件分⽀结构,实际上背后是两个进程。fork之后,⽗⼦进程的执⾏顺序和调度算法密切相关,多次执⾏有时可以看到⽗⼦进程的执⾏顺序并不是确定的。

fork中断上下文切换:

    从父进程的角度看,fork的执行过程跟一般的系统调用一样:⽤户态有⼀个int $0x80或syscall指令触发系统调⽤,跳转到系统调⽤⼊⼝的汇编代码。int $0x80指令触发entry_INT80_32并以iret返回系统调⽤,syscall指令触发entry_SYSCALL_64并sysret或iret返回系统调⽤。系统调⽤陷⼊内核态,从⽤户态堆栈转换到内核态堆栈,然后把相应的CPU关键的现场栈顶寄存器、指令指针寄存器、标志寄存器等保存到内核堆栈,保存现场。系统调⽤⼊⼝的汇编代码还会通过系统调⽤号执⾏系统调⽤内核处理函数,最后恢复现场和系统调⽤返回将CPU关键现场栈顶寄存器、指令指针寄存器、标志寄存器等从内核堆栈中恢复到对应寄存器中,并回到⽤户态int $0x80或syscall指令之后的下⼀条指令的位置继续执⾏。

fork进程上下文切换:

    fork创建了一个子进程,涉及进程的上下文切换:⼦进程复制了⽗进程中所有的进程上下文信息,包括内核堆栈、进程描述符等,⼦进程作为⼀个独⽴的进程也会被调度。

    当⼦进程获得CPU开始运⾏时,它是从哪⾥开始运⾏的呢?从⽤户态空间来看,就是fork系统调⽤的下⼀条指令(参见上面小程序的输出结果)。

    但fork系统调⽤在⼦进程当中也是返回的,也就是说fork系统调⽤在内核⾥⾯变成了⽗⼦两个进程,⽗进程正常fork系统调⽤返回到⽤户态,fork出来的⼦进程也要从内核⾥返回到⽤户态。

    对于⼦进程来讲,fork系统调⽤在内核处理程序中是从何处开始执⾏的呢?

    创建⼀个进程是复制当前进程的信息,就是通过_do_fork函数来创建了⼀个新进程。⽗进程和⼦进程的绝⼤部分信息是完全⼀样的,但是有些信息是不能⼀样的,⽐如 pid 的值和内核堆栈。还有将新进程链接到各种链表中,要保存进程执⾏到哪个位置,有⼀个thread数据结构记录进程执⾏上下⽂的关键信息也不能⼀样,否则会发⽣问题。fork⼀个⼦进程的过程中,复制⽗进程的资源时采⽤了Copy OnWrite(写时复制)技术,不需要修改的进程资源⽗⼦进程是共享内存存储空间的。

     _do_fork函数主要完成了调⽤copy_process()复制⽗进程、获得pid、调⽤wake_up_new_task将⼦进程加⼊就绪队列等待调度执⾏等。

    copy_process()是创建⼀个进程的主要的代码。copy_process函数主要完成了调⽤dup_task_struct复制当前进程(⽗进程)描述符task_struct、信息检查、初始化、把进程状态设置为TASK_RUNNING(此时⼦进程置为就绪态)、采⽤写时复制技术逐⼀复制所有其他进程资源、调⽤copy_thread_tls初始化⼦进程内核栈、设置⼦进程pid等。其中最关键的就是dup_task_struct复制当前进程(⽗进程)描述符task_struct和copy_thread_tls初始化⼦进程内核栈。接下来具体看dup_task_struct和copy_thread_tls。

    copy_thread_tls负责构造fork系统调⽤在⼦进程的内核堆栈,也就是fork系统调⽤在⽗⼦进程各返回⼀次,⽗进程中和其他系统调⽤的处理过程并⽆⼆致,⽽在⼦进程中的内核函数调⽤堆栈需要特殊构建,为⼦进程的运⾏准备好上下⽂环境。

     task_struct数据结构的最后是保存进程上下⽂中CPU相关的⼀些状态信息的关键数据结构thread

    ⼦进程创建好了进程描述符、内核堆栈等,就可以通过wake_up_new_task(p)将⼦进程添加到就绪队列,使之有机会被调度执⾏,进程的创建⼯作就完成了,⼦进程就可以等待调度执⾏,⼦进程的执⾏从这⾥设定的ret_from_fork开始

    总结来说,进程的创建过程⼤致是⽗进程通过fork系统调⽤进⼊内核_do_fork函数,如下图所示复制进程描述符及相关进程资源(采⽤写时复制技术)、分配⼦进程的内核堆栈并对内核堆栈和thread等进程关键上下⽂进⾏初始化,最后将⼦进程放⼊就绪队列,fork系统调⽤返回;⽽⼦进程则在被调度执⾏时根据设置的内核堆栈和thread等进程关键上下⽂开始执⾏。

 

5 execve系统调用中断上下文切换的特殊之处

    有6种不同的exec函数可以使用,他们的差别主要是对命令行参数和系统变量参数的传递方式不同,exec函数都是通过execve系统调用进入内核,对应的系统调用内核处理函数为sys_execve或__x64_sys_execve,这俩函数最终都是通过调用do_execve来具体执行加载可执行文件的工作。

sys_execve的核心是调用do_execve函数,传给do_execve的第一个参数是已经拷贝到内核空间的路径名filename,第二个和第三个参数仍然是系统调用execve的第二个参数argv和第三个参数envp,它们代表的传给可执行文件的参数和环境变量仍然保留在用户空间中。

2     int execl(const char *pathname, const char *arg1, ... /* (char*)0 */ );
3     int execlp(const char *filename, const char *arg1, ... /* (char*)0 */ );
4     int execle(const char *pathname, const char *arg1, ... 
5                                                     /* (char*)0, char * const *envp */);
6     int execv(const char *pathname, char * const argv[]);
7     int execvp(const char *filename, char * const argv[]);
8     int execve(const char *pathname, char * const argv[], char * const envp[]);

   整体的调⽤关系为sys_execve()或__x64_sys_execve -> do_execve() –>do_execveat_common() -> __do_execve_file -> exec_binprm()-> search_binary_handler() ->load_elf_binary() -> start_thread()

search_binary_handler()函数会搜索Linux支持的可执行文件类型队列,让各种可执行程序的处理程序前来认领和处理。如果类型匹配,则调用load_binary函数指针所指向的处理函数来处理目标映像文件。在ELF文件格式中,处理函数是load_elf_binary函数,
load_elf_binary() 函数可以校验可执行文件并加载文件到内存,根据ELF文件中Program header table和Section header table映射到进程的地址空间;判断是否需要动态链接,配置进程启动的上下文环境start_thread。

execve特殊之处在于:当前的可执⾏程序在执⾏,执⾏到execve系统调⽤时陷⼊内核态,在内核⾥⾯⽤do_execve加载可执⾏⽂件,把当前进程的可执⾏程序给覆盖掉。当execve系统调⽤返回时,返回的已经不是原来的那个可执⾏程序了,⽽是新的可执⾏程序。execve返回的是新的可执⾏程序执⾏的起点,静态链接的可执⾏⽂件也就是main函数的⼤致位置,动态链接的可执⾏⽂件还需要ld链接好动态链接库再从main函数开始执⾏。

6 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程

参见孟老师的linxu内核分析课程所述:

最一般的情况:正在运行的用户态进程X切换到运行用户态进程Y的过程

    1、正在运行的用户态进程X

    2、发生中断——

                          (CPU自动完成)

                           save cs:eip/esp/eflags(current) to kernel stack

                           load cs:eip(entry of a specific ISR) and ss:esp(point to kernel stack).

    3、SAVE_ALL //保存现场

    4、中断处理过程中或中断返回前调用了schedule(),其中的switch_to做了关键的进程上下文切换

    5、标号1之后开始运行用户态进程Y(这里Y曾经通过以上步骤被切换出去过因此可以从标号1继续执行)

    6、restore_all //恢复现场

    7、iret - pop cs:eip/ss:esp/eflags from kernel stack返回执行的是Y进程曾经发生中断时用户态的下一条指令,恢复现场,恢复不是X进程的现场,而是曾经保存的Y进程现场)

    8、继续运行用户态进程Y

 

几种特殊情况

    1、通过中断处理过程中的调度时机,用户态进程与内核线程之间互相切换和内核线程之间互相切换,与最一般的情况非常类似,只是内核线程运行过程中发生中断没有进程用户态和内核态的转换;(CS段没有发生变化)

    2、用户态进程不能主动调度,主动地调用schedule(),内核线程主动调用schedule(),只有进程上下文的切换没有发生中断上下文的切换,与最一般的情况略简略

    3、创建子进程的系统调用在子进程中的执行起点及返回用户态,如fork,返回了两次,在父进程返回一次,在子进程也返回;如果next进程是一个新创建的子进程,没有被执行过,则next进程的执行起点是ret_from_fork

    4、加载一个新的可执行程序后返回到用户态的情况,如execve;

 

以上是关于结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程的主要内容,如果未能解决你的问题,请参考以下文章

结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

结合中断上下文切换和进程上下文切换分析Linux内核一般执行过程