从整理上理解进程创建可执行文件的加载和进程执行进程切换,重点理解分析forkexecve和进程切换

Posted zhongweics

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从整理上理解进程创建可执行文件的加载和进程执行进程切换,重点理解分析forkexecve和进程切换相关的知识,希望对你有一定的参考价值。

一、首先我们来看看进程控制块PCB也就是task_struct,(源码

  选出task_struct中几个关键的参数进行分析

struct task_struct {
volatile
long state; //进程状态 /* -1 unrunnable, 0 runnable, >0 stopped */ void *stack; //进程内核堆栈 atomic_t usage; unsigned int flags; //进程标识符 /* per process flags, defined below */
    ....
unsigned int ptrace; struct list_head tasks; //进程链表 struct thread_struct thread; //在进行进程切换时与CPU状态相关的代码结构体 /* filesystem information */ struct fs_struct *fs; //文件系统相关数据结构 /* open file information */ struct files_struct *files; //文件描述列表 /* namespaces */ struct nsproxy *nsproxy; /* signal handlers */ struct signal_struct *signal; struct sighand_struct *sighand; //信号处理相关代码 .... }

二、进程的创建

  fork()允许用户态下创建新的进程, fork 创造的子进程复制了父亲进程的资源,包括内存的内容task_struct内容,新旧进程使用同一代码段,复制数据段和堆栈段,这里的复制采用了注明的copy_on_write技术,即一旦子进程开始运行,则新旧进程的地址空间已经分开,两者运行独立.

  在 Linux 内核中,供用户创建进程的系统调用fork()函数的响应函数是 sys_fork()、sys_clone()、sys_vfork()。这三个函数都是通过调用内核函数 do_fork() 来实现的。根据
调用时所使用的 clone_flags 参数不同,do_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     
13     // 复制进程描述符,返回创建的task_struct的指针
14     p = copy_process(clone_flags, stack_start, stack_size,
15              child_tidptr, NULL, trace); //复制子进程
16 
17     if (!IS_ERR(p)) {
18         struct completion vfork;
19         struct pid *pid;
20 
21         trace_sched_process_fork(current, p);
22 
23         // 取出task结构体内的pid
24         pid = get_task_pid(p, PIDTYPE_PID);
25         nr = pid_vnr(pid);
26 
27         if (clone_flags & CLONE_PARENT_SETTID)
28             put_user(nr, parent_tidptr);
29 
30         // 如果使用的是vfork,那么必须采用某种完成机制,确保父进程后运行
31         if (clone_flags & CLONE_VFORK) {
32             p->vfork_done = &vfork;
33             init_completion(&vfork);
34             get_task_struct(p);
35         }
36 
37         // 将子进程添加到调度器的队列,使得子进程有机会获得CPU
38         wake_up_new_task(p);
39 
40         // ...
41 
42         // 如果设置了 CLONE_VFORK 则将父进程插入等待队列,并挂起父进程直到子进程释放自己的内存空间
43         // 保证子进程优先于父进程运行
44         if (clone_flags & CLONE_VFORK) {
45             if (!wait_for_vfork_done(p, &vfork))
46                 ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
47         }
48 
49         put_pid(pid);
50     } else {
51         nr = PTR_ERR(p);
52     }
53     return nr;
54 }

copy_process()主要完成进程数据结构,各种资源的初始化。

里面包含了两个主要的函数dup_task_struct () 和 copy_thread()

对于dup_task_struct():

  tsk = alloc_task_struct_node(node);为task_struct开辟内存
  ti = alloc_thread_info_node(tsk, node);ti指向thread_info的首地址,同时也是系统为新进程分配的两个连续页面的首地址。
  err = arch_dup_task_struct(tsk, orig);复制父进程的task_struct信息到新的task_struct里, (dst = src;)
  tsk->stack = ti;task的对应栈
  setup_thread_stack(tsk, orig);初始化thread info结构
  set_task_stack_end_magic(tsk);栈结束的地址设置数据为栈结束标示(for overflow detectio

但是创建新进程从哪里开始呢?

  kernel中是可以指定新进程开始的位置(也就是通过eip寄存器指定代码行)。fork中也有相似的机制 这涉及子进程的内核堆栈数据状态和task_ struct中thread记录的sp和ip的一致性问题,这是在copy_ thread in copy_ process设定的。

让我们在来看一下copy_thread函数:

132int copy_thread(unsigned long clone_flags, unsigned long sp,
133    unsigned long arg, struct task_struct *p)
134{
135    struct pt_regs *childregs = task_pt_regs(p);
136    struct task_struct *tsk;
137    int err;
138
139    p->thread.sp = (unsigned long) childregs;
140    p->thread.sp0 = (unsigned long) (childregs+1);
141    memset(p->thread.ptrace_bps, 0, sizeof(p->thread.ptrace_bps));
142
143    if (unlikely(p->flags & PF_KTHREAD)) {
144        /* kernel thread */
145        memset(childregs, 0, sizeof(struct pt_regs));
146        p->thread.ip = (unsigned long) ret_from_kernel_thread;
147        task_user_gs(p) = __KERNEL_STACK_CANARY;
148        childregs->ds = __USER_DS;
149        childregs->es = __USER_DS;
150        childregs->fs = __KERNEL_PERCPU;
151        childregs->bx = sp;    /* function */
152        childregs->bp = arg;
153        childregs->orig_ax = -1;
154        childregs->cs = __KERNEL_CS | get_kernel_rpl();
155        childregs->flags = X86_EFLAGS_IF | X86_EFLAGS_FIXED;
156        p->thread.io_bitmap_ptr = NULL;
157        return 0;
158    }
159    *childregs = *current_pt_regs();
160    childregs->ax = 0;
161    if (sp)
162        childregs->sp = sp;
163
164    p->thread.ip = (unsigned long) ret_from_fork;
165    task_user_gs(p) = get_user_gs(current_pt_regs());
166
167    p->thread.io_bitmap_ptr = NULL;
168    tsk = current;
169    err = -ENOMEM;
170
171    if (unlikely(test_tsk_thread_flag(tsk, TIF_IO_BITMAP))) {
172        p->thread.io_bitmap_ptr = kmemdup(tsk->thread.io_bitmap_ptr,
173                        IO_BITMAP_BYTES, GFP_KERNEL);
174        if (!p->thread.io_bitmap_ptr) {
175            p->thread.io_bitmap_max = 0;
176            return -ENOMEM;
177        }
178        set_tsk_thread_flag(p, TIF_IO_BITMAP);
179    }
180
181    err = 0;
182
183    /*
184     * Set a new TLS for the child thread?
185     */
186    if (clone_flags & CLONE_SETTLS)
187        err = do_set_thread_area(p, -1,
188            (struct user_desc __user *)childregs->si, 0);
189
190    if (err && p->thread.io_bitmap_ptr) {
191        kfree(p->thread.io_bitmap_ptr);
192        p->thread.io_bitmap_max = 0;
193    }
194    return err;
195}

copy_thread的流程如下:
  (1) 获取子进程寄存器信息的存放位置
  (2) 对子进程的thread.sp赋值,将来子进程运行,这就是子进程的esp寄存器的值。
  (3)如果是创建内核线程,那么它的运行位置是ret_from_kernel_thread,将这段代码的地址赋给thread.ip,之后准备其他寄存器信息,退出 。
  (4)将父进程的寄存器信息复制给子进程。
  (5)将子进程的eax寄存器值设置为0,所以fork调用在子进程中的返回值为0。
  (6)子进程从ret_from_fork开始执行,所以它的地址赋给thread.ip,也就是将来的eip寄存器。 从上面的流程中,我们看出,子进程复制了父进程的上下文信息,仅仅对某些地方做了改动,运行逻辑和父进程完全一致

*childregs = *current_pt_regs(); 复制内核堆栈(复制的pt_regs,是SAVE_ALL中系统调用压栈的那一部分。)
childregs->ax = 0; 子进程的fork返回0
p->thread.sp = (unsigned long) childregs; 调度到子进程时的内核栈顶
p->thread.ip = (unsigned long) ret_from_fork; 调度到子进程时的第一条指令地址
ip指向的是ret_from_fork,所以是从这里开始执行的。

三、实验过程

四、可执行文件的加载

  1.编译链接的过程

技术图片

  C代码(.c)--》经过编译器预处理,编译成汇编代码(.asm) --》 汇编器,生成目标代码(.o) ---》链接器,链接成可执行文件(.out) ---》OS将可执行文件加载到内存里执行

    1. 预处理
      gcc -E -o hello.cpp hello.c -m32 预处理(文本文件) 预处理负责把include的文件包含进来及宏替换等工作
    2. 编译
      gcc -x cpp-output -S -o hello.s hello.cpp -m32 编译成汇编代码(文本文件)
    3. 汇编
      gcc -x assembler -c hello.s -o hello.o -m32 汇编成目标代码(ELF格式,二进制文件,有一些机器指令,只是还不能运行)
    4. 链接
      gcc -o hello hello.o -m32 链接成可执行文件(ELF格式,二进制文件)
      在hello可执行文件里面使用了共享库,会调用printf,libc库里的函数
      gcc -o hello.static hello.o -m32 -static 静态链接
      把执行所需要依赖的东西都放在程序内部

  2.ELF三种主要的目标文件
    1.可重定位:保存代码和适当数据,用来和其他的object文件一起创建可执行/共享文件,主要是.o文件
    2.可执行文件:指出了exec如何创建程序进程映像,怎么加载,从哪里开始执行
    3.共享object文件:保存代码和适当数据,用来被下面的两个连接器链接。
      (1)连接editor,连接可重定位、共享object文件。即装载时链接。
      (2)动态链接器,联合可执行、其他共享object文件创建进程映像。即运行时链接

  3.可执行程序的执行环境
    命令行参数和shell环境,一般我们执行一个程序的Shell环境,我们的实验直接使用execve系统调用。
    $ ls -l /usr/bin 列出/usr/bin下的目录信息
    Shell本身不限制命令行参数的个数, 命令行参数的个数受限于命令自身
    例如,int main(int argc, char *argv[])
    又如, int main(int argc, char *argv[], char *envp[])
    Shell会调用execve将命令行参数和环境参数传递给可执行程序的main函数
    int execve(const char * filename,char * const argv[ ],char * const envp[ ]);
    库函数exec*都是execve的封装例程

  4.可执行程序的装载

    Linux 一般通过shell程序为执行环境来启动一个可执行程序。Shell本身不限制命令行参数的个数,它受限于命令自身;Shell会调用一个系统调用exece将命令参数和环境参数传递给可执行程序的main函数。
命令行参数与环境变量的保存与传递:
    当fork一个子进程时,是先复制父进程,再调用exece,会把原来的进程环境覆盖掉,用户态堆栈也被清空。用户态堆栈以start_stack作为main函数的起点,把argv[ ]命令行参数 和envp[ ]环境变量的内容通过指针的方式传递到系统调用exeve(内核处理函数);exeve创建一个新的用户态堆栈时把上面的命令行参数与环境变量拷贝到新的用户态堆栈里,从而初始化新的可执行程序的上下文环境。exece在内核态下装载可执行程序,再返回用户态。所以它先进行函数调用参数传递,然后系统调用参数传递,最后又进行函数调用参数传递

    可执行程序的装载过程 :
在进入execve()系统调用之后,Linux内核就开始进行真正的装载工作。在内核中,execve()系统调用相应的入口是sys_execve(),作用:参数的检查复制;调用do_execve(),流程:查找被执行的文件,读取文件的前128个字节以判断文件的格式是elf还是其它;调用search_binary_handle(),流程:通过判断文件头部确定文件的格式,并且调用相应的装载处理程序
  
1549int do_execve(struct filename *filename,
1550    const char __user *const __user *__argv,
1551    const char __user *const __user *__envp)
1552{
1553    struct user_arg_ptr argv = { .ptr.native = __argv };
1554    struct user_arg_ptr envp = { .ptr.native = __envp };
1555    return do_execve_common(filename, argv, envp);
1556}
技术图片
1430static int do_execve_common(struct filename *filename,
1431                struct user_arg_ptr argv,
1432                struct user_arg_ptr envp)
1433{
1434    struct linux_binprm *bprm;
1435    struct file *file;
1436    struct files_struct *displaced;
1437    int retval;
1438
1439    if (IS_ERR(filename))
1440        return PTR_ERR(filename);
1441
1442    /*
1443     * We move the actual failure in case of RLIMIT_NPROC excess from
1444     * set*uid() to execve() because too many poorly written programs
1445     * don‘t check setuid() return code.  Here we additionally recheck
1446     * whether NPROC limit is still exceeded.
1447     */
1448    if ((current->flags & PF_NPROC_EXCEEDED) &&
1449        atomic_read(&current_user()->processes) > rlimit(RLIMIT_NPROC)) {
1450        retval = -EAGAIN;
1451        goto out_ret;
1452    }
1453
1454    /* We‘re below the limit (still or again), so we don‘t want to make
1455     * further execve() calls fail. */
1456    current->flags &= ~PF_NPROC_EXCEEDED;
1457
1458    retval = unshare_files(&displaced);
1459    if (retval)
1460        goto out_ret;
1461
1462    retval = -ENOMEM;
1463    bprm = kzalloc(sizeof(*bprm), GFP_KERNEL);
1464    if (!bprm)
1465        goto out_files;
1466
1467    retval = prepare_bprm_creds(bprm);
1468    if (retval)
1469        goto out_free;
1470
1471    check_unsafe_exec(bprm);
1472    current->in_execve = 1;
1473
1474    file = do_open_exec(filename);
1475    retval = PTR_ERR(file);
1476    if (IS_ERR(file))
1477        goto out_unmark;
1478
1479    sched_exec();
1480
1481    bprm->file = file;
1482    bprm->filename = bprm->interp = filename->name;
1483
1484    retval = bprm_mm_init(bprm);
1485    if (retval)
1486        goto out_unmark;
1487
1488    bprm->argc = count(argv, MAX_ARG_STRINGS);
1489    if ((retval = bprm->argc) < 0)
1490        goto out;
1491
1492    bprm->envc = count(envp, MAX_ARG_STRINGS);
1493    if ((retval = bprm->envc) < 0)
1494        goto out;
1495
1496    retval = prepare_binprm(bprm);
1497    if (retval < 0)
1498        goto out;
1499
1500    retval = copy_strings_kernel(1, &bprm->filename, bprm);
1501    if (retval < 0)
1502        goto out;
1503
1504    bprm->exec = bprm->p;
1505    retval = copy_strings(bprm->envc, envp, bprm);
1506    if (retval < 0)
1507        goto out;
1508
1509    retval = copy_strings(bprm->argc, argv, bprm);
1510    if (retval < 0)
1511        goto out;
1512
1513    retval = exec_binprm(bprm);
1514    if (retval < 0)
1515        goto out;
1516
1517    /* execve succeeded */
1518    current->fs->in_exec = 0;
1519    current->in_execve = 0;
1520    acct_update_integrals(current);
1521    task_numa_free(current);
1522    free_bprm(bprm);
1523    putname(filename);
1524    if (displaced)
1525        put_files_struct(displaced);
1526    return retval;
1527
1528out:
1529    if (bprm->mm) {
1530        acct_arg_size(bprm, 0);
1531        mmput(bprm->mm);
1532    }
1533
1534out_unmark:
1535    current->fs->in_exec = 0;
1536    current->in_execve = 0;
1537
1538out_free:
1539    free_bprm(bprm);
1540
1541out_files:
1542    if (displaced)
1543        reset_files_struct(displaced);
1544out_ret:
1545    putname(filename);
1546    return retval;
1547}
View Code

整体调用关系为sys_execve()->do_execve()->do_execveat_common()->__do_execve_file()->prepare_binprm()->search_binary_handler()->load_elf_binary()->start_thread().

五、实验过程

七、进程调度的时机

发生方式:

1 中断处理过程(时钟中断、I/O中断、系统调用和异常)中或返回用户态根据need_resched标记调用

2 内核线程直接调用进行进程切换,也可以在中断处理过程中进行调度,内核线程可以主动调度,也可以被动调度

3 用户态进程无法实现主动调度,仅能通过陷入内核态后的某个时机点进行调度,在中断处理过程中调度

调度中介:schedule()函数

1 schedule()函数开始,禁用内核抢占并初始化一些局部变量,此时,schedule()检查运行队列中剩余的可运行进程数,如果有可调用进程,开始调用。

schedule()函数中在switch_to宏之后紧接着的指令并不由next进程立即执行,而是稍后当调度程序选择prev又执行时由prev执行。 prev局部变量并不指向开始描述schedule()时所替换出去的原来的那个进程,而是指向prev被调度时由prev替换出的原来那个进程。

紧接着context_switch()函数调用之后,宏barrier()产生一个代码优化屏障。执行finish_task_switch()函数。

4 schedule()函数最后执行的是:重新获得大内核锁,重新启动内核抢占,并检查是否其他进程设置了当前进程的TIF_RESCHED标志,如果是,整个schedule()函数重新开始执行

参考链接 https://www.cnblogs.com/chuishi/p/5400943.html

 

进程调度函数关系

schedule() --> context_switch() --> switch_to --> __switch_to()

switch_to 完成进程切换,让我们看一下关键代码

31#define switch_to(prev, next, last)                    
32do {                                    33    /*                                34     * Context-switching clobbers all registers, so we clobber    35     * them explicitly, via unused output variables.        36     * (EAX and EBP is not listed because EBP is saved/restored    37     * explicitly for wchan access and EAX is the return value of    38     * __switch_to())                        39     */                                40    unsigned long ebx, ecx, edx, esi, edi;                41                                    42    asm volatile("pushfl
	"        /* save    flags */    43             "pushl %%ebp
	"        /* save    EBP   */    44             "movl %%esp,%[prev_sp]
	"    /* save    ESP   */ 45             "movl %[next_sp],%%esp
	"    /* restore ESP   */ 46             "movl $1f,%[prev_ip]
	"    /* save    EIP   */   "保存当前进程的eip,恢复的时候从prev_ip来恢复eip
47             "pushl %[next_ip]
	"    /* restore EIP   */    把下个进程的起点ip位置压到next进程堆栈栈顶(起点)
48             __switch_canary                    49             "jmp __switch_to
"    /* regparm call  */    jmp通过prevnext寄存器的方式传递参数
50             "1:	"                        51             "popl %%ebp
	"        /* restore EBP   */    恢复上下文,next曾经pushebp
52             "popfl
"            /* restore flags */    53                                    54             /* output parameters */                55             : [prev_sp] "=m" (prev->thread.sp),       
56               [prev_ip] "=m" (prev->thread.ip),        prev_sp是内核堆栈栈顶 prev_ip是当前进程的eip */
57               "=a" (last),                    58                                    59               /* clobbered output registers: */        60               "=b" (ebx), "=c" (ecx), "=d" (edx),        61               "=S" (esi), "=D" (edi)                62                                           63               __switch_canary_oparam                64                                    65               /* input parameters: */                66             : [next_sp]  "m" (next->thread.sp),        
ext_sp下一个进程的内核堆栈的栈顶
67               [next_ip]  "m" (next->thread.ip),        
ext_ip下一个进程执行的起点,一般是$1f,对于新创建的子进程是ret_from_fork*/
68                                           69               /* regparm parameters for __switch_to(): */    70               [prev]     "a" (prev),                71               [next]     "d" (next)                72                                    73               __switch_canary_iparam                74                                    75             : /* reloaded segment registers */            76            "memory");                    77}

分析见汇编代码注释

八、实验过程

以上是关于从整理上理解进程创建可执行文件的加载和进程执行进程切换,重点理解分析forkexecve和进程切换的主要内容,如果未能解决你的问题,请参考以下文章

动态加载并执行Win32可执行程序

main函数由哪个进程创建?

第一次作业:深入源码分析进程模型

Linux之进程第一谈

一个可执行文件的生成过程到进程在内存中的分布

进程的创建与可执行程序的加载