3. 内存数据
前面我们知道了,内存是按字节编址,每个地址的存储单元可以存放8bit的数据。我们也知道CPU通过内存地址获取一条指令和数据,而他们存在存储单元中。现在就有一个问题。我们的数据和指令不可能刚好是8bit,如果小于8位,没什么问题,顶多是浪费几位(或许按字节编址是为了节省内存空间考虑)。但是当数据或指令的长度大于8bit呢?因为这种情况是很容易出现的,比如一个16bit的Int数据在内存是如何存储的呢?
3.1 内存数据存放
其实一个简单的办法就是使用多个存储单元来存放数据或指令。比如Int16使用2个内存单元,而Int32使用4个内存单元。当读取数据时,一次读取多个内存单元。于是这里又出现2个问题:
- 多个存储单元存储的顺序?
- 如何确定要读几个内存单元?
3.1.1 大端和小端存储
- Little-Endian 就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。
- Big-Endian 就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。
需要说明的是,计算机采用大端还是小端存储是CPU来决定的, 我们常用的X86体系的CPU采用小端,一下ARM体系的CPU也是用小端,但有一些CPU却采用大端比如PowerPC、Sun。判断CPU采用哪种方式很简单:
- bool IsBigEndian()
- {
- int vlaue = 0x1234;
- char lowAdd = *(char *)&value;
- if( lowAdd == 0x12)
- {
- return true;
- }
- return false;
- }
既然不同计算机存储的方式不同,那么在不同计算机之间交互就可能需要进行大小端的转换。这一点我们在Socket编程中可以看到。这里就不介绍了,对以我们单一CPU来说我们可以不需要管这个转换的问题,另外我们目前个人PC都是采用小端方式,所以我们后面默认都是这种方式。
3.1.2 CPU指令
前面我们多次提到了指令的概念,也知道指令是0和1组成的,而汇编代码提高了机器码的可读性。为什么突然在这里介绍CPU指令呢? 主要是解释上面的第二个问题,当我读取一个数据或指令时,我怎么知道需要读取多少个内存单元。
3.1.2.1 CPU指令格式
首先我们来看看CPU指令的格式,我们知道CPU质量主要就是告诉CPU做什么事情,所以一条CPU指令一般包含操作码(OP)和操作
操作码字段 | 地址码字段 |
根据一条指令中有几个操作数地址,可将该指令称为几操作数指令或几地址指令。
操作码 | A1 | A2 | A3 |
三地址指令: (A1) OP (A2) --> A3
操作码 | A1 | A2 |
二地址指令: (A1) OP (A2) --> A1
操作码 | A1 |
一地址指令: (AC) OP (A) --> AC
操作码 |
零地址指令
A1为被操作数地址,也称源操作数地址; A2为操作数地址,也称终点操作数地址; A3为存放结果的地址。 同样,A1,A2,A3以是内存中的单元地址,也可以是运算器中通用寄存器的地址。所以就有一个寻址的问题。关于指令寻址后面会介绍。
CPU指令设计是十分复杂的,因为在计算机中都是0和1保存,那计算机如何区分一条指令中的操作数和操作码呢?如何保证指令不会重复呢?这个不是我们讨论的重点,有兴趣的可以看看计算机体系结构的书,里面都会有介绍。从上图来看我们知道CPU的指令长度是变长的。所以CPU并不能确定一条指令需要占用几个内存单元,那么CPU又是如何确定一条指令是否读取完了呢?
3.1.2.2 指令的获取
现在的CPU多数采用可变长指令系统。关键是指令的第一字节。 当CPU读指令时,并不是一下把整个指令读近来,而是先读入指令的第一个字节。指令译码器分析这个字节,就知道这是几字节指令。接着顺序读入后面的字节。每读一个字节,程序计数器PC加一。整个指令读入后,PC就指向下一指令(等于为读下一指令做好了准备)。
Sample1:
- MOV AL,00 机器码是1011 0000 0000 0000
机器码是16位在内存中占用2个字节:
【00000000】 <- 0x0002
【10110000】 <- 0x0001
比如上面这条MOV汇编指令,把立即数00存入AL寄存器。而CPU获取指令过程如下:
- 从程序计数器获取当前指令的地址0x0001。
- 存储控制器从0x0001中读出整个字节,发送给CPU。PC+1 = 0X0002.
- CPU识别出【10110000】表示:操作是MOV AL,并且A2是一个立即数长度为一个字节,所以整个指令的字长为2字节。
- CPU从地址0x0002取出指令的最后一个字节
- CPU将立即数00存入AL寄存器。
这里的疑问应该是在第3步,CPU是怎么知道是MOV AL 立即数的操作呢?我们在看下面一个列子。
Sample2:
- MOV AL,[0000] 机器码是1010 0000 0000 0000 0000 0000
这里同样是一条MOV的汇编指令,整个指令需要占用3个字节。
【00000000】 <-0x0003
【00000000】 <- 0x0002
【10100000】 <- 0x0001
我们可以比较一下2条指令第一个字节的区别,发现这里的MOV AL是1010 0000,而不是Sample1中的1011 000。CPU读取了第一个字节后识别出,操作是MOV AL [D16],表示是一个寄存器间接寻址,A3操作是存放的是一个16位就是地址偏移量(为什么是16位,后面文章会介绍),CPU就判定这条指令长度3个字节。于是从内存0x0002~0x0003读出指令的后2个字节,进行寻址找到真正的数据内存地址,再次通过CPU读入,并完成操作。
从上面我们可以看出一个指令会根据不同的寻址格式,有不同的机器码与之对应。而每个机器码对应的指令的长度都是在CPU设计时就规定好了。8086采用变长指令,指令长度是1-6个字节,后面可以添加8位或16位的偏移量或立即数。 下面的指令格式相比上面2个就更加复杂。
- 第一个字节的高6位是操作码,W表示传说的数据是字(W=1)还是字节(W=0),D表示数据传输方向D=0数据从寄存器传出,D=1数据传入寄存器。
- 第二个字节中REG表示寄存器号,3位可以表示8种寄存器,根据第一字节的W,可以表示是8位还是16位寄存器。表3-1中列出了8086寄存器编码表
- 第二个字节中的MOD和R/M指定了操作数的寻址方式,表3-2列出了8086的编码
这里没必要也无法更详细介绍CPU指令的,只需要知道,CPU指令中已经定义了指令的长度,不会出现混乱读取内存单元的现象。有兴趣的可以查看引用中的连接。
3.1.3 内存数据
3.1.3.1 内存数据的操作
从上面我们可以知道,操作数可以是立即数,可以存放在寄存器,也可以存放在内存。对于第一个例子,指令已经说明,操作时是一个字节,于是CPU可以从下一个内存地址读取操作时,而对于第二个列子,操作数只是地址偏移,所以当CPU获得这个数据后,需要转换成实际的内存地址,在进行一次内存访问,把数据读入到寄存器中。这里就出现我们前面提到的问题,这个数据我们要读几个存储单元呢?
- MyClass cla;
- 008C3EC9 lea ecx,[cla]
- 008C3ECC call MyClass::MyClass (08C1050h)
- 008C3ED1 mov dword ptr [ebp-4],0
- cla.num5 = 500;
- 008C3ED8 mov dword ptr [ebp-6Ch],1F4h
- int b1 = MyClass::num1;
- 008C3EDF mov dword ptr [b1],64h
- int b2 = MyClass::num2;
- 008C3EE6 mov dword ptr [b2],0C8h
- int b3 = MyClass::num3;
- 008C3EF0 mov eax,dword ptr ds:[008C9008h]
- 008C3EF5 mov dword ptr [b3],eax
- int b4 = cla.num4;
- 008C3EFB mov eax,dword ptr [cla]
- 008C3EFE mov dword ptr [b4],eax
- int b5 = cla.num5;
- 008C3F04 mov eax,dword ptr [ebp-6Ch]
- 008C3F07 mov dword ptr [b5],eax
让我们看一段C++代码和对应的汇编代码,操作很简单,创建一个Myclass对象后,对成员变量赋值。而赋值都是试用Mov操作符。对于这些变量我们有赋值操作和取值操作,那么是如何确定要读取或写入数据的大小呢?
- cla.num5 = 500;
- 08C3ED8 mov dword ptr [ebp-6Ch],1F4h
我看先看看赋值操作,往dword ptr [ebp-6Ch]内存存入一个立即数, [ebp-6Ch]是num5的内存地址,而前面的dword ptr 表示这是进行一个双子操作。还记得上面指令格式中第一个字节的W字段吗? 在8086中只能进行字节或字操作,而现在CPU都可以进行双字操作。
- int b5 = cla.num5;
- 08C3F04 mov eax,dword ptr [ebp-6Ch]
同样,当我们要从一个内存读取数据的时候,也要指定读取数据的操作类型,这里也是双字操作。这样以来,就能从内存中正确的读出需要的长度了。就这么一个简单的赋值操作,获取你从来没想过在内存中怎么存放,又是怎么读取的。这一切都是编译器和CPU在背后为我们完成了。
3.1.3.2 内存对齐
前面我们清楚了CPU是如何正确读取数大小不同的数据的,最后一部分来看看有关内存对齐的问题。对于大部分程序员来说,内存对齐应该是透明的。内存对齐是编译器的管辖范围。编译器为程序中的每个数据单元安排在适当的位置上。
3.1.3.2.1 对齐原因
从前面我们知道,目前计算机内存按照字节编址,每个地址的内存大小为1个字节。而读取数据的大小和数据线有关。比如数据线为8位那么一次读取一个字节,而如果数据线为32位,那么一次需要读取32个字节,这样是为了一次更多的获取数据提高效率。否则读取一个int变量就需要进行4次内存操作。对于内存访问一般有以下两个条件:
- CPU进行一次内存访问读取的数据和字长相同。
- 有些CPU只能对字长倍数的内存地址进行访问。
对于第一个条件一般来说,目前存储器一个cell是8bit,进行位扩展使他和字长还有数据线位数是相同,那么一次就能传送CPU可以处理最多的数据。而前面我们说过目前是按字节编址可能是因为一个cell是8bit,所以一次内存操作读取的数据就是和字长相同。
也正是因为和存储器扩展有关(参考1.2.1的图),每个DRAM位扩展芯片使用相同RAS。如果需要跨行访问,那么需要传递2次RAS。所以以32位CPU为例,CPU只能对0,4,8,16这样的地址进行寻址。而很多32位CPU禁掉了地址线中的低2位A0,A1,这样他们的地址必须是4的倍数,否则会发送错误。
如上图,当计算机数据线为32位时,一次读入4个地址范围的数据。当一个int变量存放在0-3的地址中时,CPU一次就内存操作就可以取得int变量的值。但是如果int变量存放在1-4的地址中呢? 根据上面条件2的解释,这个时候CPU需要进行2次内存访问,第一次读取0-4的数据,并且只保存1-3的内容,第二次访问读取4-7的数据并且只保存4的数据,然后将1-4组合起来。如下图:
所以内存对齐不但可以解决不同CPU的兼容性问题,还能减少内存访问次数,提高效率。当然目前关于这个原因争论很多,可以看看CSDN上的讨论:http://bbs.csdn.net/topics/30388330
3.1.3.2.2 如何对齐内存
内存对齐有一个对齐系数,一般是2,4,8,16字节这样。而不同平台上的对齐方式不同,这个主要是编译器来决定的。
具体的规则可以参考之前转的一篇文章,这里就不详细写了: http://blog.csdn.net/cc_net/article/details/2908600
总结
通过这一篇对内存工作的介绍,我们从内存的硬件结构,存储方式过渡到了内存的编址方式,然后又探讨了按字节编址带来的问题和解决的办法。这里就涉及到了CPU的指令格式,编译器的支持。最后我们也是从硬件和软件方面讨论了内存对齐的问题。
我自己感觉,内存的访问管理是计算机中最重要的部分,也是计算机硬件和软件之间交互的过渡的一个地方。所以理解了内存的工作原理,对于后面理解不同的内存模型很有帮助。
参考 http://blog.csdn.net/cc_net/article/details/11097267