C 05_C语言 计算机内存管理

一、计算机进程内存管理

1.1 进程概念

进程( Process) 一个正在执行程序就是一个进程,我们使用 C语言编译生成的程序,运行后就是一个进程。 进程最显著的特点就是拥有 独立的地址空间

程序 是存储在磁盘上的一个文件,是指令和数据的集合,是一个静态的概念;进程 是程序加载到内存运行后一序列的活动,是一个动态的概念。

一个进程对应一个地址空间,而一个程序可能会创建多个进程。

1.2 内存管理

在计算机科学中,内存管理是操作系统和程序员的共同关注点。它是操作系统和程序之间的接口,是程序运行的基础。 内存管理涉及内存分配、内存保护和内存重用三个主要方面:

  • 内存分配 负责为程序分配所需的内存空间,确保程序有足够的空间运行。
  • 内存保护 是为了防止一个程序访问或修改另一个程序的内存空间,从而保护数据的安全性和完整性。
  • 内存重用 则是为了提高内存利用率,减少因为频繁的分配和释放内存而导致的开销。

1.3 进程虚拟内存空间

1.3.1 虚拟地址空间

计算机把 进程 所使用的内存地址 “隔离” 开来,即让操作系统为每个进程分配独立的一套 “虚拟地址”,每个进程只能访问、操作自己的内存地址,互不干涉。但是有个前提每个进程都不能访问物理地址,至于虚拟地址最终怎么落到物理内存里,对进程来说是透明的,由操作系统负责做这些工作。

虚拟地址空间 就是一个中间层,相当于在进程和物理内存之间设置了一个屏障,将二者隔离开来。进程中访问的内存地址不再是实际的物理内存地址,而是一个虚拟地址,然后由操作系统将这个虚拟地址映射到适当的物理内存地址上。

虚拟地址空间 不是 虚拟内存, 虚拟内存是(Windows系统下)硬盘中一部分用来存放内存中暂时不用的数据的存储空间、Linux下称做交换分区

扩展: 虚拟内存和swap分区的关系

  • windows:虚拟内存
  • linux:swap分区
  • swap类似于windows的虚拟内存,不同之处在于,Windows可以设置在windows的任何盘符下面,默认是在C盘,可以和系统文件放在一个分区里。
  • 而linux则是独立占用一个磁盘分区,方便内存需求不够的情况下,把一部分内存内容放在swap分区里,待内存有空余的情况下再继续执行,也称之为交换分区,交换空间是其中的部分。

windows 的虚拟内存是电脑自动设置的;Linux 的swap分区是装系统时分好的;

虚拟地址 长度一般为机器字长,是由物理内存页表的索引(高位) 和 页表内偏移

1.3.2 虚拟内存地址空间映射到物理内存

在 CPU 内部,有一个部件叫做 MMU( Memory Management Unit,内存管理单元),由它来负责将虚拟地址映射为物理地址, 如下图所示: CPU-MMU-Mem

在页映射模式下, CPU 发出的是虚拟地址,也就是我们在程序中看到的地址,这个地址会先交给 MMU,经过MMU 转换以后才能变成了物理地址。

浅析Linux 64位系统虚拟地址和物理地址的映射及验证方法: https://blog.csdn.net/weiqifa0/article/details/113362429

即便是这样, MMU 也要访问好几次内存,性能依然堪忧,所以在 MMU 内部又增加了一个缓存,专门用来存储页目录和页表。 MMU 内部的缓存有限,当页表过大时,也只能将部分常用页表加载到缓存,但这已经足够了,因为经过算法的巧妙设计,可以将缓存的命中率提高到 90%,剩下的 10%的情况无法命中,再去物理内存中加载页表。

有了硬件的直接支持,使用虚拟地址和使用物理地址相比,损失的性能已经很小,在可接受的范围内。

MMU 只是通过页表来完成虚拟地址到物理地址的映射,但不会构建页表,构建页表是操作系统的任务。在程序加载到内存以及程序运行过程中, 操作系统会不断更新程序对应的页表,并将页目录的物理地址保存到 CR3寄存器(CR3 是 CPU 内部的一个寄存器,专门用来保存页目录的物理地址 )。 MMU 向缓存中加载页表时,会根据 CR3 寄存器找到页目录,再找到页表,最终通过软件和硬件的结合来完成内存映射 。

每个程序在运行时都有自己的一套页表,切换程序时,只要改变 CR3 寄存器的值就能够切换到对应的页表。

1.3.3 虚拟内存的作用

第一,虚拟内存可以使得进程的运行内存超过物理内存大小,因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。

第二,由于每个进程都有自己的页表,所以每个进程的 虚拟内存空间 就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题。

第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。

1.3.4 虚拟内存地址(Address)的编号

对于 x86架构 32 位环境,程序能够使用的内存为 4GB($2^{32}$),最小的地址为 0,最大的地址为 0XFFFFFFFF。

对于 x86-64 架构 64 位环境,程序能够使用的内存为 256TB($2^{48}$),地址长度为 64 位,虚拟地址空间为 $2^{64}$ 字节。按照当前芯片的实现情况,只能使用地址的低 48 位。 最小的地址为 0,最大的地址为 0XFFFFFFFF。

Tips: 在 x86 64bit 系统中,可以描述的最长地址空间为 16EB($2^{64}$),远远超过了目前主流内存卡的规格,所以在 Linux 中只使用了 48bit 长度,寻址空间为 256TB($2^{48}$),User Space 和 Kernel Space 各占 128T。寻址空间分别为:

  • User Space: 0x0000 0000 0000 0000~0x0000 7FFF FFFF F000,高 16bit 全 0。
  • Kernel Space: 0xFFFF 8000 0000 0000~0xFFFF FFFF FFFF FFFF,高 16bit 全 1。
  • Canonical Address Space: :0x0000 7FFF FFFF F000 - 0xFFFF 8000 0000 0000,无效地址空间。

二、C语言程序内存模型

2.1 程序内存模型简介

程序在地址空间中的分布情况称为 内存模型(Memory Model)

C语言是比较接近底层的语言,因此它的很多知识点是和操作系统挂钩的。它的内存模型,其实也是操作系统进程的内存模型,本章就是解释进程、虚拟内存空间、内存模型的相关知识和它们之间的联系。

内存模型由操作系统构建,在Linux和Windows下有所差异,并且会受到编译模式的影响。

本节我们讲解Linux下32位环境C语言程序的内存模型。

2.2 C语言程序内存模型

对于32位环境,理论上程序可以拥有 4GB ($2^{32}$)的虚拟地址空间(寻址空间),我们在C语言中使用到的变量、函数、字符串等都会对应内存中的一块区域。

但是,在这 4GB 的地址空间中,要拿出一部分给操作系统内核使用,应用程序无法直接访问这一段内存,这一部分内存地址被称为内核空间(Kernel Space)

Windows 在默认情况下会将高地址的 2GB 空间分配给内核(也可以配置为1GB),而 Linux 默认情况下会将高地址的 1GB 空间分配给内核。

也就是说,应用程序只能使用剩下的 2GB 或 3GB 的地址空间,称为用户空间(User Space)

2.2.1 用户空间与内核空间

操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证内核的安全保证内核的安全, 用户进程不能直接操作内核。

操作系统将进程的虚拟空间划分为两部分,一部分为 内核空间(Kernel Space),一部分为 用户空间User Space)

在Linux操作系统中,将最高的1G(从虚拟地址0xC0000000到0xFFFFFFFF)内存提供给内核使用,称为 内核空间(Kernel Space),而将较低的3G(从虚拟地址0x00000000到0xBFFFFFFF)内存提供给各个进程使用,称为 用户空间User Space)

每个进程可以通过系统调用进入内核,因此,Linux内核由系统内的所有进程共享。

从进程的角度来看,每个进程可以拥有4G字节的虚拟空间。

将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间。

有了用户空间和内核空间,整个Linux系统内部结构可以分为三部分,从最底层到最上层依次是:硬件 –> 内核空间 –> 用户空间。如图所示: 需要注意的细节问题:

  • 内核空间中存放的是内核代码和数据,而进程的用户空间中存放的是用户程序的代码和数据。不管是内核空间还是用户空间,它们都处于虚拟空间中。
  • Linux使用两级保护机制:0级 供内核使用3级 供用户程序使用

2.2.2 内核态与用户态

(1)当一个任务(进程)执行系统调用而陷入内核代码中执行时,称进程处于内核运行态(内核态)。此时处理器处于特权级最高的(0级)内核代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核栈。每个进程都有自己的内核栈。

(2)当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。此时处理器在特权级最低的(3级)用户代码中运行。当正在执行用户程序而突然被中断程序中断时,此时用户程序也可以象征性地称为处于进程的内核态。因为中断处理程序将使用当前进程的内核栈。

2.2.3 进程上下文与中断上下文

当一个程序执行了系统调用或者触发某个异常(软中断),此时就会陷入内核空间,内核此时代表进程执行,并处于进程上下文中。

程序在执行过程中通常有用户态和内核态两种状态,CPU对处于内核态根据上下文环境进一步细分,因此有了下面三种状态:

  • 内核态,运行于进程上下文,内核代表进程运行于内核空间。
  • 内核态,运行于中断上下文,内核代表硬件运行于内核空间。
  • 用户态,运行于用户空间。

2.2.4 上下文context

上下文简单说来就是一个环境。

用户空间的应用程序,通过系统调用,进入内核空间。这个时候用户空间的进程要传递 很多变量、参数的值给内核,内核态运行的时候也要保存用户进程的一些寄存 器值、变量等。所谓的“进程上下文”,可以看作是用户进程传递给内核的这些参数以及内核要保存的那一整套的变量和寄存器值和当时的环境等。

相对于进程而言,就是进程执行时的环境。具体来说就是各个变量和数据,包括所有的寄存器变量、进程打开的文件、内存信息等。 一个进程的上下文可以分为三个部分: 用户级上下文、寄存器上下文以 及 系统级上下文:

  • 用户级上下文: 正文、数据、用户堆栈以及共享存储区;
  • 寄存器上下文: 通用寄存器、程序寄存器(IP)、处理器状态寄存器(EFLAGS)、栈指针(ESP);
  • 系统级上下文: 进程控制块task_struct、内存管理信息(mm_struct、vm_area_struct、pgd、pte)、内核栈。

当发生进程调度时,进行进程切换就是上下文切换(context switch).操作系统必须对上面提到的全部信息进行切换,新调度的进程才能运行。而系统调用进行的模式切换(mode switch)。模式切换与进程切换比较起来,容易很多,而且节省时间,因为模式切换最主要的任务只是切换进程寄存器上下文的切换。

硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的 一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。所谓的“ 中断上下文”,其实也可以看作就是硬件传递过来的这些参数和内核需要保存的一些其他环境(主要是当前被打断执行的进程环境)。中断时,内核不代表任何进程运行,它一般只访问系统空间,而不会访问进程空间,内核在中断上下文中执行时一般不会阻塞。

LINUX完全注释中的一段话: 当一个进程在执行时,CPU的所有寄存器中的值、进程的状态以及堆栈中的内容被称为该进程的上下文。当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的上下文,以便在再次执行该进程时,能够必得到切换时的状态执行下去。在LINUX中,当前进程上下文均保存在进程的任务数据结构中。在发生中断时,内核就在被中断进程的上下文中,在内核态下执行中断服务例程。但同时会保留所有需要用到的资源,以便中继服务结束时能恢复被中断进程的执行。

2.2.5 进程内存模型

Linux下32位环境的(一种经典)内存模型:

由上图可知:

  1. 保留区域:最底层0x0000 0000 —— 0x08048000段区域, 受保护的地址(0-4k);
  2. 代码区(Text sement/ELF):用来存放程序代码(函数体的二进制代码)的内存区域,只读,运行期会一直存在,(运行期会一直存在,程序结束后由系统释放);
  3. 常量区(Constant):存放一般的常量、字符串常量等。这块内存只有读取权限,没有写入权限,因此它们的值在程 序运行期间不能改变,(运行期会一直存在,程序结束后由系统释放);
  4. Data segment:存储运行时已经初始化的全局变量和静态变量,可读写(运行期会一直存在,程序结束后由系统释放);
  5. BSS segment:BBS(Block Started by Symbol),存储运行时还未初始化的全局变量和静态变量,可读写(运行期会一直存在);
  6. 堆(Heap):一般由程序员分配释放,若程序员不释放,程序结束时由OS回收,堆用来存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当调用malloc分配内存时,新分配的内存就被动态添加到堆上,当调用free释放内存时,会从堆中剔除,堆区分配内存向上增长;
  7. 动态链接库:用于在程序运行期间加载和卸载动态链接库;
  8. 栈(Stack):由编译器自动分配释放,存放程序中的局部变量(但不包括static声明的变量,static变量放在数据段中)。同时,在函数被调用时,栈用来传递参数和返回值。由于栈先进先出特点,所以栈特别方便用来保存/恢复调用现场;栈区分配内存向下增长;
  9. 内核区(Kernel space): 内存管理、进程管理(PCB:Process Ctrl Block)、设备驱动管理、VFS虚拟文件系统、环境变量的名号, 用户不能对该空间进行读写操作、否则会出现段错误。

Linux下64位环境的内存模型: 进程内存模型总结:

  1. 程序代码区用来保存指令,常量区、全局数据区、堆、栈都用来保存数据。对内存的研究,重点是对数据分区的研究 。
  2. 程序代码区、常量区、全局数据区在程序加载到内存后就分配好了,并且在程序运行期间一直存在,不能销毁也不能增加(大小已被固定),只能等到程序运行结束后由操作系统收回,所以全局变量、字符串常量等在程序的任何地方都能访问,因为它们的内存一直都在。
  3. 常量区和全局数据区有时也被合称为静态数据区,意思是这段内存专门用来保存数据,在程序运行期间一直存在。
  4. 函数被调用时,会将参数、局部变量、返回地址等与函数相关的信息压入栈中,函数执行结束后,这些信息都将被销毁。所以局部变量、参数只在当前函数中有效,不能传递到函数外部,因为它们的内存不在了。
  5. 常量区、全局数据区、栈上的内存由系统自动分配和释放,不能由程序员控制。
  6. 程序员唯一能控制的内存区域就是堆( Heap):它是一块巨大的内存空间,常常占据整个虚拟空间的绝大部分,在这片空间中,程序可以申请一块内存,并自由地使用(放入任何数据)。堆内存在程序主动释放之前会一直存在,不随函数的结束而失效。在函数内部产生的数据只要放到堆中,就可以在函数外部使用。

一个例子(完美的解决c语言变量的所处内存块问题)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <stdio.h>
#include "stdlib.h"

// 字符串在常量区,全局变量在全局区
char *global1 = "junhaozhendeshuai";
// 静态区
static int global2 = 1;
int n;

char* func()
{
    char* str = "张三真的帅!";
    return str;
}

int main()
{
    int a;
    char *str2 = "01234";
    char ptr[20] = "56789";
    char *pstr = func();
    char* c  =  (char *)malloc(100);
    // printf("局部变量初始值为:%d, 全局变量初始值为:%d\n", a, n);
    printf("全局区/static:&global:%p,&global2:%p,n:%p\n", &global1, &global2, &n);
    printf("常量:global1:%p pstr:%p\n",global1,pstr);
    printf("堆区:c:%p\n", c);
    printf("栈区:&str2:%p,&ptr:%p, ptr:%p, a:%p", &str2, &ptr, ptr, &a);
    return 0;
}
/*

总结如下:
char *global1 = "junhaozhendeshuai", global1这个指针是在全局区, "junhaozhendeshuai"这个字符串是在常量区,我们要查看global1这个指针的地址可以通过&global1来获得,我们要查看junhaozhendeshuai这个字符串的地址可以通过global1来获得
static int global2 = 1;, 这个静态变量global2也在全局区,可以通过&global2获得它的地址
int n;,这个变量在函数外定义,在全局区,通过&n来获得地址
int a;,这个函数在main函数中,在栈区,通过&a来获得地址
char *str2 = "01234";, str2这个指针是在栈区,"01234"这个字符串是在常量区,我们要查看str2这个变量的地址可以通过&str2获得,我们要查看"01234"这个字符串的地址可以通过str2获得,
char ptr[20] = "56789";,ptr这个指针是在栈区,"56789"这个字符串也是在栈区,我们要查看ptr这个变量的地址可以通过&ptr获得,我们要查看"56789"这个字符串的地址可以通过ptr获得

// 为啥两种字符串的表达形式在内存模型不一样呢?
// 字符串数组和char *表示的字符串有什么区别呢?它们最根本的区别是在内存中的存储区域不一样,字符数组存储在全局数据区或栈区,第二种形式的字符串存储在常量区。全局数据区和栈区的字符串(也包括其他数据)有读取和写入的权限,而常量区的字符串(也包括其他数据)只有读取权限,没有写入权限。

char *pstr = func();,调用函数一个指向字符串常量的地址,值得注意的是,由于字符串常量已经被建立出来了,它不会被释放,此时我们把指向它的指针返回回来,pstr这个指针是在栈区,可以使用&pstr获得地址,"张三真的帅!"这段字符串是在常量区,可以用pstr获得他的地址
char* c = (char *)malloc(100),很明显,用malloc开辟出来的内存是在堆区,即c这个变量的地址在堆区
最后一点,我们可以发现全局整型变量你没初始化时它被赋值为0, 而局部变量你没初始化它被赋值为未知值
*/

2.3 栈(Stack)详解

2.3.1 栈的概念

栈(stack): 是由系统自动分配和释放的内存区域,存放函数的参数值、返回值、局部变量 以及 函数调用开辟的栈帧等的内存空间。其操作方式类似于数据 结构中的栈。按照程序的调用顺序依次入栈。函数结束返回时自动释放空间,栈区使用LIFO结构。 栈区的内存地址是连续且固定长度的。

  • 栈是一种限定性线性表,是数据结构的一种。将线性表的插入和删除操作限制为仅在表的一端进行,通常将表中允许进行插入、删除操作的一端称为栈顶,因此栈顶的当前位置是动态变化的,它由一个称为栈顶指针的位置指示器来指示。同时表的另一端被称为栈底当栈中没有元素时称为空栈。栈的插入操作被形象地称为进栈或入栈(push),删除操作称为出栈或退栈(pop)。
  • 栈指针是一个指向栈区域内部的指针,它的值是一个地址,这个地址位于栈区的下界和栈区的上界之间。栈指针把这个栈区域分为两个部分,一个是已经使用的区域,一个是没有使用的区域。
  • 在函数调用结束后, 局部变量先出栈, 然后是参数, 最后是栈顶指针指向的地址。 6. 栈帧保存了每一个函数的返回位置、实参、局部变量、返回值地址。

2.3.2 栈的申请

  • 当在函数或块内部声明一个局部变量时,如:uint8_t a; 系统会判断申请的空间是否足够,足够,在栈中开辟空间,提供内存;不够空间,报异常提示栈溢出。
  • 当调用一个函数时,系统会自动把参数当局部变量,压进栈中,当函数调用结束时,会自动提升堆栈。

2.3.3 栈的大小

栈是有一定大小的,在Windows中默认栈区大小上限为1M或者2M,如果在分配内存时剩余栈的空间不足以分配通常会抛出段错误(segmentation fault)或者是缓冲区溢出(Buffer overflow)的异常报错告警,在Linux系统中栈区的默认上限为8M。 在linux中,查看进程/线程栈大小,命令:

1
2
$ ulimit  -s
8192

设置栈大小:

  1. 临时改变栈大小:ulimit -s 10240
  2. 开机设置栈大小:在/etc/rc.local中加入 ulimit -s 10240
  3. 改变栈大小: 在/etc/security/limits.conf中加入如下一行配置:
1
* soft stack 10240

在声明局部变量时,新手要特别注意栈的大小:

  • 对于局部变量,尽量不定义大的变量,如大数组(大于2*1024*1024字节)char buf[2*1024*1024]; 可能会导致栈溢出
  • 对于内存较大或不知大小的变量,用堆分配,局部变量用指针,注意要释放 char* pBuf = (char*)malloc(2*1024*1024); char* 为调用malloc申请的局部变量(内存),在堆上分配,不用时要调用 free(pBuf) 释放;
  • 或定义在全局区中,static变量 或 常量区中 static char buf[2*1024*1024];

2.3.4 栈的生长方向

栈的生长方向和存放数据的方向相反,自顶向下(自高地址向低地址)。

2.4 堆(Heap)详解

2.4.1 堆的概念

堆(heap):是用来存放动态申请或释放的内存区域。需要程序员分配和释放,系统不会自动管理,如果用完不释放,将会造成内存泄露,直到进程结速后,系统自动回收。堆区的内存地址通常是不连续的, 每个堆区都有一个固定8bytes长度的头部标识信息, 且由于内存对齐制度,后面的块长度如果不足8字节则补空对齐。

堆区是一种经过排序之后的树形结构, 也就是 二叉树 堆中某个节点的值总是不大于或不小于其父节点的值 。

2.4.2 堆的目的

为什么在堆呢?原因很简单,在栈中,大小是有限制的,通常常大小为2M,如果需要更大的空间,那么就要用到堆了,堆的目的就是为了分配使用更大的空间。

2.4.3 堆的申请和释放

在C语言中堆内存通常使用 <stdlib.h>中的malloc或calloc函数来进行分配, 也可以在分配之后使用realloc重新分配堆区大小,在使用完后使用free或delete(C++)函数手动进行回收,并且需要将指针置空,尽量避免野指针的出现。

1
2
3
4
5
6
7
8
9
int  function()
{
    char *pTmp = (char*) malloc(1024);   // malloc在堆中分配1024字节空间
    //pTmp 为局部变量,只占四字节
    free(pTmp);  
    // free为手动释放堆中空间
    pTmp = NULL;  
    // 防止pTmp变野指针误用
}

2.4.4 堆的大小

堆是可以申请大块内存的区域,但堆的大小到底有多大,在linux中,堆区的内存申请,在32位系统中,理论上:$2^{32}$=4G。 理论上,使用malloc最大能够申请空间大约3G。但这是理论值,因为实际中,还会包含代码区,全局变量区和栈区。

2.4.5 堆的生长方向

堆是由低地址向高地址生长的。

2.4.6 堆的注意事项

堆虽然可以分配较大的空间,但有一些要注意的地方,否则会出现问题。

  1. 释放问题:分配了堆内存,一定要记得手动释放,否则将会导致内存泄露
  2. 碎片问题:如果频繁地调用内存分配和释放,将会使堆内存造成很多内存碎片,从而造成空间浪费和效率低下。
    • 对于比较固定,或可预测大小的,可以程序启动时,分配好内存;
    • 结构对齐,尽量使结构不浪费内存;
  3. 超堆大小问题:如果申请内存超过堆大小,会出现虚拟内存不足等问题
    • 尽量不要申请很大的内存,如真需要,可采用内存数据库等
  4. 分配是否成功问题:申请内存后,都在判断内存是否分配成功,分配成功后才能使用,否则会出现段错误
  5. 释放后野指针问题:释放指针后,一定要记得把指针的值设置成NULL,防止指针被释放后误用
  6. 多次释放问题:如果第5 没置NULL,多次释放将会出现问题。

2.5堆区与栈区之间的区别

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

三、C语言的6种存储模型

3.1 C语言存储类别

在C语言中,存储类别 是指用来描述变量或函数的存储方式和生命周期的关键字。

在C语言中,一共有4种存储类别,分别是 自动存储类(auto)静态存储类(static)寄存器存储类(register)外部存储类(extern)

3.1.1 自动存储类:auto

自动存储类(auto) 是默认的存储类别,也称为 局部变量。在函数内部定义的变量默认为auto存储类别。auto变量的生命周期与其所在的函数相同。自动存储类的变量会在函数调用时被自动分配内存(在栈上分配),在函数结束时自动释放内存。当函数执行完毕时,auto变量就会被销毁。

自动存储类的变量不会被初始化,其值是未知的, 故需要先赋值再使用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <stdio.h>
void func(int x) // 形参也是自动存储类变量
{
    auto int i = 0; // 定义自动存储类变量 i
    i = x + 1; // 对变量进行操作
    printf("i = %d\n", i);
}
int main()
{
    int a = 10; // 定义自动存储类变量 a
    func(a);
    return 0;
}

3.1.2 静态存储类:static

静态存储类(static) 用于指定在函数内部或函数外部定义的静态变量。静态存储类定义的变量在程序执行期间一直存在,不会随着函数的结束而销毁。静态变量可以被多次调用,但只会被初始化一次。静态变量的作用域是局部作用域,但是在函数外部也可以访问,需要使用static关键字来声明。静态存储类的变量会被初始化为0或NULL。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <stdio.h>
void func()
{
    static int i = 0; // 定义静态存储类变量
    i++; // 对变量进行操作
    printf("i = %d\n", i);
}
int main()
{
    func();
    func();
    return 0;
}

3.1.3 寄存器存储类:register

寄存器存储类用于定义需要频繁访问的变量。寄存器变量存储在CPU的寄存器中,访问速度比内存快得多。但是并不是所有的变量都能存储在寄存器中,因为寄存器的数量有限,所以只有一些变量才能被放入寄存器中。使用register关键字声明的变量,只是对编译器的建议,不能保证被分配到寄存器中。

寄存器存储类的变量不能取地址,因为它们没有内存地址。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <stdio.h>
void func()
{
    register int i = 0; // 定义寄存器存储类变量
    i++; // 对变量进行操作
    printf("i = %d\n", i);
}
int main()
{
    func();
    return 0;
}

3.1.4 外部存储类:extern

外部存储类(extern) 用于在不同的文件之间共享变量和函数。当在一个文件中定义了一个变量或函数,如果想在其他文件中使用它们,就需要使用extern关键字来声明。extern声明的变量或函数并不会分配内存空间,它只是告诉编译器在其他文件中有这个变量或函数的定义。

外部存储类的变量或函数可以在任何文件中访问,需要在使用前进行声明。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 文件1.c
#include <stdio.h>
int i = 0; // 定义全局变量
void func()
{
    i++; // 对变量进行操作
    printf("i = %d\n", i);
}
 
// 文件2.c
extern int i; // 声明全局变量
int main()
{
    i = 100;
    printf("i = %d\n", i);
    func();
    return 0;
}

3.2 语言存储模型相关概念及分类

C语言提供了包括 自动、寄存器、静态&空链接、静态&外部链接、静态&内部链接 和 基于指针的存储模型 在内的6种存储模型。与 存储期(storage duration)作用域(scope)链接(linkage) 的不同组合与不同的存储模型紧密相关。

作用域(scope) 具体来说就是程序中可以访问一个变量标识符的一个乃至多个区域。C语言中拥有包括 代码块作用域(block scope)、函数原型作用域(function prototype scope)、文件作用域(file scope){全局变量(global variable)具有文件作用域属性} 在内的多种作用域类型。

链接(linkage) 包括 外部链接(external linkage)、内部链接(internal linkage)、空链接(no linkage)。

  • 具有外部链接(external linkage)属性的变量可以被工程内的全部文件识别。
  • 具有内部链接(internal linkage)的变量能被本文件识别。
  • 具有空链接(no linkage)属性的变量可以在本代码块内识别。

存储期(storageduration) 分为 静态存储期(static storage duration)和 自动存储期(auto matic storage duration)。

3.3 C语言的6种存储模型简介

  1. 自动存储模型 的变量具有 自动存储期(auto matic storage duration)、代码块作用域(blockscope)、空链接(no linkage),使用存储类型说明符 auto 加以标识。

  2. 寄存器存储模型 与自动存储模型一样具有 自动存储期(auto matic storage duration)、代码块作用域(blockscope)、空链接(no linkage),但还需要使用存储类型说明符 register加以声明。

  3. 静态&空链接存储模型 具有 静态存储期(static storage duration)、代码块作用域(block scope)、空链接(no linkage)属性。例如静态变量(static variable)就是具有静态&空链接。

  4. 静态&外部链接存储模型 具有 静态存储期(static storage duration)、文件作用域(file scope)、外部链接(external linkage)。具有静态&外部链接属性的变量被称之为 外部变量(external variable),需要使用关键字 extern 加以标识。

编程小技巧: 如果在编程中变量需要被多个文件共同使用,好的处理方法是将它们统一在一个单独的源文件中定义,并且将其定义为静态&外部链接存储模型的变量。这样有利于管理。

  1. 静态&内部链接存储模型 具有 静态存储期(static storage duration)、文件作用域(file scope)、内部链接(internal linkage)。需要使用存储类型说明符 static 加以标识。

  2. 基于指针的存储模型 和前面的5种服从预先定义的内存管理规则的存储模型不同,它给编程人员带来了极大的灵活性。内存分配函数 malloc()和 与之相对应的 free(),是分配和管理内存的有力工具。这里值得注意的一个小细节是malloc()函数的返回类型,它的返回类型是被称之为 通用指针类型 的指向void的指针类型,所以显式的进行类型指派是有必要且可行的。

Licensed under CC BY-NC-SA 4.0