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

实验要求

 

1、以fork和execve系统调用为例分析中断上下文的切换

2、分析execve系统调用中断上下文的特殊之处

3、分析fork子进程启动执行时进程上下文的特殊之处

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

 

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


 

一、基础知识

1、用户空间与内核空间

操作系统采用虚拟存储器,对于32位的操作系统而言,其寻址空间为4G(即2^32)。

为保证内核的安全,操作系统将虚拟空间划分为两部分:

(1)内核空间:存放内核代码和数据

(2)用户空间:存放用户程序的代码和数据

针对Linux操作系统,将最高的1G字节(0xC0000000 - 0xFFFFFFFF)供内核使用,称为内核空间;将较低的3G字节(0x00000000-0xBFFFFFFF)供各个进程使用,称为用户空间。

虚拟空间分配如下图所示:

每个进程可以通过系统调用进入内核。

 

2、内核态和用户态

(1)常规划分

a. 内核态:

当一个任务(进程)执行系统调用而陷入内核代码中执行时,称进程处于内核运行态(内核态)。

当进程处于内核态时,执行的内核代码会使用当前进程的内核栈。每个进程都有自己的内核栈。

b. 用户态:

当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)

(2)内核态的进一步划分

上下文简单来说就是一个环境。

内核态根据上下文环境可以进行进一步细分:

a. 内核态,运行于进程上下文,内核代表进程运行于内核空间。

b. 内核态,运行于中断上下文,内核代表硬件运行于内核空间。

c. 用户态,运行于用户空间。

 

3、进程上下文

 一般程序在用户空间执行,当一个程序调用了系统调用或者触发了某个异常,它就陷入了内核空间。此时,我们称内核“代表进程执行“并处于进程上下文。

进程上下文实际上是进程执行全过程的静态描述。

(1)上文:把已执行过的进程指令和数据在相关寄存器与堆栈中的内容称为上文

(2)正文:把正在执行的指令和数据在寄存器和堆栈中的内容称为正文

(3)下文:把待执行的指令和数据在寄存器与堆栈中的指令。

具体来说,进程上下文包括:

(1)计算机系统中与执行该进程有关的各种寄存器的值(例如通用寄存器、程序计数器PC、程序状态字寄存器PS等)

(2)程序段在经过编译过后形成的机器指令代码集

(3)数据集

(4)各种堆栈值PCB结构

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

 

4、中断上下文

硬件通过中断触发信号,导致内核调用中断处理程序,进入内核空间。在这个过程中,硬件的一些变量和参数需要传递给内核,内核通过这些参数进行中断处理。

(1)中断上文:硬件传递过来的参数和内核需要保存的一些其他环境(主要是当时被中断的进程的环境)

(2)中断下文:执行在内核空间的中断服务程序

 

5、进程上下文和中断上下文

当工作在用户态的进程想要访问某些内核才能访问的资源时,必须通过系统调用或者中断切换到内核态,由内核代为执行。

进程上下文中断上下文就是完成用户态和内核态切换所进行的操作总称。

(1)进程上下文主要是异常处理程序和内核线程。内核之所以进入进程上下文是因为进程自身的一些工作需要在内核中做。

当一个进程在执行时,CPU中的所有寄存器中的值、进程的状态以及堆栈中的内容被称为该进程的上下文。当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的上下文,以便在再次执行该进程时,能够得到切换时的状态执行下去。在Linux中,当前进程上下文军保存在进程的任务数据结构中。

(2)中断上下文是由于硬件发生中断时会触发中断信号请求,请求系统处理中断,执行中断服务子程序。

在发生中断时,内核就在被中断进程的上下文中,在内核态下执行中断服务例程。但同时会保留所有需要用到的资源,以便中断服务结束时能恢复被中断进程的执行。

 

二、fork系统调用

1、fork() 函数基础知识

函数原型:

pid_t fork( void);

返回值:

若成功调用一次,则返回两个值,子进程返回0,父进程返回子进程ID;否则,出错返回 -1。

函数说明:

(1)一个现有进程可以调用 fork 函数创建一个新进程。

(2)由 fork 创建的进程被称为子进程(child process)

(3)fork 函数被调用一次但会返回两次,两次返回的唯一区别是子进程种返回0值而父进程返回子进程的ID。

(4)子进程是父进程的副本,其将获得父进程数据空间、堆栈等资源的副本,即父子进程之间不共享这些存储空间。

注意:

(1)在不同的UNIX系统下,我们无法确定fork之后是子进程先运行还是父进程先运行,这依赖于系统的实现。

在fork函数执行完毕后,如果创建新进程成功,则出现两个进程,一个是子进程,一个是父进程。我们可以通过fork返回的值来判断当前进程是子进程还是父进程。

(2)为什么fork会返回两次:

fork函数在创建子进程时复制了父进程的堆栈段,所以两个进程都停留在fork函数种,等待返回。因此fork函数会返回两次,一次是在父进程中返回,另一次是在子进程中返回,这两次的返回值不一样。

(3)调用fork之后,数据空间和堆栈有两份,但是代码仍然只有一份,这个代码段是父子进程的共享代码段。当父子进程有一个想要修改数据或者堆栈时,两个进程真正分裂。

 

2、fork系统调用与一般系统调用的比较

(1)一般系统调用过程:

a. 系统调用陷入内核态,从用户态堆栈转换到内核态堆栈,保存现场

b.  恢复现场和系统调用返回,回到用户态

c. 执行 int $0x80 或者 syscall 指令之后的下一条指令

(2)fork系统调用:

fork系统调用陷入内核态之后会有 两次返回,分别从父进程返回和子进程返回。

a. 从父进程的角度来看,fork系统调用的执行过程与一般系统调用完全一致,父进程正常fork系统调用返回到用户态

b. fork出来的子进程也要从内核态返回到用户态

 

3、fork系统调用的内核处理过程分析

(1)fork系统调用的内核处理函数

 Linux源代码中的 syscall_32.tbl 和 syscall_64.tbl 分别定义了 32位x86 和 64位x86-64的系统调用内核处理函数。

a. 32位系统

进入下方目录中的syscall_32.tbl,查看32位系统下的系统调用表:

linux-5.4.34/arch/x86/entry/syscalls/syscall_32.tbl

可以看出fork系统调用在32位系统中对应的内核处理函数为2号系统调用 sys_fork

b. 64位系统

进入如下目录,查看fork源代码:

linux-5.4.34/kernel/fork.c

源代码如下:

/*
 * Create a kernel thread.
 */
pid_t kernel_thread(int (*fn)(void *), void *arg, unsigned long flags)
{
    struct kernel_clone_args args = {
        .flags        = ((flags | CLONE_VM | CLONE_UNTRACED) & ~CSIGNAL),
        .exit_signal    = (flags & CSIGNAL),
        .stack        = (unsigned long)fn,
        .stack_size    = (unsigned long)arg,
    };

    return _do_fork(&args);
}

#ifdef __ARCH_WANT_SYS_FORK
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
    struct kernel_clone_args args = {
        .exit_signal = SIGCHLD,
    };

    return _do_fork(&args);
#else
    /* can not support in nommu mode */
    return -EINVAL;
#endif
}
#endif

#ifdef __ARCH_WANT_SYS_VFORK
SYSCALL_DEFINE0(vfork)
{
    struct kernel_clone_args args = {
        .flags        = CLONE_VFORK | CLONE_VM,
        .exit_signal    = SIGCHLD,
    };

    return _do_fork(&args);
}
#endif

#ifdef __ARCH_WANT_SYS_CLONE
#ifdef CONFIG_CLONE_BACKWARDS
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
         int __user *, parent_tidptr,
         unsigned long, tls,
         int __user *, child_tidptr)
#elif defined(CONFIG_CLONE_BACKWARDS2)
SYSCALL_DEFINE5(clone, unsigned long, newsp, unsigned long, clone_flags,
         int __user *, parent_tidptr,
         int __user *, child_tidptr,
         unsigned long, tls)
#elif defined(CONFIG_CLONE_BACKWARDS3)
SYSCALL_DEFINE6(clone, unsigned long, clone_flags, unsigned long, newsp,
        int, stack_size,
        int __user *, parent_tidptr,
        int __user *, child_tidptr,
        unsigned long, tls)
#else
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
         int __user *, parent_tidptr,
         int __user *, child_tidptr,
         unsigned long, tls)
#endif
{
    struct kernel_clone_args args = {
        .flags        = (clone_flags & ~CSIGNAL),
        .pidfd        = parent_tidptr,
        .child_tid    = child_tidptr,
        .parent_tid    = parent_tidptr,
        .exit_signal    = (clone_flags & CSIGNAL),
        .stack        = newsp,
        .tls        = tls,
    };

    if (!legacy_clone_args_valid(&args))
        return -EINVAL;

    return _do_fork(&args);
}
#endif

进入下方目录中的syscall_64.tbl,查看64位系统下的系统调用表:

linux-5.4.34/arch/x86/entry/syscalls/syscall_32.tbl

可以看出fork系统调用在64位系统中对应的内核处理函数为56号系统调用__64_sys_clone、57号系统调用__x64_sys_fork和58号系统调用__64_sys_vfork。

分析上述源代码:

fork、vfork、clone系统调用和kernel_thread内核函数都可以创建一个新进程,并且都是通过调用_do_fork函数来创建进程,只不过传递的参数不同。

 

(2)_do_fork函数

_do_fork函数位于fork系统调用的源代码fork.c中,目录如下:

linux-5.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;
}

 关键部分如下所示:

long _do_fork(struct kernel_clone_args *args)
{
    ……
    //复制进程描述符和执行时所需的其他数据结构
    p = copy_process(NULL, trace, NUMA_NO_NODE, args);
    ……
    //将子进程添加到就绪队列
    wake_up_new_task(p);
    ……
    //返回子进程pid(父进程中fork返回值为子进程的pid)
    return nr;
}

_do_fork函数主要完成了:

(i) 调用 copy_process() 复制父进程的进程描述符和执行时所需的其他数据结构

(ii) 获得pid

(iii) 调用 wake_up_new_task() 将子进程加入就绪队列等待调度执行

 a. copy_process() 函数

主要代码如下:

static __latent_entropy struct task_struct *copy_process(
    struct pid *pid,
    int trace,
    int node,
    struct kernel_clone_args *args)
    {
        //复制进程描述符task_struct、创建内核堆栈等
        p = dup_task_struct(current, node);
        /* copy all the process information */
        shm_init_task(p);
        …
        //初始化⼦进程内核栈和thread  
        retval = copy_thread_tls(clone_flags, args->stack, args->stack_size, p, args->tls);
        …
        return p;//返回被创建的⼦进程描述符指针
    }

copy_process()函数主要完成了:

(i) 调用 dup_task_struct() 复制当前进程(即父进程)描述符task_struct

(ii) 信息检查、初始化、把进程状态设置为 TASK_RUNNING(此时子进程置为就绪态)、采用写时复制技术逐一复制所有其他进程资源

(iii) 调用 copy_thread_tis() 初始化子进程内核栈

(iv) 设置子进程pid 等

copy_thread_tls() 函数

作用:负责构造fork系统调用在子进程的内核堆栈,即fork系统调用在父子进程各返回一次,父进程中和其他系统调用处理过程完全一致;而在子进程中的内核函数调用堆栈需要特殊构建,为子进程的运行准备好上下文环境。

预备知识:

(i) fork子进程的内核堆栈在struct pt_regs 的基础上增加了 struct inactive_task_frame

(ii) 进程描述符(struct thread_struct)的最后是保存进程上下文中CPU的一些状态信息的数据结构 thread,最关键的是:

sp 用来保存进程上下文中ESP寄存器状态

ip 用来保存进程上下文中EIP寄存器状态

注意:如果数据结构中没有ip,则可将ip通过内核堆栈来保存

比如 fork 创建的子进程内核堆栈中会有一个 ret_addr

此外,进程描述符的最后还保存了很多其他和CPU相关的状态。

b. wake_up_new_task()

子进程创建好了进程描述符、内核堆栈等,就可以通过 wake_up_new_task(p) 将子进程添加到就绪队列,使之有机会被调度执行,进程的创建工作就完成了,子进程就可以等待调度执行。

c. ret_from_fork

进程的执行从设定的ret_from_fork开始。

d. _do_fork() 函数总结

 

 

 总结来说,进程的创建过程大致是:

(i) 父进程通过fork系统调用进入内核_do_fork函数,如上图所示复制进程描述符及相关进程资源(采用写时复刻技术)、分配子进程的内核堆栈并对内核堆栈和thread等进程关键上下文进行初始化,最后将子进程放入就绪队列,fork系统调用返回

(ii) 子进程则在被调度执行时根据设置的内核堆栈和thread等进程关键上下文开始执行

 

三、execve系统调用

1、execve基础知识

表头文件:

#include<unistd.h>

函数原型:

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

返回值:

如果执行成功则函数不会返回;执行失败则直接返回-1,失败原因存于errno中。

函数说明:

execve() 用来执行参数 filename 字符串所代表的文件路径;第二个参数是利用指针数组来传递给执行文件,并且需要以空指针(NULL)结束;最后一个参数则为传递给执行文件的新环境变量数组。

作用:

execve(执行文件)在父进程中fork一个子进程,在子进程中调用exec函数启动新的程序。exec函数一共有六个,其中execve为内核级系统调用,其他(execl execle execlp execv execvp)都是调用execve的库函数。

 

2、execve系统调用与一般系统调用、fork系统调用的比较

(1)一般系统调用:

正常的一个系统调用都是陷入内核态,再返回用户态,然后继续执行系统调用后的下一条指令。

(2)fork系统调用:

fork系统调用与其他系统调用的不同之处在于:

它陷入内核态之后有两次返回,第一次返回到原来的父进程的位置继续向下执行,这和一般的系统调用是一样的;在子进程中fork也返回了一次,会返回到一个特定的点——ret_from_fork,通过内核构造的堆栈环境,它可以正常系统调用返回到用户态。

(3)execve系统调用:

当前的可执行程序在执行,执行到execve系统调用时陷入内核态,在内核态里面用 do_execve 加载可执行文件,把当前进程的可执行文件给覆盖掉。

当 execve 系统调用返回时,返回的已经不是原来的那个可执行程序了,而是新的可执行程序。execve 返回的是新的可执行程序执行的起点,静态链接的可执行文件也就是 main 函数的大致位置;动态链接的可执行文件还需要 ld 链接好,动态链接库再从 main 函数开始执行。

 

3、execve系统调用的内核处理过程

Linux系统一般会提供 execl execlp execle execv execvp execve 等6个用以加载执行一个可执行文件的库函数,这些库函数统称为 exec 函数,差异在于对命令行参数和环境变量参数的传递方式不同。

exec 函数都是通过 execve 系统调用进入内核,对应的系统调用内核处理函数为 sys_execve(32位系统)或者 __x64_sys_execve(64位系统),它们都是通过调用 do_execve 来具体执行加载可执行文件的工作。

整体的调用关系为:

sys_execve() 或者 __x64_sys_execve -->

    do_execveat_common() ->

        __do_execve_file -->

            exec_binprm() -->

                search_binary_handler() -->

                    load_elf_binary() -->

                        start_thread() 

 

四、Linux 系统的一般执行过程

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

2、发生中断(包括异常、系统调用等),跳转到中断处理程序入口。

3、中断上下文切换,保存 EIP/ESP/EFLAGS 的值到内核态堆栈,加载EIP和ESP寄存器的值,从进程X的用户态到进程X的内核态。具体包括如下:

(1)swapgs指令:保存现场

(2)rsp point to kernel stack:加载当前进程内核堆栈栈顶地址到RSP寄存器

(3)save cs:/rip/ss:rsp/flags:将当前CPU关键上下文压入进程X的内核堆栈。

4、中断处理过程中或中断返回前调用了 schedule 函数,其中:

(1)进程调度算法选择 next 进程;

(2)进程地址空间切换;

(3)switch_to 做了关键的进程上下文切换:switch_to 调用 __switch_to_asm 汇编代码做了关键的进程上下文切换,将当前进程 X 的内核堆栈切换到进程调度算法选出来的 next 进程(假定为进程 Y)的内核堆栈,并完成了进程上下文所需的指令指针寄存器状态切换。然后进程 Y 开始运行。

5、恢复中断上下文,与步骤3的中断上下文切换相对应:

iret - pop cs:rip/ss:rsp/flags

从 Y 进程的内核堆栈中弹出步骤3对应的压栈内容,此时完成了中断上下文的切换,从Y进程的内核态返回到Y进程的用户态。

6、继续运行用户态进程Y。

 
posted on 2020-06-15 07:47  琉娅璃  阅读(340)  评论(0编辑  收藏  举报