我们通常将内存设想为单字节存储位置的集合,如图1所示。每个位置都有一个唯一的地址,允许我们访问该地址的数据。
图1
但是,处理器通常以大于一个字节的块形式访问内存。例如,处理器可以以四字节块的形式访问内存。在这种情况下,我们可以设想图1中的12个连续字节,如下面的图2所示。
图2
你可能想知道这两种处理内存的方式之间有什么区别。使用图1,处理器一次读取一个字节并向内存写入。请注意,在读取内存位置或写入内存之前,我们需要访问该内存单元,并且每次内存访问都需要一些时间。假设我们想要读取图1中存储器的前八个字节。对于每个字节,处理器需要访问内存并读取它。因此,为了读取前八个字节的内容,处理器将必须访问内存八次。
在图2中,处理器一次读取4个字节并将其写入内存。因此,为了读取前四个字节,处理器访问存储器的地址0并读取四个连续的存储位置(地址0到3)。同样,要读取下一个四字节块,处理器需要再次访问内存。它转到地址4并同时从地址4到7读取存储位置。对于字节大小的块,需要8次内存访问来读取连续8个字节的内存。但是,使用图2,只需要两次内存访问。如上所述,每次内存访问都需要一些时间。由于图2中所示的存储器配置减少了访问次数,因此可以提高处理效率。处理器在访问内存时使用的数据大小称为内存访问粒度。图2描绘了具有四字节存储器访问粒度的系统。
内存访问边界
硬件设计人员经常采用另一种重要技术来提高处理系统的效率:它们限制处理器,使其只能在某些边界访问内存。例如,处理器可能仅能够在四字节边界上访问图2的内存,如图3中的红色箭头所示。
图3
这种边界限制会使系统显着提高效率吗?仔细看看。假设我们需要读取地址为3和4的内存位置的内容(由图3中的绿色和蓝色矩形表示)。如果处理器可以从任意地址开始读取一个四字节的块,那么我们可以访问地址3并通过单个内存访问读取两个所需的内存位置。但是,如上所述,处理器不能直接访问任意地址;相反,它只在某些边界访问内存。那么如果处理器只能访问四字节边界,它将如何读取地址3和4的内容?
由于内存访问边界限制,处理器必须访问地址为0的内存位置并读取连续的四个字节(地址0到3)。接下来,它必须使用移位操作将地址3的内容与其他三个字节(地址0到2)分开。类似地,处理器可以访问地址4并从地址4到7读取另一个四字节块。最后,可以使用移位操作将所需字节(蓝色矩形)与其他三个字节分开。
如果没有内存访问边界限制,可以用一个内存访问读取地址3和地址4。但是,边界限制迫使处理器两次访问存储器。那么,如果数据操作变得更加困难,为什么需要限制对某些边界的内存访问呢?内存访问边界存在限制,因为对地址进行某些假设可以简化硬件设计。例如,假设一个内存块中的所有字节都需要32位来寻址。如果将地址限制为四字节边界,那么32位地址中的两个最低有效位将始终为零(因为地址始终可以被4整除)。因此,我们可以使用30位来寻址一个232字节的内存。
数据对齐
既然已经知道基本处理器如何访问内存,那么下面就可以可以讨论数据对齐要求。通常,任何K字节C数据类型必须具有K的倍数的地址。例如,四字节数据类型只能存储在地址0,4,8,...中; 它不能存储在地址1,2,3,5 ...... 这些限制简化了处理器和内存系统之间的接口硬件的设计。
例如,考虑一个具有四字节内存访问粒度的处理器,它只能以四字节边界访问内存。假设一个四字节变量存储在地址1,如图4所示(四个字节对应四种不同的颜色)。在这种情况下,我们需要两次内存访问和一些额外的工作来读取未对齐的四字节数据(“未对齐”指它被分成两个四字节块)。该过程如图所示。
图4
但是,如果将一个四字节变量存储在4的倍数的任何地址,只需要一个内存访问来修改数据或读取数据。所以将K字节数据类型存储在K的倍数的地址可以提高系统的效率。因此,C语言“char” 变量(只需要一个字节)可以存储在任何字节地址,但是一个双字节变量必须存储在偶数地址中。四字节类型必须从可被4整除的地址开始,并且八字节数据类型必须存储在可被8整除的地址。例如,假设在特定机器上,“short”变量需要两个字节,“int ”和“float” 类型占用四个字节,“long ”、“double”指针占用八个字节。这些数据类型中的每一种通常应具有K的倍数的地址,其中K由下表给出。
请注意,不同数据类型的大小可能因编译器和计算机体系结构的不同而不同。sizeof()运算符是查找数据类型实际大小的最佳方法。
结构的内存布局
让我们检查一下结构的内存布局。为32位计算机编译以下结构:
struct Test2{
uint8_t c;
uint32_t d;
uint8_t e;
uint16_t f;
} MyStruct;
我们知道将分配四个内存位置来存储结构中的成员,并且内存位置的顺序将与声明成员的顺序相匹配。第一个成员是一个单字节变量,可以存储在任何地址。因此,第一个可用存储位置将分配给此变量。假设,如图5所示,编译器为此变量分配地址0。下一个成员是一个四字节数据类型,只能存储在4的倍数地址。第一个可用的存储位置是地址4。但是,这需要不使用地址1、2和3。如你所见,数据对齐要求会导致内存布局中出现一些浪费空间(或填充)。
下一个成员是e,它是一个单字节变量。第一个可用的存储位置(图5中的地址8)分配给此变量。接下来,我们到达f,这是一个双字节变量。它可以存储在可被2整除的地址。第一个可用空间是地址10。如你所见,为了满足数据对齐要求,将出现更多的填充。
图5
我们期望该结构占用8个字节,但实际上它需要12个字节。有趣的是,如果了解数据对齐要求,我们可能能够重新排列结构中成员的顺序,使内存使用效率更高。例如,让我们重写上面的结构,如下所示,其中成员是从最大的到最小的。
struct Test2 {
uint32_t d;
uint16_t f;
uint8_t c;
uint8_t e;
} MyStruct;
在32位机器上,上述结构的内存布局可能看起来像图6中所示的布局。
图6
第一种结构需要12个字节,而新排列只需要8个字节。这是一个显著的改进,特别是在内存受限的嵌入式处理器环境中。另外请注意,结构的最后一个成员后面可能有一些填充字节。结构的总大小必须能够被其最大成员的大小整除。考虑以下结构:
struct Test3 {
uint32_t c;
uint8_t d;
} MyStruct2;
在这种情况下,内存布局将如图7所示。如你所见,在内存布局的末尾添加了三个填充字节,以将结构的大小增加到8个字节。这将使结构大小可以被结构中较大成员的大小整除(c成员,这是一个四字节变量)。
图7
总结
处理器通常以大于一个字节的块形式访问内存,这可以提高系统效率;处理器访问内存时使用的数据大小是处理器的内存访问粒度;处理器可能仅限于在某些边界(例如,在四字节边界)访问内存;存在这种内存访问限制,因为对地址做出某些假设可以简化硬件设计;通常,任何K字节C数据类型必须具有K的倍数的地址,这些限制简化了处理器和内存系统之间的接口硬件的设计;数据对齐要求导致内存布局中出现一些浪费空间(或填充);结构的最后一个成员后面可以有一些填充字节,结构的总大小必须能被其最大成员的大小整除。