0.在Linux下验证C语言地址空间排布
这里是limou3434的博文系列。接下来,我会带您了解在C语言程序视角下的内存分布,会涉及到一点操作系统的知识,但是不多,您无需担忧。
注意:只能在Linux下验证,因为Windows的空间布局不是严格按照这种规则的。
另外如果您感兴趣的话,可以看看我的其他博客内容。
在Linux环境中输入下面的指令和代码来验证
$ vim test.c
# ----------------
//在vim中的输入
#include <stdio.h>
#include <stdlib.h>
int g_value_2;
int g_value_1 = 10;
int main()
{
printf("code addr: %p\n", main);//<代码区>
printf("\n");
const char *str = "hello word!";//注意“hello word!”是存储在静态数据区(字符常量区)的,而str变量的空间开辟在栈上,但是str这个指针变量保存的是处于静态数据区内的“hello word!”里'h'的地址,故打印str就是打印静态数据区的地址
printf("read only addr: %p\n", str);//<静态区> printf("\n");
printf("init g_value_1 global addr: %p\n", &g_value_1);//<已初始化全局变量区>
printf("uninit g_value_2 global addr: %p\n", &g_value_2);//<未初始化全局变量区>
printf("\n");
int *p1 = (int*)malloc(sizeof(int) * 10);
int *p2 = (int*)malloc(sizeof(int) * 10);
printf("heap addr: %p\n", p1);//<堆区>
printf("heap addr: %p\n", p2);
printf("\n");
printf("stack addr: %p\n", &str);//<栈区>
printf("stack addr: %p\n", &p1);
printf("stack addr: %p\n", &p2);
printf("\n");
free(p1);
free(p2);
return 0;
}
# ----------------
$ gcc test.c
$ ./a.out
code addr: 0x40060d
read only addr: 0x4007ef
init g_value_1 global addr: 0x60104c
uninit g_value_2 global addr: 0x601054
heap adder: 0x1e16010
heap adder: 0x1e16040
stack addr: 0x7ffeaaa87f98
stack addr: 0x7ffeaaa87f90
stack addr: 0x7ffeaaa87f88
可以看到在Linux中的确遵守这一顺序排布空间,并且栈和堆之间的有比较巨大的空间,并且的确是相向生长的。在申请堆空间的时候会大于预期申请空间,这部分多出来的空间是用来维护堆的,这在后面也有讲到。
另外如果在上面的代码中加入使用static修饰变量x,可以观察到这个变量x的地址和全局变量的地址很是接近,这也就证明了这个变量不是在栈上开辟的而是在全局数据区开辟的,所以作用域不变,而生命周期变成全局。
$ vim test.c
# ---------------- //在vim中的输入
#include <stdio.h>
#include <stdlib.h>
int g_value_2;
int g_value_1 = 10;
int main()
{
static int x = 100;
printf("code addr: %p\n", main);//<代码区>
printf("\n");
const char *str = "hello word!";//注意“hello word!”是存储在静态数据区(字符常量区)的,而str变量的空间开辟在栈上,但是str这个指针变量保存的是处于静态数据区内的“hello word!”里'h'的地址,故打印str就是打印静态数据区的地址
printf("read only addr: %p\n", str);//<静态区>
printf("\n"); printf("init g_value_1 global addr: %p\n", &g_value_1);//<已初始化全局变量区>
printf("uninit g_value_2 global addr: %p\n", &g_value_2);//<未初始化全局变量区>
printf("\n");
int *p1 = (int*)malloc(sizeof(int) * 10);
int *p2 = (int*)malloc(sizeof(int) * 10);
printf("heap addr: %p\n", p1);//<堆区>
printf("heap addr: %p\n", p2);
printf("\n");
printf("stack addr: %p\n", &str);//<栈区>
printf("stack addr: %p\n", &p1);
printf("stack addr: %p\n", &p2);
printf("\n");
printf("%p", &x);
free(p1);
free(p2);
return 0;
}
# ----------------
$ gcc test.c
$ ./a.out
code addr: 0x40060d
read only addr: 0x4007ff
init g_value_1 global
addr: 0x60104c
uninit g_value_2 global addr: 0x601058
heap adder: 0x9b1010
heap adder: 0x9b1040
stack addr: 0x7ffd420b19c8
stack addr: 0x7ffd420b19c0
stack addr: 0x7ffd420b19b8
0x601050
另外上面所讲的C程序地址空间概念并不是内存分布,但是想要搞清楚这其中的概念,就必须学习操作系统理论,所以这些就以后再来谈了(这已经不归在C语言的学习范畴了…)。
1.动态内存基础
1.1.malloc和free的使用
int* arr = (int*)malloc(sizeof(int) * 4);//申请内存
for(int i = 0; i < 4; i++)
{
arr[i] = i;
}
for(int i = 0; i < 4; i++)
{
printf("%d ", arr[i]);
}
free(arr);//释放内存
1.2.为什么需要动态内存
- 动态内存的大小:自动变量都是在栈空间里开辟的,但是栈空间是有限的,不如堆空间大,因此需要动态内存。
- 动态内存的灵活:很多情况下,程序员自己也不知道自己需要用到多少内存,而malloc只有在运行的时候被调用,这个时候才会申请空间,因此提供了很大的灵活性(在栈空间申请的内存由编译器决定,已经写“死”了,内存是确定的,比如“int arr[10]”就固定了数组大小为10,定义是简单了,但是不够灵活改变大小)。
2.野指针的概念
野指针就是,该指针变量指向了一个不该被访问的空间(因为该空间正在被使用)。
3.malloc申请的空间对应C的空间布局
malloc是在堆空间上申请内存的。
4.常见内存错误与对策
4.1.指针没有指向一块合法的内存
- 结构体成员没有初始化
struct student
{
char *name;
int score;
}str, *pstu;
int main()
{
strcpy(str.name, "limou");//这里的指针name指向的是一个随机地址,stacpy的内部对其解引用了,访问了非法空间
str.score = 100;
}
- 函数的入口处校验指针有效性
//野指针是没有办法校验的,因此所有指针如果没有被直接使用,就必须设置为NULL,这样子函数的入口参数的校验,就转化为指针是否为空的问题(空则不合法、非空则合法),这个时候就诞生了assert的用法
void function(int *p)
{
assert(p);
printf("合法!\n");
}
int main()
{
int *p = NULL;
function(p);
return 0;
}
//而assert函数在判断指针为NULL的时候就会直接中断程序,一般是在调试代码的时候使用
//综上所述就是使用“编码规范”+“assert”来判断一个指针是否合法
4.2.没有为指针分配足够的内存
struct student
{
char *name;
int score;
}str, *pstu;
int main()
{
str.name = (struct student*)malloc(sizeof(struct student*));//错误的原因是没有申请好足够的空间,这里应该改成(struct student*)malloc(sizeof(struct student))
strcpy(str.name, "limou");
str.score = 100;
return 0;
}
4.3.内存分配成功,但是没有初始化(不是大问题)
int* arr = (int*)malloc(sizeof(int) * 10);
memset(arr, 0, sizeof(arr));
4.4.内存越界
内存越界有可能修改到正在使用的空间,导致程序奔溃,但是有的时候这是不会报错的,这是因为有可能越界访问到的内存并没有被控制使用,这个时候访问也没有太大的问题,但是终究是一个隐患。
例如下面这个例子就是一种越界访问
int main()
{
int *p = (int*)malloc(sizeof(int) * 5);
int i = 0;
for(i = 0; i < 5; i++)
{
p[i] = i;
}
printf("%p\n", p);
ferr(p);
printf("%p\n", p);//可以看到释放前和释放后p存储的依旧是申请时的地址,如果后面一不小心使用了p来解引用,就会造成非法访问
p = NULL;//置空,不让p成为野指针
return 0;
}
从上面的代码例子中可以看出释放动态内存的过程就像:面对已经分手(释放)的前任(动态内存),有些人总是会念念不忘(p依旧保存着指向之前开辟好动态内存的地址)。也就是说p变成了野指针(痴情汉?or痴情女?)因此free的作用就是改变指针和对用动态内存之间的对应关系(而这些“关系”也是需要靠数据去维护的)。而解决p是野指针的方法就是将p置空(p = NULL),编译其并不会直接帮助你处理掉这个野指针。
4.5.内存泄露
不断使用malloc申请空间而忘记释放空间,不断执行含有malloc的函数就会造成内存泄漏。但是如果程序退出了,则内存泄露的问题就会消失,这是因为操作系统进行了回收(不是编译器回收,一旦代码运行起来就和编译器没有关系了)。因此内存泄露最经典的现象就是,运行程序久了,内存空间被不断吃掉,导致变“卡”,而此时如果退出程序,操作系统就会进行自动回收。
void function(void)
{
int* p = (int*)malloc(sizeof(int) * 1000);
}
int main()
{
while(1)
{
function();
}
}
有些存在bug的杀毒软件一旦退出电脑就不卡了,有些服务器如果出现内存错误也会带来严重经济损失。
因此,这类常驻进程一旦加载到内存中就不会轻易退出是最怕内存泄露的。
4.6.重复释放内存
int main()
{
int* p = (int*)malloc(int);
//某些代码使用了p
free(p);
//某些代码,但是忘记之前是否释放了
free(p);//回想起来使用了malloc但是没有想起自己早就释放了,结果再次释放,此时出现了问题
}
那么为什么会出错呢?这个就要先提到一个奇怪的现象:为什么free函数可以不需要知道malloc了多少空间就可以直接释放呢?这是因为maloc申请的空间实际上是超出我们的预期的,这些超出我们需要的空间用来维护这些堆空间,即:这部分空间就会存储本次申请的详细信息(比如申请了多大的空间),而free就可以利用这些空间里的信息来释放堆空间。
另外这部分多出来的内存也叫“内存cookie”,而若要再往后深入探究就涉及到操作系统的知识了,这超出了C语言的范畴,所以我们搁一边暂且不谈。
不过这个现象也能传递我们另外一个信息,使用malloc申请堆空间应该是申请较大空间的比较好,申请小空间的话cookie占比会比较多,因此小空间在栈上开辟就是最好的,而我们会发现栈和堆形成一种互补的关系。
5.体现动态内存管理的例子
那么通常书中的内存管理的“管理二字”体现在哪里,仅仅只是mallor和free么?
C语言的动态内存管理实际上就是:“1.空间什么时候申请2.申请多少空间3.什么时候释放4.释放多少”的问题,对比其他语言,比如Java语言这样的高级语言,其本身是自带内存管理的,程序员只需使用即可,这就让程序员使用起来更加得省心。
而C是偏底层得语言,相比Java来说更加自由,其动态内存管理是直接暴露给用户的,给程序员提供了更多的灵活性,但是也相应带来更多的安全隐患。