AkiraZheng's Time.

C++底层学习

Word count: 5.5kReading time: 19 min
2024/04/20

一、程序预编译-编译-链接-运行的过程

1.1 基础知识(操作系统相关)

1.1.1 可执行文件格式及数据

1)Windows和Linux的可执行文件格式

以C++为例,Windows下的可执行文件格式是.exe,格式为PE;Linux下的可执行文件是ELF格式。

2)数据和指令的区别

无论用什么语言编写的程序,最终都是一堆二进制数据,这些数据有两种类型:指令数据。指令是CPU执行的,数据是CPU读写的。

其中函数调用循环条件判断等最终都会被编译成一系列的指令存在文本段,而变量常量等则会被编译成数据存储在**数据段。

3)文本段和数据段

  • 文本段:存放程序的指令,是只读的,不允许写入。(代码区)
    • 静态成员函数、普通成员函数
  • 数据段:存放程序的数据,是可读写的。数据段又可以分为数据段BSS段
    • 数据段存放初始化的全局变量
    • BSS段存放未初始化的全局变量

这些组成了可执行文件,在程序运行时会被加载到进程的虚拟内存中,然后通过页表映射到物理内存中。

4)虚拟地址空间

前面提到在程序运行时会被加载到虚拟内存中,当程序运行时,操作系统会为每个进程分配一个虚拟地址空间(Linux下是4G),其中0-3G是用户空间,3-4G是内核空间。

其中用户空间组成为(从低位到高位):

  • 预留空间128M
  • 文本段.text:存放程序的指令
  • 数据段.data:存放程序的已初始化的数据
  • BSS段.bss:存放未初始化的全局变量
  • 堆heap
  • 栈stack

虚拟空间由用户空间内核空间组成:

1.1.2 CPU、寄存器、内存的关系

计算机的三个核心组成部分:CPU内存I/O

  • CPU:是计算机的大脑,属于高速设备,用来频繁地读取、执行指令(数据)。
  • 内存:属于中速设备,用来存储程序的指令和数据。
  • I/O:属于低速设备,用来输入输出数据。

寄存器CPU内部的一块高速缓存,用来存储CPU执行指令时的临时数据,如程序计数器指令寄存器通用寄存器等。

一般来说,程序运行中会先从内存中读取指令到寄存器中,然后CPU执行这些指令,最后将结果通过寄存器写回到内存中。也就是说,寄存器CPU内存之间的桥梁。

1.2 生成可执行文件的过程

以Linux下的C程序为例,生成可执行文件的过程主要分为四个阶段:

  • 预编译(Preprocessing)->.i文件
  • 编译(Compilation)->.s文件
  • 汇编(Assembly)->.o文件
  • 链接(Linking)->可执行文件

当我们有一个.c文件时,我们可以通过gcc命令将其编译成可执行文件:

1
2
3
4
5
6
// hello.c
#include <stdio.h>
int main() {
printf("Hello World!\n");
return 0;
}
1
2
3
$ gcc hello.c # 编译
$ ./a.out # 执行
hello world!

而生成可执行文件过程就是gcc这个语句实现的,这句语句就实现了上述说到的4个步骤,接下来我们将剖析一下这四个步骤的具体实现。

1.2.1 预编译

预编译的主要工作是将头文件以及宏定义替换成其真正的内容,得到一个.i文件。所以预编译后得到的文件将比原文件大很多。

在Linux中我们可以通过gcc -E命令来分解编译过程,使其停留在预编译阶段,并通过-o指定文件名:

1
$ gcc -E hello.c -o hello.i

结果预编译后文件的大小变大,因为预编译后的文件中包含了很多头文件的内容:

时间 文件名 大小 代码行数
预处理前 hello.c 50 3
预处理后 hello.i 1.1K 3

1.2.2 编译

编译的主要工作是将预编译后的文件转换成汇编代码,得到一个.s文件。

在Linux中我们可以通过gcc -S命令来分解编译过程,使其停留在编译阶段,并通过-o指定文件名:

1
$ gcc -S hello.i -o hello.s

最终得到的文件是一个汇编代码文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
    .file	"hello.c"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
......

1.2.3 汇编

汇编的主要工作是将汇编代码转换成机器码Machine Code(二进制),得到一个.o文件。

1
$ gcc -c hello.s -o hello.o

前面我们提到,可执行文件是由文本段数据段组成的,其实每个.o文件就是一个目标文件,其中包含了文本段数据段的内容。因此单独一个.o文件是无法运行的分布如下:

1.2.4 链接

假设我们代码不止一个文件,那么我们需要先将这些文件都全部生成为汇编代码.o,然后再将这些汇编代码链接成一个可执行文件a.out

1
$ gcc hello.o hello2.o -o a.out

在链接的过程中,会将多个.o文件中的文本段数据段合并成一个可执行文件,也就是将所有.text合并成一个.text,将所有.data合并成一个.data。最终的布局如下:

1.2.5 运行

运行时会先将可执行文件a.out加载到虚拟内存中,然后通过页表映射到物理内存中,最终通过CPU执行这些指令。

1.3 静态链接和动态链接

静态链接和动态链接的主要区别在于链接的时机不同

  • 静态链接是在编译时进行的
  • 动态链接是在运行时进行的。

其库存储的位置不同

  • 静态链接库是编译时链接集成到可执行文件
  • 动态链接库是作为独立的共享库存储的,运行时再加载到内存中。

1.3.1 静态链接

静态链接是指在链接阶段将程序中所有的静态库(如Linux下的.a文件、Windows下的.lib文件)都链接到可执行文件中,生成一个独立的可执行文件

1)优点

静态链接的方式包含了程序运行需要的所有代码和数据,因此可以独立运行,不需要依赖其他文件。

2)缺点

每个使用该库的程序都会有一份该库的拷贝,因此会占用更多的磁盘空间

同时,如果库文件更新了,那么所有使用该库的程序都需要重新编译,维护成本高

1.3.2 动态链接

动态链接是指在链接阶段只将程序中的引用链接到可执行文件中,实际链接库是在运行时进行的,操作系统执行时会根据需要加载共享库(如Linux下的.so文件、Windows下的.dll文件)。

1)优点

由于多个程序运行时只需要一个共享库,因此节省了磁盘空间

如果库文件更新了,只需要更新一份库文件,所有使用该库的程序都会自动更新

2)缺点

程序运行时需要加载共享库,因此启动速度慢

如果共享库出现问题(丢失或损坏),那么所有使用该库的程序都会受到影响。

参考:C/C++编译链接
参考:C++:深入理解编译和链接过程

二、内存管理

2.1 C++内存分区

C++程序在运行时会将内存分为5个区域

  • 栈区:存放局部变量等,由系统自动分配和释放。
  • 堆区:存放动态分配的内存,由程序员分配和释放。
  • 全局/静态区:存放全局变量静态变量,程序启动时分配,程序结束时释放。
  • 常量区:存放常量,如字符串常量。
  • 代码区:存放函数体的二进制代码

其实还有个自由存储区,也会被认为是堆区的一部分,用于存放newdelete的内存。

2.2 内存池

2.3 内存泄漏

内存泄漏在前面的博客中已经提到过:C++基础知识学习

内存泄漏主要是由newmalloc分配的内存没有被释放,长时间运行后如果内存满了会导致内存溢出、造成程序崩溃。建议通过析构函数智能指针RAII等方式来避免内存泄漏。

2.4 this指针

同样在前面的博客中也提到过:C++基础知识学习

主要注意的是它只能在类的成员函数中使用,且静态成员函数中不能使用。

2.5 类的内存分配

这部分包括类内的对齐在后面的内存对齐中会详细介绍。

三、内存对齐

内存对齐是编译器为了提高内存访问效率而采取的一种措施,能减少内存访问次数

以32位系统为例,寄存器只能从能被4整除的地址(4bytes=32bits)中读取数据,因此如果数据不是4的整数倍,那么就需要两次内存访问才能读取完整的数据。

至于对齐字节是多少,这跟硬件的颗粒度有关,比如32位系统的寄存器是32bits,64位系统的寄存器是64bits。

3.1 内存对齐的好处

  • 方便平台移植
    • 某些硬件平台不能访问任意地址的内存,只能访问某些特定地址的内存,因此需要对齐来兼容这些硬件平台。
  • 提高内存访问效率
    • 未对齐的数据在内存中需要访问两次,而对齐的数据只需要访问一次,因此对齐能提高内存访问效率。(对于数据结构特别是栈,最好是在自然边界上对齐)

3.2 C++中结构体及类的内存对齐

1)结构体的内存对齐

C++中结构体的内存对齐是由最宽基本类型决定的,即结构体中的每个成员都要对齐到最宽基本类型的整数倍。

  • 对齐原则:
    • 确定对齐基准字节数:找到结构体中最宽的基本类型,如最宽的是double,那么对齐基准字节数就是8。后面的数据都要按照8的整数倍对齐,不足的补0。
    • 整体对齐原则:最后结构体的大小sizeof(structA)必须是对齐基准字节数的整数倍。

举例说明:

1
2
3
4
5
6
struct structA {
//最大的是int,因此对齐基准字节数是4,最终大小为12
char a; //a - - -
int b; //b b b b
short c; //c c - -
};
1
sizeof(structA);//12

优化后将int放在最前面或者最后面:

1
2
3
4
5
6
struct structA {
//最大的是int,因此对齐基准字节数是4,最终大小为8,放在末尾同理
int b; //b b b b
char a; //a - - -
short c; //c c - -
};

2)类的内存对齐

  • 如果是类中的成员变量,也是按照上述规则进行对齐
  • 同时如果类中有虚函数,那么类中会有一个虚函数表指针,这个指针大小为4也要进行对齐。
  • 类中的成员函数不占实例化对象内的空间,因为成员函数共享的。
  • 所以一个类中如果只有普通函数,没有任何变量,那么这个类的大小是1。(因为类的大小最小是1
1
2
3
4
5
6
class A {
//最大的是int,因此对齐基准字节数是4,最终大小为12
int a;//a a a a
char b;//b - - -
virtual void func();//虚函数表指针:- - - -
};
1
2
3
4
class A {
void func();//成员函数不占空间
};
sizeof(A);//1

3)C++内存对齐最终还会再操作系统再次检查对齐

比如我们在一个结构体中设置两个char型数据,那么根据C++的对齐规则,该结构体的大小是2,但是操作系统会再次检查对齐,如果操作系统要求对齐是4(32位),那么最终该结构体的大小是4(通过检查两个类对象的地址差发现的)。

1
2
3
struct test2 {
char a;
};

当用sizeof和通过地址差来检查对齐时,会发现sizeof(test2)2,但是两个类对象的地址差是4

1
2
3
4
5
6
7
8
9
10
int main(){
test2 t1;
test2 t2;

cout << sizeof(test2) << endl;//1
cout << &t2<< endl;//000000D4769CF384
cout << &t1<< endl;//000000D4769CF3A4
//发现前后差了4,说明跟我们想象中的一个对象占用1个字节不一样,猜测操作系统也进行了对齐操作
return 0;
}

四、内存池设计

4.1 C++默认的内存管理函数

4.1.1 系统分配内存空间

系统在接收到内存分配请求时,会有以下的操作:

  • 查找内存空闲表
  • 按照一定算法分配不小于申请需求的内存块
  • 切割成合适的大小返回给用户
  • 更新内存表
  • 如果涉及多线程,由于多线程共享一块内存空间,所以还会统一进行锁竞争

那么就会存在内存碎片效率性能的问题:

1)内存碎片

  • 由于上内存的分配和释放是程序员控制的、不连续的,所以会产生内存碎片,导致内存利用率低
  • 内存碎片又分为外部碎片内部碎片
    • 外部碎片是指已经被释放的内存,但是由于大小不合适无法分配给新的内存请求。
    • 内部碎片是指已分配的空间如C++中的new分配的内存,由于对齐等原因导致的浪费

2)效率性能问题

  • 在多线程环境下,并发加锁会导致性能下降
  • 就像前面提到的系统分配内存的方式需要经过查找分配更新等操作,这些操作都会消耗时间,因此会影响效率

所以需要引入内存池优化性能以及减少内存碎片问题。

内存池是一种预先分配一定数量的内存,然后按需分配给用户,用户使用完后再归还给内存池,这样就可以减少内存碎片减少频繁地向系统申请和释放资源来提高效率

4.1.2 malloc内存分配机制

1)C++内存回收

C/C++是编译型语言,没有内存回收机制,程序员需要自己释放不需要的内存。

C++中的内存管理方式主要有运算操作符new函数malloc两种,其中new也是基于malloc实现的。所以我们这里主要讨论malloc

在C++程序中进行内存回收的方式有全局变量自动回收delete、free回收RAII通过析构函数自动回收等方式

对于上的资源,由于上面提到的效率内存碎片问题,C++不会直接向堆申请资源,而是借助malloc函数来向系统申请资源。

2)malloc内存分配机制

malloc属于标准库用于接口操作层,而用户调用malloc接口属于在用户层实现的,就像上面提到的,出于性能考虑,malloc是通过内存池的方式来管理内存的。

malloc的实现机制简单来说是,当我们申请一块如8B内存时,会实际上向系统申请一块更大的如1M内存,然后后面申请内存时都是从这1M内存中分配,当这1M内存不够时,再向系统申请1M内存。同时malloc内部还尝试通过空闲链表的设计方式来减少内存碎片

其中最主要的分配内存思想是分配时会搜索空闲链表,找到第一个大于等于所需空间的空闲区块来进行分配,并根据不同的编译器具有不同的分配方式(一般不同编译器平台用的都是不同的内存分配算法):

  • 如windows下的采用的是微软的一套方案
  • Linux下gcc用的glibc采用的内存分配器是ptmalloc

malloc的优点:减少内存碎片

malloc 只分配几种固定大小的内存块,可以减少外部碎片。

malloc的缺点:多线程下性能问题

  • malloc的分配区有互斥锁来保证线程安全,但是进程中多个线程是共享一个malloc的分配区的,加锁的时间代价高。
  • 由于需要查找合适的空闲区块,因此还有查找耗时的问题。

4.2 经典内存池的设计

4.2.1 设计思路

最基础的内存池设计类似于malloc的设计,主要包括内存分配内存释放两个操作。操作是由空闲链表来实现的。

  • 首先是提前向OS申请一块大内存,然后将这块内存均分成小块(后面优化可以分割成不同大小的内存块)

  • 用链表表头freeNodeHead来存储空闲内存块,当用户申请内存时,就从freeNodeHead中取出一块内存块分配给用户

  • 分割后的内存块通过链表组成内存节点

  • 每次分配内存时从空闲链表中取出一个头节点,时间复杂度为O(1)

  • 每次释放内存时将内存节点插入到空闲链表头部,时间复杂度为O(1)

  • 当空间不够用时,再向OS申请一块大内存

4.2.2 内存池的结构体设计

1)变量

  • 申请的内存块头指针:pBlockHeader
    • 内存池可以申请多块内存
  • 上述每个内存块的空闲链表头指针:pFreeNodeHead
    • 每个内存块可以被分割成更小的内存
    • 每个内存块的空闲链表都是独立

节点间的关系如下:

2)结构体

  • 内存块结构体MemoryBlock

    • 包括内存块所有空闲节点空闲链表头指针
      1
      2
      3
      4
      5
      struct MemoryBlock
      {
      MemBlock *pNext;
      FreeNode data[NumofObjects];
      };
  • 空闲节点结构体FreeNode

    • 包括空闲节点数据下一个空闲节点指针
      1
      2
      3
      4
      struct FreeNode {
      FreeNode* pNext;
      char data[ObjectSize];
      };

4.3 tcmlloc内存池

tcmalloc 是 Google 开发的内存分配器,在多线程场景下性能表现比malloc更好。

主要表现在tcmalloc会为每个单独的线程申请一块独享内存空间,在线程级实现了缓存,使得用户在申请内存时大多情况下是无锁内存分配

4.3.1 tcmalloc的架构

tcmalloc的架构在应用层由上至下主要包括:多个Thread-cache线程缓存、一个Central-cache中央缓存和一个Page-cache页缓存三部分。

  • Thread-cache线程缓存:每个线程都有一个线程缓存,用于存储小块内存,线程在申请内存时优先从线程缓存中申请,如果线程缓存中没有内存,那么再从中央缓存中申请。
    • 由于线程缓存独享的,因此线程缓存无锁的。
  • Central-cache中央缓存:用于存储大块内存线程缓存中没有内存时会从中央缓存中申请。
    • 中央缓存共享的,因此需要加锁
    • 但是tcmalloc设计中,用哈希桶来减少锁竞争,每个哈希桶对应一个,因此锁竞争的概率会降低。
  • Page-cache页缓存:用于存储大块内存中央缓存中没有内存时会从页缓存中申请。
    • 页缓存共享的,且为串行方式,每次仅有一个线程可以访问,因此页缓存有锁的。

4.3.2 tcmalloc的性能优化

1)效率优化

查找时间优化

为了减少查找合适内存块的时间tcmalloc采用了哈希桶的方式来设计内存块的分割:

前面讲到分割一块内存块线性的,而且首次分割是等值的。而tcmalloc通过空间换时间的方式,将内存块分割成不同大小的内存块,然后将这些内存块放到哈希桶中,这样在分配内存时就可以直接按照对应大小找到合适的内存块,而不需要线性查找

这里大小不同是按照2的幂次来分割的,如163264等。

多线程竞争优化

  • 线程缓存独享的无锁机制
  • 中央缓存采用哈希桶来减少锁竞争(多线程访问同一个哈希桶的概率降低)
  • 加锁使用更高效的自旋锁(自旋锁减少上下文切换、持锁时间短)

2)减少内存碎片

central cache释放回一个span,则依次寻找page cache中span所管理的页号的前后页号的页有没有空闲,看是否可以合并,如果合并继续向前寻找。这样就可以将切小的内存合并收缩成大的span,减少内存碎片。

4.3.3 tcmalloc的缺点

所需内存较大的服务时,独享Thread-cache保守的小内存空间会失去意义,这种情况下当请求量上来,锁冲突严重,CPU使用率将指数暴增。

实际生成环境我们可以选择用jemalloc

参考:【项目】九万字手把手教你写高并发内存池(化简版tcmalloc)
参考:C++ 内存池介绍与经典内存池的实现

CATALOG
  1. 一、程序预编译-编译-链接-运行的过程
    1. 1.1 基础知识(操作系统相关)
      1. 1.1.1 可执行文件格式及数据
      2. 1.1.2 CPU、寄存器、内存的关系
    2. 1.2 生成可执行文件的过程
      1. 1.2.1 预编译
      2. 1.2.2 编译
      3. 1.2.3 汇编
      4. 1.2.4 链接
      5. 1.2.5 运行
    3. 1.3 静态链接和动态链接
      1. 1.3.1 静态链接
      2. 1.3.2 动态链接
  2. 二、内存管理
    1. 2.1 C++内存分区
    2. 2.2 内存池
    3. 2.3 内存泄漏
    4. 2.4 this指针
    5. 2.5 类的内存分配
  3. 三、内存对齐
    1. 3.1 内存对齐的好处
    2. 3.2 C++中结构体及类的内存对齐
  4. 四、内存池设计
    1. 4.1 C++默认的内存管理函数
      1. 4.1.1 系统分配内存空间
      2. 4.1.2 malloc内存分配机制
    2. 4.2 经典内存池的设计
      1. 4.2.1 设计思路
      2. 4.2.2 内存池的结构体设计
        1. 1)变量
        2. 2)结构体
    3. 4.3 tcmlloc内存池
      1. 4.3.1 tcmalloc的架构
      2. 4.3.2 tcmalloc的性能优化
        1. 1)效率优化
        2. 2)减少内存碎片
      3. 4.3.3 tcmalloc的缺点