不对齐的内存访问
作者
- Daniel Drake dsd@gentoo.org
- Johannes Berg johannes@sipsolutions.net
在Linux上运行着各种行为各异的架构,当涉及到内存访问时,它们的行为也各不相同。本文档介绍了一些关于不对齐访问的细节,为什么你需要编写不会引起不对齐访问的代码,以及如何编写这样的代码!
不对齐访问的定义
当你尝试从一个地址开始读取 N 个字节的数据,而该地址不能被 N 整除时(即 addr % N != 0),就会发生不对齐的内存访问。例如,从地址 0x10004 读取 4 个字节的数据是可以的,但是从地址 0x10005 读取 4 个字节的数据就会导致不对齐的内存访问。
上述定义可能有点模糊,因为内存访问可以以不同的方式发生。这里的上下文是在机器码级别:某些指令会读取或写入一定数量的字节到内存中(例如 x86 汇编中的 movb、movw、movl)。正如将要明确的那样,很容易发现会编译成多字节内存访问指令的 C 语句,尤其是当涉及到 u16、u32 和 u64 等类型时。
自然对齐
上面提到的规则构成了我们所说的自然对齐:当访问 N 个字节的内存时,基本内存地址必须能够被 N 整除,即 addr % N == 0。
在编写代码时,假设目标架构需要自然对齐要求。
实际上,只有少数架构要求在所有大小的内存访问上进行自然对齐。然而,我们必须考虑所有支持的架构;编写满足自然对齐要求的代码是实现完全可移植性的最简单方式。
不对齐访问的危害
执行不对齐的内存访问的影响因架构而异。在这里,很容易写一整篇关于不同架构之间差异的文档;下面简要总结了常见情况:
- 一些架构能够透明地执行不对齐的内存访问,但通常会带来显著的性能损失。
- 一些架构在发生不对齐访问时会引发处理器异常。异常处理程序能够纠正不对齐访问,但代价是显著的性能损失。
- 一些架构在发生不对齐访问时会引发处理器异常,但异常中不包含足够的信息来纠正不对齐访问。
- 一些架构无法进行不对齐的内存访问,但会默默地执行一个与请求不同的内存访问,导致难以检测的微妙代码错误!
从上面很明显,如果你的代码导致不对齐的内存访问发生,你的代码将无法在某些平台上正确工作,并且会在其他平台上引起性能问题。
不会引起不对齐访问的代码
起初,上面的概念可能与实际编码实践有些难以关联。毕竟,你对某些变量的内存地址没有太多控制。
幸运的是,大多数情况下,编译器会确保事情对你来说是可行的。例如,考虑以下结构:
struct foo { u16 field1; u32 field2; u8 field3; };
假设上述结构的一个实例位于从地址 0x10000 开始的内存中。在基本的理解水平下,期望访问 field2 会导致不对齐访问并不是不合理的。你期望 field2 位于结构的偏移量为 2 字节的位置,即地址 0x10002,但该地址不能被 4 整除(记住,我们在这里读取的是一个 4 字节的值)。
幸运的是,编译器理解了对齐约束,因此在上述情况下,它会在 field1 和 field2 之间插入 2 个字节的填充。因此,对于标准结构类型,你总是可以依赖编译器填充结构,以便对字段进行适当的对齐访问(假设你没有将字段转换为不同长度的类型)。
同样,你也可以依赖编译器将变量和函数参数按照自然对齐方案对齐,基于变量类型的大小。
此时,应该清楚访问单个字节(u8 或 char)永远不会导致不对齐访问,因为所有内存地址都可以被一个整除。
在相关主题上,考虑到上述情况,你可能会观察到,你可以重新排列结构中的字段,以将字段放置在否则会插入填充的位置,从而减少结构实例的整体占用内存大小。上面示例的最佳布局是:
struct foo { u32 field2; u16 field1; u8 field3; };
对于自然对齐方案,编译器只需要在结构末尾添加一个字节的填充。这个填充是为了满足这些结构的数组的对齐约束。
另一个值得一提的是对结构类型使用 __attribute__((packed))
。这个特定于 GCC 的属性告诉编译器在结构内部永远不要插入任何填充,这在你想要使用 C 结构来表示某些以固定排列方式“从线上”获取的数据时非常有用。
你可能会倾向于相信使用这个属性会很容易导致在访问不满足架构对齐要求的字段时发生不对齐访问。然而,同样,编译器了解对齐约束,并将生成额外的指令来执行内存访问,以避免发生不对齐访问。当然,与非打包情况相比,额外的指令显然会导致性能损失,因此应该仅在避免结构填充很重要时使用打包属性。
导致不对齐访问的代码
有了上述的理解,让我们来看一个真实例子,一个可能导致不对齐内存访问的函数。下面的函数取自 include/linux/etherdevice.h,它是一个用于比较两个以太网 MAC 地址是否相等的优化例程:
bool ether_addr_equal(const u8 *addr1, const u8 *addr2) { #ifdef CONFIG_HAVE_EFFICIENT_UNALIGNED_ACCESS u32 fold = ((*(const u32 *)addr1) ^ (*(const u32 *)addr2)) | ((*(const u16 *)(addr1 + 4)) ^ (*(const u16 *)(addr2 + 4))); return fold == 0; #else const u16 *a = (const u16 *)addr1; const u16 *b = (const u16 *)addr2; return ((a[0] ^ b[0]) | (a[1] ^ b[1]) | (a[2] ^ b[2])) == 0; #endif }
在上述函数中,当硬件具有高效的不对齐访问能力时,这段代码没有问题。但是当硬件无法在任意边界上访问内存时,对 a[0] 的引用会导致从地址 addr1 开始读取 2 个字节(16 位)的数据。
想象一下,如果 addr1 是一个奇数地址,比如 0x10003,会发生什么?(提示:这将是一个不对齐的访问。)
尽管上述函数可能存在不对齐访问的问题,它仍然包含在内核中,但是已经理解它只在 16 位对齐的地址上正常工作。调用者需要确保这种对齐,否则根本不使用这个函数。这个不安全的对齐函数仍然有用,因为它是一个对于你几乎总是可以确保对齐的以太网网络环境中的良好优化。
下面是另一个可能导致不对齐访问的代码示例:
void myfunc(u8 *data, u32 value) { [...] *((u32 *) data) = cpu_to_le32(value); [...] }
每当 data 参数指向不能被 4 整除的地址时,这段代码都会导致不对齐访问。
总之,你可能会遇到不对齐访问问题的两个主要场景包括:
- 将变量转换为不同长度的类型
- 指针算术后访问至少 2 个字节的数据
避免不对齐访问
避免不对齐访问的最简单方法是使用 <asm/unaligned.h>
头文件提供的 get_unaligned()
和 put_unaligned()
宏。
回到前面可能导致不对齐访问的代码示例:
void myfunc(u8 *data, u32 value) { [...] *((u32 *) data) = cpu_to_le32(value); [...] }
为了避免不对齐内存访问,你可以将其重写如下:
void myfunc(u8 *data, u32 value) { [...] value = cpu_to_le32(value); put_unaligned(value, (u32 *) data); [...] }
get_unaligned()
宏的使用方式类似。假设 'data' 是一个指向内存的指针,你希望避免不对齐访问,它的用法如下:
u32 value = get_unaligned((u32 *) data);
这些宏适用于任何长度的内存访问(不仅仅是上面示例中的 32 位)。需要注意的是,与标准对齐内存访问相比,使用这些宏访问不对齐内存可能会在性能上产生代价。
如果使用这些宏不方便,另一个选择是使用 memcpy()
,其中源或目标(或两者)是类型为 u8* 或 unsigned char*。由于这个操作是逐字节进行的,因此可以避免不对齐访问。
对齐 vs. 网络
在需要对齐加载的架构上,网络需要 IP 头在四字节边界上对齐,以优化 IP 栈。对于常规以太网硬件,使用常量 NET_IP_ALIGN
。在大多数架构上,这个常量的值为 2,因为正常的以太网头部长度为 14 字节,因此为了获得正确的对齐,需要将 DMA 到一个可以表示为 4*n + 2 的地址。这里一个值得注意的例外是 powerpc,它将 NET_IP_ALIGN
定义为 0,因为 DMA 到不对齐的地址可能非常昂贵,而且会使不对齐加载的成本相形见绌。
对于一些不能 DMA 到 4*n+2 或非以太网硬件的以太网硬件,这可能是一个问题,因此需要将传入的帧复制到一个对齐的缓冲区中。因为在能够进行不对齐访问的架构上这是不必要的,所以代码可以根据 CONFIG_HAVE_EFFICIENT_UNALIGNED_ACCESS
的设置来进行判断:
#ifdef CONFIG_HAVE_EFFICIENT_UNALIGNED_ACCESS skb = original skb #else skb = copy skb #endif
结语
以上是关于不对齐内存访问的一些细节,以及如何编写不会引起不对齐访问的代码。希望对你有所帮助!