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

实验内容

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

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

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

理论分析

为什么会出现CPU上下文切换

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

 

 

 首先LInux 是个多用户系统,支持大于cpu核数的任务在系统上运行所以不可避免的出现cpu资源竞争,竞争CPU会导致 上下文切换

什么是上下文切换
       上下文切换的基本原理就是当发生任务切换时, 保存当前任务的寄存器到内存中, 将下一个即将要切换过来的任务的寄存器状态恢复到当前CPU寄存器中, 使其继续执行, 同一时刻只允许一个任务独享寄存器。在任务切换的过程中是涉及任务上下文的保存和恢复操作, 而任务上下文切换操作的性能是衡量操作系统性能的一个重要指标。任务上下文切换指标可以反映出操作系统在多任务环境下的处理能力。

上下文切换分类

1.进程上下文切换

是指从一个进程切换到另一个进程运行。一个进程既可以在用户空间运行,也可以在内核空间运行,因此进程的上下文不仅包括了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的状态。进程上下文切换需要保存上述资源,加载新进程(内核态)并装入新进程的上下文

2.线程上下文切换

线程是调度的基本单位,进程是资源拥有的基本单位 通俗理解:系统内核中的任务调度实际上调度的对象是线程,而进程只是给线程提供了虚拟内存,全局变量等资源这些资源在线程上下文切换时是不需要修改的

3.中断上下文切换

中断优先级会打断进程的正常调度和执行

对于同一个CPU来说,中断处理比进程拥有更高的优先级

硬件/软件通过中断触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。中断上下文可以看作就是硬件传递过来的这些参数和内核需要保存的一些其他环境因此中断上下文的切换是一直发生在内核态的,相比进程上下文切换要轻量很多。

中断上下?和进程上下?的?个关键区别是堆栈切换的?法。中断是由CPU实 现的,所以中断上下?切换过程中最关键的栈顶寄存器sp和指令指针寄存器ip 是由CPU协助完成的;进程切换是由内核实现的,所以进程上下?切换过程中 最关键的栈顶寄存器sp切换是通过进程描述符的thread.sp实现的,指令指针 寄存器ip的切换是在内核堆栈切换的基础上巧妙利?call/ret指令实现的。

实验过程

fork系统调用

 

fork() 系统调用将创建一个与父进程几乎一样的新进程,之后继续执行下面的指令。程序可以根据 fork() 的返回值,确定当前处于父进程中,还是子进程中——在父进程中,返回值为新创建子进程的进程 ID,在子进程中,返回值是 0。一些使用多进程模型的服务器程序(比如 sshd),就是通过 fork() 系统调用来实现的,每当新用户接入时,系统就会专门创建一个新进程,来服务该用户。

fork() 系统调用所创建的新进程,与其父进程的内存布局和数据几乎一模一样。在内核中,它们的代码段所在的只读存储区会共享相同的物理内存页,可读可写的数据段、堆及栈等内存,内核会使用写时拷贝技术,为每个进程独立创建一份。

在 fork() 系统调用刚刚执行完的那一刻,子进程即可拥有一份与父进程完全一样的数据拷贝。对于已打开的文件,内核会增加每个文件描述符的引用计数,每个进程都可以用相同的文件句柄访问同一个文件。

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

fork 函数原型

 

所需头文件

#include <sys/types.h>   // 提供类型 pid_t 的定义

#include <unistd.h>

函数说明

建立一个新的进程

函数原型

pid_t fork(void)

函数返回值

0:返回给子进程

子进程的ID(大于0的整数):返回给父进程

-1:出错,返回给父进程,错误原因存于errno中

错误代码

EAGAIN:内存不足

ENOMEM:内存不足,无法配置核心所需的数据结构空间

  

 

 

 

 

 

 

 

 

 

 

 

 

 

 

系统进行fork调用的过程:

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

 

 

 

 

Linux下用于创建进程的API有三个fork,vfork和clone,这三个函数分别是通过系统调用sys_fork,sys_vfork以及sys_clone实现的。而且这三个系统调用,都是通过do_fork来实现的,只是传入了不同的参数。所以我们可以得出结论:所有的子进程是在do_fork实现创建和调用的。

进入linux5.4.34->kernel->fork.c

long _do_fork(struct kernel_clone_args *args)
{
    u64 clone_flags = args->flags;
    struct completion vfork;
    struct pid *pid;
    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 (args->exit_signal != SIGCHLD)
            trace = PTRACE_EVENT_CLONE;
        else
            trace = PTRACE_EVENT_FORK;

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

    p = copy_process(NULL, trace, NUMA_NO_NODE, args);
    add_latent_entropy();

    if (IS_ERR(p))
        return PTR_ERR(p);

    /*
     * Do this prior waking up the new thread - the thread pointer
     * might get invalid after that point, if the thread exits quickly.
     */
    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, args->parent_tid);

    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);
    return nr;
}

父进程进行fork系统调用时完成的操作

      假设id=fork(),父进程进行fork系统调用时,fork所做工作如下:

①    为新进程分配task_struct任务结构体内存空间。

②    把父进程task_struct任务结构体复制到子进程task_struct任务结构体。

③    为新进程在其内存上建立内核堆栈。

④    对子进程task_struct任务结构体中部分变量进行初始化设置。

⑤    把父进程的有关信息复制给子进程,建立共享关系。

⑥    把子进程加入到可运行队列中。

⑦    结束fork()函数,返回子进程ID值给父进程中栈段变量id。

⑧    当子进程开始运行时,操作系统返回0给子进程中栈段变量id。

示例:

#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string>
#include<iostream>
using namespace std;

int main(int argc, char** argv){
    pid_t pid;
    string message="";
    int n;
    pid = fork();
    if (pid < 0) {
        perror("fork failed");
        exit(-1);
    }else if (pid == 0) {
        message = "This is the child";
        n = 3;
    }else {
        wait(0) ; /*阻塞等待子进程返回*/
        message = "This is the parent";
        n = 1;
    }

    for(; n > 0; n--) {
        cout<<message<<endl;
        sleep(1);
    }

    return 0;

}

使用g++编译运行结果如下:

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

 

execve系统调用

execve() 系统调用的作用是运行另外一个指定的程序。它会把新程序加载到当前进程的内存空间内,当前的进程会被丢弃,它的堆、栈和所有的段数据都会被新进程相应的部分代替,然后会从新程序的初始化代码和 main 函数开始运行。同时,进程的 ID 将保持不变。

execve() 系统调用通常与 fork() 系统调用配合使用。从一个进程中启动另一个程序时,通常是先 fork() 一个子进程,然后在子进程中使用 execve() 变身为运行指定程序的进程。 例如,当用户在 Shell 下输入一条命令启动指定程序时,Shell 就是先 fork() 了自身进程,然后在子进程中使用 execve() 来运行指定的程序。

execve() 系统调用的函数原型为:

int execve(const char *filename, char *const argv[], char *const envp[]);
 

filename 用于指定要运行的程序的文件名,argv 和 envp 分别指定程序的运行参数和环境变量。除此之外,该系列函数还有很多变体,它们执行大体相同的功能,区别在于需要的参数不同,包括 execl、execlp、execle、execv、execvp、execvpe 等。它们的参数意义和使用方法请读者自行查看帮助手册。

需要注意的是,exec 系列函数的返回值只在遇到错误的时候才有意义。如果新程序成功地被执行,那么当前进程的所有数据就都被新进程替换掉了,所以永远也不会有任何返回值。

对于已打开文件的处理,在 exec() 系列函数执行之前,应该确保全部关闭。因为 exec() 调用之后,当前进程就完全变身成另外一个进程了,老进程的所有数据都不存在了。如果 exec() 调用失败,当前打开的文件状态应该被保留下来。让应用层处理这种情况会非常棘手,而且有些文件可能是在某个库函数内部打开的,应用对此并不知情,更谈不上正确地维护它们的状态了。

所以,对于执行 exec() 函数的应用,应该总是使用内核为文件提供的执行时关闭标志(FD_CLOEXEC)。设置了该标志之后,如果 exec() 执行成功,文件就会被自动关闭;如果 exec() 执行失败,那么文件会继续保持打开状态。使用系统调用 fcntl() 可以设置该标志。

有6种不同的exec函数可供使用,这些函数最终都是通过系统调用execve来实现的:

<unistd.h>
    int execl(const char *pathname, const char *arg1, ... /* (char*)0 */ );
    int execlp(const char *filename, const char *arg1, ... /* (char*)0 */ );
    int execle(const char *pathname, const char *arg1, ... 
                                                    /* (char*)0, char * const *envp */);
    int execv(const char *pathname, char * const argv[]);
    int execvp(const char *filename, char * const argv[]);
    int execve(const char *pathname, char * const argv[], char * const envp[]);

它们的关系如下图所示:

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

 

 

系统调用execve的内核入口为sys_execve:

/*
  *sys_execve() executes a new program.
  */
asmlinkage int sys_execve(struct pt_regs regs)
{
    int error;
    char * filename;

    filename = getname((char *) regs.ebx);
    error = PTR_ERR(filename);
    if (IS_ERR(filename))
        goto out;

    error = do_execve(filename, (char **) regs.ecx, (char **) regs.edx, &regs);
    if (error == 0)
        current->ptrace &= ~PT_DTRACE;

    putname(filename);
    out:
    return error;
}

示例:

main.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
    char *envp[] = {"T1=222","T2=333",NULL};
    char *argv_send[] = {"./execvetest","1","2",NULL};
    execve("./execvetest",argv_send,envp);
 
    printf("do this.....\r\n");
    return 0;
}

execvetest.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
extern char **environ;
int main(int argc, char *argv[])
{
    int i = 0;
    //打印envp,新环境变量
    for( i = 0; environ[i] != NULL; i++ )
    {
        printf("environ[%d]:%s\r\n",i,environ[i]);
    }
 
    printf("\r\n\r\n");
 
    //打印argv,参数
    for( i = 0; i < argc; i++ )
    {
        printf("argv[%d]:%s\r\n",i,argv[i]);
    }
    return 0;
}

执行结果:

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

 

实验结果分析:

 

所谓上下文切换,实质就是寄存器堆的切换过程。这其中一部分需要硬件来切换,一部分需要软件来处理。

当在用户空间发生中断时,首先由 x86 CPU 从硬件角度进行处理,然后才是 linux 内核的处理。当中断处理完毕,返回到用户空间时,最后的步骤也是交给 CPU 硬件来处理的。

1、 X86 CPU 对中断的硬件支持

CPU 从中断控制器取得中断向量

根据中断向量从 IDT 中找到对应的中断门

根据中断门,找到中断处理程序

在进入中断处理程序前,需要将堆栈切换到内核堆栈。也就是将 TSS 中的 SS0、ESP0装入SS、ESP

然后将原来的用户空间堆栈(SS, ESP)、EFLAGS、返回地址(CS, EIP)压入新的堆栈。

以上这一系列动作都由硬件完成

最后,才进入中断处理程序,接下来,由 linux 内核处理

2、 Linux 内核对中断的处理

保存中断来源号

调用 SAVE_ALL,保存各种寄存器

将 DS、ES 指向 __KERNEL_DS

将返回地址 ret_from_intr入栈

调用 do_IRQ进行中断处理

中断处理完毕,返回到 ret_from_intr

3、 ret_from_intr

所有的中断处理程序在处理完之后都要走到这里;

判断进入中断前是用户空间还是系统空间

如果进入中断前是系统空间,则直接调用 RESTORE_ALL

如果进入中断前是用户空间,则可能需要进行一次调度;如果不调度,则可能有信号需要处理;最后,还是走到 RESTORE_ALL

RESOTRE_ALL 和 SAVE_ALL 是相反的操作,将堆栈中的寄存器恢复

最后,调用 iret 指令 ,将处理权交给 CPU

4、 iret 指令使 CPU 从中断返回

此时,系统空间的堆栈和CPU在第1步处理完之后,交给 linux 内核时的情形是一样的,也就是保存着用户空间的返回地址(CS、EIP)、EFLAGS、用户空间的堆栈(SS、ESP)。

CPU将 CS、EIP、EFLAGS 、SS、ESP恢复,从而返回到用户空间。

一个CPU同一时刻只能在用户空间和内核空间的一个中运行。在CPU运行在用户空间的过程中,可能以以下两种方式陷入内核 1)系统调用:这其实是一种同步中断,也称“软件中断”(注意,不是软中断),或者称为“异常”,通过int指令来实现;陷入内核后,内核代码运行在进程上下文中。2)中断(也称异步中断),是由I/O设备产生的中断,它会触发中断服务例程的执行,并往往伴随着软中断的执行,此时,CPU运行在中断上下文中。上文中描述了CPU从中断返回用户空间的过程。

实验总结

通过本次实验,我fork系统调用和execve系统调用有了更加详细的了解,同时对系统调用的过程有了了解,对中断上下文切换和进程上下文切换也有了一定的了解。对Linux内核有了更加深刻的了解。总而言之,这次实验让我受益匪浅。

 

参考资料:

https://baike.baidu.com/item/上下文切换/4842616?fr=aladdin

https://www.jianshu.com/p/4393a4537eca

https://blog.csdn.net/weixin_35695879/article/details/89530422

https://www.cnblogs.com/xuepei/p/4414469.html

https://blog.csdn.net/Always2015/article/details/45008785

https://my.oschina.net/u/3857782/blog/1854572

https://blog.csdn.net/Antonio0827/article/details/80411553

https://blog.csdn.net/zhedatianrui/article/details/4323017

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

上一篇:Linux minicom 软件退出方法


下一篇:基于vmware15的centos7.6的安装操作记录