在这个小结中,我们要探讨的是数据的存储和排列。
一、大小端模式
首先来看一个之前提到过的问题,叫做大小端模式
。我们在内存里经常会存储某一些多字节的数据,比如 c 语言里的 Int 型变量,在很多时候占 4 个字节。
我们用 16 进制的方式把 4 个字节的内容给描述出来。
最左边01这个部分我们可以把它称为最高有效字节,最右边67我们可以把它称为最低有效字节。英文缩写也要注意,分别是 MSB 和LSB。
如果把这个 4 字节的 Int 型变量翻译为 10 进制,应该对应的是19088743这样的一个正整数。
而如果把这个 16 进制数翻译成 2 进制的形式,应该是下面这样的。总共有 32 个比特, 4 个字节。
之前我们说过,对于这种多字节的数据,它在内存里边一定是占据连续的几个字节的。
根据这些字节在内存里的排列方式不同,我们可以有两种存储这种多字节数据的方式,一种叫大端
,一种叫小端
。
①大端方式
大端方式的存储会更符合我们人类阅读的习惯。 这儿我们上面给的 0800 H、 0801 H 这些指的是内存地址,左边是内存的低地址部分,右边是内存的高地址部分。
因此所谓的大端模式,就是把最高的有效字节存到更低地址的部分,最低有效字节存在了最高的地址部分。
整个 Int 型变量占据连续的 4 个字节,这是大端存储的方式。
②小端方式
小端方式就是把它逆过来。在低地址的部分存储最低有效字节,在高地址的部分存储最高有效字节。是逆过来存储的。
小端方式显然不太符合我们阅读的习惯,我们要阅读 4 字节数据的时候,得把它逆过来拼凑。
虽然小端方式不方便让我们阅读,但是它更方便让机器进行处理。
因为机器在处理这种多字节数据的时候,通常也是按照这种内存地址递增的次序来读取多字节数据里边的这些每一个字节或者每一个字的。
也就是机器在读取这 4 个字节的 Int 型变量的时候,如果它每次只能读取一个字节,那么它先读入的一定是低地址部分的这一个字节。
如果用大端方式,就相当于先把最高有效字节给读入,一直往后到最后才读入最低有效字节。
而如果是小端模式,就意味着它会先读入最低有效字节,慢慢的读入最高有效字节。这么做是有好处的。
比如如果我们的CPU,它每一次只能处理8位二进制的加法运算,当 CPU 对两个 Int 型变量进行加法操作的时候,显然应该先从它的最低有效字节先进行加法,再加次低位的字节。
所以,如果使用小端方式来存储,计算机首先从内存里读入的就是最初最应该先被处理的字节。
所以小端存储的方式会更便于机器的处理。
这是大端方式和小端方式的区别。
二、边界对齐
接下来再探讨一个问题,叫做边界对齐
的存储方式。
现代的计算机通常是按照字节编址。所谓按字节编址,就是指每一个字节会对应一个地址。
如果结合下面这个图,第一个字节我们可以把它编址内存地址对应的是0,第二个字节对应的内存地址是1,第三个对应的是2,第四个对应的是3。
在这个图里边,除了字节之外,大家还会发现半字和字这样的一种描述方式。
假设计算机,它的存储字长为 32 位,一个字就是 32 个比特位,一个半字就是 16 个比特。
现代计算机虽然是按照字节编址,但是通常来说也可以支持按字或者按半字来进行寻址的操作。
什么意思呢?
①按字节寻址
比如一条指令,它可以指明我要访问的是内存里地址编号为 4 的字节。由于我要寻找的是一个字节,所以最终找到的应该是这儿我们画出的字节,这个字节的编号刚好为 4。这是按字节寻址
。
②按半字寻址
什么叫按半字寻址?
比如我同样可以指定我这次要访问的是编号为 3 的半字。
由于一个半字是 16 个比特,所以最开始的两个字节组成了 0 号半字,接下来的这两个字节组成了 1 号半字,再往后的这两个字节组成了 2 号半字,接下来的两个字节又组成了编号为 3 的半字。
所以,如果我要按半字寻址,我指明了我要访问的是 3 号半字,就意味着接下来我要读入的就是我要访问的就是这两个字节的内容。这是按半字寻址。
③按字寻址
按字寻址也是类似的原理。
由于每个字占 32 个比特,也就是 4 个字节,所以这儿我们画的一整行 4 个字节组成了一个字。因此我可以指明我这次要访问的是 2 号字,下图标明了分别是 0 号字, 1 号字, 2 号字。
所以要访问 2 号字,就意味着我要把这四个字节的内容给读入。
这就是所谓按字按半字和按字节寻址的意思。
大家会发现,我们一个半字对应两个字节(2B),一个字对应四个字节(4B)。
所以当我们给出我们要访问的字地址的时候,要怎么把它转换成与之对应的字节地址呢?
很简单,我们只需要把字地址逻辑左移两位就可以。因为逻辑左移 1 位意味着乘以2,逻辑左移 2 位,意味着乘以4。
所以像刚才这个例子当中,第一个字节我们把它编号为0,接下来第二行的第一个字节编号应该是4,再往后的字节编号应该是8,接下来这个字节编号应该是12,这是每一行的第一个字节的字节地址。
现在我要访问编号为 2 的字,也就是要访问第三行的这一整个字。
我们要把字地址转换成用字节描述的地址。
很简单,2号字它用二进制表示,应该是 10 。我们把它逻辑左移两位,就相当于在末尾又添了两个0。二进制翻译过来应该是8。如下:
所以 2 号字的起始地址就是 8 号字节位置。
从 8 号字节开始,读出连续的四个字节,这就是 2 号字的内容。
半字的原理也是一样,我们给出半字地址之后,想把它转换成字节地址,只需要逻辑左移1位,也就是在末尾添一个 0 就可以。
这是按字按半字,还有按字节寻址的意思。
现代计算机都是按字节编址的。也就是无论我们要访问的是字、半字还是字节,最终肯定需要转换成相对应的字节地址。转换方法就是逻辑左移。
这我们还需要强调一个事情,我们每次访存只能读写一个字的内容,并且这儿所谓的一个字就是我们这儿的一整行,不能跨行读取。
所以基于这种特性,有的计算机会采取数据边界对齐的方式,也就是我们上边图的这种方式。
也有的计算机会采用边界不对齐的方式,也就是下面这种方式。
举个例子。
在 c 语言里边,char型变量刚好占 1 个字节, short 型的变量刚好占 2 个字节, Int 型的变量占 4 个字节。
如果我定义了一个结构体,这个结构体里边包含了 3 个char型的变量, 3 个 short 型的变量,还有一个 Int 型的变量。
如果按边界对齐的方式,我们在存储了刚开始的三个char型变量之后,最开始的这一个字还会剩下一个字节的空间。但是接下来我们要存储的 short 型变量,它必须占一个半字,也就是两个字节。
如果采用的是边界不对齐的方式,我们可以把 short 型的变量第一个字节就放在这一个字的末尾这个地方,把 short 型变量的第二个字节放到第二个字最开始的地方。
基于之前我们提到的读写,仿存相关的特性,我们可以知道,如果按照这种边界不对齐的方式来存储,就意味着当我们要读出 short 型变量的时候,下边这种方案我们必须进行两次访存。
因为第一次访存,我们读入的是最上面这一整个字的内容,而第二次访存,我们才能读入第二个字的内容。
只有把这两个字的内容都读入,再把最末尾的字节和最开头的字节进行一个拼接,我们才可以得到 short 型变量的一个完整的表示。
而如果采用上边这种边界对齐的方式来存储,最后只剩一个字节,存不下半字的数据,我们就会干脆把这一个字节给浪费掉。
虽然空间上能会有一些浪费,但是当我们要读入 short 型变量的时候,只需要进行一次访存就可以,因为这个变量的所有数据都是存放在这一整个字里边的。如下:
所以这就是边界对齐方式和边界不对齐方式的一个对比。
显然,边界对齐方式它是一种空间换时间的一种策略,虽然我们会浪费某一些空间,但是浪费这些存储空间可以换来更快的仿存效率,所以边界对齐的这种存储策略也是值得的。