浅析C语言中的内存模型

2,350 阅读7分钟

我正在参加「掘金·启航计划」

前言

近来学习深觉自己在语言的底层方面理解欠缺, 正好之前有看过一点内存模型相关的文章(虽然没看懂), 正好就花点时间研究一下这方面相关的知识。以此文整合一下自己学到的知识, 文章中有错误或不足之处还请师傅们斧正。
重点讲堆栈与堆区两个动态区的概念, 简单分析其中的区别和各自的优势。

动态区

栈区(stack)

  1. 栈区也被称为堆栈, 栈区的分配和释放都由系统自动分配, 栈区存放函数的参数值以及函数的局部变量,以及函数调用开辟的栈帧;按照程序的调用顺序依次入栈。函数结束返回时自动释放空间,栈区使用LIFO结构。 栈区的内存地址是连续且固定长度的。
    2. 在Windows中默认栈区大小上限为1M或者2M, 如果在分配内存时剩余栈的空间不足以分配通常会抛出段错误(segmentation fault)或者是缓冲区溢出(Buffer overflow)的异常报错告警, 在Linux系统中栈区的默认上限为8M。
    3. 栈是一种
    限定性线性表
    ,是数据结构的一种。将线性表的插入和删除操作限制为仅在表的一端进行,通常将表中允许进行插入、删除操作的一端称为栈顶,因此栈顶的当前位置是动态变化的,它由一个称为栈顶指针的位置指示器来指示。同时表的另一端被称为栈底当栈中没有元素时称为空栈。栈的插入操作被形象地称为进栈入栈(push),删除操作称为出栈退栈(pop)
    4. 栈指针是一个指向栈区域内部的指针,它的值是一个地址,这个地址位于栈区的下界和栈区的上界之间。栈指针把这个栈区域分为两个部分,一个是已经使用的区域,一个是没有使用的区域。
    5. 在函数调用结束后, 局部变量先出栈, 然后是参数, 最后是栈顶指针指向的地址。
    6. 栈帧保存了每一个函数的返回位置、实参、局部变量、返回值地址

stack.jpg

堆区(heap)

  1. 堆区的内存由自己手动分配手动释放的, 如果在使用完后没有及时释放在程序运行完后将由操作系统自动回收, 堆区的内存地址通常是不连续的, 每个堆区都有一个固定8bytes长度的头部标识信息, 且由于内存对齐制度,后面的块长度如果不足8字节则补空对齐。
    (PS:看的文章有点驳杂,暂时没找到个讲的比较全又比较清晰的文章,有关内存对齐的补充可以看看下面讲malloc的文章)
    2. 堆区是一种经过排序之后的树形结构, 也就是**二叉树, ** 堆中某个节点的值总是不大于或不小于其父节点的值 。
    3. 在C语言中堆内存通常使用 <stdlib.h>中的malloccalloc函数来进行分配, 也可以在分配之后使用realloc重新分配堆区大小, 而在Java中则使用new关键字来进行分配。两者不同之处是Java会在堆内存使用完后自动回收, 而C则需要在使用完后使用freedelete(C++)函数手动进行回收,并且需要将指针置空,尽量避免野指针的出现
    4. 对堆来说,频繁分配和释放(malloc / free)不同大小的堆空间势必会造成内存空间的不连续,从而造成大量碎片,导致程序效率降低;而对栈来讲,则不会存在这个问题。 在日常编写代码时需要尽量减少使用频次,不要频繁地申请和释放内存,数据量不大或非动态内存也尽量使用栈内存。 在运行中如果内存管理没有做好的话是有可能会出现内存泄露的情况的, 会严重危害服务器运行内存和影响程序运行效率。
    功能:释放一块堆内存,不能重复释放,也不能释放非法地址,但是可以释放 NULL
    注意:释放的仅仅是使用权,里面的数据不会全部清理,是会清理前4个字节为0
    程序一旦结束属于他的所有资源都会被操作系统回收

  2. 在内存对齐之后每个内存块之间会留4个字节的长度记录malloc的维护信息,这些维护信息决定了malloc下次分配内存的位置,以及借助这个维护信息计算出每个内存块的大小,当这些信息被破坏时,就会造成堆损坏。
    6. 在使用malloc创建了一个堆后,返回的指针地址指向堆的头部地址,如果此时对指针进行地址的修改,则会造成堆损坏,且此时访问数据有可能会造成越界访问。

#include <stdio.h>
#include <stdlib.h>
int main(){
    int *p = (int *)malloc(100);  // 分配一个100字节大小的malloc堆
    p++; // 将指针往高位移动一个块, 指向了原本p[1]所在的地址, 
    printf("%d\n", &p[24]);  // 此时访问的p[24]不在malloc分配的堆块范围内, 属于非法访问, 如果访问到了被使用中的内存则会造成脏数据,
							 // 但是只要不访问到一些特殊的内存地址也并不会有异常抛出
    free(p);  // 如果在此时指针偏移的情况下释放堆会造成堆损坏, 在释放内存时会检查堆块内存的完整性, 如果没有通过则会造成堆损坏, 但是不会有异常抛出
			  // 此处不多深究堆损坏的原因和修复方案, 感兴趣可以自行学习。
    return 0;
}

堆区与栈区之间的区别

  1. 栈区的速度是要比堆区快的, 且因为栈区内存用完即立刻释放, 在面对某些不需要多次复用的代码时要比堆区更为可靠。
    因为访问模式使从中分配内存和取消分配内存变得微不足道(指针/整数只是递增或递减),而堆的分配或释放则涉及到更为复杂的簿记工作。而且堆栈中的每个字节都倾向于被非常频繁地重用,这意味着它倾向于被映射到处理器的高速缓存中,从而使其非常快。堆的另一个性能损失是,堆(通常是全局资源)通常必须是多线程安全的,即,每个分配和释放都必须(通常)与程序中的“所有”其他堆访问同步。
    2. 与栈区不同的是, 栈区的内存是从高位开始向低地址扩展的数据结构, 而堆区的内存是从低地址向高地址扩展的数据结构
    3. 栈区的空间是固定的, 随线程分配; 堆区的空间是动态的, 由自己手动分配和释放。

静态区

全局区(static)

  1. data段, 这些数据会在程序结束后由操作系统自动释放, data段由三部分组成

     - 读写(Read & Write)数据段: 初始化过的全局变量+静态变量
     - 只读(Read Only)数据段: 未初始化过的全局变量+静态变量 (BSS段)
     - 只读(Read Only)数据段: 常量
    
  2. BSS段(bss segment)通常是指用来存放程序中未初始化初始化为0的全局变量的一块区域 (C语言规定未显式初始化的全局变量值默认为0)

代码块(text segment/code segment)

       <br />1.   text段, 代码段就是程序中的可执行部分,直观来讲代码段就是由一个个函数的堆叠来组成的, 并且是只读的(某些架构也许会允许修改, 没去深入研究过)

常量存储区(const)

  1. 用于存放定义的文字常量与宏以及不可修改的常量的静态区域, 由系统分配和释放内存
#include <stdio.h>

int main(){
	// 此处指针s虽然存放在栈区, 但是所指向的字符串takahashi存放在常量区
	char* s = "takahashi";
	const char Str1 = "a";  		// 此处Str1存放在栈区中
	char Str2 = "a";        		// 此处Str2同样也存放在栈区当中
	char strings1[9] = "takahashi"  // 此处strings1存放在堆区当中
	const strings2[9] = "takahashi";// 此处strings2存放在哪个区当中?
	return 0;
}