您的位置:首页 > 运维架构 > Linux

Linux实验:从整体上理解进程创建、可执行文件的加载和进程执行进程切换,重点理解分析fork、execve和进程切换

2019-03-26 19:48 337 查看

学号后三位:288

原创作品转载请注明出处 + https://github.com/mengning/linuxkernel/ 

1. 实验要求

    4000
  • 阅读理解task_struct数据结构,代码链接:http://codelab.shiyanlou.com/xref/linux-3.18.6/include/linux/sched.h#1235;
  • 分析fork函数对应的内核处理过程do_fork,理解创建一个新进程如何创建和修改task_struct数据结构;
  • 使用gdb跟踪分析一个fork系统调用内核处理函数do_fork ,验证您对Linux系统创建一个新进程的理解,特别关注新进程是从哪里开始执行的?为什么从那里能顺利执行下去?即执行起点与内核堆栈如何保证一致。
  • 理解编译链接的过程和ELF可执行文件格式; 
  • 编程使用exec*库函数加载一个可执行文件,动态链接分为可执行程序装载时动态链接和运行时动态链接;
  • 使用gdb跟踪分析一个execve系统调用内核处理函数do_execve ,验证您对Linux系统加载可执行程序所需处理过程的理解;
  • 特别关注新的可执行程序是从哪里开始执行的?为什么execve系统调用返回后新的可执行程序能顺利执行?对于静态链接的可执行程序和动态链接的可执行程序execve系统调用返回时会有什么不同?
  • 理解Linux系统中进程调度的时机,可以在内核代码中搜索schedule()函数,看都是哪里调用了schedule(),判断我们课程内容中的总结是否准确;
  • 使用gdb跟踪分析一个schedule()函数 ,验证您对Linux系统进程调度与进程切换过程的理解;
  • 特别关注并仔细分析switch_to中的汇编代码,理解进程上下文的切换机制,以及与中断上下文切换的关系;

2. 实验内容

(1)task_struct数据结构

     为了描述和控制进程的运行,操作系统为每个进程定义了一个数据结构,即进程控制块(Process Control Block,PCB)。我们通常所说的进程实体包含程序段,数据段和PCB三部分。PCB在进程实体中占据重要的地位。所谓的创建进程,实质上就是创建PCB的过程;而撤销进程,实质上也就是对PCB的撤销。在Linux内核中,PCB对应着一个具体的结构体—task_struct,也就是所谓的进程描述符(process descriptor)。该数据结构中包含了与一个进程相关的所有信息,比如包含众多描述进程属性的字段,以及指向其他与进程相关的结构体的指针。

     进程描述符中有指向mm_struct结构体的指针mm,这个结构体是对该进程用户空间的描述;也有指向fs_struct结构体的指针fs,这个结构体是对进程当前所在目录的描述;也有指向files_struct结构体的指针files,这个结构体是对该进程已打开的所有文件进行描述;另外还有一个小型的进程描述符(low-level information)—thread_info。在这个结构体中,也有指向该进程描述符的指针task。因此,这两个结构体是相互关联的。

(2)分析fork函数对应的内核处理过程do_fork

    do_fork函数代码如下

[code]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;
}

  当执行fork系统调用时,操作系统会执行以下步骤: 

   1. 内核确保有创建新进程所需的充足的系统资源。其完成过程如下:

    (1)内核确保系统可以处理多个将要调度的进程,而且调度程序上的负载是可以管理的。

    (2)内核确保这个特定的用户当前没有运行过多垄断使用现有资源的进程。

    (3)内核确保为新进程提供足够的内存空间。
    操作系统已经知道:此时新进程和父进程在各个方面都是相同的。这还包括内存要求。在交换系统中,整个内存都要是可用的。在纯分页系统中,需要大量用于保存整个地址空间和页面映射表的内存空间。在请求分页调度中,启动进程,至少页面映射表必不可少。在请求分页调度方法中,地址空间中更多的页面可以通过缺页错误累计得到。如果内存空间不足,内核检查磁盘上是否有空间,如果有,就占用该空间的交换区。像前面在进程状态转移中讨论的一样,据此确定子进程的状态。

    2. 内核现在从进程表中找到一个位置,然后开始构造子进程的上下文。

    3. 内核维护"下一个可用的ID号"的全局值。任何时候,当fork系统调用创建新进程时,内核将该ID分配给新的子进程,并将该编号加1。内核还要设置一个最大值,当设置超过这个值的时候,系统就不能处理任何进程。如果该编号等于或大于这个最大值,内核从0重新分配编号,但是另一方面希望pid等于0的进程已经终止运行。

    4. 内核初始化子进程的进程表插槽中的字段,如下:

    (1) 内核将真实有效的用户ID从父进程的进程表插槽中复制到子进程对应的位置。

    (2)内核还要将父进程的准确值复制给子进程。

    (3)内核通过将父进程ID复制到子进程插槽,从而链接进程树结构中的子进程。

    (4)内核初始化子进程插槽中不同的调度字段和统计字段,如初始优先级、CPU使用情况等。

    (5)内核将该子进程的状态设置为"正在创建"。

    5. 现在,内核搜索父进程u区(进程信息交换区)中的文件描述符,并沿着指针从用户打开文件描述符到文件表条目,同时将文件表中那些条目的引用计数增加1。

    6. 内核为子进程的u区、区域表、页表等分配内存空间。

    7. 现在,除了子进程u区指向进程表插槽的指针要做适当的调整之外,内核将父进程的u区复制给子进程。这是因为父进程和子进程在进程表中有两个不同的条目。因此,指向这两个不同条目的指针也不相同。此时,所有其他内容是相同的。

    8. 内核将数据和堆栈区(非共享的部分)复制到子进程的另一个内存区,并调整区域表条目。然而,它只保存文本区的一个副本,因为文本区是共享的。诚如所示,此时文本包含相同的程序代码。

     9. 内核在子进程上下文的静态部分后面创建动态内容。它复制父进程上下文包含保存Fork系统调用的寄存器和内核堆栈的第一层。此时,父进程和子进程的内核堆栈的内容完全相同。

    10. 内核创建子进程第2层的伪程序上下文,这个伪程序上下文包括第1层保存的寄存器上下文。它在寄存器内容保存区中设置程序计数器(PC)和其他寄存器,这样就可以在适当的位置"重新开始"执行子进程。

    11. 现在,内核将子进程状态从"准备就绪"变成"准备运行"(根据情况要么在内存中,要么被交换)。它将子进程ID返回给用户。

    12. 调度程序最终调度子进程。在程序中,调度程序检查它是不是子进程。因为如果是子进程,它会执行"Exec"系统调用,由此将新程序加载到子进程的地址空间中。

(3)使用gdb跟踪分析一个fork系统调用内核处理函数do_fork

   1.使用实验楼环境输入指令启动Menu Os

[code]cd LinuxKernel
rm menu -rf
git clone https://github.com/mengning/menu.git
cd menu
mv test_fork.c test.c
make rootfs

    2.输入指令进入gdb调试模式并设置断点

[code] gdb
file linux-3.18.6/vmlinux
target remote:1234
b sys_clone
b do_fork
b dup_task_struct
b copy_process
b copy_thread

   运行开始后首先停在第一个断点sys_clone

   接着道第二个断点do_dork

  接着第三个断点copy_process

  停留在第五个断点copy_thread

3. 分析新进程从哪里开始执行

   我们可以分析函数copy_process中的copy_thread() 

[code]int copy_thread(unsigned long clone_flags, unsigned long sp,
unsigned long arg, struct task_struct *p)
{
...
*childregs = *current_pt_regs();
childregs->ax = 0;
if (sp)
childregs->sp = sp;
p->thread.ip = (unsigned long) ret_from_fork;
...
}

 子进程执行ret_from_fork

[code]ENTRY(ret_from_fork)
CFI_STARTPROC
pushl_cfi %eax
call schedule_tail
GET_THREAD_INFO(%ebp)
popl_cfi %eax
pushl_cfi $0x0202       # Reset kernel eflags
popfl_cfi
jmp syscall_exit
CFI_ENDPROC
END(ret_from_fork)

      p->thread.ip = (unsigned long) ret_from_fork;这句代码将子进程的 ip 设置为 ret_form_fork 的首地址,因此子进程是从 ret_from_fork 开始执行的。因此,函数copy_process中的copy_thread()决定了子进程从系统调用中返回后的执行。

(4) 理解编译链接的过程和ELF可执行文件格式

  1.编译链接的过程

   从源代码到可执行程序所要经历的过程概述如下图:

      源代码(.c .cpp .h)经过c预处理器(cpp)后生成.i文件,编译器(cc1、cc1plus)编译.i文件后生成.s文件,汇编器(as)汇编.s文件后生成.o文件,链接器(ld)链接.o文件生成可执行文件。gcc是对cpp、cc1(cc1plus)、as、ld这些后台程序的包装,它会根据不同的参数要求去调用后台程序。以helloworld程序为例,使用gcc -o hello hello.c时加上-v选项可观察到详细的步骤。也可使用gcc分别进行以上四步骤,预编译gcc -E hello.c -o hello.i,编译gcc -S hello.i -o hello.s,汇编gcc -c hello.s -o hello.o,链接gcc -o hello hello.o

 2.ELF可执行文件格式

ELF文件(目标文件)格式主要三种:

  •  可重定向文件:文件保存着代码和适当的数据,用来和其他的目标文件一起来创建一个可执行文件或者是一个共享目标文件。(目标文件或者静态库文件,即linux通常后缀为.a和.o的文件)
  • 可执行文件:文件保存着一个用来执行的程序。(例如bash,gcc等)
  • 共享目标文件:共享库。文件保存着代码和合适的数据,用来被下连接编辑器和动态链接器链接。(linux下后缀为.so的文件。)

目标文件既要参与程序链接又要参与程序执行:

一般的 ELF 文件包括三个索引表:ELF header,Program header table,Section header table。

  • ELF header:在文件的开始,保存了路线图,描述了该文件的组织情况。
  • Program header table:告诉系统如何创建进程映像。用来构造进程映像的目标文件必须具有程序头部表,可重定位文件不需要这个表。
  • Section header table:包含了描述文件节区的信息,每个节区在表中都有一项,每一项给出诸如节区名称、节区大小这类信息。用于链接的目标文件必须包含节区头部表,其他目标文件可以有,也可以没有这个表。
     

(5)编程使用exec*库函数加载一个可执行文件

    先利用文本编辑器编辑一个test.c

    然后进行编译

也可以使用静态编译

静态链接
在编译链接时直接将需要的执行代码复制到最终可执行文件中,有点是代码的装在速度块,执行速度也比较快,对外部环境依赖度低。编译时它会把需要的所有代码都链接进去,应用程序相对较大。

动态链接
动态链接是在程序运行时由操作系统将需要的动态库加载到内存中。动态链接分为装载时动态链接和运行时动态链接。

(6)使用gdb跟踪分析一个execve系统调用内核处理函数do_execve

   1. do_execve函数

[code]int do_execve(struct filename *filename,

20000
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 };
//调用do_execve_common
return do_execve_common(filename, argv, envp);
}

2. 设置断点

3. 各断点中断情况

4. execve系统调用在内核中的执行过程

    (1) exec系统调用要求以参数形式提供可执行文件名,并存储该参数以备将来使用。连同文件名一起,还要提供和存储其他参数,例如如果shell命令是"ls -l",那么ls作为文件名,-l作为选项,一同存储起来以备将来使用。

    (2) 现在,内核解析文件路径名,从而得到该文件的索引节点号。然后,访问和读取该索引节点。内核知道对任何shell命令而言,它要先在/bin目录中搜索。

    (3) 内核确定用户类别(是所有者、组还是其他)。然后从索引节点得到对应该可执行文件用户类别的执行(X)权限。内核检查该进程是否有权执行该文件。如果不可以,内核提示错误消息并退出。

    (4) 如果一切正常,它访问可执行文件的头部。

    (5) 现在,内核要将期望使用的程序(例如本例中的ls)的可执行文件加载到子进程的区域中。但"ls"所需的不同区域的大小与子进程已经存在的区域不同,因为它们是从父进程中复制过来的。因此,内核释放所有与子进程相关的区域。这是准备将可执行镜像中的新程序加载到子进程的区域中。在为仅仅存储在内存中的该系统调用存储参数后释放空间。进行存储是为了避免"ls"的可执行代码覆盖它们而导致它们丢失。根据实现方式的不同,在适当的地方进行存储。例如,如果"ls"是命令,"-l"是它的参数,那么就将"-l"存储在内核区。/bin目录中"ls"实用程序的二进制代码就是内核要加载到子进程内存空间中的内容。

    (6) 然后,内核查询可执行文件(例如ls)镜像的头部之后分配所需大小的新区域。此时,建立区域表和页面映射表之间的链接。

    (7) 内核将这些区域和子进程关联起来,也就是创建区域表和P区表之间的链接。

    (8) 然后,内核将实际区域中的内容加载到分配的内存中。

    (9) 内核使用可执行文件头部中的寄存器初始值创建保存寄存器上下文。

    (10) 此时,子进程("ls"程序)已经运行。因此,内核根据子进程优先级,将其插到"准备就绪"进程列表的合适位置。最终,调度这个子进程。

    (11) 在调度该子进程后,由前述(9)中介绍的保存寄存器上下文生成该进程的上下文。然后,PC、SP等就有了正确的值。

    (12) 然后,内核跳转到PC指明的地址。这就是要执行的程序中第一个可执行指令的地址。现在开始执行"ls"这样的新程序。 内核从步骤(5)中存储的预先确定的区域中得到参数,然后生成所需的输出。如果子进程在前台执行,父进程会一直等到子进程终止;否则它会继续执行。

    (13) 子进程终止,进入僵尸状态,期望使用的程序已经完成。现在,内核向父进程发送信号,指明"子进程死亡",这样现在就可以唤醒父进程了。

    如果这个子进程打开新文件,那么这个子进程的用户文件描述符表、打开文件列表和inode表结构就和父进程的不同。如果该子进程调用另一个子程序,就会重复执行/分支进程。这样就会创建不同深度层次的进程结构。

(7)使用gdb跟踪分析一个schedule()函数

     首先设置几个断点

分别在schedule函数、pick_next_task函数、context_switch函数和__switch_to函数处打断点并继续执行观察函数调用的过程。发现context_switch和pick_next_task函数都在__schedule函数中。
schedul函数选择一个新的进程来运行,并调用context_switch进行上下文的切换。context_switch首先调用switch_mm切换CR3,然后调用宏switch_to来进行硬件上的上下文切换。

(8)分析switch_to中的汇编代码,理解进程上下文的切换机制与中断上下文切换的关系

[code]asm volatile("pushfl\n\t"     //保存当前进程的标志寄存器PSW内容
"pushl %%ebp\n\t"    //保存堆栈基址寄存器内容
"movl %%esp,%[prev_sp]\n\t"  // 保存栈顶指针
"movl %[next_sp],%%esp\n\t"  // 将下一个进程的栈顶指针mov到esp寄存器中,完成了内核堆栈的切换

"movl $1f,%[prev_ip]\n\t"    // 保存当前进程的EIP
"pushl %[next_ip]\n\t"   //将下一个进程的EIP压栈
__switch_canary
"jmp __switch_to\n"

"1:\t"              //next进程开始执行
"popl %%ebp\n\t"     //恢复堆栈基址
"popfl\n"         //恢复PSW

/* output parameters 因为处于中断上下文,在内核中
prev_sp是内核堆栈栈顶
prev_ip是当前进程的eip */
: [prev_sp] "=m" (prev->thread.sp),
[prev_ip] "=m" (prev->thread.ip),  //[prev_ip]是标号
"=a" (last),

"=b" (ebx), "=c" (ecx), "=d" (edx),
"=S" (esi), "=D" (edi)

__switch_canary_oparam

/* input parameters:
next_sp下一个进程的内核堆栈的栈顶
next_ip下一个进程执行的起点,一般是$1f,对于新创建的子进程是ret_from_fork*/
: [next_sp]  "m" (next->thread.sp),
[next_ip]  "m" (next->thread.ip),

[prev]     "a" (prev),
[next]     "d" (next)

__switch_canary_iparam

: /* reloaded segment registers */
"memory");
} while (0)

    switch_to实现了进程之间的切换

  • 首先在当前进程prev的内核栈中保存esi,edi及ebp寄存器的内容。
  • 然后将prev的内核堆栈指针ebp存入prev->thread.esp中。
  • 把将要运行进程next的内核栈指针next->thread.esp置入esp寄存器中
  • 将popl指令所在的地址保存在prev->thread.eip中,这个地址就是prev下一次被调度
  • 通过jmp指令(而不是call指令)转入一个函数__switch_to()
  • 恢复next上次被调离时推进堆栈的内容。从现在开始,next进程就成为当前进程而真正开始执行

3. 总结

       在UNIX中,fork是进程创建另一个进程的唯一方法。只有第一个进程也就是被称作"init"的进程需要"手工创建"。所有其他进程都是用fork这个系统调用创建的。fork系统调用只是复制了父进程的数据和堆栈,并在这两个进程之间共享文本区。fork系统调用采用比较聪明的方式—"写时拷贝(copy-on-write)"技术,使得fork结束后并不立刻复制父进程的内容,而是到了真正实用的时候才复制,这样使效率大大提高。fork函数创建了一个子进程后,子进程会调用exec族函数执行另外一个程序。
       随着硬件MMU的诞生,多进程、多用户、虚拟存储的操作系统出现以后,可执行文件的装载过程变得非常复杂。引入了进程的虚拟地址空间;然后根据操作系统如何为程序的代码、数据、堆、栈在进程地址空间中分配,它们是如何分布的;最后以页映射的方式将程序映射至进程虚拟地址空间。 
       动态链接是一种与静态链接程序不同的概念,即一个单一的可执行文件模块被拆分成若干个模块,在程序运行时进行链接的一种方式。然后根据实际例子do_exece()分析了ELF装载的大致过程,中间实现了动态链接。

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: 
相关文章推荐