Loading

【C++ 基础】进程内存布局及其相关知识

1 引言

本文环境为 Linux 操作系统(x86) + C++

目的是了解进程内存布局,但是在了解的过程中发现需要前置一些知识,因此内容概览如下所示:

  • 1 C/C++程序从源代码到可执行程序的构建过程
    • 1.1 预处理,也叫预编译
    • 1.2 编译
    • 1.3 汇编
    • 1.4 链接
  • 2 各平台文件格式
  • 3 ELF 文件
    • 3.1 ELF 文件是什么
    • 3.2 ELF 文件类型
      • 可重定位文件
      • 可执行文件
      • 共享文件
    • 3.3 ELF 文件布局(为什么 ELF 文件可以从两个不同角度看待)
    • 3.4 ELF 文件格式分析
  • 4 进程内存布局(内存分配方式)
    • 4.1 栈区(stack)
    • 4.2 堆区(heap)
    • 4.3 数据区
      • 未初始化数据区(.bss)
      • 初始化数据区(.data)
      • 文字常量区,也可叫做只读存储区(.rodata)
    • 4.4 文本区,或叫做代码区(.text)
    • 4.5 堆和栈的区别
  • 5 内存分配与管理
  • 6 总结
  • 7 扩展问题
    • 1 vector 最大的大小是多少
    • 2 delete this 合法吗?
    • 3 如何定义一个只能在堆上(栈上)生成对象的类?

1 C/C++程序从源代码到可执行程序的构建过程

本节内容来源于 g++编译详解 - 作者:三级狗 https://blog.csdn.net/Three_dog/article/details/103688043
感谢原作者,欢迎查看原文

一个完整的 C++ 编译过程(g++ a.cpp 生成可执行文件),总共包含以下四个过程:

  • 编译预处理,也称预编译,可以使用命令 g++ -E 执行(生成 .ii 文件)
  • 编译,可以使用 g++ -S 执行(生成 .s 文件)
  • 汇编,可以使用 as 或者 g++ -c 执行(生成 .o 文件,可重定位目标文件)
  • 链接,可以使用 g++ xxx.o xxx.so xxx.a 执行(生成可执行文件)

现以 3 个 cpp 文件,举例如下所示:

main.cpp 文件如下所示:

#include "test.h"

int main (int argc, char **argv)
{
    Test t;
    t.hello();
    return 0;
}

test.h 文件如下所示:

//test.h
#ifndef _TEST_H_ 
#define _TEST_H_ 

class Test
{
public:
    Test();
    void hello();
    ~Test();
};
#endif  //TEST

test.cpp 文件如下所示:

//test.cpp
#include "test.h"
#include <iostream>
using namespace std;

Test::Test()
{

}

void Test::hello()
{
    cout << "hello" << endl;
}

Test::~Test()
{

}

1.1 预处理

1.1.1 预处理过程

预处理也叫预编译。

预处理过程是由预处理器把源代码文件中的以 “#” 开始的预编译指令,比如 “#include”、“#define” 等,按照处理规则,生成处理后的源文件

主要处理规则如下:

  • 将所有的 “#define” 删除,并且展开所有的宏定义
  • 处理所有条件预编译指令,比如 “#if”、“#ifdef”、“#elif”、“#else”、“#endif ”
  • 处理 “#include ”预编译指令,将被包含的文件插入到该预编译指令的位置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含其他文件
  • 删除所有的注释 “//” 和 “/* */”
  • 添加行号和文件名标识,比如 #2“hello.c”2,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号
  • 保留所有的 #pragma 编译器指令,因为编译器须要使用它们

注意:
经过预编译后的文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件也已经被插入到 .i 文件中,因此当我们无法判断宏定义是否正确或头文件包含是否正确时,可以查看预编译后的文件来确定问题。

1.1.2 预处理命令

使用 g++ -E 只预处理指定的源文件,不进行编译。cpp 文件生成 *.ii ,.c 文件生成的是 *.i 文件。

-o 为指定生成文件的文件名。

这里预编译一下 test.cpp 文件,如下所示:

g++ -E test.cpp -o test.ii

注意:这里我没有笔误,如果像原文写的那样 g++ -E test.cpp test.h -o test.ii 得到的预处理文件会格外短,最后在链接的时候会提示找不到 test.h 的定义,因此不能像原文那样写。(错误原因:可以看到执行这条命令后得到的预处理文件仍旧包含宏定义,并且 test.h 文件仍旧被包含,因此该预处理后的文件肯定是有问题的)

结果:

image

1.1.3 预处理生成的文件(.ii)

如果上述命令不加 -o 重定向到文件中,则会输出在屏幕上。

查看 test.ii 文件,如下所示:

image

1.2 编译

1.2.1 编译的过程

编译过程就是由编译器把预处理完的文件进行一系列词法分析,语法分析,语义分析,中间语言生成,目标代码优化及优化后生成相应的汇编代码 文件。

1.2.2 编译命令

使用 g++ -S 只编译,不进行汇编。

这里编译一下 test.ii 文件,如下所示:

g++ -S test.ii

结果:

image

1.2.3 编译生成的文件(.s)

查看 test.s 文件,如下所示:

image

1.3 汇编

1.3.1 汇编过程

汇编过程就是 由汇编器将汇编代码转变成机器可以执行的二进制指令

1.3.2 汇编命令

使用 g++ -c 令 GCC 编译器将指定文件加工至汇编阶段,但不执行链接操作,也就是说,如果指定文件为源程序文件(例如 main.cpp),则 gcc -c 指令会对 main.cpp 文件执行预处理、编译以及汇编这 3 步操作。

这里汇编一下 test.s 文件,如下所示:

g++ -c test.s

结果:

image

1.3.3 汇编生成的可重定位目标文件(.o)

查看 test.o 文件,如下所示:

image

此时已经是二进制文件了,所以直接 cat 看到的部分是乱码,注意这个可重定位目标文件为 ELF 文件,如下所示

image

1.4 链接

1.4.1 链接过程

链接的过程,其核心工作是解决模块间各种符号(变量,函数)相互引用的问题,使得各个模块之间能够正确的衔接。

简单的理解为将各个目标文件链接起来生成最终的可执行文件。

链接过程可以具体的分为以下四步:

  • 合并段和符号表,合并多个文件的符号表及各段内容,放入一个新的文件中。
  • 符号解析,在每个文件符号引用(引用外部符号)的地方找到符号的定义。这就是符号解析。
  • 地址和空间分配,符号解析成功后,为程序分配虚拟地址空间。
  • 符号重定位

链接又分为

  • 静态链接
  • 动态链接

1.4.2 链接命令

g++ 其他文件 -o 可执行文件

1、生成 main.o,如下所示:

1.1 可以直接使用 g++ main.cpp -c -o main.o 一步到位

1.2 也可以预处理,编译,汇编
预处理:g++ -E main.cpp -o main.ii
编译:g++ -S main.ii -o main.s
汇编:g++ -c main.s -o main.o

黄色的方框对应的是 1.1 的方法,绿色的框对应的 1.2 的方法

image

2、链接命令 g++ main.o test.o,如下所示:

image

1.4.3 链接生成的可执行文件

可以看到就是 .out 就是个 ELF 文件

image

2 各平台文件格式

本节是对文件格式的一个总结,具体如下所示:

平台 可重定位目标文件 可执行文件 动态库/共享对象 静态库
Windows obj exe dll lib
Unix/Linux o ELF so a
Mac o Mach-O dylib、tbd、framework a、framework

3 ELF 文件

在本文 1.3.3 汇编生成的可重定位目标文件 和 1.4.3 链接生成的可执行文件 都是 ELF 文件,那 ELF 文件到底是什么,这两个文件有什么区别呢?

3.1 ELF 文件是什么

ELF(Executable Linkable Format) 是一种文件存储格式。

3.2 ELF 文件类型

示例如下所示:

image

3.2.1 可重定位文件(relocatable)

由汇编器产生的 .o 文件。包含二进制代码和数据,用来被链接成可执行文件或者共享目标文件
例如:.o 文件。可参考 1.3.3 汇编生成的可重定位的目标文件(.o)

注意:.a 静态库是 ar 格式的归档文件,内部是 n 个 .o 文件的组合,如下所示:

image

3.2.2 可执行文件(executable)

包含二进制代码和数据,可以直接被加载器加载执行,代表了 ELF 可执行文件,他们一般没有拓展名。
例如:/bin/bash 文件

3.2.3 共享文件(shared object)

用于和其他共享文件或者可重定位目标文件一起链接生成 ELF 目标文件,或者和可执行文件一起创建进程映像
例如 *.so 动态库

3.3 ELF 文件布局(为什么 ELF 文件可以从两个不同角度看待)

ELF 文件的概念布局如下所示:

image

ELF 文件从概念上来说包含了 5 个部分:

  • ELF header(文件头):描述体系架构和操作系统等基本信息,指出 section header table 和 program header table 在文件的位置
  • program header table(程序头表):从运行的角度看 ELF 文件,给出各个 segments 的信息
  • section header table(节头表):从编译和链接的角度来看 ELF 文件,保存所有的 sections 信息
  • segments(段):运行时的各个段
  • sections(节):编译和链接时的各个节区

为什么 ELF 文件可以从两个不同角度看待?因为 ELF 文件参与程序的建立和程序的执行

1、如果用于编译和链接(即可重定位目标文件和共享文件),则编译器和链接器把 ELF 文件看作是 section header table(节头表) 描述的 sections(节) 的集合。

2、如果用于加载执行(可执行文件),则加载器把 ELF 文件看作是 program header table(程序头表)描述的 segments(段)的集合。

3.4 ELF 文件格式分析

可以参考 linux下强大的ELF文件分析工具 -- readelf - 作者:悟OO道 https://blog.csdn.net/chenzhjlf/article/details/124651103

后续有用到 readelf 或者有时间再写一篇博客来归纳总结吧。

4 进程内存布局(内存分配方式)

本节主要参考为《Linux/UNIX系统编程手册》图 6-1 在 Linux/x86-32 中典型的进程内存结构,图 48-2 共享内存、内存映射、以及共享库的位置(x86-32)

关于 C++ 的内存布局,网上说法不一,有人将其分为 4 区,也有人将其分为 5 区,不同的人分成的 5 区也不尽相同,造成这种差异的主要原因是 C 语言和 C++ 语言的发展背景、个人的分类喜好、个人的命名习惯等

在 Linux 操作系统下的内存布局,自己画了一个图(如有错误,欢迎指正),如下所示:

image

地址从高到低依次为以下部分:

  • 内核空间:命令行参数和环境变量,分配给程序的虚拟内存空间(大小和系统有关)等

  • 1 栈区(stack):包含函数的参数值和局部变量,函数调用的上下文等

  • 2 堆区(heap):动态分配的内存

  • 3 数据区,也可叫做全局区、静态区、全局静态区、静态全局区

    • 未初始化数据区(.bss):未初始化的全局变量和静态变量

    • 初始化数据区(.data):存放初始化的全局变量和静态变量

    • 文字常量区,也可叫做只读存储区(.rodata):用于存放各类常量,如:const、字面量、#define

  • 4 文本区,或叫做代码区(.text):存储程序的机器代码,机器指令

4.1 栈区(stack)

  • 存放内容:函数调用时所需保存的信息(非静态局部变量,编译器自动生成的其他临时变量、函数的返回值和参数,函数调用前后需要保存不变的寄存器(上下文)等)

  • 大小:固定,一般是8MB,系统提供参数以便自定义

  • 增长方向:高地址向低地址

  • 特点:先进后出,可读可写

注意:
1、所谓的堆栈其实就是栈没有堆。

2、堆栈段在运行时创建,有自己固定的大小空间

3、若越界访问则会出现段错误(Segmentation Fault)

4、若多次递归调用增加栈帧导致越界则会出现栈溢出(Stack Overflow)

4.2 堆区(heap)

  • 存放内容:程序运行中动态存储分配的空间

  • 大小:视内存大小而定,由程序员进行分配

  • 增长方向:低地址向高地址

  • 特点:可读可写

注意:手动 malloc/new 动态分配 , free/delete 释放。

4.3 数据区

1 未初始化数据区(.bss)

  • 存放内容:未初始化的全局变量或 static 变量

  • 特点:可读可写

2 初始化数据区(.data)

  • 存放内容:初始化的全局变量或 static 变量

  • 特点:可读可写

3 文字常量区,也可叫做只读存储区(.rodata)

  • 存放内容:const,#define,char *ptr = "string" 等定义的数据常量

  • 特点:只读

4.4 文本区,或叫做代码区(.text)

代码区 = text/code segment,又叫:正文区、文本区、正文段、文本段、代码段

  • 存放内容:存放程序执行代码,通常程序运行前就已确定,内容不可被修改

  • 特点:

    • 共享,共享的目的是对于频繁被执行的程序,只需要在内存中有一份代码即可

    • 只读:只读的原因是防止程序意外地修改了它的指令

4.5 堆和栈的区别

1 管理方式不同

堆:由程序员分配释放,若程序员不释放,程序结束时可能由OS回收

栈:由操作系统自动分配释放

2 碎片问题

堆:是不连续的内存区域,频繁的new/malloc会造成大量的内存碎片

栈:是一块连续的内存的区域,先入后出的结构,进出一一对应,不会产生内存碎片

3 空间大小不同

堆是不连续的内存空间,数据结构是链表,空间大。

栈和数据结构中的栈一样,是一块连续的内存空间,空间小。通常为2M

4 分配方式不同

堆是动态分配,没有静态分配。当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序

栈中有静态分配也有动态分配,静态分配是由编译器完成,动态分配由alloca函数分配,编译器自动释放,无需程序员实现

5 存放内容不同

栈:存放函数的参数值(从右往左入栈),局部变量(非静态)、函数返回地址等值

堆:比较灵活,由程序员安排

6 申请效率不同

栈由系统自动分配,速度较快。

堆是由new分配的内存,需要查找足够大的内存大小,一般速度比较慢

4.6 ELF 文件与内存布局的联系

此图参考为 C/C++内存四区介绍 - 作者:哔哩编程部 - https://www.bilibili.com/read/cv13914247/

image

其实简单来说就是 C++ 程序的内存布局可以通过查看 ELF 文件的结构来验证。

5 内存分配和管理

本节来自 https://interview.huihut.com/ 内存分配和管理

  • malloc:申请指定字节数的内存。申请到的内存中的初始值不确定。
  • calloc:为指定长度的对象,分配能容纳其指定个数的内存。申请到的内存的每一位(bit)都初始化为 0。
  • realloc:更改以前分配的内存长度(增加或减少)。当增加长度时,可能需将以前分配区的内容移到另一个足够大的区域,而新增区域内的初始值则不确定。
  • alloca:在栈上申请内存。程序在出栈的时候,会自动释放内存。但是需要注意的是,alloca 不具可移植性, 而且在没有传统堆栈的机器上很难实现。alloca 不宜使用在必须广泛移植的程序中。C99 中支持变长数组 (VLA),可以用来替代 alloca

5.1 malloc、free

用于分配、释放内存

示例代码-申请内存,确认是否申请成功:

char *str = (char*) malloc(100);
assert(str != nullptr);

示例代码-释放内存后指针置空:

free(p); 
p = nullptr;

5.2 new、delete

  • new / new[]:完成两件事,先底层调用 malloc 分配了内存,然后调用构造函数(创建对象)。
  • delete/delete[]:也完成两件事,先调用析构函数(清理资源),然后底层调用 free 释放空间。
  • new 在申请内存时会自动计算所需字节数,而 malloc 则需我们自己输入申请内存空间的字节数。

示例代码-申请内存,确认是否申请成功:

int main()
{
    T* t = new T();     // 先内存分配 ,再构造函数
    delete t;           // 先析构函数,再内存释放
    return 0;
}

6 总结

本文一开始的目的是为了解决常用面试问题:你对 C++ 的内存布局有多少了解?

在了解 C++ 内存布局的时候,发现布局内有数据段,文本段,因此引申出来,如何查看数据段文本段?这里引申出 ELF 文件。

而在了解 ELF 文件的过程中,发现 前置知识为 C/C++ 程序从源代码到可执行程序的构建过程

书写至此,全部打通。

7 扩展问题

这里发散一些与本文相关的一些问题

1 vector 最大的大小是多少

面试的过程有被问到:C++ vector 最大的大小是多少?

我一开始以为是考察我对 C++ 进程内存布局的了解,答了一下 vector 是在堆区分配的,堆区的大小是向上递增的。当没有空闲内存时就达到最大值。

其实这个问题是考察了 STL 中 vector 容器的实现,vector 维护的是一个连续的线性空间。当新的元素插入时,如果目前容量够用时则直接插入,容量不够则扩充至两倍(这个倍数和所在操作系统的堆空间管理方式有关),还不够就继续扩充(扩充的过程并不是直接在原有空间后面追加容量,而是重新申请一块连续空间,将原有的数据拷贝到新空间中,再释放原有空间,完成一次扩充)

其实再往深层问,还可以继续问操作系统的堆空间管理方式之类的。后续复习到 STL 时,会专门写篇文章,到时候再在此补充。

2 delete this 合法吗?

本节来自 https://interview.huihut.com/ delete this 合法吗?

合法,但:

  • 必须保证 this 对象是通过 new(不是 new[]、不是 placement new、不是栈上、不是全局、不是其他对象成员)分配的
  • 必须保证调用 delete this 的成员函数是最后一个调用 this 的成员函数
  • 必须保证成员函数的 delete this 后面没有调用 this 了
  • 必须保证 delete this 后没有人使用了

3 如何定义一个只能在堆上(栈上)生成对象的类?

本节来自 https://interview.huihut.com/ 如何定义一个只能在堆上(栈上)生成对象的类?

只能在堆上

  • 方法:将析构函数设置为私有
  • 原因:C++ 是静态绑定语言,编译器管理栈上对象的生命周期,编译器在为类对象分配栈空间时,会先检查类的析构函数的访问性。若析构函数不可访问,则不能在栈上创建对象。

只能在栈上

  • 方法:将 new 和 delete 重载为私有
  • 原因:在堆上生成对象,使用 new 关键词操作,其过程分为两阶段:第一阶段,使用 new 在堆上寻找可用内存,分配给对象;第二阶段,调用构造函数生成对象。将 new 操作设置为私有,那么第一阶段就无法完成,就不能够在堆上生成对象

8 参考资料

1、基础知识——C程序的内存空间布局- 作者:惺忪牛犊子 - https://blog.csdn.net/weixin_42645653/article/details/124166337

2、《Linux/UNIX系统编程手册》- 6.3 进程内存布局 - 作者:Michael Kerrisk

3、C++ 内存管理 - 作者:虞培峰 - https://zhuanlan.zhihu.com/p/264906260

4、C/C++内存四区介绍 - 作者:哔哩编程部 - https://www.bilibili.com/read/cv13914247/

5、C++之内存分布(对于堆栈空间的剖析) - 作者:右大臣 - https://oorik.blog.csdn.net/article/details/125860261

6、C/C++ Memory Layout - 作者:吴秦 - http://www.cnblogs.com/skynet/

7、c++进程内存布局 - 作者:zozoiiiiii - http://blog.chinaunix.net/uid-18831775-id-3690980.html

8、Linux平台下的ELF文件结构探索 - 作者:158SHI - https://blog.51cto.com/158SHI/6457665

9、linux下强大的ELF文件分析工具 -- readelf - 作者:悟OO道 - https://blog.csdn.net/chenzhjlf/article/details/124651103

10、ELF文件格式简介 --- 见过最细致的ELF讲解 - 作者:易先讯 - https://www.cnblogs.com/gongxianjin/p/16906719.html

posted @ 2023-08-02 11:44  她爱喝水  阅读(417)  评论(0编辑  收藏  举报