零基础入门C语言之动态内存管理 - 详解
在阅读本篇文章之前,建议读者先阅读完成专栏内前面的文章
目录
前言
本篇文章主要介绍与C语言中动态内存管理相关的知识。
一、为什么要有动态内存分配
目前来说,我们掌握了如下的内存开辟方式:
int val = 20;//在栈空间上开辟四个字节
char arr[10] = {0};//在栈空间上开辟10个字节的连续空间
但是上述的开辟空间的方式有两个特点:空间开辟大小是固定的;数组在申明的时候,必须指定数组的长度,数组空间一旦确定了大小不能调整。但是对于空间的需求,不仅仅是上述的情况。有时候我们需要的空间大小在程序运行的时候才能知道,那数组的编译时开辟空间的方式就不能满足了。 C语言引入了动态内存开辟,让程序员自己可以申请和释放空间,就比较灵活了。
二、malloc和free函数
在C语言中,它为我们提供了一个动态开辟的函数malloc(),我们先看看它的函数声明:
void* malloc (size_t size);
这个函数向内存申请一块连续可用的空间,并返回指向这块空间的指针。如果开辟成功,则返回一个指向开辟好空间的指针;如果开辟失败,则返回一个NULL指针,因此malloc的返回值一定要做检查。返回值的类型是void*,所以malloc函数并不知道开辟空间的类型,具体在使用的时候使用者自己来决定。如果参数size为0,malloc的行为是标准是未定义的,取决于编译器。那么其该如何使用呢,我们键入如下代码:
#include
#include
int main()
{
int* p = (int*)malloc(20);
if (p == NULL) {
perror("malloc");
return 1;
}
for(int i = 0; i < 5; i++) {
*(p + i) = i + 1;
}
for(int i = 0; i < 5; i++) {
printf("%d ", p[i]);
}
}
其运行结果如下:

同时C语言还为我们额外提供了另外一个函数free(),专门用来做动态内存的释放和回收,其函数声明如下:
void free (void* ptr);
free函数用来释放动态开辟的内存。如果参数ptr指向的空间不是动态开辟的,那free函数的行为是未定义的;如果参数ptr是NULL指针,则函数什么事都不做。我们键入如下代码:
#include
#include
int main()
{
int* p = (int*)malloc(20);
if (p == NULL) {
perror("malloc");
return 1;
}
for(int i = 0; i < 5; i++) {
*(p + i) = i + 1;
}
for(int i = 0; i < 5; i++) {
printf("%d ", p[i]);
}
free(p);
p = NULL;
}
我们首先通过free函数释放掉了刚才动态开辟的空间,但是我们的指针p依旧指向这个位置,所以为了避免让其成为野指针被保留下来,我们需要手动将其置空。
三、calloc和realloc函数
C语言还为我们提供了一个叫做calloc的函数,它也可以用于动态内存分配,我们看下它的函数声明:
void* calloc (size_t num, size_t size);
这个函数的功能就是为num个大小为size的元素开辟一片空间,并且把空间的每个字节初始化为0。与malloc函数相比,这个calloc函数会在返回地址前把申请的每个字节初始化为全0。我们可以键入如下的代码:
#include
#include
int main()
{
int *p = (int*)calloc(10, sizeof(int));
if(NULL != p)
{
int i = 0;
for(i=0; i<10; i++)
{
printf("%d ", *(p+i));
}
}
free(p);
p = NULL;
return 0;
}
其运行结果如下:

所以我们可以很方便地利用calloc函数对申请空间的内容要求初始化。
在我们进行动态内存开辟时,有时候我们会觉得开辟的空间过大,有的时候又过小。为了合理地使用内存,我们一定会对这些内存大小做灵活的调整。那么realloc函数就可以做到对动态开辟内存大小的调整。其函数声明如下:
void* realloc (void* ptr, size_t size);
这里面ptr是要调整的内存地址,size是调整之后新大小,返回值为调整之后的内存起始位置。 这个函数调整原内存空间大小的基础上,还会将原来内存中的数据移动到新的空间。realloc在调整内存空间的是存在两种情况:原有空间之后有足够大的空间,原有空间之后没有足够大的空间。

当是第一种情况的时候,要扩展内存就直接原有内存之后直接追加空间,原来空间的数据不发生变化。当是第二种情况的时候,原有空间之后没有足够多的空间时,扩展的方法是在堆空间上另找一个合适大小的连续空间来使用,然后把原有空间的数据拷贝一份到新空间,再释放旧的空间,这样函数返回的是一个新的内存地址。而如果调整失败了,就会为我们返回一个NULL。我们键入如下的代码:
#include
#include
int main()
{
int* p = (int*)malloc(20);
if (p == NULL) {
perror("malloc");
return 1;
}
for(int i = 0; i < 5; i++) {
*(p + i) = i + 1;
}
int* ptr = (int*)realloc(p, 40);
if (ptr == NULL) {
perror("realloc");
return 1;
}
else {
p = ptr;
for(int i = 0; i < 10; i++) {
*(p + i) = i + 1;
}
}
for(int i = 0; i < 10; i++) {
printf("%d ", *(p + i));
}
free(p);
p = NULL;
return 0;
}
其运行结果如下:

需要注意的是,如果我们给realloc函数传了一个空指针过去,那么就相当于一个malloc函数。
四、常见动态内存错误
我们在了解上面的动态内存操作函数之后,再来了解一些常见的动态内存错误。首先是对NULL指针的解引用操作:
void test()
{
int *p = (int *)malloc(INT_MAX/4);
*p = 20;//如果p的值是NULL,就会有问题
free(p);
}
这种错误通常是因为我们在申请内存的时候,申请空间过于巨大,导致函数申请内存失败,返回了空指针,然后我们没有对其进行是否为空的检测,而是直接对这个指针进行使用,这样就会对NULL指针进行解引用。解决这个的方法就是加上我们上面的检测操作。
掌握这个错误之后,我们再来看看第二种错误,对动态开辟空间的越界访问:
void test()
{
int i = 0;
int *p = (int *)malloc(10*sizeof(int));
if(NULL == p)
{
exit(EXIT_FAILURE);
}
for(i=0; i<=10; i++)
{
*(p+i) = i;//当i是10的时候越界访问
}
free(p);
}
很明显,当我们的i为10时,就会形成越界访问。这和我们之前讲解数组相关知识的时候遇到的越界访问是相似的。然后让我们看看第三种错误,对非动态开辟空间使用free函数释放:
void test()
{
int a = 10;
int *p = &a;
free(p);//ok?
}
我们的free函数只能用来释放我们动态开辟的内存,如果我们像上述代码一样释放并非动态开辟的内存的话,就会导致程序卡死。接着是第四种错误,使用free释放一块动态开辟的内存的一部分:
void test()
{
int *p = (int *)malloc(100);
p++;
free(p);//p不再指向动态内存的起始位置
}
这个代码是想保留一部分内存中的内容,然后释放掉我们不需要的那部分,是通过改变指针的方式实现的。但是需要注意的是,当我们的p发生改变之后,也就是说当p不再指向起始的动态申请内存的位置的时候,我们去free的话就会报错。然后让我们看看第五种错误,对同一块内存多次释放:
void test()
{
int *p = (int *)malloc(100);
free(p);
free(p);//重复释放
}
这种问题相对来说也是比较好解决的,那就是在我们free掉一片空间后,要及时把这片空间的指针置空,这样即使重复释放的话,程序也不会出错。然后让我们看看最后一种错误,动态开辟空间忘记释放:
void test()
{
int *p = (int *)malloc(100);
if(NULL != p)
{
*p = 20;
}
}
int main()
{
test();
while(1);
}
上面这段代码会造成很严重的内存泄漏问题,由于死循环导致我们这段代码一直运行,并且我们忘记释放掉内存后由于这个原因也没有办法成功释放掉。我们对于动态开辟的空间,如果不想使用,是可以通过free释放掉的,而即使我们忘记释放的话,在代码运行完毕之后也会由操作系统进行回收。
五、动态内存经典笔试题
在掌握完上面的知识后,我们可以看一看一系列的笔试题,首先是第一段代码:
void GetMemory(char *p)
{
p = (char *)malloc(100);
}
void Test(void)
{
char *str = NULL;
GetMemory(str);
strcpy(str, "hello world");
printf(str);
}
请读者先思考,我们运行Test函数会有什么结果呢?这个程序会直接崩掉。那么为什么会出现这个问题呢,我们来简要分析一下。首先我们进入Test函数,它创建了一个字符指针并通过一个函数为其动态开辟了空间,我们之前在函数栈帧那篇文章提到过,当一个函数运行结束后,它所创建的栈帧和临时变量都会被回收,因而这个str指针实际上开辟完就被销毁了,也就是一个空指针。那我们下面就相当于是去给一个空指针指向的地址拷贝字符串,那么这个对于空指针的解引用操作就会导致我们程序崩溃。
我们再看一下下面这段代码:
char *GetMemory(void)
{
char p[] = "hello world";
return p;
}
void Test(void)
{
char *str = NULL;
str = GetMemory();
printf(str);
}
请读者思考一下,这段代码运行后的结果是什么?

这个输出就证明是这片区域刚被初始化,所以才会输出这种结果,但是输出的原因是什么呢?我们继续逐步分析一下,我们先进入Test函数之后进入GetMemory函数,我们让str指向了hello world这个字符串的首元素地址,但是当我们退出这个函数的时候,函数栈帧就被销毁掉了。那么这个时候,如果说操作系统对这片空间进行覆盖,也就是上面的情况,我们就会有这种输出;而如果操作系统并未回收这片空间,我们就仍能得到原先的字符串。
然后让我们看一下这段代码:
void GetMemory(char **p, int num)
{
*p = (char *)malloc(num);
}
void Test(void)
{
char *str = NULL;
GetMemory(&str, 100);
strcpy(str, "hello");
printf(str);
}
请读者思考一下,运行的结果应该是什么样的?

这个代码只有一个小问题,就是我们没有free和野指针置空,其余部分就相当于是我们对第一道面试题错误代码的改正。
然后我们来看最后一道题,其代码如下:
void Test(void)
{
char *str = (char *) malloc(100);
strcpy(str, "hello");
free(str);
if(str != NULL)
{
strcpy(str, "world");
printf(str);
}
}
其运行结果如下:

这道题是因为我们将这片空间释放本质上是我们失去了对这片空间的控制权,但是这片空间并非消失不见了。并且由于我们并未给指针置空,所以我们还是知道这片空间的位置,然后进行字符串的拷贝就能得到上面的结果了。
六、柔性数组
首先我们需要了解一下什么是柔性数组?柔性数组其实就是在C99中,结构体中的最后一个元素允许是未知大小的数组,这就是柔性数组。比如说下面这种:
struct st_type
{
int i;
int a[0];//柔性数组成员
};
而有些编译器会产生报错,我们可以更改为:
struct st_type
{
int i;
int a[];//柔性数组成员
};
结构体中的柔性数组成员前面必须至少一个其他成员。sizeof返回的这种结构体大小不包括柔性数组的内存。包含柔性数组成员的结构用malloc()函数进行内存的动态分配,并且分配的内存应该大于结构的大小,以适应柔性数组的预期大小。我们键入如下代码:
typedef struct st_type
{
int i;
int a[0];//柔性数组成员
}type_a;
int main()
{
printf("%d\n", sizeof(type_a));//输出的是4
return 0;
}
其运行结果如下:

那么也就说明,其实我们在计算柔性数组大小时根本就不会将数组算入其中。并且我们需要用动态内存开辟的方式来为柔性数组开辟空间。我们键入如下代码:
#include
#include
int main()
{
int i = 0;
type_a *p = (type_a*)malloc(sizeof(type_a)+100*sizeof(int));
//业务处理
p->i = 100;
for(i=0; i<100; i++)
{
p->a[i] = i;
}
free(p);
return 0;
}
那么让我们看看下面这段代码:
#include
#include
typedef struct st_type
{
int i;
int *p_a;
}type_a;
int main()
{
type_a *p = (type_a *)malloc(sizeof(type_a));
p->i = 100;
p->p_a = (int *)malloc(p->i*sizeof(int));
//业务处理
for(int i=0; i<100; i++)
{
p->p_a[i] = i;
}
//释放空间
free(p->p_a);
p->p_a = NULL;
free(p);
p = NULL;
return 0;
}
其实这段代码就是在模拟柔性数组,但是与柔性数组相比,到底哪种是更优解呢?答案是柔性数组。但是为什么呢?
第一个好处是方便内存释放。如果我们的代码是在一个给别人用的函数中,你在里面做了二次内存分配,并把整个结构体返回给用户。用户调用free可以释放结构体,但是用户并不知道这个结构体内的成员也需要free,所以你不能指望用户来发现这个事。所以,如果我们把结构体的内存以及其成员要的内存一次性分配好了,并返回给用户一个结构体指针,用户做一次free就可以把所有的内存也给释放掉。第二个好处是这样有利于访问速度。连续的内存有益于提高访问速度,也有益于减少内存碎片。(其实,我个人觉得也没多提高,反正你跑不了要用做偏移量的加法来寻址)
强烈推荐下面这篇文章,与我们前面的知识极其相关并且更加深入:
C语言结构体里的成员数组和指针 | 酷 壳 - CoolShell
最后我们强调一下C程序中内存的划分:

这个部分的详细解读我们在前面的函数栈帧的创建与销毁一文中。
总结
本文主要介绍了C语言中的动态内存管理机制。首先分析了静态内存分配的局限性,引出了动态内存分配的必要性。重点讲解了四个动态内存分配函数:malloc(分配未初始化空间)、calloc(分配并初始化为0)、realloc(调整已分配空间大小)和free(释放内存)。文章详细说明了这些函数的使用方法、注意事项和常见错误,如空指针解引用、越界访问、重复释放等。最后介绍了柔性数组的概念和优势,包括内存连续性和释放便利性。全文通过代码示例演示了动态内存管理的正确使用方法,并强调了对NULL指针检查和及时释放内存的重要性。

浙公网安备 33010602011771号