C语言学习笔记 —— 内存管理

一、内存模型

对于一个C语言程序而言,内存空间主要由五个部分组成 代码段(text)数据段(data)未初始化数据段(bss)堆(heap)栈(stack) 组成,其中代码段,数据段和BSS段是编译的时候由编译器分配的,而堆和栈是程序运行的时候由系统分配的。布局如下:

二、栈(stack)

2.1 介绍

栈(stack)又称堆栈, 是用户存放程序临时创建的局部变量,也就是说我们函数括弧“{}”中定义的变量(但不包括static声明的变量,static意味着在数据段中存放变量)。除此以外,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值也会被存放回栈中。

由于栈的先进后出特点,所以栈特别方便用来保存/恢复调用现场。

从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区。注意:栈空间是向下增长的,每个线程有一个自己的栈,在Linux上默认的大小是8M,可以用ulimit查看和修改

函数内( 包括main()函数 )声明的变量都储存在栈内:

int main(){
    // 整型a,字符b,字符串str都储存在stack内
    int a;
    char b;
    char str[100];
}

2.2 特点

栈具有如下特点:

  • 运行时自动分配和自动回收: 栈是自动管理的,程序员不需要手工干预,使用起来方便简单。
  • 反复使用: 栈内存在程序中其实就是那一块空间,程序反复使用这一块空间。
  • 脏内存: 栈内存由于反复使用,每次使用后程序不会去清理,因此分配到时如果没有初始化会保留原来的值
#include

//函数不能返回函数内部局部变量的地址,因为这个函数执行完返回这个局部变量已经不在了,这个局部变量是分配在栈上的,虽然不在了,但是栈内存还存在,还可以访问,但是访问时实际上这个内存地址已经和当时那个变量无关了。

int *func(void)
{
        int a=4;                    //a是局部变量,分配在栈上又叫栈变量,又叫临时变量
        printf("&a=%p\n",&a);
        return &a;
}

int main(void)
{
        int *p=NULL;
        p=func();
        printf("&a=%p\n",&a);       //&a和p的值相同
        printf("*p=%d.\n",*p);      //*p=4,证明了栈内存完了之后是脏的
        return 0;
}
  • 临时性: 函数不能返回栈变量的指针,因为这个空间是临时的。
  • 栈会溢出: 因为操作系统事先给定了栈的大小,如果在函数中无穷尽的分配栈内存总会消耗完。
//栈溢出的实例
void stack_overflow(void)
{
        int a[100000000]={0};
        a[100000000-1]=12;
}

int main(void)
{
        stack_overflow();           
}
//程序报错:error:Segmentation fault(core dumped) 

三、堆(heap)

3.1 介绍

堆(heap) 是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当进程调用 malloc 等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用 free 等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)。

#include 
#include 
#include 

int main(){
    int *s;
    s = (char *)malloc(20);     // 分配内存
    strcpy(s, "hello!");
    printf("%s\n", s);
    free(s);    // 释放内存
    return 0;
}

3.2 特点

堆具有如下特点:

  • 大块内存: 堆内存管理是总量很大的操作系统内存块,各进程可以按需申请使用,使用完释放。
  • 程序手动申请和释放: 手工意思是需要写代码去申请malloc()和释放free()
  • 脏内存: 堆内存也是反复使用的,而且使用者用完释放前不会清除,因此也是脏的。
  • 临时性: 堆内存只在申请malloc()和释放free()之间属于这个进程,可以访问。在申请malloc()和释放free()之后都不能再访问,否则会有不可预料的后果。
  • 不可直接操作: 需要通过指针操作。

3.3 动态分配函数

3.3.1 malloc()

头文件:#include

功能 在内存的动态存储器(堆区)中分配一块size字节大小连续区域,用来存放类型说明符指定的类型。malloc 函数会返回 void* 指针,使用时必须做相应的强制类型转换分配的内存空间内容不确定,一般使用memset初始化
函数定义 void *malloc(unsigmed int size)
参数 size:申请空间的大小
返回 成功 - 分配空间的起始地址,失败 - NULL

注意:

  • malloc()申请的内存时用完后要free()释放。free(p);会告诉堆管理器这段内存我用完了你可以回收了。堆管理器回收了这段内存后(除非再次申请且刚好又分配到这段内存),这段内存在当前进程就不应该再使用了,因为释放后堆管理器就可能把这段内存再次分配给别的进程,所以就不能再使用了。
  • 使用malloc()开辟空间需要保存开辟好的地址空间的首地址,在调用free()归还这段内存之前,指向这段内存的指针p一定不能丢,也就是不能给p另外赋值。因为p一旦丢失这段malloc()来的内存就永远的丢失了,就会造成内存泄漏,直到当前程序结束时操作系统才会回收这段内存。
  • 但是由于不确定空间用于做什么,所以malloc()返回值为void *类型的指针,所以在调用函数时根据接收者的类型对其进行强制转换。实质上malloc()返回的是堆管理器分配给本次申请的那段内存空间的首地址,也就是说malloc()返回的值其实是一个数字,这个数字表示一个内存地址。
  • 在调用malloc()之后,一定要判断是否为NULL,是否申请内存成功。malloc()成功申请空间后返回这个内存空间的指针,申请失败时返回NULL。
  • 如果多次malloc申请内存,第一次和第二次申请的内存不一定是连续的

3.3.2 calloc()

头文件:#include

功能 在堆区申请指定大小的空间,在内存堆中,申请nmemb块,每块的大小为size个字节的连续区域。
函数定义 void *calloc(size_t nmemb, size_t size)
参数 nmemb:要申请的空间的块数
size:每块的字节数
返回 成功 - 分配空间的起始地址,失败 - NULL

注意:

  • malloc()申请的内存,内存中存放的内容是随机的,不确定的,而calloc()函数申请的内存中的内容为0

3.3.3 realloc()

头文件:#include

功能 在原本申请好的堆区空间的基础上重新申请内存,新的空间大小为函数的第二个参数,如果原本申请好的空间后面不足以增加指定的大小,系统会重新找一个足够大的位置靠谱指定的空间,然后将原本空间的数据拷贝过来,然后释放原本的空间。
函数定义 void *realloc(void *s, unsigned int newsize)
参数 s:原本开辟好的空间的首地址
newsize:重新开辟的空间的大小
返回 新申请的内存的首地址

注意:

  • malloc()、calloc()、realloc()动态申请的内存,只有在free()或程序结束时才释放。

3.3.4 free()

头文件:#include

功能 释放ptr指向的内存。只能释放堆区空间。
函数定义 void *free(void *ptr)
参数 ptr:开辟后使用完毕的堆区空间的首地址
返回

注意:

  • free()函数只能释放区的空间,其他区域的空间无法使用free()。
  • free()释放空间必须释放malloc()或calloc()或relloc()的返回值对应空间,不能说只释放一部分
  • free()后,因为没有给p赋值,所以p还是指向原先动态申请的内存,但内存已经不能再使用了,p变成野指针。【解释:释放空间并不是把内存直接删除,而是告诉系统本人不在使用这个空间了,而别人可以使用这个空间。还会出现一种情况,虽然告知别人这块空间不用了,但该变量p还知道该空间在哪,因为p还保存原先地址,假设一个A同学在班级坐在x1的座位,在班级中这个x1的座位就属于他,但如果这个A同学要换位置呢,这个x1的位置还是存在,但里这个位置坐的内容却不确定,因此,只要你释放内容,尽可能不要去操作了。因为不一定这块空间内容是什么。】一般为了防止野指针,会在free()完毕之后对p赋为NULL
  • 一块动态申请的内存,只能free一次,不能多次free。

四、未初始化数据段(bss)

4.1 介绍

未初始化数据段bss(Block Started by Symbol):又称 ZI Zero Initial 段,通常是指用来存放程序中未初始化的或者初始化为0全局变量静态变量。bss段本质上也是属于数据段,bss段就是被初始化为0的数据段。

4.2 特点

  • bss段数据在程序开始执行之前被内核初始化为0或者空(NULL)。
  • 数据的生存周期为整个程序运行过程。
  • 由编译器在编译时分配的。
  • .text段和.data段都在可执行文件中(在嵌入式系统里一般是固化在镜像文件中),由系统从可执行文件中加载;而.bss段不在可执行文件中,由系统初始化。

这里注意一个问题:一般的书上都会说全局变量和静态变量是会自动初始化的,那么哪来的未初始化的变量呢?变量的初始化可以分为显示初始化和隐式初始化,全局变量和静态变量如果程序员自己不初始化的话的确也会被初始化,那就是不管什么类型都初始化为0,这种没有显示初始化的就是我们这里所说的未初始化。既然都是0那么就没必要把每个0都存储起来,从而节省磁盘空间,这是BSS的主要作用)的一块内存区域。BSS是英文Block Started by Symbol的简称。BSS段属于静态内存分配。 BSS节不包含任何数据,只是简单的维护开始和结束的地址,即总大小,以便内存区能在运行时分配并被有效地清零。BSS节在应用程序的二进制映象文件中并不存在,即不占用磁盘空间而只在运行的时候占用内存空间,所以如果全局变量和静态变量未初始化那么其可执行文件要小很多。
示例 - 程序1:

int ar[30000];
void main()
{
   ......
}

示例 - 程序2:

int ar[300000] = {1, 2, 3, 4, 5, 6 };
void main()
{
   ......
}

程序2的可执行文件大小要比程序1大得多。原因也很简单:程序1全局的未初始化变量存在于.bss段中;而程序2全局的已初始化变量存于.data段中。.bss是不占用可执行文件空间的,其内容由操作系统初始化;.data却需要占用,其内容由程序初始化。因此造成了上述情况。

有些特殊数据会被放到代码段:

  • C语言中使用char *p = "hello";定义字符串时,字符串"hello"实际被分配在代码段,也就是说这个"hello"字符串实际上是一个常量字符串而不是变量字符串。
  • const型常量:C语言中const关键字用来定义常量,常量就是不能被改变的量。const的实现方法至少有2种:第一种就是编译将const修饰的变量放在代码段去以实现不能修改,这种方法普遍见于各种单片机的编译器;第二种就是由编译器来检查以确保const型的常量不会被修改,实际上const型的常量还是和普通变量一样放在数据段的,gcc中就是这样实现的,所以实际上gcc中可以通过指针方式来更改

五、数据段

5.1 介绍

数据段(data segment)也被称为数据区、静态数据区、静态区,数据段就是程序中的数据,直观理解就是C语言程序中的全局变量。值得注意的是:全局变量才算是程序的数据,局部变量不算程序的数据,局部变量只能算是函数的数据。放在数据段的变量有2种:

  • 第一种是显式初始化为非零的全局变量
  • 第二种是静态局部变量,也就是static修饰的局部变量。普通局部变量分配在栈上,静态局部变量分配在数据段。

数据段属于静态内存分配,可以分为只读数据段和读写数据段。 字符串常量等,但一般都是放在只读数据段中注: 字符串常量归类在代码区还是数据区有争议,也有把它单独拿出来归类文字常量区(只读数据区)。我们只要自己清楚它是在数据区和代码区的之间就行了。该区一般用于存储只读数据,字面常量都存在这个区里面。

  • 显式初始化为非零的全局变量
// 在主函数外定义全局变量
#include 
#include 
int theforce;

int main(){
    ...
}
  • 静态局部变量
// 在主函数内定义全局变量
int main(){
    static int theforce;
    ...
}

// 在自定义函数内定义全局变量
void func(){
    static int theforce2;
}

5.2 特点

  • 程序在加载到内存前,数据段的大小就是固定的,程序运行期间不能改变。
  • 数据的生存周期为整个程序运行过程。
  • 由编译器在编译时分配的。

六、代码段

代码段(code segment/text segment) 通常是指用来存放程序执行代码的一块内存区域,直观理解代码段就是函数堆叠组成的。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读,某些架构也允许代码段为可写,即允许修改程序。

七、区别

7.1 栈和堆的区别

有以下几点:

  • 管理方式不同。
    栈编译器自动管理,无需程序员手工控制;而堆空间的申请释放工作由程序员控制,容易产生内存泄漏。

  • 空间大小不同。
    栈是向低地址扩展的数据结构,是一块连续的内存区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,当申请的空间超过栈的剩余空间时,将提示溢出。因此,用户能从栈获得的空间较小。

    堆是向高地址扩展的数据结构,是不连续的内存区域。因为系统是用链表来存储空闲内存地址的,且链表的遍历方向是由低地址向高地址。由此可见,堆获得的空间较灵活,也较大。栈中元素都是一一对应的,不会存在一个内存块从栈中间弹出的情况。

  • 是否产生碎片。
    对于堆来讲,频繁的malloc/free(new/delete)势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低(虽然程序在退出后操作系统会对内存进行回收管理)。对于栈来讲,则不会存在这个问题。

  • 增长方向不同。
    堆的增长方向是向上的,即向着内存地址增加的方向;栈的增长方向是向下的,即向着内存地址减小的方向。

  • 分配方式不同。
    堆都是程序中由malloc()函数动态申请分配并由free()函数释放的;栈的分配和释放是由编译器完成的,栈的动态分配由alloca()函数完成,但是栈的动态分配和堆是不同的,他的动态分配是由编译器进行申请和释放的,无需手工实现。

  • 分配效率不同。
    栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行。堆则是C函数库提供的,它的机制很复杂,例如为了分配一块内存,库函数会按照一定的算法(具体的算法可以参考数据结构/操作系统)在堆内存中搜索可用的足够大的空间,如果没有足够大的空间(可能是由于内存碎片太多),就有需要操作系统来重新整理内存空间,这样就有机会分到足够大小的内存,然后返回。显然,堆的效率比栈要低得多。

7.2 堆和数据段的区别

堆和数据段几乎拥有完全相同的属性,大部分时候是可以完全替换的。但是生命周期不一样:堆内存的生命周期是从malloc开始到free结束,而全局变量是从整个程序一开始执行就开始,直到整个程序结束才会消灭,伴随程序运行的一生。所以如果这个变量只是在程序的一个阶段有用,用完就不用了,就适合用堆;如果这个变量本身和程序是一生相伴的,那就适合用全局变量。堆就好象图书馆借书,数据段就好象自己书店买书,你以后会慢慢发现:买不如租,堆的使用比全局变量广泛。

7.3 数据段和bss段的区别

二者本来没有本质区别,都是用来存放C程序中的全局变量的。区别在于把显示初始化为非零的全局变量存在.data段中,而把显式初始化为0或者并未显式初始化(C语言规定未显式初始化的全局变量值默认为0)的全局变量存在bss段

八、常见的内存错误及对策

8.1 指针没有指向一块合法的内存

1. 结构体成员指针未初始化

struct student
{
    char *name;
    int score;
}stu,*pstu;

int main()
{
    strcpy(stu.name,"Jimy");
    stu.score = 99;
    return 0;
}

这里定义了结构体变量 stu,但是这个结构体内部 char *name 这成员在定义结构体变量 stu 时,只是给 name 这个指针变量本身分配了 4 个字节。name 指针并没有指向一个合法的地址,这时候其内部存的只是一些乱码。所以在调用 strcpy 函数时,会将字符串"Jimy"往乱码所指的内存上拷贝,而这块内存 name 指针根本就无权访问,导致出错。解决的办法是为 name 指针 malloc 一块空间。同样,也有人犯如下错误:

int main()
{
    pstu = (struct student*)malloc(sizeof(struct student));
    strcpy(pstu->name,"Jimy");
    pstu->score = 99;
    free(pstu);
    return 0;
}

为指针变量 pstu 分配了内存,但是同样没有给 name 指针分配内存。错误与上面第一种情况一样,解决的办法也一样。这里用了一个 malloc 给人一种错觉,以为也给 name 指针分配了内存。

2. 函数的入口校验
不管什么时候,我们使用指针之前一定要确保指针是有效的。

一般在函数入口处使用 assert(NULL != p) 对参数进行校验。在非参数的地方使用 if(NULL != p) 来校验。但这都有一个要求,即 p 在定义的同时被初始化为 NULL 了。比如上面的例子,即使用 if(NULL != p) 校验也起不了作用,因为 name 指针并没有被初始化为 NULL,其内部是一个非 NULL 的乱码。assert 是一个宏,而不是函数,包含在 assert.h 头文件中。如果其后面括号里的值为假,则程序终止运行,并提示出错;如果后面括号里的值为真,则继续运行后面的代码。这个宏只在 Debug 版本上起作用,而在 Release 版本被编译器完全优化掉,这样就不会影响代码的性能。

8.2 内存分配成功,但并未初始化

犯这个错误往往是由于没有初始化的概念或者是以为内存分配好之后其值自然为 0。未初始化指针变量也许看起来不那么严重,但是它确确实实是个非常严重的问题,而且往往出现这种错误很难找到原因。

也许这种严重的问题并不多见,但是也绝不能掉以轻心。所以在定义一个变量时,第一件事就是初始化。你可以把它初始化为一个有效的值,比如:

int i = 10;
char *p = (char *)malloc(sizeof(char));

但是往往这个时候我们还不确定这个变量的初值,这样的话可以初始化为 0 或 NULL。

int i = 0;
char *p = NULL;

如果定义的是数组的话,可以这样初始化:

int a[10] = {0};

或者用 memset 函数来初始化为 0:

memset(a,0,sizeof(a));

8.3 内存越界

何谓內存访问越界,简单的说,你向系统申请了一块内存,在使用这块内存的时候,超出了你申请的范围。

  • 访问到野指针指向的区域,越界访问
  • 数组下标越界访问
  • 使用已经释放的内存
  • 企图访问一段释放的栈空间
  • 容易忽略字符串后面的'\0'

8.3.1 数组下标越界访问

内存分配成功,且已经初始化,但是操作越过了内存的边界。这种错误经常是由于操作数组或指针时出现“多 1”或“少 1”。比如:

int a[10] = {0};
for (i=0; i<=10; i++)
{
  a[i] = i;
}

所以,for 循环的循环变量一定要使用半开半闭的区间,而且如果不是特殊情况,循环变量尽量从 0 开始。

8.3.2 容易忽略字符串后面的'\0'

为指针分配了内存,但是内存大小不够,导致出现越界错误。

char *p1 = “abcdefg”;
char *p2 = (char *)malloc(sizeof(char)*strlen(p1));
strcpy(p2,p1);

p1 是字符串常量,其长度为 7 个字符,但其所占内存大小为 8byte。初学者往往忘了字符串常量的结束标志 “\0”。这样的话将导致 p1 字符串中最后一个空字符 “\0” 没有被拷贝到 p2 中。

解决的办法是加上这个字符串结束标志符:

char *p2 = (char *)malloc(strlen(p1)+1);

这里需要注意的是,只有字符串常量才有结束标志符。比如下面这种写法就没有结束标志符了:

char a[7] = {‘a’,’b’,’c’,’d’,’e’,’f’,’g’};

8.4 内存泄漏

当定义了一个指针时,要立即为该指针分配一个内存空间,这是为了防止野指针的产生。当使用完毕后,应立即释放其所占空间,否则,下次再使用该指针时又为其分配内存空间,再使用。再分配。这样。内存空间就被慢慢消耗掉了,这种现象称为内存泄漏。

  • 丢失了分配的内存的首地址,导致无法释放
  • 丢失分配的内存地址
  • 企图希望传入指针变量获取对内存,殊不知是拷贝
  • 每循环一次,泄露一次内存
  • 非法访问常量区

8.4.1 丢失了分配的内存的首地址,导致无法释放


int main()
{
    char *p;
    p = (char *)malloc(100);
    p = "hello world"; // p指向其他地方,把字符串的首地址赋给p;这样子导致原本堆区的空间找不到了
    //从此刻起,再也找不到申请的100个字节的空间,动态申请的100个字节被泄漏了
    return 0;
}

8.4.2 每循环一次,泄露一次内存

void test1()
{
    char *p;
    p =(char *)malloc(100); 
    //接下来,可以用p指向内存了 , 
    //...
    //没有释放,没有返回,这样导致这个函数执行完后不知道这个申请的内存空间在哪了 
}
 
void main()
{
    //每次用一次fun泄露100个字节 
    test1();
    test1();
}

修改如下:

void test1()
{
    char *p;
    p =(char *)malloc(100); 
    //接下来,可以用p指向内存了 , 
    //...
    free(p); 
}
 
void main()
{
    //每次用一次fun泄露100个字节 
    test1();
    test1();
}

九、内存对齐

CPU 通过地址总线来访问内存,一般多少位cpu一次能取到的数据就是多少位的,所以32位cpu一次可以处理4个字节的数据,那么每次就从内存读取4个字节的数据,而单位时间内读取的次数叫做主频。为了做到最快的寻址,即一次获取的数据尽量多,并且不要重复,cpu寻址采用了步长进行寻址,并且只对编号为一定数据量的倍数的内存寻址(不一定严格为4倍数,假如最起始的地址不是0)。比如32位cpu的寻址步长为4个字节,即每次只对编号为 4 的倍数的内存开始进行寻址,例如只对0、4、8、12…1000的地址为开始进行寻址 :


所以对于程序来说,一个变量最好位于一个寻址步长的范围内,这样一次就可以读取到变量的值;如果跨步长存储,就需要读取两次,然后再拼接数据,效率显然降低了。

将一个数据尽量放在一个步长之内,避免跨步长存储,这称为内存对齐。


• 由 Leung 写于 2022 年 7 月 26 日

• 参考:C语言内存管理深入
    C语言的内存管理
    必知必会-C语言内存管理
    内存的管理(c语言)
    重学C语言内存管理

你可能感兴趣的:(C语言学习笔记 —— 内存管理)