C/C++中的未对齐访问:现象、原因及解决办法

栏目: C++ · 发布时间: 6年前

内容简介:当采用对齐方式访问内存时,即当指针值为对齐值的整数倍时,CPU会获得更好的性能。现在各种CPU中还存在这种区别,并且某些CPU仅包含执行对齐访问的指令。考虑到这个问题,C标准中纳入了对齐规则,因此编译器可以根据这些规则来尽可能地生成有效的代码。根据本文的分析,我们在转换(cast)指针时需要格外小心,确保不破坏这些规则。本文的目标是向大家描述这方面存在的问题,并且给出能够轻松克服该问题的一些方法,为大家提供参考。如果大家想直接获取最终代码,可跳到下文的“C++辅助库”部分。

C/C++中的未对齐访问:现象、原因及解决办法

一、前言

当采用对齐方式访问内存时,即当指针值为对齐值的整数倍时,CPU会获得更好的性能。现在各种CPU中还存在这种区别,并且某些CPU仅包含执行对齐访问的指令。考虑到这个问题,C标准中纳入了对齐规则,因此编译器可以根据这些规则来尽可能地生成有效的代码。根据本文的分析,我们在转换(cast)指针时需要格外小心,确保不破坏这些规则。本文的目标是向大家描述这方面存在的问题,并且给出能够轻松克服该问题的一些方法,为大家提供参考。

如果大家想直接获取最终代码,可跳到下文的“C++辅助库”部分。

剧透:本文提供的解决方案没有任何特殊性,都是非常标准的处理方式。互联网上也有其他资料涉及到这方面内容(参考 1 以及 2 )。

二、问题描述

让我们来看一个哈希函数,该函数可以从缓冲区中计算出64位整数值:

#include <stdint.h>
#include <stdlib.h>

static uint64_t load64_le(uint8_t const* V)
{
#if !defined(__LITTLE_ENDIAN__)
#error This code only works with little endian systems
#endif
  uint64_t Ret = *((uint64_t const*)V);
  return Ret;
}

uint64_t hash(const uint8_t* Data, const size_t Len)
{
   uint64_t Ret = 0;
   const size_t NBlocks = Len/8;
   for (size_t I = 0; I < NBlocks; ++I) {
     const uint64_t V = load64_le(&Data[I*sizeof(uint64_t)]);
     Ret = (Ret ^ V)*CST;
   }
   uint64_t LastV = 0;
   for (size_t I = 0; I < (Len-NBlocks*8); ++I) {
     LastV |= ((uint64_t)Data[NBlocks*8+I]) << (I*8);
   }
   Ret = (Ret^LastV)*CST;
   return Ret;
}

大家可以访问 此处 下载包含 main 函数的完整源代码。

函数的主要功能是将输入数据当成若干块64位低字节序(little endian)整数来处理,与当前的哈希值执行XOR操作以及乘法操作。对于剩下的字节,函数会使用余下的字节来填充64位数。

如果我们希望这个哈希函数能够跨架构可移植(这里可移植的意思是能够在每一个CPU/OS上生成相同的值),我们需要小心处理目标的字节序,在本文末尾我们会提到这个话题。

接下来,让我们在典型的Linux x64计算机上编译并运行该程序:

$ clang -O2 hash.c -o hash && ./hash 'hello world'
527F7DD02E1C1350

一切顺利。现在,让我们交叉编译这段代码,目标为Android手机,搭载处于Thumb模式的ARMv5 CPU。假设 ANDROID_NDK 为指向Android NDK安装位置的环境变量,我们可以执行如下命令:

$ $ANDROID_NDK/build/tools/make_standalone_toolchain.py --arch arm --install-dir arm
$ ./arm/bin/clang -fPIC -pie -O2 hash.c -o hash_arm -march=thumbv5 -mthumb
$ adb push hash_arm /data/local/tmp && adb shell "/data/local/tmp/hash_arm 'hello world'"
hash_arm: 1 file pushed. 4.7 MB/s (42316 bytes in 0.009s)
Bus error

出现了一些问题。让我们尝试另一个字符串:

$ adb push hash_arm && adb shell "/data/local/tmp/hash_arm 'dragons'"
hash_arm: 1 file pushed. 4.7 MB/s (42316 bytes in 0.009s)
39BF423B8562D6A0

调试过程

我们可以对内核日志执行 grep 操作,得到如下结果:

$ dmesg |grep hash_arm
[13598.809744]  [2:       hash_arm:22351] Unhandled fault: alignment fault (0x92000021) at 0x00000000ffdc8977

貌似我们碰到了对齐问题。来看一下编译器生成的汇编代码:

C/C++中的未对齐访问:现象、原因及解决办法

LDMIA 指令会将数据从内存中加载到多个寄存器中。对于我们这个例子,该指令会将我们的64位整数加载到2个32位寄存器中。根据ARM的文档对该指令的 说明 ,内存指针必须word对齐(word-aligned,这里1个word对应2字节)。问题之所以出现,是因为我们的 main 函数使用了由 libc 加载器传递给 argv 的一个缓冲区,而这里并不能保证满足对齐条件。

原因解释

这里我们自然会问一个问题:为何编译器会采用这样一条指令?为何编译器会认为 Data 指向的内存是word对齐的?

问题在于 load64_le 函数中,其中存在这样一条转换语句:

uint64_t Ret = *((uint64_t const*)V);

根据C标准中的 说明 :“完整的对象类型需要满足对齐要求,这会限制该类型的对象可能分配的地址。对齐值是由具体实现所定义的一个整数值,表示给定对象所能分配的连续地址之间对应的字节数”。换句话说,这意味着我们必须满足如下条件:

V % (alignof(uint64_t)) == 0

同样根据C 标准 ,将指针从某种类型转化为另一种类型,而不遵守这个对齐规则时属于未定义的行为。

在我们的例子中, uint64_t 大小为8字节(我们可以使用这个 测试网页 来验证这一点),因此我们遇到了这种未定义行为。更具体一点,前面那条转换语句会告诉编译器这样一个事实:“ Ret 大小是8的倍数,因此是2的倍数,因此你可以安全地使用 LDMIA ”。

x86-64架构上不会出现这个问题,因为Intel的 mov 指令支持未对齐的 加载方式 (如果未启用 对齐检测 功能,而只有操作系统才能启用该功能。另外据我所知,没有一个x86系统会激活该功能,如果对该功能不了解,启用该功能可能导致编译器生成错误的代码)。这也是为什么“老代码”中会存在这样一个隐蔽bug,因为该bug永远不会出现在x86计算机上(而这些计算机正是开发代码的节点)。ARM Debian内核可以捕获并 正确处理 未对齐访问行为,这实际上真的非常糟糕。

三、解决方案

多次加载

一种经典的解决方案就是通过从内存中逐字节加载的方式来“手动”生成64位整数,这里采用低字节序方式,如下所示:

uint64_t load64_le(uint8_t const* V)
{
   uint64_t Ret = 0;
   Ret |= (uint64_t) V[0];
   Ret |= ((uint64_t) V[1]) << 8;
   Ret |= ((uint64_t) V[2]) << 16;
   Ret |= ((uint64_t) V[3]) << 24;
   Ret |= ((uint64_t) V[4]) << 32;
   Ret |= ((uint64_t) V[5]) << 40;
   Ret |= ((uint64_t) V[6]) << 48;
   Ret |= ((uint64_t) V[7]) << 56;
   return Ret;
}

如上代码具有多个优点:这是从内存中加载低字节序64位整数的可移植方法,并不会打破前面的对齐规则。当然也有缺点:如果我们想使用CPU对整数的自然字节序,我们需要编写两个版本的代码,然后使用 ifdef 方法来编译正确的版本。另外,这种代码编写起来有点乏味,容易出错。

无论如何,我们来看一下clang 6.0在 -O2 模式下的处理结果,不同架构的处理结果如下所示:

  • x86-64: mov rax, [rdi] (参考 此处 结果)。这是我们可以预期的结果,因为x86上的 mov 指令支持未对齐访问。
  • ARM64: ldr x0, [x0] (参考 此处 结果)。 ldr ARM64指令的确没有任何 对齐限制
  • Thumb模式下的ARMv5:参考 此处 结果。编译结果基本上就是我们编写的代码,会逐字节加载整数进行构造。与之前的代码相比,我们可以注意到有一些显著的变化。

因此只要激活优化选项(注意上面各种测试结果中的 -O1 标志),Clang可以检测到这种方式,尽可能生成有效的代码。

另一种方案就是使用 memcpy

uint64_t load64_le(uint8_t const* V) {
  uint64_t Ret;
  memcpy(&Ret, V, sizeof(uint64_t));
#ifdef __BIG_ENDIAN__
  Ret = __builtin_bswap64(Ret);
#endif
  return Ret;
}

这种方法的优点是我们仍然不会破坏任何对齐规则,可以使用自然的CPU字节序来加载整数(删除 __builtin_bswap64 语句),并且编写起来不大可能出错。一个缺点是需要依赖非标准的内置实现(即 __builtin_bswap64 )。GCC和Clang支持这种方式,MSVC有等效的 解决方案

来看一下Clang 6.0在 -02 模式下的处理结果,不同架构的处理结果如下所示:

  • x86-64: mov rax, [rdi] (参考 此处 结果)。这是我们可以预期的结果(前面已经解释过)。
  • ARM64: ldr x0, [x0] (参考 此处 结果)。
  • Thumb模式下的ARMv5:参考 此处 结果(与之前结果一致)。

我们可以看到编译器能够理解 memcpy 的含义,并正确优化代码(因为对齐规则仍然有效)。生成的代码基本上与上一种方案相同。

四、C++辅助库

经过数十次编写这种代码后,我决定编写一个只包含头文件的小型C++辅助库,可以帮我们以自然序/小字节序/大字节序方式加载/存储任意类型的整数。大家可以访问 Github 下载源码。代码并没有特别花哨,但的确可以帮助我们节省时间。

我已经在Linux(x86 32/64、ARM和mips)上使用Clang和GCC测试过代码,Windows(x86 32/64)上使用MSVC 2015进行测试。

五、总结

令人遗憾的是,我们仍然需要使用这种“黑科技”来编写可移植代码,从内存中加载整数。目前的状况非常糟糕,我们需要依赖编译器的优化来生成高效且有效的代码。

事实上,编译器方面的人们喜欢说“你应该信任让编译器来优化代码”。虽然我们通常可以遵循这个建议,但本文描述的解决方案有个大问题,在于这种方案并没有依赖C标准,而是依赖C编译器的优化。因此,编译器无法优化我们的 memcpy 调用或者多次加载方案中的一系列 OR 及位移操作,并且这些操作一旦更改或者出现bug,就可能导致我们的代码效率低下(大家可以观察使用 -O0 之后生成的 结果 )。

最后,为了保证编译后的结果符合我们的预期,我们只能查看最终生成的汇编代码,而这在实际环境中并不是特别可行。如果有自动化方法能够检查这种优化结果会更好,比如使用 pragma 语法,或者由C标准定义的、能够按需激活的一小部分优化子集(但问题在于现在并没有这些子集,也不知道如何定义)。或者我们还可以为 C语言 添加标准的可移植的内置功能来解决这个问题,但这又是另一个话题了。

此外,如果大家有富余精力,建议阅读一下David Chisnall写的一篇 文章 ,解释为何C并不是一门低级语言。


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

谋局者

谋局者

何常在 / 北京联合出版公司 / 2017-1 / 39.80

★商战版《官场笔记》!全面超越《问鼎》《交手》!商战小说*大神何常在迄今为止至为满意之作! ★以马云、马化腾、李彦宏、雷军、刘强东、张朝阳等大佬为原型,写透高手们的大智慧、大手腕、大谋略! ★善谋者胜,善算者赢!内含大量阳谋诡计、商业运作、商业谈判、事件营销等可以读以致用的知识!是商界人士必看读物! ★全景再现互联网三大帝国七大诸侯从无到有从有到强从强到吞并一切的成长和并购史! ......一起来看看 《谋局者》 这本书的介绍吧!

在线进制转换器
在线进制转换器

各进制数互转换器

Markdown 在线编辑器
Markdown 在线编辑器

Markdown 在线编辑器

HEX HSV 转换工具
HEX HSV 转换工具

HEX HSV 互换工具