Linux用户空间内存分布

程序内存在地址空间中的分布情况称为内存模型。
对于32位环境,理论上程序可以拥有4GB的虚拟地址空间,要拿出一部分给操作系统内核使用,应用程序无法直接访问这一段内存,这一部分内存地址被称为内核空间
Linux默认将高地址的1GB空间分配给内核。也就是说,应用程序只能使用剩下3GB的地址空间,称为用户空间

Linux用户空间内存分布
内存分区 说明
text段(程序代码 区) 存放已编译程序的机器代码,只读。
rodata段(常量区) 存放一般的常量、字符串常量等。这块内存只有读取权限,没有写入权限,因此它们的值在程序运行期间不能改变。
data段(全局数据区的已初始化部分) 存放已经初始化的全局变量和静态变量等,这块内存有读写权限,因此它们的值在程序运行期间可以任意改变。
bss段(全局数据区的未初始化部分) 存放未初始化的全局变量和静态变量等。
heap(堆区) 一般由程序员分配和释放,若程序员不释放,程序运行结束时由操作系统回收。与数据结构中的堆不是一个概念,堆区的分配方式倒是类似于链表。
文件/内存映射区(动态链接库) 用于在程序运行期间加载和卸载动态链接库。例如使用 mmap() 创建内存映射区时,就是在这里申请的内存空间。
stack(栈区) 存放函数的参数值、局部变量的值等,其操作方式类似于数据结构中的栈。

Linux程序内存空间

Linux 下内存资源是通过虚拟内存管理的,在分配内存时并不是在物理内存开辟了一段空间,而是在使用时才分配的,并且是通过段页式管理。linux 下内存分配是以页为单位的,而页是通过段管理。各个段之间是独立的,方便管理。

Linux 程序执行时能够分为下面几个内存段:text、rodata、data、bss、stack、heap

  1. text 段(代码段):text 段存放程序代码,运行前就已经确定(编译时确定),通常为只读;
  2. rodata 段(常量区):rodata 段存储常量数据,比如程序中定义为 const 的全局变量,“#define”定义的常量,以及诸如“Hello World”的字符串常量。const 修饰的全局变量在常量区,const 修饰的局部变量只是为了防止修改,没有放入常量区(存在栈区);
  3. data 段:data 存储已经初始化的全局变量(静态变量),属于静态内存分配,需要占用可执行文件空间(注意:初始化为0的全局变量和静态变量还是被保存在 bss 段);
  4. bss 段:bss 段存储没有初始化或初始化为0的全局变量(静态变量),属于静态内存分配。bss 不占用可执行文件空间,其内容由操作系统初始化(清零),但占据程序运行时的内存空间;
  5. stack 段:stack 段存储参数变量和局部变量,由系统进行申请和释放,属于静态内存分配;
  6. heap 段:heap 段是程序运行过程中动态分配的内存段,由用户申请和释放,程序中不释放,则程序结束时,由 OS 回收。

执行文件中包含了 text、rodata、data 段的内容,不包含 bss 段内容(一堆0放入执行文件没有意义)。 堆和栈的内存增长方向是相反的:栈是从高地址向低地址生长,堆是从低地址向高地址生长。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const int NUM = 10086; // 常量区(rodata段)
int a = 10;    // 全局初始化区(data)
char *p1;      // 全局未初始化区(bss)

int main()
{
    int b;            // 栈区
    char s[] = "abc"; // 栈区
    char *p; 		  // 栈区

    p1 = (char *)malloc(10);
    p2 = (char *)malloc(20); // 分配得来的10和20字节的区域就在堆区 

    static int c =0;         // bss段
    const int d = 5;         // stack段
    
    char *p3 = "123456";     // p3在栈区,"123456\0" 在rodata段    
    strcpy(p1, "123456");    // "123456\0" 放在rodata段,编译器可能会将它与p3所指向的"123456"优化成一个地方。
}

虚拟内存

物理内存是实际的物理空间,即直接映射到硬件 RAM 的物理内存资源。
虚拟内存是逻辑上的地址空间,它通过将物理内存和硬盘空间组合使用,从逻辑上扩展了物理内存的大小,从而让程序获得更多的可用内存。

为了更好的管理内存,操作系统将内存抽象成地址空间。每个程序拥有自己的地址空间,这个地址空间被分割成多个页(Linux下页大小为4KB,即4096字节)。而物理内存则被分成相同大小的页面帧,程序的页被映射到物理内存对应的帧(但不需要映射到连续的物理内存,也不需要所有页都必须在物理内存中)。当程序访问到不在物理内存中的页时,发生缺页中断,将缺失的页装入物理内存并重新执行失败的指令。

从上面的描述中可以看出,虚拟内存允许程序不用将地址空间中的每一页都映射到物理内存,也就是说一个程序不需要全部调入内存就可以运行,这使得有限的内存运行大程序成为可能。例如有一台计算机可以产生16位地址,那么一个程序的地址空间范围是0~64K。该计算机只有32KB的物理内存,虚拟内存技术允许该计算机运行一个64K大小的程序。

虚拟内存

new/delete 和 malloc/free

new 和 delete 是用户进行动态内存申请和释放的操作符,operator newoperator delete 是系统提供的全局函数,new 在底层调用 operator new 全局函数来申请空间,delete 在底层通过 operator delete 全局函数来释放空间。

new的过程

  1. 调用 operator new 函数申请空间;
  2. 在申请的空间上执行构造函数,完成对象的构造。

delete的过程

  1. 在申请的空间上执行析构函数,完成对象中资源的清理工作;
  2. 调用 operator delete 函数释放对象的空间。

new T[N]的过程

  1. 调用 operator new[] 函数,在 operator new[] 中实际调用 operator new 函数完成N个对象空间的申请;
  2. 在申请的空间上执行N次构造函数。

delete[]的过程

  1. 在释放的对象空间上执行N次析构函数,完成N个对象中资源的清理;
  2. 调用 operator delete[] 释放空间,实际在 operator delete[] 中调用 operator delete 来释放空间。

new/delete 和 malloc/free的异同

相同点是: 都是从堆上申请空间,并且需要用户手动释放。

不同点是:

  1. malloc 和 free 是函数,new 和 delete 是操作符
  2. malloc 申请的空间不会初始化,new 可以初始化;
  3. malloc 申请空间时,需要手动计算空间大小并传递,new 只需在其后跟上空间的类型即可;
  4. malloc 的返回值为 void*, 在使用时必须强转;new 不需要,因为 new 后跟的是空间的类型;
  5. malloc 申请空间失败时,返回的是 NULL,因此使用时必须判空;new 不需要,但是 new 需要捕获异常;
  6. 申请自定义类型对象时,malloc/free 只会开辟与销毁空间,不会调用构造函数与析构函数;而 new 在申请空间后会调用构造函数完成对象的初始化,delete 在释放空间前会调用析构函数完成空间中资源的清理
  7. new/delete 比 malloc/free 的效率稍微低点,因为 new/delete 的底层封装了 malloc/free。

free 如何知道要 free 多大的空间

malloc 函数的实现是以块分配内存,在被分配的块中包括两部分:

  1. 第一部分中存储含有报头的元数据,它其中包含有分配块的大小信息,是一个常量;
  2. 第二部分中存储实际用户数据。而使用 malloc 分配内存返回的是第二部分用户数据的地址。

所以内存释放时不再需要再指定释放多大的内存空间,只需要指定该块内存空间的首地址即可。

new 一个对象时加括号和不加括号的区别

  • 对于内置类型:
    int *a = new int; 不会将申请到的 int 空间初始化,而 int *a = new int(); 则会将申请到的 int 空间初始化为0。

    1
    2
    
    int *a1 = new int[10];   // 10个未初始化int
    int *a2 = new int[10](); // 10个值初始化为0的int
    
  • 对于自定义类类型:

    • 如果该类显式定义了默认构造函数,那么 class c = new class;class c = new class(); 一样,都会调用默认构造函数。

    • 如果该类没有显式定义构造函数(由编译器生成默认构造函数)但有虚函数,那么 class c = new class;class c = new class(); 一样,都会调用默认构造函数;

      如果该类没有显式定义构造函数(由编译器生成默认构造函数)也没有虚函数,那么 class c = new class 将不调用合成的默认构造函数,而 class c = new class() 则会调用默认构造函数。


malloc/free 实现原理

brk 和 mmap 函数

brk() 和 sbrk() 函数:作用是扩展 heap 的上界 brk

1
2
3
4
5
// addr为新的brk地址,成功返回0,失败返回-1
int brk(const void *addr);

// incr为需要申请的内存大小,返回heap新的上界brk地址。若sbrk的参数为0,则返回原来的brk地址
void* sbrk (intptr_t incr);

mmap() 和 munmap() 函数:映射磁盘文件到内存中或匿名映射(向映射区申请一块内存)

1
2
3
4
5
6
7
// addr为映射区的开始地址,length为映射区的长度,prot为期望的内存保护标志,
// flags指定映射对象的类型,fd为文件描述符,offset为文件映射的偏移量,通常设置为0,
// 代表从文件最前方开始对应,offset必须是分页大小的整数倍
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

// munmap执行与mmap相反的操作,删除特定地址区域的对象映射
int munmap(void *addr, size_t length);

实现原理

malloc 申请内存的时候,会有两种方式向操作系统申请堆内存。
方式一:如果用户分配的内存 $<128 KB$,则通过 brk() 系统调用从堆分配内存;
方式二:如果用户分配的内存 $\geq128 KB$,则通过 mmap() 系统调用在文件/内存映射区域分配内存。

brk 是将 heap 的上界指针 brk 往高地址推,mmap 是在进程的虚拟地址空间中(堆和栈中间,称为内存/文件映射区域的地方)找一块空闲的虚拟内存。 这两种方式分配的都是虚拟内存,没有分配物理内存。在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系。

brk 分配的内存需要等到高地址内存释放以后才能释放(这就是内存碎片产生的原因),当最高地址空间的空闲内存超过128K(可由 M_TRIM_THRESHOLD 选项调节)时,执行内存紧缩操作(trim)。 mmap 分配的内存可以单独释放。

brk和mmap

调用 free(A) 后 A 对应的虚拟内存和物理内存都没有释放,因为只有一个 brk 指针,如果往回推,那么 B 这块内存怎么办呢?
当然 A 这块内存是可以重用的,如果这个时候再来一个 40KB 的请求,那么 malloc 很可能就使用 A 这块内存。

进程调用 free(B) 以后,A 和 B 连接起来,变成一块 150KB 的空闲内存,于是内存紧缩。

malloc 采用了内存池的实现方式:先申请一大块内存,然后将内存分成不同大小的内存块,用户申请内存时,直接从内存池中选择一块相近的内存块即可。brk 分配内存时优先从内存池获取,失败的话走 brk 系统调用。细节参考:简书:glibc内存管理那些事儿

既然堆内内存 brk 和 sbrk 不能直接释放,导致疑似“内存泄露”问题,为什么不全部使用 mmap 来分配,munmap 直接释放,而是仅仅对于大于 128K 的大块内存才使用 mmap?

因为 brk 和 mmap 都是系统调用,频繁调用系统调用都比较消耗系统资源。使用 mmap 申请的空间,直接调用 munmap 是将空间真正释放了,而 brk 释放的空间,并不一定真正释放了,那些没有被真正释放的内存碎片可以被重复利用,再次访问该内存可能不需产生任何系统调用和缺页中断,这将大大降低 CPU 的消耗。


malloc 和 tcmalloc

malloc 和 tcmalloc 都是内存分配库,主要的不同在于实现方式和效率。

  • malloc 是C标准库中的一部分,是系统提供的内存分配器。它的主要作用是管理进程虚拟内存空间,负责分配和释放内存资源。但是,由于多线程并发执行时需要加锁,所以它的性能并不是最优的。
  • tcmalloc 是 Google 开发的一个高性能、多线程的内存管理库,是 malloc 的替代品之一。tcmalloc 采用线程本地缓存技术(Thread-Caching Malloc,TCMalloc),避免了锁的竞争,从而在多线程环境下具有更好的性能表现。此外,tcmalloc 还会将未使用的页面设置为“dirty”状态,提高缓存命中率,进一步提升了其效率。

总的来说,tcmalloc 比 malloc 具有更高的性能和更好的可扩展性,在大型高并发的应用场景中使用更为适合。但是,tcmalloc 也相对较复杂,需要结合实际情况进行使用和优化。

参考:内存优化总结:ptmalloc、tcmalloc和jemallocptmalloc、tcmalloc与jemalloc对比分析

内存管理可以分为三个层次,自底向上分别是:

  • 内核层
  • C运行时库层(glibc 层使用系统调用维护的内存管理算法)
  • 应用程序层(应用程序从 glibc 动态分配内存后,根据应用程序本身的程序特性进行优化,比如使用引用计数 std::shared_ptr,内存池方式等)

现状

目前大部分服务端程序使用 glibc 提供的 malloc/free 系列函数,而 glibc 使用的 ptmalloc2 在性能上远远弱后于 google 的 tcmalloc 和 facebook 的 jemalloc。而且后两者只需要使用 LD_PRELOAD 环境变量启动程序即可,甚至并不需要重新编译。

ptmalloc2

ptmalloc2 即是我们当前使用的 glibc malloc 版本。

ptmalloc2 操作 heap 时,一般大内存采用 mmap,小内存使用 brk。

🔰 主分配区和非主分配区

ptmalloc 的内存分配器中,为了解决多线程锁争夺问题,分为主分配区(main_area)和非主分配区(no_main_area)。

  • 每个进程有一个主分配区,也可以允许有多个非主分配区。
  • 主分配区可以使用 brk 和 mmap 来分配内存,而非主分配区只能使用 mmap 来映射内存块(批发申请 HEAP_MAX_SIZE 大小的虚拟内存)。当用户向非主分配区请求分配内存时再切割成小块“零售” 出去,毕竟系统调用是相对低效的,直接从用户空间分配内存快多了。所以 ptmalloc 在必要的情况下才会调用 mmap 函数向操作系统申请虚拟内存。
  • 非主分配区的数量一旦增加,则不会减少。
  • 主分配区和非主分配区形成一个环形链表进行管理。

当某一线程需要调用 malloc 分配内存空间时,该线程先查看线程私有变量中是否已经存在一个分配区,如果存在,尝试对该分配区加锁,如果加锁成功,使用该分配区分配内存,如果失败,该线程搜索循环链表试图获得一个没有加锁的分配区。如果所有的分配区都已经加锁,那么 malloc 会开辟一个新的分配区,把该分配区加入到全局分配区循环链表并加锁,然后使用该分配区进行分配内存操作。

在释放操作中,线程同样试图获得待释放内存块所在分配区的锁,如果该分配区正在被别的线程使用,则需要等待直到其他线程释放该分配区的互斥锁之后才可以进行释放操作。

🔰 chunk 内存块的基本组织单元

ptmalloc 通过 chunk 的数据结构来组织每个内存单元。当我们使用 malloc 分配得到一块内存的时候,这块内存就会通过 chunk 的形式被 glibc 管理起来。你可以把它想象成自己写内存池的时候的一个内存数据结构。chunk 的结构可以分为使用中的 chunk 和空闲的 chunk。

空闲的 chunk 会被放置到空闲的链表 bins 上。当用户 malloc 申请内存的时候,会先去查找空闲链表 bins上是否有合适的内存。

🔰 空闲链表 bins

当用户使用 free 函数释放掉的内存,ptmalloc 并不会马上交还给操作系统,而是被 ptmalloc 本身的空闲链表 bins 管理起来了,这样当下次进程需要 malloc 一块内存的时候,ptmalloc 就会从空闲的 bins 上寻找一块合适大小的内存块分配给用户使用。这样的好处可以避免频繁的系统调用,降低内存分配的开销。

ptmalloc

ptmalloc 一共维护了128个 bin。每个 bin 都维护了大小相近的双向链表的 chunk。通过上图这个 bins 的列表就能看出,当用户调用 malloc 的时候,能很快找到用户需要分配的内存大小是否在维护的 bin 上,如果在某一个 bin 上,就可以通过双向链表去查找合适的 chunk 内存块给用户使用。

  • fast bins:fast bins 是 bins 的高速缓冲区,大约有10个定长队列。当用户释放一块不大于 max_fast(默认值64B)的 chunk 的时候,会默认会被放到 fast bins 上。当用户下次需要申请内存的时候首先会到 fast bins 上寻找是否有合适的 chunk。ptmalloc 会遍历 fast bin,看是否有合适的 chunk 需要合并到 bins 上。
  • unsorted bin:是 bins 的一个缓冲区。当用户释放的内存大于 max_fast 或者 fast bins 合并后的 chunk 都会进入 unsorted bin 上。当用户 malloc 的时候,先会到 unsorted bin 上查找是否有合适的 bin,如果没有合适的 bin,ptmalloc 会将 unsorted bin 上的 chunk 放入 bins 上,然后到 bins 上查找合适的空闲 chunk。
  • small bins large bins:small bins 和 large bins 是真正用来放置 chunk 双向链表的。每个 bin 之间相差8个字节,并且通过上面的这个列表,可以快速定位到合适大小的空闲 chunk。前64个为 small bins 定长;后64个为 large bins,非定长。
  • Top chunk:并不是所有的 chunk 都会被放到 bins 上。top chunk 相当于分配区的顶部空闲内存,当 bins 上都不能满足内存分配要求的时候,就会来 top chunk 上分配。
  • mmaped chunk:当分配的内存非常大(大于分配阀值,默认128K)的时候,需要被 mmap 映射,则会放到 mmaped chunk 上,当释放 mmaped chunk 上的内存的时候会直接交还给操作系统。

✏️ 内存分配 malloc() 的流程

malloc()的流程
  1. 获取分配区的锁,防止多线程冲突;

  2. 计算出需要分配的内存的 chunk 实际大小;

  3. 判断chunk的大小,如果小于 max_fast(64B),则取 fast bins 上去查询是否有适合的 chunk,如果有则分配结束;

  4. chunk 大小是否小于512B,如果是,则从 small bins 上去查找 chunk,如果有合适的,则分配结束;

  5. 继续从 unsorted bins 上查找:

    • 如果 unsorted bins 上只有一个 chunk 并且大于待分配的 chunk,则进行切割,并且剩余的 chunk 继续扔回 unsorted bins;

    • 如果 unsorted bins 上有大小和待分配 chunk 相等的,则返回,并从 unsorted bins 删除;

    • 如果 unsorted bins 中的某一 chunk 大小属于 small bins 的范围,则放入 small bins 的头部;

    • 如果 unsorted bins 中的某一 chunk 大小属于 large bins 的范围,则找到合适的位置放入;

  6. 从 large bins 中查找,找到链表头后,反向遍历此链表,直到找到第一个大小大于待分配的 chunk,然后进行切割,如果有余下的,则放入 unsorted bin 中去,分配则结束;

  7. 如果搜索 fast bins 和其他 bins 都没有找到合适的 chunk,那么就需要操作 top chunk 来进行分配了(top chunk 相当于分配区的剩余内存空间)。判断 top chunk 大小是否满足所需 chunk 的大小,如果是,则从 top chunk 中分出一块来;

  8. 如果 top chunk 也不能满足需求,则需要扩大 top chunk。主分区上,如果分配的内存小于分配阀值(默认128K),则直接使用 brk 分配一块内存;如果分配的内存大于分配阀值,则需要 mmap 来分配。

    非主分区上,则直接使用 mmap 来分配一块内存。通过 mmap 分配的内存,就会放入 mmaped chunk 上,mmaped chunk 上的内存会直接回收给操作系统。

✏️ 内存释放 free() 的流程

  1. 获取分配区的锁,保证线程安全;

  2. 如果 free 的是空指针,则返回,什么都不做;

  3. 判断当前 chunk 是否是 mmap 映射区域映射的内存,如果是,则直接 munmap 释放这块内存。已使用 chunk 的数据结构中,有对应的标识判断是否是 mmap 映射的内存;

  4. 判断 chunk 是否与 top chunk 相邻,如果相邻,则直接和 top chunk 合并(和 top chunk 相邻相当于和分配区中的空闲内存块相邻),转到步骤8;

  5. 如果 chunk 的大小大于 max_fast(64B),则放入 unsorted bin,并且检查是否有合并,有合并情况并且和 top chunk 相邻,则转到步骤8;没有合并情况则 free;

  6. 如果 chunk 的大小小于 max_fast(64B),则直接放入 fast bin,fast bin 并没有改变 chunk 的状态。没有合并情况,则 free;有合并情况,转到步骤7;

  7. 在 fast bin,如果当前 chunk 的下一个 chunk 也是空闲的,则将这两个 chunk 合并,放入 unsorted bin 上面。合并后的大小如果大于64KB,会触发进行 fast bins 的合并操作,fast bins 中的 chunk 将被遍历,并与相邻的空闲 chunk 进行合并,合并后的 chunk 会被放到 unsorted bin 中,fast bin 会变为空。合并后的 chunk 和 top chunk 相邻,则会合并到 top chunk 中。转到步骤8;

  8. 判断 top chunk 的大小是否大于 mmap 收缩阈值(默认为128KB),如果是的话,对于主分配区,则会试图归还 top chunk 中的一部分给操作系统。free 结束。

✏️ ptmalloc 的问题

ptmalloc 的主要问题其实是内存浪费、内存碎片、以及加锁导致的性能问题

  1. 内存浪费:

    每个 chunk 本身至少8字节的开销很大;

    后分配的内存先释放,可能无法及时归还系统。因为 ptmalloc 收缩内存是从 top chunk 开始,如果与 top chunk 相邻的 chunk 不能释放,top chunk 以下的 chunk 都无法释放;

    内存不能在线程间移动,多线程使用内存不均衡将导致内存浪费。

  2. 内存碎片:不定期分配长生命周期的内存容易造成内存碎片,不利于回收。

  3. 加锁导致的性能问题:加锁耗时,无论当前分区有无耗时,在内存分配和释放时,会首先加锁。

tcmalloc

tcmalloc 是 Google 开源的一个内存管理库, 作为 glibc malloc 的替代品。目前已经在 chrome、safari 等知名软件中运用。

🔰 对象分配

  • tcmalloc 为每个线程分配了一个线程本地 ThreadCache,小内存从 ThreadCache 分配,此外还有个中央堆(CentralCache),ThreadCache 不够用的时候,会从 CentralCache中 获取空间放到 ThreadCache 中。
  • 小对象($\leq32KB$)从 ThreadCache 分配,大对象从 CentralCache 分配。大对象分配的空间都是4K页面对齐的,多个 pages 也能切割成多个小对象划分到 ThreadCache 中。

✏️ tcmalloc的优势

  • 小内存可以在 ThreadCache 中不加锁分配(加锁的代价大约100ns)
  • 大内存可以直接按照大小分配不需要再像 ptmalloc 一样进行查找
  • 大内存加锁使用更高效的自旋锁
  • 减少了内存碎片

内存对齐

什么是内存对齐

现代计算机中内存空间都是按照字节(byte)划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但是实际的计算机系统对基本类型数据在内存中存放的位置有限制,它们会要求这些数据的首地址的值是某个数k(通常它为4或8)的倍数,这就是所谓的内存对齐。

为什么要内存对齐

主要是由于 CPU 的访问内存的特性决定,CPU 访问内存时并不是以字节为单位来读取内存,而是以机器字长为单位,实际机器字长由 CPU 数据总线宽度决定的。实际 CPU 运行时,每一次控制内存读写信号发生时,CPU 可以从内存中读取数据总线宽度的数据,并将其写入到 CPU 的通用寄存器中。内存对齐的主要目的是为了减少 CPU 访问内存的次数。假设读取8个字节的数据,按照每次读取4个字节的速度,则8个字节需要 CPU 耗费2次读取操作。CPU 始终以字长访问内存,如果不进行内存对齐,很可能增加 CPU 访问内存的次数。

怎么使用内存对齐

我们可以用#progma pack(x)指定结构体以x为单位进行对齐。一般情况下使用方法如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#pragma pack(push)  /* push current alignment to stack */
#pragma pack(1)     /* set alignment to 1 byte boundary */

struct MyPackedData
{
    char Data1;  // 1 byte
    long Data2;  // 8 byte(64位环境)
    char Data3;  // 1 byte
};

#pragma pack(pop)   /* restore original alignment from stack */

int main() {
    cout << alignof(MyPackedData) << endl;  // 1
    cout << sizeof(MyPackedData) << endl;   // 10
    return 0;
}

C++11之后提供了alignas关键字,允许往更大的字节数去对齐(有的平台不支持未对齐内存访问,alignas 的目的是允许你往更大的字节数去对齐,比如 char 对齐到32位供 SIMD load)。

即 pack 是变小,alignas 是变大,用法如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
struct alignas(1) Point // 使用g++能正常运行,但是在CLion中会报错,因为这样做是不合规范(ill-formed)的
{
    int a;
    char b;
}p;

cout << alignof(p) << endl;  // 4
cout << sizeof(p)  << endl;  // 8

#pragma pack(push)
#pragma pack(1)
struct Point2 {
    int a;
    char b;
}p2;
#pragma pop(pop)

cout << alignof(p2) << endl;  // 1
cout << sizeof(p2) <<  endl;  // 4

用户态与内核态

用户态/内核态的概念

CPU 指令可以直接操作硬件,而对于硬件的操作是非常复杂的,出问题的几率相当大,所以操作系统内核直接屏蔽了个人开发者对于硬件操作的可能。因为这个需求,硬件设备商直接提供了硬件级别的支持,做法就是对 CPU 指令设置了权限,不同级别的权限可以使用的 CPU 指令是有限制的。

以 Intel CPU 为例,CPU 指令操作的权限划为4级:ring 0ring 1ring 2ring 3。其中ring 0权限最高,可以使用所有 CPU 指令,ring 3 权限最低,仅能使用常规 CPU 指令,不能使用访问硬件资源的指令,比如 I/O读写、网卡访问、申请内存等。

Linux 系统内核采用了:ring 0ring 3 这2个权限

  • ring 0 被叫做 内核态,完全在操作系统内核中运行,由专门的内核线程执行其任务;
  • ring 3 被叫做 用户态,在应用程序中运行,由用户线程执行其任务。

切换方式

从用户态到内核态切换可以通过三种方式,或者说会导致从用户态切换到内核态的操作:

  1. 系统调用:系统调用本身就是中断,属于软件中断,跟硬件中断不同。系统调用机制是使用了操作系统为用户特别开放的一个中断来实现,如 Linux 的 int 80h 中断。比如我们使用库函数 fopen 打开文件,就会触发 open 系统调用。
  2. 异常:如果当前进程运行在用户态,如果这个时候发生了异常事件,会触发由当前运行进程切换到处理此异常的内核相关进程中。
  3. 外围设备中断:硬件中断,外围设备完成用户请求的操作之后,会向 CPU 发出中断信号,这时 CPU 会转去处理对应的中断处理程序。

切换过程

当发生用户态到内核态的切换时,会发生如下过程(本质上是从“用户程序”切换到“内核程序”)

  1. 设置处理器至内核态;

  2. 保存当前程序的寄存器(栈指针、程序计数器、通用寄存器);

  3. 将栈指针设置指向内核栈地址;

  4. 执行中断处理程序;

  5. 恢复之前保存的寄存器,从中断处理程序返回。

具体流程参考:中断(系统调用)的具体过程


中断(系统调用)的具体过程

总结: 用户运行库函数(系统调用的封装),函数里面其实是执行的 int 0x80 指令。系统调用先把系统调用号保存在 eax 寄存器中,然后执行 int 0x80 指令。int 0x80 指令先进行切换堆栈(找到进程的堆栈,将寄存器值压入到内核栈中,将 esp,ss 设置成对应内核栈的值),查找相应中断向量的中断处理程序(system_call)并调用,随后 system_call 从系统调用表中找到相应的系统调用进行调用,调用结束后从 system_call 中返回。

中断一般有两个属性,中断号中断处理程序(ISR,Interrupt Service Routine)。在内核中,有一个数组称为中断向量表,包含了中断号及其对应中断处理程序的指针。中断到来时,CPU 暂停当前执行的代码,根据中断的中断号,在中断向量表中找到对应的中断处理程序,并调用它。中断处理程序执行完成之后,CPU 会继续执行之前的代码。

由于中断号是有限的,操作系统不舍得每一个系统调用对应一个中断号,而更倾向于用一个或少数几个中断号来对应所有的系统调用。Linux 则使用 int 0x80 来触发所有系统调用。每个系统调用对应一份系统调用号,这个系统调用号在执行 int 0x80 指令前会放置在某个固定的寄存器里(eax),对应的中断代码会取得这个系统调用号,并且调用正确的函数。

系统调用的详细过程
  1. 触发中断

用户程序在代码中调用系统调用,执行 int 指令前将系统调用号放入 eax 寄存器中,执行 int 0x80 指令(int 指令最终执行的函数是 system_call,该函数验证系统调用号的有效性,查找系统调用函数并执行,最后通过 itret 指令从中断处理程序返回)。

  1. 切换堆栈(此步在 int 指令中完成)

在实际执行 0x80 号中断向量所对应的中断处理程序(system_call)之前,CPU 首先要进行堆栈切换,即从用户态切换到内核态。所谓的当前栈,指得是 esp(栈指针)的值所在的栈空间。如果 esp 的值位于用户栈的范围内,那么程序的当前栈就是用户栈,反之亦然。此外,寄存器 ss 的值还应该指向当前栈所在的页。

所以,将当前栈由用户栈切换为内核栈(用户态切换到内核态)的实际行为就是:保存当前的 esp,ss 的值(保证存在内核栈上,int 指令发送后自动地由硬件完成),并将 esp,ss 的值设置为内核栈的相应值

当 0x80 号中断发生的时候,cpu 除了切入内核态之外,还会自动完成下列几件事:

(1)找到当前进程的内核栈(每一个进程都有自己的内核栈)

(2)在内核栈中一次压入用户态的寄存器 ss、esp、eflags、cs、eip

而当内核从系统调用返回的时候,须要调用 iret 指令来回到用户态,iret 指令则会从内核栈里弹出寄存器 ss、esp、eflags、cs、eip 的值,使得栈恢复到用户态的状态。

中断时内核栈和用户栈的切换
  1. 中断处理程序

在 int 指令切换内核栈之后,程序就切换到了中断向量表中的 0x80 号中断处理程序。Linux 中 0x80 向量对应的中断处理程序是 system_call。

system_call 中断服务程序首先检查系统调用号的有效性,再根据 eax 寄存器存储的系统调用号从系统调用表上找到相应的系统调用并调用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// system_call的开头部分
......
SAVE_ALL // 保存寄存器的值到栈中,以免被覆盖
......
cmpl $(nr_syscalls), %eax // 比较eax寄存器中的值和系统调用号大1的值(验证系统调用号的有效性)
jae syscall_badsys	      // 如果系统调用无效,指向syscall_badsys


// 如果系统调用号有效,则会执行以下代码
syscall_call:
	call *sys_call_table(0, %eax, 4) // 查找中断服务程序并执行, sys_call_table其实就是系统调用表
	.....
	RESTORE_REGS // 恢复之前保存的寄存器
	......
	iret 		 // 从中断程序返回
  1. 恢复之前保存的寄存器,从中断处理程序返回,从内核态切换回用户态

缺页中断

在操作系统中,程序执行时需要访问的内存在虚拟内存中,不在物理内存中,此时就会发生缺页中断(该情况为硬件页缺失)。

缺页中断的分类

软性页缺失:指页缺失发生时,相关的页已经被加载进内存,但是没有向 MMU(内存管理单元)注册的情况。操作系统只需要在 MMU 中注册相关页对应的物理地址即可。 硬性页缺失:硬性页缺失是指相关的页在页缺失发生时未被加载进内存的情况。 无效页缺失:当程序访问的虚拟地址是不存在于虚拟地址空间内的时候,则发生无效页缺失。

查看进程发生缺页中断的次数

ps -o majflt,minflt -C <program_name>
ps -o majflt,minflt -p <pid>

majflt 代表 major fault,中文名叫大错误,minflt 代表 minor fault,中文名叫小错误。 majflt 表示需要读写磁盘,可能是内存对应页面在磁盘中需要load到物理内存中,也可能是此时物理内存不足,需要淘汰部分物理页面至磁盘中。 这两个数值表示一个进程自启动以来所发生的缺页中断的次数。

缺页中断过程

当程序访问一个虚拟地址时,操作系统会检查该地址是否被映射到物理内存中,如果没有,则触发缺页中断:

  1. 缺页中断会暂停程序执行,将控制权交给操作系统内核(进程会陷入内核态),保护程序现场,以免被操作系统破坏

  2. 内核会检查发生缺页中断的虚拟地址是否有效:

  • 如果虚拟地址有效,操作系统将会分配一个空闲物理页面,并把未被读取的数据从磁盘拷贝到物理内存所分配的页面中(没有空闲物理页面会触发页面置换算法),并更新页表信息。
  • 如果虚拟地址无效,则操作系统会向进程发出一个信号或杀掉该进程。
  1. 当操作系统处理完缺页中断后,程序会恢复至发生缺页中断指令以前的状态,程序计数器重新指向这条指令

缺页中断和缺页异常

  • 缺页中断是指当程序访问一个虚拟内存地址时,对应的物理内存页不在主存中,需要从磁盘中加载到主存中,此时 CPU 就会发出一个中断请求,操作系统会响应中断请求,将对应的物理页加载到主存中,并重新执行产生缺页中断的指令。缺页中断是一种同步事件,也就是说,程序需要等待操作系统完成页的加载操作后才能继续执行。

  • 缺页异常是指当程序访问一个虚拟内存地址时,对应的物理内存页不在主存中,需要从磁盘中加载到主存中,但是此时 CPU 不会发出中断请求,而是将控制权交给操作系统,让操作系统处理缺页异常。缺页异常是一种异步事件,也就是说,程序不需要等待操作系统完成页的加载操作就可以继续执行。

因此,缺页中断和缺页异常的主要区别在于它们是同步事件还是异步事件。缺页中断是同步事件,程序需要等待操作系统完成页的加载操作后才能继续执行;而缺页异常是异步事件,程序不需要等待操作系统完成页的加载操作就可以继续执行。


虚表/虚表指针

为了实现 C++ 的多态,C++ 使用了一种动态绑定的技术,这个技术的核心是虚函数表。

虚表

  1. 每个包含了虚函数的类都包含一个虚表;
  2. 一个类继承了包含虚函数的基类,那么这个类也会拥有自己的虚表;
  3. 虚表是一个指针数组,其元素是虚函数的指针,即虚表中每个元素对应一个虚函数的函数指针;
  4. 虚函数的的调用都需要经过虚表,普通函数即非虚函数,其调用并不需要经过虚表;
  5. 虚表内的条目,即虚函数指针的赋值发生在编译器的编译阶段;
  6. 虚表是属于类的,而不属于某个具体的对象,一个类只需要一个虚表即可。同一个类的所有对象都使用同一个虚表。

虚表指针

  1. 如有类中包含虚函数,编译器会自动在类中添加一个指针:*__vptr,用来指向该类的虚表;
  2. 类的对象在创建时便拥有虚表指针,且这个指针的值会自动被设置为指向类的唯一虚表。

类A的定义如下:

1
2
3
4
5
6
7
8
9
class A {
public:
    virtual void vfunc1();
    virtual void vfunc2();
    void func1();
    void func2();
private:
    int m_data1, m_data2;
};

因为类A中存在(一个或多个)虚函数,编译器会自动给类A加上一个虚表指针(占8字节空间),所以sizeof(A)=16。 类A的虚表和虚表指针(__vptr)关系如下:

虚表和虚表指针

多态相关

  1. 一个类的基类如果包含虚函数,那个这个继承类也有拥有自己的虚表,故这个继承类的对象也包含一个虚表指针,用来指向它的虚表;
  2. 一旦继承类重写了基类的某个虚函数,那么它的虚表项也会同步更新,指向继承类重写的函数,否则指向它基类的同名函数。
  3. 通过基类的指针调用派生类中的函数称为(运行时)多态,经过虚表调用虚函数的过程称为动态绑定

虚表和虚表指针的创建时机:

  • 在编译的过程中编译器就为含有虚函数的类创建了虚表,并且编译器会在构造函数中插入一段代码,这段代码用来给虚指针赋值。因此虚表是在编译的过程中创建
  • 由于虚指针是基于对象的,所以对象在实例化的时候,虚指针就会创建,所以虚指针是在运行时创建

此外,编译器规定一个模版函数不能为虚函数。因为模版机制需要在编译期间识别模版支持类型,对于每一种类型我们都要生成对应类型的函数体。如果这个模版函数为虚函数,我们不知道这个模版函数被生成了多少份对应类型的虚函数,也就不好确定虚表的大小,所以编译器禁止这一行为。

更详细的内容参考:知乎:C++中虚函数、虚继承内存模型