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

一、execve系统调用

execve函数的作用是在父进程中创建一个子进程,在子进程中调用execve函数启动新的程序。exec函数一共有六个,其中execve是内核级的系统调用,其余(execl,execle,execlp,execv,execvp)都是调用execve的库函数。

execve执行成功的时候没有返回值,执行失败时返回-1。execve与fork的区别是exec系列的系统调用是把当前程序替换为需要执行的程序,而fork用来产生一个和当前进程一样的进程,因此如果想要运行另一个程序,同时又保留原程序,通常使用fork+exec。

execve系统调用与普通系统调用相比最特殊的地方就是execve函数执行成功后返回新的可执行程序的起点,并且代码段、数据段以及bss段和调用进程的栈会被加载进来的程序覆盖掉。

以下是execve系统调用的具体流程为:

  •  内核陷入
  • 加载新进程的上下文
  • 用新的进程覆盖掉原先的进程数据
  • 设置程序入口IP为新进程的地址
  • execve函数返回,执行新的进程,但进程的pid不变

 通过程序分析execve系统调用特殊之处:

使用以下简单c程序调用execve:

test.c

 1 #include
 2 #include
 3 #include
 4 
 5 int main()
 6 {
 7         char *filename = "./test.sh";
 8         char *test_argv[4];
 9         test_argv[0] = "sh"; 
10         test_argv[1] = "Hello";
11         test_argv[2] = "World";
12         test_argv[3] = NULL;
13         char *envp[] = {"T1=!!!", NULL};
14         if(0 != execve(filename, test_argv, envp)) {
15                 printf("execve failed\n");
16         }
17         printf("This is main program!\n");
18 }

test.sh

1 #!/bin/bash
2 
3 echo $1 
4 echo $2
5 echo $T1

以上程序的作用是给execve函数参数赋值,然后调用execve函数执行test.sh脚本。test.sh脚本的作用是打印出赋值给execve函数的参数。请注意,在test.c函数中。执行完execve函数后还有一个printf函数打印一些信息,这样如果屏幕打印出了这些信息就证明execve函数返回到了之前执行的函数,反之则不返回。

执行结果如下:

可以看到并没有打印出execve之后的printf语句,因此可以说明execve执行后不返回原程序,而是执行新的程序。

查看linux-5.4.34/fs/exec.c下的源码,由于源码很长,这里只节选一部分关键代码:

int flush_old_exec(struct linux_binprm * bprm)
{
    int retval;

    /*
     * Make sure we have a private signal table and that
     * we are unassociated from the previous thread group.
     */
    retval = de_thread(current);
    if (retval)
        goto out;

    /*
     * Must be called _before_ exec_mmap() as bprm->mm is
     * not visibile until then. This also enables the update
     * to be lockless.
     */
    set_mm_exe_file(bprm->mm, bprm->file);

    /*
     * Release all of the old mmap stuff
     */
    acct_arg_size(bprm, 0);
    retval = exec_mmap(bprm->mm);
    if (retval)
        goto out;

    /*
     * After clearing bprm->mm (to mark that current is using the
     * prepared mm now), we have nothing left of the original
     * process. If anything from here on returns an error, the check
     * in search_binary_handler() will SEGV current.
     */
    bprm->mm = NULL;

    set_fs(USER_DS);
    current->flags &= ~(PF_RANDOMIZE | PF_FORKNOEXEC | PF_KTHREAD |
                    PF_NOFREEZE | PF_NO_SETAFFINITY);
    flush_thread();
    current->personality &= ~bprm->per_clear;

    /*
     * We have to apply CLOEXEC before we change whether the process is
     * dumpable (in setup_new_exec) to avoid a race with a process in userspace
     * trying to access the should-be-closed file descriptors of a process
     * undergoing exec(2).
     */
    do_close_on_exec(current->files);
    return 0;

out:
    return retval;
}

这一部分函数是清除旧的exec执行痕迹。

void setup_new_exec(struct linux_binprm * bprm)
{
    /*
     * Once here, prepare_binrpm() will not be called any more, so
     * the final state of setuid/setgid/fscaps can be merged into the
     * secureexec flag.
     */
    bprm->secureexec |= bprm->cap_elevated;

    if (bprm->secureexec) {
        /* Make sure parent cannot signal privileged process. */
        current->pdeath_signal = 0;

        /*
         * For secureexec, reset the stack limit to sane default to
         * avoid bad behavior from the prior rlimits. This has to
         * happen before arch_pick_mmap_layout(), which examines
         * RLIMIT_STACK, but after the point of no return to avoid
         * needing to clean up the change on failure.
         */
        if (bprm->rlim_stack.rlim_cur > _STK_LIM)
            bprm->rlim_stack.rlim_cur = _STK_LIM;
    }

    arch_pick_mmap_layout(current->mm, &bprm->rlim_stack);

    current->sas_ss_sp = current->sas_ss_size = 0;

    /*
     * Figure out dumpability. Note that this checking only of current
     * is wrong, but userspace depends on it. This should be testing
     * bprm->secureexec instead.
     */
    if (bprm->interp_flags & BINPRM_FLAGS_ENFORCE_NONDUMP ||
        !(uid_eq(current_euid(), current_uid()) &&
          gid_eq(current_egid(), current_gid())))
        set_dumpable(current->mm, suid_dumpable);
    else
        set_dumpable(current->mm, SUID_DUMP_USER);

    arch_setup_new_exec();
    perf_event_exec();
    __set_task_comm(current, kbasename(bprm->filename), true);

    /* Set the new mm task size. We have to do that late because it may
     * depend on TIF_32BIT which is only updated in flush_thread() on
     * some architectures like powerpc
     */
    current->mm->task_size = TASK_SIZE;

    /* An exec changes our domain. We are no longer part of the thread
       group */
    WRITE_ONCE(current->self_exec_id, current->self_exec_id + 1);
    flush_signal_handlers(current, 0);
}

设置新的exec执行环境。

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

该函数加载了elf格式的可执行文件并执行,其中search_binary_handler(bprm)进行了进程镜像的替换。

 二、fork系统调用

fork系统调用用于创建一个新进程,称为子进程,它与进程(称为系统调用fork的进程)同时运行,此进程称为父进程。创建新的子进程后,两个进程将执行fork()系统调用之后的下一条指令。子进程使用相同的pc(程序计数器),相同的CPU寄存器,在父进程中使用的相同打开文件。fork函数被调用一次但返回两次。两次返回的唯一区别是子进程中返回0值而父进程中返回子进程ID。如果fork函数出现错误,则返回一个负值。如果初始参数或者传入的变量不同,父子进程也可以做不同的事。

一个进程调用fork()函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己。

fork函数的大致流程如下:

结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程_第1张图片

 

写一个函数来展示fork函数的特殊之处:

 1 #include 
 2 #include 
 3 
 4 int main()
 5 {
 6     int pid = fork();
 7     
 8     if (pid == -1)
 9         return -1;
10         
11     if(pid)
12     {
13         printf("father pid %d\n", getpid());
14         return 0;
15     }
16     else
17     {
18         printf("child pid %d\n", getpid());
19         return 0;
20     }
21 }

该函数的执行效果是判断自己是父进程还是子进程并分别打印自己的pid,执行效果如下:

 

 可以看到父子进程都分别打印了自己的pid,也印证了之前的fork调用一次,但返回两次的说法。

查看do_fork的源码:

 1 long do_fork(unsigned long clone_flags,
 2     unsigned long stack_start,
 3     unsigned long stack_size,
 4     int __user *parent_tidptr,
 5     int __user *child_tidptr)
 6 {
 7     struct task_struct *p;
 8     int trace = 0;
 9     long nr;
10 
11     /*
12     * Determine whether and which event to report to ptracer.  When
13     * called from kernel_thread or CLONE_UNTRACED is explicitly
14     * requested, no event is reported; otherwise, report if the event
15     * for the type of forking is enabled.
16     */
17     if (!(clone_flags & CLONE_UNTRACED)) {
18         if (clone_flags & CLONE_VFORK)
19             trace = PTRACE_EVENT_VFORK;
20         else if ((clone_flags & CSIGNAL) != SIGCHLD)
21             trace = PTRACE_EVENT_CLONE;
22         else
23             trace = PTRACE_EVENT_FORK;
24     
25         if (likely(!ptrace_event_enabled(current, trace)))
26             trace = 0;
27     }
28     
29     p = copy_process(clone_flags, stack_start, stack_size,
30         child_tidptr, NULL, trace);
31     /*
32     * Do this prior waking up the new thread - the thread pointer
33     * might get invalid after that point, if the thread exits quickly.
34     */
35     if (!IS_ERR(p)) {
36         struct completion vfork;
37         struct pid *pid;
38     
39         trace_sched_process_fork(current, p);
40     
41         pid = get_task_pid(p, PIDTYPE_PID);
42         nr = pid_vnr(pid);
43     
44         if (clone_flags & CLONE_PARENT_SETTID)
45             put_user(nr, parent_tidptr);
46     
47         if (clone_flags & CLONE_VFORK) {
48             p->vfork_done = &vfork;
49             init_completion(&vfork);
50             get_task_struct(p);
51         }
52     
53         wake_up_new_task(p);
54     
55         /* forking complete and child started to run, tell ptracer */
56         if (unlikely(trace))
57             ptrace_event_pid(trace, pid);
58     
59         if (clone_flags & CLONE_VFORK) {
60             if (!wait_for_vfork_done(p, &vfork))
61                 ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
62         }
63     
64         put_pid(pid);
65     } else {
66         nr = PTR_ERR(p);
67     }
68     return nr;
69 
70 }

可以看到,第29行copy_process(clone_flags, stack_start, stack_size, 30 child_tidptr, NULL, trace);函数生成了新的进程。

copy_process所做的工作如下:

  1. 定义返回值亦是retval和新的进程描述符task_struct结构p。
  2. 标志合法性检查。对clone_flags所传递的标志组合进行合法性检查。
  3. 安全性检查。通过调用security_task_create()和后面的security_task_alloc()执行所有附加的安全性检查。询问 Linux Security Module (LSM) 看当前任务是否可以创建一个新任务。LSM是SELinux的核心。
  4. 复制进程描述符。通过dup_task_struct()为子进程分配一个内核栈、thread_info结构和task_struct结构。
  5. 一些初始化。通过诸如ftrace_graph_init_task,rt_mutex_init_task完成某些数据结构的初始化。调用copy_creds()复制证书(复制权限及身份信息)。
  6. 检测系统中进程的总数量是否超过了max_threads所规定的进程最大数。
  7. 复制标志。通过copy_flags,将从do_fork()传递来的的clone_flags和pid分别赋值给子进程描述符中的对应字段。
  8. 初始化子进程描述符。初始化其中的各个字段,使得子进程和父进程逐渐区别出来。这部分工作包含初始化子进程中的children和sibling等队列头、初始化自旋锁和信号处理、初始化进程统计信息、初始化POSIX时钟、初始化调度相关的统计信息、初始化审计信息。
  9. 调度器设置。调用sched_fork函数执行调度器相关的设置,为这个新进程分配CPU,使得子进程的进程状态为TASK_RUNNING。并禁止内核抢占。并且,为了不对其他进程的调度产生影响,此时子进程共享父进程的时间片。
  10. 复制进程的所有信息。根据clone_flags的具体取值来为子进程拷贝或共享父进程的某些数据结构。比如copy_semundo()、复制开放文件描述符(copy_files)、复制符号信息(copy_sighand 和 copy_signal)、复制进程内存(copy_mm)以及最终复制线程(copy_thread)。
  11. 复制线程。通过copy_threads()函数更新子进程的内核栈和寄存器中的值。在之前的dup_task_struct()中只是为子进程创建一个内核栈,至此才是真正的赋予它有意义的值。
  12. 分配pid。用alloc_pid函数为这个新进程分配一个pid,Linux系统内的pid是循环使用的,采用位图方式来管理。简单的说,就是用每一位(bit)来标示该位所对应的pid是否被使用。分配完毕后,判断pid是否分配成功。成功则赋给p->pid。
  13. 更新属性和进程数量。根据clone_flags的值继续更新子进程的某些属性。将 nr_threads加一,表明新进程已经被加入到进程集合中。将total_forks加一,以记录被创建进程数量。
  14. 如果上述过程中某一步出现了错误,则通过goto语句跳到相应的错误代码处;如果成功执行完毕,则返回子进程的描述符p。

 copy_process()执行完后返回do_fork(),do_fork()执行完毕后,虽然子进程处于可运行状态,但是它并没有立刻运行。至于子进程何时执行则取决于调度程序。

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

以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析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内核的一般执行过程)