C与C++内存管理详解

在计算机系统,特别是嵌入式系统中,内存资源是非常有限的。尤其对于移动端开发者来说,硬件资源的限制使得其在程序设计中首要考虑的问题就是如何有效地管理内存资源。本文是作者在学习C语言内存管理的过程中做的一个总结,如有不妥之处,望读者不吝指正。

因为不同的编译器和平台,对于内存的管理(段的划分)不尽相同,所以这里以 Linux 为参考总结C语言的内存管理

几个基本概念

在C语言中,关于内存管理的知识点比较多,如函数、变量、作用域、指针等,在探究C语言内存管理机制时,先简单复习下这几个基本概念:

变量

不解释。但需要搞清楚这几种变量类型:

局部变量(自动变量)

一般情况下,代码块{}内部定义的变量就是自动变量,也可使用auto显示定义。

auto只能用来标识局部变量的存储类型,对于局部变量,auto是默认的存储类型,不需要显示的指定。因此,auto标识的变量存储在栈区中。示例如下:

1
2
3
4
5
6
7
8
9
10
 #include <stdio.h>  
int main(void)
{
auto int i=1; //显示指定变量的存储类型
int j=2;

printf("i=%d\tj=%d\n",i,j);

return 0;
}

全局变量(外部变量)

出现在代码块{}之外的变量就是全局变量。

extern

注意:extern修饰变量时,根据具体情况,既可以看作是定义也可以看作是声明;但extern修饰函数时只能是定义,没有二义性。

extern用于定义的时候,用来声明在当前文件中引用在当前项目中的其它文件中定义的全局变量(必须是全局变量)。如果全局变量未被初始化,那么将被存在BBS区(存放的是未初始化的全局变量和静态变量)中,且在编译时,自动将其值赋值为0,如果已经被初始化,那么就被存在数据区中。

全局变量,不管是否被初始化,其生命周期都是整个程序运行过程中,为了节省内存空间,在当前文件中使用extern来声明其它文件中定义的全局变量时,就不会再为其分配内存空间。

例如:
file.c文件

1
2
3
4
5
6
7
 #include <stdio.h>  

int i=5; //定义全局变量,并初始化
void test(void)
{
printf("in subfunction i=%d\n",i);
}

test.c文件

1
2
3
4
5
6
7
8
9
 #include <stdio.h>  

extern i; //声明引用全局变量i
int main(void)
{
printf("in main i=%d\n",i);
test();
return 0;
}

编译并运行:

1
2
gcc -o test test.c file.c #编译连接  
./test #运行

结果:

1
2
3
4
结果:  

in main i=5
in subfunction i=5

extern关键字只需要指明类型和变量名就行了,不能再重新赋值,初始化需要在原文件所在处进行,如果不进行初始化的话,全局变量会被编译器自动初始化为0。像这种写法是不行的。

1
extern int num=4;

但是在声明之后就可以使用变量名进行修改了,像这样:

1
2
3
4
5
6
7
8
9
#include<stdio.h>

int main()
{
extern int num;
num=1;
printf("%d",num);
return 0;
}

如果不想这个变量被修改可以使用const关键字进行修饰,写法如下:
mian.c

1
2
3
4
5
6
7
8
#include<stdio.h>

int main()
{
extern const int num;
printf("%d",num);
return 0;
}

b.c

1
2
3
4
5
6
7
#include<stdio.h>

const int num=5;
void func()
{
printf("fun in a.c");
}

使用include将另一个文件全部包含进去可以引用另一个文件中的变量,但是这样做的结果就是,被包含的文件中的所有的变量和方法都可以被这个文件使用,这样就变得不安全,如果只是希望一个文件使用另一个文件中的某个变量还是使用extern关键字更好。

extern除了引用另一个文件中的变量外,还可以引用另一个文件中的函数,引用方法和引用变量相似。

mian.c

1
2
3
4
5
6
7
8
#include<stdio.h>

int main()
{
extern void func();
func();
return 0;
}

b.c

1
2
3
4
5
6
7
#include<stdio.h>

const int num=5;
void func()
{
printf("fun in a.c");
}

这里main函数中引用了b.c中的函数func。因为所有的函数都是全局的,所以对函数的extern用法和对全局变量的修饰基本相同,需要注意的就是,需要指明返回值的类型和参数。

静态变量

被声明为静态类型的变量,无论是全局的还是局部的,都存储在数据区中,其生命周期为整个程序,如果是静态局部变量,其作用域为一对{}内,如果是静态全局变量,其作用域为当前文件。静态变量如果没有被初始化,则自动初始化为0。静态变量只能够初始化一次

总结:用static声明局部变量,使其变为.data或者.bss段,作用域不变;用static声明外部变量,其本身就是静态变量,这只会改变其连接方式,使其只在本文件内部有效,而其他文件不可连接或引用该变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 #include <stdio.h>  
int sum(int a)
{
auto int c=0;
static int b=5;
c++;
b++;
printf("a=%d,\tc=%d,\tb=%d\t",a,c,b);
return (a+b+c);
}

int main()
{
int i;
int a=2;
for(i=0;i<5;i++)
printf("sum(a)=%d\n",sum(a));
return 0;
}

运行结果

1
2
3
4
5
6
7
 $ gcc -o test test.c  
$ ./test
a=2, c=1, b=6 sum(a)=9
a=2, c=1, b=7 sum(a)=10
a=2, c=1, b=8 sum(a)=11
a=2, c=1, b=9 sum(a)=12
a=2, c=1, b=10 sum(a)=13

字符串常量

字符串常量存储在常量数据(.rodata段)中,其生存期为整个程序运行时间,但作用域为当前文件,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
 #include <stdio.h>  
char *a="hello";
void test()
{
char *c="hello";
if(a==c)
printf("yes,a==c\n");
else
printf("no,a!=c\n");
}

int main()
{
char *b="hello";
char *d="hello2";

if(a==b)
printf("yes,a==b\n");
else
printf("no,a!=b\n");

test();
if(a==d)
printf("yes,a==d\n");
else
printf("no,a!=d\n");

return 0;
}

运行结果:

1
2
3
4
5
$ gcc -o test test.c  
$ ./test
yes,a==b
yes,a==c
no,a!=d

register存储类型

声明为register的变量在由内存调入到CPU寄存器后,则常驻在CPU的寄存器中,因此访问register变量将在很大程度上提高效率,因为省去了变量由内存调入到寄存器过程中的好几个指令周期。如下示例:

1
2
3
4
5
6
7
8
9
10
 #include <stdio.h>  

int main(void)
{
register int i,sum=0;
for(i=0;i<10;i++)
sum=sum+1;
printf("%d\n",sum);
return 0;
}

常见问题

static全局变量与普通的全局变量有什么区别?
答:全局变量(外部变量)的说明之前再冠以static 就构成了静态的全局变量。全局变量本身就是静态存储方式, 静态全局变量当然也是静态存储方式。 这两者在存储方式上并无不同。这两者的区别在于作用域的扩展上。非静态的全局变量可以用extern扩展到组成源程序的多个文件中,而静态的全局变量的作用域只限于本文件,不能扩展到其它文件,由于静态全局变量的作用域局限于一个源文件内,只能为该源文件内的函数公用,因此可以避免在其它源文件中引起错误。把全局变量改变为静态全局变量后是改变了它的作用域,限制了它的使用范围。

static局部变量和普通局部变量有什么区别?
答:把局部变量改变为静态局部变量后是改变了它的存储方式即改变了它的生存期。

static函数与普通函数有什么区别?
答:static函数与普通函数作用域不同,仅在本文件。只在当前源文件中使用的函数应该说明为内部函数(static),内部函数应该在当前源文件中说明和定义。对于可在当前源文件以外使用的函数,应该在一个头文件中说明,要使用这些函数的源文件要包含这个头文件。

综上所述:
static全局变量与普通的全局变量有什么区别:
static全局变量只初使化一次,防止在其他文件单元中被引用;

static局部变量和普通局部变量有什么区别:
static局部变量只被初始化一次,下一次依据上一次结果值;

static函数与普通函数有什么区别:
static函数在内存中只有一份,普通函数在每个被调用中维持一份拷贝

作用域

通常指的是变量的作用域,广义上讲,也有函数作用域及文件作用域等。我理解的作用域就是指某个事物能够存在的区域或范围,比如一滴水只有在0-100摄氏度之间才能存在,超出这个范围,广义上讲的“水”就不存在了,它就变成了冰或气体。

生存周期

作用域可以看作是变量的一个有效范围,就像网游中的攻击范围一样;生存周期可以看成是一个变量能存在多久,能在那些时段存在,就像网游中的魔法持续时间……

简单的以一个局部变量来举个例子:

在main函数中声明了变量a,那么a的作用域就是main函数内部,脱离了main函数,a就无法使用了,main函数之外的函数或者方法,都无法去使用a。那么a的生存周期是指a在那些时候存在,具体到这个例子,a什么时候存在,要取决于main函数,或者说,main函数只要被调用,且调用没有完成,那么a就将存在。除此以外的情况,a都将被释放。

生存周期也可以理解为从声明到释放的之间的时间。

函数

不解释。

注意:C语言中函数默认都是全局的,可以使用static关键字将函数声明为静态函数(只能被定义这个函数的文件访问的函数)

内存六段

计算机中的内存是分段来管理的,程序和程序之间的内存是独立的,不能互相访问,比如QQ和浏览器分别所占的内存段是不能相互访问的。而每个程序的内存也是分段管理的,一个应用程序所占的内存可以分为很多个段,在Linux下主要需要了解六个段。

.text段

程序被操作系统加载到内存的时候,所有的可执行代码(程序代码指令)、部分整数常量(有些立即数与指令编译在一起)都加载到代码区,这块内存在程序运行期间是不变的。代码区是平行的,里面装的就是一堆指令,在程序运行期间是不能改变的。函数也是代码的一部分,故函数都被放在代码区,包括main函数。

注意:"int a = 0;"语句可拆分成"int a;""a = 0",定义变量a"int a;"语句并不是代码,它在程序编译时就执行了,并没有放到代码区,放到代码区的只有”a = 0”这句。

运行前就已经确定(编译时确定),通常为只读,可以直接在ROM或Flash中执行,无需加载到RAM。在嵌入式中,有时为了特别的需求(例如加速),也可将某个模块搬移到RAM中执行。

.bss段

bss段用来存放 没有被初始化已经被初始化为0全局变量(静态局部变量)。如下例代码:

1
2
3
4
5
6
7
#include<stdio.h>

int bss_array[1024*1024];
int main(int argc, char *argv[])
{
return 0;
}

编译并查看:

1
2
3
4
5
6
7
8
9
10
$ gcc -g mainbss.c -o mainbss
$ ls -l mainbss
-rwxrwxr-x. 1 hy hy 8330 Apr 22 19:33 mainbss
$ objdump -h mainbss |grep bss
mainbss: file format elf32-i386
24 .bss 00400020 0804a020 0804a020 00001018 2**5

$ size mainbss
text data bss dec hex filename
1055 272 4194336 4195663 40054f mainbss

全局变量bss_array的大小为4MB = 1024*1024*sizeof(int) Byte = 4194304 Byte。 通过size 查看可知数据被存在了 bss 段

而 可执行文件mainbss 有8KB左右,命令 **ls -l mainbss** 查看得知。可知,bss类型的全局变量只占用 运行时的内存空间,而不占用可执行文件自身的文件空间。若在运行时内存空间不足的问题,编译器会帮忙检查的。这些是数据,若是在运行时的 堆和栈 不足,这点编译器没法检查。

总结:.bss不占据实际的磁盘空间,只在段表中记录大小,在符号表中记录符号。当文件加载运行时,才分配空间以及初始化。

.data段

data段用来存放已经被初始化为非零的全局变量还有static声明的变量。如下代码,只将矩阵的第一个元素初试化为1:

1
2
3
4
5
6
7
#include<stdio.h>

int data_array[1024*1024]={1};
int main(int argc, char *argv[])
{
return 0;
}

编译查看

1
2
3
4
5
6
7
8
[hy@localhost memcfg]$ gcc -g maindata.c -o maindata
[hy@localhost memcfg]$ ls -l maindata
-rwxrwxr-x. 1 hy hy 4202682 Apr 22 19:48 maindata
[hy@localhost memcfg]$ objdump -h maindata |grep \\.data
23 .data 00400020 0804a020 0804a020 00001020 2**5
[hy@localhost memcfg]$ size maindata
text data bss dec hex filename
1055 4194604 4 4195663 40054f maindata

而 可执行文件maindata 有4MB左右。通过size 查看可知数据被存在了 data 段

可知,data类型的全局变量既占用运行时的内存空间,也占用可执行文件自身的文件空间

链接时初值加入执行文件;执行时,因为这些变量的值是可以被改变的,所以执行时期必须将其从ROM或Flash搬移到RAM。总之,data段会被加入ROM,但却要寻址到RAM的地址。

这就是.bss 和 .data的区别。.bss使可执行文件更小更快加载

.rodata段

rodata用来存放常量数据。 ro: read only。在有的嵌入式系统中, rodata放在 ROM(或 NOR Flash)里,运行时直接读取,不须加载到RAM内存中。

诸如“Hello World”的字符串常量、加 const 关键字的全局变量(const修饰的局部变量只是为了防止修改,没有放入常量区)、#define定义的常量,会被编译器自动放在rodata中。

所以,在嵌入式开发中,常将已知的常量系数,表格数据等造表加以 const 关键字。存在ROM中,避免占用RAM空间。

编译器会去掉重复的字符串常量,程序的每个字符串常量只有一份。有些系统中rodata段是多个进程共享的,目的是为了提高空间利用率。

stack

栈(stack)是一种先进后出的内存结构,所有的自动变量(局部变量)、函数形参都存储在栈中,这个动作由编译器自动完成,我们写程序时不需要考虑。栈区在程序运行期间是可以随时修改的。当一个自动变量超出其作用域时,自动从栈中弹出。

总结:
1) stack 存放函数的局部变量和函数参数

2) 每个线程都有自己专属的栈;被调用函数的参数和返回值 被存储到当前程序的栈区,之后被调用函数再为自身的自动变量和临时变量在栈区上分配空间

3) 函数返回时,栈区的数据会被释放掉,先入后出(FILO)的顺序。

4) 栈的最大尺寸固定,超出则引起栈溢出;

实验一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//实验一:观察代码区、静态区、栈区的内存地址
#include "stdafx.h"
int n = 0;
void test(int a, int b)
{
printf("形式参数a的地址是:%d\n形式参数b的地址是:%d\n",&a, &b);
}
int _tmain(int argc, _TCHAR* argv[])
{
static int m = 0;
int a = 0;
int b = 0;
printf("自动变量a的地址是:%d\n自动变量b的地址是:%d\n", &a, &b);
printf("全局变量n的地址是:%d\n静态变量m的地址是:%d\n", &n, &m);
test(a, b);
printf("_tmain函数的地址是:%d", &_tmain);
getchar();
}

结果分析:自动变量ab依次被定义和赋值,都在栈区存放,内存地址只相差12,需要注意的是a的地址比b要大,这是因为栈是一种先进后出的数据存储结构,先存放的a,后存放的b,形象化表示如上图(注意地址编号顺序)。一旦超出作用域,那么变量b将先于变量a被销毁。这很像往箱子里放衣服,最先放的最后才能被拿出,最后放的最先被拿出。

实验二

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//实验二:栈变量与作用域
#include "stdafx.h"
//函数的返回值是一个指针,尽管这样可以运行程序,但这样做是不合法的,因为
//非要这样做需在x变量前加static关键字修饰,即static int a = 0;
int *getx()
{
int x = 10;
return &x;
}

int _tmain(int argc, _TCHAR* argv[])
{
int *p = getx();
*p = 20;
printf("%d", *p);
getchar();
}

这段代码没有任何语法错误,也能得到预期的结果:20。但是这么写是有问题的:因为int *p = getx()中变量x的作用域为getx()函数体内部,这里得到一个临时栈变量x的地址,getx()函数调用结束后这个地址就无效了,但是后面的*p = 20仍然在对其进行访问并修改,结果可能对也可能错,实际工作中应避免这种做法,不然怎么死的都不知道。

不能将一个栈变量的地址通过函数的返回值返回,切记!

另外,栈不会很大,一般都是以K为单位。如果在程序中直接将较大的数组保存在函数内的栈变量中,很可能会内存溢出,导致程序崩溃(如下实验三),严格来说应该叫栈溢出(当栈空间以满,但还往栈内存压变量,这个就叫栈溢出)。

1
2
3
4
5
6
7
8
//实验三:看看什么是栈溢出
int _tmain(int argc, _TCHAR* argv[])
{
char array_char[1024*1024*1024] = {0};
array_char[0] = 'a';
printf("%s", array_char);
getchar();
}

怎么办?这个时候就该堆出场了。

heap

注意:当malloc、calloc等分配的内存较小的时候,系统允许你越界访问,但是free的时候会报错!另外,不是所有的指针都需要free,而是所有的对指针使用malloc或者calloc分配大小的需要用到free

堆(heap)和栈一样,也是一种在程序运行过程中可以随时修改的内存区域,但没有栈那样先进后出的顺序。更重要的是堆是一个大容器,它的容量要远远大于栈,这可以解决上面实验三造成的内存溢出困难。一般比较复杂的数据类型都是放在堆中。但是在C语言中,堆内存空间的申请和释放需要手动通过代码来完成。对于一个32位操作系统,最大管理管理4G内存,其中1G是给操作系统自己用的,剩下的3G都是给用户程序,一个用户程序理论上可以使用3G的内存空间。堆上的内存必须手动释放(C/C++),除非语言执行环境支持GC(如C#在.NET上运行就有垃圾回收机制)。那堆内存如何使用?

接下来看堆内存的分配和释放:

malloc与free

malloc函数用来在堆中分配指定大小的内存,单位为字节(Byte),函数返回void *指针free负责在堆中释放malloc分配的内存。malloc与free一定成对使用。看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//实验四:解决栈溢出的问题
#include "stdafx.h"
#include "stdlib.h"
#include "string.h"

void print_array(char *p, char n)
{
int i = 0;
for (i = 0; i < n; i++)
{
printf("p[%d] = %d\n", i, p[i]);
}
}

int _tmain(int argc, _TCHAR* argv[])
{
char *p = (char *)malloc(1024*1024*1024);//在堆中申请了内存
memset(p, 'a', sizeof(int) * 10);//初始化内存
int i = 0;
for (i = 0; i < 10; i++)
{
p[i] = i + 65;
}
print_array(p, 10);
free(p);//释放申请的堆内存
getchar();
}

程序可以正常运行,这样就解决了刚才实验三的栈溢出问题。堆的容量有多大?理论上讲,它可以使用除了系统占用内存空间之外的所有空间。实际上比这要小些,比如我们平时会打开诸如QQ、浏览器之类的软件,但这在一般情况下足够用了。实验二中说到,不能将一个栈变量的地址通过函数的返回值返回,如果我们需要返回一个函数内定义的变量的地址该怎么办?可以这样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//实验五:
#include "stdafx.h"
#include "stdlib.h"

int *getx()
{
int *p = (int *)malloc(sizeof(int));//申请了一个堆空间
return p;
}

int _tmain(int argc, _TCHAR* argv[])
{
int *pp = getx();
*pp = 10;
free(pp);
}

这样写是没有问题的,可以通过函数返回一个堆地址,但记得一定用通过free函数释放申请的堆内存空间"int *p = (int *)malloc(sizeof(int));"换成"static int a = 0"也是合法的。因为静态区的内存在程序运行的整个期间都有效,但是后面的free函数就不能用了!

注意:malloc动态分配内存后,加上memset函数对其初始化。否则若某一行malloc方法被多次频繁调用,buf指向的动态内存也是被重复创建,释放。但可能都是指向同一块堆内存。没有初始化的话,除第一次外buf中的内容总是有上几次字符转码的残留字符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 实验6
#include<stdio.h>
#include<stdlib.h>

int main(int argc, char *argv[])
{
int *p = (int *)malloc(10*1);
// p= (int *)malloc(10*1);
if(p==NULL) {
printf("malloc p err\n");
return -1;
}
free(p);
printf("p = %4x\n",p);
p = NULL;
printf("p = %4x\n",p);
return 0;
}

程序运行结果:

1
2
3
4
[hy@localhost memcfg]$ gcc maindata.c
[hy@localhost memcfg]$ ./a.out
p = 9cf4008
p = 0

  • 开辟了空间,就要适时的释放。释放时,指针应指向开辟时的内存空间,所以在使用指针时,要注意不要修改了其地址,或者将开辟时的起始地址保存起来。
  • 对指针free后,其地址不一定就为NULL。如代码中的 p,在 free(p)后,printf(“p=%4x”,p)后并非为0。所以建议在free(p)后,立即加一句p=NULL。
  • 检查p的地址 if(p!=NULL){ … }

用来在堆中申请内存空间的函数还有calloc和realloc,用法与malloc类似。

calloc与free

1
void *calloc(size_t nmemb, size_t size);

参数nmemb表示要分配元素的个数,size表示每个元素的大小,分频的内存空间大小是nmemb*size; 返回值是 void*类型的指针,指向分配好的内存首地址。

用法一:分配1024*sizeof(int)字节大小的内存,并清空为0

1
int *p = (int *)calloc(1024,sizeof(int));

用法二:与 alloc等价的 malloc 用法

1
2
int *p = (int *)malloc(1024*sizeof(int));
memset(p,0,1024*sizeof(int));

差异:

  • 用法一calloc,会根据分配的的类型来初始化为0,如:分配int型,则初始化为(int)0; 若为指针类型,则初始化为空指针;若为浮点,则初始化为浮点型。
  • 用法二memset,不能保证初试化为空指针值和浮点型。(与NULL常量和浮点型的定义有关)

realloc与free

realloc()用来重新分配正在使用的一块内存大小。

定义:

1
void *realloc(void *ptr, size_t size);

用法示例:

1
2
3
int *p = (int *)malloc(1024);    //
p = (int *)realloc(512); // 重新分配为 512字节大小内存,缩小数据丢失
p = (int *)realloc(2048); // 重新分配为2048字节大小内存

注意:经过realloc()调整后的内存空间起始地址有可能与原来的不同。

free函数

下面是 free() 函数的声明。

1
void free(void *ptr)

参数:ptr — 指针指向一个要释放内存的内存块,该内存块之前是通过调用 malloc、calloc 或 realloc 进行分配内存的。如果传递的参数是一个空指针,则不会执行任何动作。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int main()
{
struct stu
{
int num;
char *name;
char sex;
float score;
} *ps;

ps=(struct stu*)malloc(sizeof(struct stu));
ps->num=102;
ps->name="Zhang ping";
ps->sex='M';
ps->score=62.5;
printf("Number=%d Name=%s ",ps->num,ps->name);
printf("sex=%c Score=%f ",ps->sex,ps->score);
free(ps);
return 0;
}

本例中,定义了结构stu,定义了stu类型指针变量ps。然后分配一块stu大内存区,并把首地址赋予ps,使ps指向该区域。再以ps为指向结构的指针变量对各成员赋值,并用printf输出各成员值。最后用free函数释放ps指向的内存空间。整个程序包含了申请内存空间、使用内存空间、释放内存空间三个步骤,实现存储空间的动态分配。

使用free释放堆内存空间时,其内部会判断该指针是否有效,然后判断指向的内存是否是一整块动态分配内存(malloc,calloc,realloc)。

1
2
3
4
5
char *s = (char*)malloc(1000);
char *s1 = s+100;
free(s1); //错误,s1并不是指向一整块动态分配的堆空间,s1只是指向动态分配内存的一部分。
s1 = s;
free(s1);//正确,现在s1指向的就是一整块动态分配的堆空间。

例如:

1
2
3
4
5
char *str = (char *) malloc (100);
strcpy(str, "hello world!");
free(str);
strcpy(str, " OK");
printf("%s\n", str);

这段代码在本机测试的时候输出结果是:OK

其实free()函数只是释放str指针指向的内存空间,即解除了指针str和那块内存的绑定关系,但是str仍然指向那块内存,即指针变量str中存储的地址值并没有改变,由于指针str没有对这个地址的访问权限,程序中对str的引用都可能导致错误的产生。而在上述例子中复制函数执行的时候,若那块内存未被使用,则完成复制,倘若恰好那块内存被分配了,这行代码就会出错。所以一个好的习惯是在free()后手动将指针指向NULL,防止野指针。

另外,str起始地址所在内存中的数据内容已经没法使用了,即使采用其他的指针也不能访问。如果下一次调用malloc函数可能会在刚才释放的区域创建一个内存空间,由于释放以后的存储空间的内容并没有改变(我是参考书上的,但我认为free后存储器中的内容是发生变化的,后面的调试可以说明这个问题,只是不知道发生什么变化,我也只是猜测,但是不要访问这个存储空间的内容是最安全的),这样可能会影响后面的结果,因此需要对创建的内存空间进行清零操作(防止前面的操作影响后面),这通常采用memset函数实现,具体参看memset函数。

又例如下面代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include  <stdio.h>
#include <stdlib.h>
#include <string.h>

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

int main()
{
/*为name分配指向的一段内存空间*/
stu.name = (char *)malloc(20 *sizeof(char));
memset(stu.name, 0, 20 * sizeof(char));

strcpy(stu.name, "Jimy");
stu.score = 99;

/*为pstu分配指向的一段内存空间*/
pstu= (struct student *)malloc(sizeof(struct student));
memset(pstu, 0, sizeof(struct student));

/*为name分配指向的一段内存空间*/
pstu->name = (char *)malloc(20 * sizeof(char));
memset(pstu->name, 0, 20 * sizeof(char));

strcpy(pstu->name, "Jimy");
pstu->score = 99;

/*采用另外的指针访问分配的存储空间,测试内存中内容是否改变*/
char *p = stu.name;
char *p1 = (char *)0x804a008; //具体的地址值
char *ppstu = pstu->name;
char *pp = (char *)0x804a030; //具体的地址值

/*释放的顺序要注意,pstu->name必须在pstu释放之前释放,
如果pstu先释放,那么pstu->name就不能正确的访问。
*/
free(pstu->name);
free(stu.name);
free(pstu);

/*为了防止野指针产生*/
pstu->name = NULL;
stu.name = NULL;
pstu = NULL;
p = NULL;
ppstu = NULL;

// 结构体 stu 不是指针,所以下面的会报错
// free(stu);
// stu = NULL;
return 0;
}

其中,stu不是指针,因此不能free,而stu.name是在堆上的,使用了malloc函数。而pstu是指针,先使用了malloc分配结构体类型所占用的大小,接着对pstu->name开辟了大小。因此需要两次free。

总结

| 段名称 | 存储内容 | 执行文件是否包含 | 执行过程 |
| | | | |
| .text | 代码、部分常量数据(立即数) | 是 |在ROM或Flash中执行 |
| .bss | 没有被初始化、已经被初始化为0的全局变量(含静态局部变量)| 否 |RAM中执行 |
| .data | 已经被初始化为非零的全局变量、static声明的变量 | 是 | RAM中执行(加载进来) |
| .rodata | const全局变量、含字符串常量、#define定义变量 | 是 | ROM中执行 |
| stack |函数调用语句的下一条可执行语句的地址、局部变量、函数参数 | 否 |RAM中执行 |
| heap | 用户申请和释放 | 否 |RAM中执行 |

在rom中执行的在编译的时候已经确定,执行的时候不可改变。

作用域和生存域是相对于变量的含义而言的,所以针对各个变量总结如下:

| 类型 | 作用域| 生存域 | 存储位置 |
| | | | |
| auto变量 | 一对{}内 | 当前函数 |变量默认存储类型,存储在stack |
| extern函数 | 整个程序 | 整个程序运行周期 | 函数默认存储类型,存储在 .text |
| extern变量 | 整个程序 | 整个程序运行周期 | 初始化在.data段,未初始化在.bss段 |
| static函数 | 当前文件 | 整个程序运行周期 | 代码段 |
| static全局变量 | 当前文件 | 整个程序运行周期 | 初始化在.data段,未初始化在.bss段 |
| static局部变量 | 一对{}内 | 整个程序运行周期 | 初始化在.data段,未初始化在.bss段 |
| register变量 | 一对{}内 | 当前函数 | 运行时存储在CPU寄存器中 |
| 字符串常量 | 当前文件 | 整个程序运行周期 | .rodata |

案例分析

案例一

部分分析如下:

  • main函数UpdateCounter为代码的一部分,故存放在代码区
  • 数组a默认为全局变量,故存放在静态区
  • main函数中的"char *b = NULL"定义了自动变量b(variable),故其存放在栈区
  • 接着"b = (char *)malloc(1024*sizeof(char));"向堆申请了部分内存空间,故这段空间在堆区

案例二

需要注意以下几点:

  • 栈是从高地址向低地址方向增长;
  • 在C语言中,函数参数的入栈顺序是从右到左,因此UpdateCounter函数的3个参数入栈顺序是a1、c、b;
  • C语言中形参和实参之间是值传递,UpdateCounter函数里的参数a[1]、c、b与静态区的a[1]、c、b不是同一个
  • 在函数调用时,第一个进栈的是主函数中后的下一条指令(函数调用语句的下一条可执行语句)的地址,然后是函数的各个参数,在大多数的C编译器中,参数是由右往左入栈的,然后是函数中的局部变量。注意静态变量是不入栈的。当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行
  • "char *b = NULL"定义一个指针变量b,b的地址是0xFFF8,值为空—>运行到"b = (char*)malloc(1024*sizeof(char))"时才在堆中申请了一块内存(假设这块内存地址为0x77a0080)给了b,此时b的地址并没有变化,但其值变为了0x77a0080,这个值指向了一个堆空间的地址(栈变量的值指向了堆空间),这个过程b的内存变化如下:

    ——>

案例三

这是一个前辈写的,非常详细

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//main.cpp
int a = 1; // 全局初始化区
char *p1; // 全局未初始化区
int main()
{
int b; // 栈
char s[] = "abc"; // 栈
char *p2; // 栈
char *p3 = "123456"; // 123456\0在常量区,p3在栈上。
static int c =0; // 全局(静态)初始化区
p1 = (char *)malloc(10);
p2 = (char *)malloc(20);
// 分配得来得10和20字节的区域就在堆区。
strcpy(p1, "123456"); // 123456\0放在常量区,编译器可能会将它与p3所指向的"123456"优化成一个地方。
return 0;
}

案例四

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <stdio.h>
#include <string.h>
int g_var1 = 0;
int g_var = 10;
static int g_static_var = 11;
const char *gp_str = "helloworld"; //此处的const可以省略,结果相同
const int int1 = 10;

int main()
{
int i;
static int l_static_var = 12;
const char *lp_str = "bye-bye"; //此处的const可以省略,结果相同
printf("g_var1 = %p\n", &g_var1);
printf("g_var = %p\n", &g_var);
printf("g_static_var = %p\n", &g_static_var);
printf("l_static_var = %p\n", &l_static_var);

printf("gp_str = %p\n", gp_str);
printf("int1 = %p\n", &int1);
printf("lp_str = %p\n", lp_str);

for(i=0; i<strlen(gp_str)+1+strlen(lp_str)+1; i++)
{
printf("%p\t", &gp_str[i]);
if((gp_str[i] >= '!') && (gp_str[i] <= '~'))
{
printf("'%c'\n", gp_str[i]);
}
else
{
printf("0x%02x\n", gp_str[i]);
}
}

const int int2 = 2;
char a[] = "helloworld";
static int int3 = 0;
printf("a = %p\n", &a); // 和 a 的结果相同,都是数组首地址
printf("int2 = %p\n", &int2);
printf("i = %p\n", &i);
printf("int3 = %p\n", &int3);
return 0;
}

输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
g_var1       = 00450008  //全局初始化为0的变量
g_var = 00402000 //全局整型变量的存储地址
g_static_var = 00402004 //文件静态变量的存储地址
l_static_var = 0040200C //函数静态变量的存储地址
//可以看出以上三个地址都是连续的
gp_str = 00403024 //全局字符指针,指向一个字符串常量"helloworld"
int1 = 00403030 //全局int变量
lp_str = 00403034 //局部字符指针,指向一个字符串常量"bye-bye"
//可以看出以上三个地址都是连续的
00403024 'h'
00403025 'e'
00403026 'l'
00403027 'l'
00403028 'o'
00403029 'w'
0040302A 'o'
0040302B 'r'
0040302C 'l'
0040302D 'd'
0040302E 0x00
0040302F 0x00
00403030 0x0a
00403031 0x00
00403032 0x00
00403033 0x00
00403034 'b'
00403035 'y'
00403036 'e'
a = 2228FF09 // 局部变量
int2 = 0028FF14 // 局部静态变量
i = 0028FF18 // 局部变量
//可以看出以上三个地址都是连续的,先入栈,地址高
int3 = 0040500C // 初始化为0的静态局部变量

案例五

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include<iostream>
using namespace std;

char* test(void)
{
char str[]="hello world!";
return str;
}

int main(void)
{
char *p;
p=test();
cout<<p<<endl;
return 0;
}

输出结果可能是hello world!,也可能是乱码。出现这种情况的原因在于:在test函数内部声明的str数组以及它的值”hello world”是在栈上保存的,当用return将str的值返回时,将str的值拷贝一份传回,当test函数执行结束后,会自动释放栈上的空间,即存放hello world的单元可能被重新写入数据,因此虽然main函数中的指针p是指向存放hello world的单元,但是无法保证test函数执行完后该存储单元里面存放的还是hello world,所以打印出的结果有时候是hello world,有时候是乱码。

案例六

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include<iostream>
using namespace std;

int test(void)
{
int a=1;
return a;
}

int main(void)
{
int b;
b=test();
cout<<b<<endl;
return 0;
}

输出结果为 1。有人会问为什么这里传回来的值可以正确打印出来,不是栈会被刷新内容么?是的,确实,在test函数执行完后,存放a值的单元是可能会被重写,但是在函数执行return时,会创建一个int型的临时变量,将a的值复制拷贝给该临时变量,因此返回后能够得到正确的值,即使存放a值的单元被重写数据,但是不会受到影响。而在上例中,返回的是char *指针,指针指向的内容可能被释放。

案例七

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<iostream>
using namespace std;

char* test(void)
{
char *p=(char *)malloc(sizeof(char)*100);
strcpy(p,"hello world");
return p;
}

int main(void)
{
char *str;
str=test();
cout<<str<<endl;
return 0;
}

运行结果 hello world。这种情况下同样可以输出正确的结果,是因为是用malloc在堆上申请的空间,这部分空间是由程序员自己管理的,如果程序员没有手动释放堆区的空间,那么存储单元里的内容是不会被重写的,因此可以正确输出结果。

案例八

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include<iostream>
using namespace std;

void test(void)
{
char *p=(char *)malloc(sizeof(char)*100);
strcpy(p,"hello world");
free(p);
if(p==NULL)
{
cout<<"NULL"<<endl;
}
}

int main(void)
{
test();
return 0;
}

没有输出。在这里注意了,free()释放的是指针指向的内存!注意!释放的是内存,不是指针!这点非常非常重要!指针是一个变量,只有程序结束时才被销毁。释放了内存空间后,原来指向这块空间的指针还是存在!只不过现在指针指向的内容的垃圾,是未定义的,所以说是垃圾。因此,释放内存后应把把指针指向NULL,防止指针在后面不小心又被使用,造成无法估计的后果。

学习内存管理的目的

学习内存管理就是为了知道日后怎么样在合适的时候管理我们的内存。那么问题来了?什么时候用堆什么时候用栈呢?一般遵循以下三个原则:

  • 如果明确知道数据占用多少内存,那么数据量较小时用栈,较大时用堆;
  • 如果不知道数据量大小(可能需要占用较大内存),最好用堆(因为这样保险些);
  • 如果需要动态创建数组,则用堆。
1
2
3
4
5
6
7
8
9
//实验六:动态创建数组
int _tmain(int argc, _TCHAR* argv[])
{
int i;
scanf("%d", &i);
int *array = (int *)malloc(sizeof(int) * i);
//...//这里对动态创建的数组做其他操作
free(array);
}

最后的最后

操作系统在管理内存时,最小单位不是字节,而是内存页(32位操作系统的内存页一般是4K)。比如,初次申请1K内存,操作系统会分配1个内存页,也就是4K内存。4K是一个折中的选择,因为:内存页越大,内存浪费越多,但操作系统内存调度效率高,不用频繁分配和释放内存;内存页越小,内存浪费越少,但操作系统内存调度效率低,需要频繁分配和释放内存。嵌入式系统的内存内存资源很稀缺,其内存页会更小,因此在嵌入式开发当中需要特别注意。

对于Windows下的内存划分:
我们需要了解的主要有四个区域,通常叫内存四区,如下图:

附件

文章中的visio图的附件在这里

参考

C语言知识整理(3):内存管理(详细版
Linux内存管理(text、rodata、data、bss、stack&heap)
笔记:程序内存管理 .bss .data .rodata .text stack heap
malloc 之后使用memset初始化
字符串常量到底存放在哪个存储区
C/C++堆区、栈区、常量区、静态数据区、代码区详解

------ 本文结束------
坚持原创技术分享,您的支持将鼓励我继续创作!

欢迎关注我的其它发布渠道