C语言-内存管理基础
前言
C语言作为一门应用途广泛、功能强大、使用灵活的面向过程式编程语言。既可用于编写应用软件,又能用于编写系统软件。所以深入理解C语言的内存管理能够加深我们对程序的理解,有助于开发出更高质量的应用。本文就是笔者在学习C语言过程中对其内存管理的学习总结,分享出来希望能给正在学习C、Objective-C、C++等语言的小伙伴们一点启发。
内存定义
- 硬件角度:内存是计算机必不可少的一个组成部分,是于CPU沟通的桥梁,计算机中所有的程序都是运行在内存中的。
- 逻辑角度:内存是一块具备随机访问能力,支持读、写操作,用来存放程序及程序运行中产生的数据的区域。
- 联想理解:以现实生活中的建筑物来类比最合适不过,计算机中可用的内存对应着一栋大楼,内存中的每一个单元格的地址对应着房间的门牌号。内存中存储的内容就是住在房间里面的人或物。我们根据内存单元格的地址就能找到对应内存中存储的数据。
内存单位和编址
- 位 :( bit ) 是电子计算机中最小的数据单位。每一位的状态只能是0或1。
- 字节:1 Byte = 8 bit ,是内存基本的计量单位,
- 字:"字" 由若干个字节构成,字的位数叫做字长,不同档次的机器有不同的字长。
- KB :1KB = 1024 Byte。也就是1024个字节。
- MB : 1MB = 1024 KB。类似的还有GB、TB。
- 内存编址:计算机中的内存按字节编址,每个地址的存储单元可以存放一个字节(8个bit)的数据,CPU通过内存地址获取指令和数据,并不关心这个地址所代表的空间具体在什么位置、怎么分布,因为硬件的设计保证一个地址对应着一个固定的空间,所以说:内存地址和地址指向的空间共同构成了一个内存单元。
-
内存地址:内存地址通常用十六进制的数据表示,例如通常在C或者Objective-C中输出一个变量的地址可能为:0x7fff5fbff79c,这就是一个用十六进制的数表示的地址。
某块内存的存储单元分配可能如下:
内存单元分配示意图
由上面的图可以看出:内存是由字节为单位组成的,字节在内存中是连续分配的,一个字节单元的地址一般由十六进制的数表示。 - 十六进制:计算机中数据的一种表示方法。由0-9,A-F组成,字母不区分大小写。C、C++规定,16进制数必须以 0x 开头。比如 0x1 表示一个16进制数。而 1 则通常表示一个十进制数。另外如:0xff、0xFF、0X102A等等。其中的x也不区分大小写。(注意:0x 中的0是数字0,而不是字母O)。使用十六进制表示一个内存地址是因为:1,二进制、十进制、十六进制之间相互转换比较方便;2,一位十六进制数可以表示4个二进制位数,更大的数使用十六进制数表示更加精短。3,计算机硬件设计需要。例如下图的整数100在三种进制中的表示:
二进制、十进制、十六进制表示
内存组成
对于一个由C语言编写的程序而言,内存主要可以分为以下5个部分组成:
其中需要注意的是:代码段、数据段、BSS段在程序编译期间由编译器分配空间,在程序启动时加载,由于未初始化的全局变量存放在BSS段,已初始化的全局变量存放在数据段,所以程序中应该尽量少的使用全局变量以节省程序编译和启动时间;栈和堆在程序运行中由系统分配空间。下面简单介绍一下各个组成部分具体含义,重点介绍堆、栈。
-
栈(stack)
首先栈应该作为数据结构中的一种线性结构被介绍。其具有先进后出的特点(简称FILO)。
栈的结构示意图
类似于手枪中的弹夹(别告诉我你没打过手枪...),最先放入的子弹最后弹出,最后放入的子弹最先弹出,通过不断移动栈顶指针实现子弹的装载和发射(对应栈的操作是入栈和出栈)。与栈对应的还有一种数据结构叫做 “队列”,具有先进先出(FIFO)的特点,这里就不再赘述了,有兴趣的同学可以看看关于“数据结构”方面的书了解更多。
其次栈作为内存中存储结构,通常存放程序临时创建的局部变量,即函数括大括号 “{ }” 中定义的变量,其中还包括函数调用时其形参,调用后的返回值等。 栈是由到高地址向低地址扩展的数据结构。即依次定义两个局部变量,首先定义的变量的地址是高地址,其次变量的地址是低地址。例如
#include <stdio.h>
int main(int argc, const char * argv[]) {
int a = 100;
int b = 100;
printf("%p \n",&a); // 0x7fff5fbff79c
printf("%p \n",&b); // 0x7fff5fbff798
return 0;
}
// a 变量的地址 0x7fff5fbff79c 比 b 变量的地址 0x7fff5fbff798 要大
最后栈还具有“小内存、自动化、可能会溢出”的特点。栈顶的地址和栈的最大容量一般是系统预先规定好的,通常不会太大。由于栈中主要存放的是局部变量,而局部变量的占用的内存空间是其所在的代码段或函数段结束时由系统回收重新利用,所以栈的空间是循环利用自动管理的,一般不需要人为操作。如果某次局部变量申请的空间超过栈的剩余空间时就有可能出现 “栈的溢出”,进而导致意想不到的后果。所以一般不宜在栈中申请过大的空间,比如长度很大的数组、递归调用重复次数很多的函数等等。
-
堆(heap)
通常存放程序运行中动态分配的存储空间。堆是低地址向高地址扩展的数据结构,是一块不连续的内存区域。在标准C语言上,使用malloc等内存分配函数是从堆中分配内存的,在Objective-C中,使用new创建的对象也是从堆中分配内存的。
堆具有“大内存、手工分配管理、申请大小随意、可能会泄露”的特点,堆内存是操作系统划分给堆管理器来管理的,管理器向使用者(用户进程)提供API(malloc和free等)来使用堆内存。需要程序员手动分配释放,如果程序员在使用完申请后的堆内存却没有及时把它释放掉,那么这块内存就丢失了(进程自身认为该内存没被使用,但是在堆内存记录中该内存仍然属于这个进程,所以当需要分配空间时又会重新去申请新的内存而不是重复利用这块内存),就是我们常说的-内存泄漏,所以内存泄漏指的是堆内存被泄露了。下面是一个很典型关于各种变量在内存中分配位置的例子(示例代码来源于网络):
#include <stdio.h>
int a = 0; // 全局初始化区
char p1; // 全局未初始化区
int main(int argc, const char * argv[]) {
int b ; // 栈
char s[] = "abc"; // 栈
char p2 ; // 栈
char p3 = "123456"; // 123456在常量区,p3在栈上。
static int c = 0 ; // 全局(静态)初始化区
p1 = (char )malloc(10); // 分配的10字节的区域就在堆区
p2 = (char )malloc(20); // 分配的20字节的区域就在堆区
printf("%p\n",p1); // 0xffffffb0
printf("%p\n",p2); // 0xffffffc0
return 0;
//p1 变量的地址 0xffffffb0 比 p2 变量的地址 0xffffffc0 要小
}
-
BSS段
Block Started by Symbol的简称,通常是指用来存放程序中未初始化的全局变量和静态变量。 -
数据段
通常是指用来存放程序中已初始化的全局变量和静态变量以及字符串常量 -
代码段
通常是指用来存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定。
C语言操作堆内存的函数
C语言中申请和释放堆内存有四个函数,分别是:
-
malloc
【函数原型】void *malloc(size_t __size)
【参数说明】size
需要分配的内存空间的大小,单位是字节。
【返回值类型】void *
表示未确定类型的指针,分配成功返回指向该内存的地址,失败则返回NULL
。C、C++规定,void *
类型可以强制转换为任何其它类型的指针。
【函数功能】 表示向系统申请分配指定 size 个字节的内存空间。例如:
int *a = malloc(4); //申请4个字节的空间用于存放一个int类型的值
char *b = malloc(2); //申请2个字节的空间用于存放一个char类型的值
-
calloc
【函数原型】void *calloc(size_t __count, size_t __size)
【参数说明】count
表示个数,size
单位个需要分配的内存空间的大小,单位是字节。
【返回值类型】void *
表示未确定类型的指针。例如:
【函数功能】 表示向系统申请分配 count 个长度为 size 一共为 count 乘以 size 个字节长度的连续内存空间,并将每一个字节都初始化为 0。
int *c = calloc(10, sizeof(int)); 申请10个sizeof(int) 字节的空间
char *d = calloc(2, sizeof(char)); 申请10个sizeof(char) 字节的空间
-
realloc
【函数原型】void *realloc(void *__ptr, size_t __size)
【参数说明】ptr
表示需要修改的内存空间的地址,size
表示需要重写分配的内存空间的大小,单位是字节。
【返回值类型】void *
表示未确定类型的指针。
【函数功能】 表示更改已经配置好的内存空间到指定的大小。例如:
char *d = calloc(2, sizeof(char)); //申请2个sizeof(char) 字节的空间
char *f = realloc(d, 5 * sizeof(char)); //将原来变量d指向的2个sizeof(char) 字节的空间更改到5个sizeof(char) 字节的空间并由变量f指向。
-
free
【函数原型】void free(void *)
【参数说明】void *
表示需要释放的内存空间对应的内存地址。
【返回值类型】返回值为空。
【函数功能】 表示用来释放已经动态分配的内存空间。free()
可以释放由malloc()
、calloc()
、realloc()
分配的内存空间,以便其他程序再次使用。需要注意的是:free()
不会改变 传入的指针的值,调用free()
后它仍然会指向相同的内存空间,但是此时该内存已无效,不能被使用。所以建议将释放完的指针设置为NULL
。例如:
char *g = malloc(sizeof(char)); //申请sizeof(char)大小内存空间
free(g); //释放掉g指针指向的内存空间
g = NULL; //将g指针指向NULL
free()
函数只能释放动态分配的内存,并不能释放任意分配的内存,比如:
int h[10]; //在栈上申请的10乘以sizeof(int)大小的内存空间
free(h); //此处报错:不能释放栈上分配的空间
void 指针类型
在系统提供的内存分配的4个库函数malloc
、calloc
、recolloc
、free
中,除了free
的返回值为空外,其他三个函数的返回值均为void *
类型,由于指针不是本文的重点,所以这里仅仅简单介绍一下关于void
指针类型(即void *
类型)。
《C语言程序设计》一书中对void
指针类型是这样解释的:
C99(C语言的官方标准第二版)允许使用基类型为
void
的指针类型,可以定义一个基类型为void
的指针变量,它不指向任何类型的数据。请注意:不要把 “指向void
类型” 理解为能指向 “任何的类型” 的数据,而应该理解为 “指向空类型” 或者 “不指向确定” 的类型的数据。在将它的值赋给另一个指针变量时由系统对它进行类型转换,使之适合被赋值变量的类型。
例如下面的代码:
int main(int argc, const char * argv[]) {
int a = 3; //定义a为整型变量
int *p1 = &a; //p1指向 int 型变量
char *p2; //p2指向 char 型变量
void *p3; //p3为无类型指针变量
p3 = (void *)p1; //将p1的值转换为void *类型,然后赋值给p3
p2 = (char *)p3; //将p3的值转换为char *类型,然后赋值给p2
printf("%d\n", *p1); //输出a的值 3
p3 = &a;
printf("%d", *p3); //此处报错,p3无指向,不能指向a
return 0;
}
C99标准把上述三个函数的基类型定义为void
类型,这种指针称之为无类型指针,即不指向哪一种具体的类型数据,只表示用来指向一个抽象类型的数据,仅仅提供一个纯地址,不能指向任何具体的对象。
变量的存储方式
-
变量的作用域
局部变量:在函数内定义的变量,只在该函数内有效
全局变量:在函数外定义的变量,从定义开始到文件结束都有效
全局变量的作用范围
resultA
、resultB
都是全局变量,但是作用范围不同,在add()
、sub()
、main()
函数中都可以使用resultA
,但是add()
不能使用resultB
变量。
-
变量的存储规律
占用空间和编译环境示意图
变量在内存中以二进制形式存储,一个变量占用的存储空间,不仅和变量类型有关,还和编译环境有关,同一种类型的变量在不同编译环境下占用的存储空间不一样。比如开发中常用的基本数据类型char
、int
等在不同编译环境下就会占用不同大小的空间。
类似下面的代码在64位编译器中其内存中地址和分配的内存空间应该如下图:
int main(int argc, const char * argv[]) {
int a = 1;
int b = 2;
printf("%p\n",&a); //0x7fff5fbff79b
printf("%p\n",&b); //0x7fff5fbff797
return 0;
}
int型变量内存分配
局部变量存储细节:由于是a、b是临时变量,因此他们的内存空间分配在栈上,栈中内存寻址由高到低,所以 a 变量的地址比 b 变量的地址要大,其次由于是在64位编译环境中,int 型变量占据4个字节的空间,每一个字节由低到高依次对应着8位二进制数,四个8位二进制数就是十进制中的 1 或 2,而变量a、b的地址就是四个字节中最小值的内存地址。
全局变量存储细节:关于全局变量存储在前面介绍内存组成已经说明,这里不再赘述。
变量的存储类别:C的存储类别包括4种:auto(自动的)、static(静态的)、register(寄存器的)、extern(外部的)。根据变量的存储类别可以得知其作用域和生命周期。
数组的存储
C语言中数组的存储和普通的变量不太一样,数值中存储的元素,是从所占用的低地址开始存储的。例如:
int main(int argc, const char * argv[]) {
char chars[4] = {'l','o','v','e'};
printf("chars[0] = %p\n",&chars[0]); //0x7fff5fbff79b
printf("chars[1] = %p\n",&chars[1]); //0x7fff5fbff79c
printf("chars[2] = %p\n",&chars[2]); //0x7fff5fbff79d
printf("chars[3] = %p\n",&chars[3]); //0x7fff5fbff79e
return 0;
}
字符数组的存储示意图
仅仅通过上面的字符数组例子还不能完全说明数组在内存中关于其元素存储和元素中值的存储关系。如果换用一个整型数组就能看出一些差别。
int main(int argc, const char * argv[]) {
int nums[2] = {5, 6};
printf("nums[0] = %p\n",&nums[0]); // 0x7fff5fbff7a0
printf("nums[1] = %p\n",&nums[1]); // 0x7fff5fbff7a4
return 0;
}
整型数组内存分配
通过字符数组和整型数组内存分配示意图可以看出:数组中的元素按照存放顺序依次从低地址到高地址存放,但是每个元素中的内容又是按高地址向低地址方向存储。
数组在使用过程中遇到的最多的问题可能就是下标越界,下面的代码就是越界访问数组示例:
int main(int argc, const char * argv[]) {
char charsOne[2] = {'a', 'b'};
char charsTwo[3] = {'c', 'd', 'e'};
charsTwo[3] = 'f';
printf("charsOne[0] = %p\n",&charsOne[0]); // 0x7fff5fbff79e
printf("charsTwo[0] = %p\n",&charsTwo[0]); // 0x7fff5fbff79b
printf("charsOne[0] = %c\n",charsOne[0]); // f
return 0;
}
数组下标越界示意图
结合下标越界示意图看上面的的代码会发现,由于越界设置
charsTwo[3]
元素的值,导致变相更改了charsOne[0]
的值。
int main(int argc, const char * argv[]) {
char charsOne[2] = {'a', 'b'};
char charsTwo[3] = {'c', 'd', 'e'};
charsOne[-1] = 'g';
printf("charsTwo[2] = %c\n",charsTwo[2]); // g
return 0;
}
下标越界示意图
同样的道理,由于越界访问
charsOne[-1]
元素,但是charsOne
并不存在这个元素,编译器会向上寻找到charsTwo [2]
的位置,导致变相更改了charsTwo[2]
的值。
通过数组在内存中的分配图能够更好帮助我们理解数组越界问题的严重性,在开发中使用数组首先应该考虑的就是数组长度问题,尽量不要出现类似低级错误。
总结
以上就是笔者对于C语言内存管理基本概念的学习心得,非常的基础和入门,在接下来的文章中还可能会深入探讨关于“一般函数调用”、“递归函数调用”、“结构体和指针”等在C语言中内存的分配。
如果文中有任何纰漏或错误欢迎在评论区留言指出,本人将在第一时间修改过来;喜欢我的文章,可以关注我以此促进交流学习; 如果觉得此文戳中了你的G点请随手点赞;转载请注明出处,谢谢支持。
下一篇:C语言-内存管理深入