第 4 章 进程


本章讲述什么是进程, 以及 Linux 核心是如何创建, 管理和清除系统中的进程的. 在操作系统中, 进程是任务的执行者。 程序只是存贮在盘上的可执行映像里面的机器 指令和数据的集合, 因此是被动的实体。 进程可以被看作正在运行的计算机程序。 进程是一个动态实体, 随着处理器执行着机器指令而不断变化。 除了程序中的指令和 数据之外, 进程中还包括了程序计数器, CPU 的所有寄存器, 堆栈(包含着象过程参数, 返回地址,保存的变量等临时数据)。 当前正在执行的程序, 也就是进程, 含有微处理 器当前的所有活动。 Linux 是一个多重处理型的操作系统(multiprocessing, 或叫做多道)。 进程各司其职, 如果某个进程崩溃, 不会导致系统中别的进程崩溃。 每个进程在独立 的虚拟地址空间中运行, 除非通过核心提供的安全的机制之外, 不能和别的进程相互作 用。 进程在其生命周期内要使用许多系统资源, 它要用 CPU 运行指令, 用物理内存存贮 指令和数据; 它会打开并使用文件系统中的文件, 直接或间接使用物理设备。 Linux 必 须了解进程使用资源的情况以便合理地管理系统中的所有进程。 假如让某个进程独占大 部份系统物理内存或者 CPU, 对别的进程就不公平。 系统中最重要的资源是 CPU, 通常只有一个。 作为一个多重处理操作系统, Linux 的 目标是让系统中的每个 CPU 上面始终有一个进程在执行, 以充份利用 CPU。 如果进程 数多于 CPU 数(通常总是这样), 多余的进程必须等待有 CPU 空闲下来才能运行。 多重处理的想法很简单: 让进程一直执行直到它必须等待, 通常是等待使用一些系统 资源; 当它可以使用这个资源时, 可以再让它运行。 在一个单一处理的操作系统 (uniprocessing, 或叫做单道)中, 例如 DOS, CPU 在进然b等待资源的时候将无所事事, 白白浪费时间。 在一个多重处理操作系统中, 内存中同时存在许多进程。 每当一个进 程必须等待, 操作系统就把 CPU 分配给别的需要运行的进程。 系统中专门有一个调度器 (scheduler)负责选出下一个要运行的进程。 Linux 使用很多调度策略来保证调度的公平。 Linux 支持很多不同的可执行的文件格式, 比如 ELF , 还有 Java。 这些格式必须被透明 地管理。 4.1 Linux 进程 Linux 系统为了管理进程,用 task_struct 数据结构表示每个进程 (任务和进程 (task and process) 在 Linux 中是可以互换使用的术语)。 任务向量(task vector)是一个指针数组, 里 面的指针指向系统中的每个 task_struct 数据结构。 这样就意味着系统中的最大进程数受到任务向量的大小的限制; 缺省它有 512 个入口。 当创建新进程时, 新的 task_struct 从系统存储器中被分配出来并被加入任务向量。 为了 便于查找, 一个 current 指针指向当前的进程。 除了普通进程, Linux 还支持实时进程。 所谓实时是指这些进程必须能够快速响应外 部的事件。 调度器会区别对待实时进程和普通进程。 尽管 task_struct 数据结构相当大, 而且很复杂, 但是其中能够划分出很多功能区域: State (状态) 进程执行时会根据不同的情形改变状态。 Linux 进程有下列状态: 1。Running 运行态 进程或者正在运行(它是系统的当前进程), 或者是准备运行的(它正在等待被分到系 统的 CPU 之一) 。 2。Waiting 等待态 进程正在等一个事件或一个资源。 Linux 中的等待态有性质不同的两种类型: interruptible (可中断的)和 uninterruptible (不可中断的)。 可中断的等待进程能被信号打断而 不可中断的等待进程直接等待某种硬件条件, 在任何情形下都不能被中断。 3。Stoped 停止态 进程被停止了, 通常是通过接受一个信号的方法。 被调试的进程能处于一个停止态。 4。Zombie 僵死态 某个已经终止的进程, 由于一些原因, 仍然在任务向量中占有一个 task_struct 数据 结构, 就处于僵死态。 Scheduling Information (调度信息) 调度程序需要这个信息以便相当决定系统中哪个进程最需要运行。 Identifiers (标识符) 每个进程有一个进程标识符。 进程标识符不是任务向量的一个索引, 它就是一个数字 而已。 每个进程也有用户和组标识符, 它们是用来控制这个进程对系统中的文件和设备 的访问的。 Inter-Process Communication (IPC, 进程间通讯) Linux 支持 Unix 中经典的 IPC 机制, 如: signal (信号), pipe (管道) 和 semaphore (信 号灯),并且支持System V (Unix的一种较流行的标准版本)中的 share memory (共享存储器 ), semaphore (信号灯) 和 message queue (消息队列)。 Linux 所支持的 IPC 机制在第 IPC 章中有详细讲述。 Links (连接) Linux 系统没有进程与别的进程完全无关。 除了初始化进程(init process)之外, 每个进 程都有一个父进程(parent process)。 新进程不是被凭空创造出来的, 它们是从已有的进 程拷贝得来, 或者是克隆得来的。 代表进程的每个 task_struct 中都有指针指向它的父进 程, 兄弟进程(同一个父进程产生的进程之间是兄弟关系), 以及自己的子进程。 你能使 用 pstree 命令看到正在运行的进程的家庭关系, 下面是某次运行 pstree 命令得到的结 果: init(1)-+-crond(98) |-emacs(387) |-gpm(146) |-inetd(110) |-kerneld(18) |-kflushd(2) |-klogd(87) |-kswapd(3) |-login(160)---bash(192)---emacs(225) |-lpd(121) |-mingetty(161) |-mingetty(162) |-mingetty(163) |-mingetty(164) |-login(403)---bash(404)---pstree(594) |-sendmail(134) |-syslogd(78) `-update(166) 另外, 系统中有一个以初始化进程的 task_struct 数据结构为根的双向链表, 把所有进 程都链接在里面。 有了这样的表, Linux 核心就可以方便地查看系统中的每个进程。 这 是为了支持 ps 和 kill 这样的命令(分别是列出系统中的进程的命令和向进程发送信号的命 令(通常用于终止进程)). Times and Timers (时钟和定时器) 在进程的生命周期内, 核心记录进程的创建时间并随时记录进程消耗的 CPU 时间。 每过一次时钟滴答(tick)的时间, 核心就更新当前进程在系统态和用户态所花的 CPU 时间 (以 jiffy 为单位)。 Linux 也支持进程特定的间隔定时器, 进程可以使用系统调用设置定时 器, 当定时器所设置的时间间隔已到, 核心就会给进程发送一个信号。 这些定时器可以 是一次性的或周期性地触发。 File system (文件系统) 进程可以打开和关闭文件。 进程的 task_struct 中包含了指向打开的文件的描述符 (descriptor)的指针, 还有两个指向 VFS i节点(inode)的指针。 VFS i节点能够唯一描述文件 系统中的一个文件或目录, 它也是文件系统所提供的统一的访问文件的接口。 关于 Linux 系统中怎样支持文件系统, 请参看第章文件系统。 第一个指针指向进程的根目录 (进程的可执行映像文件所在的目录), 第二个指向进程的当前目录或者叫 pwd 目录(得名 于 Unix 中的 pwd 命令, 是 print working directory 之意。)。 VFS i节点中有一个域用来记 录有多少个进程指向它们。 现在你明白为什么当一个进程的 pwd 目录是你想删除的目录 或者是这个目录的一个子目录的时候, 你就不能删除它的原因了吧? Virtual memory (虚存) 大多数进程有一些虚存(核心线程和精灵(daemon)除外), Linux 核心必须追踪虚存到系 统物理内存上的映射关系。 Processor Specific Context (处理器特定的上下文) 进程可以被看作是系统的当前的各种状态的集合。 进程运行时要使用处理器的寄存器, 堆栈等等。 这就是所谓的进程上下文。 当进程被挂起时(暂时不再运行), 这个进程的 CPU 特定的上下文必须被保存到这个进程的 task_struct 中。 当进程被调度器重新启动时, 它就从这里恢复它的上下文。 4.2 Identifiers 标识符 Linux 象所有的 Unix 一样, 使用用户(user)和组(group)标识符在来检查进程对系统中文 件或者映像的访问权限。 Linux 系统中的文件都有所有权和许可权, 这些许可权描述了系 统中的用户对那个文件有什么访问权限。 基本的许可权有读(read), 写(write)和执行 (execute), 它们被分派到3类用户: 文件的主人(owner), 属于某个特定组的所有进程, 还有系统中的所有进程。 每一类用户可以有不同的许可权, 例如: 一个文件可以允许它 的主人读写, 允许文件所在的组读并且不允许系统中的其它进程访问。 Linux 系统中, 使用组就能够把文件的权限分配到一组用户而不是简单地到一个用户或 到所有的进程。 例如, 你可以为一个软件项目中的所有用户创建一个组, 并且只允许这 个组中的用户能够读写该项目的源程序。 进程能属于若干组(缺省最多能够属于32个组)。 每个进程的 task_struct 中有一个组向量(group_vector)来记录这些组。 只要进程所属的组 中有一个具有访问权限, 这个进程就有权访问那个文件。 每个进程的 task_struct 中有4对用户和组的标识符: 1。 uid, gid 进程所代表的用户(也就是启动这个进程的用户)的用户标识符和组标识符。 2。 effective uid and gid (有效的 uid 和 gid) 有一些程序在执行的时候会把 uid 和 gid 改变为它们的自己的特定的某个 uid 和 gid (这些程序的可执行映像文件的 VFS i节点中有一个属性规定了这样的行为)。 这些程序被 称为 "setuid" 程序。 它是限制系统服务(service)的权限一个方法, 尤其在实现为别的用户 服务的网络精灵程序等类似的服务时很有用。 有效的 uid 和 gid 来自程序的映像文件本身, 和启动它的用户无关。 核心在检查权限的时候会使用有效的 uid 和 gid. 3。 file system uid and gid (文件系统 uid 和 gid) 这两个标识符通常与有效的 uid 和 gid 一样, 当检查文件系统存取权限时会用上。 这两个标识符是为了建立 NFS(Network File System, 网络文件系统)而使用的, 因为用户 模式的 NFS 服务器需要像一个特别的进答7b一样来访问文件。 在这种情况下, 只有文件 系统 uid 和 gid 被改变(有效的 uid 和 gid 不变)。 这样可以防止恶意的用户向 NFS 服务器 发送 kill 信号。 Kill 信号会被以一个特别的有效 uid 和 gid 发送到进程。 4。 saved uid and gid (节省的 uid 和 gid) 这是 POSIX 标准中要求的两个标识符。 当然b序通过系统调用来改变 uid 和 gid 的时 候必须要用它们来保存真实的 uid 和 gid 。 4.3 Scheduling 调度 进程执行时总是一会儿在用户态下, 一会儿在系统态下。 不同的硬件如何实现对这两 种模式的支持不一定相同, 但是都有一种安全机制保证从用户态进入系统态然后再回到 用户态。 用户态时进答7b的权限比较系统态要小。 每当进程进行系统调用的时候就会从 用户态切换到系统态, 然后继续运行。 进入系统态之后, 核心代码开始执行, 为这个 进程服务。 在 Linux 系统中, 进程不能从当前正在运行的进程那里强占执行的权利。 当 执行的进程需要等待某个系统事件的时候, 它就让出 CPU 。 例如, 进答7b可能等待从一 个文件中读出一个字符。 这个等待在系统调用内部, 处于系统态; 这时, 等待事件的 的进然b将被核心暂停, 其它更着急的进程会被选中来运行。 进程总是要经常做系统调用所以就经常会这样等待。 尽管如此, 如果进程愿意, 它还 是可以长时间地不做系统调用从而不合理地占用 CPU 的处理时间。 因此, Linux 系统要 使用抢先式的调度。 在这种情况下, 每个进程被允许运行一小段时间, 比如 200ms, 如果时间到了, 核心就会暂停当前的进程, (不管它是不是愿意), 选择别的进程来运行。 这一小段时间就是所谓的 time slice (时间片)。 负责在系统中所有可以运行的进程中选择最该运行的进程的核心部份是调度器。可以 运行的进程(runnable process)是指这个进程就在等待 CPU 来执行。 Linux 使用基于优先级的相当简单的调度算法在系统在当前的进程之间选择。 当选择了 新进程来运行时, 它保存当前的进程的状态, 特定的处理器寄存器以及其它的上下文, 到这个进程的 task_struct 数据结构中。 然后它恢复新进程的状态(这仍就是处理器相关的), 把系统的控制交给这个进程, 开始 运行它。 调度器为了能够公平地分配 CPU 时间, 它在每个进程的 task_struct 中保存了下 列信息: policy (策略) 在这个进程上使用的调度策略。 Linux 进程有两种类型, 普通和实时。 实时进程比其 它所有的进程的优先级都要高。 如果有实时进程可以运行, 它将总是首先运行。 实时进 程有2种调度策略, Round Robin (轮转式)和 First in First out (先入先出式)。在轮转式调度 下, 每个 runnable 的实时进程轮流运行; 在先入先出式调度下, 每个 runnable 的进程依 次运行, 次序就是它们进入运行队列式的顺序, 而且不会变化。 priority (优先级) 进程的优先级。它也是这个进程被允许运行的时间的总量(以 jiffy 为单位)。 通过系统调 用和 nice 命令能够改变进程的优先级。 rt_priority (实时优先级) 实时进程的优先级高于其他类型的进程。 这个域允许调度器给每个实时进程以相对的 优先级。 实时的进程的优先级可以通过系统调用来改变。 counter (计数器) 这是该进程被允许运行的时间的总量(以 jiffy 为单位)。 进程第一次贻d始运行时, 这个 值就被设定为优先级的大小, 每次时钟中断一次, 这个数值就被减小。 核心内若干地方会运行调度器。 把当前的进程放入等待队列后会运行调度器; 在系统 调用结束, 即将返回到用户态的时候, 也可能会运行。 如果系统定时器把当前的进程的 counter 减小到了零, 它也需要运行。 调度器运行时, 需要做的事情是: kernel work (核心工作) 调度器运行 bottom half handler (一种推迟处理任务的机制)并处理调度器的任务队列。 关于 bottom half handler 以及这些轻量的核心线程在 第11章 核心机制 中有详细讲解。 process current process (处理当前进程) 在选择其它进程运行之前,必须处理当前进程。 如果当前进程的调度策略是 Round Robin (轮转式), 它就被放到运行队列的末尾, 如果任务是可以被中断的(INTERRUPTIBLE), 并且自从最后一次调度它之后, 它收到 了一个信号, 那么就设置它的状态为 RUNNING(运行)。 如果当前的进程执行超时了,那么它的状态变为 RUNING。 如果当前的进程就是 RUNNING,它将保持这个状态。 不处于 RUNNING 态并且也不是 INTERRUPTIBLE 的进程就被移出运行队列。 这意味着 当调度器要寻找最需要运行的进程时, 不再会考虑它们。 Process selection (进程选择) 调度器查找整个运行队列来选择最需要运行的进程。 如果有实时进程(调度策略是实时 的那些), 它们就会获得比普通进程更高的权重量。 正常的进程的权重是它的 counter (或 者优先级), 而实时进程是 counter 加 1000。 这说明如果系统中有处于 runnable 状态的实 时进程, 它们就会比普通的 runnable 的进程先执行。 当前的进程, 因为已经执行了一段 时间, 经过了若干时间片, 它的 counter 就被减去了一些, 所以如果有同样优先级的进 答7b的话, 它就要让位了。 这正是需要的。 如果若干进程有同样的优先级, 在队列前 面的被先选中。 当前进程会被放到队列的最后。 在有许多优先级相同的进程的平衡的系 统中, 它们会被轮流运行。 这就是称为 Round Robin 的调度方案。然而, 进程会等待资 源, 它们的运行顺序就会发生变化。 Swap processes (交换进程) 如果最需要运行的进程不是当前进程, 当前进程就必须被暂停, 新的进程将取而代之。 进程在运行时, 它在使用 CPU 的寄存器和系统的物理内存。 调用过程时, 它用寄存器 传递参数, 并且可能需要把返回地址放在堆栈中。 因此, 当调度器运行时它是在当前进 程的上下文(Context)中。 这时, CPU 处于特权态下, 也即核心态, 但是正在运行的仍 然是当前进程。 如果要暂停它, 就必须把它的上下文保存进它的 task_struct 数据结构中。 然后, 新进程的机器状态的必须被装载。 这是和具体的系统相关的操作, 各种 CPU 的 做法很不相同, 但是通常有一些硬件辅助来做这件事。 进程上下文的切换在调度器运行结束时进行。 所切换的上下文是与被调度进程有关的 硬件环境在此时的一个快照。 如果刚才的进程或新的当前进程使用虚存, 系统的页表的项目可以需要更新。 同样地, 这是和特定的机器体系结构相关的。 象 Alpha AXP 这样的处理器, 使用 Look-aside Tables (转换对照表)或者 cached Page Table Entries (缓冲页表项), 必须刷新那些属于先 前进程的表项。 4.3.1 多处理机系统中的调度 多个 CPU 的系统在 Linux 世界中是相当稀罕的, 但是 Linux 系统中已经做了很多工作 使其成为一个 SMP(Symetric Multi Processing 对称多处理) 操作系统。 那就是说, 有能力 在系统的 CPU 之间平衡工作。 在调度器中做这种工作是最合适的。 多处理器系统中, 理想的情况是所有处理器均忙于运行进程。 每当一个 CPU 的当前 的进程用尽它的时间片或必须等一个系统资源, 就会单独运行调度程序。 关于一个 SMP 统要注意的第一事情是系统中不止存在一个空闲的进程。 在单处理器系统中空闲的 进程是在任务向量的第一任务, 在一个 SMP 系统中每个 CPU 都有一空闲的进程, 并且 你可能有不止一个空闲的 CPU 。另外每个 CPU 有一个当前进程, 因此 SMP 系统必须追 踪每个处理器上的当前进程和空闲进程。 SMP 系统中每个进程的 task_struct 包含它当前正运行在上面的处理器的数字 (processor)以及上次运行在上面的处理器的数字(last_processor)。 虽然进程可以每次在不 同的 CPU 上面运行, Linux 可以使用 processor_mask 来限制进程可以使用的 CPU 。 如 果 processor_mask 的第 N 位被设置, 这个进程就能在处理器 N 上运行。 当调度器选择 新进程, 它不会选择 processor_mask 中和当前的处理器对应的位被清除的进程。 调度器 会略微照顾上次在这个处理器上面运行的进程, 因为把进程在不同的处理器之间移动通 常会带来一定的性能损失。 4.4 Files (文件)
图 4.1 :进程的文件
图 4.1 表明系统中的每个进程有2个数据结构描述文件系统相关的信息。 第一, fs_struct, 包含指针指向进程的 VFS i节点 和它的 umask 。 umask 是创建新文件 时使用的缺省模式, 可以用系统调用改变。 第二, files_struct, 包含进程当前正在使用的所有文件的信息。 然b序从 standard input (标准输入) 读并且写到 standard output (标准输出)。 任何错误消息应该输出到 standard error (标准错误)。 这些可以是文件, 终端输入/输出或一台真实的设备, 但是程序都把它 们当作文件。 每个文件有它的自己的 descriptor (描述符), files_struct 中包含可以指向 256 个文件数据结构的指针, 每个可以描述进程打开的一个文件。f_mode 描述文件是以 什么模式被创建的:只读, 读写 或者 只写。 f_pos 记录下一个读或写操作的位置。 f_inode 指向描述该文件的 VFS i节点, 而 f_ops 是一个指向例程地址的向量的指针, 每 一个例程实现你希望在文件上做的一个操作, 例如, 一个写数据的例程。 这种对界面的 抽象非常有用, 允许 Linux 系统支持各种各样的文件类型。 我们以后就会看到, Linux 中 的 pipe (管道) 就是用这个机制实现的。 每打开一个文件, 在 files_struct 的一个空闲的文件指针被用来指向新文件结构。 Linux 进程启动的时候, 会有 3 个文件描述符已经打开, 它们是标准输入, 标准输出和标准错 误, 通常都是从父进程中继承来的。 所有的文件访问都要使用系统调用, 它们使用或者 返回 file descriptor (文件描述符)。 文件描述符是到进程的 fd 向量的索引, 所以标准输入, 标准输出和标准错误的文件描述符是 0 ,1 和 2 。 文件的每次访问都要使用文件数据结 构的文件操作例程和 VFS i节点。 4.5 虚存 进程的虚存包含从许多来源来的可执行的代码和数据。 首先, 程序映像被装载。 例如 象 ls 一样的命令。 这个命令, 象所有的可执行的映像一 样, 都由可执行代码和数据组成。 映像文件包含装载可执行的代码以及有关的程序数据 到进程的虚存所需的全部信息。 第二, 进程运行时能分配(虚拟)存储器, 比如说保留它正在读的文件的内容。 这最新 分配的, 虚拟的存储器要被连接进进程的已有的虚存才能使用。 第三, Linux 进程通常使用的公用代码库, 例如处理文件的例程。 每个进程有库的自己 的拷贝, 这很不明智。 Linux 使用能同时被若干运行的进答7b使用的共享库。 共享库的 代码和数据必须被连接到共享这个库的多个进程的虚拟地址空间。 在任何给定的时间段内, 进程不会使用在它的虚存中包含的所有代码和全部数据。 它 可以包含仅仅在某些状况下被使用的代码, 例如在初始化期间或一个特别的事件发生时。 它可能仅仅使用了从共享库连接的一些例答7b。 装载这些无用的东西进物理存储器, 实 在是一种浪费。 考虑到系统中同时存在多个进程, 这将使系统很低效地运行。 为此, Linux 使用 demand paging (请求换页) 技术, 仅仅当进程试图访问某页时, 才把它装入物 理内存。 因此, Linux 核心只要改变进程的页表, 把虚拟的空间标明为存在但是不在内 存中就行了, 而不需要直接装载代码和数据进物理存储器。 当进程尝试访问这里的代码 或数据时, 系统硬件将产生 page fault (页错) 并且把控制传递给 Linux 核心来处理。 因此, Linux 核心需要知道进程的虚拟地址空间的各个区域是从何处来的以及如何把它装入内存, 这样才能处理 page fault。
图 4.2 :进程的虚存
Linux 核心需要管理虚存的所有这些区域。 进程的虚存的内容在 mm_struct 数据结构中 描述, 进程的 task_struct 有指针指向这个结构。 进程的 mm_struct 数据结构也包含已装 载的可执行的映像的信息, 还有到进程的页表的指针。 进程的页表包含一些指针, 指到 vm_area_struct 数据结构的一个表, 每个表示进程虚存的一个区域。 这张链接的表是按虚存地址升序链接的, 图 4.2 显示了一个简单进程的虚存的布局以 及管理它的核心数据结构。 因为虚存中的那些区域从若干来源, Linux 让 vm_area_struct 指向一套处理虚存的抽象接口的例程(经由vm_ops)。 这样不管管理那存储器的内在的服 务怎么不同, 进程的所有虚存都能用一致的方法处理。 例如有一个例程在进程试图存取 存储器并且它不存在时, 将被调用, 这就是用来处理 page fault 的。 进程的 vm_area_struct 数据结构的会被 Linux 核心很频繁地调用。 这就使得寻找到 vm_area_struct 结构的时间对系统性能影响很大。为了加快存取, Linux 另外把 vm_area_struct 数据结构排列成一个 AVL (Adelson-Velskii 和 Landis)树 (也称平衡树)。 这 棵树上, 每个 vm_area_struct (或节点) 有一左一右两个指针指到它的邻近的 vm_area_struct 结构。 左指针指向的节点虚拟地址小于右指针指向的节点。寻找正确的节 点时, Linux 从树根开始, 根据每个节点的左右指针指向的地址的大小关系决定向何处去 找, 直到找到为止。 当然, 没有免费的午餐, 把一个新的 vm_area_struct 插入到这棵树要花一些额外的处理时间。 当进程分配虚存时, Linux 实际上不为进程保留物理存储器。 相反, 它创建新的 vm_area_struct 数据结构描述虚存, 再连接进进程的虚存的表。 当进程试图在那个新虚 存区域以内写时,系统将发生 page fault (页错)。 处理器将试图进行虚拟地冶d译码, 但 是因为这块存储器的没有页表入口, 它将失败并引发 page fault 异常, 让 Linux 核心来处 理。 Linux 检查引用的虚拟的地址是否在当前的进程的虚拟的地址空间。 如果是, Linux 创造适当的 PTEs 并且为这个进程的分配物理存储器的一页。 代码或数据可能需要从文 件系统或从交换磁盘拷贝到那个物理页。 进程然后在引起了 page fault 的指令处被重启并 且, 这次因为存储器物理上存在, 它可以继续运行。 4.6 创建进程 当系统启动时, 它在核心态运行并且有,从某种意义上说, 仅仅一个进程, initial process (初始进程)。 象所有的进程一样, 初始进程的机器状态由堆栈, 寄存器等等表示。 当系统的另外的进程被创建并运行时, 这些将在初始进程的 task_struct 数据结构被保存。 系统初始化结束时, 初始进程启动一个核心线程(叫 init) 然后进入一个无事可做的空闲循 环。 当没有别的事情做时, 调度器将运行这个空闲进程。 空闲进程的 task_struct 是唯一 一个不被动态地被分配的, 当构造核心的时候, 它就静态地在核心里面定义并且被叫做 init_task, 相当含糊。 Init 核心线程或进程的进程标识符为1, 是系统的第一个真正的进程。 它做一些系统初 始化设置工作(例如打开系统控制台, 安装根文件系统)然后运行系统初始化程序。 这个 程序是 /etc/init, /bin/init 或者 /sbin/init, 与你的系统有关。 init 程序使用 /etc/inittab 作为脚本 文件来创建系统中的新进程。 这些新进□ '7b可能还要再创建新进程。 例如, 当用户试图登录时, getty 进程可能会创建 login 进程。 所有这些进程都是 init 核心线程的后代。 新进程通过克隆旧进程,或克隆当前进程来创建。 一个新任务通过系统调用(fork 或 clone)来创建。 克隆在核心态由核心来完成。 在系统调用结束时如果调度器选择了新进 程, 新进程就可以运行了。 新的 task_struct 数据结构在系统物理内存中分配, 而且有一 页或多页物理内存页被用来作为克隆进程的堆栈(用户堆栈和核心堆栈)。 新的进程标识符 被创建, 它在系统内唯一。 但是有理由让克隆出来的进程记住它的父进程。 新的 task_struct 被加入 task vector (任务向量), 老进程的 task_struct 的内容被复制到克隆的进 程的 task_struct. 当克隆进程时, Linux 允许两个进程共享资源而不是各自复制一份。 这包括进程的文 件, 信号处理程序, 以及虚存。 当资源被共享时, 各自的计数域将被增加, 这样当两 个进程全部释放资源的时候 Linux 才会回收它。 克隆进程的虚存比较困难。 新的 vm_area_struct 数据结构集合要被创建, 还有它们所 拥有的 mm_struct 数据结构, 以及被克隆的进程的页表。 这时还没有进程的虚存的内容 被复制。 这可能是个很困难的工作因为有的虚存在物理内存, 有的在可执行映像里, 有 的在交换文件里。 为此, Linux Linux 使用称为 "copy on write" (写时复制) 的技术, 具体 做法是当其中一个进程试图写共享虚存时才进行复制。 实现的方法是把可写的内存区域 在页表中标为 "read only" (只读), 在 vm_area_struct 数据结构中标为 "copy on write "。 当 某个进程试图写时, 就会发生 page fault, 此时 Linux 就进行内存的复制, 并修改页表和 虚存的数据结构。 4.7 时间和定时器 在进程的生命周期内, 核心记录进□ '7b的创建时间并随时记录进程消耗的 CPU 时间。 每过一次时钟滴答(tick)的时间, 核心 就更新当前进程在系统态和用户态所花的 CPU 时间(以 jiffy 为单位)。 除了这些用于记账 的定时器之外, Linux 也支持进然b特定的间隔定时器, 当定时器所设置的时间间隔一到, 核心就会给进程发送信号。 有3种间隔定时器: Real (实时) 定时器实时地走动。 当定时器到时, 进程会收到一个 SIGALRM 信号。 Virtual (虚拟) 当进程正在运行时定时器才走。 如果到时, 这个定时器会发送一个 SIGVTALRM 信号 给进然b。 Profile (活动总计) 当进程正在运行时或者当系统代表进程在执行时, 这个定时器就走动。 它会发送 SIGPROF 信号。 Linux 系统把间隔定时器的信息存放在进程的 task_struct 数据结构中。通过系统调用能 够添加定时器, 启动, 停止以及读取定时器的当前的时间。 每当系统的时钟的一次滴答到来, 当前进程的所有间隔定时器的计数值就被减少, 如 果时间间隔已到, 就会发送相应的信号给进程。 实时间隔定时器有点特别。 Linux 在核心中使用了定时器机制来处理它。 每个进程有 自己的 timer_list 数据结构, 当实时间隔定时器运行时, 系统的 timer list (定时器列表)中把 它排入了队列。 当定时器的时间间隔一到, 负责处理定时器事件的 bottom half handler 会 把它从队列中删除, 然后调用调用间隔定时器的处理器(并不是 CPU, 而是一段代码)。 这 个处理器这就产生了 SIGALRM 信号并且重启间隔定时器, 又把它加入系统定时器队列。 请参看 第11章 核心机制 中的具体讲解。 4.8 Executing programs 执行程序 象 Unix 系统一样, Linux 系统中的程序和命令通常是由一个命令解释器来执行的。 一 个命令解释器是一个用户进程, 一般被称为 shell , 因为它就象是系统的外壳, 被用户 直接感受到。 Linux 系统中有许多命令解释器, 最流行的一些是 sh, bash 和 tcsh 。 除了一些内部命 令之外, 例如 cd 和 pwd , 一个命令就是一个可执行的二进制的文件。 对每个输入的命 令, 命令解释器在进程的搜索路径中指定的目录中查找能够匹配的可执行的映像文件。 搜索路径由 PATH 环境变量定义。 如果找到了匹配的文件, 它就被装载执行。 命令解释器使用上面说的 fork 机制克隆自己。 新的子进程用所找到的可执行的二进制 映像文件的内容替换自己原先的内容, 也就是命令解释器自身。 通常命令解释器等待命 令完成, 也就是等待子进程退出。 你能让命令处理器不要等待, 只要把子进程放到后台 运行就可以做到。 使用 control-Z 组合键, 它会导致一个 SIGSTOP 信号被送给子进程, 让它暂停。 然后你可以用 shell 命令 bg 把它放到后台。 命令解释器向它发送一个 SIGCONT 信号让它恢复运行, 它将一直在哪儿, 直到运行结束或者它需要做终端输入或 输出。 一个可执行的文件能有许多格式或甚至是一个脚本文件。 脚本文件必须被识别出来并 且用适当的解释器来处理。 例如 /bin/sh 解释 shell 脚本。 可执行的目标文件中包含可执 行的代码和数据, 以及足够的信息以便操作系统能够装载并运行。 Linux 系统中使用的最 多的目标文件格式是 ELF (参见下面的小节)。 但是理论上, Linux 灵活到几乎能处理任何 格式的目标文件。
图 4.3 :注册的二进制格式
就象文件系统, 格式由 Linux 支持了的二进制代码是在核被造了进核的任何一个造时 间或可得到作为模块被装载。核坚持支持二进制的 格式的一张表 ( 参见图 4.3 ) 并且当被尝试执行一个文件时,每二进制的格式接着被试用 直到一个人工作。 通常被支持了的 Linux 二进制代码格式是 a.out 和ELF 。可执行的文件不必须完全被读进 存储器, 作为装载的需求被知道的技术被使用。 当可执行的图象的每部份被进程使用,它被使存储器。图象的闲置的部份可以从存储器 被丢弃。 4.8.1 ELF ELF (Executable and Linkable Format) 目标文件格式, 由 Unix 系统实验室所设计,是 Linux 系统中最常用的格式。 虽然同其它的目标文件格式, 例如 ECOFF 和 a.out, 比较, ELF 在性能上略有损失, 但 ELF 更灵活。 ELF 可执行文件中包含可执行的代码(有时称 为正文(text)), 还有数据。 除此之外, 还有表说明程序应该怎样被放进进程的虚存。 静 态连接的映像可以用连接器(ld)构造, 或用连接编辑器, 结果成为一个包含运行时所需的 全部代码和数据的单个的映像。 映像中还说明了映像在内存中的布局, 以及第一条指令 在映像中的地址。
图 4.4 :ELF 可执行文件文件格式
图 4.4 显示了一个静态连接的 ELF 可执行的映像的内部布局。 这是一个简单的 C 程序, 打印 "Hello, world!" 然后结束。 文件头说明它是一个 ELF 映 像, 在文件头起始的 52 个字节是 2 个物理的头。 第一个物理头中指示在映像中的可执 行的代码。 代码起始于虚地址 0x8048000 , 有 65532 个字节。 因为它是为 printf 包含图 书馆代码的所有的一幅静态地被连接了的图象,这是 () 输出"你好世界"的呼叫。 映像的 入口点, 也就是程序的第一条指令, 不在映像的开始, 而是在虚地址 0x8048090 ( e_entry ) 处。 代码紧跟在第二物理的头之后。 这个物理头说明程序的数据, 要在装在虚 地址 0x8059BB8 处。 数据是可读可写的。 你会注意到在文件中的数据块的大小是 2200 个字节( p_filesz ), 而在内存中所占的大小是 4248 个字节。 这是因为第一个 2200 个字节 包含预初始化的数据而随后的 2048 个字节包含将由执行的代码来初始化的数据。 当 Linux 装载 ELF 可执行文件映像到进程的虚拟地址空间时, 它实际上没有真的装载 映像。 它设置虚存数据结构, 进程的 vm_area_struct 树和它的页表。 当程序执行时, 页差错 (page fault)将导致程序的代码和数据被装进物理内存。 程序中没用到的部份的部份决不会 被装载进存储器。 当 ELF 二进制格式装载器检验认为这个映像确实是一个 ELF 可执行映 像后, 它就从进程的虚存中刷新当前的可执行映像。 因为这个进程是一个克隆的映像(所 有的进程都这样) 这旧映像就是父进程正在执行的程序。 刷新导致旧的虚存数据结构被废 弃, 进程的页表被重新设置。 它也清除所有的信号 handler, 关闭已经打开的文件。 刷 新过后, 进程就可以用新的可执行映像了。 不管可执行的映像是什么格式的, 进程的 mm_struct 中需要设置同样的信息。 有指向映像的代码和数据的开始和结束的指针。 这 些值在读入 ELF 可执行映像的物理头时被得到, 它们所说明的程序段被映射到进程的虚 拟地址空间。 此时, vm_area_struct 数据结构被设置, 进程的页表也被修改。 mm_struct 数据结构中还包含指针指向传递给程序的参数以及进程的环境变量。 ELF 共享库 反之, 一个动态连接的映像, 并没有包含运行所必需的全部代码和数据。 部份代码和 数据在共享库里, 当映像执行的时候会被连接进来。 这时, ELF 共享库的表也被连接进 了映像。 Linux 使用若干动态的连接器, ld.so.1 , libc.so.1 和 ld-linux.so.1 , 都存放在 /lib序目录下。库中包含公用的代码, 比如语言的子程序。 如果没有动态连接, 所有的程序需 要把库中的这些代码各自复制一份, 这样会需要多得多的磁盘空间和虚拟内存。 有了动 态连接, 每个被引用到的子程序都在 ELF 映像的表中保存了信息, 动态连接器根据这个 信息知道怎样找到库中的代码并把它连接到程序的内存空间。 4.8.2 脚本文件 脚本文件是需要一个解释器来运行的可执行文件。 有各式各样的解释器可以在Linux中 使用, 例如 wish, perl 和命令处理程序比如 tcsh 。 Linux 使用标准的 Unix 习惯, 就是在 脚本文件的第一行中包含解释器的名字。 因此, 一个典型的脚本文件将这样开头: #! /usr/bin/wish 为了找到脚本指定的解释器, 脚本二进制代码装载器试图打开在脚本文件的第一行中 指名的可执行的文件。 如果能打开它, 就让这个文件, 也就是一个解释器, 来执行这 个脚本。 脚本文件的名字成为参数零(第一参数)并且所有其它的参数向后移动一个位置 (原来第一参数成为新的第二参数, 依此类推)。 装入解释器的方法和 Linux 中装入一个 可执行文件的方法是一样的。 Linux 试用每一种二进制格式直到某个格式能够成功为止。 这样, 从理论上, 你能够安排若干个解释器以及二进制格式, 使 Linux 的二进制格式处 理器变得非常灵活。