C语言-内存管理深入
前言
基础篇介绍了一些关于C语言内存管理的常见概念,包括内存编址、堆栈、内存操作函数、变量和数组存储简介等等。本文将在前文的基础上扩展以下知识:结构体变量的存储、函数调用与内存分配、递归函数的调用过程。如果有需要浏览上一篇文章的同学请点击C语言-内存管理基础。希望本文能给正在学习C、Objective-C、C++等语言的小伙伴们更多启发。
结构体变量的存储
结构体在C语言中是一种常见的数据结构,开发中系统提供的基本类型往往不能满足需要,所以会常常使用到“结构体”。作为由基本数据类型组合而成的结构体与普通变量存储不一样,结构体变量占用空间不是简单的结构体成员空间单纯相加,其在内存中分配空间和数组很类似,可以总结为以下几点:
- 结构体变量占用的内存空间是其成员占用最大内存空间的整数倍
struct Person {
int age;
int hegiht;
int weight;
};
int main(int argc, const char * argv[]) {
struct Person p1 = {1, 2, 3};
int size = sizeof(p1);
printf("%i\n",size); //12 = 4 * 3
printf("变量的地址:%p\n",&p1); //0x7fff5fbff750
printf("%p\n",&p1.age); //0x7fff5fbff750
printf("%p\n",&p1.hegiht); //0x7fff5fbff754
printf("%p\n",&p1.weight); //0x7fff5fbff758
return 0;
}
结构体变量内存分配示意图1
说明:在
p1
变量中,由于其成员都是int
类型,在64位编译器中占用4个字节空间,所以系统为p1
变量分配的内存空间大小应该是:4 X 3 = 12个字节。另外:结构体成员内存地址分配是从低地址到高地址的,结构体变量的地址是其首成员的地址,这点和数组是一致的。
- 系统从结构体首个成员分配空间;如果空间不够则重新分配,如果空间剩余则会把下一个成员的数据存储到剩余的空间中
struct Student {
char id;
double score;
int age;
};
int main(int argc, const char * argv[]) {
struct Student s1 = {'A', 10.0, 15};
int size = sizeof(s1);
printf("%i\n",size); //24
printf("%p\n",&s1.id); //0x7fff5fbff788
printf("%p\n",&s1.score); //0x7fff5fbff790
printf("%p\n",&s1.age); //0x7fff5fbff798
return 0;
}
结构体变量内存分配示意图2
说明:按照前面的说明,系统为
s1
分配内存时以sizeof(double)
8个字节为单位,所以为s1.id
分配了8个字节的空间,但是由于id
定义为char
类型,所以只占了8个字节中数值最小的一个内存空间;由于前面剩下的 8 - 1 = 7个字节不足以存放double
类型的值,所以接着为s1.score
分配8个字节并占满8个字节空间;最后为s1.age
分配8个字节并占用了前4个字节空间。故s1
变量在内存中占用的内存大小为 8 X 3 = 24个字节。如果调换结构体
Student
成员之间的顺序如下,情况又会发生变化。
struct Student {
double score;
char id;
int age;
};
int main(int argc, const char * argv[]) {
struct Student s1 = {'A', 10.0, 15};
int size = sizeof(s1);
printf("%i\n",size); //16
printf("%p\n",&s1.score) //0x7fff5fbff790
printf("%p\n",&s1.id); //0x7fff5fbff790
printf("%p\n",&s1.age); //0x7fff5fbff79c
return 0;
}
结构体变量内存分配示意图3
说明:这是由于第二次为
s1.id
分配内存时没有完全占满8个字节的空间,而且第三次为s1.age
分配时其需要的4个字节空间也没有超出剩余的8-1 = 7个字节空间,所以s1.age
的值按照内存对齐的原则就存放在了第二次分配的8个字节的后4位空间中。
-
为结构体变量分配内存还需要遵循内存对齐原则
内存对齐:系统在为不同类型的变量分配空间时按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这是对内存对齐的模糊解释。简单来说每个特定平台上的编译器都有自己的默认“对齐系数”(也叫对齐模数 n=1,2,4,8,16)。不同的对齐系数会对结构体变量分配的内存空间产生影响。关于“内存对齐和结构体变量“的更多内容这里引用几篇相关文章帮助大家深入理解:
【知乎】-如何理解 struct 的内存对齐?
【CSDN】-C内存对齐
【CSDN】-C/C++内存对齐原则及作用
【个人博客】-内存对齐
总结:结构体变量所占存储空间受其不同类型的成员排列顺序及编译器内存对齐影响,开发中尽量将相同类型的成员依次定义,有助于节省内存空间。
函数调用与内存分配
函数作为编程中实现功能的重要手段,深入理解函数的调用过程对提升开发能力有很大的帮助,首先了解一下两个任意函数之间进行调用的情形,与汇编程序设计中主程序和子程序之间的链接及信息交换类似,在高级语言编写的程序中(比如C语言),调用函数与被调用函数之间的链接及信息交换需要通过栈来进行。《C程序设计》一书中对于函数之间调用提出两个注意点:
- 函数运行期间调用另外一个函数,在运行被调用函数之前,系统需要先完成3件事情:
- 将所有的实参、返回地址等信息传递给被调用函数保存
- 为被调用函数局部变量在栈上分配内存
- 将控制转移到被调用函数入口
-
被调用函数返回调用函数之前,系统也需要相应的完成3件事情:
- 在栈中保存被调用函数的计算结果(返回值)
- 释放在栈中为被调用函数分配的数据区
- 依照被调用函数保存的返回地址将控制转移到调用函数
当有多个函数嵌套调用时,按照 “后调用先返回” 的原则依次进行,看到这里想必大家一目了然,函数之间的调用规则和 “栈” 管理数据的方式完全相同,因此函数之间的信息传递和控制转移必须通过 “栈”来实现。
《数据结构(C语言版)》一书中对函数的调用过程在内存中的描述是这样的:
函数之间信息传递和控制转移必须通过“栈”来实现,即系统将整个程序运行所需的数据空间安排在一个栈中,每当调用一个函数就为它在栈顶分配一个存储区,每当从一个函数退出时,就释放它的存储区,当前正在运行的函数存储区必在栈顶。
-
帧栈
帧栈也叫过程活动记录,是编译器用来实现过程/函数调用的一种数据结构。每个栈帧对应着一个未运行完的函数。栈帧中保存了该函数形参、返回地址、局部变量等信息。简而言之栈帧就是一个函数执行的环境。有时候函数嵌套调用,栈中会有多个函数的信息,每个函数占用一个连续的区域。引文中提到的“存储区”就是指一个函数对应的分配空间也就是一个函数帧。 -
参数的传递和内存分配
被调用函数的形参,在未出现函数调用时不占用内存空间,发生函数调用时,形参按照确定的类型在该函数帧中被分配指定大小的空间。并且由调用函数的实参传递给被调用函数的形参保存。 -
函数具体调用过程:
- 为被调用函数在栈顶分配空间,为被调用函数的形参分配空间
- 将实参的值传递给形参
- 被调用函数利用形参(如果存在)进行运算
- 通过
return
语句将返回值带回调用函数 - 调用结束,释放被调用函数空间,释放其形参分配空间
举个例子说明问题
int add(int num1, int num2) {
int tempSum = num1 + num2;
return tempSum;
}
int main(int argc, const char * argv[]) {
int a = 10;
int b = 20;
int sum = add(a, b);
printf("%i\n",sum); // sum = 30
return 0;
}
上面的main()
、add()
函数之间调用的过程及参数传递在内存的示意图如下:
(1)首先执行
main()
函数,系统为main()
函数在栈顶分配一定大小的空间,其次为a、b局部变量分配空间;(2)调用add()
函数,main()
函数压入栈底,栈顶指针上移,系统为add()
函数在栈顶分配一定大小的空间,其次为num1、num2局部变量分配空间;(3)执行两个整数的加法运算,在add()
函数帧中新开辟一块空间存放计算后的结果tempSum;(4)最后add()
函数返回,在main()
函数帧中开辟一块新的空间存放add()
函数的返回值sum,(5)add()
函数帧调用结束出栈,系统释放其空间并且栈顶指针下移,main()
函数重新回到栈顶。
注意:当前正在运行的函数存储区必在栈顶。
以上就是两个函数在调用过程中栈内存完整的工作情况(省略了main()
函数形参的内存分配)。虽然函数在开发中无处不见,但是执行过程在内存中的表现形式还是有很多值得研究的。掌握其内存分配原理有助于我们更加深入理解函数。
递归函数的调用过程
- 递归函数
在调用一个函数过程中又出现直接或间接调用该函数本身的情况,称为函数的递归调用。C语言的特点之一就是允许函数的递归调用。 -
递归函数的调用过程
一个递归函数的运行过程类似于多个函数之间的嵌套调用,只是调用函数和被调用函数是同一个函数,因此,和每次调用相关的一个重要概念是递归函数运行的“层数”。假设调用该递归函数的主函数为第 0 层,主函数调用递归函数进入第一层;从第 i 层调用本函数为进入第 i+1 层,反之,退出第 i 层递归则应返回至第 i - 1 层。例如:
int getAge(int n) {
if (n == 1) {
return 10;
} else {
return getAge(n-1) + 2;
}
}
int main(int argc, const char * argv[]) {
printf("NO.5.age: %d\n",getAge(5));
return 0;
}
递归函数调用过程
《数据结构(C语言版)》对递归函数调用从内存分配角度的解释如下:
为了保证递归函数正确执行,系统建立一个“递归工作栈”作为整个递归函数运行期间使用的数据存储区,每一层递归所需信息构成一个“工作记录”,其中包括所有的实参、局部变量以及上一层的返回地址。每进入一层递归,就产生一个新的工作记录压入栈顶。每退出一层递归,就从栈顶弹出一个工作记录。当前活动的工作记录成为“活动记录”,并称活动记录的栈顶指针为“当前环境指针”。
一个递归问题可以分为“递推”和“回溯”两个阶段,要经历若干步才能求出最后的结果。但是其原理和一般函数的调用没有本质区别。递归函数调用次数越多,在栈上为其分配的空间就越大,所以我们应该避免调用次数过多的递归函数,因为该操作很可能会使栈的容量“溢出”。
由于”递归函数“概念本身不是本文的重点,这里仅仅是介绍一下递归函数调用在内存中分配情况。对”递归函数“不甚了解的同学可以查阅一下相关资料,这里就不再赘述了。
文章最后
以上就是笔者对于C语言内存管理深入的学习心得,知识点比较少,部分描述引自书籍文档。如果文中有任何纰漏或错误欢迎在评论区留言指出,本人将在第一时间修改过来;喜欢我的文章,可以关注我以此促进交流学习; 如果觉得此文戳中了你的G点请随手点赞;转载请注明出处,谢谢支持。