C/C++:堆栈面面观(二)

简介: 学习C语言,我们都听过堆(heap)和栈(stack)的概念。也是C/C++码农面试的常见考点,今天带大家来深入浅出一下。本文写于大四,写作初衷也是来自于曾经的面试经历、大学课程所学以及各种网络资料,融合《CSAPP》的读书感悟总结而成。需要注意的是:有些地方“堆栈”这个词特指的是栈,而不是堆和栈。命名约定:本文中堆栈一次出现的地方,指的是两种东西,而非一种。


概念与分配策略


所谓“堆”,即动态存储区,与栈不同,堆是在程序运行时被分配的。C语言中的malloc,C++中的new完成的都是堆上的操作。堆不会自动释放所以需要free和delete。


还记得经典面试题吗:比较一下malloc和new的不同。这个问题其实不难,首先明确:malloc是函数,而new是关键字。然后new作为C++中动态对象创建的基石,除了完成堆空间的分配操作以外还要完成一些初始化操作,及new的过程中会调用对象的构造函数去初始化,而malloc不会。最后要明确的是malloc分配的内存只能用free来释放,而new分配的地址只能用delete来释放,如果new分配的是数组,则需要delete[ ]来释放,否则会出现未定义行为。


无论是malloc还是new返回的都是一个指针,即堆地址。堆与栈不同它不是顺序分配的,而是离散分配的,它的空闲内存可能不是连续的,而是断断续续的,通常通过链表来连接每个空闲存储区(实际数据结构要更复杂和多样化)。关于动态内存的分配策略其实大家的“操作系统”课上都有学过,只不过通常大家很难直接和malloc/new联系起来。


随便翻开一本操作系统的课本,找到存储器管理的章节都能找到动态内存的分配策略(简单描述之):


  • 首次适应算法:在空闲区链首开始向后寻找,找到第一块能满足所需大小的空闲块


  • 循环首次适应算法:从上一次找的空闲区开始向后寻找。直到链尾都不满足,则返回链首寻找空闲块


  • 最佳适应算法:每次分配内存时,都选择能满足要求,并且又是最小的空闲块。优点是:每次第一次找到的满足要求的空闲块,必然是最佳的


  • 最坏适应算法:每次总是选择一个最大的空闲块分配给进程使用。优点是:产生内存碎片的几率较小


  • 快速适应算法:将空闲区依据其容量大小进行分类,每一类同容量的空闲块都有自己的链表。同时在内存中设立一张管理索引表,每个表项为一种空闲块类型,并记录其链首指针。其优点是:查找效率高,不会产生内存碎片。缺点是实现复杂,系统开销大。


实际上真实的堆内存管理要比上述各类算法所描述的还要复杂。比如内存管理器(一般是glibc的ptmalloc)可能将零散的空闲内存移动到一起,形成更大的空闲内存块。基于各自不同的动态内存分配策略,很多其他的内存管理器应运而生,比如谷歌的 tcmalloc,BSD的jemalloc。


堆首地址


我们通过malloc分配的内存空间返回给我们一个这段内存的首地址,你有没有疑问,当我们free的时候,只是将该首地址传递给free函数。而free函数又是如何知道该释放该首地址以后多大的内存空间的呢?在初学的时候,我有这个疑惑,我认为如果是我来实习free函数的话,它需要两个参数:带释放堆内存的首地址及其偏移量(分配空间的大小)。


实际上关于这个问题,不同的内存管理器有各自的策略,但大致的思想就是将偏移量存储在内存中。比如一种简单实现是:在堆分配内存的时候,通常会比你需要的内存多分配一些,其中一部分用于字节对齐,另外一小部分用于存取偏移量。比如用前4个字节存储已分配内存的大小,然后将其后面的地址返回,首地址前的4个字节可以被称之为“首部”。这样free的时候,先搜索该首地址前的首部,取出这个值即为偏移量。(这只是一种最简单的概念性的方案,实际编译器的解决方案更为复杂,但无一例外都会分配额外的内存保存元数据)


9d6cf488641e4dc08b47807e20b56de4.jpg


知道这点后,我们可以理解这样一种C语法的限制,那就是free的时候,其参数只能是malloc等动态内存分配函数返回的(显然,不包括new)值。举个例子:


  char *a = malloc(100);
    free(a+10);


比如你分配了100个字节,其首地址为a,如果你将a+10(首地址之后偏移10字节处)传递给free,那么free并不能为你释放掉剩余的90个字节,并且这是一个危险的行为。因为内存管理器会将a+10位置之前视作首部,并从中去取出该段地址的“偏移量”,然后进行释放。显然这样是达不到你想要的效果的。


当然,这只是举了一种例子来阐述free函数的参数为什么只需要一个首地址,实际上其真实的行为远比我这里描述的要复杂,可能有首部,还有尾部,其存储的内容除了偏移量以外可能还有其他信息,比如空闲链表的信息等。


虚拟地址和MMU


内存是分页的(页式存储)。物理内存、虚拟内存都是。并且这两者的页大小是严格相同的。不同之处当然是物理内存的页数比较多,虚拟内存的页数比较少。这两者之间是存在一定映射关系的。通过一道笔试题来揭露这种关系:


某虚拟存储器的用户编程空间共4个页面,每页1K,主存位16K,假定某时刻系统为用户的0,1,2,3页分别分配物理块号6,8,12,3中,则虚拟地址0C8Eh对应的物理地址为:

_____


这道题虚拟地址位CC8Eh,h表示16进制。为了便于计算可以把它转换位十进制:

0C8Eh = 3214。接着计算它所属的页号和页内偏移,页面大小是1K,即1024。页号=3214/1024 = 3。偏移=3214%1024=142


物理地址=物理页号*页面大小+页内偏移。因为根据题意,逻辑地址中的3号页对应物理地址中的页号也是3。所以实际这题物理地址和逻辑地址是一样的。




共享库基本概念


在进程的内存空间图示中,有一段标识是:共享库内存映射区。表示这段内存地址是给共享库使用的。何谓“共享库(shared libraries)”?共享库在Windows系统中叫做动态链接库(.dll),或者简称“动态库”。动态库就需要和静态库区分开来。你应该见过这两种库,知识你不知道罢了。


93b09960a3cb4cf6a0b1b495a8d3d173.png


如果一个程序引用了静态库,那么在编译期静态库文件就会和该程序编译到一起。这样编译出来的可执行文件通常比较大,并且如果多个程序都使用了同一个静态库,那么每个程序编译后的文件都包含一份该静态库的拷贝;而如果一个程序引用了共享库,那么在编译期共享库文件不会和该程序编译到一起,而是在程序运行时动态地加载该共享库文件。如果多个程序都使用了同一个共享库,那么这些程序都是在运行时加载该共享库,系统中之后存在一份该库的拷贝,这就是为什么叫做共享库的原因。


因为共享库是运行时加载的,在加载后也必须有一个地址,图中的“共享内存映射区”就是用来给共享库分配地址的,它的地址增长方式同堆一样,从低到高。


关于共享库如何实现的,如何动态连接的。本文不详述,有兴趣的可以阅读《深入理解计算机系统》的“链接”一章。


链接共享库


另外需要注意的是,如果你要链接的共享库(动态库)不是标准库,并且不在标准库的路径下。那么在编译的时候通常会报错。此时需要给gcc加上-L选项加上共享库所在的路径,并用-l选项去连接对应的库,这里要明确的是如果你的库文件名叫libabc.so.1234那么连接选项l要写成 -labc(去掉前后缀),而当同一个库里面同时有静态库和共享库的时候,优先连接的是共享库。


此时只是解决了编译期间的麻烦,因为共享库实际是程序运行时链接的,即使你编译期间使用了-L选项也可能会找不到库(-L只解决编译期间的问题)。如果你有root的话,那么去/etc/ld.so.conf.d/目录下,新建一个conf文件,里面加入你的库路径。然后用ldconfig命令刷新共享库路径。


但如果你没有root权限的话,你可以使用gcc的-Wl,-rpath选项去指定路径(这是一个选项)。此时一个完整的编译命令类似:


gcc -L /home/jelly/lib/ \
    -labc \
    -Wl,-rpath=/home/xxx/lib/
相关文章
|
4月前
|
存储 NoSQL 安全
【C++调试】深入探索C++调试:从DWARF到堆栈解析
【C++调试】深入探索C++调试:从DWARF到堆栈解析
235 1
|
4月前
|
Linux 编译器 程序员
【Linux 调试秘籍】深入探索 C++:运行时获取堆栈信息和源代码行数的终极指南
【Linux 调试秘籍】深入探索 C++:运行时获取堆栈信息和源代码行数的终极指南
481 0
|
3月前
|
存储 程序员 编译器
C/C++堆栈详细分析,新老程序员必会
C/C++堆栈详细分析,新老程序员必会
108 1
|
4月前
|
Linux C++
【代码片段】Linux C++打印当前函数调用堆栈
【代码片段】Linux C++打印当前函数调用堆栈
90 0
|
4月前
|
存储 算法 C++
四则计算机实现(C++)(堆栈的应用)
四则计算机实现(C++)(堆栈的应用)
|
4月前
|
存储 程序员 编译器
【C/C++ 堆栈以及虚拟内存分段 】C/C++内存分布/管理:代码区、数据区、堆区、栈区和常量区的探索
【C/C++ 堆栈以及虚拟内存分段 】C/C++内存分布/管理:代码区、数据区、堆区、栈区和常量区的探索
217 0
|
4月前
|
存储 安全 编译器
C/C++面试题:堆栈的作用
C/C++面试题:堆栈的作用
47 0
|
存储 算法 C++
【奇妙的数据结构世界】用图像和代码对堆栈的使用进行透彻学习 | C++
简单来说,数据结构是一种辅助程序设计并且进行优化的方法论,它不仅讨论数据的存储与处理的方法,同时也考虑到了数据彼此之间的关系与运算,从而极大程度的提高程序执行的效率,减少对内存空间的占用等。不同种类的数据结构适用于不同的程序应用,选择合适正确的数据结构,可以让算法发挥出更大的性能,给设计的程序带来更高效率的算法。
132 0
|
算法 C++ Python
压入弹出堆栈算法-附LeetCode剑指 Offer 31. 栈的压入、弹出序列-题解-python && C++源代码
压入弹出堆栈算法-附LeetCode剑指 Offer 31. 栈的压入、弹出序列-题解-python && C++源代码
压入弹出堆栈算法-附LeetCode剑指 Offer 31. 栈的压入、弹出序列-题解-python && C++源代码
|
存储 Java C++
基于堆栈内存详析 Java函数形参是传值还是引用? | C++指针与Java引用的区别 | C++引用、指针等之间的区别 | C++与Java类的实例化的区别
基于堆栈内存详析 Java函数形参是传值还是引用? | C++指针与Java引用的区别 | C++引用、指针等之间的区别 | C++与Java类的实例化的区别