爱生活,爱阅读
分类: LINUX
2012-09-08 09:38:14
我们讨论的背景是:运行在AMD Opteron硬件上,采用64位GNU/Linux的AICT Linux集群。如果你对于当前的材料有什么意见或者问题,请发送邮件到:。
内容
在Linux环境下,所有程序运行在虚拟内存环境(Virtual memory environment)中。如果一个C程序员打印指针的值(实际中根本不需要),输出的结果将是虚拟内存地址。在Fortran中,尽管指针不是该语言标准的特征,虚拟地址隐含在每一个变量引用(variable reference)和子程序调用(subroutine call)中。
当然,程序的代码与数据实际上驻留(reside in)在真实的物理内存(real physical memory)中。因此,每个虚拟内存地址均被操作系统通过一个叫做页表(page table)(参见表1)的结构映射到物理内存地址。例如,为了取回(retrieve)或者更新变量的值,程序必须首先通过查找页表(page table)中与之相关的虚拟内存地址,以获取该变量的物理内存地址。幸运的是,该步骤已被Linux透明的处理。Linux内核中的优化代码(Optimized code)以及CPU的特殊电路使得该操作相当有效率。尽管如此,你还是应该清楚为什么要这样做(you may wonder why do it at all)。
Fig. 1 Mapping virtual memory to real memory through the page table.
在一个像Linux这样的、通用的、多用户计算环境中,所有程序必须共享这些可用的、有限的物理内存。如果没有虚拟内存,每个程序必须知道它的“邻居”(即,其它程序or进程)的活动。例如,有两个独立的程序,二者均试图在同一时刻,对同一片空闲内存执行直接访问。为了避免冲突,程序将不得不参与到场景同步(in a synchronization scheme),而这将导致过度复杂的代码。取而代之,采用虚拟内存之后,所有低级(low-level)的内存管理均由操作系统负责。因此,Linux内核为每一个程序维护一个私有的页表(a private page table),这样就给程序一种假象,那就是该程序在计算机上独立运行。当两个程序同时引用相同的虚拟内存地址时,内核保证每个程序使用的是不同的实际内存地址,如表2中所示:
Fig. 2 Concurrent programs.
为了便于理解,可以将虚拟内存视作一种用于隔离内存硬件的抽象层。程序通常运行在该层之上,而不用关注(be aware of)任何特定内存的实现细节。
在下面的部分中,我们将熟悉与虚拟内存相关的术语与概念(concepts and terminology)。尽管我们聚焦于AICT Linux集群,但是下面描述的原则适用于大多数其他计算环境。
程序的页表(page table)是该程序执行时的上下文环境的组成部分。其它部分包括当前的工作目录、打开的文件、环境变量等等。它们构成了我们所说的进程或任务。
“程序”和“进程”经常交替的使用。但是有一个区别。程序通常与用一种编程语言编写的源代码关联。例如,我们会说Fortran程序或者C程序。它涉及到磁盘上编译的源代码或者可执行文件。而进程则是操作系统的概念,表示运行中的程序。
内核为每个进程赋予了独一无二的标识号(identification number),该号码即为进程标识(process ID,pid),并使用该ID来索引存储该进程有用信息的各种数据结构。进程可以使用编程接口获得该进程标识以及其它进程相关的属性。该接口是标准的C接口,并被Fortran扩展支持。例如,getuid()获取调用进程的用户标识(ID,uid)。
在shell命令提示符下,交互地执行程序是创建新进程的常见方式。该新进程从shell进程真正的产生(literally spawned)或者派生(fork)出来。在这种方式下,进程的分层结构就建立起来:shell是父进程,新进程是子进程。自然地,子进程继承了父进程的许多属性,比如当前工作目录,环境变量。值得注意的是,内存资源的限制(memory resource limits)也由父进程传递给子进程。更多信息参见后面部分。
程序包含(comprise)可执行语句和数据声明。每种数据都有一个称作存储类别的属性,该属性反映了程序执行时数据的生存周期。另一个相关的属性是可见范围(scope),它描述了数据的可见范围。变量的“存储类别”与“可见范围”在变量的声明处即被设定,并决定了其在虚拟内存中的存储位置。
在C语言中,在函数之外声明的数据均为全局变量,拥有整个进程的生命周期。尽管可能已经赋予了初值,但是全局数据通常是未初始化的。同时,函数内部的数据声明,包括main()函数,均是局部变量,拥有临时的生命周期。可以通过在局部变量的声明之前添加static关键字,而使其拥有整个生命周期,这样静态局部变量就能够在函数调用之间获取其值。
在Fortran中,所有的数据均是局部变量,除非它们在一个模块中声明或者在一个命名的公用块(named common block)出现。此外,通过向模块变量(module variable)赋予SAVE属性,以及通过SAVE语句引用一个公共模块,这些变量将会有效地获得全局范围与静态的生命周期。在PGI Fortran 编译器pgf77,pgf95中,本地的“明显成型的数组”(explicit-shaped array)拥有静态变量的存储类别与范围,而不是自动的局部变量存储类别。然而,此类数据组的内容在子程序调用过程中是无效的,除非它们通过SAVE属性声明,或者在一个SAVE语句中出现。
注意:明显成型的数组(explicit-shaped array)在不同的编译器中处理方式是不同的。与pgf95不同,IBM的xlf95编译器将之视作自动变量。如果需要,这种语义可以通过编译器选项进行更改。
程序大小编译器将程序中可执行语句翻译成CPU指令,静态数据翻译为特定的机器(machine-specific)规格(specification)。为了创建一个可执行文件,系统链接器将指令与数据整合为不同的分段(segment)。所有的指令进入传统上被称为代码(text)段中,不幸的是,该名称给人的印象是该段包含源代码,而事实上并没有。同时,数据被安排在两个分段中。一个叫做数据段(data),用于保存初始化后的静态数据以及字符常量;另一个称作bss段,用于保存未初始化的静态数据。Bss曾经代表“block started from symbol”,这是一种大型机(mainfram)汇编语言指令。但是,该条目现在已经没有意义。
考虑如下的简单的C语言程序,以及等效的Fortran90/95版本,该程序中的主要数据组成是200兆字节未初始化的静态数组。
点击(此处)折叠或打开
点击(此处)折叠或打开
点击(此处)折叠或打开
点击(此处)折叠或打开
按照上述过程进行编译将生成一个ELF(Executable and Linking Format)格式的可执行程序文件。运行size命令来抽取出ELF文件中的代码、数据,bss段的大小。
在上述两种情况下,bss段的确是200兆字节(加上一些管理信息)。该程序中仅有两个不占空间的字符串和一个数字常量填入了数据段(data segment)中。很明显,编译器为这种矛盾的结果负责。
此外,因为ELF 文件包含了程序的所有指令以及所有的初始化数据,代码段(text segment)与数据段(data segment)总和可以接近但不能超越硬盘上的文件大小。为bss段保留空间是没有必要的,因为没有什么数据需要保存。这在例子中已经得到确认。
注意,数据段与bss段经常被称作数据段,但这很少导致混乱。
内存映射当执行ELF文件时,代码(text)与两个数据段(data segment)被加载到不同的虚拟内存之中。按照惯例,代码段占用最低的地址空间,然后是数据段。然后为每个分段赋予合理的访问权限。通常情况下,代码段是可读-可执行权限,而数据段是可读-可写。一个经典的进程的内存映射情况在图3中描述。
Fig. 3 Process memory map showing text, data, and bss segments.
虚拟地址空间从图表中底部的0开始,然后增加到顶部的512G,512G之上的地址空间保留供Linux内核使用。这是对于AMD64硬件的明确规格。其它架构可能有不同的限制。
尽管进程的大小(代码+数据+bss)在程序编译的时候已经确立,并且在执行过程中保持不变。但是进程可以在运行时扩展到虚拟内存中未被占用的空间(expand into the unoccupied portion of virtual memory at runtime),在C语言程序中通过调用malloc(),在Fortran90/95中通过ALLOCATABLE数组。在Fortran77中,通过非标准的扩展,相似的特性仍然可用。这种动态申请的内存在数据段之上的堆段(heap segment)中。参见图表4。
Fig. 4 Memory
map with heap segment included.
Data and bss segments are shown as one.
a and bss segments are shown as one.
代码段(text)、数据段(data+bss)以及堆(heap)均通过页表(page table)映射到物理内存中。该图表明,堆段在内存申请以及释放过程中放大与缩小。相应地,页表也会根据需要经常性的增加或删除记录。
过程化(procedural style)(与面向对象语言相反)的程序是通过调用子程序的逻辑层次组织起来的。通常情况下,每个子程序的调用均涉及调用者将参数传递给被调用者。另外,被调用者可能声明临时的局部变量。子程序参数以及自动的局部变量包含在虚拟地址空间的顶部,通常被称作栈分段(stack segment),或者简称栈(stack)。参见图5。
Fig. 5 Memory map showing the stack segment.
正常情况下,在操作系统调用C语言程序的main()函数或者Fortram语言中调用MAIN时,子程序的调用层次(The hierarchy of subroutine calls)已经开始了。当main返回给操作系统时候,该层次结构结束。这整个序列(The entire sequence)可以通过图表6中展示出来:
Fig. 6 Typical subroutine call graph.
在调用main之前,操作系统将用于调用程序的命令行成员压入到初始的空栈的栈顶(on top of the initially empty stack)。在C语言中main()函数可以通过argc 以及argv参数获取这些参数。而在Fortran的MAIN程序中,可以通过IARGC与GETARG子程序获取之,而这些是非标准(non-standard)的扩展。
在程序执行的开始,main将自动变量压入栈顶。这使得栈向着低地址处增长。然后,在调用func1之前,main将参数传递给函数func1。Main的自动变量,以及传递给func1的参数构成了一个栈帧。 随着程序调用曲线的延伸,栈帧在栈上不断累积。并随着调用的返回逐步减少。该过程在图表7中概述:
按照惯例,当前活跃的子程序仅仅能够引用传递过来的参数,局部变量,静态变量(加上任何全局可访问的数据)。例如,在func2执行时,它不能访问func1的局部变量,除非func1将局部变量以引用的方式将其作为参数传递给func2。
图8表明了内存映射、页表、与物理内存的关系。伴随着页表的扩展与减少,就会映射较多或者较少的物理内存。正如栈与堆一样,改变了大小。
假设每个页表记录由两个数组成,一个64位的数代表虚拟地址,而另一个64位的数代表的真实地址,那么每条记录占用了16字节。为每个字节进行映射是不切实际的。页表映射一块称作页(page)的虚拟内存而不是映射单独的字节。与之对应的物理内存的增加称作页帧(page frame)。
页的大小因架构而异,且通常可配置的。它总是2的若干次幂。AICT Linux 集群中的AMD Opteron处理器采用4K字节作为页面大小。相应地,一个200兆的进程的页表大小仅仅为800K字节。
超过128兆个页面构成了512G比特的虚拟地址空间。页的号码是连续的,称作VPN(virtual process number)。每个页表记录(each page table entry)将进程的虚拟页映射为物理内存的页帧(page frame)。这就是说从一个VPN的页到PFN的页帧。VPN是由虚拟内存地址除以页大小计算得到的。具体的字节则根据页的起始地址与偏移量获取。