首页 > 系统服务 > 详细

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

时间:2020-06-15 19:34:32      阅读:43      评论:0      收藏:0      [点我收藏+]

一.实验目的

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

二、实验过程

1、Linux进程切换

1)Linux进程切换原理

为了控制进程的执?,内核必须有能?挂起正在CPU上运?的进程,并恢复执?以前挂起的某个进程。这种?为被称为进程切换,任务切换或进程上下?切换。尽管每个进程可以拥有属于??的地址空间,但所有进程必须共享CPU及寄存器。因此在恢复?个进程执?之前,内核必须确保每个寄存器装?了挂起进程时的值。进程恢复执?前必须装?寄存器的?组数据,称为进程的CPU上下?。

操作系统管理很多进程的执行,有些进程是来自各种程序、系统和应用程序的单独进程,而某些进程来自被分解为很多进程的应用或程序。当一个进程从内核中移出,另一个进程成为活动的,这些进程之间便发生了上下文切换。 操作系统必须记录重启进程和启动新进程使之活动所需要的所有信息。这些信息被称作上下文,它描述了进程的现有状态,进程上下文是可执行程序代码是进程的重要组成部分,实际上是进程执行活动全过程的静态描述,可以看作是用户进程传递给内核的这些参数以及内核要保存的那一整套的变量和寄存器值和当时的环境等。

进程的上下文信息包括, 指向可执行文件的指针、栈、内存(数据段和堆)、进程状态、优先级、程序I/O的状态、授予权限、调度信息、审计信息、有关资源的信息(文件描述符和读/写指针)、关事件和信号的信息、寄存器组(栈指针, 指令计数器)等等。在Linux中,当前进程上下文均保存在进程的任务数据结构中。在发生中断时,内核就在被中断进程的上下文中,在内核态下执行中断服务例程。但同时会保留所有需要用到的资源,以便中继服务结束时能恢复被中断进程的执行。

技术分享图片

可以使用vmstat工具来查询系统的上下文切换情况,如图所示:

技术分享图片

  • cs:每秒上下文切换的次数
  • in:每秒中断的次数
  • r:就绪队列的长度,也就是正在运行和等待CPU的进程数
  • b:处于不可中断睡眠状态的进程数

2)进程内核堆栈及CPU上下文切换

技术分享图片

schedule()函数选择一个新的进程来运行,并调用context_switch进行上下文的切换,这个宏调用switch_to来进行关键上下文切换

    (1)next = pick_ next_task(rq, prev);//进程调度算法都封装这个函数内部

    (2)context_switch(rq, prev, next);//进程上下文切换

    (3)switch_to利用了prev和next两个参数:prev指向当前进程,next指向被调度的进程

3)context_switch进程上下文切换

linux中进程调度时,内核在选择新进程之后进行抢占时,通过context_switch完成进程上下文切换。

context_switch函数建立next进程的地址空间。进程描述符的active_mm字段指向进程所使用的内存描述符,而mm字段指向进程所拥有的内存描述符。对于一般的进程,这两个字段有相同的地址,但是,内核线程没有它自己的地址空间而且它的 mm字段总是被设置为 NULL。

context_switch( )函数保证:如果next是一个内核线程, 它使用prev所使用的地址空间 。

技术分享图片

static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
           struct task_struct *next)
{
    struct mm_struct *mm, *oldmm;

    /*  完成进程切换的准备工作  */
    prepare_task_switch(rq, prev, next);

    mm = next->mm;
    oldmm = prev->active_mm;
    arch_start_context_switch(prev);

    /*  如果next是内核线程,则线程使用prev所使用的地址空间
     *  schedule( )函数把该线程设置为懒惰TLB模式
     *  内核线程并不拥有自己的页表集(task_struct->mm = NULL)
     *  它使用一个普通进程的页表集
     *  不过,没有必要使一个用户态线性地址对应的TLB表项无效
     *  因为内核线程不访问用户态地址空间。
    */
    if (!mm)        /*  内核线程无虚拟地址空间, mm = NULL*/
    {
        /*  内核线程的active_mm为上一个进程的mm
         *  注意此时如果prev也是内核线程,
         *  则oldmm为NULL, 即next->active_mm也为NULL  */
        next->active_mm = oldmm;
        /*  增加mm的引用计数  */
        atomic_inc(&oldmm->mm_count);
        /*  通知底层体系结构不需要切换虚拟地址空间的用户部分
         *  这种加速上下文切换的技术称为惰性TBL  */
        enter_lazy_tlb(oldmm, next);
    }
    else            /*  不是内核线程, 则需要切切换虚拟地址空间  */
        switch_mm(oldmm, mm, next);

    /*  如果prev是内核线程或正在退出的进程
     *  就重新设置prev->active_mm
     *  然后把指向prev内存描述符的指针保存到运行队列的prev_mm字段中
     */
    if (!prev->mm)
    {
        /*  将prev的active_mm赋值和为空  */
        prev->active_mm = NULL;
        /*  更新运行队列的prev_mm成员  */
        rq->prev_mm = oldmm;
    }

    lockdep_unpin_lock(&rq->lock);
    spin_release(&rq->lock.dep_map, 1, _THIS_IP_);

    /* Here we just switch the register state and the stack. 
     * 切换进程的执行环境, 包括堆栈和寄存器
     * 同时返回上一个执行的程序
     * 相当于prev = witch_to(prev, next)  */
    switch_to(prev, next, prev);

    /*  switch_to之后的代码只有在
     *  当前进程再次被选择运行(恢复执行)时才会运行
     *  而此时当前进程恢复执行时的上一个进程可能跟参数传入时的prev不同
     *  甚至可能是系统中任意一个随机的进程
     *  因此switch_to通过第三个参数将此进程返回
     */


    /*  路障同步, 一般用编译器指令实现
     *  确保了switch_to和finish_task_switch的执行顺序
     *  不会因为任何可能的优化而改变  */
    barrier();  

    /*  进程切换之后的处理工作  */
    return finish_task_switch(prev);
} 

2、fork系统调用

1)fork系统调用

fork()系统调用用于创建新进程,新创建的进程为子进程,调用fork()并创建新进程的进程是父进程。fork()函数通过系统调用创建一个与原来进程几乎完全相同的进程,这俩个进程默认完成同样的功能,但如果初始参数和传入的变量不同,俩个进程也可以完成不同的功能。
父子进程运行的时间: 子进程和父进程是同时执行的。但是输出没有固定的顺序,有可能父进程先输出,也有可能子进程先输出。

  • 子进程创建后,系统会给子进程分配资源,然后把原来的进程的所有值都复制到新的子进程中,只有少数值与原来的进程的值不同;其实就是父进程的一份副本。但是子进程和父进程驻留在不同的内存空间上。这些内存空间具有相同的内容,并且一个进程执行的任何操作都不会影响其他进程。
  • fork 创造的子进程复制了父亲进程的资源,包括内存的内容task_struct内容(2个进程的pid不同)。
#include"stdio.h"
int main() {
        int count = 1;
        int child;

        if(!(child = fork())) { //开始创建子进程
                printf("This is son, his count is: %d. and his pid is: %d\n", ++count, getpid());//子进程的内容
        } else {
                printf("This is father, his count is: %d, his pid is: %d\n", count, getpid());
        }
}

编译运行

gcc test.c -o test

技术分享图片

从代码里面可以看出2者的pid不同,内存资源count是值得复制,子进程改变了count的值,而父进程中的count没有被改变。在复制过程中,子进程复制了父进程的task_struct,系统堆栈空间和页面表,这意味着上面的程序,没有执行count++前,其实子进程和父进程的count指向的是同一块内存。而当子进程改变了父进程的变量时候,会通过copy_on_write的手段为所涉及的页面建立一个新的副本。所以当执行++count后,这时候子进程才新建了一个页面复制原来页面的内容,基本资源的复制是必须的,而且是高效的。整体看上去就像是父进程的独立存储空间也复制了一遍。其次,子进程和父进程直接没有互相干扰,明显2者资源都独立了。

2)执行过程

fork()系统调用其实封装的是do_fork()函数:

 技术分享图片

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;
}

do_fork()利用辅助函数copy_process()来创建进程描述符以及子进程执行所需要的所有其他内核数据结构。它先把父进程复制了一份,返回struct task_struct结构的一个指针,然后唤醒子进程,基本上就完成了一个fork过程。可见fork主要的工作就是创建一个新进程,然后把父进程的栈等内容复制过去。

copy_process函数也在./linux/kernel/fork.c中。它会用当前进程的一个副本来创建新进程并分配pid,但不会实际启动这个新进程。它会复制寄存器中的值、所有与进程环境相关的部分,每个clone标志。新进程的实际启动由调用者来完成。

3、execve系统调用

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

技术分享图片

 技术分享图片 

 execve系统调用的主要处理过程在do_execve函数中:

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;
    /* 用于解析ELF文件 */ 
    struct linux_binprm *bprm; 
    struct files_struct *displaced;
    int retval;

    if (IS_ERR(filename))
        return PTR_ERR(filename);

    if ((current->flags & PF_NPROC_EXCEEDED) &&
        atomic_read(&current_user()->processes) > rlimit(RLIMIT_NPROC)) {
        retval = -EAGAIN;
        goto out_ret;
    }

   /* 标记程序已经被执行 */
    current->flags &= ~PF_NPROC_EXCEEDED; 

    /* 拷贝当前运行进程的fd到displaced中 */
    retval = unshare_files(&displaced);
    if (retval)
        goto out_ret;

    retval = -ENOMEM;
    bprm = kzalloc(sizeof(*bprm), GFP_KERNEL);
    if (!bprm)
        goto out_files;

   /* 创建一个新的凭证*/
    retval = prepare_bprm_creds(bprm);
    if (retval)
        goto out_free;

    check_unsafe_exec(bprm);/* 安全检查 */
    current->in_execve = 1;

    if (!file)
        file = do_open_execat(fd, filename, flags);/* 打开要执行的文件 */
    retval = PTR_ERR(file);
    if (IS_ERR(file))
        goto out_unmark;

    sched_exec();

    bprm->file = file;
    if (!filename) {
        bprm->filename = "none";
    } else if (fd == AT_FDCWD || filename->name[0] == /) {
        bprm->filename = filename->name;
    } else {
        if (filename->name[0] == \0)
            pathbuf = kasprintf(GFP_KERNEL, "/dev/fd/%d", fd);
        else
            pathbuf = kasprintf(GFP_KERNEL, "/dev/fd/%d/%s",
                        fd, filename->name);
        if (!pathbuf) {
            retval = -ENOMEM;
            goto out_unmark;
        }
        if (close_on_exec(fd, rcu_dereference_raw(current->files->fdt)))
            bprm->interp_flags |= BINPRM_FLAGS_PATH_INACCESSIBLE;
        bprm->filename = pathbuf;
    }
    bprm->interp = bprm->filename;

    retval = bprm_mm_init(bprm); /* 为ELF文件分配内存 */
    if (retval)
        goto out_unmark;

    retval = prepare_arg_pages(bprm, argv, envp);
    if (retval < 0)
        goto out;

    retval = prepare_binprm(bprm); /* 从打开的可执行文件中读取信息,填充bprm结构*/
    if (retval < 0)
        goto out;

    /* 将运行参数和环境变量都拷贝到bprm结构的内存空间中 */
    retval = copy_strings_kernel(1, &bprm->filename, bprm);
    bprm->exec = bprm->p;
    retval = copy_strings(bprm->envc, envp, bprm);
    retval = copy_strings(bprm->argc, argv, bprm);

    would_dump(bprm, bprm->file);

    /* 开始执行加载到内存中的ELF文件 */
    retval = exec_binprm(bprm);
    if (retval < 0)
        goto out;

    /* 执行完成,清理并返回 */
    current->fs->in_exec = 0;
    current->in_execve = 0;
    rseq_execve(current);
    acct_update_integrals(current);
    task_numa_free(current, false);
    free_bprm(bprm);
    kfree(pathbuf);
    if (filename)
        putname(filename);
    if (displaced)
        put_files_struct(displaced);
    return retval;

ELF映像就是由文件头、区段头表、程序头表、一定数量的区段、以及一定数量的部构成,而ELF映像的装入/启动过程,则就是在各种头部信息的指引下将某些部或区段装入一个进程的用户空间,并为其运行做好准备(例如装入所需的共享库),最后(在目标进程首次受调度运行时)让CPU进入其程序入口的过程。接着是对elf_bss 、elf_brk、start_code、end_code等等变量的初始化。这些变量分别纪录着当前(到此刻为止)目标映像的bss段、代码段、数据段、以及动态分配“堆” 在用户空间的位置。除start_code的初始值为0xffffffff外,其余均为0。随着映像内容的装入,这些变量也会逐步得到调整。
由代码分析可以看出,execve() 实现了在一个进程中启动另外一个程序的方法. 它可以根据指定的文件名或目录名找到可执行文件,并用它来取代原调用进程的数据段、代码段和堆栈段,在执行完之后,原调用进程的内容除了进程号外,其他全部被新的进程替换了。

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

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

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

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

  (2)发生中断——save cs:eip/esp/eflags(current) to kernel stack,then 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

  (8)继续运行用户态进程Y

2、几种特殊情况

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

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

  (3)创建子进程的系统调用在子进程中的执行起点及返回用户态,如fork;

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

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

原文:https://www.cnblogs.com/mia-blog/p/13093638.html

(0)
(0)
   
举报
评论 一句话评论(0
关于我们 - 联系我们 - 留言反馈 - 联系我们:wmxa8@hotmail.com
© 2014 bubuko.com 版权所有
打开技术之扣,分享程序人生!