什么是内存
宏观上储存数据的都可以叫做存储器,本章讨论的内存是在程序设计中对程序来讲的内存。
内存用来存放数据,可以理解成一个个箱子排列而成,箱子用来存放数据,但是箱子本身有编号,而且是连续的,这个编号就是内存地址。
也就是说,内存可以抽象成由数据和地址组成。
例如上图中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个字节。
那么这么多字节按照换算关系也就是4GB的大小。
这解释了为什么32位机器上不论插多少根内存条最大都只能显示4GB。因为CPU根本没有办法去表示大于4GB的内存地址。
如果是64位程序就可以用64个位来表示地址,这是一个非常大的地址,本文只讨论在32位环境下的情况。
内存四区
内存按照功能被划分成4个区,从低到高分别是栈区,堆区,代码区和数据区。
本文将详细讨论栈区和堆区的内存管理。但是不需要纠结他们的具体意义和用法。
栈区
什么是栈?
栈在汉语中的原意指棚子或牲口棚,后引申为储存货物或供旅客住宿的房屋,如客栈、栈房等,他们的特点是有进有出。在计算机概念上也是同样的特点,英语中的Stack原意:
A stack of things is a pile of them.
a pile of更加符合在数据在内存中的形象,因为叠起来的事物一定是从底层开始叠,并从顶层开始拿,这样很符合对栈的操作。
把数据叠起来的操作就是栈操作,先从底层放入,再从顶层拿出。
计算机也是这么做的,栈区存储了我们定义的局部变量,那么我们便可以通过局部变量来做一个实验。
#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 *)# //进行隐式类型转换
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是一种很理想的表示一个字节的数据类型。
当然上述对其只是一个具象化的理解,在实际生产环境中,内存对齐往往由已经打包好的工具,按照需求进行定制化设计。
而对于内存的操作往往设计大量的指针操作,详见下一篇文章。