简介: 本文基于Linux™系统对进程建立与加载进行分析,文中实现了Linux库函数fork、exec,剖析内核态执行过程,并进一步展现进程建立过程当中进程控制块字段变化信息及ELF文件加载过程。 html
1、初识Linux进程 linux
进程这个概念是针对系统而不是针对用户的,对用户来讲,他面对的概念是程序。当用户敲入命令执行一个程序的时候,对系统而言,它将启动一个进程。但和程序不一样的是,在这个进程中,系统可能须要再启动一个或多个进程来完成独立的多个任务。简单介绍下进程的结构。 数组
1.1 Linux下的进程查看 session
咱们可使用$ps命令来查询正在运行的进程,好比$ps -eo pid,comm,cmd,下图为执行结果: 数据结构
(-e表示列出所有进程,-o pid,comm,cmd表示咱们须要PID,COMMAND,CMD信息) 并发
每一行表明了一个进程。每一行又分为三列。第一列PID(process IDentity)是一个整数,每个进程都有一个惟一的PID来表明本身的身份,进程也能够根据PID来识别其余的进程。第二列COMMAND是这个进程的简称。第三列CMD是进程所对应的程序以及运行时所带的参数。
框架
(第三列有一些由中括号[]括起来的。它们是kernel的一部分功能,显示为进程的样子主要是为了方便操做系统管理。) xss
咱们看第一行,PID为1,名字为init。这个进程是执行/bin/init这一文件(程序)生成的。当Linux启动的时候,init是系统建立的第一个进程,这一进程会一直存在,直到咱们关闭计算机。 函数
1.2 Linux下进程的结构 测试
Linux下一个进程在内存里有三部分的数据,就是"代码段"、"堆栈段"和"数据段"。其实学过汇编语言的人必定知道,通常的CPU都有上述三种段寄存器,以方便操做系统的运行。这三个部分也是构成一个完整的执行序列的必要的部分。
"代码段",顾名思义,就是存放了程序代码的数据,假如机器中有数个进程运行相同的一个程序,那么它们就可使用相同的代码段。"堆栈段"存放的就是子程序的返回地址、子程序的参数以及程序的局部变量。而数据段则存放程序的全局变量,常数以及动态数据分配的数据空间(好比用malloc之类的函数取得的空间)。系统若是同时运行数个相同的程序,它们之间就不能使用同一个堆栈段和数据段。
1.3 Linux进程描述符
在Linux中每个进程都由task_struct 数据结构来定义.task_struct就是咱们一般所说的PCB.它是对进程控制的惟一手段也是最有效的手段. 当咱们调用fork() 时,系统会为咱们产生一个task_struct结构。而后从父进程,那里继承一些数据, 并把新的进程插入到进程树中,以待进行进程管理。
如下是进程描述符的源码:
1 struct task_struct { 2 volatile long state; 3 unsigned long flags; 4 int sigpending; 5 mm_segment_taddr_limit; 6 volatile long need_resched; 7 int lock_depth; 8 long nice; 9 unsigned long policy; 10 struct mm_struct *mm; 11 int processor; 12 unsigned long cpus_runnable, cpus_allowed; 13 struct list_head run_list; 14 unsigned longsleep_time; 15 struct task_struct *next_task, *prev_task; 16 struct mm_struct *active_mm; 17 struct list_headlocal_pages; 18 unsigned int allocation_order, nr_local_pages; 19 struct linux_binfmt *binfmt; 20 int exit_code, exit_signal; 21 int pdeath_signal; 22 unsigned long personality; 23 int did_exec:1; 24 pid_t pid; 25 pid_t pgrp; 26 pid_t tty_old_pgrp; 27 pid_t session; 28 pid_t tgid; 29 int leader; 30 struct task_struct*p_opptr,*p_pptr,*p_cptr,*p_ysptr,*p_osptr; 31 struct list_head thread_group; 32 struct task_struct *pid hash_next; 33 struct task_struct **pid hash_pprev; 34 wait_queue_head_t wait_chldexit; 35 struct completion *vfork_done; 36 unsigned long rt_priority; 37 unsigned long it_real_value, it_prof_value, it_virt_value; 38 unsigned long it_real_incr, it_prof_incr, it_virt_value; 39 struct timer_listreal_timer; 40 struct tmstimes; 41 unsigned long start_time; 42 long per_cpu_utime[NR_CPUS],per_cpu_stime[NR_CPUS]; 43 uid_t uid,euid,suid,fsuid; 44 gid_t gid,egid,sgid,fsgid; 45 int ngroups; 46 gid_t groups[NGROUPS]; 47 kernel_cap_tcap_effective, cap_inheritable, cap_permitted; 48 int keep_capabilities:1; 49 struct user_struct *user; 50 struct rlimit rlim[RLIM_NLIMITS]; 51 unsigned shortused_math; 52 charcomm[16]; 53 int link_count, total_link_count; 54 struct tty_struct*tty; 55 unsigned int locks; 56 struct sem_undo*semundo; 57 struct sem_queue *semsleeping; 58 struct thread_struct thread; 59 struct fs_struct *fs; 60 struct files_struct *files; 61 spinlock_t sigmask_lock; 62 struct signal_struct *sig; 63 sigset_t blocked; 64 struct sigpendingpending; 65 unsigned long sas_ss_sp; 66 size_t sas_ss_size; 67 int (*notifier)(void *priv); 68 void *notifier_data; 69 sigset_t *notifier_mask; 70 u32 parent_exec_id; 71 u32 self_exec_id; 72 spinlock_t alloc_lock; 73 void *journal_info; 74 };
主要结构分析:
volatile long state; 说明了该进程是否能够执行,仍是可中断等信息
unsigned long flags; Flage 是进程号,在调用fork()时给出
int sigpending; 进程上是否有待处理的信号
mm_segment_taddr_limit; 进程地址空间,区份内核进程与普通进程在内存存放的位置不一样(0-0xBFFFFFFF foruser-thead 0-0xFFFFFFFF forkernel-thread)
volatile long need_resched;调度标志,表示该进程是否须要从新调度,若非0,则当从内核态返回到用户态,会发生调度
struct mm_struct *mm; 进程内存管理信息
pid_tpid; 进程标识符,用来表明一个进程
pid_tpgrp; 进程组标识,表示进程所属的进程组
task_struct的数据成员mm指向关于存储管理的struct mm_struct结构。它包含着进程内存管理的不少重要数据,如进程代码段、数据段、未未初始化数据段、调用参数区和进程。
2、 如何建立一个进程
2.1 Linux下的进程控制
在传统的Linux环境下,有两个基本的操做用于建立和修改进程:函数fork()用来建立一个新的进程,该进程几乎是当前进程的一个彻底拷贝;函数族exec( )用来启动另外的进程以取代当前运行的进程。
关于fork()与execl(),去年写过一篇文章对部分源码进行过度析:system()和execv()函数使用详解
2.2 fork()
一个进程在运行中,若是使用了fork,就产生了另外一个进程。下面就看看如何具体使用fork,这段程序演示了使用fork的基本框架:
#include <stdio.h> void main() { int i; if ( fork() == 0 ) { /* 子进程程序 */ for ( i = 1; i <1000; i ++ ) printf("This is child process\n"); } else { /* 父进程程序*/ for ( i = 1; i <1000; i ++ ) printf("This is origin process\n"); } }
运行结果以下:
从上图能够看出父进程和子进程并发运行,内核可以以任意方式交替运行它们,这里是父进程先运行,而后是子进程。可是在另一个系统上运行时不必定是这个顺序。
使用fork函数建立的子进程从父进程的继承了所有进程的地址空间,包括:进程上下文、进程堆栈、内存信息、打开的文件描述符、信号控制设置、进程优先级、进程组号、当前工做目录、根目录、资源限制、控制终端等。
fork建立子进程,首先调用int80中断,而后将系统调用号保存在eax寄存器中,进入内核态后调用do_fork(),其实是建立了一份父进程的拷贝,他们的内存空间里包含了彻底相同的内容,包括当前打开的资源,数据,固然也包含了程序运行到的位置,也就是说fork后子进程也是从fork函数的位置开始往下执行的,而不是从头开始。而为了判别当前正在运行的是哪一个进程,fork函数返回了一个pid,在父进程里标识了子进程的id,在子进程里其值为0,在咱们的程序里就根据这个值来分开父进程的代码和子进程的代码。
一旦使用fork建立子进程,则进程地址空间中的任何有效地址都只能位于惟一的区域,这些区域不能相互覆盖。编写以下代码进行测试:
1 #include <stdio.h> 2 #include <sys/types.h> 3 #include <unistd.h> 4 5 struct con { 6 int a; 7 }; 8 9 int main() { 10 pid_t pid; 11 struct con s; 12 s.a = 2; 13 struct con* sp = &s; 14 pid = fork(); 15 if (pid > 0) { 16 printf("parent show %p, %p, a = %d\n", sp, &sp->a, sp->a); 17 sp->a = 1; 18 sleep(10); 19 printf("parent show %p, %p, a = %d\n", sp, &sp->a, sp->a); 20 printf("parent exit\n"); 21 } 22 else { 23 printf("child show %p, %p, a = %d\n", sp, &sp->a, sp->a); 24 sp->a = -1; 25 printf("child change a to %d\n", sp->a); 26 } 27 return 0; 28 }
得到结果以下:
从上面的分析能够看出进程copy过程当中,fork就是基于写时复制,只读代码段是能够同享的,通常CPU都是以"页"为单位来分配内存空间的,每个页都是实际物理内存的一个映像,象INTEL的CPU,其一页在一般状况下是 4086字节大小,而不管是数据段仍是堆栈段都是由许多"页"构成的,fork函数复制这两个段,物理空间上两个进程的数据段和堆栈段都仍是共享着的,当有一个进程写了某个数据时,这时两个进程之间的数据才有了区别,系统就将有区别的" 页"从物理上也分开。系统在空间上的开销就能够达到最小。
2.3 exec( )函数族
下面咱们来看看一个进程如何来启动另外一个程序的执行。在Linux中要使用exec函数族。系统调用execve()对当前进程进行替换,替换者为一个指定的程序,其参数包括文件名(filename)、参数列表(argv)以及环境变量(envp)。exec函数族固然不止一个,但它们大体相同,在 Linux中,它们分别是:execl,execlp,execle,execv,execve和execvp,下面以execve为例。
一个进程一旦调用exec类函数,它自己就"死亡"了,execve首先调用int80中断,而后将系统调用号保存在eax寄存器中,调用sys_exec,将可执行程序加载到当前进程中,系统把代码段替换成新的程序的代码,废弃原有的数据段和堆栈段,并为新程序分配新的数据段与堆栈段,惟一留下的,就是进程号,也就是说,对系统而言,仍是同一个进程,不过已是另外一个程序了。(不过exec类函数中有的还容许继承环境变量之类的信息。)
那么若是个人程序想启动另外一程序的执行但本身仍想继续运行的话,怎么办呢?那就是结合fork与exec的使用。下面一段代码显示如何启动运行其它程序:
1 #include <stdio.h> 2 #include <unistd.h> 3 int main(){ 4 if(!fork()) 5 execve("./test",NULL,NULL); 6 else 7 printf("origin process!\n"); 8 return 0; 9 }
输出结果以下:
原始进程和execve建立的新进程,并发运行,exec函数在当前进程的上下文中加载并运行一个新的程序,而且不返回建立进程的函数。
接下来,咱们分析一下execve函数执行过程当中,以及可执行程序的加载过程,在内核中execve()系统调用相应的入口是sys_execve(),函数首先经过 pt_regs参数检查赋值在执行该系统调用时,用户态下的CPU寄存器在核心态的栈中的保存状况。经过这个参数,sys_execve能够得到保存在用户空间的如下信息:可执行文件路径的指针(regs.ebx中)、命令行参数的指针(regs.ecx中)和环境变量的指针(regs.edx中)。
struct pt_regs { long ebx; long ecx; long edx; long esi; long edi; long ebp; long eax; int xds; int xes; long orig_eax; long eip; int xcs; long eflags; long esp; int xss; }
而后调用do_execve函数,首先查找被执行的文件,读取前128个字节,确实加载的可执行文件的类型,而后调用search_binary_handle()搜索和匹配合适的可执行文件装载处理过程,elf调用load_elf_binary();
struct linux_binprm{ char buf[BINPRM_BUF_SIZE]; //保存可执行文件的头128字节 struct page *page[MAX_ARG_PAGES]; struct mm_struct *mm; unsigned long p; //当前内存页最高地址 int sh_bang; struct file * file; //要执行的文件 int e_uid, e_gid; //要执行的进程的有效用户ID和有效组ID kernel_cap_t cap_inheritable, cap_permitted, cap_effective; void *security; int argc, envc; //命令行参数和环境变量数目 char * filename; //要执行的文件的名称 char * interp; //要执行的文件的真实名称,一般和filename相同 unsigned interp_flags; unsigned interp_data; unsigned long loader, exec; };
load_elf_binary()加载过程以下:
a.检查ELF可执行文件的有效性,好比魔数(开头四个字节,elf文件为0x7F),段“Segment”的数量;
b.寻找动态连接.interp段,设置动态链接器的路径;
c.根据elf可执行文件的程序头表的描述,对elf文件进行映射;
d.初始化elf进程环境,好比启动时候的edx寄存器地址是DT_FINI的地址;
e.将系统调用的返回地址修改成elf可执行文件的入口点,就是e_entry所存的地址。对于动态连接的elf可执行文件就是动态链接器。
加载完成后返回do_execve返回到exeve(),从内核态转化为用户态并返回e步所在更改的程序入口地址。即eip存储器直接跳转到elf程序的入口地址,新进程执行。
3、 进程虚拟地址空间与可执行程序格式
从操做系统来看,一进程最关键的特征是它拥有独立的虚拟地址空间,通常状况下,建立过程以下:
①建立一个独立的虚拟空间。
②读取可执行文件头,而且简历虚拟空间与可执行文件的映射关系。
③将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。
在讨论地址空间,进程描述符以及ELF文件格式的以前,咱们先介绍一点预备知识,因为第一节已经介绍了进程描述符的部分信息,在这里介绍下ELF文件格式:
在第二节使用execve时,咱们使用了test可执行程序进行测试,代码以下:
#include <stdio.h> int main(int argc, char const *argv[]) { printf("%s\n","execve the new process!"); return 0; }
描述“Segment”的结构叫程序头,它描述了ELF文件该如何被操做系统映射到进程的虚拟空间:
上图共有5个Segment。从装载的角度看,咱们只关心两个LOAD和DYNAMIC,其余Segment在装载过程当中只具备辅助做用,映射过程当中,根据读写执行权限映射到不一样的虚拟内存区域
第四行LOAD表示代码段,具备可读可执行权限,被映射到虚拟地址0x08048000,长度为0x005c4字节的虚拟存储区域中。
第五行LOAD表示长度为0x100个字节的数据段,具备可读可写权限,被映射到开始于虚拟地址0x08049f08处,长度为0x0011c字节的虚拟存储区域中。
DYNAMIC字段表示的是动态连接器所须要的基本信息,具备可读可写权限,被映射到开始于虚拟地址0x08049f14处,长度为0x000e8字节的虚拟存储区域中。
在第二节中执行以下命令后,ELF文件正式开始加载工做,执行第二节中的加载过程:
execve("./test",NULL,NULL);
文件在加载过程当中是以elf可执行文件的形式加载,加载过程初始化时,根据elf段头部表信息,初始化bss段、代码段和数据段的起始地址和终止地址。
而后调用mm_release释放掉当前进程所占用的内存(old_mm),而且将当前进程的内存空间替换成bprm->mm所指定的页面,而这块空间,即是新进程在初始化时暂时向内核借用的存储空间,当这段空间读取到目前进程的mm之后,事实上也就完成了旧进程到新进程的替换。这个时候bprm->mm这块内核空间也就完成了它的使命,因而被置为NULL予以回收。(bprm为中保存了读取128字节elf文件头)。
mm指向关于存储管理的struct mm_struct结构,其包含在task_struct中。
而后加载段地址到虚拟内存地址,映射以下:
而后另外一部分段映射到数据区,关系以下:
到这里,对于elf文件的载入(包括以前对可执行文件运行环境准备工做)的分析基本上能够告一段落了。
4、进程建立中动态连接库的表现形式
动态连接的基本思想是把程序按照模块拆分,运行时才将它们连接在一块儿造成一个完整的程序,而不是像静态连接同样把全部的程序模块都连接成一个单独的可执行文件。多个动态连接库均以ELF文件存储,执行过程当中以依赖树的关系存在,并以深度优先的方式加载动态连接库,最终将可执行程序返回给用户。
咱们经过如下实例来测试动态连接库在虚拟地址及ELF文件的中表现形式:
/* Lib.c */ #include <stdio.h> void lab(int i){ printf("Printing from lib.so %d\n", i); sleep(-1); }
使用gcc编译生成一个共享对象文件,而后连接dyn.c程序,生成可执行文件dyn:
gcc -fPIC -shared -o lib.so lib.c gcc -o dyn dyn.c ./lib.so
运行并查看进程的虚拟地址空间分布:
整个进程的虚拟地址空间中,多出了几个文件的映射。dyn与lib.so同样,都被系统映射到进程的虚拟地址空间,地址与长度均不相同。由第二节可知,在映射完可执行文件以后,操做系统会先启动一个动态连接器。
动态连接器的的位置由ELF文件中的“.interp”段决定,而段“.dynamic”为动态连接提供了:依赖哪些共享对象、动态连接符号表的位置,动态连接重定位表的位置、共享对象初始化代码的地址等。可经过readelf查看".dynamic" 段的内容:
动态连接过程须要动态符号表来肯定函数的定义和引用关系,还须要重定位表来修正导入符号的引用。初始化完成后堆栈中保存了动态链接器所须要的一些辅助信息数组(其中包括程序入口地址,程序表头地址,程序表头项数及大小)。动态连接库最后被映射到进程地址空间的共享库区域段。
完成重定位和初始化后,全部准备工做结束,所须要的共享对象也都已经装载而且连接完成。最后将进程的控制权转交给dyn程序的入口并开始执行。
以上内容均为我的理解,因为能力有限,可能会有诸多错误,但愿可以和你们一块儿讨论修正。