本文将首先解释
内存访问粒度的概念,以便我们对
处理器如何访问内存有一个基本的了解。然后,我们将仔细研究数据对齐的概念并研究一些示例结构的内存布局。
在上一篇有关嵌入式 C 中的结构的文章中,我们观察到重新排列结构中成员的顺序可以更改存储结构所需的内存量。我们还看到编译器在为结构体成员分配内存时有一定的限制。这些称为数据对齐要求的约束允许处理器更有效地访问变量,但代价是内存布局中可能出现一些浪费的空间(称为“填充”)。
本文将首先解释内存访问粒度的概念,以便我们对处理器如何访问内存有一个基本的了解。然后,我们将仔细研究数据对齐的概念并研究一些示例结构的内存布局。
值得一提的是,计算机的内存系统可能比这里介绍的要复杂得多。本文的目的是讨论一些在嵌入式系统编程时有用的基本概念。
内存访问粒度
我们通常将内存想象为单字节存储位置的集合,如图 1 所示。每个位置都有一个的地址,允许我们访问该地址的数据。
图1
然而,处理器通常以大于一字节的块的形式访问内存。例如,处理器可以以四字节块的形式访问
存储器。在这种情况下,我们可以设想图1的12个连续字节如下图2所示。
图2
您可能想知道这两种处理内存的方式之间有什么区别。在图 1 中,处理器每次读取和写入内存一个字节。请注意,在读取或写入内存位置之前,我们需要访问该内存单元,并且每次内存访问都需要一些时间。假设我们要读取图1中内存的前八个字节。对于每个字节,处理器都需要访问内存并读取它。因此,要读取前八个字节的内容,处理器必须访问内存八次。
在图 2 中,处理器从内存中读取和写入四个字节。因此,为了读取前四个字节,处理器访问存储器的地址0并读取四个连续的存储位置(地址0到3)。同样,要读取下一个四字节块,处理器需要再次访问内存。它转到地址 4,并同时读取地址 4 到 7 的存储位置。对于字节大小的块,需要八次内存访问来读取内存的八个连续字节。然而,对于图 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 位地址的两个有效位将始终为零(因为该地址始终可被四整除)。因此,我们将能够使用 30 位来寻址 2 32字节的内存。
数据对齐
现在我们知道了基本处理器如何访问内存,我们可以讨论数据对齐要求。一般来说,任何 K 字节的 C 数据类型都必须有一个 K 倍数的地址。例如,四字节数据类型只能存储在地址 0、4、8、…;它不能存储在地址 1、2、3、5、... 处。这些限制简化了处理器和存储器系统之间的
接口硬件的设计。
例如,考虑具有四字节内存访问粒度的处理器,该处理器只能在四字节边界访问内存。假设地址1处存储了一个四字节变量,如图4所示(四个字节对应四种不同颜色)。在这种情况下,我们需要两次内存访问和一些额外的工作来读取未对齐的四字节数据(我所说的“未对齐”是指它被分成两个四字节块)。流程如图所示。
图4
但是,如果我们在 4 的倍数的任何地址存储一个四字节变量,则只需要内存访问即可修改数据或读取数据。
这就是为什么将 K 字节数据类型存储在 K 倍数的地址处可以使系统更加高效。因此,C 语言的“
char ”变量(只需要一个字节)可以存储在任意字节地址,但两字节变量必须存储在偶数地址。四字节类型必须从可被 4 整除的地址开始,八字节数据类型必须存储在可被 8 整除的地址。例如,假设在特定机器上,“短”变量需要两个字节, “ int ”和“ float ”类型占用四个字节,“ long ”、“ double”类型占用四个字节”,指针占用8个字节。这些数据类型中的每一种通常都应具有 K 倍数的地址,其中 K 由下表给出。
数据类型K
字符1
短的2
整型、浮点型4
长整型、双精度型、字符*8
请注意,不同数据类型的大小可能会根据编译器和机器体系结构的不同而有所不同。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 所示。如您所见,在内存布局的末尾添加了 3 个填充字节,以将结构的大小增加到 8 字节。这将使结构体大小可以被结构体中较大成员(c 成员,它是一个四字节变量)的大小整除。
图7