非对齐内存访问

作者:

Daniel Drake <dsd@gentoo.org>,

作者:

Johannes Berg <johannes@sipsolutions.net>

鸣谢:

Alan Cox, Avuton Olrich, Heikki Orsila, Jan Engelhardt, Kyle McMartin, Kyle Moffett, Randy Dunlap, Robert Hancock, Uli Kunitz, Vadim Lobanov

Linux 运行在多种架构上,这些架构在内存访问方面表现出不同的行为。本文档详细介绍了非对齐访问,说明了为何需要编写不会导致非对齐访问的代码,以及如何编写此类代码!

非对齐访问的定义

当你尝试从一个不能被 N 整除的地址(即 addr % N != 0)开始读取 N 字节数据时,就会发生非对齐内存访问。例如,从地址 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 结构体来表示以固定排列“线外”传入的数据时非常有用。

你可能会倾向于认为,当访问不满足架构对齐要求的字段时,使用此属性很容易导致非对齐访问。然而,同样地,编译器清楚对齐约束,并将生成额外的指令以避免非对齐访问的方式执行内存访问。当然,与非 packed 情况相比,额外的指令显然会导致性能损失,因此 packed 属性只应在避免结构体填充至关重要时使用。

导致非对齐访问的代码

考虑到上述内容,我们来看一个可能导致非对齐内存访问的实际函数示例。以下函数取自 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 整除时,这段代码都会导致非对齐访问。

总而言之,可能遇到非对齐访问问题的两种主要情况包括

  1. 将变量强制转换为不同长度的类型

  2. 指针运算后访问至少 2 字节的数据

避免非对齐访问

避免非对齐访问最简单的方法是使用 <linux/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*。由于此操作的字节级特性,可以避免非对齐访问。

对齐与网络

在需要对齐加载的架构上,网络要求 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