目录, 显示框架, 无框架

第 4 章
进程


本章介绍什么是进程,以及 Linux 内核如何在系统中创建、管理和删除进程。

进程在操作系统内执行任务。程序是一组机器代码指令和数据,存储在磁盘上的可执行映像中,因此,它是一个被动的实体;进程可以被认为是正在运行的计算机程序。

它是一个动态实体,随着处理器执行机器代码指令而不断变化。除了程序的指令和数据外,进程还包括程序计数器和所有 CPU 寄存器,以及包含临时数据(如例程参数、返回地址和保存的变量)的进程堆栈。当前正在执行的程序或进程,包括微处理器中的所有当前活动。Linux 是一个多进程操作系统。进程是独立的任务,每个任务都有自己的权利和责任。如果一个进程崩溃,它不会导致系统中的另一个进程崩溃。每个独立的进程都在其自己的虚拟地址空间中运行,并且除了通过安全的、内核管理的机制外,无法与另一个进程交互。

在一个进程的生命周期中,它将使用许多系统资源。它将使用系统中的 CPU 来运行其指令,并使用系统的物理内存来保存它及其数据。它将打开和使用文件系统中的文件,并可能直接或间接地使用系统中的物理设备。Linux 必须跟踪进程本身以及它拥有的系统资源,以便它可以公平地管理它和系统中的其他进程。如果一个进程垄断了系统的大部分物理内存或 CPU,这对系统中的其他进程是不公平的。

系统中最重要的资源是 CPU,通常只有一个。Linux 是一个多进程操作系统,其目标是始终让系统中的每个 CPU 上都有一个进程在运行,以最大限度地提高 CPU 利用率。如果进程数量多于 CPU 数量(通常情况下是这样),则其余进程必须等待 CPU 空闲才能运行。多进程是一个简单的概念;一个进程被执行,直到它必须等待,通常是等待一些系统资源;当它拥有这个资源时,它可以再次运行。例如,在单进程系统中DOSCPU 将简单地处于空闲状态,等待时间将被浪费。在多进程系统中,许多进程同时保存在内存中。每当一个进程必须等待时,操作系统就会将 CPU 从该进程中取出,并将其交给另一个更值得运行的进程。调度器负责选择下一个最合适的进程来运行,Linux 使用多种调度策略来确保公平性。

Linux 支持多种不同的可执行文件格式,ELF 是一种,Java 是另一种,这些都必须透明地管理,系统共享库的进程使用也必须透明地管理。

4.1  Linux 进程

为了让 Linux 能够管理系统中的进程,每个进程都由一个task_struct数据结构表示(task 和 process 是 Linux 交替使用的术语)。task向量是一个指向系统中每个task_struct数据结构的指针数组。

这意味着系统中进程的最大数量受到task向量大小的限制;默认情况下,它有 512 个条目。当创建进程时,会从系统内存中分配一个新的task_struct,并添加到task向量中。为了便于查找,当前正在运行的进程由current指针指向。

除了普通类型的进程外,Linux 还支持实时进程。这些进程必须非常快速地对外部事件做出反应(因此称为“实时”),并且调度器对它们的处理方式与普通用户进程不同。虽然task_struct数据结构相当大且复杂,但其字段可以分为多个功能区域

状态
当进程执行时,它会根据其情况改变状态。Linux 进程具有以下状态:1
运行
进程正在运行(它是系统中的当前进程),或者它已准备好运行(它正在等待被分配到系统的一个 CPU)。
等待
进程正在等待事件或资源。Linux 区分两种类型的等待进程;可中断不可中断。可中断等待进程可以被信号中断,而不可中断等待进程直接等待硬件条件,在任何情况下都不能被中断。
停止
进程已停止,通常是由于接收到信号。正在调试的进程可能处于停止状态。
僵尸
这是一个已停止的进程,由于某种原因,它仍然在task_struct向量中有一个task数据结构。它就像它听起来的那样,一个死亡的进程。

调度信息
调度器需要此信息才能公平地决定系统中哪个进程最值得运行。

标识符
系统中的每个进程都有一个进程标识符。进程标识符不是task向量的索引,它只是一个数字。每个进程还具有用户和组标识符,这些标识符用于控制此进程对系统中文件和设备的访问。

进程间通信
Linux 支持经典的 Unix TM IPC 机制,包括信号、管道和信号量,以及 System V IPC 机制,包括共享内存、信号量和消息队列。Linux 支持的 IPC 机制在第 IPC 章节中描述。

链接
在 Linux 系统中,没有哪个进程是独立于任何其他进程的。系统中的每个进程,除了初始进程外,都有一个父进程。新进程不是被创建的,而是从以前的进程复制或更确切地说是克隆出来的。每个task_struct代表进程的结构都保存着指向其父进程和兄弟进程(那些具有相同父进程的进程)以及其自身子进程的指针。您可以使用pstree命令查看 Linux 系统中运行进程之间的家庭关系。
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)

此外,系统中的所有进程都保存在一个双向链表中,该链表的根是init进程task_struct数据结构。此列表允许 Linux 内核查看系统中的每个进程。它需要这样做以支持诸如pskill.

之类的命令。
时间和定时器内核会跟踪进程的创建时间以及在其生命周期内消耗的 CPU 时间。每个时钟滴答,内核都会更新当前进程在系统模式和用户模式下花费的时间量,单位为jiffies

文件系统
进程可以根据需要打开和关闭文件,并且进程的task_struct包含指向每个打开文件的描述符的指针,以及指向两个 VFS inode 的指针。每个 VFS inode 唯一地描述文件系统中的文件或目录,并为底层文件系统提供统一的接口。Linux 如何支持文件系统在第 filesystem 章节中描述。第一个指针指向进程的根目录(其主目录),第二个指针指向其当前或 pwd 目录。pwd 源自 Unix TM 命令pwdprint working directory(打印工作目录)。这两个 VFS inode 的count字段会递增,以表明一个或多个进程正在引用它们。这就是为什么您无法删除进程设置为其 pwd 目录的目录,或者就此而言,也无法删除其子目录之一。

虚拟内存
大多数进程都有一些虚拟内存(内核线程和守护程序没有),Linux 内核必须跟踪该虚拟内存如何映射到系统的物理内存。

处理器特定上下文
进程可以被认为是系统当前状态的总和。每当进程运行时,它都在使用处理器的寄存器、堆栈等等。这是进程的上下文,当进程被挂起时,所有这些 CPU 特定上下文都必须保存在进程的task_struct中。当进程被调度器重新启动时,其上下文将从此处恢复。

4.2  标识符

Linux 像所有 Unix TM 一样,使用用户和组标识符来检查对系统中文件和映像的访问权限。Linux 系统中的所有文件都具有所有权和权限,这些权限描述了系统用户对该文件或目录的访问权限。基本权限是读取写入执行,并分配给三类用户:文件的所有者、属于特定组的进程以及系统中的所有进程。每个用户类可以具有不同的权限,例如,一个文件可以具有允许其所有者读取和写入它的权限,允许文件组读取它的权限,以及允许系统中所有其他进程完全没有访问权限的权限。

审阅说明 展开并给出位分配 (777)。

组是 Linux 为一组用户而不是单个用户或系统中的所有进程分配文件和目录权限的方式。例如,您可以为软件项目中的所有用户创建一个组,并安排只有他们可以读取和写入该项目的源代码。一个进程可以属于多个组(默认最多 32 个),这些组保存在每个进程的groups向量中。只要文件具有进程所属组之一的访问权限,该进程就将拥有对该文件的适当组访问权限。task_struct进程的

中保存着四对进程和组标识符task_struct:

uid, gid
进程代表其运行的用户的用户标识符和组标识符。
effective uid and gid
有些程序会将其 uid 和 gid 从正在执行的进程的 uid 和 gid 更改为它们自己的 uid 和 gid(作为属性保存在描述可执行映像的 VFS inode 中)。这些程序被称为 setuid 程序,它们很有用,因为这是一种限制对服务的访问的方式,特别是那些代表其他人运行的服务,例如网络守护程序。有效 uid 和 gid 来自 setuid 程序,uid 和 gid 保持不变。内核在检查特权权限时会检查有效 uid 和 gid。
文件系统 uid 和 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  调度

所有进程都部分在用户模式下运行,部分在系统模式下运行。底层硬件如何支持这些模式有所不同,但通常存在一种从用户模式进入系统模式并再次返回的安全机制。用户模式的权限远低于系统模式。每次进程进行系统调用时,它都会从用户模式切换到系统模式并继续执行。此时,内核代表进程执行。在 Linux 中,进程不会抢占当前的正在运行的进程,它们无法阻止它运行以便它们可以运行。每个进程都会在必须等待某些系统事件时决定放弃它正在运行的 CPU。例如,进程可能必须等待从文件中读取一个字符。这种等待发生在系统调用中,在系统模式下;进程使用库函数打开并读取文件,而库函数又进行了系统调用以从打开的文件中读取字节。在这种情况下,等待进程将被挂起,另一个更值得运行的进程将被选择运行。

进程总是进行系统调用,因此可能经常需要等待。即便如此,如果进程执行到等待状态,它仍然可能使用不成比例的 CPU 时间,因此 Linux 使用抢占式调度。在这种方案中,每个进程被允许运行一小段时间,200 毫秒,当这段时间到期时,将选择另一个进程运行,原始进程将被迫等待一段时间,直到它可以再次运行。这一小段时间被称为时间片

调度器必须从系统中所有可运行的进程中选择最值得运行的进程。

可运行进程是仅等待 CPU 运行的进程。Linux 使用相当简单的基于优先级的调度算法来在系统中的当前进程之间进行选择。当它选择了一个新的进程来运行时,它会保存当前进程的状态,处理器特定的寄存器和其他上下文被保存在进程的task_struct数据结构中。然后,它会恢复新进程的状态(同样是处理器特定的)以运行,并将系统控制权交给该进程。为了使调度器在系统中的可运行进程之间公平地分配 CPU 时间,它会在每个进程的task_struct中保留信息

policy
这是将应用于此进程的调度策略。Linux 进程有两种类型:普通进程和实时进程。实时进程的优先级高于所有其他进程。如果有实时进程准备好运行,它将始终首先运行。实时进程可能具有两种类型的policy轮询先进先出。在轮询调度中,每个可运行的实时进程依次运行,在先进先出调度中,每个可运行的进程按照它们在运行队列中的顺序运行,并且该顺序永远不会改变。
priority
这是调度器将赋予此进程的优先级。它也是此进程被允许运行时将运行的时间量(单位为内核会跟踪进程的创建时间以及在其生命周期内消耗的 CPU 时间。每个时钟滴答,内核都会更新当前进程在系统模式和用户模式下花费的时间量,单位为)。您可以通过系统调用和renice命令来更改进程的优先级。
rt_priority
Linux 支持实时进程,这些进程被调度为具有高于系统中所有其他非实时进程的优先级。此字段允许调度器为每个实时进程提供相对优先级。可以使用系统调用更改实时进程的优先级。
counter
这是此进程被允许运行的时间量(单位为内核会跟踪进程的创建时间以及在其生命周期内消耗的 CPU 时间。每个时钟滴答,内核都会更新当前进程在系统模式和用户模式下花费的时间量,单位为)。它在进程首次运行时设置为priority,并在每个时钟滴答时递减。

调度器从内核内的多个位置运行。它在将当前进程放入等待队列后运行,并且它也可能在系统调用结束时,在进程从系统模式返回到进程模式之前运行。它可能需要运行的一个原因是系统定时器刚刚将当前进程的counter设置为零。每次运行调度器时,它都会执行以下操作

内核工作
调度器运行底部半处理程序并处理调度器任务队列。这些轻量级内核线程在第 kernel 章节中详细描述。

当前进程
必须先处理当前进程,然后才能选择另一个进程运行。

如果当前进程的调度策略是轮询,则将其放在运行队列的末尾。

如果任务是INTERRUPTIBLE并且自上次调度以来它已收到信号,则其状态变为RUNNING.

如果当前进程已超时,则其状态变为RUNNING.

如果当前进程是RUNNING,则它将保持该状态。

既不是RUNNING也不是INTERRUPTIBLE的进程将从运行队列中删除。这意味着当调度器寻找最值得运行的进程时,将不会考虑它们。

进程选择
调度器遍历运行队列上的进程,寻找最值得运行的进程。如果有任何实时进程(那些具有实时调度策略的进程),则这些进程将获得比普通进程更高的权重。普通进程的权重是其counter,而实时进程的权重是counter加 1000。这意味着如果系统中有任何可运行的实时进程,则这些进程将始终在任何普通可运行进程之前运行。当前进程,它已经消耗了部分时间片(其counter已被递减),如果系统中存在其他具有相同优先级的进程,则处于不利地位;这应该是这样的。如果多个进程具有相同的优先级,则选择最靠近运行队列前端的进程。当前进程将被放在运行队列的末尾。在具有许多相同优先级的进程的平衡系统中,每个进程将依次运行。这被称为轮询调度。但是,随着进程等待资源,它们的运行顺序往往会被移动。

交换进程
如果最值得运行的进程不是当前进程,则必须挂起当前进程,并使新进程运行。当进程运行时,它正在使用 CPU 和系统的寄存器和物理内存。每次它调用例程时,它都会在寄存器中传递其参数,并可能堆叠保存值,例如要返回到调用例程中的地址。因此,当调度器运行时,它在当前进程的上下文中运行。它将处于特权模式,内核模式,但仍然是当前进程正在运行。当该进程即将被挂起时,其所有机器状态,包括程序计数器 (PC) 和处理器的所有寄存器,都必须保存在进程的task_struct数据结构中。然后,必须加载新进程的所有机器状态。这是一个系统相关的操作,没有 CPU 以完全相同的方式执行此操作,但通常有一些硬件辅助来完成此操作。

进程上下文的这种交换发生在调度器结束时。因此,先前进程的已保存上下文是系统硬件上下文的快照,因为它在调度器结束时对于此进程是这样的。同样,当加载新进程的上下文时,它也将是调度器结束时状态的快照,包括此进程的程序计数器和寄存器内容。

如果先前进程或新的当前进程使用虚拟内存,则可能需要更新系统的页表条目。同样,此操作是特定于体系结构的。像 Alpha AXP 这样的处理器,它们使用转换后备缓冲区或缓存页表条目,必须刷新属于先前进程的那些缓存表条目。

4.3.1  多处理器系统中的调度

在 Linux 世界中,具有多个 CPU 的系统相当罕见,但已经投入了大量工作使 Linux 成为 SMP(对称多处理)操作系统。也就是说,它可以均匀地平衡系统中 CPU 之间的工作负载。在调度器中,这种工作负载的平衡最为明显。

在多处理器系统中,希望所有处理器都在忙碌地运行进程。每个处理器都会在其当前进程耗尽其时间片或必须等待系统资源时单独运行调度器。关于 SMP 系统,首先要注意的是系统中不仅仅有一个空闲进程。在单处理器系统中,空闲进程是task向量中的第一个任务,在 SMP 系统中,每个 CPU 都有一个空闲进程,并且您可能有多个空闲 CPU。此外,每个 CPU 都有一个当前进程,因此 SMP 系统必须跟踪每个处理器的当前进程和空闲进程。

在 SMP 系统中,每个进程的task_struct都包含它当前正在运行的处理器编号(processor)以及它上次运行的处理器编号(last_processor)。进程每次被选择运行时,没有理由不能在不同的 CPU 上运行,但 Linux 可以使用processor_mask将进程限制为一个或多个处理器。如果设置了位 N,则此进程可以在处理器 N 上运行。当调度器选择一个新的进程运行时,它不会考虑一个在其processor_mask中没有为当前处理器编号设置适当位的进程。调度器还稍微优先考虑上次在当前处理器上运行的进程,因为将进程移动到不同的处理器通常会产生性能开销。

4.4  文件


图 4.1:进程的文件

4.1 显示,对于系统中的每个进程,有两个数据结构描述特定于文件系统的信息。第一个是fs_struct

,它包含指向此进程的 VFS inode 和其umask的指针。umask是新文件将以其创建的默认模式,可以通过系统调用更改。

第二个数据结构是files_struct,它包含有关此进程当前正在使用的所有文件的信息。程序从标准输入读取,并写入标准输出。任何错误消息都应转到标准错误。这些可以是文件、终端输入/输出或真实设备,但就程序而言,它们都被视为文件。每个文件都有自己的描述符,并且files_struct包含指向最多 256 个file数据结构的指针,每个数据结构描述此进程正在使用的文件。f_mode字段描述文件以何种模式创建;只读、读写或只写。f_pos保存文件中下一个读取或写入操作将发生的位置。f_inode指向描述文件的 VFS inode,并且f_ops是指向例程地址向量的指针;每个向量对应于您可能希望对文件执行的函数。例如,有一个写入数据函数。这种接口的抽象非常强大,并允许 Linux 支持各种文件类型。在 Linux 中,管道是使用这种机制实现的,我们稍后将看到。

每次打开文件时,都会使用file中空闲的files_struct指针之一来指向新的file结构。Linux 进程期望在启动时打开三个文件描述符。这些描述符被称为标准输入标准输出标准错误,它们通常从创建它们的父进程继承。对文件的所有访问都通过标准的系统调用,这些调用传递或返回文件描述符。这些描述符是进程的fd向量的索引,因此标准输入标准输出标准错误的文件描述符分别为 0、1 和 2。每次访问文件都使用file数据结构的文件操作例程以及 VFS inode 来实现其需求。

4.5  虚拟内存

进程的虚拟内存包含来自多个来源的可执行代码和数据。首先是加载的程序映像;例如,像ls这样的命令。像所有可执行映像一样,此命令由可执行代码和数据组成。映像文件包含将可执行代码和关联的程序数据加载到进程虚拟内存中所需的所有信息。其次,进程可以分配(虚拟)内存以在处理期间使用,例如,保存它正在读取的文件内容。这种新分配的虚拟内存需要链接到进程现有的虚拟内存中,以便可以使用它。第三,Linux 进程使用常用代码库,例如文件处理例程。每个进程都拥有自己的库副本是没有意义的,Linux 使用可以由多个正在运行的进程同时使用的共享库。来自这些共享库的代码和数据必须链接到此进程的虚拟地址空间,以及共享该库的其他进程的虚拟地址空间。

在任何给定的时间段内,进程都不会使用其虚拟内存中包含的所有代码和数据。它可能包含仅在某些情况下使用的代码,例如在初始化期间或处理特定事件期间。它可能只使用了其共享库中的某些例程。将所有这些代码和数据加载到物理内存中是浪费的,因为它们将在那里未使用。将这种浪费乘以系统中的进程数量,系统将非常低效地运行。相反,Linux 使用一种称为按需分页的技术,其中进程的虚拟内存仅在进程尝试使用它时才被带入物理内存。因此,Linux 内核不是立即将代码和数据加载到物理内存中,而是更改进程的页表,将虚拟区域标记为存在但不在内存中。当进程尝试访问代码或数据时,系统硬件将生成页错误,并将控制权交给 Linux 内核来修复问题。因此,对于进程地址空间中的每个虚拟内存区域,Linux 都需要知道该虚拟内存来自何处,以及如何将其放入内存中,以便它可以修复这些页错误。


图 4.2:进程的虚拟内存

Linux 内核需要管理所有这些虚拟内存区域,并且每个进程的虚拟内存内容都由一个mm_struct数据结构描述,该数据结构从其task_struct指向。进程的mm_struct

数据结构还包含有关加载的可执行映像的信息以及指向进程页表的指针。它包含指向vm_area_struct数据结构列表的指针,每个

代表此进程中的虚拟内存区域。

此链表按照虚拟内存升序排列,图  4.2 展示了一个简单进程的虚拟内存布局以及管理它的内核数据结构。由于虚拟内存的这些区域来自多个源,Linux 通过拥有以下内容来抽象接口vm_area_struct指向一组虚拟内存处理例程(通过vm_ops)。这样,无论管理该内存的底层服务有何不同,都可以以一致的方式处理进程的所有虚拟内存。例如,有一个例程会在进程尝试访问内存但该内存不存在时被调用,这就是页面错误的处理方式。

进程的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。 当然,没有什么是免费的,将新的vm_area_struct插入到这棵树中需要额外的处理时间。

当进程分配虚拟内存时,Linux 实际上并没有为该进程保留物理内存。 相反,它通过创建一个新的vm_area_struct数据结构来描述虚拟内存。 这链接到进程的虚拟内存列表中。 当进程尝试写入该新虚拟内存区域内的虚拟地址时,系统将发生页面错误。 处理器将尝试解码虚拟地址,但由于此内存没有任何页表条目,因此它将放弃并引发页面错误异常,从而让 Linux 内核来修复问题。 Linux 会查看引用的虚拟地址是否在当前进程的虚拟地址空间中。 如果是,Linux 会创建适当的 PTE 并为此进程分配一个物理内存页。 代码或数据可能需要从文件系统或交换磁盘带入该物理页。 然后可以在导致页面错误的指令处重新启动进程,并且这次由于内存物理存在,它可以继续执行。

4.6  创建进程

当系统启动时,它在内核模式下运行,并且在某种意义上,只有一个进程,即初始进程。 像所有进程一样,初始进程具有由堆栈、寄存器等表示的机器状态。 当系统中创建和运行其他进程时,这些将保存在初始进程的task_struct数据结构中。 在系统初始化结束时,初始进程启动一个内核线程(称为init),然后在一个空闲循环中无所事事。 每当没有其他事情可做时,调度程序将运行此空闲进程。 空闲进程的task_struct是唯一一个不是动态分配的,它是在内核构建时静态定义的,并且非常令人困惑地被称为init_task.

init内核线程或进程的进程标识符为 1,因为它是系统的第一个真实进程。 它执行一些系统的初始设置(例如打开系统控制台和挂载根文件系统),然后执行系统初始化程序。 这是/etc/init, /bin/init/sbin/init之一,具体取决于您的系统。init程序使用/etc/inittab作为脚本文件在系统中创建新进程。 这些新进程本身可能会继续创建新进程。 例如,当用户尝试登录时,getty进程可能会创建login进程。 系统中的所有进程都源自init内核线程。

新进程通过克隆旧进程,或者更确切地说,通过克隆当前进程来创建。 新任务通过系统调用(forkclone)创建

,克隆发生在内核模式下的内核中。 在系统调用结束时,有一个新进程等待调度程序选择它后运行。 从系统的物理内存中分配一个新的task_struct数据结构,其中包含一个或多个物理页用于克隆进程的堆栈(用户和内核)。 可能会创建一个新的进程标识符,该标识符在系统中的进程标识符集中是唯一的。 但是,克隆进程保留其父进程的进程标识符是完全合理的。 新的task_struct被输入到task向量中,旧的(current)进程的task_struct内容被复制到克隆的task_struct.

中。count在克隆进程时,Linux 允许两个进程共享资源,而不是拥有两个单独的副本。 这适用于进程的文件、信号处理程序和虚拟内存。 当资源要共享时,它们各自的task_struct字段会递增,以便 Linux 在两个进程都完成使用这些资源之前不会释放这些资源。 因此,例如,如果克隆的进程要共享虚拟内存,则其mm_struct将包含指向原始进程的mm_struct的指针,并且该count

字段已递增以显示当前共享它的进程数。vm_area_struct克隆进程的虚拟内存相当棘手。 必须生成一组新的mm_struct数据结构,以及它们拥有的vm_area_struct数据结构和克隆进程的页表。 此时不会复制进程的任何虚拟内存。 对于某些虚拟内存会在物理内存中,某些虚拟内存会在进程当前正在执行的可执行映像中,甚至有些虚拟内存会在交换文件中,这将是一项相当困难且耗时的任务。 相反,Linux 使用一种称为“写时复制”的技术,这意味着虚拟内存只有在两个进程之一尝试写入它时才会被复制。 任何未写入的虚拟内存,即使可以写入,也将在两个进程之间共享而不会发生任何危害。 只读内存(例如可执行代码)将始终共享。 为了使“写时复制”工作,可写区域的页表条目标记为只读,并且描述它们的

数据结构标记为“写时复制”。 当进程之一尝试写入此虚拟内存时,将发生页面错误。 此时,Linux 将复制内存并修复两个进程的页表和虚拟内存数据结构。

4.7  时间和定时器内核会跟踪进程的创建时间以及在其生命周期内消耗的 CPU 时间。每个时钟滴答,内核都会更新当前进程在系统模式和用户模式下花费的时间量,单位为内核会跟踪进程的创建时间以及进程在其生命周期内消耗的 CPU 时间。 每个时钟节拍,内核都会更新当前进程在系统模式和用户模式下花费的时间量。

除了这些记帐定时器外,Linux 还支持特定于进程的间隔定时器。

进程可以使用这些定时器在每次定时器到期时向自身发送各种信号。 支持三种间隔定时器

实际
定时器在实际时间中滴答作响,当定时器到期时,进程会收到SIGALRM信号。
虚拟
此定时器仅在进程运行时滴答作响,当它到期时,它会发送SIGVTALRM信号。
配置文件
此定时器在进程运行时以及系统代表进程自身执行时都会滴答作响。SIGPROF在到期时发出信号。

一个或所有间隔定时器可能正在运行,Linux 将所有必要的信息保存在进程的task_struct数据结构中。 可以进行系统调用来设置这些间隔定时器并启动、停止和读取它们的当前值。 虚拟和配置文件定时器的处理方式相同。

每个时钟节拍,当前进程的间隔定时器都会递减,如果它们已过期,则会发送相应的信号。

实时间隔定时器略有不同,对于这些定时器,Linux 使用章节  kernel-chapter 中描述的定时器机制。 每个进程都有自己的timer_list数据结构,当实时间隔定时器运行时,它会排队到系统定时器列表上。 当定时器到期时,定时器下半部处理程序会将其从队列中移除并调用间隔定时器处理程序。

这会生成SIGALRM信号并重新启动间隔定时器,将其添加回系统定时器队列中。

4.8  执行程序

在 Linux 中,与 Unix TM 中一样,程序和命令通常由命令解释器执行。 命令解释器是一个用户进程,就像任何其他进程一样,被称为shell 2.

。 Linux 中有很多 shell,其中一些最受欢迎的是sh, bashtcsh。 除了一些内置命令(例如cdpwd)外,命令是一个可执行二进制文件。 对于输入的每个命令,shell 都会在进程的搜索路径(保存在PATH环境变量中)中搜索具有匹配名称的可执行映像。 如果找到该文件,则加载并执行它。 shell 使用上面描述的 fork 机制克隆自身,然后新的子进程用刚刚找到的可执行映像文件的内容替换它正在执行的二进制映像(shell)。 通常,shell 会等待命令完成,或者更确切地说,等待子进程退出。 您可以通过按下control-Z来将子进程推送到后台,从而导致向子进程发送SIGSTOP信号来停止它,从而使 shell 再次运行。 然后,您可以使用 shell 命令bg将其推送到后台,shell 向其发送SIGCONT信号以重新启动它,它将保持在那里,直到它结束或需要执行终端输入或输出。

可执行文件可以有多种格式,甚至可以是脚本文件。 脚本文件必须被识别,并且运行相应的解释器来处理它们; 例如/bin/sh解释 shell 脚本。 可执行目标文件包含可执行代码和数据,以及足够的信息,以允许操作系统将它们加载到内存中并执行它们。 Linux 最常用的目标文件格式是 ELF,但从理论上讲,Linux 足够灵活,可以处理几乎任何目标文件格式。


图 4.3:注册的二进制格式

与文件系统一样,Linux 支持的二进制格式要么在内核构建时内置到内核中,要么可以作为模块加载。 内核保留支持的二进制格式列表(参见图  4.3),当尝试执行文件时,会依次尝试每种二进制格式,直到有一种格式起作用。

常用的 Linux 二进制格式有a.outELF。 可执行文件不必完全读取到内存中,而是使用一种称为按需加载的技术。 当进程使用可执行映像的每个部分时,它会被带入内存。 映像未使用的部分可能会从内存中丢弃。

4.8.1  ELF

ELF(可执行和可链接格式)对象文件格式由 Unix 系统实验室设计,现在已牢固地确立为 Linux 中最常用的格式。 虽然与其他对象文件格式(例如ECOFFa.out)相比,ELF 的性能开销略有增加,但 ELF 被认为更灵活。 ELF 可执行文件包含可执行代码(有时称为文本)和数据。 可执行映像中的表描述了程序应如何放置到进程的虚拟内存中。 静态链接映像由链接器(ld)或链接编辑器构建为包含运行此映像所需的所有代码和数据的单个映像。 该映像还指定了该映像在内存中的布局以及映像中要执行的第一个代码的地址。


图 4.4:ELF 可执行文件格式

图  4.4 显示了静态链接的 ELF 可执行映像的布局。

这是一个简单的 C 程序,它打印“hello world”然后退出。 标头将其描述为具有两个物理标头的 ELF 映像(e_phnum为 2),从映像文件开头偏移 52 字节(e_phoff)开始。 第一个物理标头描述了映像中的可执行代码。 它位于虚拟地址 0x8048000,并且有 65532 字节。 这是因为它是一个静态链接的映像,其中包含printf()调用输出“hello world”的所有库代码。 映像的入口点(程序的第一个指令)不在映像的开头,而是在虚拟地址 0x8048090e_entry)。 代码紧跟在第二个物理标头之后开始。 此物理标头描述了程序的数据,并将加载到虚拟内存中的地址 0x8059BB8。 此数据既是可读的又是可写的。 您会注意到文件中的数据大小为 2200 字节(p_filesz),而其在内存中的大小为 4248 字节。 这是因为前 2200 字节包含预初始化的数据,而后 2048 字节包含将由执行代码初始化的数据。

当 Linux 将 ELF 可执行映像加载到进程的虚拟地址空间时,它实际上并没有加载该映像。

它设置了虚拟内存数据结构、进程的vm_area_struct树及其页表。 当程序执行时,页面错误将导致程序的代码和数据被提取到物理内存中。 程序的未使用部分永远不会加载到内存中。 一旦 ELF 二进制格式加载器确信该映像是有效的 ELF 可执行映像,它就会从进程的虚拟内存中刷新进程的当前可执行映像。 由于此进程是克隆映像(所有进程都是),因此此旧映像是父进程正在执行的程序,例如命令解释器 shell,例如bash。 刷新旧的可执行映像会丢弃旧的虚拟内存数据结构并重置进程的页表。 它还会清除任何已设置的信号处理程序并关闭任何已打开的文件。 在刷新结束时,进程已准备好用于新的可执行映像。 无论可执行映像是什么格式,相同的信息都会在进程的mm_struct中设置。 有指向映像的代码和数据开始和结束的指针。 这些值是在读取 ELF 可执行映像物理标头时找到的,并且它们描述的程序部分会映射到进程的虚拟地址空间中。 这也是设置vm_area_struct数据结构并修改进程的页表时。mm_struct数据结构还包含指向要传递给程序的参数以及此进程的环境变量的指针。

ELF 共享库

另一方面,动态链接的映像不包含运行所需的所有代码和数据。 其中一部分保存在共享库中,这些共享库在运行时链接到映像中。 当共享库在运行时链接到映像中时,动态链接器也会使用 ELF 共享库的表。 Linux 使用多个动态链接器,ld.so.1, libc.so.1ld-linux.so.1,都可以在/lib中找到。 这些库包含常用代码,例如语言子例程。 如果没有动态链接,所有程序都需要它们自己的这些库副本,并且需要更多的磁盘空间和虚拟内存。 在动态链接中,ELF 映像的表中包含有关每个引用的库例程的信息。 该信息指示动态链接器如何定位库例程并将其链接到程序的地址空间中。

审阅说明 我是否需要在此处提供更多详细信息,工作示例?

4.8.2  脚本文件

脚本文件是需要解释器才能运行的可执行文件。 Linux 有各种各样的解释器可用; 例如wish, perl和命令 shell,例如tcsh。 Linux 使用标准的 Unux TM 约定,即脚本文件的第一行包含解释器的名称。 因此,一个典型的脚本文件将以

#!/usr/bin/wish

开头。 脚本二进制加载器尝试查找脚本的解释器。

它通过尝试打开脚本文件第一行中命名的可执行文件来完成此操作。 如果它可以打开它,它将获得指向其 VFS inode 的指针,并且它可以继续让它解释脚本文件。 脚本文件的名称变为参数零(第一个参数),所有其他参数都向上移动一位(原始的第一个参数变为新的第二个参数,依此类推)。 加载解释器的方式与 Linux 加载其所有可执行文件的方式相同。 Linux 依次尝试每种二进制格式,直到有一种格式起作用。 这意味着从理论上讲,您可以堆叠多个解释器和二进制格式,从而使 Linux 二进制格式处理程序成为非常灵活的软件。


脚注

1 审阅说明 我省略了 SWAPPING,因为它似乎未使用。

2 将内核想象成坚果,内核是中间的可食用部分,而 shell 围绕着它,提供了一个接口。


文件由 TEX 翻译而来,使用 TTH,版本 1.0。
章节顶部 目录 显示框架 无框架
� 1996-1999 David A Rusling 版权声明