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

1、普通系统调用

系统调用是一种特殊的中断,中断分外部中断(硬件中断)和内部中断(软件中断),内部中断?称为异常(Exception),异常?分为故障(fault)和陷阱(trap),系统调?就是利?陷阱(trap)这种软件中断?式是主动从?户态进?内核态的。但是,一般从用户态进入内核态,是由两种方式触发,第一种是硬件中断,就是当硬件中断信号来到的时候,就会执行这个中断对应的中断服务例程。第二种是用户态程序在执行的过程当中,调用了一个系统调用,产生了一个内部中断,陷入内核态,也称为陷阱。

C库函数内部使用了系统调用的封装例程,所以当用户态程序调用一个系统调用时,该封装例程发布系统调用,通过特定的陷阱向内核发出服务请求,且int 0x80和sysacall指令会触发一个系统调用,因为这条汇编指令时产生中断向量为128的编程异常。CPU切换到内核态,并开始执行system_call对应汇编代码,也就是entry_INT80_32或者entry_SYSCALL_64(分别对应于int 0x80和syscall),并根据系统调用号,调用对应的内核处理函数,用户态程序通过给EAX寄存器传递一个名为系统调用号的参数来告知CPU应该执行哪个系统调用,除此之外,系统调用也需要传递其他参数。32位x86体系结构下,系统调用通过寄存器的方式传递参数,除了EAX传递系统调用号之外,参数依次赋值给EBX、ECX、EDX、ESI、EDI、EBP。在64位x86体系结构下,系统调用也是通过寄存器传递参数,使用RDI、RSI、RDX、RCX、R8、R9这6个寄存器作为参数传递寄存器。

int $0x80指令或syscall指令触发系统调?机制会在堆栈上保存?些寄存器的值,会保存中断发?时当前执?程序的栈顶地址(ESP、RSP)、当时的状态字(EFlags、RFlags)、当时的 CS:EIP/RIP 的值。同时会将当前进程内核态的栈顶地址、内核态的状态字放? CPU 对应的寄存器,并且 CS:EIP/RIP 寄存器的值会指向中断处理程序的??,对于系统调?来讲是指向系统调?处理的??。系统调用的内核堆栈展示如下:

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

中断是在?个进程当中从进程的?户态到进程的内核态,或从进程的内核态返回到进程的?户态,中断上下?代表当前进程执?,所以中断上下?中的get_current可获取?个指向当前进程描述符的指针,即指向被中断进程,相应的中断上下?切换的信息存储于该进程的内核堆栈中。中断有多种类型,?如有不可屏蔽中断、可屏蔽中断、异常、陷阱(系统调?)等。

  进程上下?切换时需要保存要切换进程的相关信息(如thread.sp与thread.ip),这与中断上下?的切换是不同的。中断上下文切换发生在进入内核态或者从内核态回到用户态的过程中,但切换进程需要在不同的进程间切换。但?般进程上下?切换是嵌套到中断上下?切换中的,?如前述系统调?作为?种中断先陷?内核,即发?中断保存现场和系统调?处理过程。其中调?了schedule函数发?进程上下?切换,当系统调?返回到?户态时会恢复现场,?此完成了保存现场和恢复现场,即完成了中断上下?切换。
 

2、fork系统调用

  fork?进程的内核堆栈示意图中struct pt_regs就是内核堆栈中保存的中断上下?,struct inactive_task_frame就是fork?进程的进程上下?。__switch_to_asm汇编代码中完成内核堆栈切换后的代码,正好与struct inactive_task_frame对应??出栈。fork子进程的内核堆栈展示如下,对应的文件位置是/arch/x86/include/asm/switch_to.h:

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

  在文件/kernel/fork.c中,有如下声明,通过上?的代码可以看出fork创建一个新进程,是通过_do_fork函数来创建进程的,传递结构体kernel_clone_args类型变量args就行。

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
}

_do_fork具体进程的创建?概就是把当前进程的描述符等相关进程资源复制?份,从?产??个?进程,并根据?进程的需要对复制的进程描述符做?些修改,然后把创建好的?进程放?运?队列(操作系统原理中的就绪队列),更细致点说,它主要完成了调?copy_process()复制?进程、获得pid、调?wake_up_new_task将?进程加?就绪队列等待调度执?等,它在/kernel/fork.c中有如下定义:

long _do_fork(struct kernel_clone_args *args) 
{ 
                  //复制进程描述符和执?时所需的其他数据结构
  p = copy_process(NULL, trace, NUMA_NO_NODE, args); 
  wake_up_new_task(p);    //将?进程添加到就绪队列
  return nr;          //返回?进程pid(?进程中fork返回值为?进程的pid) 
}
  因此,可以总结,fork系统调用的执行过程?致是当前进程通过调用fork()系统调?函数进?内核态,执行_do_fork函数,如下图所示复制进程描述符pid及相关进程资源(采?写时复制技术)、调?copy_thread_tls初始化?进程内核栈、设置?进程pid等。其中最关键的就是dup_task_struct复制当前进程(?进程)描述符task_struct和copy_thread_tls初始化?进程内核栈。最后将?进程 放?就绪队列,fork系统调?返回;??进程则在被调度执?时根据设置的内核堆栈和thread等进程关键上下?开始执?。


  写一段代码a.c来验证一下:
#include <stdio.h>
#include <stdlib.h>

#include <sys/types.h>
#include <unistd.h>

int main(void)
{
    int count = 1;
    int child;

    child = fork();

    if (child < 0)
    {
        perror("error fork");
    }
    else if (child == 0)
    {
        printf("Child process  : %d (%p), %d\n", ++count, &count, getpid());
    }
    else
    {
        printf("Parent process : %d (%p), %d\n", count, &count, getpid());
    }

    return EXIT_SUCCESS;
}
1 Parent process : 1 (0x7ffeeeb5e7c8), 9408
2 Child process  : 2 (0x7ffeeeb5e7c8), 9409
  结果显示如上,可见,fork?个?进程的过程中,复制?进程的资源时采?了Copy On Write(写时复制)技术,不需要修改的进程资源??进程是共享内存存储空间的。事实显示的确如此,不同于普通的系统调用,fork系统调用的一个奇妙之处就是函数仅仅被调用一次,却能够返回两次,有两个返回结果,而且它可能有三种不同的返回值:
    1)在父进程中,fork返回新创建子进程的进程ID;    2)在子进程中,fork返回0;    3)如果出现错误,fork返回一个负值;
    在fork函数执行完毕后,如果创建新进程成功,则出现两个进程,一个是子进程,一个是父进程。在子进程中,fork函数返回0,在父进程中,fork返回新创建子进程的进程ID。我们可以通过fork返回的值来判断当前进程是子进程还是父进程,辨别方法如上述。

3、分析execve系统调用
  execve() 系统调用的作用是运行另外一个指定的程序。它会把新程序加载到当前进程的内存空间内,当前的进程会被丢弃,当前进程的堆、栈和所有的段数据都会被新进程相应的部分代替,然后会从新程序的初始化代码和main函数开始运行。但是,进程的ID将保持不变。函数原型是sys_execve(),sys_execve()根据参数中指定的二进制文件路径,执行相应的二进制文件。在tools/include/nolibc/nolibc.h中sys_execve函数有有如下定义:

int sys_execve(const char *filename, char *const argv[], char *const envp[])
{
  return my_syscall3(__NR_execve, filename, argv, envp);
}
  在tools/include/nolibc/nolibc.h文件中,my_syscall3函数有如下定义,num中记录系统调用号,其余则是触发该系统调用的各种参数。

 1 #define my_syscall3(num, arg1, arg2, arg3)                                     2 ({                                                                             3     long _ret;                                                             4     register long _num  asm("rax") = (num);                                5     register long _arg1 asm("rdi") = (long)(arg1);                         6     register long _arg2 asm("rsi") = (long)(arg2);                         7     register long _arg3 asm("rdx") = (long)(arg3);                         8                                            9     asm volatile (                                                        10         "syscall\n"                                                   11         : "=a" (_ret)                                                 12         : "r"(_arg1), "r"(_arg2), "r"(_arg3),                         13           "0"(_num)                                                   14         : "rcx", "r8", "r9", "r10", "r11", "memory", "cc"             15     );                                                                    16     _ret;                                                                 17 })
  写一段程序验证一点,可以发现execve系统调用的确用被加载的进程替换掉原来的进程。如下图程序及运行结果所示,第23行,没有执行,是因为子进程被加载的系统调用给替换掉了,所以不会出现子进程的运行过程,而且execve函数执行成功后不会返回,而且代码段,数据段,bss段和调用进程的栈会被被加载进来的程序覆盖掉:

#include <stdio.h>
#include <stdlib.h>

#include <sys/types.h>
#include <unistd.h>

int main(void)
{
    const char *oldpath = "execl_ln.c";
    const char *newpath = "a.c";
    int count = 1;
    int child;

    child = fork();

    if (child < 0)
    {
        perror("error fork");
    }
    else if (child == 0)
    {
        execlp("ls", "ls", NULL);
        printf("Child process  : %d (%p), %d\n", ++count, &count, getpid());
    }
    else
    {
        printf("Parent process : %d (%p), %d\n", count, &count, getpid());
    }

    return EXIT_SUCCESS;
}
 
Parent process : 1 (0x7ffee5ab97d4), 11041
a
a.c
execl_ln
execl_ln.c
forktest
ln.s

事实上,内核中实际执行execv()或execve()系统调用的程序是do_execve(),这个函数先打开目标映像文件,并从目标文件的头部(第一个字节开始)读入若干字节,然后调用另一个函数search_binary_handler(),在此函数里面,它会搜索我们上面提到的Linux支持的可执行文件类型队列,让各种可执行程序的处理程序前来认领和处理。如果类型匹配,则调用load_binary函数指针所指向的处理函数来处理目标映像文件。在ELF文件格式中,处理函数是load_elf_binary函数,调用链为sys_execve()->do_execve()->do_execveat_common()->__do_execve_file()->exec_binprm()->search_binary_handler()->load_binary(),代码段展示如下:

int do_execve(struct filename *filename,
    const char __user *const __user *__argv,
    const char __user *const __user *__envp)
{
    struct user_arg_ptr argv = { .ptr.native = __argv };
    struct user_arg_ptr envp = { .ptr.native = __envp };
    return do_execveat_common(AT_FDCWD, filename, argv, envp, 0);
}
static int do_execveat_common(int fd, struct filename *filename,
                  struct user_arg_ptr argv,
                  struct user_arg_ptr envp,
                  int flags)
{
    return __do_execve_file(fd, filename, argv, envp, flags, NULL);
}
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;
    struct linux_binprm *bprm;
    struct files_struct *displaced;
    int retval;
    /* We‘re below the limit (still or again), so we don‘t want to make
     * further execve() calls fail. */
    current->flags &= ~PF_NPROC_EXCEEDED;

        ......

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

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

    /* execve succeeded */

    return retval;
        ......          
}
static int exec_binprm(struct linux_binprm *bprm)
{
    pid_t old_pid, old_vpid;
       
    ......
    int ret;
    ret = search_binary_handler(bprm);
    
    ......
    return ret;
}
int search_binary_handler(struct linux_binprm *bprm)
{
    bool need_retry = IS_ENABLED(CONFIG_MODULES);
    struct linux_binfmt *fmt;
    int retval;
        
        ......
    retval = security_bprm_check(bprm);//检查是否具有运行权限
    if (retval)
        return retval;

    retval = -ENOENT;
 retry:
    read_lock(&binfmt_lock);
    list_for_each_entry(fmt, &formats, lh) {//尝试每一种格式的解析函数
        if (!try_module_get(fmt->module))
            continue;
        read_unlock(&binfmt_lock);

        bprm->recursion_depth++;
        retval = fmt->load_binary(bprm);//最关键的步骤,调用合适格式的处理函数加载该可执行文件
        bprm->recursion_depth--;

                .........
    return retval;
}

在load_binary函数中,加载进来的可执行文件,也就是系统调用"ls",将把当前正在执行的进程的内存空间,也就是fork出来的子进程,完全覆盖掉,由于当前的进程都会被新加载进来的ls系统调用程序完全替换掉,所以我们的测试程序的第23行没有在terminal上打印信息。


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

  中断和中断返回有中断上下?的切换,CPU和内核代码中断处理程序??的汇编代码结合起来完成中断上下?的切换。进程调度过程中有进程上下?的切换,?进程上下?的切换完全由内核完成,具体包括:从?个进程的地址空间切换到另?个进程的地址空间;从?个进程的内核堆栈切换到另?个进程的内核堆栈;还有进程的CPU上下?的切换。 
  中断上下?和进程上下?的?个关键区别是堆栈切换的?法。中断是由CPU实现的,所以中断上下?切换过程中最关键的栈顶寄存器sp和指令指针寄存器ip是由CPU协助完成的;进程切换是由内核实现的,所以进程上下?切换过程中最关键的栈顶寄存器sp切换是通过进程描述符的thread.sp实现的,指令指针寄存器ip的切换是在内核堆栈切换的基础上巧妙利?call/ret指令实现的。一个完整的linux系统运行过程,可描述如下:
 
  1 正在运?的?户态进程X发?中断时(包括异常、系统调?等),CPU完成load cs:rip(entry of a specifific ISR),即跳转到中断处理程序??。然后进入到中断上下文切换的步骤:
  [1] swapgs指令保存现场,可以理解CPU通过swapgs指令给当前CPU寄存器状态做了?个快照。
  [2] rsp point to kernel stack,加载当前进程内核堆栈栈顶地址到RSP寄存器。快速系统调?是由系统调???处的汇编代码实现?户堆栈和内核堆栈的切换。
  [3] save cs:rip/ss:rsp/rflflags:将当前CPU关键上下?压?进程X的内核堆栈,快速系统调?是由系统调???处的汇编代码实现的。
     至此,完成了中断上下?切换,即从进程X的?户态到进程X的内核态。
  2 中断处理过程中或中断返回前调?了schedule函数,其中完成了进程调度算法选择next进程、进程地址空间切换、以及switch_to关键的进程上下?切换等。
  3 switch_to调?了__switch_to_asm汇编代码做了关键的进程上下?切换。将当前进程X的内核堆栈切换到进程调度算法选出来的next进程(假定为进程Y)的内核堆栈,并完成了进程上下?所需的指令指针寄存器状态切换。之后开始运?进程Y。
  4 中断上下?恢复,与1中断上下?切换相对应。注意这?是进程Y的中断处理过程中,?1中断上下?切换是在进程X的中断处理过程中,因为从用户态切换到内核态,会导致内核堆栈从进程X切换到进程Y了。
  5 为了对应起?中断上下?恢复的最后?步,单独拿出来iret - pop cs:rip/ss:rsp/rflflags,从Y进程的内核堆栈中弹出1中对应的压栈内容。此时完成了中断上下?的切换,即从进程Y的内核态返回到进程Y的?户态。这里因sysret和iret的不同而略有差异。
  6 继续运??户态进程Y。
 
 

相关推荐