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

Posted 公元1864

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程相关的知识,希望对你有一定的参考价值。

一,实验目的:

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

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

二,实验过程:

fork系统调用为例分析中断上下文的切换

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

  fork系统调用用于从已存在进程中创建一个新进程,新进程称为子进程,而原进程称为父进程。fork调用一次,返回两次,这两个返回分别带回它们各自的返回值,其中在父进程中的返回值是子进程的进程号,而子进程中的返回值则返回 0。因此,可以通过返回值来判定该进程是父进程还是子进程。使用fork函数得到的子进程是父进程的一个复制品,它从父进程处继承了整个进程的地址空间,包括进程上下文、进程堆栈、内存信息、打开的文件描述符、信号控制设定、进程优先级、进程组号、当前工作目录、根目录、资源限制、控制终端等,而子进程所独有的只有它的进程号、计时器等。因此可以看出,使用fork系统调用的代价是很大的,它复制了父进程中的数据段和堆栈段里的绝大部分内容,使得fork系统调用的执行速度并不很快。

  do_fork的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
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;
 
    // ...
 
    // 复制进程描述符,返回创建的task_struct的指针
    p = copy_process(clone_flags, stack_start, stack_size,
             child_tidptr, NULL, trace);
 
    if (!IS_ERR(p)) {
        struct completion vfork;
        struct pid *pid;
 
        trace_sched_process_fork(current, p);
 
        // 取出task结构体内的pid
        pid = get_task_pid(p, PIDTYPE_PID);
        nr = pid_vnr(pid);
 
        if (clone_flags & CLONE_PARENT_SETTID)
            put_user(nr, parent_tidptr);
 
        // 如果使用的是vfork,那么必须采用某种完成机制,确保父进程后运行
        if (clone_flags & CLONE_VFORK) {
            p->vfork_done = &vfork;
            init_completion(&vfork);
            get_task_struct(p);
        }
 
        // 将子进程添加到调度器的队列,使得子进程有机会获得CPU
        wake_up_new_task(p);
 
        // ...
 
        // 如果设置了 CLONE_VFORK 则将父进程插入等待队列,并挂起父进程直到子进程释放自己的内存空间
        // 保证子进程优先于父进程运行
        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复制父进程、获得pid、调用wake_up_new_task将子进程加入就绪队列等待调度执行等。我们知道,在Linux中,除了0号进程由手工创建外,其他进程都是通过复制已有进程创建而来,而这正是fork的主要工作,具体的任务交由copy_process完成。

简单流程如下所示:

  copy_process函数主要完成了调用dup_task_struct复制当前进程(父进程)描述符task_struct、信息检查、初始化、把进程状态设置为TASK_RUNNING(此时⼦进程置为就绪态)、采⽤写时复制技术逐⼀复制所有其他进程资源、调⽤copy_thread_tls初始化子进程内核栈、设置子进程pid等。其中copy_thread_tls所做的工作是关键。我们知道执行fork系统调用之后,会由内核态返回两次:一次返回到父进程,这与一般的系统调用返回流程别无二致;而另一次则返回到子进程,为了实现这一点,就需要为子进程构造出合适的执行上下文,也就是初始化其内核栈和进程描述符的thread字段。这正是copy_thread_tls的任务。

  ⽗进程通过fork系统调⽤进⼊内核_do_fork函数,复制进程描述符及相关进程资源(采⽤写时复制技术)、分配⼦进程的内核堆栈并对内核堆栈和thread等进程关键上下⽂进⾏初始化,最后将⼦进程放⼊就绪队列,fork系统调⽤返回;⽽⼦进程则在被调度执⾏时根据设置的内核堆栈和thread等进程关键上下⽂开始执⾏。

特殊之处:

  fork在陷⼊内核态之后有两次返回,第⼀次返回到原来的⽗进程的位置继续执⾏,但是在⼦进程中fork也返回了⼀次,会返回到⼀个特 定的点——ret_from_fork,所以它可以正常系统调⽤返回到⽤户态。

  

分析execve系统调用:

execve系统调用简介:

execve系统调⽤接⼝函数的函数原型如下:

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

filename为可执⾏⽂件的名字,argv是以NULL结尾的命令⾏参数数 组,envp同样是以NULL结尾的环境变量数组(使⽤命令man execve,可查看其说明)

 

evecve工作流程:

 Linux系统⼀般会提供了execl、execlp、execle、execv、execvp和execve等6个⽤以加载执⾏ ⼀个可执⾏⽂件的库函数,这些库函数统称为exec函数,差异在于对命令⾏参数和环境变量参数 的传递⽅式不同。exec函数都是通过execve系统调⽤进⼊内核,对应的系统调⽤内核处理函数为 sys_execve或__x64_sys_execve,它们都是通过调⽤do_execve来具体执⾏加载可执⾏⽂件的 ⼯作。

整体的调⽤关系为sys_execve()或__x64_sys_execve -> do_execve() –> do_execveat_common() -> __do_execve_file -> exec_binprm()-> search_binary_handler() -> load_elf_binary() -> start_thread()。

 

 上下文环境切换流程:

先看一下再进行evecve系统调用时有哪些上下文环境:

 

  在布局⼀个新的⽤户态堆栈时,实际上是把命令⾏参数内容和环境变量的内容通过指针的⽅ 式传到系统调⽤内核处理函数,再创建⼀个新的⽤户态堆栈时会把这些char *argcv[]和char *envp[]等复制到⽤户态堆栈中,来初始化这个新的可执⾏程序的执⾏上下⽂环境。所以新 的程序可以从main函数开始把对应的参数接收过来,然后执⾏。 值得注意的是,在调⽤execve系统调⽤时,当前的执⾏环境是从⽗进程复制过来的, execve系统调⽤加载完新的可执⾏程序之后已经覆盖了原来⽗进程的上下⽂环境。execve 系统调⽤在内核中帮我们重新布局了新的⽤户态执⾏环境。 执⾏readelf -h可以查看ELF可执⾏⽂件⾸部信息,如下所示程序⼊⼝点Entry point address:0x804887f。如果是静态链接程序在execve系统调⽤加载完成后,堆栈上的返回地 址会修改为程序⼊⼝点的地址。当系统调⽤从内核态返回时,会从该地址0x804887f继续执 ⾏。

对于 execve 系统调用,最主要的处理过程都在 do_execve_common() 函数中,以下为该函数的主要部分:

复制代码
static int do_execve_common(struct filename *filename,struct user_arg_ptr argv,struct user_arg_ptr envp)
{
    struct linux_binprm *bprm;  // 用于解析ELF文件的结构 
    struct file *file;
    struct files_struct *displaced;
    int retval;
    current->flags &= ~PF_NPROC_EXCEEDED;  // 标记程序已被执行
    retval = unshare_files(&displaced);  // 拷贝当前运行进程的fd到displaced中
    bprm = kzalloc(sizeof(*bprm), GFP_KERNEL);
    retval = prepare_bprm_creds(bprm);   // 创建一个新的凭证
    check_unsafe_exec(bprm);             // 安全检查
    current->in_execve = 1;
    file = do_open_exec(filename);       // 打开要执行的文件
    sched_exec();
    bprm->file = file;
    bprm->filename = bprm->interp = filename->name;
    retval = bprm_mm_init(bprm);       // 为ELF文件分配内存
    bprm->argc = count(argv, MAX_ARG_STRINGS);
    bprm->envc = count(envp, MAX_ARG_STRINGS);
    retval = prepare_binprm(bprm);     // 从打开的可执行文件中读取信息,填充bprm结构
     // 下面的4句是将运行参数和环境变量都拷贝到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);
    // 开始执行加载到内存中的ELF文件
    <strong>retval = exec_binprm(bprm);</strong>
    // 执行完毕
    current->fs->in_exec = 0;
    current->in_execve = 0;
    acct_update_integrals(current);
    task_numa_free(current);
    free_bprm(bprm);
    putname(filename);
    if (displaced)
        put_files_struct(displaced);
    return retval;
}
复制代码

其中最关键的exec_binprm()函数具体如下:

复制代码
static int exec_binprm(struct linux_binprm *bprm)
{
    pid_t old_pid, old_vpid;   
    int ret;
    old_pid = current->pid;
    rcu_read_lock();
    old_vpid = task_pid_nr_ns(current, task_active_pid_ns(current->parent));
    rcu_read_unlock();
    <strong>ret = search_binary_handler(bprm);</strong>
    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;
}   
复制代码

其中,search_binary_handler() 函数实现了核心功能,即当前正在执行的进程内存空间会被加载进来的可执行程序所覆盖,并根据具体情况指向新可执行程序。若新的可执行程序为静态链接的文件,main函数的入口地址为新进程的 IP 寄存器所指向的值;若为动态链接,IP 值为加载器 ld 的入口地址,ld 负责动态链接库的处理工作。

 

do_execve主要流程如下:

 evecve的特别之处:

当前的可执⾏程序在执⾏,执⾏到execve系统调⽤时陷⼊内核态,在内 核⾥⾯⽤do_execve加载可执⾏⽂件,把当前进程的可执⾏程序给覆盖掉。当execve系统调⽤返回 时,返回的已经不是原来的那个可执⾏程序了,⽽是新的可执⾏程序。execve返回的是新的可执⾏ 程序执⾏的起点,静态链接的可执⾏⽂件也就是main函数的⼤致位置,动态链接的可执⾏⽂件还需 要ld链接好动态链接库再从main函数开始执⾏。

三,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

   Linux系统执行过程中的几个特殊情况

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

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

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

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

 

以上是关于结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程的主要内容,如果未能解决你的问题,请参考以下文章

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

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

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

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

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

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