C语言内存管理入门

什么是内存

宏观上储存数据的都可以叫做存储器,本章讨论的内存是在程序设计中对程序来讲的内存。

内存用来存放数据,可以理解成一个个箱子排列而成,箱子用来存放数据,但是箱子本身有编号,而且是连续的,这个编号就是内存地址。

也就是说,内存可以抽象成由数据和地址组成。

内存示意图
例如上图中BB就储存在0x00000002的地址中,只要访问这个地址,就可以拿到这个数据,在32位的机器上,程序在运行时最多可获得4GB的连续内存,这部分内存称为程序的虚拟内存。

为什么是4GB?

每8个二进制位是一个字节,字节是内存的最小单位,每个字节都有自己的地址,32位指的是用32个二进制位来表示地址,通过计算器我们可以得知32位从最小是32个0,最大是32个1,也就是说一共有 2 32 + 1 2^{32}+1 232+1 个地址可以表示,这么多地址就代表这么多个字节,也就是4,294,967,296个字节。
全0也是一种状态
那么这么多字节按照换算关系也就是4GB的大小。
4GB的总字节数
这解释了为什么32位机器上不论插多少根内存条最大都只能显示4GB。因为CPU根本没有办法去表示大于4GB的内存地址。

如果是64位程序就可以用64个位来表示地址,这是一个非常大的地址,本文只讨论在32位环境下的情况。

内存四区

内存按照功能被划分成4个区,从低到高分别是栈区,堆区,代码区和数据区。

四区是四个抽象出来的内存区域
本文将详细讨论栈区和堆区的内存管理。但是不需要纠结他们的具体意义和用法。

栈区

什么是栈?

栈在汉语中的原意指棚子或牲口棚,后引申为储存货物或供旅客住宿的房屋,如客栈、栈房等,他们的特点是有进有出。在计算机概念上也是同样的特点,英语中的Stack原意:

A stack of things is a pile of them.

a pile of更加符合在数据在内存中的形象,因为叠起来的事物一定是从底层开始叠,并从顶层开始拿,这样很符合对栈的操作。

Release下的栈区示意
把数据叠起来的操作就是栈操作,先从底层放入,再从顶层拿出。

计算机也是这么做的,栈区存储了我们定义的局部变量,那么我们便可以通过局部变量来做一个实验。

#include<stdio.h>
#define PrintVarName(x) #x
int main() {
	unsigned char c1, c2, c3, c4;
	c1 = 0xaa;
	c2 = 0xbb;
	c3 = 0xcc;
	c4 = 0xdd;
	printf("Variable %s = %.2x at %p\n", PrintVarName(c1), c1, &c1);
	printf("Variable %s = %.2x at %p\n", PrintVarName(c2), c2, &c2);
	printf("Variable %s = %.2x at %p\n", PrintVarName(c3), c3, &c3);
	printf("Variable %s = %.2x at %p\n", PrintVarName(c4), c4, &c4);
	return 0;
}

他的输出结果中

输出结果
我们可以发现最先定义的c1的地址是却是最高的,最后定义的c4的地址却是最低的。

这是因为栈区的地址并不是由低到高按照次序来的,而是编译器先将需要的栈内存开辟出来,然后向里面放入需要的变量。

而编译器会从低到高开辟内存,开辟出来的内存便是栈内存。

开辟完成后,结束的那个地址是高地址,就会变成栈底,变量将会被依次放入,从栈底开始,然后向栈顶叠放。
临时变量的压栈过程
为什么选择栈作为储存临时变量的结构?

C语言中,在调用函数的时候,对于程序底层的一条条指令来讲,只是函数作用域里面的变量要进行添加和删除的操作而已,那么用栈来储存数据,当函数被调用的时候,把函数压栈,函数调用完成的时候把栈顶复位,这样的操作极其简单而且高效,因此优先选择用栈来储存函数和作用域内的变量等。

但是,我们在对栈进行操作的时候不得不注意一件事情,就是程序储存信息的方式。

在遍历一个表或者其他数据结构的时候,正常情况下,从栈底遍历到栈顶是没有问题的。

可如果要精确的对数据项进行操作,例如加密或压缩,就需要考虑机器是如何储存数据的。

例如一个int占4个字节,4个字节占有4个地址,这4个地址可能是按照栈本身的顺序,从低到高,也可能是按照相反的顺序储存。这是取决于机器本身在储存数据时是采用了大端模式还是小端模式。

什么是大小端?

如果有一串内存地址,00/01/02/03/04可以储存5个值,先将hello这个单词的五个字符放入五个值内,正常的顺序一定是00储存h,01储存e以此类推。

但是一个地址只能存放一个字节,最大值是255(无符号),如果数据本身超过了一个字节,例如1234这个数超过了255就不止占用了1个字节,那么如果我们将12存入00地址,34存入01地址,我们就得到了正常顺序的字符。这种符合我们阅读习惯的方式称为大端模式,(Big-endian)因为他把代表大的那个数放在了大的位置上(权重高的值先储存)。

但是一个字节本身有它的顺序,这意味着每个字节储存的数据都是独立的,没有权重之分,低地址存放的反而是权重高的那个,遍历时先碰到12,在拼凑成整数的时候我们还要进行权重变换,把12乘上自己的权重,即乘上100,再加上34才是最终结果。

地址是有顺序的,如果我们把地址看成权重,每个地址增量的权重增加100,把12存入01,把34存入00,那么看起来我们得到了3412,但是这时12会根据自己的高地址获得高权重,读取数据的时候会直接得到1234这个值,而不需要进行计算。这种符合逻辑的方法是小端模式(little-endian),即把代表小的那个数放在了大的位置上(权重低的值先储存)。
大小端模式
在通信中,往往需要确认通信机器之间的大小端模式才能进行通信,若大小端不一,还需进行端序调整。一般使用一个常数来判断。

如何判断大小端?

CSAPP给出了使用unsigned char *来遍历单字节的函数,通过字符顺序来判断大小端。

//这段程序来自CSAPP,测试机器大小端分配方式
#include <stdio.h>
#include <string>
typedef unsigned char* byte_pointer;
void show_bytes(byte_pointer start, int len) {
    int i;
    for (i = 0; i < len; i++)
        printf("%.2x ", start[i]);
    printf("\n");
}
int main() {
    int i = 180150000;
    i += 1;
    //这个1可以换成任意小于等于0xf的数,来确认数据差异
    printf("%x at memory array:\n", i);
    show_bytes((byte_pointer)&i, sizeof(i));
    printf("%x\n", i);
    //在不同平台,大小端的排布方式不同
    //这个顺序可能不同
    const char* s = "abcdef";//算上NULL一共7个
    printf("%s at memory array:\n", s);
    show_bytes((byte_pointer)s, strlen(s)+2); //应该打印8个
    //但是在所有平台上ascii码的结果都几乎一致,所以ascii有更好的平台兼容性
    return 0;
}

按照这个思路简化程序,写出一个判断大小端的函数

void endianMode() {
    unsigned char *cursor;          //单字节的游标指针
    unsigned short num = 0xff00;    //储存两个字节的数
    cursor = (unsigned char *)&num; //进行隐式类型转换
    if (*cursor < *(cursor + 1))    //如果高地址值更大
        printf("little-endian");    //则机器为小端模式
    else printf("big-endian");
}

如果是小端模式,内存的示意如下:
大小端不同的内存排布

堆区

何为堆

堆在汉语中的原意是堆起来的物品,相对于栈来说,并不要求他的顺序性,因此英语中的Heap原意是:

A heap of things is a pile of them, especially a pile arranged in a rather untidy way.

a rather untidy way表示了堆的随意性,堆的随意性给了程序员在堆区分配内存更大的自主性和灵活性。

通过malloc来分配堆区内存是常见操作,例如下面的程序通过malloc和free来管理内存

#include<stdio.h>
#include<malloc.h>
int main() {
	int *p = NULL;
	int *opt = NULL;
	p = (int *)malloc(sizeof(int) * 10);
	if (!p) { 
		printf("Memory allocate failed\n"); 
		return 0;
	}
	opt = p;
	for (int i = 0; i < 10; i++, opt++) 
		*opt = i;
	opt = p;
	for (int i = 0; i < 10; i++, opt++) 
		printf("%d at %p\n", *opt, opt);
	free(p);
	return 0;
}

而实际上这个部分是由操作系统和编译器管理的,这涉及操作系统和页表等知识,在此不做拓展。
而在一些情况下分配堆区内存的时候需要注意内存的对齐。

什么是内存对齐?

在下面的这个结构体中,我们可以猜测结构体的大小是8个字节,因为使用了两个int类型。

#include<stdio.h>
struct Example {
	int a;
	int b;
}s1;
int main() {
	printf("%d\n", sizeof(s1));
	return 0;
}

但是当我们进行网络传输、跨平台移植程序时情况往往不会这样简单。

例如下面这个示例结构体就是被设计过的。

#include<stdio.h>
struct AlignedStruct {
	int	Var_int32;
	char	Var_char1;
	char	Var_char2;
	char	Var_char3;
	char	Var_char4;
};
int main() {
	AlignedStruct s1;
	printf("%d\n", sizeof(s1));
	return 0;
}

这个我们也可以猜测占了8个字节,因为一个int是4字节,4个char是4个字节。

这时我们将定义的顺序调整一下。

struct AligninggStruct {
	char	Var_char1;
	int	Var_int32;
	char	Var_char2;
	char	Var_char3;
	char	Var_char4;
};

这个结构体的成员变量没有变化,但是却占据了12个字节
内存对齐示意
这是因为没有进行内存对齐造成的。

内存的对齐可以被具象化为下图:

简单的内存对齐示意
内存对齐的意义不仅是节省空间,在传输中格式保持一致可以减少错误发生。

不论是数组,还是结构体,或是C++的类,由于指针的存在,只可以通过访问指针就可访问到一个整体的,再通过一定的偏移量访问整体的其他部分,内存对齐就是基址+偏移量访问方式的体现。

按照这种思路,下面的代码中操作123都是一致的。

#include<stdio.h>
struct AlignedStruct {
	int		Var_int32;
	char	Var_char1;
	char	Var_char2;
	char	Var_char3;
	char	Var_char4;
};
int main() {
	AlignedStruct s1, *ps1 = &s1;
	unsigned int *reg = (unsigned int *)ps1;
	(*ps1).Var_int32 = 1;		//操作1
	ps1->Var_int32 = 1;		//操作2
	*reg = 1;			//操作3
	printf("%p and int %p\n", ps1, &s1);
	printf("%d\n", ps1->Var_int32);
	return 0;
}

或者利用偏移量来对结构体内部进行操作。

#include<stdio.h>
struct AlignedStruct {
	int		Var_int32;
	char	Var_char1;
	char	Var_char2;
	char	Var_char3;
	char	Var_char4;
};
int main() {
	AlignedStruct s1, *ps1 = &s1;
	char *reg = (char *)ps1;
	char ch = 'a';
	for (reg += 4; reg < (char *)ps1 + sizeof(s1); reg++) {
		*reg = ch++;
	}
	printf("%c\n", ps1->Var_char1);
	printf("%c\n", ps1->Var_char2);
	printf("%c\n", ps1->Var_char3);
	printf("%c\n", ps1->Var_char4);
	return 0;
}

会输出a b c d四个字符。

unsigned char是一种很理想的表示一个字节的数据类型。
当然上述对其只是一个具象化的理解,在实际生产环境中,内存对齐往往由已经打包好的工具,按照需求进行定制化设计。
而对于内存的操作往往设计大量的指针操作,详见下一篇文章。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值