2013年11月16日

Linux进程地址空间再学习


再看linux进程地址空间

之前就有缕清进程地址空间的一丝想法,很拙劣的记录了两篇博客,这儿这儿,但是在尝试学习Linux进程地址空间的过程中,发现此问题并非一两篇博客可以描述的清楚,中间涉及到很多知识,并且再回头看这两篇博客,发现其中的内容更多的是一种堆砌,与网上已有的博文相差无几,失去了意义。

在开始正式的内容之前,还是强烈推荐下《程序员的自我修养》一书,书中详细的介绍了程序的链接,装载与库。Linux的进程地址空间与这些内容息息相关,此书会解读大部分关于进程地址空间的疑惑。

程序的编译


从编写的代码到最终成为可运行的程序需要经过几个阶段,而这几个阶段往往都由强大的编译器一带而过,呈现给我们一个可执行文件,例如简单的一行命令gcc hello.c,并得到了可执行文件a.out。这个简单命令背后则包含了对程序代码的多个阶段的处理,我们熟知的预处理,编译,汇编和链接,而这多个阶段可能被模糊笼统的称为编译了。其实仔细琢磨会发现,链接不同于编译,却也是一个至关重要的过程,也是引导我们探索进程地址空间的一个入口。

预处理阶段主要处理源代码文件中的以#开始的预处理指令,编译过程则需要词法,语法,语义分析,优化后生成汇编代码,汇编阶段则将汇编代码转成机器可执行的指令,汇编过后输出的文件一般称之为目标文件。对于最后一个阶段,链接,则是将多个目标文件链接成可执行文件。更详细的描述参考此文章:C程序的编译过程

我们先聚焦在链接上。

现在编写的程序越趋复杂庞大,往往包含多个源码文件,并且还会引用一个或多个库文件。编译器在编译源码的过程中,则是对每个文件单独编译的,然后将这些编译之后生成的目标文件与引用的库组装成可执行的文件。这个组装的过程就是所谓的链接,从程序的角度来看,就是要解决各个模块之间相互引用的问题,为所有的函数变量这些符号的引用产生正确的地址。

对于链接的理解,我们可以举一个很简单的例子, 摘自《程序员的自我修养》98页:

/* a.c */
extern int shared;

int main(){
    int a = 100;
    swap(&a, &shared);
}

/* b.c */

int shared = 1;

void swap(int* a , int* b){
    *a ^= *b ^= *a ^= *b;
}

假设编写的程序只有上面两个文件,在a.c文件中,很明显存在两个外部符号,shared和main,这两个符号都引用于b.c文件。编译器是单独编译a.c和b.c文件,生成a.o与b.o两个目标文件, 最后再将这两个目标文件链接成可执行文件。然而在生成目标文件a.o的过程中是无法找到shared和swap的正确地址的,所以需要在链接过程来解决此问题。这个简单的例子告诉我们链接在程序编译过程中的重要性了。

我们知道链接还有所谓的静态链接和动态链接之分,在理解二者的区别之前,我们来看下面这张图,两种链接的不同过程。图还是从《程序员的自我修养》中截取过来的。

对比两个过程,我们会发现二者的不同之处在链接阶段。静态链接会把libc.a等静态库文件和源码生成的目标文件链接到一块,整体打包生成一个可执行文件,而动态链接则是lib.so等动态库文件只参与到链接过程,并不会和源码生成的目标文件组成一个整体,lib.so库文件而是在程序运行时期加载。显然,动态链接库解决了内存和磁盘空间浪费,模块更新的诸多问题。在后面的进程地址空间中我们将会再看到动态链接库。

ELF文件格式


对静态链接与动态链接有基本的了解之后,我们需要明白,目标文件得按照一定的格式来组织,以便链接器获取足够的信息完成这两种链接过程。当然,目标文件的格式不止限于链接这种作用。可执行文件在被执行之前需要进行装载,操作系统要友好的将其载入到进程的地址空间中,同样,可执行文件也得按照一定格式组织。

前面我们了解了所谓的链接,即读取目标文件中的相关信息解决符号跨模块之间的引用问题。那么执行是个什么基本过程了?《程序员的自我修养》中讲到,很多时候一个程序被执行同时都伴随着一个新的进程的创建,那么最通常的情形便是:创建一个进程,然后装载相应的可执行文件并且执行。在有虚拟存储的情况下,上述过程的最开始只需要做三件事情:

创建一个独立的虚拟地址空间;

读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系;

将CPU的指令寄存器设置成可执行文件的入口地址,启动运行;

第一步完成了虚拟空间到物理内存的映射关系,而第二步专注于虚拟空间与可执行文件的映射关系。这样,当程序执行发生页错误时,操作系统知道程序当前所需要的页在可执行文件中的哪一个位置。从某种角度来说,第二步应该是整个装载过程中最重要的一步。那么可执行文件到底是怎样与虚拟空间映射起来的了?

我们先了解下可执行文件结构,然后尝试回答上面的问题!

可执行文件的设计者们则将目标文件的格式与可执行文件的格式进行了统一,均为ELF格式文件,该文件格式提供了两种视图,如下,是ELF文件的基本格式,左边是链接视图,右边是执行视图。

从图中可以看出,存在section与segment两种概念,中文皆可理解为段,我们稍加以区分,section主要面向链接过程,segment则面向执行过程。实际上,一个segment是由多个section构成,也就是我们在x86架构中常听到的段。

对于这两种视图,我们给出一个简单的例子,然后来观察和验证。

#include<iostream>

using namespace std;

int global_a = 100;
int global_b;
static int global_static_a = 200;

int main(){
    static int main_static_b = 200;
    static int main_static_c;
    cout << "Hello World" << endl;
    return 0;
}

chouan@ubuntu:~/coding/cpp$ g++ hello.cpp

编译之后观察结果,通过命令readelf -S a.out来查看该执行文件中都有哪些section,结果如下:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] .interp           PROGBITS        08048154 000154 000013 00   A  0   0  1
  [ 2] .note.ABI-tag     NOTE            08048168 000168 000020 00   A  0   0  4
  [ 3] .note.gnu.build-i NOTE            08048188 000188 000024 00   A  0   0  4
  [ 4] .gnu.hash         GNU_HASH        080481ac 0001ac 000030 04   A  5   0  4
  [ 5] .dynsym           DYNSYM          080481dc 0001dc 0000c0 10   A  6   1  4
  [ 6] .dynstr           STRTAB          0804829c 00029c 00014b 00   A  0   0  1
  [ 7] .gnu.version      VERSYM          080483e8 0003e8 000018 02   A  5   0  2
  [ 8] .gnu.version_r    VERNEED         08048400 000400 000050 00   A  6   2  4
  [ 9] .rel.dyn          REL             08048450 000450 000010 08   A  5   0  4
  [10] .rel.plt          REL             08048460 000460 000040 08   A  5  12  4
  [11] .init             PROGBITS        080484a0 0004a0 00002e 00  AX  0   0  4
  [12] .plt              PROGBITS        080484d0 0004d0 000090 04  AX  0   0 16
  [13] .text             PROGBITS        08048560 000560 0001fc 00  AX  0   0 16
  [14] .fini             PROGBITS        0804875c 00075c 00001a 00  AX  0   0  4
  [15] .rodata           PROGBITS        08048778 000778 000014 00   A  0   0  4
  [16] .eh_frame_hdr     PROGBITS        0804878c 00078c 000044 00   A  0   0  4
  [17] .eh_frame         PROGBITS        080487d0 0007d0 000104 00   A  0   0  4
  [18] .init_array       INIT_ARRAY      08049ef8 000ef8 000004 00  WA  0   0  4
  [19] .ctors            PROGBITS        08049efc 000efc 000008 00  WA  0   0  4
  [20] .dtors            PROGBITS        08049f04 000f04 000008 00  WA  0   0  4
  [21] .jcr              PROGBITS        08049f0c 000f0c 000004 00  WA  0   0  4
  [22] .dynamic          DYNAMIC         08049f10 000f10 0000e0 08  WA  6   0  4
  [23] .got              PROGBITS        08049ff0 000ff0 000004 04  WA  0   0  4
  [24] .got.plt          PROGBITS        08049ff4 000ff4 00002c 04  WA  0   0  4
  [25] .data             PROGBITS        0804a020 001020 000014 00  WA  0   0  4
  [26] .bss              NOBITS          0804a040 001034 0000a0 00  WA  0   0 32
  [27] .comment          PROGBITS        00000000 001034 00002a 01  MS  0   0  1
  [28] .shstrtab         STRTAB          00000000 00105e 000108 00      0   0  1
  [29] .symtab           SYMTAB          00000000 001640 000500 10     30  52  4
  [30] .strtab           STRTAB          00000000 001b40 0003c3 00      0   0  1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings)
  I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)

从上面这个结果,我们发现一个简单的可执行文件中包含了很多的section,其中有些我们比较熟悉的,例如.text,.data,.bss等等,并且很多section的flag标志都一样,这点跟后面的segment有很大关系。对于每个section的意义,我们可以从这篇博文中窥见一二。

上面是从链接的视图来看可执行文件,我们再从执行视图的角度来看,通过这条命令readelf --segments a.out

There are no section groups in this file.

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  PHDR           0x000034 0x08048034 0x08048034 0x00120 0x00120 R E 0x4
  INTERP         0x000154 0x08048154 0x08048154 0x00013 0x00013 R   0x1
      [Requesting program interpreter: /lib/ld-linux.so.2]
  LOAD           0x000000 0x08048000 0x08048000 0x008d4 0x008d4 R E 0x1000
  LOAD           0x000ef8 0x08049ef8 0x08049ef8 0x0013c 0x001e8 RW  0x1000
  DYNAMIC        0x000f10 0x08049f10 0x08049f10 0x000e0 0x000e0 RW  0x4
  NOTE           0x000168 0x08048168 0x08048168 0x00044 0x00044 R   0x4
  GNU_EH_FRAME   0x00078c 0x0804878c 0x0804878c 0x00044 0x00044 R   0x4
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x4
  GNU_RELRO      0x000ef8 0x08049ef8 0x08049ef8 0x00108 0x00108 R   0x1

 Section to Segment mapping:
  Segment Sections...
   00     
   01     .interp 
   02     .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .text .fini .rodata .eh_frame_hdr .eh_frame 
   03     .init_array .ctors .dtors .jcr .dynamic .got .got.plt .data .bss 
   04     .dynamic 
   05     .note.ABI-tag .note.gnu.build-id 
   06     .eh_frame_hdr 
   07     
   08     .init_array .ctors .dtors .jcr .dynamic .got 

显然,给出的结果已经告诉我们了,segment是section的一个集合,sections按照一定规则映射到segment。那么为什么需要区分两种不同视图了?

我们知道,不同的section可能具有不同的属性,例如,.text是可读可执行的,而.data是可读可写的,.bss也是可读可写的等等。操作系统往往以页为基本单位来管理内存分配,一般页的大小为4096B,即4KB的大小。同样,内存的权限管理的粒度也是以页为单位,页内的内存是具有同样的权限等属性,并且操作系统对内存的管理往往追求高效和高利用率这样的目标。ELF文件在被映射时,是以系统的页长度为单位的,那么每个section在映射时的长度都是系统页长度的整数倍,如果section的长度不是其整数倍,则导致多余部分也将占用一个页。而我们从上面的例子中知道,一个ELF文件具有很多的section,那么会导致内存浪费严重。这样,ELF文件格式就考虑到这些特点,在执行的角度,将程序中具有相同权限的section划分到同一个segment当中,可执行文件在装载时,则以segment为单位进行映射,对应于进程虚拟空间中的VMA(虚拟内存区域)。这样则减少了页面内部的碎片,节省了空间,显著的提高了内存利用率。

进程地址空间


每个程序都拥有独立的虚拟地址空间,虚拟地址空间通过某种方式映射到物理内存。这样,每个进程都拥有一个连续完整的地址空间,并且同其他的进程区别开来。在程序的运行过程中,再完成虚拟地址到物理地址的转换。我们知道,进程的地址空间是分段的,存在所谓的数据段,代码段,bbs段,堆,栈等等。每个段都有特定的作用,仔细看下面这张图,就对进程地址空间中的结构有了大致的了解。

对于32位的机器来说,虚拟的地址空间大小就是4G,可能实际的物理内存大小才1G到2G,意味着程序可以使用比物理内存更大的空间。

对进程的地址空间有了初步了解之后,现在我们可以从下面这张图来看到ELF文件中的section或segment与地址空间的具体映射关系:

很多时候,我们会遇到stack overflow这样的问题,应该进程的栈空间被耗没了,linux进程栈的大小,我们可以通过ulimit -a | grep stack来查看,例如在我的32位 Ubuntu机器上,显示的结果是8192KB。可能大家还会有一个疑问,动态分配的空间最大可以分配到多大了?即通过malloc可以最大申请到多少的内存?《程序员的自我修养》给出了一个简单的小程序来测试,在我的机器下运行的结果大概是1.36GB左右的空间。

#include<stdio.h>
#include<stdlib.h>

unsigned max = 0;

void main(){
    unsigned int blocksize[] = {1024*1024, 1024, 1};
    int i, count;
    for(i = 0; i < 3; ++i){
        for(count = 1; ; ++count){
            void *block = malloc(max + blocksize[i]*count);
            if(block){
                max = max + blocksize[i]*count;
                free(block);
            }else{
                break;
            }
        }
    }
    printf("maximum malloc size = %u bytes\n", max);
}

实际上malloc的最大申请数量会受到操作系统版本,程序本身大小,用到的动态或共享库数量以及大小,程序栈的数量和大小等等,甚至有可能每次运行的结果都会不同,因为操作系统会使用一种叫做随机地址空间分布的技术,使得进程的空间变小。

总结


对进程地址空间的初步认识就到这了,还有很多具体的问题没有阐述,例如

进程下的多线程能共享地址空间中的哪些区域,高地址区域的内核地址空间到底是个什么情况,mmap内存映射区到底是如何实现的等等。

下次一点一滴的将这些知识补齐,再记录到自己的博客上。在学习进程地址空间的过程中,查了很多资料,其中最为受用的还是《程序员的自我修养》一书,全面,详细,整个内容连贯一气呵成。

前一篇: Openstack Heat Project介绍 后一篇: Linux进程地址空间学习(二)