本文主要记录程序逻辑地址空间的相关知识,以及 C++ 与 C 中不同点。

C 程序的地址空间

传统 C 程序的逻辑地址空间主要分为五段,分别是 apue1 中提及到的五段:

  1. 正文段(.text),存储的主要是机器指令,且通常是可共享的,同时为了避免程序由于以外而被修改,此段数据大多数情况下是只读的。
  2. 初始化数据段(.data),也称为数据段,包含了程序中赋予了初始值的变量,例如在所有函数之外定义的全局变量,带有 static 关键字的变量。
  3. 未初始化数据段(.bss),bss 段名字的由来是汇编程序中的一个操作符,全称是 block started by symbol(由符号开始的块),该段中的数据在程序执行之前,内核将此段默认初始化为 0 或者 空指针,例如在所有函数之外定义的变量。
  4. 栈(.stack),自动变量以及每次函数调用时所需保存的信息都存放在此段中,每次函数调用的时候,其返回地址以及调用者的环境信息(例如相关寄存器)都存放在栈中。然后,最近被调用的函数在栈上为其自动变量和临时变量分配存储空间。这种工作方式,恰好支持了递归。
  5. 堆(.heap),动态存储分配的变量。

下图是 apue1 中提及到的一种分配方式。

通过 size(1) 可以查看目标文件的各段长度,例如

$ size a.out
   text    data     bss     dec     hex filename
  14586     800     576   15962    3e5a a.out

例子

参考2 中给出了一个 C 程序,该程序展示了地址空间中各段的分布情况。

  #include <stdio.h>
  #include <malloc.h>     /* for definition of ptrdiff_t on GLIBC */
  #include <unistd.h>
  #include <alloca.h>     /* for demonstration only */

  extern void afunc(void);    /* a function for showing stack growth */

  int bss_var;            /* auto init to 0, should be in BSS */
  int data_var = 42;      /* init to nonzero, should be data */

  int
  main(int argc, char **argv) /* arguments aren't used */
  {
      char *p, *b, *nb;

      printf("Text Locations:\n");
      printf("\tAddress of main: %p\n", main);
      printf("\tAddress of afunc: %p\n", afunc);

      printf("Stack Locations:\n");
      afunc();

      p = (char *) alloca(32);
      if (p != NULL) {
          printf("\tStart of alloca()'ed array: %p\n", p);
          printf("\tEnd of alloca()'ed array: %p\n", p + 31);
      }

      printf("Data Locations:\n");
      printf("\tAddress of data_var: %p\n", & data_var);

      printf("BSS Locations:\n");
      printf("\tAddress of bss_var: %p\n", & bss_var);

      b = sbrk((ptrdiff_t) 32);   /* grow address space */
      nb = sbrk((ptrdiff_t) 0);
      printf("Heap Locations:\n");
      printf("\tInitial end of heap: %p\n", b);
      printf("\tNew end of heap: %p\n", nb);

      b = sbrk((ptrdiff_t) -16);  /* shrink it */
      nb = sbrk((ptrdiff_t) 0);
      printf("\tFinal end of heap: %p\n", nb);

      /* infinite loop */
      while (1) {}
  }

  void
  afunc(void)
  {
      static int level = 0;       /* recursion level */
      auto int stack_var;        /* automatic variable, on stack */

      if (++level == 3)           /* avoid infinite recursion */
          return;

      printf("\tStack level %d: address of stack_var: %p\n",
              level, & stack_var);
      afunc();                    /* recursive call */
  }

编译上述程序,运行可以看到上述程序的各段情况。

$ ./a.out
Text Locations:
        Address of main: 0x40058d
        Address of afunc: 0x40070c
Stack Locations:
        Stack level 1: address of stack_var: 0x7ffff42661fc
        Stack level 2: address of stack_var: 0x7ffff42661dc
        Start of alloca()'ed array: 0x7ffff42661e0
        End of alloca()'ed array: 0x7ffff42661ff
Data Locations:
        Address of data_var: 0x601040
BSS Locations:
        Address of bss_var: 0x60104c
Heap Locations:
        Initial end of heap: 0x1afa000
        New end of heap: 0x1afa020
        Final end of heap: 0x1afa010

使用 pmap(1) 可以查看进程的内存映射状态的情况。

$ pmap -d 6153
6153:   ./a.out
Address           Kbytes Mode  Offset           Device    Mapping
0000000000400000       4 r-x-- 0000000000000000 000:00000 a.out
0000000000600000       4 r---- 0000000000000000 000:00000 a.out
0000000000601000       4 rw--- 0000000000001000 000:00000 a.out
0000000001ad9000     136 rw--- 0000000000000000 000:00000   [ anon ]
00007f341c400000    1948 r-x-- 0000000000000000 000:00000 libc-2.27.so
00007f341c5e7000      36 ----- 00000000001e7000 000:00000 libc-2.27.so
00007f341c5f0000    2012 ----- 00000000001f0000 000:00000 libc-2.27.so
00007f341c7e7000      16 r---- 00000000001e7000 000:00000 libc-2.27.so
00007f341c7eb000       8 rw--- 00000000001eb000 000:00000 libc-2.27.so
00007f341c7ed000      16 rw--- 0000000000000000 000:00000   [ anon ]
00007f341c800000     160 r-x-- 0000000000000000 000:00000 ld-2.27.so
00007f341c828000       4 r-x-- 0000000000028000 000:00000 ld-2.27.so
00007f341ca29000       4 r---- 0000000000029000 000:00000 ld-2.27.so
00007f341ca2a000       4 rw--- 000000000002a000 000:00000 ld-2.27.so
00007f341ca2b000       4 rw--- 0000000000000000 000:00000   [ anon ]
00007f341ca70000       8 rw--- 0000000000000000 000:00000   [ anon ]
00007ffff3a67000    8192 rw--- 0000000000000000 000:00000   [ anon ]
00007ffff49c9000       4 r-x-- 0000000000000000 000:00000   [ anon ]
mapped: 12564K    writeable/private: 8372K    shared: 0K

依据程序输出和 pamp命令 的输出,可以推测出各段开始的内存地址,具体见下表,值得注意的是,栈是向下增长的(Intel x86架构下),pmap 命令的输出的栈开始地址是没有写权限的,这是由于栈的有效区域是当前地址的下一个地址,即当前位置是栈底的位置。

Memory address (hex) Contents Comments
0x400000 text Code segment
0x600000 data Data segment – Initialized
0x601000 bss Data segment – BSS
0x1ad9000 heap Heap segment
0000 7fff f49c 9000 stack Stack segment

上述仅是用户态进程地址空间的冰山一角而已,更多详细的可以查看 Gustavo Duarte3 对此的更深一步的剖析和知乎4中的相关讨论。

栈的增长方向?

《程序员的自我修养 - 链接、装载与库》一书中提及到栈的地址比堆高,栈是向下增长的,堆是向上增长的;可能大多数 OS 课上都是这样提及的,但这种说法并不严谨。下面是知乎中的一段回答4,具体分析请看原文:

进程地址空间的分布取决于操作系统,栈向什么方向增长取决于操作系统与CPU的组合。

x86硬件直接支持的栈确实是“向下增长”的:push 指令导致 sp 自减一个 slot ,pop 指令导致 sp 自增一个 slot。其它硬件有其它硬件的情况。

这个上下文里说的“栈”是函数调用栈,是以“栈帧”(stack frame)为单位的。每一次函数调用会在栈上分配一个新的栈帧,在这次函数调用结束时释放其空间。

被调用函数(callee)的栈帧相对调用函数(caller)的栈帧的位置反映了栈的增长方向:如果被调用函数的栈帧比调用函数的在更低的地址,那么栈就是向下增长;反之则是向上增长。

关于函数调用与栈帧的关系,可以看 slider5,下图为截取的一段。

调用函数先将实参入栈,然后将返回地址入栈;然后是被调用函数的栈帧,由栈底到栈顶依次是保存的调用者的栈帧指针,保存的调用者的寄存器值,一些局部变量,最后才是由实参初始化的形参。

C++ 的进一步抽象

C++ 的内存空间则对 C 进行了进一步抽象,分别是常量存储区,静态存储区,自由存储区,堆以及栈。

其中,常量存储区存储的是只读常量,无法修改,例如字符串字面值常量,全局定义的带const关键字的常量都存放在该区域。 静态存储区存储的是全局中定义的变量以及程序中带 static 关键字的变量。

另外,最好奇的是自由存储区 (free store),大多数的说法是,堆中是由 malloc 分配出的动态内存空间,自由存储区则是由 new 分配出来的。但疑问是,有些 new 操作的底层就有用到 malloc 来请求一片内存区域,然后再调用构造。所以,自由存储区和堆的区别到底是什么呢?

事实上,自由存储区是一个更加抽象的概念,是 C++ 使用 new/delete 动态管理对象的一个抽象概念;而堆是操作系统和 C 中的一个概念,同样也提供了动态分配内存的概念。C++ 的自由存储区具体在哪里,是由编译器实现的,可以使用堆来实现,亦可以使用 placement new 操作来指定一块内存区域来存储。所以,堆和自由存储区的概念是并不等价的,应该记住,堆是由操作系统维护的一块内存,而自由存储区是 C++ 中的一个更加抽象的概念,这个区域是抽象出来实现 new / delete 来动态管理对象的6

具体分析可见7

The free store is one of the two dynamic memory areas, allocated/freed by new/delete. Object lifetime can be less than the time the storage is allocated; that is, free store objects can have memory allocated without being immediately initialized, and can be destroyed without the memory being immediately deallocated. During the period when the storage is allocated but outside the object’s lifetime, the storage may be accessed and manipulated through a void* but none of the proto-object’s nonstatic members or member functions may be accessed, have their addresses taken, or be otherwise manipulated.

The heap is the other dynamic memory area, allocated/freed by malloc/free and their variants. Note that while the default global new and delete might be implemented in terms of malloc and free by a particular compiler, the heap is not the same as free store and memory allocated in one area cannot be safely deallocated in the other. Memory allocated from the heap can be used for objects of class type by placement-new construction and explicit destruction. If so used, the notes about free store object lifetime apply similarly here.

常见错误:

OSTEP8 中提到了一些常见的 C 语言中的与内存管理相关的错误。

  1. 忘记申请内存

    下述代码中,dst 指针指向的是一个未知的地址,没能正确的分配号一个内存区域,故这样极有可能导致 segmentation fault, segmentation fault 一般是由访问非法地址造成的。

    char *src = "hello";
    char *dst; 
    strcpy(dst, src); 
    

    将上述代码修改成如下即可:

    char *src = "hello";
    char *dst = (char *) malloc(strlen(src) + 1);
    strcpy(dst, src); 
    
  2. 分配内存不足

    以下代码由于分配的内存不足,会发生 buffer overflow,下述代码很有可能在某些编译器下正常运行,但是会给程序留下安全性等严重隐患。正确的做法是分配足够的内存空间

    char *src = "hello";
    char *dst = (char *) malloc(strlen(src)); 
    strcpy(dst, src); 
    
  3. 忘记初始化分配的内存

    申请了一片内存后,该片内存区域是未经初始化的,大多时候是一些未知的值,使用这些值很有可能带来严重的问题。因此,一旦使用 malloc() 申请了内存,一定要将这片内存初始化。

  4. 忘记释放内存

    忘记释放内存会导致内存泄漏,如果持续发生,最终会导致内存耗尽,直至被 OS kill。因此,应当在适当的时候释放内存。

  5. 释放内存后未将指针置空

    如果释放内存后,未将指针置空,指针还指向这原来的内存地址,再次使用这个指针进行内存操作,可能访问到了其他变量的内存空间,导致程序发生错误行为,甚至崩溃。这种指针被称为空悬指针,避免上述错误应该将释放内存后的指针置空。

  6. 重复释放内存

    重复释放同一块内存是一个未定义的行为,通常会导致程序崩溃。

  7. 使用 free() 的时间点不正确

以上都是 C 语言中使用 malloc/free 管理内存造成的错误;当然,在 C++ 中有所改进,使用智能指针能避免上述大多数情况,具体可参考陈硕的《Linux 多线程服务端编程》第一章。

另外,关于 Segmentation Fault 错误及相关建议,见 MIT 的一篇 20年前的文章的建议9

参考


  1. http://www.apuebook.com/ Advanced Programming in the UNIX Environment ↩︎

  2. http://alumni.cs.ucr.edu/~saha/stuff/memaddr.html Process Address Space ↩︎

  3. https://manybutfinite.com/post/anatomy-of-a-program-in-memory/ Anatomy of a Program in Memory ↩︎

  4. https://www.zhihu.com/question/36103513 堆、栈的地址高低? 栈的增长方向? ↩︎

  5. https://www.cs.cmu.edu/~410/lectures/L02_Stack.pdf IA32 Stack Discipline ↩︎

  6. https://www.cnblogs.com/qg-whz/p/5060894.html C++ 自由存储区是否等价于堆? ↩︎

  7. http://www.gotw.ca/gotw/009.htm Memory Management - Part I ↩︎

  8. http://pages.cs.wisc.edu/~remzi/OSTEP/ Operating Systems: Three Easy Pieces ↩︎

  9. http://web.mit.edu/10.001/Web/Tips/tips_on_segmentation.html Troubleshooting Segmentation Violations/Faults ↩︎