Linux内核分析(六):进程的描述和进程的创建

来源:互联网 发布:网络小额贷款平台 编辑:程序博客网 时间:2024/06/07 13:41

何天杨+ 原创作品转载请注明出处 + 《Linux内核分析》MOOC课程http://mooc.study.163.com/course/USTC-1000029000

一、Linux中的进程简析

进程是具有多道程序设计的操作系统的基本概念,关于进程的定义就是程序执行的一个实例,也是系统资源调度的最小单位。如果同一个程序被多个用户同时运行,那么这个程序就有多个相对独立的进程,与此同时他们又共享相同的执行代码。在Linux系统中进程的概念类似于任务或者线程(task & threads)。

实际上我们说进程是一个程序运行时候的一个实例实际上说的是它就是一个可以充分描述程序以达到了其可以运行状态的的一个数据和代码集合。一个进程会被产生并会复制出自己的子代,类似细胞分裂一样。从系统的角度来看进程的任务实际上就是担当承载系统资源的单位,系统在调度和分配资源的时候也会以他们作为基本单位开始进行分配。(系统中的资源很多例如CPU的时间片、内存堆栈等等)

程序或者进程执行的时候会需要创建新的实例,这个时候A如果新创建了B那么A就是B的父进程。创建一个新进程的时候,实际上系统就是在复制他的父进程。实际上就是复制了几乎所有父进程的信息包括代码。子进程接收父进程地址空间的一个逻辑拷贝,(实际上就是可以理解为面向对象中的类创建实例的过程或者继承父类的这种关系,实际上他们看起来域属性是一样的但是又不会完全一样,所以我们说这里面是逻辑上的一个复制)然后,这个子进程会从创建进程那个系统调用服务代码之后的下一条指令开始执行(ret_from_fork),执行代码与父进程是相同的。但是我们要知道实际上虽然AB都是指向相同的代码部分,但是正如我们知道的程序需要指令和数据,所以他们的数据拷贝是不同的,因此进程对一个内存单元的修改在AB之间是不可见的。以上是早期的时候情况,现代的系统实际上可能并不是这样的。在支持多线程应用的系统中很多拥有相对独立执行路径的用户程序共享应用程序的大部分数据结构。那么这样的话一个进程就是由几个用户线程组成,而且每一个执行线路就是一个线程。

那系统中的数据结构又是什么样子的呢?首先最应该知道就是系统如何管理这些进程,那么系统一定要有相应的数据结构去标识每一个进程以及他们的扩展数据结构。实际上这个结构就是我们在操作系统中所说的PCB(Process Control Block)在Linux中这个数据结构我们叫做task_struct,它实际上至少应该包括以下信息,比如优先级,它的运行状态,他所在的内存空间,它的文件访问权限等等。
这里写图片描述

实际上我们看到他的结构还是很冗长的,不仅仅包含了很多进程信息的标识字段,同时又有很多的指针指向很多附件的数据结构。图中列出来的包括进程的基本信息thread_info、内存区域描述mm_struct、与进程相关的tty tty_struct、当前的目录fs_struct 、文件描述符files_struct、所接受的信号singal_struct等等。

1.1进程的状态
进程执行时,它会根据具体情况改变状态 。进程状态是调度和对换的依据。Linux中的进程主要有如下状态(上面图中的那个state字段)
这里写图片描述
(1)运行态:进程正在使用CPU运行的状态。处于运行态的进程又称为当前进程(current process)。
(2)可运行态:进程已分配到除CPU外所需要的其它资源,等待系统把CPU分配给它之后即可投入运行。
(3)等待态:又称睡眠态,它是进程正在等待某个事件或某个资源时所处的状态。 等待态进一步分为可中断的等待态和不可中断的等待态。处于可中断等待态的进程可以由信号(signal)解除其等待态。处于不可中断等待态的进程,一般是直接或间接等待硬件条件。 它只能用特定的方式来解除,例如使用唤醒函数wake_up()等。

可中断的等待状态:进程被挂起,直到等到一个可以唤醒他的东西,例如一个硬件中断、某项系统资源、或一个信号量。当它等到这些唤醒条件的之后就会进入可运行状态。不可中断的等待:一种常见的状态就是这个进程正在访问一个独占的临界资源,这种时候处于一种不可抢占的状态。通常当进程接收到SIGSTOP、SIGTSTP、SIGTTIN或 SIGTTOU信号后就处于这种状态。例如,正接受调试的进程就处于这种状态。

(4)暂停态:进程需要接受某种特殊处理而止运行所处的状态。通常进程在接受到外部进程的某个信号进入暂停态,例如,正在接受调试的进程就处于这种状态。
(5)僵死状态
进程虽然已经终止,但由于某种原因,父进程还没有执行wait()系统调用,终止进程的信息也还没有回收。顾名思义,处于该状态的进程就是死进程,这种进程实际上是系统中的垃圾,必须进行相应处理以释放其占用的资源。
我们在设置这些状态的时候是可以直接用语句进行的比如:p—>state = TASK_RUNNING。同时内核也会使用set_task_state和set_current_state。
这里写图片描述

1.2关于thread_info和内核栈
进程是一个动态的东西,所以系统也是希望很有效率的进行管理。Linux内核把两个不同的数据结构紧凑的存放在一个单独为进程分配的内存区域中:一个是内核态的进程堆栈,另一个是紧挨着进程描述符的小数据结构thread_info,叫做线程描述符。实际上就像我们猜到的一样他们既然总是在一个8k的页中,所以基于Linux简洁优雅的特性这些信息的地址分配肯定也是基于8K对齐的(即起始地址都是8k整数倍)

为什么需要内核栈:
进程在内核态运行时需要自己的堆栈信息, 因此linux内核为每个进程都提供了一个内核栈kernel stack

struct task_struct{    // ...    void *stack;    //  指向内核栈的指针    // ...};

内核态的进程访问处于内核数据段的栈,这个栈不同于用户态的进程所用的栈。用户态进程所用的栈,是在进程线性地址空间中;而内核栈是当进程从用户空间进入内核空间时,特权级发生变化,需要切换堆栈,那么内核空间中使用的就是这个内核栈。因为内核控制路径使用很少的栈空间,所以只需要几千个字节的内核态堆栈。

需要注意的是,内核态堆栈仅用于内核例程,Linux内核另外为中断提供了单独的硬中断栈和软中断栈

为什么需要thread_info:
内核还需要存储每个进程的PCB信息, linux内核是支持不同体系的的, 但是不同的体系结构可能进程需要存储的信息不尽相同, 这就需要我们实现一种通用的方式, 我们将体系结构相关的部分和无关的部门进行分离
用一种通用的方式来描述进程, 这就是struct task_struct, 而thread_info就保存了特定体系结构的汇编代码段需要访问的那部分进程的数据,我们在thread_info中嵌入指向task_struct的指针, 则我们可以很方便的通过thread_info来查找task_struct

linux将内核栈和进程控制块thread_info融合在一起, 组成一个联合体thread_union
通常内核栈和thread_info一同保存在一个联合体中, thread_info保存了线程所需的所有特定处理器的信息, 以及通用的task_struct的指针

内核数据结构描述
thread_union

union thread_union{    struct thread_info thread_info;    unsigned long stack[THREAD_SIZE/sizeof(long)];};

这块区域32位上通常是8K=8192(占两个页框),64位上通常是16K,其实地址必须是8192的整数倍。
下图中显示了在物理内存中存放两种数据结构的方式。线程描述符驻留与这个内存区的开始,而栈顶末端向下增长。
这里写图片描述
在这个图中,esp寄存器是CPU栈指针,用来存放栈顶单元的地址。在80x86系统中,栈起始于顶端,并朝着这个内存区开始的方向增长。从用户态刚切换到内核态以后,进程的内核栈总是空的。因此,esp寄存器指向这个栈的顶端。一旦数据写入堆栈,esp的值就递减(向下递增)。

同时我们可以看到,thread_info和内核栈虽然共用了thread_union结构, 但是thread_info大小固定, 存储在联合体的开始部分, 而内核栈由高地址向低地址扩展, 当内核栈的栈顶到达thread_info的存储空间时, 则会发生栈溢出

系统的current指针指向了当前运行进程的thread_union(或者thread_info)的地址

进程task_struct中的stack指针指向了进程的thread_union(或者thread_info)的地址, 在早期的内核中这个指针用struct thread_info *thread_info来表示, 但是新的内核中用了一个更浅显的名字void *stack, 即内核栈

在Linux3.5.4内核中,thread_info结构是72个字节长(ULK3时代的内核中,这个结构的大小是52个字节),因此内核栈能扩展到8120个字节。thread_info结构的定义如下:

struct thread_info {    struct task_struct  *task;       /* main task structure */    struct exec_domain  *exec_domain;    /* execution domain */    __u32     flags;    /* low level flags */    __u32    status;   /* thread synchronous flags */    __u32    cpu;     /* current CPU */    int      preempt_count;   /* 0 => preemptable, <0 => BUG */    mm_segment_t            addr_limit;    struct restart_block     restart_block;    void __user             *sysenter_return;#ifdef CONFIG_X86_32    unsigned long previous_esp; /* ESP of the previous stack in                                   case of nested (IRQ) stacks                                   */    __u8                supervisor_stack[0];#endif    unsigned int        sig_on_uaccess_error:1;    unsigned int        uaccess_err:1;    /* uaccess failed */};

Linux内核中使用一个联合体来表示一个进程的线程描述符和内核栈:

#define THREAD_SIZE        8192  union thread_union {    struct thread_info thread_info;    unsigned long stack[THREAD_SIZE/sizeof(long)];};

下面来说说如何通过esp栈指针来获取当前在CPU上正在运行进程的thread_info结构。实际上,上面提到,thread_info结构和内核态堆栈是紧密结合在一起的,占据两个页框的物理内存空间。而且,这两个页框的起始起始地址是8K对齐的。那既然是8k的整数倍那么后13位就是一样的呢。屏蔽esp的低13位然后加上thread_info的页内偏移量就可以快速找到thread_info结构

所有的体系结构都必须实现两个current和current_thread_info的符号定义宏或者函数

current_thread_info可获得当前执行进程的thread_info实例指针, 其地址可以根据内核指针来确定, 因为thread_info总是位于起始位置,因为每个进程都有自己的内核栈, 因此进程到内核栈的映射是唯一的, 那么指向内核栈的指针通常保存在一个特别保留的寄存器中(多数情况下是esp)

current给出了当前进程进程描述符task_struct的地址,该地址往往通过current_thread_info来确定 current = current_thread_info()->task

因此我们的关键就是current_thread_info的实现了,即如何通过esp栈指针来获取当前在CPU上正在运行进程的thread_info结构。

早期的版本中,不需要对64位处理器的支持,所以,内核通过简单的屏蔽掉esp的低13位有效位就可以获得thread_info结构的基地址了。

早期版本如3.14的Linux/arch/x86/include/asm/thread_info.hstatic inline struct thread_info *current_thread_info(void){   return (struct thread_info *)        (current_stack_pointer & ~(THREAD_SIZE - 1)); }当前的栈指针(current_stack_pointer == sp)就是esp,THREAD_SIZE为8K,二进制的表示为0000 0000 0000 0000 0010 0000 0000 0000。current_thread_info(void)

而在3.18
linux/arch/x86/include/asm/thread_info.h中,有如下代码:

#ifndef __ASSEMBLY__DECLARE_PER_CPU(unsigned long, kernel_stack);static inline struct thread_info *current_thread_info(void){   struct thread_info *ti;   ti = (void *)(this_cpu_read_stable(kernel_stack) +   KERNEL_STACK_OFFSET - THREAD_SIZE);   return ti;}#else /* !__ASSEMBLY__ *//* how to get the thread information struct from ASM */ #define GET_THREAD_INFO(reg) \_ASM_MOV PER_CPU_VAR(kernel_stack),reg ; \_ASM_SUB $(THREAD_SIZE-KERNEL_STACK_OFFSET),reg ;/* Same if PER_CPU_VAR(kernel_stack) is, perhaps with some offset, already in a certain register (to be used in assembler memory operands)*/#define THREAD_INFO(reg, off) KERNEL_STACK_OFFSET+(off)-THREAD_SIZE(reg)#endif

kernel_stack per-CPU变量
用于指向当前CPU上运行的进程的内核栈,由于内核栈与thread_info是放在一起的,所以,内核中也用这个变量来获取当前进程的thread_info

进程最常用的是进程描述符结构task_struct而不是thread_info结构的地址。为了获取当前CPU上运行进程的task_struct结构,内核提供了current宏。由于task_struct *task在thread_info的起始位置,该宏本质上等价于current_thread_info()->task,
在include/asm-generic/current.h中定义:

#define get_current() (current_thread_info()->task)#define current get_current()

这个定义是体系结构无关的,当然linux也为各个体系结构定义了更加方便或者快速的current

如在3.18中对于x86结构,在/arch/x86/include/asm/current.h中定义:

static __always_inline struct task_struct *get_current(void) {    return this_cpu_read_stable(current_task); }#define current get_current()

1.3进程的创建
fork、vfork、clone三个系统调用都可以创建一个新进程,而且都是通过调用do_fork来实现的
1.子进程被创建后继承了父进程的资源。
2.子进程共享父进程的虚存空间。
3.写时拷贝 (copy on write):子进程在创建后共享父进程的虚存内存空间,写时拷贝技术允许父子进程能读相同的物理页。只要两者有一个进程试图写一个物理页,内核就把这个页的内容拷贝到一个新的物理 页,并把这个新的物理页分配给正在写的进程
4.子进程在创建后执行的是父进程的程序代码。
这里写图片描述

我们都知道,对于父进程 fork 返回子进程号,对于子进程 fork 返回 0 ,这也是执行路径如此的原因所在。但是, fork 的返回不同值的原因又是什么,这就得看 fork 的实现了。fork 先是调用 find_empty_process 为子进程找到一个空闲的任务号,然后调用 copy_process 复制进程, fork 返回 copy_process 的返回值 last_pid ,也就是子进程号。所以fork()实际上是一次调用,两次返回

下图是负责创建进程的函数的层次结构

从图中,可以看到 do_fork 是进程创建的基础。可以在 ./linux/kernel/fork.c 内找到 do_fork 函数(以及合作函数 copy_process)。

fork()函数创建新进程是通过下列一系列函数实现的:fork() -> sys_clone() -> do_fork() -> copy_process()->dup_task_struct() -> copy_thread() -> ret_from_fork()

do_fork 函数首先会分配一个新的 PID(但是我还没找到该调用)。接下来,do_fork 检查调试器是否在跟踪父进程。如果是,在 clone_flags 内设置 CLONE_PTRACE 标志以做好执行 fork 操作的准备。

之后 do_fork 函数还会调用 copy_process,向其传递这些标志、堆栈、注册表、父进程以及最新分配的 PID。
新的进程在 copy_process 函数内作为父进程的一个副本创建。此函数能执行除启动进程之外的所有操作,启动进程在之后进行处理。copy_process 内的第一步是验证 CLONE 标志以确保这些标志是一致的。如果不一致,就会返回 EINVAL 错误。接下来,询问 Linux Security Module (LSM) 看当前任务是否可以创建一个新任务。

do_fork源码如下

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;    // 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 ((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);// Do this prior waking up the new thread - the thread pointer might get invalid after that point, if the thread exits quickly.    if (!IS_ERR(p)) {   /*判断p的有效性*/             struct completion vfork;            struct pid *pid;            trace_sched_process_fork(current, p);            pid = get_task_pid(p, PIDTYPE_PID);            nr = pid_vnr(pid);/*返回p的命名空间的pid*/         /*如果设置父进程的TID,将nr放到parent_tidptr地址*/            if (clone_flags & CLONE_PARENT_SETTID)                  put_user(nr, parent_tidptr);       /*如果设置了该标志,初始化进程中的completion结构*/              if (clone_flags & CLONE_VFORK) {                 p->vfork_done = &vfork;                 init_completion(&vfork);                 get_task_struct(p);            }         /*唤醒函数,将进程入运行队列,创建进程copy_process之后并未执行.返回到do_fork中,将新创建进程加入到运行队列中等待被执行。*/              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;}

接下来,调用 dup_task_struct 函数(在 ./linux/kernel/fork.c 内),这会分配一个新 task_struct 并将当前进程的描述符复制到其内。在新的线程堆栈设置好后,一些状态信息也会被初始化,并且会将控制返回给 copy_process。控制回到 copy_process 后,除了其他几个限制和安全检查之外,还会执行一些常规管理,包括在新 task_struct 上的各种初始化。

部分dup_task_struct源码如下:

//dup_task_struct根据父进程创建子进程内核栈和进程描述符:static struct task_struct *dup_task_struct(struct task_struct *orig)  {    struct task_struct *tsk;    struct thread_info *ti;    int node = tsk_fork_get_node(orig);    int err;    /*创建进程描述符对象*/  tsk = alloc_task_struct_node(node);    if (!tsk)           return NULL;  /*给新进程分配一个新的内核堆栈*/    ti = alloc_thread_info_node(tsk, node);    if (!ti) /*如果thread info结构没申请到,释放tsk*/           goto free_tsk;    /*复制task_struct,使子进程描述符和父进程一致*/    err = arch_dup_task_struct(tsk, orig);  if (err)           goto free_ti;    tsk->stack = ti; /*task对应栈*/  #ifdef CONFIG_SECCOMP     /* We must handle setting up seccomp filters once         we're under the sighand lock in case orig has       changed between now and then. Until then,filter       must be NULL to avoid messing up  the usage       counts on the error path calling free_task.     */      tsk->seccomp.filter = NULL;  #endif     /*初始化thread info结构*/     setup_thread_stack(tsk, orig);//使子进程thread_info内容与父进程一致但task指向子进程task_struct   clear_user_return_notifier(tsk);     clear_tsk_need_resched(tsk);     set_task_stack_end_magic(tsk);  #ifdef CONFIG_CC_STACKPROTECTOR     tsk->stack_canary = get_random_int();    /*初始化stack_canary变量*/     .......

之后,会调用一系列复制函数来复制此进程的各个方面,比如复制开放文件描述符(copy_files)、复制符号信息(copy_sighand 和 copy_signal)、复制进程内存(copy_mm)以及最终复制线程(copy_thread)。

最终复制线程(copy_thread)部分源码

//新进程有自己的堆栈且会根据task_pt_regs中的内容进行修改。  int copy_thread(unsigned long clone_flags,                 unsigned long sp, unsigned long arg,                   struct task_struct *p)     {        struct pt_regs *childregs = task_pt_regs(p);        struct task_struct *tsk;        int err;      //调度到子进程时的内核栈顶        p->thread.sp = (unsigned long) childregs;      p->thread.sp0 = (unsigned long) (childregs+1);        memset(p->thread.ptrace_bps, 0, sizeof(p->thread.ptrace_bps));        if (unlikely(p->flags & PF_KTHREAD))       {        /* kernel thread */        memset(childregs, 0, sizeof(struct pt_regs));    p->thread.ip =(unsignedlong)ret_from_kernel_thread;          task_user_gs(p) = __KERNEL_STACK_CANARY;          childregs->ds = __USER_DS;          childregs->es = __USER_DS;          childregs->fs = __KERNEL_PERCPU;          childregs->bx = sp;  /* function */          childregs->bp = arg;          childregs->orig_ax = -1;        childregs->cs = __KERNEL_CS | get_kernel_rpl();       childregs->flags = X86_EFLAGS_IF | X86_EFLAGS_FIXED;      p->thread.io_bitmap_ptr = NULL;          return 0;     }     *childregs = *current_pt_regs();//复制内核堆栈     childregs->ax = 0;//eax寄存器值强置为0,即子进程返回到用户态时返回值为0     if (sp)        childregs->sp = sp;//sp为父进程传给子进程的用户态栈,可以与父进程共享     p->thread.ip = (unsigned long) ret_from_fork; //调度到子进程时的第一条指令地址     task_user_gs(p) = get_user_gs(current_pt_regs());      p->thread.io_bitmap_ptr = NULL;     tsk = current;  

之后,这个新任务会被指定给一个处理程序,同时对允许执行进程的处理程序进行额外的检查(cpus_allowed)。新进程的优先级从父进程的优先级继承后,执行一小部分额外的常规管理,而且控制也会被返回给 do_fork。在此时,新进程存在但尚未运行。do_fork 函数通过调用 wake_up_new_task 来修复此问题。此函数(可在 ./linux/kernel/sched.c 内找到)初始化某些调度程序的常规管理信息,将新进程放置在运行队列之内,然后将其唤醒以便执行。最后,一旦返回至 do_fork,此 PID 值即被返回给调用程序,进程完成。

二、进程创建总结

实际上,用户空间的寄存器、用户态堆栈等信息在切换到内核态的上下文时保存在内核栈中,父进程在内核态(dup_task_struct)复制出子进程,但子进程作为一个独立的进程,之后被调度运行时必须有一个指令地址,进程切换时,ip地址及当前内核栈的位置esp都存在于thread_info中,由copy_thread设置其thread.ip指向ret_from_fork作为子进程执行的第一条语句,并完成了内核态到用户态的切换。

进程创建由系统调用来建立新进程,归根结底都是调用do_fork来实现。do_fork主要就是调用copy_process。

copy_process()主要完成进程数据结构,各种资源的初始化。初始化方式可以重新分配,也可以共享父进程资源,主要根据传入clone_flags参数来确定。将task_struct结构体分配给子进程,并为其分配pid,最后将其加入可运行队列中。

dup_task_struct()为子进程获取进程描述符

copy_thread()函数将父进程内核栈复制到子进程中,同时设置子进程调度后执行的第一条语句地址为do_frok返回,并将保存返回值的寄存器eax值置为0,因此子进程返回为0,而父进程继续执行之后的初始化,最后返回子进程的pid(tgid)。

0 0
原创粉丝点击