进程模型
进程模型
Process Elements
进程 ID(或 PID)由操作系统分配,并且对于每个正在运行的进程都是唯一的。
Process Hierarchy
尽管操作系统可以同时运行多个进程,但实际上它只能直接启动一个称为 init(初始缩写)进程的进程。这不是一个特别特殊的过程,除了它的 PID 始终为 0 并且将一直运行。所有其他过程都可以视为此初始过程的子过程。进程与其他进程一样都有一棵家谱。每个进程都有一个父进程,并且可以有许多同级,它们是同一父进程创建的进程。当然,子进程可以创造更多的子进程,依此类推。
1 init-+-apmd
|-atd
|-cron
...
5 |-dhclient
|-firefox-bin-+-firefox-bin---2*[firefox-bin]
| |-java_vm---java_vm---13*[java_vm]
| `-swf_play
文件描述符(File Descriptors)
我们之前了解了 stdin,stdout 和 stderr;给每个进程的默认文件。您将记住,这些文件始终具有相同的文件描述符号(分别为 0,1,2)。因此,内核为每个进程单独保存文件描述符。文件描述符也具有权限。例如,您可能能够从文件中读取但无法写入文件。打开文件后,操作系统会在文件描述符中保留对该文件的进程权限记录,并且不允许进程执行不应执行的任何操作。
Registers
处理器实际上对寄存器中的值执行一般简单的操作。这些值被读取(或写入)到内存中,每个进程都分配有内核跟踪的内存。
因此,方程式的另一面是跟踪寄存器。当当前正在运行的进程放弃处理器以便其他进程可以运行时,它需要保存其当前状态。同样,当进程有更多时间在 CPU 上运行时,我们需要能够恢复此状态。为此,操作系统需要将 CPU 寄存器的副本存储到内存中。当该进程再次运行时,操作系统会将寄存器值从内存中复制回 CPU 寄存器中,而该进程将立即从中断处返回。
内核状态(Kernel State)
在内部,内核需要跟踪每个进程的许多元素。操作系统要跟踪的另一个重要元素是进程状态。如果该进程当前正在运行,则使其处于运行状态是有意义的。但是,如果进程请求从磁盘读取文件,则从内存层次结构中我们知道这可能会花费大量时间。该进程应该放弃其当前执行以允许另一个进程运行,但是内核不必让该进程再次运行,直到磁盘中的数据在内存中可用为止。因此,它可以将进程标记为磁盘等待(或类似),直到数据准备就绪为止。
一些过程比其他过程更重要,并且具有更高的优先级。请参阅下面有关调度程序的讨论。内核可以保留有关每个进程行为的统计信息,这有助于其决定进程的行为。例如,它主要是从磁盘读取还是主要是 CPU 密集型操作。
内存(Memory)
所有程序代码以及变量和任何其他分配的存储都存储在该存储器中。内存的一部分可以在进程之间共享(称为共享内存)。在较早版本的操作系统中执行原始操作后,通常会看到称为系统五共享内存(SysV SHM)的信息。进程可以利用的另一个重要概念是将磁盘上的文件映射到内存。这意味着不必打开文件并使用诸如 read()
和 write()
之类的命令,文件看起来就像是其他任何类型的 RAM。映射区域具有诸如读取,写入和执行之类的权限,需要对其进行跟踪。众所周知,维护安全性和稳定性是操作系统的工作,因此它需要检查进程是否尝试写入只读区域并返回错误。
一个过程可以进一步分为代码和数据部分。程序代码和数据应分开保存,因为它们需要与操作系统不同的权限,并且分开可以促进代码共享(如您稍后所见)。操作系统需要授予程序代码读取和执行的权限,但通常不予写入。另一方面,数据(变量)需要读写权限,但不能执行。
The Stack
进程的另一个非常重要的部分是称为栈的内存区域。这可以视为流程数据部分的一部分,并且与任何程序的执行都密切相关。栈是通用的数据结构,其工作方式与一堆板块完全相同。您可以推动一个元素(将一个元素放在一叠元素的顶部),然后成为顶部元素,或者您可以弹出一个元素(取下元素,露出前一个元素)。
栈是函数调用的基础。每次调用一个函数都会得到一个新的栈框架。这是一个内存区域,通常至少包含完成后要返回的地址,函数的输入参数和局部变量的空间。按照惯例,栈通常会变小。这意味着栈从内存中的高地址开始,然后逐渐降低。
我们可以看到拥有栈如何带来函数的许多功能:
-
每个函数都有其输入参数的副本。这是因为为每个函数分配了一个新的栈帧,其参数位于新的内存区域中。
-
这就是为什么在函数内部定义的变量无法被其他函数看到的原因。全局变量(可以通过任何函数看到)都保存在数据存储器的单独区域中。
-
这有助于递归调用。这意味着一个函数可以自由地再次调用自身,因为将为其所有局部变量创建一个新的栈框架。
-
每个帧都包含要返回的地址。C 仅允许从函数返回单个值,因此按照惯例,该值将在指定的寄存器中而不是栈中返回给调用函数。
-
由于每一帧都引用了它之前的那一帧,因此调试器可以向后“遍历”指针,跟随指针到达栈。由此可以生成栈跟踪,该跟踪向您显示调用该函数的所有函数。这对于调试非常有用。
-
您可以看到函数的工作方式完全适合栈的性质。任何函数都可以调用任何其他函数,然后成为其他函数(放在栈顶)。最终,该函数将返回到调用它的函数(将自身移出栈)。
-
栈的确会使调用函数变慢,因为必须将值移出寄存器并移入内存。有些体系结构允许参数直接在寄存器中传递。但是,为了保持每个函数获得每个参数的唯一副本的语义,寄存器必须旋转。
-
您可能听说过术语栈溢出。这是通过传递伪造的值来入侵系统的一种常见方法。如果您是程序员,则可以接受对栈变量的任意输入(例如,从键盘或通过网络读取),则需要明确说明数据的大小。允许不检查任何数量的数据将仅覆盖内存。通常,这会导致崩溃,但是有些人意识到,如果函数写满了足以在栈帧的返回地址部分中放置特定值的内存,则函数完成时将返回而不是返回正确的位置(从),他们可以使其返回到刚发送的数据中。如果该数据包含会破坏系统的二进制可执行代码(例如,以 root 权限为用户启动终端),则说明您的计算机已受到威胁。发生这种情况是因为栈向下增长,但是数据是“向上”读取的(即从较低地址到较高地址)。有几种解决方法:首先,作为程序员,您必须确保始终检查要接收到的数据量。操作系统可以通过确保栈被标记为不可执行来帮助程序员避免这种情况。也就是说,即使恶意用户试图将某些代码传递到您的程序中,处理器也不会运行任何代码。现代体系结构和操作系统支持此功能。
-
栈最终由编译器管理,因为它负责生成程序代码。对于操作系统来说,栈就像该进程的任何其他内存区域一样。
为了跟踪栈的当前增长,硬件将寄存器定义为栈指针。编译器(或编程器,在用汇编器编写时)使用该寄存器来跟踪栈的当前顶部。
1 $ cat sp.c
void function(void)
{
int i = 100;
5 int j = 200;
int k = 300;
}
$ gcc -fomit-frame-pointer -S sp.c
10
$ cat sp.s
.file "sp.c"
.text
.globl function
15 .type function, @function
function:
subl $16, %esp
movl $100, 4(%esp)
movl $200, 8(%esp)
20 movl $300, 12(%esp)
addl $16, %esp
ret
.size function, .-function
.ident "GCC: (GNU) 4.0.2 20050806 (prerelease) (Debian 4.0.1-4)"
25 .section .note.GNU-stack,"",@progbits
上面我们展示了一个在栈上分配三个变量的简单函数。该反汇编说明了在 x86 体系结构上使用栈指针。首先,我们在栈上为局部变量分配一些空间。由于栈变小,我们从栈指针中保存的值中减去。值 16 是一个足以容纳我们的局部变量的值,但可能不完全是所需的大小(例如,对于 3 个 4 字节的 int 值,我们实际上只需要 12 个字节,而不是 16 个)就可以保持内存中栈的对齐 编译器要求的特定边界。
然后,我们将这些值移动到栈存储器中(并在实函数中使用它们)。最后,在返回父函数之前,我们通过将栈指针移回开始之前的位置来“弹出”栈中的值。
The Heap
堆是由进程管理的用于动态分配内存的内存区域。这适用于在编译时不知道其内存要求的变量。堆的底部称为 brk,因此被称为对其进行修改的系统调用。通过使用 brk 调用向下扩展区域,进程可以请求内核分配更多的内存供其使用。
堆通常由 malloc 库调用管理。通过允许程序员简单地分配和释放(通过 free 调用)堆内存,这使程序员易于管理堆。malloc 可以使用诸如伙伴分配器之类的方案来管理用户的堆内存。malloc 在分配方面也可以更聪明,并且可以使用匿名 mmap 来获得额外的进程内存。在这里,不是将文件映射到进程内存,而是直接映射系统 RAM 的区域。这样可以更有效。由于正确管理内存的复杂性,对于任何现代程序来说,都有理由直接调用 brk 是非常罕见的。
内存布局
如我们所见,一个进程分配了较小的内存区域,每个区域都有特定的用途。上面给出了内核如何在内存中安排进程的示例。从顶部开始,内核会在进程的顶部为其自身保留一些内存(我们通过虚拟内存了解如何在所有进程之间实际共享此内存)。在其下方是用于映射文件和库的空间。在堆栈的下面,在堆栈的下面。底部是程序映像,是从磁盘上的可执行文件加载的。在后面的章节中,我们将仔细研究加载数据的过程。
进程上下文
进程是操作系统对一个正在运行的程序的一种抽象,在一个系统上可以同时运行多个进程,而每个进程都好像在独占地使用硬件。所谓的并发运行,则是说一个进程的指令和另一个进程的指令是交错执行的。无论是在单核还是多核系统中,可以通过处理器在进程间切换,来实现单个 CPU 看上去像是在并发地执行多个进程。操作系统实现这种交错执行的机制称为上下文切换。
操作系统保持跟踪进程运行所需的所有状态信息。这种状态,也就是上下文,它包括许多信息,例如 PC 和寄存器文件的当前值,以及主存的内容。在任何一个时刻,单处理器系统都只能执行一个进程的代码。当操作系统决定要把控制权从当前进程转移到某个新进程时,就会进行上下文切换,即保存当前进程的上下文、恢复新进程的上下文,然后将控制权传递到新进程。新进程就会从上次停止的地方开始。
在《Linux-Notes/虚拟存储管理器》一节中,我们介绍过它为每个进程提供了一个假象,即每个进程都在独占地使用主存。每个进程看到的是一致的存储器,称为虚拟地址空间。其虚拟地址空间最上面的区域是为操作系统中的代码和数据保留的,这对所有进程来说都是一样的;地址空间的底部区域存放用户进程定义的代码和数据。
-
程序代码和数据,对于所有的进程来说,代码是从同一固定地址开始,直接按照可执行目标文件的内容初始化。
-
堆,代码和数据区后紧随着的是运行时堆。代码和数据区是在进程一开始运行时就被规定了大小,与此不同,当调用如 malloc 和 free 这样的 C 标准库函数时,堆可以在运行时动态地扩展和收缩。
-
共享库:大约在地址空间的中间部分是一块用来存放像 C 标准库和数学库这样共享库的代码和数据的区域。
-
栈,位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数调用。和堆一样,用户栈在程序执行期间可以动态地扩展和收缩。
-
内核虚拟存储器:内核总是驻留在内存中,是操作系统的一部分。地址空间顶部的区域是为内核保留的,不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数。