Chinaunix首页 | 论坛 | 博客
  • 博客访问: 364801
  • 博文数量: 83
  • 博客积分: 5322
  • 博客等级: 中校
  • 技术积分: 1057
  • 用 户 组: 普通用户
  • 注册时间: 2010-04-11 11:27
个人简介

爱生活,爱阅读

文章分类

全部博文(83)

文章存档

2015年(1)

2013年(1)

2012年(80)

2011年(1)

分类: LINUX

2012-09-08 09:38:14

       我们讨论的背景是:运行在AMD Opteron硬件上,采用64GNU/LinuxAICT 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 编译器pgf77pgf95中,本地的“明显成型的数组”(explicit-shaped array)拥有静态变量的存储类别与范围,而不是自动的局部变量存储类别。然而,此类数据组的内容在子程序调用过程中是无效的,除非它们通过SAVE属性声明,或者在一个SAVE语句中出现。

注意:明显成型的数组(explicit-shaped array)在不同的编译器中处理方式是不同的。与pgf95不同,IBMxlf95编译器将之视作自动变量。如果需要,这种语义可以通过编译器选项进行更改。

程序大小

编译器将程序中可执行语句翻译成CPU指令,静态数据翻译为特定的机器(machine-specific)规格(specification)。为了创建一个可执行文件,系统链接器将指令与数据整合为不同的分段(segment)。所有的指令进入传统上被称为代码(text)段中,不幸的是,该名称给人的印象是该段包含源代码,而事实上并没有。同时,数据被安排在两个分段中。一个叫做数据段(data),用于保存初始化后的静态数据以及字符常量;另一个称作bss段,用于保存未初始化的静态数据。Bss曾经代表“block started from symbol”,这是一种大型机(mainfram)汇编语言指令。但是,该条目现在已经没有意义。

考虑如下的简单的C语言程序,以及等效的Fortran90/95版本,该程序中的主要数据组成是200兆字节未初始化的静态数组。

点击(此处)折叠或打开

  1. /**
  2. * simple.c
  3. */
  4. #include
  5. #include
  6. #define NSIZE 200000000
  7. char x[NSIZE];
  8. int
  9. main (void)
  10. {
  11. for (int i=0; i
  12. x[i] = 'x';
  13. printf ("done\n");
  14. exit (EXIT_SUCCESS);
  15. }

点击(此处)折叠或打开

  1. $ pgcc -c9x -o simple simple.c
  2. $ size simple
  3. text data bss dec hex filename
  4. 1226 560 200000032 200001818 bebc91a simple
  5. $ ls -l simple
  6. -rwxr-xr-x 1 esumbar uofa 7114 Nov 15 14:12 simple

点击(此处)折叠或打开

  1. !
  2. ! simple.f90
  3. !
  4. module globals
  5. implicit none
  6. integer, parameter :: NMAX = 200000000
  7. character(1), save :: x(NMAX)
  8. end module globals
  9. program simple
  10. use globals
  11. implicit none
  12. integer :: i
  13. do i = 1, NMAX
  14. x(i) = 'x'
  15. end do
  16. print*, "done"
  17. stop
  18. end program simple

点击(此处)折叠或打开

  1. $ pgf95 -o simple simple.f90
  2. $ size simple
  3. text data bss dec hex filename
  4. 77727 1088772 200003752 201170251 bfd9d4b simple
  5. $ ls -l simple
  6. -rwxr-xr-x 1 esumbar uofa 1201694 Nov 15 14:12 simple
  7. $ file simple
  8. simple: ELF 64-bit LSB executable, AMD x86-64, ...

按照上述过程进行编译将生成一个ELFExecutable 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开始,然后增加到顶部的512G512G之上的地址空间保留供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中展示出来:

  1. OS calls main
  2. main calls func1
  3. func1 calls func2
  4. func2 returns to func1
  5. func1 calls func3
  6. func3 returns to func1
  7. func1 returns to main
  8. main calls func4
  9. func4 returns to main
  10. main returns (exit status) to OS

Fig. 6 Typical subroutine call graph.

在调用main之前,操作系统将用于调用程序的命令行成员压入到初始的空栈的栈顶(on top of the initially empty stack)。在C语言中main()函数可以通过argc 以及argv参数获取这些参数。而在FortranMAIN程序中,可以通过IARGCGETARG子程序获取之,而这些是非标准(non-standard)的扩展。

在程序执行的开始,main将自动变量压入栈顶。这使得栈向着低地址处增长。然后,在调用func1之前,main将参数传递给函数func1Main的自动变量,以及传递给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是由虚拟内存地址除以页大小计算得到的。具体的字节则根据页的起始地址与偏移量获取。





阅读(1887) | 评论(0) | 转发(0) |
给主人留下些什么吧!~~