进程的创建与可执行程序的加载
http://blog.csdn.net/q_l_s/article/details/52597330
一、进程试探
编程实现一个简单的shell程序
点击(此处)折叠或打开
- #include<stdio.h>
- #include<stdlib.h>
- #include<unistd.h>
- #include<string.h>
- #include<sys/types.h>
- #define NUM 1024
- int mystrtok(char *argv[], char* string)
- {//遍历字符串,截取空格之间的各个字符子串,保存入参数数组argv中
- int i=0;
- char delim[]=" ";
- argv[0] = strtok(string,delim);//将字符串string根据间隔符delim分成一个个片段
- while(argv[i]!=NULL)
- {
- argv[++i] = strtok(NULL,delim);
- }
- return 0;
- }
- int main()
- {
- char str[NUM];
- int status;
- pid_t pid;
- char * argv[NUM];
- while(1)
- {
- printf("n$:");
- fgets(str,NUM,stdin);//从键盘中读取命令字符,直到遇到换行符
- str[strlen(str)-1]='0';//读取的字符串中添加字符串结束符’0’
- status = mystrtok(argv,str);//截取命令及参数存入argv数组中
- if(status!=0)//截取失败
- {
- printf("fail to get command!\n");
- }
- pid = fork();
- if(pid==-1)
- printf("fork failure!\n");
- else if(pid==0)//子进程
- {
- execvp(argv[0],argv);
- //execvp()会从PATH 环境变量所指的目录中查找符合参数argv[0] 的文件名,找到后便执行该文件,然后将第二个参数argv传给该欲执行的文件
- }
- else//父进程
- {
- wait();
- }
- }
- }
运行结果如下:
二、C代码嵌入汇编代码
1、c代码嵌入一般汇编代码
点击(此处)折叠或打开
- #include<stdio.h>
- int main()
- {
- unsigned int val1=1;
- unsigned int val2=2;
- unsigned int val3=0;
- printf("vala:%d,val2:%d,val3:%d\n",val1,val2,val3);
- asm volatile(
- "movl $0,%%eax\n\t"
- "addl %1,%%eax\n\t"
- "addl %2,%%eax\n\t"
- "movl %%eax,%0\n\t"
- :"=m"(val3)
- :"c"(val1),"d"(val2)
- );
- printf("val1:%d+val2:%d=val3:%d\n",val1,val2,val3);
- }
2、C代码嵌入系统调用汇编代码
点击(此处)折叠或打开
- #include<stdio.h>
- #include<time.h>
- int main()
- {
- time_t tt;
- struct tm *t;
- int ret;
- #if 0
- time(&tt);
- printf("tt:%ld\n",tt);
- #else
- //没有使用常规寄存器%ebx传参
- asm volatile(
- "mov $0,%%ebx\n\t" //没有使用参数tt
- "mov $0xd,%%eax\n\t"
- "int $0x80\n\t"
- "mov %%eax,%0\n\t"
- :"=m"(tt)
- );
- printf("tt:%ld\n",tt);
- t=localtime(&tt);
- printf("time:%d:%d:%d:%d:%d:%d\n",t->tm_year+1900,t->tm_mon,t->tm_mday,t->tm_hour,t->tm_min,t->tm_sec);
- //使用常规寄存器%ebx传参
- asm volatile(
- "mov %1,%%ebx\n\t" //使用参数tt
- "mov $0xd,%%eax\n\t"
- "int $0x80\n\t"
- "mov %%eax,%0\n\t"
- :"=m"(ret)
- :"b"(&tt)
- );
- printf("tt:%ld\n",tt);
- t=localtime(&tt);
- printf("time:%d:%d:%d:%d:%d:%d\n",t->tm_year+1900,t->tm_mon,t->tm_mday,t->tm_hour,t->tm_min,t->tm_sec);
- #endif
- return 0;
- }
首先看看time的汇编代码,执行上述代码中的time函数,然后反汇编如下:
对time的反汇编来看gcc编译器也没有使用ebx寄存器。
然后执行上述代码中else的部分进行测试,结果如下图:
输出不用%ebx传递参数的结果与用%ebx传递参数的运行结果完全一样。
正如我们看到的那样,tiem系统调用函数没有使用%ebx进行参数传递,显然没有遵守系统调用参数传递的一般方法。
三、分析fork和exec系统调用在内核中的执行过程
1、task_struct进程控制块
为了描述和控制进程的运行,操作系统为每个进程定义了一个数据结构,即进程控制块(Process Control Block,PCB)。我们通常所说的进程实体包含程序段,数据段和PCB三部分。PCB在进程实体中占据重要的地位。所谓的创建进程,实质上就是创建PCB的过程;而撤销进程,实质上也就是对PCB的撤销。在Linux内核中,PCB对应着一个具体的结构体—task_struct,也就是所谓的进程描述符(process descriptor)。该数据结构中包含了与一个进程相关的所有信息,比如包含众多描述进程属性的字段,以及指向其他与进程相关的结构体的指针。include/linux/sched.h包含有struct task_struct的定义:
进程描述符中有指向mm_struct结构体的指针mm,这个结构体是对该进程用户空间的描述;也有指向fs_struct结构体的指针fs,这个结构体是对进程当前所在目录的描述;也有指向files_struct结构体的指针files,这个结构体是对该进程已打开的所有文件进行描述;另外还有一个小型的进程描述符(low-level information)—thread_info。在这个结构体中,也有指向该进程描述符的指针task。因此,这两个结构体是相互关联的。
Linux为每个进程分配一个8KB大小的内存区域,用于存放该进程两个不同的数据结构:Thread_info 和进程的内核堆栈,示意图如下:
2、fork系统调用在内核中的执行过程
当执行fork系统调用时,操作系统会执行以下动作:
(1) 内核确保有创建新进程所需的充足的系统资源。其完成过程如下:
① 内核确保系统可以处理多个将要调度的进程,而且调度程序上的负载是可以管理的。
② 内核确保这个特定的用户当前没有运行过多垄断使用现有资源的进程。
③ 内核确保为新进程提供足够的内存空间。
操作系统已经知道:此时新进程和父进程在各个方面都是相同的。这还包括内存要求。在交换系统中,整个内存都要是可用的。在纯分页系统中,需要大量用于保存整个地址空间和页面映射表的内存空间。在请求分页调度中,启动进程,至少页面映射表必不可少。在请求分页调度方法中,地址空间中更多的页面可以通过缺页错误累计得到。
如果内存空间不足,内核检查磁盘上是否有空间,如果有,就占用该空间的交换区。像前面在进程状态转移中讨论的一样,据此确定子进程的状态。
(2) 内核现在从进程表中找到一个位置,然后开始构造子进程的上下文。
(3) 内核维护"下一个可用的ID号"的全局值。任何时候,当fork系统调用创建新进程时,内核将该ID分配给新的子进程,并将该编号加1。内核还要设置一个最大值,当设置超过这个值的时候,系统就不能处理任何进程。如果该编号等于或大于这个最大值,内核从0重新分配编号,但是另一方面希望pid等于0的进程已经终止运行。
(4) 内核初始化子进程的进程表插槽中的字段,如下:
① 内核将真实有效的用户ID从父进程的进程表插槽中复制到子进程对应的位置。
② 内核还要将父进程的准确值复制给子进程。
③ 内核通过将父进程ID复制到子进程插槽,从而链接进程树结构中的子进程。
④ 内核初始化子进程插槽中不同的调度字段和统计字段,如初始优先级、CPU使用情况等。
⑤ 内核将该子进程的状态设置为"正在创建"。
(5) 现在,内核搜索父进程u区(进程信息交换区)中的文件描述符,并沿着指针从用户打开文件描述符到文件表条目,同时将文件表中那些条目的引用计数增加1。
(6) 内核为子进程的u区、区域表、页表等分配内存空间。
(7) 现在,除了子进程u区指向进程表插槽的指针要做适当的调整之外,内核将父进程的u区复制给子进程。这是因为父进程和子进程在进程表中有两个不同的条目。因此,指向这两个不同条目的指针也不相同。此时,所有其他内容是相同的。
(8) 内核将数据和堆栈区(非共享的部分)复制到子进程的另一个内存区,并调整区域表条目。然而,它只保存文本区的一个副本,因为文本区是共享的。诚如所示,此时文本包含相同的程序代码。
(9) 内核在子进程上下文的静态部分后面创建动态内容。它复制父进程上下文包含保存Fork系统调用的寄存器和内核堆栈的第一层。此时,父进程和子进程的内核堆栈的内容完全相同。
(10) 内核创建子进程第2层的伪程序上下文,这个伪程序上下文包括第1层保存的寄存器上下文。它在寄存器内容保存区中设置程序计数器(PC)和其他寄存器,这样就可以在适当的位置"重新开始"执行子进程。
(11) 现在,内核将子进程状态从"准备就绪"改变成"准备运行"(根据情况,要么在内存中,要么被交换)。它将子进程ID返回给用户。
(12) 调度程序最终调度子进程。在程序中,调度程序检查它是不是子进程。因为如果是子进程,它会执行"Exec"系统调用,由此将新程序加载到子进程的地址空间中。下一节将介绍"Exec"这个系统调用。上图给出了复制的进程地址空间(包括共享的文本区)、u区(包括指向文件表的相同指针)、内核堆栈等内容。
3、exec系统调用在内核中的执行过程
内核通过以下步骤实现该系统调用:
(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表结构就和父进程的不同。如果该子进程调用另一个子程序,就会重复执行/分支进程。这样就会创建不同深度层次的进程结构。
三、分析在fork产生新进程中ELF文件格式与进程地址空间的联系
1、进程的虚拟地址空间
每个程序都有自己的虚拟地址空间(Virtual Address Space),大小由硬件平台(CPU位数)决定。 如32位平台下每个程序都有4G虚拟空间。但4G空间不是都分配给程序的用户空间,还有系统的虚拟空间。如Linux系统默认情况下高1G为系统的虚拟地址空间,低3G为用户空间。 这也就是说每个进程原则上最多可使用3G的虚拟空间。
2、 进程装载
覆盖装入(Overlay)和页映射(Paging)是两种典型的动态装载方法。现在前者已经不用了。
创建一个进程,然后装载相应的可执行文件并且执行。上述过程最开始只需要做三件事情:
①创建一个独立的虚拟地址空间。主要是分配一个页目录(Page Directory)。
②读取可执行文件的头,并且建立虚拟空间和可执行文件的映射关系。主要是把可执行文件映射到虚拟地址空间,即做虚拟页和物理页的映射,以便“缺页”时载入。
③将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。从ELF文件中的入口地址开始执行程序。
3、过程分析
在bash下执行一个程序时,Linux是怎样装载这个ELF文件并执行的呢?
首先bash调用fork()系统调用创建一个新的进程,然后新的进程调用execve()系统调用执行指定的ELF文件。 bash进程继续返回等待新进程执行结束,然后重新等待用户输入命令。execve()系统调用被定义在unistd.h,它的原型如下:
int execve(const char *filenarne, char *const argv[], char *const envp[]);
它的三个参数分别是被执行的程序文件名、执行参数和环境变最。Glibc对execvp()系统调用进行了包装,提供了execl(), execlp(), execle(), execv()和execvp()等5个不同形式的exec系列API,它们只是在调用的参数形式上有所区别,但最终都会调用到execve()这个系统中。
调用execve()系统调用之后,再调用内核的入口sys_execve()。 sys_execve()进行一些参数的检查复制之后,调用do_execve()。 因为可执行文件不止ELF一种,还有java程序和以“#!”开始的脚本程序等, 所以do_execve()会首先检查被执行文件,读取前128个字节,特别是开头4个字节的魔数,用以判断可执行文件的格式。 如果是解释型语言的脚本,前两个字节“#!"就构成了魔数,系统一旦判断到这两个字节,就对后面的字符串进行解析,以确定程序解释器的路径。
当do_execve()读取了这128个字节的文件头部之后,然后调用search_binary_handle()去搜索和匹配合适的可执行文件装载处理过程。Linux中所有被支持的可执行文件格式都有相应的装载处理过程,search_binary_handle()会通过判断文件头部的魔数确定文件的格式,并且调用相应的装载处理过程。如ELF用load_elf_binary(),a.out用load_aout_binary(),脚本用load_script()。其中ELF装载过程的主要步骤是:
①检查ELF可执行文件格式的有效性,比如魔数、程序头表中段(Segment)的数量。
②寻找动态链接的”.interp”段(该段保存可执行文件所需要的动态链接器的路径),设置动态链接器路径。
③根据ELF可执行文件的程序头表的描述,对ELF文件进行映射,比如代码、数据、只读数据。
④初始化ELF进程环境,比如进程启动时EDX寄存器的地址应该是DT_FINI的地址(结束代码地址)。
⑤将系统调用的返回地址修改成ELF可执行文件的入口点,这个入口点取决于程序的链接方式,对于静态链接的ELF可执行文件,这个程序入口就是ELF文件的文件头中e_enEry所指的地址;对于动态链接的ELF可执行文件,程序入口点是动态链接器。
当ELF被load_elf_binary()装载完成后,函数返回至do_execve()在返回至sys_execve()。在load_elf_binary()中(第5步)系统调用的返回地址已经被改成ELF程序的入口地址了。 所以当sys_execve()系统调用从内核态返回到用户态时,EIP寄存器直接跳转到了ELF程序的入口地址,于是新的程序开始执行,ELF可执行文件装载完成。
四、实验心得
在UNIX中,fork是进程创建另一个进程的唯一方法。只有第一个进程也就是被称作"init"的进程需要"手工创建"。所有其他进程都是用fork这个系统调用创建的。fork系统调用只是复制了父进程的数据和堆栈,并在这两个进程之间共享文本区。fork系统调用采用比较聪明的方式—"写时拷贝(copy-on-write)"技术,使得fork结束后并不立刻复制父进程的内容,而是到了真正实用的时候才复制,这样使效率大大提高。fork函数创建了一个子进程后,子进程会调用exec族函数执行另外一个程序。
随着硬件MMU的诞生,多进程、多用户、虚拟存储的操作系统出现以后,可执行文件的装载过程变得非常复杂。引入了进程的虚拟地址空间;然后根据操作系统如何为程序的代码、数据、堆、栈在进程地址空间中分配,它们是如何分布的;最后以页映射的方式将程序映射至进程虚拟地址空间。
动态链接是一种与静态链接程序不同的概念,即一个单一的可执行文件模块被拆分成若干个模块,在程序运行时进行链接的一种方式。然后根据实际例子do_exece()分析了ELF装载的大致过程,中间实现了动态链接。