linux中进程的创建和切换以及可执行文件加载的研究
原创作品转载请注明出处 + https://github.com/mengning/linuxkernel/
概念知识点理解
进程控制块
进程控制块:PCB是操作系统管理控制进程运行所有的信息集合,主要包括进程描述信息、进程控制和管理信息、资源分配清单和处理机相关信息等,是进程实体的一部分,进程存在的唯一标志。
PCB是进程在内存中的静态存在方式,因此进程的静态描述符必须保证一个进程在获得CPU并重新进入运行态时,能够精确的接着上次运行的位置继续运行,相关的程序段、数据以及CPU现场信息都要保存下来,CPU的现场信息主要包括内部寄存器和堆栈的基本数据。进程上下文
进程上下文:当程序执行了系统调用或中断而进入内核态时,进程切换现场就称为进程上下文,包含了一个进程所具有的全部信息,一般包括:进程控制块(PCB)、有关程序段和相应的数据集。
进程堆栈
进程的堆栈:内核在创建进程的时候,会为进程创建相应的堆栈。每个进程会有两个栈,一个用户栈,存在于用户空间,一个内核栈,存在于内核空间。当进程在用户空间运行时,cpu堆栈指针寄存器里面的内容是用户堆栈地址,使用用户栈;当进程在内核空间时,cpu堆栈指针寄存器里面的内容是内核栈空间地址,使用内核栈。进程用户栈和内核栈的切换:当进程因中断或系统调用而陷入内核态运行时,进程所使用的堆栈也要从用户栈转到内核栈。进程进入内核态后,先把用户态堆栈的地址保存在内核栈之中,然后设置堆栈指针寄存器的内容为内核栈的地址,完成用户栈向内核栈的转换;当进程从内核态恢复到用户态之行时,在内核态之行的最后将保存在内核栈里面的用户栈的地址恢复到堆栈指针寄存器,实现内核栈和用户栈的互转。那么,我们知道从内核转到用户态时用户栈的地址是在陷入内核的时候保存在内核栈里面的,但是在陷入内核的时候,我们是如何知道内核栈的地址的呢?关键在进程从用户态转到内核态的时候,进程的内核栈总是空的。这是因为,当进程在用户态运行时,使用的是用户栈,当进程陷入到内核态时,内核栈保存进程在内核态运行的相关信息,但是一旦进程返回到用户态后,内核栈中保存的信息无效,会全部恢复,因此每次进程从用户态陷入内核的时候得到的内核栈都是空的。所以在进程陷入内核的时候,直接把内核栈的栈顶地址给堆栈指针寄存器就可以了。
进程的创建
Linux创建进程是通过子进程复制父进程所拥有的资源来实现的。现代Linux通过写时复制、共享数据等方法优化这一过程,提高创建子进程的效率。
在Linux中,进程创建实际上是通过do_fork函数处理的。do_fork函数的功能相对简单(kernel/fork.c):
- 检查是否或者哪个事件应该汇报给ptracer。
- 通过copy_process创建进程描述符和子进程执行所需要的其它数据结构。
- 执行wake_up_new_task函数,唤醒新进程。
- 结束并返回子进程的ID
copy_process则负责对进程创建的相关资源的申请(kernel/fork.c): - 调用security_task_create以及稍后调用的security_task_alloc执行附加的安全检查。
- 执行dup_task_struct复制父进程的task_struct描述符
- 初始化新结构体的各个字段:did_exec,utime,stime,gtime,irq_events,hardirqs_enabled等等
- 进行调度相关的初始化:perf_event_init_task,audit_alloc.
- 复制父进程的信息到子进程:copy_semundo,copy_files,copy_fs,copy_mm等
- 初始化其它进程相关字段
- 将total_forks增加1
从上可得task_struct进程控制块与进程地址空间的联系:
在task_struct结构体内的struct mm_struct成员执行内存区描述符的指针。在进程描述符中,还应该存储进程空间的页表信息,和将逻辑地址转换成页号和页内偏移地址所需的相关信息。
通过总结可以得到:进程的创建的系统调用clone fork vfork都是调用do_fork实现的,而do_fork在做了一些参数检查之后。调用了copy_process函数,copy_process函数在进行安全性检查之后,使用dup_task_struct复制父进程的结构体。对新进程描述符的一些标志信息和时间信息进行初始化,之后将父进程的所有进程信息拷贝到子进程空间,包括IO、文件、内存信息等。然后,设置新进程的pid,将新进程加入进程调度队列中。子进程的eax设置为0,父进程则返回新进程的pid,所以在fork调用中,子进程返回的是0,父进程返回的是新进程的pid。详细见后续代码分析。
可执行程序的加载
在Linux中提供了一系列的函数,这些函数能用可执行文件所描述的新上下文代替进程的上下文。这样的函数名以前缀exec开始。所有的exec函数都是调用了execve()系统调用。
sys_execve接受参数:
- 可执行文件的路径
- 命令行参数字符串
- 环境变量字符串
sys_execve是调用do_execve实现的。do_execve则是调用do_execve_common实现的,依次执行以下操作(fs/exec.h): - 在堆上分配一个linux_binprm结构。
- 调用open_exec读取可执行文件。
- 调用sched_exec(),确定最小负载的CPU以执行新程序,并把当前进程转移过去。
- 调用bprm_mm_init()函数,为新程序初始化内存管理。
- 调用prepare_bprm()函数填充linux_binprm数据结构。
- 拷贝命令行参数argv,环境变量envp,可执行文件名filename到新进程中
- 调用search_binary_handler()函数对formats链表进行扫描,并尝试每个load_binary函数,如果成功加载了文件的执行格式,对formats的扫描终止。
- 释放linux_binprm数据结构,返回从该文件可执行格式的load_binary中获得的代码。
然后是load_binary分析如下: - 检查存放在文件前128字节的一些魔数进行匹配以确认文件格式。
- 读可执行文件的首部。
- 从可执行文件中确定动态链接程序的路径名,并用它来确定共享库的位置并把他们映射到内存。
- 获得动态链接程序的目录项对象。
- 检查动态链接的执行许可权。
- 把动态链接程序的前128字节拷贝到缓冲区。
- 对动态链接程序类型执行一致性检查。
- 调用arch_pick_mmap_layout(),以选择进程线性区的布局。
- 调用setup_arg_pages()函数为进程的用户态堆栈分配一个新的线性区描述符。
- 调用do_mmap()函数创建一个新线性区来对可执行文件正文段进行映射。
- 调用装入动态链接程序的函数
- 把可执行格式的linux_binfmt对象的地址放在进程描述符的binfmt字段。
- 创建动态链接程序表,并把它们放在用户态堆栈。
- 调用do_brk()函数创建一个新的匿名线性区来映射程序的bss段。
- 调用start_thread()宏修改保存在内核态堆栈。
- 返回0.
以上分析可得:
Linux内核使用execve系统调用就开始进行程序的装载。execve()系统调用相应的实现是sys_execve()。
sys_execve()进行一些参数检查复制之后,调用do_execve()。
do_execve会首先查找被执行的文件,如果找打文件。则读取文件的前128个字节以确定被检查的文件的格式。然后,继续调用search_binary_handle()去搜索合适的可执行文件装载处理过程。
对ELF可执行文件的装载处理工程是load_elf_binary()。步骤是:检查elf文件的有效性,寻找动态链接库的“.interp”段,设置动态链接器的路径,根据ELF的可执行文件的程序头表的描述,对ELF文件进行映射,初始化ELF进程环境。
最后,将系统调用的返回地址修改为ELF可执行文件的入口点。在设置了eip之后,sys_execve返回到用户态时,程序就返回到新的程序开始执行,ELF可执行文件装载完成。详细代码见后续分析
ELF文件格式与进程地址空间的联系:在壮载ELF文件时,会将文件头部的.init、.text、.rodata段装载到进程的代码段中,将ELF中数据相关的部分,装载到数据段中。在动态链接的过程中,ELF文件全局变量的数据区的部分会在进程空间中拷贝一份,代码段则多个进程进行共享。exec执行之后,EIP指向的地方:EIP指向新程序的入口点,在sys_execve执行完成进入用户态之后,将从新的程序入口点继续执行。
源码分析
fork调用
fork()调用创建一个新的进程,该进程几乎是当前进程的一个完全拷贝。由fork()创建的新进程被称为子进程。fork函数被调用一次但返回两次。两次返回的唯一区别是子进程中返回0值,而父进程中返回子进程ID。子进程是父进程的副本,它将获得父进程数据空间、堆、栈等资源的副本。注意,子进程持有的是上述存储空间的“副本”,这意味着父子进程间不共享这些存储空间。Linux将复制父进程的地址空间内容给子进程,因此,子进程拥有独立的地址空间。
从输出结果可以看出:父进程和子进程的栈和堆的数据是相同的。这些数据在创建子进程时是通过拷贝产生的。
execl调用
系统调用exec是以新的进程去代替原来的进程,但进程的PID保持不变。因此,可以这样认为,exec系统调用并没有创建新的进程,只是替换了原来进程上下文的内容。原进程的代码段,数据段,堆栈段被新的进程所代替。
|
|
我们执行一个不存在的hello_world程序,会报错。
我们如果添加一个函数,现在我们继续运行execl_example程序,这时输出为:Hello World!
通过比较两次输出,我们发现:当execl成功时,原有的进程执行就会被打断,替换为新的进程继续执行。
使用汇编进行系统调用
我们知道在Linux中,每个系统调用都对应一个系统调用号。这个系统调用号是在unistd.h中定义的。文件的位置是在:
/usr/src/linux-headers-2.6.28-11-generic/arch/x86/include/asm/unistd_32.h
使用汇编调用fork:
可以看到fork的系统调用号是2,我们现在使用汇编代码重新编写fork_example.c
输出结果为:CHILD PROCESS: stack_data, heap_data
PARENT PROCESS: stack_data, heap_data
可以尝试将调用号替换一下,改成$0x3,得到的结果是:FORK FAILED.
使用汇编调用execl:我们再尝试一下使用汇编调用execl。通过上面的观察我们可以看到execl的系统调用号是11.
运行结果为:Hello World!
如果将系统调用号改为0x3,输出结果为:The execl must be failed!
系统调用过程详解
通过上一步的过程,我们了解到,系统调用在内核中的执行是依靠中断实现的。如果我们想进一步定位fork和execl的代码,我们需要先了解系统调用的详细过程。即回答以下两个问题:
1.中断是怎么工作的?
2.int 0x80中断是怎么工作的?
中断是怎么工作的
在Linux操作系统中,中断是通过中断描述符表工作的。中断描述符表(Interrupt Descriptor Table, IDT)是一个系统表,它与每一个中断或者异常向量相联系,每一个向量在表中有相应的中断或者异常处理程序的入口地址。内核在允许中断发生前,必须适当的初始化IDT。对于每个中断,都会有对应的中断处理程序。当产生一个中断时,Linux根据中断向量表中对应的项找到存储中断处理程序的地址,然后调用相应的中断处理程序。中段描述符表在内存中的地址存储在idtr寄存器中。内核在启动中断前,必须初始化IDT,然后将IDT的地址壮载到idtr中。
内核初始化的时候调用trap_init()函数和init_IRQ()函数初始化中断向量表。
int 0x80中断是怎么工作的
通过上面的分析,我们知道每个中断都有对应的处理程序。在系统调用的过程中,会有一个系统调用分派表,每个表项存储了一个系统调用。系统调用中断处理程序,根据系统调用号找到对应的系统调用执行。对于系统调用,参数的传递是通过寄存器ebx ecx edx进行传递的。eax中存储的是系统调用号。系统调用最大为__NR_syscalls个。
在arch/x86/include/asm/irq_vectors.h中定义了
define SYSCALL_VECTOR 0x80
现在我们查找trap_init函数,在arch/x86/kernel/traps.c中
set_system_trap_gate(SYSCALL_VECTOR, &system_call);
现在,查找system_call函数,在arch/x86/kernel/entry_32.s中:
在include/uapi/asm_generic/unistd.h中找到:
SYSCALL(NR_fork, sys_fork)
fork的系统调用号是2,对应的系统调用分派表中为sys_fork函数。在kernel/fork.c中找到如下代码:
do_fork源码分析
现在查找do_fork函数,也在kernel/fork.c中:
可以看到do_fork调用了copy_process完成了绝大部分的工作。copy_process位于同一个文件当中:
dup_task_struct也在fork.c文件中
|
|
通过上面的代码,可以总结出fork的工作的基本流程是: