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

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

1. fork系统调用过程

fork()函数又叫计算机程序设计中的分叉函数,fork是一个很有意思的函数,它可以建立一个新进程,把当前的进程分为父进程和子进程,新进程称为子进程,而原进程称为父进程。fork调用一次,返回两次,这两个返回分别带回它们各自的返回值,其中在父进程中的返回值是子进程的PID,而子进程中的返回值则返回 0。其实就相当于链表,进程形成了链表,父进程的fpid(p 意味point)指向子进程的进程id, 因为子进程没有子进程,所以其fpid为0.因此,可以通过返回值来判定该进程是父进程还是子进程。还有一个很奇妙的是:fork函数将运行着的程序分成2个(几乎)完全一样的进程,每个进程都启动一个从代码的同一位置开始执行的线程。这两个进程中的线程继续执行,就像是两个用户同时启动了该应用程序的两个副本。
新创建的子进程几乎但是不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同(但是独立)的一份拷贝,包括文本,数据和bss段、堆以及用户栈。子进程还获得与父进程任何打开文件描述符相同的拷贝。这就是意味着当父进程调用fork时候,子进程还可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大区别在于他们有着不同的PID。
系统调用fork在内核中对应的服务例程分别为sys_fork()。sys_fork()声明如下(arch/x86/kernel/process.c):

int sys_fork(struct pt_regs *regs)
{
        return do_fork(SIGCHLD, regs->sp, regs, 0, NULL, NULL);
}

可以看到do_fork()是kernel创建进程的核心。通过分析调用过程如下、在Linux操作系统中采取0x80中断调用syscall:

从图中可以看到do_fork()和copy_process()是主要分析对象。
do_fork函数的主要就是复制原来的进程成为另一个新的进程,在一开始,该函数定义了一个task_struct类型的指针p,用来接收即将为新进程(子进程)所分配的进程描述符。但是这个时候要检查clone_flags是否被跟踪就是ptrace,ptrace是用来标示一个进程是否被另外一个进程所跟踪。所谓跟踪,最常见的例子就是处于调试状态下的进程被debugger进程所跟踪。ptrace字段非0时说明debugger程序正在跟踪父进程,那么接下来通过fork_traceflag函数来检测子进程是否也要被跟踪。如果trace为1,那么就将跟踪标志CLONE_PTRACE加入标志变量clone_flags中。没有的话才可以进程创建,也就是copy_process()。

long _do_fork(unsigned long clone_flags,
              unsigned long stack_start,
              unsigned long stack_size,
              int __user *parent_tidptr,
              int __user *child_tidptr,
              unsigned long tls)
{
        struct task_struct *p;
        int trace = 0;
        long nr;
        if (!(clone_flags & CLONE_UNTRACED)) {
                if (clone_flags & CLONE_VFORK)
                        trace = PTRACE_EVENT_VFORK;
                else if ((clone_flags & CSIGNAL) != SIGCHLD)
                        trace = PTRACE_EVENT_CLONE;
                else
                        trace = PTRACE_EVENT_FORK;
                if (likely(!ptrace_event_enabled(current, trace)))
                        trace = 0;
        }

这条语句要做的是整个创建过程中最核心的工作:通过copy_process()创建子进程的描述符,并创建子进程执行时所需的其他数据结构,最终则会返回这个创建好的进程描述符。

p = copy_process(clone_flags, stack_start, stack_size,
                       child_tidptr, NULL, trace, tls);

内核通过调用函数copy_process()创建进程,copy_process()函数主要用来创建子进程的描述符以及与子进程相关数据结构。如果copy_process函数执行成功,那么将继续下面的代码。定义了一个完成量vfork,之后再对vfork完成量进行初始化。如果使用vfork系统调用来创建子进程,那么必然是子进程先执行。原因就是此处vfork完成量所起到的作用:当子进程调用exec函数或退出时就向父进程发出信号。此时,父进程才会被唤醒;否则一直等待。

if (!IS_ERR(p)) {
               struct completion vfork;
               struct pid *pid;
 
               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, parent_tidptr);
               if (clone_flags & CLONE_VFORK) {
                    p->vfork_done = &vfork;
                       init_completion(&vfork);
                       get_task_struct(p);
               }

下面通过wake_up_new_task函数使得父子进程之一优先运行;如果设置了ptrace,那么需要告诉跟踪器。如果CLONE_VFORK标志被设置,则通过wait操作将父进程阻塞,直至子进程调用exec函数或者退出。

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);
} else {
                nr = PTR_ERR(p);
        }
        return nr;
}

如果copy_process()在执行的时候发生错误,则先释放已分配的pid;再根据PTR_ERR()的返回值得到错误代码,保存于pid中。 返回pid。这也就是为什么使用fork系统调用时父进程会返回子进程pid的原因。

2. execve() 系统调用

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

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

系统调用execve的内核入口为sys_execve,定义在<arch/kernel/process.c>中:

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

sys_execve的核心是调用do_execve函数,传给do_execve的第一个参数是已经拷贝到内核空间的路径名filename,第二个和第三个参数仍然是系统调用execve的第二个参数argv和第三个参数envp,它们代表的传给可执行文件的参数和环境变量仍然保留在用户空间中。

do_execve主要流程

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

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

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

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

四、以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析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、继续运行用户态进程X。

posted @ 2020-06-14 16:32  浅安时光~  阅读(178)  评论(0编辑  收藏  举报