进程执行操作系统中的任务。程序是存放在磁盘上的包括一系列机器代码指令和数据的可执行的映像,因此,是一个被动的实体。进程可以看作是一个执行中的计算机程序。它是动态的实体,在处理器执行机器代码指令时不断改变。处理程序的指令和数据,进程也包括程序计数器和其他 CPU 的寄存器以及包括临时数据(例如例程参数、返回地址和保存的变量)的堆栈。当前执行的程序,或者说进程,包括微处理器中所有的当前的活动。 Linux 是一个多进程的操作系统。进程是分离的任务,拥有各自的权利和责任。如果一个进程崩溃,它不应该让系统中的另一个进程崩溃。每一个独立的进程运行在自己的虚拟地址空间,除了通过安全的核心管理的机制之外无法影响其他的进程。
在一个进程的生命周期中它会使用许多系统资源。它会用系统的 CPU 执行它的指令,用系统的物理内存来存储它和它的数据。它会打开和使用文件系统中的文件,会直接或者间接使用系统的物理设备。 Linux 必须跟踪进程本身和它使用的系统资源以便管理公平地管理该进程和系统中的其他进程。如果一个进程独占了系统的大部分物理内存和 CPU ,对于其他进程就是不公平的。
系统中最宝贵的资源就是 CPU 。通常系统只有一个。 Linux 是一个多进程的操作系统。它的目标是让进程一直在系统的每一个 CPU 上运行,充分利用 CPU 。如果进程数多于 CPU (多数是这样),其余的进程必须等到 CPU 被释放才能运行。多进程是一个简单的思想:一个进程一直运行,直到它必须等待,通常是等待一些系统资源,等拥有了资源,它才可以继续运行。在一个单进程的系统,比如 DOS , CPU 被简单地设为空闲,这样等待的时间就会被浪费。在一个多进程的系统中,同一时刻许多进程在内存中。当一个进程必须等待时操作系统将 CPU 从这个进程拿走,并将它交给另一个更需要的进程。是调度程序选择了
下一次最合适的进程。 Linux 使用了一系列的调度方案来保证公平。
Linux 支持许多不同的可执行文件格式, ELF 是其中之一, Java 是另一个。 Linux 必须透明地管理这些文件,因为进程使用系统的共享的库。
4.1 Linux Processes ( Linux 的进程)
Linux 中,每一个进程用一个 task_struct (在 Linux 中 task 和 process 互用)的数据结构来表示,用来管理系统中的进程。 Task 向量表是指向系统中每一个 task_struct 数据结构的指针的数组。这意味着系统中最大进程数受 task 向量表的限制,缺省是 512 。当新的进程创建的时候,从系统内存中分配一个新的 task_struct ,并增加到 task 向量表中。为了更容易查找,用 current 指针指向当前运行的进程。
参见 include/linux/sched.h
除了普通进程, Linux 也支持实时进程。这些进程必须对于外界事件迅速反应(因此叫做“实时”),调度程序必须和普通用户进程区分对待。虽然 task_struct 数据结构十分巨大、复杂,但是它的域可以分为以下的功能:
State 进程执行时它根据情况改变状态 (state) 。 Linux 进程使用以下状态:(这里漏掉了 SWAPPING ,因为看来没用到)
Running 进程在运行 ( 是系统的当前进程 ) 或者准备运行(等待被安排到系统的一个 CPU 上)
Waiting 进程在等待一个事件或资源。 Linux 区分两种类型的等待进程:可中断和不可中断的( interruptible and uninterruptible )。可中断的等待进程可以被信号中断,而不可中断的等待进程直接等待硬件条件,不能被任何情况中断。
Stopped 进程停止了,通常是接收到了一个信号。正在调试的进程可以在停止状态。
Zombie 终止的进程,因为某种原因,在 task 向量表重任旧有一个 task_struct 数据结构的条目。就想听起来一样,是一个死亡的进程。
Scheduling Information 调度者需要这个信息用于公平地决定系统中的进程哪一个更应该运行。
Identifiers 系统中的每一个进程都有一个进程标识符。进程标识符不是 task 向量表中的索引,而只是一个数字。每一个进程也都有用户和组( user and group )的标识符。用来控制进程对于系统中文件和设备的访问。
Inter-Process Communication Linux 支持传统的 UNIX-IPC 机制,即信号,管道和信号灯( semaphores ),也支持系统 V 的 IPC 机制,即共享内存、信号灯和消息队列。关于 Linux 支持的 IPC 机制在第 5 章中描述。
Links 在 Linux 系统中,没有一个进程是和其他进程完全无关的。系统中的每一个进程,除了初始的进程之外,都有一个父进程。新进程不是创建的,而是拷贝,或者说从前一个进程克隆的( cloned )。每一个进程的 task_struct 中都有指向它的父进程和兄弟进程(拥有相同的父进程的进程)以及它的子进程的的指针。在 Linux 系统中你可以用 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 数据结构的双向链表中,根是 init 进程。这个表让 Linux 可以查到系统中的所有的进程。它需要这个表以提供对于 ps 或者 kill 等命令的支持。
Times and Timers 在一个进程的生命周期中,核心除了跟踪它使用的 CPU 时间还记录它的其他时间。每一个时间片( clock tick ),核心更新 jiffies 中当前进程在系统和用户态所花的时间综合。 Linux 也支持进程指定的时间间隔的计数器。进程可以使用系统调用建立计时器,在计时器到期的时候发送信号给自己。这种计时器可以是一次性的,也可是周期性的。
File system 进程可以根据需要打开或者关闭文件,进程的 task_struct 结构存放了每一个打开的文件描述符的指针和指向两个 VFS I 节点( inode )的指针。每一个 VFS I 节点唯一描述一个文件系统中的一个文件或目录,也提供了对于底层文件系统的通用接口。 Linux 下如何支持文件系统在第 9 章中描述。第一个 I 节点是该进程的根(它的主目录),第二个是它的当前或者说 pwd 目录。 Pwd 取自 Unix 命令:印出工作目录。这两个 VFS 节点本身有计数字段,随着一个或多个进程引用它们而增长。这就是为什么你不能删除一个进程设为工作目录的目录。
Virtual memory 多数进程都有一些虚拟内存(核心线程和核心守护进程没有), Linux 核心必须知道这些虚拟内存是如何映射到系统的物理内存中的。
Processor Specific Context 进程可以看作是系统当前状态的总和。只要进程运行,它就要使用处理器的寄存器、堆栈等等。当一个进程暂停的时候,这些进程的上下文、和 CPU 相关的上下文必须保存到进程的 task_struct 结构中。当调度者重新启动这个进程的时候,它的上下文就从这里恢复。
4.2 Identifiers (标识)
Linux ,象所有的 Unix ,使用用户和组标识符来检查对于系统中的文件和映像的访问权限。 Linux 系统中所有的文件都有所有权和许可,这些许可描述了系统对于该文件或目录拥有什么样的权限。基本的权限是读、写和执行,并分配了 3 组用户:文件属主、属于特定组的进程和系统中的其他进程。每一组用户都可以拥有不同的权限,例如一个文件可以让它的属主读写,它的组读,而系统中的其他进程不能访问。
Linux 使用组来给一组用户赋予对文件或者目录的权限,而不是对系统中的单个用户或者进程赋予权限。比如你可以为一个软件项目中的所有用户创建一个组,使得只有他们才能够读写项目的源代码。一个进程可以属于几个组(缺省是 32 个),这些组放在每一个进程的 task_struct 结构中的 groups 向量表中。只要进程所属的其中一个组对于一个文件有访问权限,则这个进程就又对于这个文件的适当的组权限。
一个进程的 task_struct 中有 4 对进程和组标识符。
Uid,gid 该进程运行中所使用的用户的标识符和组的标识符
Effective uid and gid 一些程序把执行进程的 uid 和 gid 改变为它们自己的(在 VFS I 节点执行映像的属性中)。这些程序叫做 setuid 程序。这种方式有用,因为它可以限制对于服务的访问,特别是那些用其他人的方式运行的,例如网络守护进程。有效的 uid 和 gid 来自 setuid 程序,而 uid 和 gid 仍旧是原来的。核心检查特权的时候检查有效 uid 和 gid 。
File system uid and gid 通常和有效 uid 和 gid 相等,检查对于文件系统的访问权限。用于通过 NFS 安装的文件系统。这时用户态的 NFS 服务器需要象一个特殊进程一样访问文件。只有文件系统 uid 和 gid 改变(而非有效 uid 和 gid )。这避免了恶意用户向 NFS 的服务程序发送 Kill 信号。 Kill 用一个特别的有效 uid 和 gid 发送给进程。
Saved uid and gid 这是 POSIX 标准的要求,让程序可以通过系统调用改变进程的 uid 和 gid 。用于在原来的 uid 和 gid 改变之后存储真实的 uid 和 gid 。
4.3 Scheduling (调度)
所有的进程部分运行与用户态,部分运行于系统态。底层的硬件如何支持这些状态各不相同但是通常有一个安全机制从用户态转入系统态并转回来。用户态比系统态的权限低了很多。每一次进程执行一个系统调用,它都从用户态切换到系统态并继续执行。这时让核心执行这个进程。 Linux 中,进程不是互相争夺成为当前运行的进程,它们无法停止正在运行的其它进程然后执行自身。每一个进程在它必须等待一些系统事件的时候会放弃 CPU 。例如,一个进程可能不得不等待从一个文件中读取一个字符。这个等待发生在系统态的系统调用中。进程使用了库函数打开并读文件,库函数又执行系统调用从打开的文件中读入字节。这时,等候的进程会被挂起,另一个更加值得的进程将会被选择执行。进程经常调用系统调用,所以经常需要等待。即使进程执行到需要等待也有可能会用去不均衡的 CPU 事件,所以 Linux 使用抢先式的调度。用这种方案,每一个进程允许运行少量一段时间, 200 毫秒,当这个时间过去,选择另一个进程运行,原来的进程等待一段时间直到它又重新运行。这个时间段叫做时间片。
需要调度程序选择系统中所有可以运行的进程中最值得的进程。一个可以运行的进程是一个只等待 CPU 的进程。 Linux 使用合理而简单的基于优先级的调度算法在系统当前的进程中进行选择。当它选择了准备运行的新进程,它就保存当前进程的状态、和处理器相关的寄存器和其他需要保存的上下文信息到进程的 task_struct 数据结构中。然后恢复要运行的新的进程的状态(又和处理器相关),把系统的控制交给这个进程。为了公平地在系统中所有可以运行( runnable )的进程之间分配 CPU 时间,调度程序在每一个进程的 task_struct 结构中保存了信息:
参见 kernel/sched.c schedule()
policy 进程的调度策略。 Linux 有两种类型的进程:普通和实时。实时进程比所有其它进程的优先级高。如果有一个实时的进程准备运行,那么它总是先被运行。实时进程有两种策略:环或先进先出( round robin and first in first out )。在环的调度策略下,每一个实时进程依次运行,而在先进先出的策略下,每一个可以运行的进程按照它在调度队列中的顺序运行,这个顺序不会改变。
Priority 进程的调度优先级。也是它允许运行的时候可以使用的时间量( jiffies )。你可以通过系统调用或者 renice 命令来改变一个进程的优先级。
Rt_priority Linux 支持实时进程。这些进程比系统中其他非实时的进程拥有更高的优先级。这个域允许调度程序赋予每一个实时进程一个相对的优先级。实时进程的优先级可以用系统调用来修改
Coutner 这时进程可以运行的时间量( jiffies )。进程启动的时候等于优先级( priority ),每一次时钟周期递减。
调度程序从核心的多个地方运行。它可以在把当前进程放到等待队列之后运行,也可以在系统调用之后进程从系统态返回进程态之前运行。需要运行调度程序的另一个原因是系统时钟刚好把当前进程的计数器 (counter) 置成了 0 。每一次调度程序运行它做以下工作:
参见 kernel/sched.c schedule()
kernel work 调度程序运行 bottom half handler 并处理系统的调度任务队列。这些轻量级的核心线程在第 11 章详细描述
Current pocess 在选择另一个进程之前必须处理当前进程。
如果当前进程的调度策略是环则它放到运行队列的最后。
如果任务是可中断的而且它上次调度的时候收到过一个信号,它的状态变为 RUNNING
如果当前进程超时,它的状态成为 RUNNING
如果当前进程的状态为 RUNNING 则保持此状态
不是 RUNNING 或者 INTERRUPTIBLE 的进程被从运行队列中删除。这意味着当调度程序查找最值得运行的进程时不会考虑这样的进程。
Process Selection 调度程序查看运行队列中的进程,查找最值得运行的进程。如果有实时的进程(具有实时调度策略),就会比普通进程更重一些。普通进程的重量是它的 counter ,但是对于实时进程则是 counter 加 1000 。这意味着如果系统中存在可运行的实时进程,就总是在任何普通可运行的进程之前运行。当前的进程,因为用掉了一些时间片(它的 counter 减少了),所以如果系统中由其他同等优先级的进程,就会处于不利的位置:这也是应该的。如果几个进程又同样的优先级,最接近运行队列前段的那个就被选中。当前进程被放到运行队列的后面。如果一个平衡的系统,拥有大量相同优先级的进程,那么回按照顺序执行这些进程。这叫做环型调度策略。不过,因为进程需要等待资源,它们的运行顺序可能会变化。
Swap Processes 如果最值得运行的进程不是当前进程,当前进程必须被挂起,运行新的进程。当一个进程运行的时候它使用了 CPU 和系统的寄存器和物理内存。每一次它调用例程都通过寄存器或者堆栈传递参数、保存数值比如调用例程的返回地址等。因此,当调度程序运行的时候它在当前进程的上下文运行。它可能是特权模式:核心态,但是它仍旧是当前运行的进程。当这个进程要挂起时,它的所有机器状态,包括程序计数器 (PC) 和所有的处理器寄存器,必须存到进程的 task_struct 数据结构中。然后,必须加载新进程的所有机器状态。这种操作依赖于系统,不同的 CPU 不会完全相同地实现,不过经常都是通过一些硬件的帮助。
交换出去进程的上下文发生在调度的最后。前一个进程存储的上下文,就是当这个进程在调度结束的时候系统的硬件上下文的快照。相同的,当加载新的进程的上下文时,仍旧是调度结束时的快照,包括进程的程序计数器和寄存器的内容。
如果前一个进程或者新的当前进程使用虚拟内存,则系统的页表需要更新。同样,这个动作适合体系结构相关。 Alpha AXP 处理器,使用 TLT ( Translation Look-aside Table )或者缓存的页表条目,必须清除属于前一个进程的缓存的页表条目。
4.3.1 Scheduling in Multiprocessor Systems (多处理器系统中的调度)
在 Linux 世界中,多 CPU 系统比较少,但是已经做了大量的工作使 Linux 成为一个 SMP (对称多处理)的操作系统。这就是,可以在系统中的 CPU 之间平衡负载的能力。负载均衡没有比在调度程序中更重要的了。
在一个多处理器的系统中,希望的情况是:所有的处理器都繁忙地运行进程。每一个进程都独立地运行调度程序直到它的当前的进程用完时间片或者不得不等待系统资源。 SMP 系统中第一个需要注意的是系统中可能不止一个空闲( idle )进程。在一个单处理器的系统中,空闲进程是 task 向量表中的第一个任务,在一个 SMP 系统中,每一个 CPU 都有一个空闲的进程,而你可能有不止一个空闲 CPU 。另外,每一个 CPU 有一个当前进程,所以 SMP 系统必须记录每一个处理器的当前和空闲进程。
在一个 SMP 系统中,每一个进程的 task_struct 都包含进程当前运行的处理器编号( processor )和上次运行的处理器编号( last_processor )。为什么进程每一次被选择运行时不要在不同的 CPU 上运行是没什么道理的,但是 Linux 可以使用 processor_mask 把进程限制在一个或多个 CPU 上。如果位 N 置位,则该进程可以运行在处理器 N 上。当调度程序选择运行的进程的时候,它不会考虑 processor_mask 相应位没有设置的进程。调度程序也会利用上一次在当前处理器运行的进程,因为把进程转移到另一个处理器上经常会有性能上的开支。
4.4 Files (文件)
图 4.1 显示了描述系统每一个进程中的用于描述和文件系统相关的信息的两个数据结构。第一个 fs_struct 包括了这个进程的 VFS I 节点和它的 umask 。 Umask 是新文件创建时候的缺省模式,可以通过系统调用改变。
参见 include/linux/sched.h
第二个数据结构, files_struct ,包括了进程当前使用的所有文件的信息。程序从标准输入读取,向标准输出写,错误信息输出到标准错误。这些可以是文件,终端输入 / 输出或者世纪的设备,但是从程序的角度它们都被看作是文件。每一个文件都有它的描述符, files_struct 包括了指向 256 个 file 数据结果,每一个描述进程形用的文件。 F_mode 域描述了文件创建的模式:只读、读写或者只写。 F_pos 记录了下一次读写操作在文件中的位置。 F_inode 指向描述该文件的 I 节点, f_ops 是指向一组例程地址的指针,每一个地址都是一个用于处理文件的函数。例如写数据的函数。这种抽象的接口非常强大,使得 Linux 可以支持大量的文件类型。我们可以看到,在 Linux 中 pipe 也是用这种机制实现的。
每一次打开一个文件,就使用 files_struct 中的一个空闲的 file 指针指向这个新的 file 结构。 Linux 进程启动时有 3 个文件描述符已经打开。这就是标准输入、标准输出和标准错误,这都是从创建它们的父进程中继承过来的。对于文件的访问都是通过标准的系统调用,需要传递或返回文件描述符。这些描述符是进程的 fd 向量表中的索引,所以标准输入、标准输出和标准错误的文件描述符分别是 0 , 1 和 2 。对于文件的所有访问都是利用 file 数据结构中的文件操作例程和它的 VFS I 节点一起来实现的。
4.5 Virtual Memory (虚拟内存)
进程的虚拟内存包括多种来源的执行代码和数据。第一种是加载的程序映像,例如 ls 命令。这个命令,象所有的执行映像一样,由执行代码和数据组成。映像文件中包括将执行代码和相关的程序数据加载到进程地虚拟内存中所需要的所有信息。第二种,进程可以在处理过程中分配(虚拟)内存,比如用于存放它读入的文件的内容。新分配的虚拟内存需要连接到进程现存的虚拟内存中才能使用。第三中, Linux 进程使用通用代码组成的库,例如文件处理。每一个进程都包括库的一份拷贝没有意义, Linux 使用共享库,几个同时运行的进程可以共用。这些共享库里边的代码和数据必须连接到该进程的虚拟地址空间和其他共享该库的进程的虚拟地址空间。
在一个特定的时间,进程不会使用它的虚拟内存中包括的所有代码和数据。它可能包括旨在特定情况下使用的代码,比如初始化或者处理特定的事件。它可能只是用了它的共享库中一部分例程。如果把所有这些代码都加载到物理内存中而不使用只会是浪费。把这种浪费和系统中的进程数目相乘,系统的运行效率会很低。 Linux 改为使用 demand paging 技术,进程的虚拟内存只在进程试图使用的时候才调入物理内存中。所以, Linux 不把代码和数据直接加载到内存中,而修改进程的页表,把这些虚拟区域标志为存在但是不在内存中。当进程试图访问这些代码或者数据,系统硬件会产生一个 page fault ,把控制传递给 Linux 核心处理。因此,对于进程地址空间的每一个虚拟内存区域, Linux 需要直到它从哪里来和如何把它放到内存中,这样才可以处理这些 page fault 。
Linux 核心需要管理所有的这些虚拟内存区域,每一个进程的虚拟内存的内容通过一个它的 task_struct 指向的一个 mm_struct mm_struc 数据结构描述。该进程的 mm_struct 数据结构也包括加载的执行映像的信息和进程页表的指针。它包括了指向一组 vm_area_struct 数据结构的指针,每一个都表示该进程中的一个虚拟内存区域。
这个链接表按照虚拟内存顺序排序。图 4.2 显示了一个简单进程的虚拟内存分布和管理它的核心数据结构。因为这些虚拟内存区域来源不同, Linux 通过 vm_area_struct 指向一组虚拟内存处理例程(通过 vm_ops )的方式抽象了接口。这样进程的所有虚拟内存都可以用一种一致的方式处理,不管底层管理这块内存的服务如何不同。例如,会有一个通用的例程,在进程试图访问不存在的内存时调用,这就是 page fault 的处理。
当 Linux 为一个进程创建新的虚拟内存区域和处理对于不在系统物理内存中的虚拟内存的引用时,反复引用进程的 vm_area_struct 数据结构列表。这意味着它查找正确的 vm_area_struct 数据结构所花的事件对于系统的性能十分重要。为了加速访问, Linux 也把 vm_area_struct 数据结构放到一个 AVL ( Adelson-Velskii and Landis )树。对这个树进行安排使得每一个 vm_area_struct (或节点)都有对相邻的 vm_area_struct 结构的一个左和一个右指针。左指针指向拥有较低起始虚拟地址的节点,右指针指向一个拥有较高起始虚拟地址的节点。为了找到正确的节点, Linux 从树的根开始,跟从每一个节点的左和右指针,直到找到正确的 vm_area_struct 。当然,在这个树中间释放不需要时间,而插入新的 vm_area_struct 需要额外的处理时间。
当一个进程分配虚拟内存的时候, Linux 并不为该进程保留物理内存。它通过一个新的 vm_area_struct 数据结构来描述这块虚拟内存,连接到进程的虚拟内存列表中。当进程试图写这个新的虚拟内存区域的时候,系统会发生 page fault 。处理器试图解码这个虚拟地址,但是没有对应该内存的页表条目,它会放弃并产生一个 page fault 异常,让 Linux 核心处理。 Linux 检查这个引用的虚拟地址是不是在进程的虚拟地址空间, 如果是, Linux 创建适当的 PTE 并为该进程分配物理内存页。也许需要从文件系统或者交换磁盘中加载相应的代码或者数据,然后进程从引起 page fault 的指令重新运行,因为这次该内存实际存在,可以继续。
4.6 Creating a Process (创建一个进程)
当系统启动的时候它运行在核心态,这时,只有一个进程:初始化进程。象所有其他进程一样,初始进程有一组用堆栈、寄存器等等表示的机器状态。当系统中的其他进程创建和运行的时候这些信息存在初始进程的 task_struct 数据结构中。在系统初始化结束的时候,初始进程启动一个核心线程(叫做 init )然后执行空闲循环,什么也不做。当没有什么可以做的时候,调度程序会运行这个空闲的进程。这个空闲进程的 task_struct 是唯一一个不是动态分配而是在核心连接的时候静态定义的,为了不至于混淆,叫做 init_task 。
Init 核心线程或进程拥有进程标识符 1 ,是系统的第一个真正的进程。它执行系统的一些初始化的设置(比如打开系统控制它,安装根文件系统),然后执行系统初始化程序。依赖于你的系统,可能是 /etc/init , /bin/init 或 /sbin/init 其中之一。 Init 程序使用 /etc/inittab 作为脚本文件创建系统中的新进程。这些新进程自身可能创建新的进程。例如: getty 进程可能会在用户试图登录的时候创建一个 login 的进程。系统中的所有进程都是 init 核心线程的后代。
新的进程的创建是通过克隆旧的进程,或者说克隆当前的进程来实现的。一个新的任务是通过系统调用创建的( fork 或 clone ),克隆发生在核心的核心态。在系统调用的最后,产生一个新的进程,等待调度程序选择它运行。从系统的物理内存中为这个克隆进程的堆栈(用户和核心)分配一个或多个物理的页用于新的 task_struct 数据结构。一个进程标识符将会创建,在系统的进程标识符组中是唯一的。但是,也可能克隆的进程保留它的父进程的进程标识符。新的 task_struct 进入了 task 向量表中,旧的(当前的)进程的 task_struct 的内容拷贝到了克隆的 task_struct 。
参见 kernel/fork.c do_fork()
克隆进程的时候, Linux 允许两个进程共享资源而不是拥有不同的拷贝。包括进程的文件,信号处理和虚拟内存。共享这些资源的时候,它们相应的 count 字段相应增减,这样 Linux 不会释放这些资源直到两个进程都停止使用。例如,如果克隆的进程要共享虚拟内存,它的 task_struct 会包括一个指向原来进程的 mm_struct 的指针, mm_struct 的 count 域增加,表示当前共享它的进程数目。
克隆一个进程的虚拟内存要求相当的技术。必须产生一组 vm_area_struct 数据结构、相应的 mm_struct 数据结构和克隆进程的页表,这时没有拷贝进程的虚拟内存。这会是困难和耗时的任务,因为一部分虚拟内存可能在物理内存中而另一部分可能在交换文件中。替代底, Linux 使用了叫做“ copy on write ”的技术,即只有两个进程中的一个试图写的时候才拷贝虚拟内存。任何不写入的虚拟内存,甚至可能写的,都可以在两个进程之间共享二部会有什么害处。只读的内存,例如执行代码,可以共享。为了实现“ copy on write ”,可写的区域的页表条目标记为只读,而描述它的 vm_area_struct 数据结构标记为“ copy on write ”。当一个进程试图写向着这个虚拟内存的时候会产生 page fault 。这时 Linux 将会制作这块内存的一份拷贝并处理两个进程的页表和虚拟内存的数据结构。
核心跟踪进程的 CPU 时间和其他一些时间。每一个时钟周期,核心更新当前进程的 jiffies 来表示在系统和用户态下花费的时间总和。
除了这些记账的计时器, Linux 还支持进程指定的间隔计时器( interval timer )。进程可以使用这些计时器在这些计时器到期的时候发送给自身信号。支持三种间隔计时器:
参见 kernel/itimer.c
Real 这个计时器使用实时计时,当计时器到期,发送给进程一个 SIGALRM 信号。
Virtual 这个计时器只在进程运行的时候计时,到期的时候,发送给进程一个 SIGVTALARM 信号。
Profile 在进程运行的时候和系统代表进程执行的时候都及时。到期的时候,会发送 SIGPROF 信号。
可以运行一个或者所有的间隔计时器, Linux 在进程的 task_struct 数据结构中记录所有的必要信息。可以使用系统调用建立这些间隔计时器,启动、停止它们,读取当前的数值。虚拟和 profile 计时器的处理方式相同:每一次时钟周期,当前进程的计时器递减,如果到期,就发出适当的信号
参见 kernel/sched.c do_it_virtual() , do_it_prof()
实时间隔计时器稍微不同。 Linux 使用计时器的机制在第 11 章描述。每一个进程都有自己的 timer_list 数据结构,当时使用实时计时器的时候,使用系统的 timer 表。当它到期的时候,计时器后半部分处理把它从队列中删除并调用间隔计时器处理程序。它产生 SIGALRM 信号并重启动间隔计时器,把它加回到系统计时器队列。
参见: kernel/iterm.c it_real_fn()
在 Linux 中,象 Unix 一样,程序和命令通常通过命令解释器执行。命令解释程序是和其他进程一样的用户进程,叫做 shell (想象一个坚果,把核心作为中间可食的部分,而 shell 包围着它,提供一个接口)。 Linux 中有许多 shell ,最常用的是 sh 、 bash 和 tcsh 。除了一些内部命令之外,比如 cd 和 pwd ,命令是可执行的二进制文件。对于输入的每一个命令, shell 在当前进程的搜索路径指定的目录中(放在 PATH 环境变量)查找匹配的名字。如果找到了文件,就加载并运行。 Shell 用上述的 fork 机制克隆自身,并在子进程中用找到的执行映像文件的内容替换它正在执行的二进制映像( shell )。通常 shell 等待命令结束,或者说子进程退出。你可以通过输入 control-Z 发送一个 SIGSTOP 信号给子进程,把子进程停止并放到后台,让 shell 重新运行。你可以使用 shell 命令 bg 让 shell 向子进程发送 SIGCONT 信号,把子进程放到后台并重新运行,它会持续运行直到它结束或者需要从终端输入或输出。
执行文件可以由许多格式甚至可以是一个脚本文件( script file )。脚本文件必须用合适的解释程序识别并运行。例如 /bin/sh 解释 shell script 。可执行的目标文件包括了执行代码和数据以及足够的其他信息,时的操作系统可以把它们加载到内存中并执行。 Linux 中最常用的目标文件类型是 ELF ,而理论上, Linux 灵活到足以处理几乎所有的目标文件格式。
好像文件系统一样, Linux 可以支持的二进制格式也是在核心连接的时候直接建立在核心的或者是可以作为模块加载的。核心保存了支持的二进制格式(见图 4.3 )的列表,当试图执行一个文件的时候,每一个二进制格式都被尝试,直到可以工作。通常, Linux 支持的二进制文件是 a.out 和 ELF 。可执行文件不需要完全读入内存,而使用叫做 demand loading 的技术。当进程使用执行映像的一部分的时候它才被调入内存,未被使用的映像可以从内存中废弃。
参见 fs/exec.c do_execve()
- ELF
ELF ( Executable and Linkable Format 可执行可连接格式)目标文件,由 Unix 系统实验室设计,现在成为 Linux 最常用的格式。虽然和其他目标文件格式比如 ECOFF 和 a.out 相比,有性能上的轻微开支, ELF 感觉更灵活。 ELF 可执行文件包括可执行代码(有时叫做 text )和数据( data )。执行映像中的表描述了程序应该如何放到进程的虚拟内存中。静态连接的映像是用连接程序( ld )或者连接编辑器创建的,单一的映像中包括了运行该映像所需要的所有的代码和数据。这个映像也描述了该映像在内存中的布局和要执行的第一部分代码在映像中的地址。
图 4.4 象是了静态连接的 ELF 可执行映像的布局。这是个简单的 C 程序,打印“ hello world ”然后退出。头文件描述了它是一个 ELF 映像,有两个物理头( e_phnum 是 2 ),从映像文件的开头第 52 字节开始( e_phoff )。第一个物理头描述映像中的执行代码,在虚拟地址 0x8048000 ,有 65532 字节。因为它是静态连接的,所以包括输出“ hello world ”的调用 printf ()的所有的库代码。映像的入口,即程序的第一条指令,不是位于映像的起始位置,而在虚拟地址 0x8048090 ( e_entry )。代码紧接着在第二物理头后面开始。这个物理头描述了程序的数据,将会加载到虚拟内存地址 0x8059BB8 。这块数据可以读写。你会注意到文件中数据的大小是 2200 字节( p_filesz )而在内存中的大小是 4248 字节。因为前 2200 字节包括预先初始化的数据,而接着的 2048 字节包括会被执行代码初始化的数据。
参见 include/linux/elf.h
当 Linux 把 ELF 可执行映像加载到进程的虚拟地址空间的时候,它不是实际的加载映像。它设置虚拟内存数据结构,即进程的 vm_area_struct 和它的页表。当程序执行了 page fault 的时候,程序的代码和数据会被放到物理内存中。没有用到的程序部分将不会被放到内存中。一旦 ELF 二进制格式加载程序满足条件,映像是一个有效的 ELF 可执行映像,它把进程的当前可执行映像从它的虚拟内存中清除。因为这个进程是个克隆的映像(所有的进程都是),旧的映像是父进程执行的程序的映像(例如命令解释程序 shell bash )。清除旧的可执行映像会废弃旧的虚拟内存的数据结构,重置进程的页表。它也会清除设置的其他信号处理程序,关闭打开的文件。在清除过程的最后,进程准备运行新的可执行映像。不管可执行映像的格式如何,进程的 mm_struct 中都要设置相同的信息。包括指向映像中代码和数据起始的指针。这些数值从 ELF 可执行映像的物理头中读入,它们描述的部分也被映射到了进程的虚拟地址空间。这也发生在进程的 vm_area_struct 数据结构建立和页表修改的时候。 mm_struct 数据结构中也包括指针,指向传递给程序的参数和进程的环境变量。
ELF Shared Libraries ( ELF 共享库)
动态连接的映像,反过来,不包含运行所需的所有的代码和数据。其中一些放在共享库并在运行的时候连接到映像中。当运行时动态库连接到映像中的时候,动态连接程序( dynamic linker )也要使用 ELF 共享库的表。 Linux 使用几个动态连接程序, ld.so.1 , libc.so.1 和 ld-linux.so.1 ,都在 /lib 目录下。这些库包括通用的代码,比如语言子例程。如果没有动态连接,所有的程序都必须有这些库的独立拷贝,需要更多的磁盘空间和虚拟内存。在动态连接的情况下, ELF 映像的表中包括引用的所有库例程的信息。这些信息指示动态连接程序如何定位库例程以及如何连接到程序的地址空间。
- Scripts Files
脚本文件是需要解释器才能运行的可执行文件。 Linux 下有大量的解释器,例如 wish 、 perl 和命令解释程序比如 tcsh 。 Linux 使用标准的 Unix 约定,在脚本文件的第一行包括解释程序的名字。所以一个典型的脚本文件可能开头是:
#!/usr/bin/wish
脚本文件加载器试图找出文件所用的解释程序。它试图打开脚本文件第一行指定的可执行文件。如果可以打开,就得到一个指向该文件的 VFS I 节点的指针,然后执行它去解释脚本文件。脚本文件的名字成为了参数 0 (第一个参数),所有的其他参数都向上移动一位(原来的第一个参数成为了第二个参数等等)。加载解释程序和 Linux 加载其他可执行程序一样。 Linux 依次尝试各种二进制格式,直到可以工作。这意味着理论上你可以把几种解释程序和二进制格式堆积起来,让 Linux 的二进制格式处理程序更加灵活。