Linux 内核源代码情景分析(二)(上):https://developer.aliyun.com/article/1597941
(5)get_pid
// // kernel/fork.c static int get_pid(unsigned long flags) { static int next_safe = PID_MAX; struct task_struct *p; if (flags & CLONE_PID) return current->pid; spin_lock(&lastpid_lock); if((++last_pid) & 0xffff8000) { last_pid = 300; /* Skip daemons etc. */ goto inside; } if(last_pid >= next_safe) { inside: next_safe = PID_MAX; read_lock(&tasklist_lock); repeat: for_each_task(p) { if(p->pid == last_pid || p->pgrp == last_pid || p->session == last_pid) { if(++last_pid >= next_safe) { if(last_pid & 0xffff8000) last_pid = 300; next_safe = PID_MAX; } goto repeat; } if(p->pid > last_pid && next_safe > p->pid) next_safe = p->pid; if(p->pgrp > last_pid && next_safe > p->pgrp) next_safe = p->pgrp; if(p->session > last_pid && next_safe > p->session) next_safe = p->session; } read_unlock(&tasklist_lock); } spin_unlock(&lastpid_lock); return last_pid; }
这里的常数 PID_MAX 定义为 0x8000。可见,进程号的最大值是 0x7fff,即 32767。进程号 0~299 是为系统进程 (包括内核线程) 保留的,主要用于各种 “保护神” 进程。以上这段代码的逻辑并不复杂,我们就不多加解释了。
(6)copy_files
// kernel/fork.c static int copy_files(unsigned long clone_flags, struct task_struct * tsk) { struct files_struct *oldf, *newf; struct file **old_fds, **new_fds; int open_files, nfds, size, i, error = 0; /* * A background process may not have any files ... */ // 读者可以在学习了 “文件系统” 一章以后再回过头来仔细阅读这段代码,我们在这里先作一些解释。 // 先看复制的方向。因为是当前进程在创建子进程,是从当前进程复制到子进程,所以把当前进程的 // task_struct 结构中的 files_struct 结构指针作为 oldf 。 oldf = current->files; if (!oldf) goto out; // 再看复制的条件。如果参数 clone_flags 中的CLONE_FILES标志位为1,就只是通过 atomic_inc() // 递增当前进程的files_struct结构中的共享计数,表示这个数据结构现在多了一个“用户”,就返回了。 // 由于在此之前已通过数据结构赋值将当前进程的整个task_struct结构都复制给了子进程,结构中的指 // 针files自然也复制到了子进程的task_struct结构中,使子进程通过这个指针共享当前进程的 // files_struct 数据结构。 if (clone_flags & CLONE_FILES) { atomic_inc(&oldf->count); goto out; } tsk->files = NULL; error = -ENOMEM; // 否则,如果CLONE_FILES标志位为0,那就要复制了。首先通过kmem_cache_alloc() 为子进程分配一个 // files_struct 数据结构作为newf,然后从oldf把内容复制到newf。 newf = kmem_cache_alloc(files_cachep, SLAB_KERNEL); if (!newf) goto out; atomic_set(&newf->count, 1); // 在files_struct数据结构 // 中有三个主要的“部件”。其一是个位图,名为close_on_exec_init;其二也是位图,名为open_fds_init; // 其三则是 file 结构数组 fd_array[]。这七个部件都是固定大小的,如果打开的文件数量超过其容量,就 // 得通过expand_fdset()和expand_fd_array()在files_struct数据结构以外另行分配空间作为替换。 // 不管是采用files_struct数据结构内部的这三个部件或是采用外部的替换空间,指针close_on_exec、 // open_fds 和 fd 总是分别指向这二组信息。所以,如何复制取决于已打开文件的数量。 newf->file_lock = RW_LOCK_UNLOCKED; newf->next_fd = 0; newf->max_fds = NR_OPEN_DEFAULT; newf->max_fdset = __FD_SETSIZE; newf->close_on_exec = &newf->close_on_exec_init; newf->open_fds = &newf->open_fds_init; newf->fd = &newf->fd_array[0]; /* We don't yet have the oldf readlock, but even if the old fdset gets grown now, we'll only copy up to "size" fds */ size = oldf->max_fdset; if (size > __FD_SETSIZE) { newf->max_fdset = 0; write_lock(&newf->file_lock); error = expand_fdset(newf, size); write_unlock(&newf->file_lock); if (error) goto out_release; } read_lock(&oldf->file_lock); open_files = count_open_files(oldf, size); /* * Check whether we need to allocate a larger fd array. * Note: we're not a clone task, so the open count won't * change. */ nfds = NR_OPEN_DEFAULT; if (open_files > nfds) { read_unlock(&oldf->file_lock); newf->max_fds = 0; write_lock(&newf->file_lock); error = expand_fd_array(newf, open_files); write_unlock(&newf->file_lock); if (error) goto out_release; nfds = newf->max_fds; read_lock(&oldf->file_lock); } old_fds = oldf->fd; new_fds = newf->fd; memcpy(newf->open_fds->fds_bits, oldf->open_fds->fds_bits, open_files/8); memcpy(newf->close_on_exec->fds_bits, oldf->close_on_exec->fds_bits, open_files/8); for (i = open_files; i != 0; i--) { struct file *f = *old_fds++; if (f) get_file(f); *new_fds++ = f; } read_unlock(&oldf->file_lock); /* compute the remainder to be cleared */ size = (newf->max_fds - open_files) * sizeof(struct file *); /* This is long word aligned thus could use a optimized version */ memset(new_fds, 0, size); if (newf->max_fdset > open_files) { int left = (newf->max_fdset-open_files)/8; int start = open_files / (8 * sizeof(unsigned long)); memset(&newf->open_fds->fds_bits[start], 0, left); memset(&newf->close_on_exec->fds_bits[start], 0, left); } tsk->files = newf; error = 0; out: return error; out_release: free_fdset (newf->close_on_exec, newf->max_fdset); free_fdset (newf->open_fds, newf->max_fdset); kmem_cache_free(files_cachep, newf); goto out; }
显而易见,共享比复制要简单得多。那么这二者在效果上到底有什么区别呢?如果共享就可以达到目的,为什么还要不辞辛劳地复制呢?区别在于子进程 (以及父进程本身) 是否能 “独立自主” 。当复制完成之初,子进程有了一份副本,它的内容与父进程的“正本”在内容上基本是相同的,在这一点上似乎与共享没有什么区别。可是,随后区别就来了。在共享的情况下,两个进程是互相牵制的。如果子进程对某个已打开文件调用了一次 lseek(),则父进程对这个文件的读写位置也随着改变了,因为两个进程共享着对文件的同一个读写上下文。而在复制的情况下就不一样了,由于子进程有自己的副本,就有了对同一文件的另一个读写上下文,以后就可以各走各的路,互不干扰了。
(7)copy_fs
// include/linux/fs_struct.h struct fs_struct { atomic_t count; rwlock_t lock; int umask; struct dentry * root, * pwd, * altroot; struct vfsmount * rootmnt, * pwdmnt, * altrootmnt; }; // =============================================================================== // kernel/fork.c static inline int copy_fs(unsigned long clone_flags, struct task_struct * tsk) { if (clone_flags & CLONE_FS) { atomic_inc(¤t->fs->count); return 0; } tsk->fs = __copy_fs_struct(current->fs); if (!tsk->fs) return -1; return 0; } static inline struct fs_struct *__copy_fs_struct(struct fs_struct *old) { struct fs_struct *fs = kmem_cache_alloc(fs_cachep, GFP_KERNEL); /* We don't need to lock fs - think why ;-) */ if (fs) { atomic_set(&fs->count, 1); fs->lock = RW_LOCK_UNLOCKED; fs->umask = old->umask; read_lock(&old->lock); fs->rootmnt = mntget(old->rootmnt); fs->root = dget(old->root); fs->pwdmnt = mntget(old->pwdmnt); fs->pwd = dget(old->pwd); if (old->altroot) { fs->altrootmnt = mntget(old->altrootmnt); fs->altroot = dget(old->altroot); } else { fs->altrootmnt = NULL; fs->altroot = NULL; } read_unlock(&old->lock); } return fs; }
代码中的 mntget() 和 dget() 都是用来递增相应数据结构中共享计数的,因为这些数据结构现在多了一个用户。注意,在这里要复制的是 fs_struct 数据结构,而并不复制更深层的数据结构。复制了 fs_struct 数据结构,就在这一层上有了自主性,至于对更深层的数据结构则还是共享,所以要递增它们的共享计数。
(8)copy_sighand
// include/asm-i386/signal.h struct sigaction { __sighandler_t sa_handler; unsigned long sa_flags; void (*sa_restorer)(void); sigset_t sa_mask; /* mask last for extensibility */ }; struct k_sigaction { struct sigaction sa; }; // =============================================================================== // include/linux/sched.h struct signal_struct { atomic_t count; struct k_sigaction action[_NSIG]; spinlock_t siglock; }; // 其中的数组 action[] 确定了一个进程对各种信号(以信号的数值为下标)的反应和处理,子进程可 // 以通过复制或共享把它从父进程继承下来。 // =============================================================================== static inline int copy_sighand(unsigned long clone_flags, struct task_struct * tsk) { struct signal_struct *sig; if (clone_flags & CLONE_SIGHAND) { atomic_inc(¤t->sig->count); return 0; } sig = kmem_cache_alloc(sigact_cachep, GFP_KERNEL); tsk->sig = sig; if (!sig) return -1; spin_lock_init(&sig->siglock); atomic_set(&sig->count, 1); memcpy(tsk->sig->action, current->sig->action, sizeof(tsk->sig->action)); return 0; }
像 copy_files() 和 copy_fs() 一样,copy_sighand() 也是只有在 CLONE_SIGHAND 为 0 时才真正进行;否则就共亨父进程的 sig 指针,并将父进程的 signal_struct 中的共享计数加 1。
(9)copy_mm
// kernel/fork.c static int copy_mm(unsigned long clone_flags, struct task_struct * tsk) { struct mm_struct * mm, *oldmm; int retval; tsk->min_flt = tsk->maj_flt = 0; tsk->cmin_flt = tsk->cmaj_flt = 0; tsk->nswap = tsk->cnswap = 0; tsk->mm = NULL; tsk->active_mm = NULL; /* * Are we cloning a kernel thread? * * We need to steal a active VM for that.. */ oldmm = current->mm; if (!oldmm) return 0; if (clone_flags & CLONE_VM) { atomic_inc(&oldmm->mm_users); mm = oldmm; goto good_mm; } retval = -ENOMEM; mm = allocate_mm(); if (!mm) goto fail_nomem; /* Copy the current MM stuff.. */ memcpy(mm, oldmm, sizeof(*mm)); if (!mm_init(mm)) goto fail_nomem; down(&oldmm->mmap_sem); retval = dup_mmap(mm); up(&oldmm->mmap_sem); /* * Add it to the mmlist after the parent. * * Doing it this way means that we can order * the list, and fork() won't mess up the * ordering significantly. */ spin_lock(&mmlist_lock); list_add(&mm->mmlist, &oldmm->mmlist); spin_unlock(&mmlist_lock); if (retval) goto free_pt; /* * child gets a private LDT (if there was an LDT in the parent) */ copy_segments(tsk, mm); if (init_new_context(tsk,mm)) goto free_pt; good_mm: tsk->mm = mm; tsk->active_mm = mm; return 0; free_pt: mmput(mm); fail_nomem: return retval; }
显然,对 mm_struct 的复制也是只在 clone_flags 中 CLONE_VM 标志为 0 时才真正进行,否则就只是通过已经复制的指针共享父进程的用户空间。对 mm_struct 的复制就不只是局限于这个数据结构本身了,也包括了对更深层数据结构的复制。其中最重要的是 vm_area_struct 数据结构和页面映射表, 这是由 dup_mmap() 复制的。函数 dup_mmap() 的代码也在 fork.c 中。读者在认真读过本书第2章以后, 阅读这段程序时应该不会感到困难,同时也是一次很好的练习。
(10)dup_mmap
// kernel/fork.c static inline int dup_mmap(struct mm_struct * mm) { struct vm_area_struct * mpnt, *tmp, **pprev; int retval; flush_cache_mm(current->mm); mm->locked_vm = 0; mm->mmap = NULL; mm->mmap_avl = NULL; mm->mmap_cache = NULL; mm->map_count = 0; mm->cpu_vm_mask = 0; mm->swap_cnt = 0; mm->swap_address = 0; pprev = &mm->mmap; // 这里通过140〜185行的for循环对同一用户空间中的各个区间进行复制。 for (mpnt = current->mm->mmap ; mpnt ; mpnt = mpnt->vm_next) { struct file *file; retval = -ENOMEM; if(mpnt->vm_flags & VM_DONTCOPY) continue; tmp = kmem_cache_alloc(vm_area_cachep, SLAB_KERNEL); if (!tmp) goto fail_nomem; *tmp = *mpnt; tmp->vm_flags &= ~VM_LOCKED; tmp->vm_mm = mm; mm->map_count++; tmp->vm_next = NULL; file = tmp->vm_file; // 对于通过mmap()映射到某个文件的区间,155〜169行是一些特殊的附加处理。 if (file) { struct inode *inode = file->f_dentry->d_inode; get_file(file); if (tmp->vm_flags & VM_DENYWRITE) atomic_dec(&inode->i_writecount); /* insert tmp into the share list, just after mpnt */ spin_lock(&inode->i_mapping->i_shared_lock); if((tmp->vm_next_share = mpnt->vm_next_share) != NULL) mpnt->vm_next_share->vm_pprev_share = &tmp->vm_next_share; mpnt->vm_next_share = tmp; tmp->vm_pprev_share = &mpnt->vm_next_share; spin_unlock(&inode->i_mapping->i_shared_lock); } /* Copy the pages, but defer checking for errors */ // 172行的copy_page_range()是关键所在,这个函数逐层处理页面目录项和页面表项, // 其代码在mm/memory.c 中 retval = copy_page_range(mm, current->mm, tmp); if (!retval && tmp->vm_ops && tmp->vm_ops->open) tmp->vm_ops->open(tmp); /* * Link in the new vma even if an error occurred, * so that exit_mmap() can clean up the mess. */ *pprev = tmp; pprev = &tmp->vm_next; if (retval) goto fail_nomem; } retval = 0; if (mm->map_count >= AVL_MIN_MAP_COUNT) build_mmap_avl(mm); fail_nomem: flush_tlb_mm(current->mm); return retval; }
(11)copy_page_range
// mm/memory.c /* * copy one vm_area from one task to the other. Assumes the page tables * already present in the new task to be cleared in the whole range * covered by this vma. * * 08Jan98 Merged into one routine from several inline routines to reduce * variable count and make things faster. -jj */ int copy_page_range(struct mm_struct *dst, struct mm_struct *src, struct vm_area_struct *vma) { pgd_t * src_pgd, * dst_pgd; unsigned long address = vma->vm_start; unsigned long end = vma->vm_end; unsigned long cow = (vma->vm_flags & (VM_SHARED | VM_MAYWRITE)) == VM_MAYWRITE; src_pgd = pgd_offset(src, address)-1; dst_pgd = pgd_offset(dst, address)-1; // 代码中163行的for循环是对页面目录项的循环, for (;;) { pmd_t * src_pmd, * dst_pmd; src_pgd++; dst_pgd++; /* copy_pmd_range */ if (pgd_none(*src_pgd)) goto skip_copy_pmd_range; if (pgd_bad(*src_pgd)) { pgd_ERROR(*src_pgd); pgd_clear(src_pgd); skip_copy_pmd_range: address = (address + PGDIR_SIZE) & PGDIR_MASK; if (!address || (address >= end)) goto out; continue; } if (pgd_none(*dst_pgd)) { if (!pmd_alloc(dst_pgd, 0)) goto nomem; } src_pmd = pmd_offset(src_pgd, address); dst_pmd = pmd_offset(dst_pgd, address); // 188行的do循环是对中间目录项的循环 do { pte_t * src_pte, * dst_pte; /* copy_pte_range */ if (pmd_none(*src_pmd)) goto skip_copy_pte_range; if (pmd_bad(*src_pmd)) { pmd_ERROR(*src_pmd); pmd_clear(src_pmd); skip_copy_pte_range: address = (address + PMD_SIZE) & PMD_MASK; if (address >= end) goto out; goto cont_copy_pmd_range; } if (pmd_none(*dst_pmd)) { if (!pte_alloc(dst_pmd, 0)) goto nomem; } src_pte = pte_offset(src_pmd, address); dst_pte = pte_offset(dst_pmd, address); // 211行的do循环则是对页面表项的循环。 do { pte_t pte = *src_pte; struct page *ptepage; /* copy_one_pte */ if (pte_none(pte)) goto cont_copy_pte_range_noset; if (!pte_present(pte)) { swap_duplicate(pte_to_swp_entry(pte)); goto cont_copy_pte_range; } ptepage = pte_page(pte); if ((!VALID_PAGE(ptepage)) || PageReserved(ptepage)) goto cont_copy_pte_range; /* If it's a COW mapping, write protect it both in the parent and the child */ if (cow) { ptep_set_wrprotect(src_pte); pte = *src_pte; } /* If it's a shared mapping, mark it clean in the child */ if (vma->vm_flags & VM_SHARED) pte = pte_mkclean(pte); pte = pte_mkold(pte); get_page(ptepage); cont_copy_pte_range: set_pte(dst_pte, pte); cont_copy_pte_range_noset: address += PAGE_SIZE; if (address >= end) goto out; src_pte++; dst_pte++; } while ((unsigned long)src_pte & PTE_TABLE_MASK); cont_copy_pmd_range: src_pmd++; dst_pmd++; } while ((unsigned long)src_pmd & PMD_TABLE_MASK); } out: return 0; nomem: return -ENOMEM; }
代码中163行的for循环是对页面目录项的循环,188行的do循环是对中间目录项的循环,211行 的do循环则是对页面表项的循环。我们把注意力集中在211一246行对页面表项的do_while循环。
循环中检查父进程一个页面表中的每个表项,根据表项的内容决定具体的操作。而表项的内容, 则无非是下面这么一些可能:
表项的内容为全 0,所以 pte_none() 返回1。说明该页面的映射尚未建立,或者说是个“空洞”, 因此不需要做任何事。
表项的最低位,即 _PAGE_PRESENT 标志位为 0,所以 pte_present() 返回 1 。说明映射已建 立,但是该页面目前不在内存中,已经被调出到交换设备上。此时表项的内容指明“盘上页面”的地点,而现在该盘上页面多了一个“用户”,所以要通过 swap_duplicate() 递增它的共享计数。然后,就转到 cont_copy_pte_range 将此表项复制到了进程的页面去中。
映射已建立,但是物理页面不是一个有效的内存页面,所以 VALID_PAGE() 返回 0 。读者可以回顾一下,我们以前讲过有些物理页面在外设接口卡上,相应的地址称为“总线地址”, 而并不是内存页面。这样的页面、以及虽是内存页面但由内核保留的页面,是不属于页面换入/换出机制管辖的,实际上也不消耗动态分配的内存页面,所以也转到cont_copy_pte_range 将此表项复制到子进程的页面表中。
需要从父进程复制的可写页面。本来,此时应该分配一个空闲的内存页面,再从父进程的页面把内容复制过来,并为之建立映射。显然,这个操作的代价是不小的。然而,对这么辛辛苦苦复制下来的页面,子进程是否一定会用呢?特别是会有写访问呢?如果只是读访问,则只要父进程从此不再写这个页面,就完全可以通过复制指针来共亨这个页面,那不知要省事多少了。所以,Linux 内核采用了一种称为 “copy on write” 的技术,先通过复制页面表项暂时共享这个页面,到子进程(或父进程)真的要写这个页面时再来分配页面和复制。代码中的局部变量 cow 是在前面158行定义的,变量名 cow 是 “copy on write” 的缩写。只要一个虚存区间的性质是可写 ( VM_MAYWRITE 为 1 ) 而又不是共享 ( VM_SHARED 为 0 ) ,就属于 copy_on_write 区间。实际上,对于绝大多数的可写虚存区间,cow 都是 1 。在通过复制页面表项暂时共享一个页面表项时要做两件重要的事情,首先要在230和231行将父进程的页面表项改成写保护,然后在236行把已经改成写保护的表项设置到子进程的页面表中。这样一来,相应的页面在两个进程中都变成“只读” 了,当不管是父进程或是子进程企图写入该页面时,都会引起一次页面异常。而页面异常处理程序对此的反应则是另行分配个物理页面,并把内容真正地“复制”到新的物理页面中,让父、子进程各自拥有自己的物理页面, 然后将两个页面表中相应的表项改成可写。所以,Linux 内核之所以可以很迅速地“复制” 一个进程,完全依赖于 “copy on write" (否则,在 fork 一个进程时就得要复制每一个物理页面了) 。可是,copy_on_write 只存在父、子进程各自拥有自己的页面表时才能实现。当 CLONE_VM 标志位为 1,因为父、子进程通过指针共享用户空间时,copy_on_write 就用不上了。此时,父、子进程是在真正的意义上共享用户空间,父进程写入其用户空间的内容同时也 “写入” 子进程的用户空间。
父进程的只读页面。这种页面本来就不需要复制。因而可以复制页面表项共享物理页面。
可见,名为 copy_page_range(),实际上却连一个页面也没有真正地 “复制”,这就是为什么 Linux 内核能够很迅速地 fork() 或 clone() 一个进程的秘密。
回到 copy_mm() 的代码中。函数 copy_segments() 处理的是进程可能具有的局部段描述表 LDT 。我们在第 2 章中讲过,只有在 VM86 模式中运行的进程才会有 LDT 。虽然我们并不关心VM86模式,但 是有兴趣的读者也不妨自己看看它是怎样复制的。copy_segments() 的代码在 arch/i386/kernel/process.c 中。
(12)copy_segments
// arch/i386/kernel/process.c /* * we do not have to muck with descriptors here, that is * done in switch_mm() as needed. */ void copy_segments(struct task_struct *p, struct mm_struct *new_mm) { struct mm_struct * old_mm; void *old_ldt, *ldt; ldt = NULL; old_mm = current->mm; if (old_mm && (old_ldt = old_mm->context.segments) != NULL) { /* * Completely new LDT, we initialize it from the parent: */ ldt = vmalloc(LDT_ENTRIES*LDT_ENTRY_SIZE); if (!ldt) printk(KERN_WARNING "ldt allocation failed\n"); else memcpy(ldt, old_ldt, LDT_ENTRIES*LDT_ENTRY_SIZE); } new_mm->context.segments = ldt; }
回到 copy_mm() 的代码。对于 i386 CPU 来说,copy_mm() 中 339 行处的 init_new_context() 是个空语句。
(13)copy_thread
// arch/i386/kernel/process.c /* * Save a segment. */ #define savesegment(seg,value) \ asm volatile("movl %%" #seg ",%0":"=m" (*(int *)&(value))) int copy_thread(int nr, unsigned long clone_flags, unsigned long esp, unsigned long unused, struct task_struct * p, struct pt_regs * regs) { struct pt_regs * childregs; childregs = ((struct pt_regs *) (THREAD_SIZE + (unsigned long) p)) - 1; struct_cpy(childregs, regs); childregs->eax = 0; childregs->esp = esp; p->thread.esp = (unsigned long) childregs; p->thread.esp0 = (unsigned long) (childregs+1); p->thread.eip = (unsigned long) ret_from_fork; savesegment(fs,p->thread.fs); savesegment(gs,p->thread.gs); unlazy_fpu(current); struct_cpy(&p->thread.i387, ¤t->thread.i387); return 0; }
名为 copy_thread(),实际上即只是复制父进程的系统空间堆栈。堆栈中的内容说明了父进程从通过系统调用进入系统空间开始到进入 copy_thread() 的来历,子进程将要循相同的路线返回,所以要把它复制给子进程。但是,如果子进程的系统空间堆栈与父进程的完全相同,那返回以后就无从区分谁是子进程了,所以复制以后还要略作调整。这是一段很有趣的程序,我们先来看535行。在第3章中,读者已经看到当一个进程因系统调用或中断而进入内核时,其系统空间堆栈的顶部保存着CPU进入内核前夕各个寄存器的内容,并形成一个 pt_regs 数据结构。这里535行中的 p 为子进程的 task_struct 指针,指向两个连续物理页面的起始地址;而 THREAD_SIZE + (unsigned long)p 则指向这两个页面的顶端。将其变换成 structpt_regs*,再从中减 1,就指向了了进程系统空间堆栈中的 pt_regs 结构,如图 4.3 所示。
得到了指向子进程系统空间堆栈中 pt_regs 结构的指针 childregs 以后,就先将当前进程系统空间堆栈中的 pt_regs 结构复制过去,再来作少量的调整。什么样的调整呢?首先,将该结构中的 eax 置成 0。 当子进程受调度而“恢复”运行,从系统调用“返回”时,这就是返回值。如前所述,子进程的返回值为 0。其次,还要将结构中的 esp 置成这里的参数 esp,它决定了进程在用户空间的堆栈位置。在 __clone() 调用中,这个参数是由调用者给定的。而在 fork() 和 vfork() 中,则来自调用 do_fork() 前夕的 regs.esp,所以实际上并没有改变,还是指向父进程原来在用户空间的堆栈。
在进程的 task_struct 结构中有个重要的成分 thread,它本身是一个数据结构 thread_struct,里面记录着进程在切换时的 (系统空间) 堆栈指针,取指令地址 (也就是“返回地址”) 等关键性的信息。在复制 task_struct 数据结构的时候,这些信息也原封不动地复制了过来。可是,子进程有自己的系统空间堆栈,所以也要相应加以调整。具体地说,540行将 p->thread.esp 设置成子进程系统空间堆栈中 pt_regs 结构的起始地址,就好像这个子进程以前曾经运行过,而在进入内核以后正要返回用户空间时被切换了一样。而 p->thread.esp0 则应该指向子进程的系统空间堆栈的顶端。当一个进程被调度运行时,内核会将这个变量的值写入 TSS 的 esp0 字段,表示当这个进程进入 0 级运行时其堆栈的位置。此外, p->thread.eip 的值表示当进程下一次被切换进入运行时的切入点,类似于函数调用或中断的返回地址。 将此地址设置成 ret_from_fork,使创建的子进程在首次被调度运行时就从那儿开始,这一点以后在阅读有关进程切换的代码时还要讲到。545行和546行的 savesegment 是个宏操作,其定义就在526行。 所以,545行在 gcc 预处理以后就会变成
asm volatile("movl %%fs, %0":"=m" (*(int *)&p->thread.fs))
也就是把当前的段寄存器 fs 的值保存在 p->thread.fs 中。546行与此类似。548行和549行是为 i387 浮点处理器而设的,那就不是我们所关心的了。
(14)DECLARE_MLTEX_LOCKED
// include/asm-i386/semaphore.h #define DECLARE_MUTEX(name) __DECLARE_SEMAPHORE_GENERIC(name,1) #define DECLARE_MUTEX_LOCKED(name) __DECLARE_SEMAPHORE_GENERIC(name,0)
将 DECLARE_MUTEX_LOCKED 与 DECLARE_MUTEX 作一比较,可以看出正常情况下信号量中资源的数量为 1,而现在这个信号量中资源的数量为 0。
4、系统调用execve
读者在前一节中已经看到,进程通常是按其父进程的原样复制出来的,在多数情况下,如果复制出来的子进程不能与父进程分道扬镳,“走自已的路”,那就没有多大意义。所以,执行一个新的可执行程序是进程生命历程中关键性的一步。Linux 为此提供了一个系统调用 execve(),而在C语言的程序库中则又在此基础上向应用程序提供一整套的库函数,包括 execl() 、execlp() 、execle() 、execle() 、execv() 和 execvp() 。 此外,还有库函数 system(),也与 exccve() 有关,不过 system() 是 fork() 、execve() 、wait4() 的组合。我们己经在本章第2节介绍过应用程序怎样调用 execve(),现在我们就来介绍 execvc() 的实现。
系统调用 execve() 内核入口 是 sys_execve(),代码见 arch/i386/kernel/process.c:
(1)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; // 以前讲过,系统调用进入内核时,regs.ebx 中的内容为应用程序中调用相应库函数时的第一个参数。 // 在本章第2节所举的例子中,这个参数为指向字符串 “/bin/echo" 的指针。现在,指针存放在 // regs.ebx 中,但字符串本身还在用户空间中,所以730行的 getname() 要把这个字符串从 // 用户空间拷贝到系统空间,在系统空间中建立起一个副本。让我们看看具体是怎么做的。 // 函数 getname() 的代码在 fs/namei.c 中 filename = getname((char *) regs.ebx); error = PTR_ERR(filename); if (IS_ERR(filename)) goto out; // 在系统空间中建立起一份可执行文件的路径名副本以后,sys_execve()就调用do_execve(),以完 // 成其主体部分的工作。当然,完成以后还要通过 putname() 将所分配的物理页面释放。 // 函数 do_execvc() 的代码在 fs/exec.c 中 error = do_execve(filename, (char **) regs.ecx, (char **) regs.edx, ®s); if (error == 0) current->ptrace &= ~PT_DTRACE; putname(filename); out: return error; }
⑴ getname
// fs/namei.c char * getname(const char * filename) { char *tmp, *result; result = ERR_PTR(-ENOMEM); tmp = __getname(); if (tmp) { int retval = do_getname(filename, tmp); result = tmp; if (retval < 0) { putname(tmp); result = ERR_PTR(retval); } } return result; } // ================================================================= // include/linux/fs.h #define __getname() kmem_cache_alloc(names_cachep, SLAB_KERNEL)
先通过 __getname() 分配一个物理页面作为缓冲区,然后调用 do_getname() 从用户空间拷贝字符串。那么,为什么要专门为此分配一个物理页面作为缓冲区呢?首先,这个字符串确有可能相当长, 因为这是个绝对路径名。其次,我们以前讲过,进程系统空间堆栈的大小是大约 7KB,不能滥用, 不宜在 getname() 中定义一个局部的 4KB 的字符数组 (注意,局部变量所占据的空间是在堆栈中分配的) 。函数 do_getname() 的代码也在文件 fs/namei.c 中。
⑵ do_getname
// fs/namei.c /* In order to reduce some races, while at the same time doing additional * checking and hopefully speeding things up, we copy filenames to the * kernel data space before using them.. * * POSIX.1 2.4: an empty pathname is invalid (ENOENT). */ static inline int do_getname(const char *filename, char *page) { int retval; unsigned long len = PATH_MAX + 1; if ((unsigned long) filename >= TASK_SIZE) { if (!segment_eq(get_fs(), KERNEL_DS)) return -EFAULT; } else if (TASK_SIZE - (unsigned long) filename < PAGE_SIZE) len = TASK_SIZE - (unsigned long) filename; retval = strncpy_from_user((char *)page, filename, len); if (retval > 0) { if (retval < len) return 0; return -ENAMETOOLONG; } else if (!retval) retval = -ENOENT; return retval; }
如果指针 filename 的值大于等于 TASK_SIZE,就表示 filename 实际上在系统空间中。读者应该还记得 TASK_SIZE 的值是 3GB 。具体的拷贝是通过 strncpy_from_user() 进行的,代码见 arch/i386/lib/usercopy.c 。
// arch/i386/lib/usercopy.c long strncpy_from_user(char *dst, const char *src, long count) { long res = -EFAULT; if (access_ok(VERIFY_READ, src, 1)) __do_strncpy_from_user(dst, src, count, res); return res; } #define __do_strncpy_from_user(dst,src,count,res) \ do { \ int __d0, __d1, __d2; \ __asm__ __volatile__( \ " testl %1,%1\n" \ " jz 2f\n" \ "0: lodsb\n" \ " stosb\n" \ " testb %%al,%%al\n" \ " jz 1f\n" \ " decl %1\n" \ " jnz 0b\n" \ "1: subl %1,%0\n" \ "2:\n" \ ".section .fixup,\"ax\"\n" \ "3: movl %5,%0\n" \ " jmp 2b\n" \ ".previous\n" \ ".section __ex_table,\"a\"\n" \ " .align 4\n" \ " .long 0b,3b\n" \ ".previous" \ : "=d"(res), "=c"(count), "=&a" (__d0), "=&S" (__d1), \ "=&D" (__d2) \ : "i"(-EFAULT), "0"(count), "1"(count), "3"(src), "4"(dst) \ : "memory"); \ } while (0)
这个函数的主体 __do_strncpy_from_user() 是一个宏操作,也在同一源文件 usercopy.c 中,与第3章中介绍过的 __generic_copy_from_user() 很相似,读者可以自行对照阅读。
(2)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; // 显然,先要将给定的可执行程序文件找到并打开,open_exec()就是为此而调用的,其代码也在 // fs/exec.c 中,读者可结合“文件系统” 一章中有关打开文件操作的内容,特别是 path_walk() // 的代码自行阅读。 file = open_exec(filename); retval = PTR_ERR(file); if (IS_ERR(file)) return retval; // 假定目标文件已经打开,下一步就要从文件中装入可执行程序了。内核中为可执行程序的装入定 // 义了一个数据结构 linux_binprm ,以便将运行一个可执行文件时所需的信息组织在一起, // 这是在 include/linux/binfmts.h 定义的 bprm.p = PAGE_SIZE*MAX_ARG_PAGES-sizeof(void *); memset(bprm.page, 0, MAX_ARG_PAGES*sizeof(bprm.page[0])); // 代码中的 linux_binprm 数据结构 bprm 是个局部量。函数 open_exec() 返回一个 file 结构指针, // 代表着读入可执行文件的上下文,所以将其保存在数据结构 bprm 中。变量 bprm.sh_bang 的值说明 // 可执行文件的性质,当可执行文件是一个Shell过程(Shell Script,用 Shell 语言编写的命令文件, // 由 shell 解释执行) 时置为 1。而现在还不知道,所以暂且将其置为 0,也就是先假定为二进制文件。 // 数据结构中的其他两个变量也暂时设置成 0。接着就处理可执行文件的参数和环境变量。 bprm.file = file; bprm.filename = filename; bprm.sh_bang = 0; bprm.loader = 0; bprm.exec = 0; // 与可执行文件路径名的处理办法一样,每个参数的最大长度也定为一个物理页面,所以 bprm 中有 // 一个页面指针数组,数组的大小为允许的最大参数个数 MAX_AGE_PAGES,目前这个常数定义为32。 // 前面已通过memset()将这个指针数组初始化成全0。现在将 bprm.p 设置成这些页面的总和减长一个 // 指针的大小,因为第 0 个参数也就是 argv[0] 是可执行程序本身的路径名。函数 count() 是在 // exec.c 中定义的,这里用它对字符串指针数组 argv[] 中参数的个数进行计数,而 // bprm.p/sizeof(void*)表示允许的最大值。同样,对作为参数传过来的环境变量也要通过 count() // 计数。注意这里的数组 argv[] 和 envp[] 是在用户空间而不在系统空间,所以计数的操作并不那么 // 简单。函数 count() 的代码在 fs/exec.c 中,它本身的代码很简单,但是引用的宏定义 // get_user() 却颇有些挑战性,值得一读。它也与第3章中介绍过的 __generic_copy_from_user() // 相似,我们把它留给读者作为练习。相关的代码在 include/asm-i386/uaccess.h 和 // arch/i386/lib/getuser.S 中,调用的路径为 // [count() > getuser() > _get_user() > _get_user_4()] 。 如果 count() 失败,即返回负值, // 则要对目标文件执行一次 allow_write_access()。这个函数是与 deny_write_access() 配对使用的, // 目的在于防止其他进程(可能在另一个CPU上运行)在读入可执行文件期间通过内存映射改变它的内容 // (详见“文件系统”以及系统调用mmap())。与其配对的 deny_write_access() 是在打开可执行文件时在 // open_exec() 中调用的。 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; } // 完成了对参数和环境变量的计数以后,do_execve()又调用prepare_binprm(),进一步做数据结构 // bprm的准备工作,从可执行文件读入开头的128个字节到 linux_binprm 结构 bprm 中的缓冲区。 // 当然,在读之前还要先检验当前进程是否有这个权力,以及该文件是否有可执行属性。如果可执行文件 // 具有"setuid”特性则要作相应的设置。这个函数的代码也在exec.c中。由于涉及文件操作的细节,我 // 们建议读者在学习了 “文件系统”以后再回过来自行阅读。此处先说明为什么只是先读128个字节。 // 这是因为,不管目标文件是elf格式还是a.out格式,或者别的格式,在开头128个字节中都包括了关 // 于可执行文件属性的必要而充分的信息。等一下读者就会看到这些信息的用途。 retval = prepare_binprm(&bprm); if (retval < 0) goto out; // 最后的准备工作就是把执行的参数,也就是argv[],以及运行的环境,也就是envp[],从用户空 // 间拷贝到数据结构bprm中。其中的第1个参数argv[0]就是可执行文件的路径名,已经在 // bprm.filename 中了,所以用copy_strings_kernel()从系统空间中拷贝,其他的就要用 // copy_strings()从用户空间拷贝。 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; // 至此,所有的准备工作都己完成,所有必要的信息都已经搜集到了 linux_binprm 结构bprm中, // 接下来就要装入并运行目标程序了 (exec.c) 。 // // 显然,这里的关键是search_binary_handler(),在深入到这个函数内部之前,先介绍一个大概。内 // 核中有一个队列,叫 formats,挂在此队列中的成员是代表着各种可执行文件格式的“代理人”,每个 // 成员只认识并且处理一种特定格式的可执行文件的运行。在前面的准备阶段中,己经从可执行文件头 // 部读入了 128 个字节存放在bprm的缓冲区,而且运行所需的参数和环境变量也已经收集在bprm中。 // 现在就由formats队列中的成员逐个来认领,谁要是辨认到了它所代表的可执行文件格式,运行的事就 // 交给它。要是都不认识呢?那就根据文件头部的信息再找找看,是否有为此种格式设计,但是作为可 // 动态安装模块实现的“代理人”存在于文件系统中。如果有的话就把这模块安装进来并且将其挂入到 // formats队列中,然后让formats队列中的各个“代理人”再来试一次。 // 函数search_binary_handler()的代码也在exec.c中,其中有一段是专门针对alpha处理器的条件编 // 译,在下列的代码中跳过了这段条件编译语句: 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; }
⑴ linux_binprm
// include/linux/binfmts.h /* * This structure is used to hold the arguments that are used when loading binaries. */ struct linux_binprm{ char buf[BINPRM_BUF_SIZE]; struct page *page[MAX_ARG_PAGES]; unsigned long p; /* current top of mem */ int sh_bang; struct file * file; int e_uid, e_gid; kernel_cap_t cap_inheritable, cap_permitted, cap_effective; int argc, envc; char * filename; /* Name of binary */ unsigned long loader, exec; };
⑵ search_binary_handler
// fs/exec.c /* * 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; #ifdef __alpha__ /* handle /sbin/loader.. */ { struct exec * eh = (struct exec *) bprm->buf; if (!bprm->loader && eh->fh.f_magic == 0x183 && (eh->fh.f_flags & 0x3000) == 0x3000) { char * dynloader[] = { "/sbin/loader" }; struct file * file; unsigned long loader; allow_write_access(bprm->file); fput(bprm->file); bprm->file = NULL; loader = PAGE_SIZE*MAX_ARG_PAGES-sizeof(void *); file = open_exec(dynloader[0]); retval = PTR_ERR(file); if (IS_ERR(file)) return retval; bprm->file = file; bprm->loader = loader; retval = prepare_binprm(bprm); if (retval<0) return retval; /* should call search_binary_handler recursively here, but it does not matter */ } } #endif 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; }
程序中有两层嵌套的 for 循环。内层是对 formats 队列中的每个成员循环,让队列中的成员逐个试试它们的 load_binary() 函数,看看能否对上号。如果对上了号,那就把目标文件装入并将其投入运行, 再返回一个正数或 0 。当 CPU 从系统调用返回时,该目标文件的执行就真正开始了。否则,如果不能辨识,或者在处理的过程中也了错,就返回一个负数。出错代码 -ENOEXEC 表示只是对不上号,而并没有发生其他的错误,所以循环回去,让队列中的下个成员再来试试。但是如果出了错而又并不是 -ENOEXEC,那就表示对上了号但出了其他的错,这就不用再让其他的成员来试了。
内层循环结束以后,如果失败的原因是 -ENOEXEC,就说明队列中所有的成员都不认识目标文件的格式。这时候,如果内核支持动态安装模块 (取决于编译选择项 CONFIG_KMOD) ,就根据目标文件的第2和第3个字节生成一个 binfmt 模块名,通过 request_module() 试着将相应的模块装入 (见本书 “文件系统”和“设备驱动”两章中的有关内容) 。外层的 for 循环共进行两次,正是为了在安装了模块以后再来试一次。
能在 Linux 系统上运行的可执行程序的开头几个字节,特别是开头 4 个字节,往往构成一个所谓的 magic number,如果把它拆开成字节,则往往又是说明文件格式的字符。例如,elf 格式的可执行文件的头四个字节为 “0x7F” 、“e”、“l” 和 “f";而 java 的可执行文件头部四个字节则为 “c”、“a”、“f” 和 “e”。如果可执行文件为 Shell 过程或 perl 文件,即笫一行的格式为 #!/bin/sh 或 #!/usr/bin/perl ,此时第一个字符为 “#”,第二个字符为 “!”,后面是相应解释程序的路径名。
数据结构 linux_binfmt 定义于 include/linux/binfmts.h 中,前面已经看到过了。结构中有三个函数指针,load_binary 用来装入可执行程序,load_shlib 用来装入动态安装的公用库程序,而 core_dump 的作用则不言自明。显然,这里最根本的是 load_binary 。同时,如果不搞清具体的装载程序怎样工作,就很难对 execve()、进而对 Linux 进程的运行有深刻的理解。下面我们以 a.out 格式为例,讲述装入并启动执行目标程序的过程。其实,a.out 格式的可执行文件已经渐渐被淘汰了,取而代之的是 elf 格式。 但是,a.out 格式要简单得多,并且方便我们通过它来讲述目标程序的装载和投入运行的过程,所以从篇幅考虑我们选择了 a.out 。读者在搞清了 a.out 格式的装载和投运过程以后,可以自行阅读有关 elf 格式的相关代码。
⑶ a.out格式目标文件的装载和投运行
// fs/binfmt_aout.c static struct linux_binfmt aout_format = { NULL, THIS_MODULE, load_aout_binary, load_aout_library, aout_core_dump, PAGE_SIZE }; /* * These are the functions used to load a.out style executables and shared * libraries. There is no binary dependent code anywhere else. */ static int load_aout_binary(struct linux_binprm * bprm, struct pt_regs * regs) { struct exec ex; unsigned long error; unsigned long fd_offset; unsigned long rlim; int retval; // 首先是检查目标文件的格式,看看是否对上号。所有a.out格式可执行文件(二进制代码)的开头 // 都应该是一个exec数据结构,这是在include/asm-i386/a.out.h 中定义的: ex = *((struct exec *) bprm->buf); /* exec-header */ if ((N_MAGIC(ex) != ZMAGIC && N_MAGIC(ex) != OMAGIC && N_MAGIC(ex) != QMAGIC && N_MAGIC(ex) != NMAGIC) || N_TRSIZE(ex) || N_DRSIZE(ex) || bprm->file->f_dentry->d_inode->i_size < ex.a_text+ex.a_data+N_SYMSIZE(ex)+N_TXTOFF(ex)) { return -ENOEXEC; } // 各种a.out格式的文件因目标代码的特性不同,其正文的起始伟置也就不同。为此提供了一个宏操 // 作N_TXTOFF(),以便根据代码的特性取得正文在目标文件中的起始位置,这是在includc/linux/a.out.h // 中定义的: fd_offset = N_TXTOFF(ex); /* Check initial limits. This avoids letting people circumvent * size limits imposed on them by creating programs with large * arrays in the data or bss. */ // 以前曾经讲过,每个进程的task_struct结构中有个数组rlim,规定了该进程使用各种资源的限制, // 其中也包括对用于数据的内存空间的限制。所以,目标文件所确定的data和bss两个“段”的总和不 // 能超出这个限制。 rlim = current->rlim[RLIMIT_DATA].rlim_cur; if (rlim >= RLIM_INFINITY) rlim = ~0; if (ex.a_data + ex.a_bss > rlim) return -ENOMEM; /* Flush all traces of the currently running executable */ // 顺利通过了这些检验就表示具备了执行该目标文件的条件,所以就到了 “与过去告别”的时候。 // 这种“告别过去”意味着放弃从父进程“继承”下来的全部用户空间,不管是通过复制还是通过共享 // 继承下来的。不过,下面读者会看到,这种告别也并非彻底的决裂。 // 函数 flush_old_exec() 的代码也在 exec.c 中 retval = flush_old_exec(bprm); if (retval) return retval; /* OK, This is the point of no return */ #if !defined(__sparc__) set_personality(PER_LINUX); #else set_personality(PER_SUNOS); #if !defined(__sparc_v9__) memcpy(¤t->thread.core_exec, &ex, sizeof(struct exec)); #endif #endif // 这里是对新的mm_struct数据结构中的一些变量进行初始化,为以后分配存储空间并读入可执行 // 代码的映象作好准备。目标代码的映象分成text、data 以及 bss 三段,mm_struct结构中为每个段都设 // 置了 start 和 end 两个指针。每段的起始地址定义于include/linux/a.out.h: current->mm->end_code = ex.a_text + (current->mm->start_code = N_TXTADDR(ex)); current->mm->end_data = ex.a_data + (current->mm->start_data = N_DATADDR(ex)); current->mm->brk = ex.a_bss + (current->mm->start_brk = N_BSSADDR(ex)); current->mm->rss = 0; current->mm->mmap = NULL; // 然后,通过compute_creds()确定进程在开始执行新的目标代码以后所具有的权限,这是根据bprm // 中的内容和当前的权限确定的。其代码在 exec.c 中,读者可自行阅读。 compute_creds(bprm); current->flags &= ~PF_FORKNOEXEC; #ifdef __sparc__ if (N_MAGIC(ex) == NMAGIC) { loff_t pos = fd_offset; /* Fuck me plenty... */ /* <AOL></AOL> */ error = do_brk(N_TXTADDR(ex), ex.a_text); bprm->file->f_op->read(bprm->file, (char *) N_TXTADDR(ex), ex.a_text, &pos); error = do_brk(N_DATADDR(ex), ex.a_data); bprm->file->f_op->read(bprm->file, (char *) N_DATADDR(ex), ex.a_data, &pos); goto beyond_if; } #endif // 前面讲过,a.out 格式目标代码中的 magic number 表示着代码的特性,或者说类型。当 magic number // 为 OMAGIC 时,表示该文件中的可执行代码并非“纯代码”。对于这样的代码,先通过do_brk()为正文 // 段和数据段合在一起分配空间,然后就把这两部分从文件中读进来。函数do_brk()我们已经在笫2章中 // 介绍过,而从文件读入则在“文件系统”和“块设备驱动”两章中有详细叙述,读者可以参阅,这里 // 就不重复了。不过要指出,读入代码时是从文件中位移为32的地方开始,读入到进程用户空间中从地 // 址0开始的地方,读入的总长度为ex.a_text+ex.a_data。对于 i386 CPU 而言, // flush_icache_range() 为一空语句。至于bss段,则无需从文件读入,只要分配空间就可以了, // 所以放在后面再处理。对于 OMAGIC 类型的a.out可执行文件而言,装入程序的工作就基本完成了。 if (N_MAGIC(ex) == OMAGIC) { unsigned long text_addr, map_size; loff_t pos; text_addr = N_TXTADDR(ex); #if defined(__alpha__) || defined(__sparc__) pos = fd_offset; map_size = ex.a_text+ex.a_data + PAGE_SIZE - 1; #else pos = 32; map_size = ex.a_text+ex.a_data; #endif error = do_brk(text_addr & PAGE_MASK, map_size); if (error != (text_addr & PAGE_MASK)) { send_sig(SIGKILL, current, 0); return error; } error = bprm->file->f_op->read(bprm->file, (char *)text_addr, ex.a_text+ex.a_data, &pos); if (error < 0) { send_sig(SIGKILL, current, 0); return error; } flush_icache_range(text_addr, text_addr+ex.a_text+ex.a_data); } else { // 在a.out格式的可执行文件中,除OMAGIC以外其他二种均为纯代码,也就是所谓的“可重入”代 // 码。此类代码中,不但其正文段的执行代码在运行时不会改变,其数据段的内容也不会在运行时改变。 // 凡是要在运行过程中改变内容的东西都在堆栈中(局部变量),要不然就在动态分配的缓冲区。所以, // 内核干脆将可执行文件映射到子进程的用户空间中,这样连通常swap所需的盘上空间也省去了。在这 // 二种类型的可执行文件中,除NMGIC以外都要求正文段及数据段的长度与页面大小对齐,如发现没有 // 对齐就要通过printk()发出警告信息。但是,发出警告信息太频繁也不好,所以就设置了一个静态变量 // error_time2,使警告信息之间的间隔不小于5秒。接下来的操作取决于具体的文件系统是否提供mmap、 // 就是将一个已打开文件映射到虚在空间的操作,以及正文段及数据段的长度是否与页面大小对齐。如 // 果不满足映射的条件,就分配空间并且将正文段和数据段一起读入至进程的用户空间。这次是从文件 // 中位移为fd_offset ,即N_TXTOFF(ex)的地方开始,读入到由文件的头部所指定的地址 // N_TXTADDR(ex),长度为两段的总和。如果满足映射的条件,那就更好了,那就通过do_mmap()分别 // 将文件中的正文段和数据段映射到进程的用户空间中,映射的地址则与装入的地址一致。 // 调用mmap()之前无需分配空间, 那已经包含在mmap()之中了。 // 至此,正文段和数据段都已经装入就绪了,接下来就是bss段和堆栈段了(fs/binfmt_aout.c) static unsigned long error_time, error_time2; if ((ex.a_text & 0xfff || ex.a_data & 0xfff) && (N_MAGIC(ex) != NMAGIC) && (jiffies-error_time2) > 5*HZ) { printk(KERN_NOTICE "executable not page aligned\n"); error_time2 = jiffies; } if ((fd_offset & ~PAGE_MASK) != 0 && (jiffies-error_time) > 5*HZ) { printk(KERN_WARNING "fd_offset is not page aligned. Please convert program: %s\n", bprm->file->f_dentry->d_name.name); error_time = jiffies; } if (!bprm->file->f_op->mmap||((fd_offset & ~PAGE_MASK) != 0)) { loff_t pos = fd_offset; do_brk(N_TXTADDR(ex), ex.a_text+ex.a_data); bprm->file->f_op->read(bprm->file,(char *)N_TXTADDR(ex), ex.a_text+ex.a_data, &pos); flush_icache_range((unsigned long) N_TXTADDR(ex), (unsigned long) N_TXTADDR(ex) + ex.a_text+ex.a_data); goto beyond_if; } down(¤t->mm->mmap_sem); error = do_mmap(bprm->file, N_TXTADDR(ex), ex.a_text, PROT_READ | PROT_EXEC, MAP_FIXED | MAP_PRIVATE | MAP_DENYWRITE | MAP_EXECUTABLE, fd_offset); up(¤t->mm->mmap_sem); if (error != N_TXTADDR(ex)) { send_sig(SIGKILL, current, 0); return error; } down(¤t->mm->mmap_sem); error = do_mmap(bprm->file, N_DATADDR(ex), ex.a_data, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_FIXED | MAP_PRIVATE | MAP_DENYWRITE | MAP_EXECUTABLE, fd_offset + ex.a_text); up(¤t->mm->mmap_sem); if (error != N_DATADDR(ex)) { send_sig(SIGKILL, current, 0); return error; } } beyond_if: set_binfmt(&aout_format); set_brk(current->mm->start_brk, current->mm->brk); // 接着,还要在用户空间的堆栈区顶部为进程建立起一个虚存区间,并将执行参数以及环境变量所 // 占的物理页面与此虚存区间建立起映射。这是由setup_arg_pages()完成的,其代码在exec.c中: // // 进程的用户空间中地址最高处为堆栈区,这里的常数STACK_TOP就是TASK_SIZE,也就是3GB // (0xC0000 0000)。堆栈区的顶部为一个数组,数组中的每一个元素都是一个页面。数组的大小为 // MAX_ARG_PAGES,而实际映射的页面数量则取决于这些执行参数和环境变量的数量。 // 然后,在这些页面的下方,就是过程的用户空间堆栈了。另一方面,大家知道任何用户程序的入 // 口都是main(),而main有两个参数argc和argv[]。其中参数argv[]是字符指针数组, // argc则为数组的大小。 但是实际上还有个隐藏着的字符指针数组envp[]用来传递环境变量, // 只是不在用户程序的“视野”之内而已。所以,用户空间堆栈中从一开始就要设置好三项数据, // 即envp[]、argv[] 以及argc。 retval = setup_arg_pages(bprm); if (retval < 0) { /* Someone check-me: is this error path enough? */ send_sig(SIGKILL, current, 0); return retval; } // 此外,还要将保存着的(字符串形式的)参数和环境变量复制到用户空间的顶端。这都是由 // create_aout_tables() 完成的,其代码也在同一文件(binfmt.aout.c)中 // // 读者应该能看明白,这是在堆栈的顶端构筑envp[]、argv[]和argc。请读者注意看一下这段代码中 // 的228至234行(以及237至243行),然后回答一个问题:为什么是get_user(c, ptt),后不是 // get_user(&c, ptt)? 以前我们曾经讲过,get_user()是一段颇具挑战性的代码, // 并建议读者自行阅读。现在简单地介绍一下,看看你是否读懂了。 // 这是在include/asm-i386/uaccess.h 中定义的一个宏定义 current->mm->start_stack = (unsigned long) create_aout_tables((char *) bprm->p, bprm); #ifdef __alpha__ regs->gp = ex.a_gpvalue; #endif // 这里只剩下最后一个关键性的操作了,那就是start_thread()。这是个宏操作,定义于 // include/asm-i386/process.h 中 // // 读者对这里的regs指针已经很熟悉,它指向保留在当前进程系统空间堆栈中的各个寄存器副本。 // 当进程从系统调用返回时,这些数值就会被“恢复”到CPU的各个寄存器中。所以,那时候的堆栈指 // 针将是current->mm->start_stack;而返回地址,也就是EIP的内容,则将是ex.a_entry。 // 显然,这正是我们所需要的。 start_thread(regs, ex.a_entry, current->mm->start_stack); if (current->ptrace & PT_PTRACED) send_sig(SIGTRAP, current, 0); return 0; // 至此,可执行代码的装入和投运已经完成。而 do_execve() 在调用了 search_binary_handler() // 以后也就结束了。当CPU从系统调用返回到用户空间时,就会从由 ex.a_entry 确定的地址开始执行。 }
Ⓐ exec
// include/asm-i386/a.out.h struct exec { unsigned long a_info; /* Use macros N_MAGIC, etc for access */ unsigned a_text; /* length of text, in bytes */ unsigned a_data; /* length of data, in bytes */ unsigned a_bss; /* length of uninitialized data area for file, in bytes */ unsigned a_syms; /* length of symbol table data in file, in bytes */ unsigned a_entry; /* start address */ unsigned a_trsize; /* length of relocation info for text, in bytes */ unsigned a_drsize; /* length of relocation info for data, in bytes */ }; #define N_TRSIZE(a) ((a).a_trsize) #define N_DRSIZE(a) ((a).a_drsize) #define N_SYMSIZE(a) ((a).a_syms)
结构中的第一个无符号长整数 a_info 在逻辑上分成两部分:其高16位是一个代表目标 CPU 类型的代码,对于 i386 CPU 这部分的值为 100 (0x64);而低 16 位就是 magic number。不过,a.out 文件的 magic number 并不像在有的格式中那样是可打印字符,而是表示某些属性的编码,一共有四种,即 ZMAGIC、OMAGIC、QMAGIC 以及 NMAGIC,这是在 include/linux/a.out.h 中定义的。
Ⓑ flush_old_exec
// fs/exec.c int flush_old_exec(struct linux_binprm * bprm) { char * name; int i, ch, retval; struct signal_struct * oldsig; /* * Make sure we have a private signal table */ oldsig = current->sig; // 首先是进程的信号(软中断)处理表。我们讲过,一个进程的信号处理表就好像一个系统中的中断向量表, // 虽然运用的层次不同,其概念是相似的。当子进程被创建出来时,父进程的信号处理表可以己经复制过来, // 但也有可能只是把父进程的信号处理表指针复制了过来,而通过这指针来共享父进程的信号处理表。现在, // 子进程最终要“自立门户” 了,所以要看一下,如果还在共享父进程的信号处理表的话,就要把它复制过来。 // 正因为这样,make_private_signals() 的代码与do_fork()中调用的 copy_sighand()基本相同。 retval = make_private_signals(); if (retval) goto flush_failed; /* * Release all of the old mmap stuff */ // 同样,子进程的用户空间可能是父进程用户空间的复制品,也可能只是通过一个指针来共享父进 // 程的用户空间,这一点只要检查一下对用户空间、也就是current->mm的共享计数就可清楚。当共享 // 计数为 1 时,表明对此空间的使用是独占的,也就是说这是从父进程复制过来的,那就要先释放 // mm_struct 数据结构以下的所有 vm_area_struct 数据结构(但是不包括mm_struct结构本身),并且将页 // 面表中的表项都设置成0。具体地这是由exit_mmap()完成的,其代码在mm/mmap.c中,读者可自行 // 阅读。在调用exit_mmap()之前还调用了一个函数mm_release(),对此我们将在稍后加以讨论,因为在 // 后面也调用了这个函数。至于flush_cache_mm() 和 flush_tlb_mm(),那只是使高速缓存与内存相一致, // 不在我们现在关心之列,而且前者对i386处理器而言根本就是空语句。这里倒是要问一句,在父进程 // fork()子进程的时候,辛辛苦苦地复制了代及用户空间的所有数据结构,难道目的就在于稍后在执行 // execve()时又辛辛苦苦把它们全部释放?既有今日,何必当初?是的,这确实不合理。这就是在有了 // fork()系统调用以后又增加了一个vfork()系统调用(从BSD Unix开始)的原因。让我们回顾一下 // sys_fork() 与 sys_vfork() 在调用 do_fork() 时的不同。 // // 可见,sys_vfork() 在调用 do_fork()时比 sys_fork()多了两个标志位,一个是 CLONE_VFORK,另 // 一个是CLONE_VM。当CLONE_VM标志位为1时,内核并不将父进程的用户空间(数据结构)复制 // 给子进程,向只是将指向mm_struct数据结构的指针复制给子进程,让子进程通过这个指针来共享父 // 进程的用户空间。这样,创建了进程时可以免去复制用户空间的麻烦。向当子进程调用execve()时就 // 可以跳过释放用户空间这一步,直接就为子进程分配新的用户空间。但是,这样一来省事是省事了, // 却可能带来新的问题。以前讲过,fork()以后,execve()之前,子进程虽然有它自己的一整套代表用户 // 空间的数据结构,但是最终在物理上还是与父进程共用相同的页面。不过,由于子进程有其独立的页 // 面目录与页面表,可以在子进程的页面表里把对所有页面的访问权限都设置成“只读”。这样,当子进 // 程企图改变某个页面的内容时,就会因权限不符而导致页面异常",在页面异常的处理程序中为子进程 // 复制所需的物理页面,这就叫"copy_on_write”。相比之下,如果子进程与父进程共享用户空间,也就 // 是共享包括页面表在内的所有数据结构,那就无法实施"copy_on_write”了。此时子进程所写入的内容 // 就真正进入了父进程的空间中。我们知道,当一个进程在用户空间运行时,其堆栈也在用户空间。这 // 意味着在这种情况下子进程可以改变父进程的堆栈,反过来父进程也可以改变子进程的堆栈!因为这 // 个原因,vfork()的使用是很危险的,在子进程尚未放弃对父进程用户空间的共享之前,绝不能让两个 // 进程都进入系统空间运行。所以,在sys_vfork()调用do_fork()时结合使用了另一个标志位 // CLONE_VFORK。当这个标志位为1时,父进程在创建了子进程以后就进入睡眠状态,等候子进程通 // 过execve()执行另一个目标程序,或者通过exit()寿终正寝。在这两种情况下子进程都会释放其共享的 // 用户空间,使父进程可以安全地继续运行。即便如此,也还是有危险,子进程绝对不能从调用vfork() // 的那个函数中返回,否则还是可能破坏父进程的返回地址。所以,vfork()实际上是建立在子进程在创 // 建以后立即就会调用execve()这个前提之上的。 // 那么,怎样使父进程进入睡眠而等待子进程调用execve()或exit()呢?当然可以有不同的实现。读 // 者已经在do_fork()的代码中看到了内核让父进程在一个0资源的“信号量”上执行一次down()操作而 // 进入睡眠的安排,这里的mm_release()则让子进程在此信号量上执行1次up()操作将父进程唤醒。 // 函数 mm_release()的代码在 fork.c 中 retval = exec_mmap(); if (retval) goto mmap_failed; // 从exec_mmap()返回到flush_old_exec()时,子进程从父进程继承的用户空间已经释放,其用户空 // 间变成了一个独立的“空壳”,也就是一个大小为0的独立的用户空间。这时候的进程已经是“义无反 // 顾” 了,回不到原来的用户空间中去了(见代码中的注解)。前面讲过,当前进程(子进程)原来可能 // 是通过指针共享父进程的信号处理表的,而现在有了自己的独立的信号处理表,所以也要递减父进程 // 信号处理表的共享计数,并且如果递减后为0就要将其所占的空间释放,这就是 // release_old_signals() 所做的事情。 /* This is the point of no return */ release_old_signals(oldsig); current->sas_ss_sp = current->sas_ss_size = 0; if (current->euid == current->uid && current->egid == current->gid) current->dumpable = 1; // 此外,进程的task_struct结构中有一个字符数组用来保存进程所执行的程序名, // 所以还要把bprm->filename的目标程序路径名中的最后一段抄过去。 name = bprm->filename; for (i=0; (ch = *(name++)) != '\0';) { if (ch == '/') i = 0; else if (i < 15) current->comm[i++] = ch; } current->comm[i] = '\0'; // 接着的flush_thread()只是处理与 debug和i387协处理器有关的内容,不是我们所关心的。 flush_thread(); // 如果“当前进程”原来只是一个线程,那么它的task_struct结构通过结构中的队列头thread_group // 挂入由其父进程为首的“线程组”队列。现在,它已经在通过execve()升级为进程,放弃了对父进程 // 用户空间的共享,所以就要通过de_thread()从这个线程组中脱离出来。这个函数的代码在fs/exec.c中 de_thread(current); if (bprm->e_uid != current->euid || bprm->e_gid != current->egid || permission(bprm->file->f_dentry->d_inode,MAY_READ)) current->dumpable = 0; /* An exec changes our domain. We are no longer part of the thread group */ current->self_exec_id++; // 前面说过,进程的信号处理表就好像是个中断向量表。但是,这里还有个重要的不同,就是中断 // 向量表中的表项要么指向一个服务程序,要么就没有;而信号处理式中则还可以有对各种信号预设的 // (default)响应,并不定非要指向一个服务程序。当把信号处理表从父进程复制过来时,其中每个 // 表项的值有三种可能:一种可能是SIG_IGN,表示不理睬;第二种是SIG_DFL,表示采取预设的响应 // 方式(例如收到SIGQUIT就exit()):笫二种就是指向一个用户空间的子程行。可是,现在整个用户空 // 间都已经放弃了,怎么还能让信号处理表的表项指向用户空间的子程序呢?所以还得检查一遍,将指 // 向服务程序的表项改成SIG_DFL。这是由flush_signal_handler()完成的,代码在kernel/signal.c 中 flush_signal_handlers(current); // 最后,是对原有已打开文件的处理,这是由flush_old_files()完成的。进程的task_struct结构中有 // 个指向一个file_struct结构的指针“files",所指向的数据结构中保存着己打开文件的信息。在 // file_struct 结构中有个位图close_on_exec,里面存储着表示哪些文件在执行一个新目标程序时应予 // 关闭的信息。 而flush_old_files()要做的就是根据这个位图的指示将这型文件关闭,并且将此位图 // 清成全0。其代码在 exec.c 中 flush_old_files(current->files); return 0; mmap_failed: flush_failed: spin_lock_irq(¤t->sigmask_lock); if (current->sig != oldsig) kfree(current->sig); current->sig = oldsig; spin_unlock_irq(¤t->sigmask_lock); return retval; }
⑷ 文字形式可执行文件的执行
前面介绍了 a.out 格式可执行文件的装入和投运过程,我们把这作为二进制可执行文件的代表。现在,再来简要地看一下字符形式的可执行文件 (为 shell 过程或 perl 文件) 的执行。有关的代码都在 binfmt_script.c 中。山于已经比较详细地阅读了二进制可执行文件的处理,读者在阅读下面的代码时应该比较轻松了,所以我们只作一些简要的提示。
// fs/binfmt_script.c struct linux_binfmt script_format = { NULL, THIS_MODULE, load_script, NULL, NULL, 0 }; static int load_script(struct linux_binprm *bprm,struct pt_regs *regs) { char *cp, *i_name, *i_arg; struct file *file; char interp[BINPRM_BUF_SIZE]; int retval; if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!') || (bprm->sh_bang)) return -ENOEXEC; /* * This section does the #! interpretation. * Sorta complicated, but hopefully it will work. -TYT */ bprm->sh_bang++; allow_write_access(bprm->file); fput(bprm->file); bprm->file = NULL; bprm->buf[BINPRM_BUF_SIZE - 1] = '\0'; if ((cp = strchr(bprm->buf, '\n')) == NULL) cp = bprm->buf+BINPRM_BUF_SIZE-1; *cp = '\0'; while (cp > bprm->buf) { cp--; if ((*cp == ' ') || (*cp == '\t')) *cp = '\0'; else break; } for (cp = bprm->buf+2; (*cp == ' ') || (*cp == '\t'); cp++); if (*cp == '\0') return -ENOEXEC; /* No interpreter name found */ i_name = cp; i_arg = 0; for ( ; *cp && (*cp != ' ') && (*cp != '\t'); cp++) /* nothing */ ; while ((*cp == ' ') || (*cp == '\t')) *cp++ = '\0'; if (*cp) i_arg = cp; strcpy (interp, i_name); // 得到了解释程序的路径名以后,问题就转化成了对解释程序的装入,而script文件本身则转化成了 // 解释程序的运行参数。虽然script文件本身并不是二进制格式的可执行文件,解释程序的映象却是一个 // 二进制的可执行文件。 /* * OK, we've parsed out the interpreter name and * (optional) argument. * Splice in (1) the interpreter's name for argv[0] * (2) (optional) argument to interpreter * (3) filename of shell script (replace argv[0]) * * This is done in reverse order, because of how the * user environment and arguments are stored. */ remove_arg_zero(bprm); retval = copy_strings_kernel(1, &bprm->filename, bprm); if (retval < 0) return retval; bprm->argc++; if (i_arg) { retval = copy_strings_kernel(1, &i_arg, bprm); if (retval < 0) return retval; bprm->argc++; } retval = copy_strings_kernel(1, &i_name, bprm); if (retval) return retval; bprm->argc++; /* * OK, now restart the process with the interpreter's dentry. */ file = open_exec(interp); if (IS_ERR(file)) return PTR_ERR(file); bprm->file = file; retval = prepare_binprm(bprm); if (retval < 0) return retval; return search_binary_handler(bprm,regs); // 可见,Script文件的使用在装入运行的过程中引入了递归性,load_script() 最后又调用 // search_binary_handler()不管递归有多深,显终执行的一定是个二进制可执行文件,例如 // /bin/sh、 /usr/bin/perl 等解释程序。在递归的过程中,逐层的可执行文件路径名形成 // 一个参数堆栈,传递给最终的解释程序。 }
Linux 内核源代码情景分析(二)(下):https://developer.aliyun.com/article/1597963