第8节 进程的创建和执行

6.8.1 进程的创建 


 新的进程通过克隆旧的程序(当前进程)而建立。fork() 和 clone()(对于线程)系统调用可用来建立新的进程。这两个系统调用结束时,内核在系统的物理内存中为新的进程分配新的 task_struct 结构,同时为新进程要使用的堆栈分配物理页。Linux 还会为新的进程分配新的进程标识符。然后,新 task_struct 结构的地址保存在链表中,而旧进程的 task_struct 结构内容被复制到新进程的 task_struct 结构中。 


 在克隆进程时,Linux 允许两个进程共享相同的资源。可共享的资源包括文件、信号处理程序和虚拟内存等(通过继承)。当某个资源被共享时,该资源的引用计数值会增加 1,从而只有两个进程均终止时,内核才会释放这些资源。图 6.23 说明了父进程和子进程共享打开的文件。


 






图 6.23父进程和子进程共享打开的文件 


系统对进程虚拟内存的克隆过程则更加巧妙些。新的 vm_area_struct 结构、新进程自己的 mm_struct 结构以及新进程的页表必须在一开始就准备好,但这时并不复制任何虚拟内存。如果旧进程的某些虚拟内存在物理内存中,而有些在交换文件中,那么虚拟内存的复制将会非常困难和费时。实际上,Linux 采用了称为写时复制的技术,也就是说,只有当两个进程中的任意一个向虚拟内存中写入数据时才复制相应的虚拟内存;而没有写入的任何内存页均可以在两个进程之间共享。代码页实际总是可以共享的。 


 此外,内核线程是调用kernel_thread()函数创建的,而kernel_thread()在内核态调用了clone()系统调用。内核线程通常没有用户地址空间,即p->mm = NULL,它总是直接访问内核地址空间。 


 不管是fork()还是clone()系统调用,最终都调用了内核中的do_fork(),其源代码在kernel/fork.c: 


/* 
 * Ok, this is the main fork-routine. It copies the system process 
 * information (task[nr]) and sets up the necessary registers. It also 
 * copies the data segment in its entirety. The "stack_start" and 
 * "stack_top" arguments are simply passed along to the platform 
 * specific copy_thread() routine. Most platforms ignore stack_top. 
 * For an example that's using stack_top, see 
 * arch/ia64/kernel/process.c. 
 */ 
 int do_fork(unsigned long clone_flags, unsigned long stack_start, 
 struct pt_regs *regs, unsigned long stack_size) 
 { 
 int retval; 
 struct task_struct *p; 
 struct completion vfork; 
 retval = -EPERM; 
 /* 
 * CLONE_PID is only allowed for the initial SMP swapper 
 * calls 
 */ 
 if (clone_flags & CLONE_PID) { 
 if (current->pid) 
 goto fork_out; 
 } 
 retval = -ENOMEM; 
 p = alloc_task_struct(); 
 if (!p) 
 goto fork_out; 
 *p = *current; 
 retval = -EAGAIN; 
 /* 
 * Check if we are over our maximum process limit, but be sure to 
 * exclude root. This is needed to make it possible for login and 
 * friends to set the per-user process limit to something lower 
 * than the amount of processes root is running. -- Rik 
 */ 
 if (atomic_read(&p->user->processes) >= p->rlim[RLIMIT_NPROC].rlim_cur 
 && !capable(CAP_SYS_ADMIN) !capable(CAP_SYS_RESOURCE)) 
 goto bad_fork_free; 
 atomic_inc(&p->user->__count); 
 atomic_inc(&p->user->processes); 
 /* 
 * Counter increases are protected by 
 * the kernel lock so nr_threads can't 
 * increase under us (but it may decrease). 
 */ 
 if (nr_threads >= max_threads) 
 goto bad_fork_cleanup_count; 
 get_exec_domain(p->exec_domain); 
 if (p->binfmt && p->binfmt->module) 
 __MOD_INC_USE_COUNT(p->binfmt->module); 
 p->did_exec = 0; 
 p->swappable = 0; 
 p->state = TASK_UNINTERRUPTIBLE; 
 copy_flags(clone_flags, p); 
 p->pid = get_pid(clone_flags); 
 p->run_list.next = NULL; 
 p->run_list.prev = NULL; 
 p->p_cptr = NULL; 
 init_waitqueue_head(&p->wait_chldexit); 
 p->vfork_done = NULL; 
 if (clone_flags & CLONE_VFORK) { 
 p->vfork_done = &vfork; 
 init_completion(&vfork); 
 } 
 spin_lock_init(&p->alloc_lock); 
 p->sigpending = 0; 
 init_sigpending(&p->pending); 
 p->it_real_value = p->it_virt_value = p->it_prof_value = 0; 
 p->it_real_incr = p->it_virt_incr = p->it_prof_incr = 0; 
 init_timer(&p->real_timer); 
 p->real_timer.data = (unsigned long) p; 
 p->leader = 0; /* session leadership doesn't inherit */ 
 p->tty_old_pgrp = 0; 
 p->times.tms_utime = p->times.tms_stime = 0; 
 p->times.tms_cutime = p->times.tms_cstime = 0;
 #ifdef CONFIG_SMP 
 { 
 int i; 
 p->cpus_runnable = ~0UL; 
 p->processor = current->processor;
 /* ?? should we just memset this ?? */ 
 for(i = 0; i < smp_num_cpus; i++) 
 p->per_cpu_utime[i] = p->per_cpu_stime[i] = 0; 
 spin_lock_init(&p->sigmask_lock); 
 } 
 #endif 
 p->lock_depth = -1; /* -1 = no lock */ 
 p>start_time = jiffies; 
 INIT_LIST_HEAD(&p->local_pages); 
 retval = -ENOMEM; 
 /* copy all the process information */ 
 if (copy_files(clone_flags, p)) 
 goto bad_fork_cleanup; 
 if (copy_fs(clone_flags, p)) 
 goto bad_fork_cleanup_files; 
 if (copy_sighand(clone_flags, p)) 
 goto bad_fork_cleanup_fs; 
 if (copy_mm(clone_flags, p)) 
 goto bad_fork_cleanup_sighand; 
 retval = copy_thread(0, clone_flags, stack_start, stack_size, p, regs); 
 if (retval) 
 goto bad_fork_cleanup_mm; 
 p->semundo = NULL; 
 /* Our parent execution domain becomes current domain 
 These must match for thread signalling to apply */ 
 p->parent_exec_id = p->self_exec_id; 
 /* ok, now we should be set up.. */ 
 p->swappable = 1; 
 p->exit_signal = clone_flags & CSIGNAL; 
 p->pdeath_signal = 0; 
 * "share" dynamic priority between parent and child, thus the 
 * total amount of dynamic priorities in the system doesnt change, 
 * more scheduling fairness. This is only important in the first 
 * timeslice, on the long run the scheduling behaviour is unchanged. 
 */ 
 p->counter = (current->counter + 1) >> 1; 
 current->counter >>= 1; 
 if (!current->counter) 
 current->need_resched = 1; 
 /* 
 * Ok, add it to the run-queues and make it 
 * visible to the rest of the system. 
 *
 * Let it rip! 
 */ 
 retal = p->pid; 
 p->tgid = retval; 
 INIT_LIST_HEAD(&p->thread_group); 
 /* Need tasklist lock for parent etc handling! */ 
 write_lock_irq(&tasklist_lock); 
 /* CLONE_PARENT and CLONE_THREAD re-use the old parent */ 
 p->p_opptr = current->p_opptr; 
 p->p_pptr = current->p_pptr; 
 if (!(clone_flags & (CLONE_PARENT | CLONE_THREAD))) { 
 p->p_opptr = current; 
 if (!(p->ptrace & PT_PTRACED)) 
 p->p_pptr = current; 
 } 
 if (clone_flags & CLONE_THREAD) { 
 p->tgid = current->tgid; 
 list_add(&p->thread_group, &current->thread_group); 
 } 
 SET_LINKS(p); 
 hash_pid(p); 
 nr_threads++; 
 write_unlock_irq(&tasklist_lock); 
 if (p->ptrace & PT_PTRACED) 
 send_sig(SIGSTOP, p, 1); 
 wake_up_process(p); /* do this last */ 
 ++total_forks; 
 if (clone_flags & CLONE_VFORK) 
 wait_for_completion(&vfork); 
 fork_out: 
 return retval; 
 bad_fork_cleanup_mm: 
 exit_mm(p); 
 bad_fork_cleanup_sighand: 
 exit_sighand(p); 
 bad_fork_cleanup_fs: 
 exit_fs(p); /* blocking */ 
 bad_fork_cleanup_files: 
 exit_files(p); /* blocking */ 
 bad_fork_cleanup: 
 put_exec_domain(p->exec_domain); 
 if (p->binfmt && p->binfmt->module) 
 __MOD_DEC_USE_COUNT(p->binfmt->module); 
 bad_fork_cleanup_count: 
 atomic_dec(&p->user->processes); 
 free_uid(p->user); 
 bad_fork_free: 
 free_task_struct(p); 
 goto fork_out; 
 } 






尽管fork()系统调用因为传递用户堆栈和寄存器参数而与特定的平台相关,但实际上do_fork()所做的工作还是可移植的。下面给出对以上代码的解释: 


 · 给局部变量赋初值-ENOMEM,当分配一个新的task_struc结构失败时就返回这个错误值。 


 · 如果在clone_flags中设置了CLONE_PID标志,就返回一个错误(-EPERM)。因为CLONE_PID有特殊的作用,当这个标志为1时,父、子进程(线程)共用一个进程号,也就是说,子进程虽然有自己的task_struct结构,却使用父进程的pid。但是,只有0号进程(即系统中的空线程)才允许使用这个标志。 


 · 调用alloc_task_struct()为子进程分配两个连续的物理页面,低端用来存放子进程的task_struct结构,高端用作其内核空间的堆栈。 


 · 用结构赋值语句*p = *current把当前进程task_struct结构中的所有内容都拷贝到新进程中。稍后,子进程不该继承的域会被设置成正确的值。 


 · 在task_struct结构中有个指针user,用来指向一个user_struct结构。一个用户常常有多个进程,所以有关用户的信息并不专属于某一个进程。这样,属于同一用户的进程就可以通过指针user共享这些信息。显然,每个用户有且只有一个user_struct结构。该结构中有一个引用计数器count,对属于该用户的进程数量进行计数。可想而知,内核线程并不属于某个用户,所以其task_struct中的user指针为0。每个进程task_struct结构中有个数组rlim,对该进程占用各种资源的数量作出限制,而rlim[RLIMIT_NPROC]就规定了该进程所属用户可以拥有的进程数量。所以,如果当前进程是一个用户进程,并且该用户拥有的进程数量已经达到了规定的界限值,就不允许它fork()了。 


 · 除了检查每个用户拥有的进程数量外,接着要检查系统中的任务总数(所有用户的进程数加系统的内核线程数)是否超过了最大值max_threads,如果是,也不允许再创建子进程。 


 · 一个进程除了属于某个用户外,还属于某个“执行域”。 Linux可以运行X86平台上其它Unix类操作系统生成的符合iBCS2标准的程序。例如,一个进程所执行的程序是为Solaris开发的,那么这个进程就属于Solaris执行域PER_SOLARIS。当然,在Linux上运行的绝大多数程序属于Linux执行域。在task_struct有一个指针exec_doman,指向一个exec_doman结构。在exec_doman结构中有一个域是module,这是指向某个module结构的指针。在Linux中,一个文件系统或驱动程序都可以作为一个单独的模块进行编译,并动态地链接到内核中。在module结构中有一个计数器count,用来统计几个进程需要使用这个模块。因此,get_exec_domain(p->exec_domain)递增模块结构module中的计数器。 


 · 另外,每个进程所执行的程序属于某种可执行映像格式,如a.out格式、elf格式、甚至java虚拟机格式。对于不同格式的支持通常是通过动态安装的模块来实现的。所以,task_struct中有一个执行linux_binfmt结构的指针binfmt,而 __MOD_INC_USE_COUNT()就是对有关模块的使用计数进行递增。 


 · 紧接着为什么要把进程的状态设置成为TASK_UNINTERRUPTIBLE?这是因为后面get_pid()的操作必须独占,子进程可能因为一时进不了临界区而只好暂时进入睡眠状态。 


 · copy_flags()函数将clone_flags参数中的标志位略加补充和变换,然后写入p->flags。 


 · get_pid()函数根据clone_flags中标志位ClONE_PID的值,或返回父进程(当前进程)的pid,或返回一个新的pid。 


 · 前面在复制父进程的task_struct结构时把父进程的所有域都照抄过来,但实际上很多域的值必须重新赋初值,因此,后面的赋值语句就是对子进程task_struct结构的初始化。其中start_time表示进程创建的时间,而全局变量jiffies就是从系统初始化开始至当前的是时钟滴答数。local_pages表示属于该进程的局部页面形成一个双向链表,在此进行了初始化。 


 · copy_files()有条件地复制已打开文件的控制结构,也就是说,这种复制只有在clone_flags中的CLONE_FILES标志为0时才真正进行,否则只是共享父进程的已打开文件。当一个进程有已打开文件时,task_struct结构中的指针files指向一个fiel_struct结构,否则为0。所有与终端设备tty相联系的用户进程的头三个标准文件stdin、stdout及stderr都是预先打开的,所以指针一般不为空。 


 · copy_fs()也是只有在clone_flags中的CLONE_FS标志为0时才加以复制。在task_struct中有一个指向fs_struct结构的指针,fs_struct结构中存放的是进程的根目录root、当前工作目录pwd、一个用于文件操作权限的umask,还有一个计数器。类似地,copy_sighand()也是只有在CLONE_SIGHAND为0时才真正复制父进程的信号结构,否则就共享父进程的。信号是进程间通信的一种手段,信号随时都可以发向一个进程,就像中断随时都可以发向一个处理器一样。进程可以为各种信号设置相应的信号处理程序,一旦进程设置了信号处理程序,其task_struct结构中的指针sig就指向signal_struct结构(定义于include/linux/sched.h)。关于信号的具体内容将在下一章进行介绍。 


 · 用户空间的继承是通过copy_mm()函数完成的。进程的task_struct结构中有一个指针mm,就指向代表着进程地址空间的mm_struct结构。对mm_struct的复制也是在clone_flags中的CLONE_VM标志为0时才真正进行,否则,就只是通过已经复制的指针共享父进程的用户空间。对mm_struct的复制不只限于这个数据结构本身,还包括了对更深层次数据结构的复制,其中最主要的是vm_area_struct结构和页表的复制,这是由同一文件中的dum_mmap()函数完成的。 


 · 到此为止,task_struct结构中的域基本复制好了,但是用于内核堆栈的内容还没有复制,这就是copy_thread()的责任了。copy_thread()函数与平台相关,定义于arch/i386/kernel/process.c中。copy_thread()实际上只复制父进程的内核空间堆栈。堆栈中的内容记录了父进程通过系统调用fork()进入内核空间、然后又进入copy_thread()函数的整个历程,子进程将要循相同的路线返回,所以要把它复制给子进程。但是,如果父子进程的内核空间堆栈完全相同,那返回用户空间后就无法区分哪个是子进程了,所以,复制以后还要略作调整。有兴趣的读者可以结合第三、四章内容去读该函数的源代码。 


 · parent_exec_id表示父进程的执行域,p->self_exec_id是本进程(子进程)的执行域,swappable表示本进程的页面可以被换出。exit_signal为本进程执行exit()系统调用时向父进程发出的信号,pdeath_signal为要求父进程在执行exit()时向本进程发出的信号。另外,counter域的值是进程的时间片(以时钟滴达为单位),代码中将父进程的时间片分成两半,让父、子进程各有原值的一半。 


 · 进程创建后必须处于某一组中,这是通过task_struct结构中的队列头thread_group与父进程链接起来,形成一个进程组(注意,thread并不单指线程,内核代码中经常用thread通指所有的进程)。 


 · 建立进程的家族关系。先建立起子进程的祖先和双亲(当然还没有兄弟和孩子),然后通过SET_LINKS()宏将子进程的task_struct结构插入到内核中其它进程组成的双向链表中。通过hash_pid()将其链入按其pid计算得的哈希表中(参看第三章进程的组成方式一节)。 


 · 最后,通过wake_up_process()将子进程唤醒,也就是将其挂入可执行队列等待被调度。 


 · 但是,还有一种特殊情况必须考虑。当参数clone_flags中CLONE_VFORK标志位为1时,一定要保证子进程先运行,一直到子进程通过系统调用execve()执行一个新的可执行程序或通过系统调用exit()退出系统时,才可以恢复父进程的执行,这是通过wait_for_completion()函数实现的。为什么要这样做呢?这是因为当CLONE_VFORK标志位为1时,就说明父、子进程通过指针共享用户空间(指向相同的mm_struct结构),那也说明父进程写入用户空间的内容同时也写入了子进程的用户空间,反之亦然。如果说,在这种情况下,父子进程对数据区的写入可能引起问题的话,那么,对堆栈区的写入可能就是致命的了。而对子程序或函数的调用肯定就是对堆栈的写入。由此可见,在这种情况下,决不能让两个进程都回到用户空间并发执行,否则,必然导致两个进程的互相“捣乱”或因非法访问而死亡。解决的办法的只能是“扣留”其中的一个进程,而让另一个进程先回到用户空间,直到两个进程不再共享它们的用户空间,或其中一个进程消亡为止(肯定是先回到用户空间的进程先消亡)。 


 到此为止,子进程的创建已经完成,该是从内核态返回用户态的时候了。实际上,fork()系统调用执行之后,父子进程返回到用户空间中相同的的地址,用户进程根据fork()的返回值分别安排父子进程执行不同的代码。 


6.8.2 程序执行 


 与 Unix类似,Linux 中的程序和命令通常由命令解释器执行,这一命令解释器称为 shell。用户输入命令之后,shell 会在搜索路径(shell 变量PATH中包含搜索路径)指定的目录中搜索和输入命令匹配的映象(可执行的二进制代码)名称。如果发现匹配的映象,shell 负责装载并执行该映像。shell 首先利用 fork 系统调用建立子进程,然后用找到的可执行映象文件覆盖子进程正在执行的 shell 二进制映象。 


 可执行文件可以是具有不同格式的二进制文件,也可以是一个文本的脚本文件。可执行映象文件中包含了可执行代码及数据,同时也包含操作系统用来将映象正确装入内存并执行的信息。Linux 使用的最常见的可执行文件格式是 ELF 和 a.out,但理论上讲,Linux 有足够的灵活性可以装入任何格式的可执行文件。 


1. ELF可执行文件 


 ELF 是“可执行可连接格式”的英文缩写,该格式由 Unix 系统实验室制定。它是 Linux 中最经常使用的格式,和其他格式(例如 a.out 或 ECOFF 格式)比较起来,ELF 在装入内存时多一些系统开支,但是更为灵活。ELF 可执行文件包含了可执行代码和数据,通常也称为正文和数据。这种文件中包含一些表,根据这些表中的信息,内核可组织进程的虚拟内存。另外,文件中还包含有对内存布局的定义以及起始执行的指令位置。 


 下面我们分析一个简单程序在利用编译器编译并连接之后的 ELF 文件格式: 


 #include <stdio.h> 
 main () 


 { 
 printf(“Hello world!\n”); 
 } 
 图6_24 所示,是上述源代码在编译连接后的 ELF 可执行文件的格式。从图可以看出,ELF 可执行映象文件的开头是三个字符 ‘E’、‘L’ 和 ‘F’,作为这类文件的标识符。e_entry 定义了程序装入之后起始执行指令的虚拟地址。这个简单的 ELF 映象利用两个“物理头”结构分别定义代码和数据,e_phnum 是该文件中所包含的物理头信息个数,本例为 2。e_phyoff 是第一个物理头结构在文件中的偏移量,而e_phentsize 则是物理头结构的大小,这两个偏移量均从文件头开始算起。根据上述两个信息,内核可正确读取两个物理头结构中的信息。






图6-24 一个简单的ELF可执行文件的布局


物理头结构的 p_flags 字段定义了对应代码或数据的访问属性。图中第一个 p_flags 字段的值为 FP_X 和 FP_R,表明该结构定义的是程序的代码;类似地,第二个物理头定义程序数据,并且是可读可写的。p_offset 定义对应的代码或数据在物理头之后的偏移量。p_vaddr 定义代码或数据的起始虚拟地址。p_filesz 和 p_memsz 分别定义代码或数据在文件中的大小以及在内存中的大小。对我们的简单例子,程序代码开始于两个物理头之后,而程序数据则开始于物理头之后的第 0x68533 字节处,显然,程序数据紧跟在程序代码之后。程序的代码大小为 0x68532,显得比较大,这是因为连接程序将 C 函数 printf 的代码连接到了 ELF 文件的原因。程序代码的文件大小和内存大小是一样的,而程序数据的文件大小和内存大小不一样,这是因为内存数据中,起始的 2200 字节是预先初始化的数据,初始化值来自 ELF 映象,而其后的 2048 字节则由执行代码初始化。 


 如前面所描述的,Linux 利用请页技术装入程序映象。当 shell 进程利用 fork ()系统调用建立了子进程之后,子进程会调用 exec ()系统调用(实际有多种 exec 调用),exec() 系统调用将利用 ELF 二进制格式装载器装载 ELF 映象,当装载器检验映象是有效的 ELF 文件之后,就会将当前进程(实际就是父进程或旧进程)的可执行映象从虚拟内存中清除,同时清除任何信号处理程序并关闭所有打开的文件(把相应 file 结构中的 f_count 引用计数减 1,如果这一计数为 0,内核负责释放这一文件对象),然后重置进程页表。完成上述过程之后,只需根据 ELF 文件中的信息将映象代码和数据的起始和终止地址分配并设置相应的虚拟地址区域,修改进程页表。这时,当前进程就可以开始执行对应的 ELF 映象中的指令了。 


2.命令行参数和shell环境 


 当用户敲入一个命令时,从shell可以接受一些命令行参数。例如,当用户敲入命令: 


 $ ls -l /usr/bin 


 以获得在/usr/bin目录下的全部文件列表时,shell进程创建一个新进程执行这个命令。这个新进程装入/bin/ls可执行文件。在这样做的过程中,从shell继承的大多数执行上下文被丢弃,但三个单独的参数ls、-l和 /usr/依然被保持。一般情况下,新进程可以接受任意个参数。 


 传递命令行参数的约定依赖于所用的高级语言。在C语言中,程序的main( )函数把传递给程序的参数个数和指向字符串指针数组的地址作为参数。下面是main()的原型: 


 int main(int argc, char *argv[]) 


 再回到前面的例子,当/bin/ls程序被调用时,argc的值为3,argv[0]指向ls字符串,argv[1]指向-l字符串,而argv[2]指向 /usr/bin字符串。argv数组的末尾处总以空指针来标记,因此,argv[3]为NULL。 


 在C语言中传递给main()函数的第三个可选参数是包含环境变量的参数。当进程用到它时,main( )的声明如下: 


 int main(int argc, char *argv[], char *envp[]) 


 envp参数指向环境串的指针数组,形式如下: 


 VAR_NAME=something 


 在这里,VAR_NAME表示一个环境变量的名字,而“=”后面的子串表示赋给变量的实际值。envp数组的结尾用一个空指针标记,就像argv数组。环境变量是用来定制进程的执行上下文、为用户或其它进程提供一般的信息、或允许进程交叉调用execve( )系统调用保存一些信息。 
 命令行参数和环境串都放在用户态堆栈。图6.25显示了用户态堆栈底部所包含的内容。注意环境变量位于栈底附近正好在一个null的长整数之后。 






图6.25 用户态堆栈底部所包含的内容


3.函数库 


 每个高级语言的源代码文件都是经过几个步骤才转化为目标文件的,目标文件中包含的是汇编语言指令的机器代码,它们和相应的高级语言指令对应。目标文件并不能被执行,因为它不包含源代码文件所用的全局外部符号名的虚拟地址。这些地址的分配或解析是由链接程序完成的,链接程序把程序所有的目标文件收集起来并构造可执行文件。链接程序还分析程序所用的库函数并把它们粘合成可执行文件。 


 任何程序,甚至最小的程序都会利用C库。请看下面的一行C程序: 


 void main(void) { } 


 尽管这个程序没有做任何事情,但还是需要做很多工作来建立执行环境并在程序终止时杀死这个进程。尤其是,当main( )函数终止时,C编译程序就把exit( )系统调用插入到目标代码中。 


 实际上,一般程序对系统调用的调用通常是通过C库中的封装例程进行的,也就是说,C语言函数库中的函数先调用系统调用,而我们的应用程序再调用库函数。除了C库,Unix系统中还包含很多其它的函数库。一般的Linux系统可能轻而易举地就有50个不同的库。这里仅仅列举其中的两个:数学库libm包含浮点操作的基本函数,而X11库libX11收集了所有X11窗口系统图形接口的基本底层函数。 


 传统Unix系统中的所有可执行文件都是基于静态库的。这就意味着链接程序所产生的可执行文件不仅包括原程序的代码,还包括程序所引用的库函数的代码。 


 静态库的一大缺点是:它们占用大量的磁盘空间。的确,每个静态链接的可执行文件都复制库代码的一部分。因此,现代Unix系统利用了共享库。可执行文件不用再包含库的目标代码,而仅仅指向库名。当程序被装入内存执行时,一个叫做程序解释器的程序就专注于分析可执行文件中的库名,确定所需库在系统目录树中的位置,并使执行进程可以使用所请求的代码。 


 共享库对提供文件内存映射的系统尤为方便,因为它们减少了执行一个程序所需的主内存量。当程序解释器必须把某一共享库链接到进程时,并不拷贝目标代码,而是仅仅执行一个内存映射,把库文件的相关部分映射到进程的地址空间中。这就允许共享库机器代码所在的页面由使用相同代码的所有进程进行共享。 


 共享库也有一些缺点。动态链接的程序启动时间通常比静态链接的长。此外,动态链接的程序的可移植性也不如静态链接的好,因为当系统中所包含的库版本发生变化时,动态链接的程序可能就不能适当地执行。 


 用户可以让一个程序静态地链接。例如,GCC编译器提供-static选项,即告诉链接程序使用静态库而不是共享库。 


 和静态连接库不同,动态连接库只有在运行时才被连接到进程的虚拟地址中。对于使用同一动态连接库的多个进程,只需在内存中保留一份共享库信息即可,这样就节省了内存空间。当共享库需要在运行时连接到进程虚拟地址时,Linux 的动态连接器利用 ELF 共享库中的符号表完成连接工作,符号表中定义了 ELF 映象引用的全部动态库例程。Linux 的动态连接器一般包含在 /lib 目录中,通常为 ld.so.1、llibc.so.1 和ld-linux.so.1。 


6.8.3 执行函数 


 在执行fork()之后,同一进程有两个拷贝都在运行,也就是说,子进程具有与父进程相同的可执行程序和数据(简称映像)。但是,子进程肯定不满足于仅仅成为父进程的“影子”,因此,父进程就要调用execve()装入并执行子进程自己的映像。execve()函数必需定位可执行文件的映像,然后装入并运行它。当然开始装入的并不是实际二进制映像的完全拷贝,拷贝的完全装入是用请页装入机制(demand pageing loading)逐步完成的。开始时只需要把要执行的二进制映像头装入内存,可执行代码的 inode 节点被装入当前进程的执行域中就可以执行了。 


 由于Linux文件系统采用了linux_binfmt数据结构(在/include/linux/ binfmt.h中,见文件系统注册)来支持各种文件系统,所以Linux中的exec()函数执行时,使用已注册的linux_binfmt结构就可以支持不同的二进制格式,即多种文件系统(EXT2,dos等)。需要指出的是binux_binfmt结构中嵌入了两个指向函数的指针,一个指针指向可执行代码,另一个指向了库函数;使用这两个指针是为了装入可执行代码和要使用的库。 linux_binfmt结构描述如下,其链表结构的示意图如图6.26所示: 


 struct linux_binfmt { 
 struct linux_binfmt * next; 
 long *use_count; 
 nt (*load_binary)(struct linux_binprm *, struct pt_regs * regs);/*装入二进制代码*/ 
 int (*load_shlib)(int fd); /*装入公用库*/ 
 int (*core_dump)(long signr, struct pt_regs * regs); }






图6.26 linux_binfmt的链表结构 


在使用这种数据结构前必须调用vod binfmt_setup()函数进行初始化;这个函数分别初始化了一些可执行的文件格式,如:init_elf_binfmt();init_aout_binfmt();init_java_binfmt();init_script_binfmt())。 


 其实初始化就是用register_binfmt(struct linux_binfmt * fmt)函数把文件格式注册到系统中,即加入*formats所指的链中,*formats的定义如下: 


 static struct linux_binfmt *formats = (struct linux_binfmt *) NULL 


 在使用装入函数的指针时,如果可执行文件是ELF格式的,则指针指向的装入函数分别是: 
 load_elf_binary(struct linux_binprm * bprm, struct pt_regs * regs); 


 static int load_elf_library(int fd); 


 所以elf_format文件格式说明将被定义成: 


 static struct linux_binfmt elf_format = {#ifndef MODULE 


 NULL, NULL, load_elf_binary, load_elf_library, elf_core_dump#else 


 NULL, &mod_use_count_, load_elf_binary, load_elf_library, elf_core_dump#endif } 


 其他格式文件处理很类似,相关代码请看本节后面介绍的search_binary_handler()函数。 


 另外还要提的,在装入二进制时还需要用到结构linux_binprm,这个结构保存着一些在装入代码时需要的信息: 


struct linux_binprm{ 


 char buf[128];/*读入文件时用的缓冲区*/ 


 unsigned long page[MAX_ARG_PAGES]; 


 unsigned long p; 


 int sh_bang; 


 struct inode * inode;/*映像来自的节点*/ 


 int e_uid, e_gid; 


 int argc, envc; /*参数数目,环境数目*/ 


 char * filename; /* 二进制映像的名字,也就是要执行的文件名 */ 


 unsigned long loader, exec; 


 int dont_iput; /* binfmt handler has put inode */ 


 }; 


 其它域的含义在后面的do_exec()代码中做进一步解释。 


 Linux所提供的系统调用名为execve(),可是,C语言的程序库在此系统调用的基础上向应用程序提供了一整套的库函数,包括execve()、execlp()、execle()、execv()、execvp(),它们之间的差异仅仅是参数的不同。下面来介绍execve()的实现。 


 系统调用execve()在内核的入口为sys_execve(),其代码在arch/i386/kernel/process.c: 


 /* 


 * sys_execve() executes a new program. 


 */ 


 asmlinkage int sys_execve(struct pt_regs regs) 


 { 


 int error; 


 char * filename; 


 filename = getname((char *) regs.ebx); 


 error = PTR_ERR(filename); 


 if (IS_ERR(filename)) 


 goto out; 


 error = do_execve(filename, (char **) regs.ecx, (char **) regs.edx, &regs); 


 if (error == 0) 


 current->ptrace &= ~PT_DTRACE; 


 putname(filename); 


 out: 


 return error; 


 } 


 系统调用进入内核时,regs.ebx中的内容为应用程序中调用相应的库函数时的第一个参数,这个参数就是可执行文件的路径名。但是此时文件名实际上存放在用户空间中,所以getname()要把这个文件名拷贝到内核空间,在内核空间中建立起一个副本。然后,调用do_execve()来完成该系统调用的主体工作。do_execve()的代码在fs/exec.c中: 
/* 
 * sys_execve() executes a new program. 
 */ 
 int do_execve(char * filename, char ** argv, char ** envp, struct pt_regs * regs) 
 { 
 struct linux_binprm bprm; 
 struct file *file; 
 int retval; 
 int i; 
 file = open_exec(filename); 
 retval = PTR_ERR(file); 
 if (IS_ERR(file)) 
 retrn retval; 
 bprm.p = PAGE_SIZE*MAX_ARG_PAGES-sizeof(void *); 
 memset(bprm.page, 0, MAX_ARG_PAGES*sizeof(bprm.page[0])); 
 bprm.file = file; 
 bprm.filename = filename; 
 bprm.sh_bang = 0; 
 bprm.loader = 0; 
 bprm.exec = 0; 
 if ((bprm.argc = count(argv, bprm.p / sizeof(void *))) < 0) { 
 allow_write_access(file); 
 fput(file); 
 return bprm.argc; 
 } 
 if ((bprm.envc = count(envp, bprm.p / sizeof(void *))) < 0) { 
 allow_write_access(file); 
 fput(file); 
 return bprm.envc; 
 } 
 retval = prepare_binprm(&bprm); 
 if (retval < 0) 
 goto out; 
 retval = copy_strings_kernel(1, &bprm.filename, &bprm); 
 if (retval < 0) 
 goto out; 
 bprm.exec = bprm.p; 
 retval = copy_strings(bprm.envc, envp, &bprm); 
 if (retval < 0) 
 goto out; 
 retval = copy_strings(bprm.argc, argv, &bprm); 
 if (retval < 0) 
 goto out; 
 retval = search_binary_handler(&bprm,regs); 
 if (retval >= 0) 
 /* execve success */ 
 return retval; 
 out: 
 /* Something went wrong, return the inode and free the argument pages*/ 
 allow_write_access(bprm.file); 
 if (bprm.file) 
 fput(bprm.file); 
 for (i = 0 ; i < MAX_ARG_PAGES ; i++) { 
 struct page * page = bprm.page[i]; 
 if (page) 
 __free_page(page); 
 }
 return retval; 
 } 






参数filename,argv,envp分别代表要执行文件的文件名,命令行参数及环境串。下面对以上代码给予解释: 


 · 首先,将给定可执行程序的文件找到并打开,这是由open_exec()函数完成的。open_exec()返回一个file结构指针,代表着所读入的可执行文件的映像。 


 · 与可执行文件路径名的处理办法一样,每个参数的最大长度也定为一个页面(是否有点浪费?),所有linux_binprm结构中有一个页面指针数组,数组的大小为系统所允许的最大参数个数MAX_ARG_PAGES(定义为32)。memset()函数将这个指针数组初始化为全0。 


 · 对局部变量bprm的各个域进行初始化。其中bprm.p几乎等于最大参数个数所占用的空间; bprm.sh_bang表示可执行文件的性质,当可执行文件是一个Shell脚本(Shell Sript)时置为1,此时还没有可执行Shell脚本,因此给其赋初值0,还有其它两个域也赋初值0。 


 · 函数count()对字符串数组argv[]中参数的个数进行计数。bprm.p / sizeof(void *)表示所允许参数的最大值。同样,对环境变量也要统计其个数。 


 · 如果count()小于0,说明统计失败,则调用fput()把该可执行文件写回磁盘,在写之前,调用allow_write_access()来防止其他进程通过内存映射改变该可执行文件的内容。 


 · 完成了对参数和环境变量的计数之后,又调用prepare_binprm()对bprm变量做进一步的准备工作。更具体地说,就是从可执行文件中读入开头的128个字节到linux_binprm结构的缓冲区buf,这是为什么呢?因为不管目标文件是elf格式还是a.out格式,或者其它格式,在其可执行文件的开头128个字节中都包括了可执行文件属性的信息,如图6.24。 


 · 然后,就调用copy_strings把参数以及执行的环境从用户空间拷贝到内核空间的bprm变量中,而调用copy_strings_kernel()从内核空间中拷贝文件名,因为前面介绍的get_name()已经把文件名拷贝到内核空间了。 


 · 所有的准备工作已经完成,关键是调用search_binary_handler()函数了,请看下面对这个函数的详细介绍。 


 search_binary_handler()函数也在exec.c中。其中有一段代码是专门针对alpha处理器的条件编译,在下面的代码中跳过了这段代码: 
/* 
 * cycle the list of binary formats handler, until one recognizes the image 
 */ 
 int search_binary_handler(struct linux_binprm *bprm,struct pt_regs *regs) 
 { 
 int try,retval=0; 
 struct linux_binfmt *fmt; 
 6 #ifdef __alpha__ 
 ….. 
 #endif 
 /* kernel module loader fixup */ 
 /* so we don't try to load run modprobe in kernel space. */ 
 set_fs(USER_DS); 
 for (try=0; try<2; try++) { 
 read_lock(&binfmt_lock); 
 for (fmt = formats ; fmt ; fmt = fmt->next) { 
 int(*fn)(struct linux_binprm *, struct pt_regs *) = fmt->load_binary; 
 if (!fn) 
 continue; 
 if (!try_inc_mod_count(fmt->module)) 
 continue; 
 read_unlock(&binfmt_lock); 
 retval = fn(bprm, regs); 
 if (retval >= 0) { 
 put_binfmt(fmt); 
 allow_write_access(bprm->file); 
 if (bprm->file) 
 fput(bprm->file); 
 bprm->file = NULL; 
 current->did_exec = 1; 
 return retval; 
 } 
 read_lock(&binfmt_lock); 
 put_binfmt(fmt); 
 if (retval != -ENOEXEC) 
 break; 
 if (!bprm->file) { 
 read_unlock(&binfmt_lock); 
 return retval; 
 } 
 } 
 read_unlock(&binfmt_lock); 
 if (retval != -ENOEXEC) { 
 break; 
 #ifdef CONFIG_KMOD 
 }else{ 
 #define printable(c) (((c)=='\t') || ((c)=='\n') || (0x20<=(c) && (c)<=0x7e)) 
 char modname[20]; 
 if (printable(bprm->buf[0]) && 
 printable(bprm->buf[1]) && 
 printable(bprm->buf[2]) && 
 printable(bprm->buf[3])) 
 break; /* -ENOEXEC */ 
 sprintf(modname, "binfmt-%04x", *(unsigned short *)(&bprm->buf[2])); 
 request_module(modname); 
 #endif 
 } 
 } 
 return retval; 
 } 






在exec.c中定义了一个静态变量formats: 


 static struct linux_binfmt *formats 


 因此,formats就指向图6.26中链表队列的头,挂在这个队列中的成员代表着各种可执行文件格式。在do_exec()函数的准备阶段,已经从可执行文件头部读入128字节存放在bprm的缓冲区中,而且运行所需的参数和环境变量也已收集在bprm中。search_binary_handler()函数就是逐个扫描formats队列,直到找到一个匹配的可执行文件格式,运行的事就交给它。如果在这个队列中没有找到相应的可执行文件格式,就要根据文件头部的信息来查找是否有为此种格式设计的可动态安装的模块,如果有,就把这个模块安装进内核,并挂入formats队列,然后再重新扫描。下面对具体程序给予解释: 


 · 程序中有两层嵌套for循环。内层是针对formats队列的每个成员,让每一个成员都去执行一下load_binary()函数,如果执行成功,load_binary()就把目标文件装入并投入运行,并返回一个正数或0。当CPU从系统调用execve()返回到用户程序时,该目标文件的执行就真正开始了,也就是,子进程新的主体真正开始执行了。如果load_binary()返回一个负数,就说明或者在处理的过程中出错,或者没有找到相应的可执行文件格式,在后一种情况下,返回-ENOEXEC。 


 · 内层循环结束后,如果load_binary()执行失败后的返回值为-ENOEXEC,就说明队列中所有成员都不认识目标文件的格式。这时,如果内核支持动态安装模块(取决于编译选项CONFIG_KMOD),就根据目标文件的第2和第3个字节生成一个binfmt模块,通过request_module()试着将相应的模块装入内核(参见第十章)。外层的for循环有两次,就是为了在安装了模块以后再来试一次。 


 · 在linux_binfmt数据结构中,有三个函数指针:load_binary、load_shlib以及core_dump,其中load_binary就是具体的装载程序。不同的可执行文件其装载函数也不同,如a.out格式的装载函数为load_aout_binary(),elf的装载函数为load_elf_binary(),其源代码分别在fs/binfmt_aout.c中和fs/binfmt_elf中。有兴趣的读者可以继续探究下去。 


 本章从内存的初始化开始,分别介绍了地址映射机制、内存分配与回收机制、请页机制、交换机制、缓存和刷新机制、程序的创建及执行等八个方面。可以说,内存管理是整个操作系统中最复杂的一个子系统,因此,本章用大量的篇幅对相关内容进行了介绍,即使如此,也仅仅介绍了主要内容。 


 在本章的学习中,有一点需特别向读者强调。在Linux系统中,CPU不能按物理地址访问存储空间,而必须使用虚拟地址。因此,对于Linux内核映像,即使系统启动时将其全部装入物理内存,也要将其映射到虚拟地址空间中的内核空间,而对于用户程序,其经过编译、链接后形成的映像文件最初存于磁盘,当该程序被运行时,先要建立该映像与虚拟地址空间的映射关系,当真正需要物理内存时,才建立地址空间与物理空间的映射关系。 

源地址:http://www.eefocus.com/article/09-06/75172s.html




  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值