【八股文 02】C++ 进程内存布局及其相关知识

发布时间 2023-08-02 11:49:42作者: 她爱喝水

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 总结

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 进程内存布局(内存分配方式)

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

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

在 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

5 总结

本文一开始的目的是为了解决常用面试问题:你对 C++ 的内存布局有多少了解,在了解 C++ 内存布局的时候,发现布局内有数据,文本段,因此引申出来,如何查看数据段文本段?这里引申出 ELF 文件,而在了解 ELF 文件的过程中,发现前置知识为 C/C++ 程序从源代码到可执行程序的构建过程,至此全部打通。

6 参考资料

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