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

一、实验要求:结合中断上下文切换和进程上下文切换分析Linux内核一般执行过程

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

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

二、进程下上文切换和中断上下文切换

  进程上下文,意思是可执行程序代码是进程的重要组成部分。我们把已执行过的进程指令和数据在相关寄存器与堆栈中的内容称为上文,把正在执行的指令和数据在寄存器和堆栈中的内容称为正文,把待执行的指令和数据在寄存器与堆栈中的内容称为下文。具体的说,进程上下文包括计算机系统中与执行该进程有关的各种寄存器(例如通用寄存器,程序计数器PC,程序状态字寄存器PS等)的值,程序段在经过编译过后形成的机器指令代码集,数据集及各种堆栈值PCB结构。这里,有关寄存器和栈区的内容是重要的,例如没有程序计数器PC和程序状态寄存器PS,CPU将无法知道下一条待执行指令的地址和控制有关操作。

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

  进程上下文和中毒上下文的区别:

  1、对于触发条件而言,进程上下文切换是由于用户的应用程序想请求系统服务,那么此时用户空间中的程序就进入了内核空间。中断上下文切换是硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。

  2、中断或异常处理程序执行的代码不是一个进程,是一个内核控制路径。作为内核控制路径它很“轻”,只包含了内核中断程序必须的状态,包括 CPU 寄存器、内核堆栈、硬件中断参数等,无需恢复进程的虚拟内存等资源,建立和终止开销小。因此中断处理应该尽可能快,因为内核要响应大量服务和请求,中断上下文占用CPU时间太长会严重影响系统功能。

  3、因为中断上下文是和特定进程无关的,它是内核代表硬件运行在内核空间,所以在终端上下文无法访问用户空间的虚拟地址

  4、中断处理例程可以被更高级别的IRQ中断。如果想禁止这种中断,可以将中断处理例程定义成快速处理例程,相当于告诉CPU,该例程运行时,禁止本地CPU上所有中断请求。这直接导致的结果是,由于其他中断被延迟响应,系统性能下降。

三、fork系统调用分析

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

  我们先执行下面一段程序

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char * argv[])
{
    int pid;
    /* fork another process */
    pid = fork();
    if (pid < 0) 
    { 
        /* error occurred */
        fprintf(stderr,"Fork Failed!");
        exit(-1);
    } 
    else if (pid == 0) 
    {
        /* child process */
        printf("This is Child Process!\n");
    } 
    else 
    {  
        /* parent process  */
        printf("This is Parent Process!\n");
        /* parent will wait for the child to complete*/
        wait(NULL);
        printf("Child Complete!\n");
    }
}

查看结果

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

 

 

   我们看到if和else的语句都被执行了,是不是if_else的结构被破坏了。其实不是的,这就fork的作用。

  fork()函数又叫计算机程序设计中的分叉函数,fork是一个很有意思的函数,它可以建立一个新进程,把当前的进程分为父进程和子进程,新进程称为子进程,而原进程称为父进程。fork调用一次,返回两次,这两个返回分别带回它们各自的返回值,其中在父进程中的返回值是子进程的PID,而子进程中的返回值则返回 0。因此,可以通过返回值来判定该进程是父进程还是子进程。还有一个很奇妙的是:fork函数将运行着的程序分成2个(几乎)完全一样的进程,每个进程都启动一个从代码的同一位置开始执行的线程。这两个进程中的线程继续执行,就像是两个用户同时启动了该应用程序的两个副本。

  新创建的子进程几乎但是不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同(但是独立)的一份拷贝,包括文本,数据和bss段、堆以及用户栈。子进程还获得与父进程任何打开文件描述符相同的拷贝。这就是意味着当父进程调用fork时候,子进程还可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大区别在于他们有着不同的PID。

  fork系统调用如下图:

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

下面我们来重点看看do_fork的代码。

long do_fork(unsigned long clone_flags,
    unsigned long stack_start,
    unsigned long stack_size,
    int __user *parent_tidptr,
    int __user *child_tidptr)
{
    struct task_struct *p;
    int trace = 0;
    long nr;

    /*
    * Determine whether and which event to report to ptracer.  When
    * called from kernel_thread or CLONE_UNTRACED is explicitly
    * requested, no event is reported; otherwise, report if the event
    * for the type of forking is enabled.
    */
    if (!(clone_flags & CLONE_UNTRACED)) {
        if (clone_flags & CLONE_VFORK)
            trace = PTRACE_EVENT_VFORK;
        else if ((clone_flags & CSIGNAL) != SIGCHLD)
            trace = PTRACE_EVENT_CLONE;
        else
            trace = PTRACE_EVENT_FORK;

        if (likely(!ptrace_event_enabled(current, trace)))
            trace = 0;
    }

    p = copy_process(clone_flags, stack_start, stack_size,
        child_tidptr, NULL, trace);
    /*
    * Do this prior waking up the new thread - the thread pointer
    * might get invalid after that point, if the thread exits quickly.
    */
    if (!IS_ERR(p)) {
        struct completion vfork;
        struct pid *pid;

        trace_sched_process_fork(current, p);

        pid = get_task_pid(p, PIDTYPE_PID);
        nr = pid_vnr(pid);

        if (clone_flags & CLONE_PARENT_SETTID)
            put_user(nr, parent_tidptr);

        if (clone_flags & CLONE_VFORK) {
            p->vfork_done = &vfork;
            init_completion(&vfork);
            get_task_struct(p);
        }

        wake_up_new_task(p);

        /* forking complete and child started to run, tell ptracer */
        if (unlikely(trace))
            ptrace_event_pid(trace, pid);

        if (clone_flags & CLONE_VFORK) {
            if (!wait_for_vfork_done(p, &vfork))
                ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
        }

        put_pid(pid);
    } else {
        nr = PTR_ERR(p);
    }
    return nr;
}

do_fork()的实现,主要是靠copy_process()完成的,这就是一环套一环。整个过程实现如下:

  1. p = dup_task_struct(current); 为新进程创建一个内核栈、thread_iofo和task_struct,这里完全copy父进程的内容,所以到目前为止,父进程和子进程是没有任何区别的。
  2. 为新进程在其内存上建立内核堆栈
  3. 对子进程task_struct任务结构体中部分变量进行初始化设置,检查所有的进程数目是否已经超出了系统规定的最大进程数,如果没有的话,那么就开始设置进程描诉符中的初始值,从这开始,父进程和子进程就开始区别开了。
  4. 把父进程的有关信息复制给子进程,建立共享关系
  5. 设置子进程的状态为不可被TASK_UNINTERRUPTIBLE,从而保证这个进程现在不能被投入运行,因为还有很多的标志位、数据等没有被设置
  6. 复制标志位(falgs成员)以及权限位(PE_SUPERPRIV)和其他的一些标志
  7. 调用get_pid()给子进程获取一个有效的并且是唯一的进程标识符PID
  8. return ret_from_fork;返回一个指向子进程的指针,开始执行

四、execve系统调用

  execve() 系统调用的作用是运行另外一个指定的程序。它会把新程序加载到当前进程的内存空间内,当前的进程会被丢弃,它的堆、栈和所有的段数据都会被新进程相应的部分代替,然后会从新程序的初始化代码和 main 函数开始运行。同时,进程的 ID 将保持不变。execve() 系统调用通常与 fork() 系统调用配合使用。从一个进程中启动另一个程序时,通常是先 fork() 一个子进程,然后在子进程中使用 execve() 变身为运行指定程序的进程。 例如,当用户在 Shell 下输入一条命令启动指定程序时,Shell 就是先 fork() 了自身进程,然后在子进程中使用 execve() 来运行指定的程序。

  正常的一个系统调用都是陷入内核态,再返回到用户态,然后继续执行系统调用后的下一条指令。上文讲到,fork和其他系统调用不同之处是它在陷入内核态之后有两次返回,第一次返回到原来的父进程的位 置继续向下执行,这和其他的系统调用是一样的。在子进程中fork也返回了一次,会返回到一个特定的点——ret_from_fork,通过内核构造的堆栈环境,它可以正常系统调用返回到用户态,所以它 稍微特殊一点。同样,execve也比较特殊。当前的可执行程序在执行,执行到execve系统调用时陷入内核态,在内核里面用do_execve加载可执行文件,把当前进程的可执行程序给覆盖掉。当execve系统调用返回时,返回的已经不是原来的那个可执行程序了,而是新的可执行程序。execve返回的是新的可执行程序执行的起点,静态链接的可执行文件也就是main函数的大致位置,动态链接的可执行文件还需 要ld链接好动态链接库再从main函数开始执行。所以fork一般于execve相互配合启动一个新程序。用户态函数库提供了exec函数族来通过execve系统调用加载执行一个可执行文件,它们的差异在于对命令行参数和环境变量参数的传递方式不同。64位下,execve系统调用号为56,函数入口为__x64_sys_execve。

该系统调用的实现位于fs/exec.c中:

SYSCALL_DEFINE3(execve,
        const char __user *, filename,
        const char __user *const __user *, argv,
        const char __user *const __user *, envp)
{
    return do_execve(getname(filename), argv, envp);
}

其调用了do_execve,后者调用了do_execveat_common,最终的工作由__do_execve_file完成。这仍然是很长的一段函数实现,我们选取关键代码:

/*
 * sys_execve() executes a new program.
 */
static int __do_execve_file(int fd, struct filename *filename,
                struct user_arg_ptr argv,
                struct user_arg_ptr envp,
                int flags, struct file *file)
{
    char *pathbuf = NULL;
    struct linux_binprm *bprm;
    struct files_struct *displaced;
    int retval;
    // ...
    bprm->file = file;
    // ...
    retval = prepare_binprm(bprm);
    // ...
    retval = copy_strings(bprm->envc, envp, bprm);
    // ...
    retval = exec_binprm(bprm);
    // ...
    return retval;
}

该函数的主要功能是从文件中载入ELF可执行文件并执行。其中exec_binprm实际执行了文件。后者的关键是调用search_binary_handler,这是真正替换进程镜像的地方。

static int exec_binprm(struct linux_binprm *bprm)
{
    pid_t old_pid, old_vpid;
    int ret;
?
    /* Need to fetch pid before load_binary changes it */
    old_pid = current->pid;
    rcu_read_lock();
    old_vpid = task_pid_nr_ns(current, task_active_pid_ns(current->parent));
    rcu_read_unlock();
?
    ret = search_binary_handler(bprm);
    if (ret >= 0) {
        audit_bprm(bprm);
        trace_sched_process_exec(current, old_pid, bprm);
        ptrace_event(PTRACE_EVENT_EXEC, old_vpid);
        proc_exec_connector(current);
    }
?
    return ret;
}

execve系统调用的过程总结如下:

  1. execve系统调用陷入内核,并传入命令行参数和shell上下文环境

  2. execve陷入内核的第一个函数:do_execve,该函数封装命令行参数和shell上下文

  3. do_execve调用do_execveat_common,后者进一步调用__do_execve_file,打开ELF文件并把所有的信息一股脑的装入linux_binprm结构体

  4. __do_execve_file中调用search_binary_handler,寻找解析ELF文件的函数

  5. search_binary_handler找到ELF文件解析函数load_elf_binary

  6. load_elf_binary解析ELF文件,把ELF文件装入内存,修改进程的用户态堆栈(主要是把命令行参数和shell上下文加入到用户态堆栈),修改进程的数据段代码段

  7. load_elf_binary调用start_thread修改进程内核堆栈(特别是内核堆栈的ip指针)

  8. 进程从execve返回到用户态后ip指向ELF文件的main函数地址,用户态堆栈中包含了命令行参数和shell上下文环境

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

进程上下文切换一般有如下几步:

  • 发生中断,保存当前进程的eip、esp、eflags到内核栈中。
  • 加载新进程的eip、esp。
  • 调用调度函数schedule函数,其中的switch_to完成了上下文的切换。
  • 运行新的进程。

进程调度的时机?般都是中断处理后和中断返回前的时机点进行,只有内核线程可以直接调?schedule函数主动发起进程调度和进程切换。进程调度根据中断上下文的切换是还是进程上下文的切换分为以下两类:

1、中断上下文的进程调度:用户进程上下?中主动调?特定的系统调用进?中断上下?,系统调用返回用户态之前进行进程调度。或者内核线程或可中断的中断处理程序,执行过程中发?中断进?中断上下文,在中断返回前进行进程调度。

2、进程上下文的进程调度:内核线程主动调?schedule函数进?进程调度。

正在运行的用户态进程X切换到运行用户态进程Y的过程

       1: 发生中断 ,完成以下步骤:

1 save cs:eip/esp/eflags(current) to kernel stack
2 load cs:eip(entry of a specific ISR) and ss:esp(point to kernel stack)  

   2:SAVE_ALL //保存现场,这里是已经进入内核中断处里过程

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

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

       5: 通过restore_all来恢复现场

       6: 继续运行用户态进程Y

 

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

上一篇:打开Visual Studio 2017报错:未能正确加载“VSTS for Database Professionals Sql Server Data-tier Application”包


下一篇:打开Vs2010时,卡在加载工具箱内容 不动了