导语:众所周知,当指针值是对齐值的倍数时,用于执行内存访问时使用的CPU性能更好。这种现象仍然存在于当前的CPU中,并且仍有一些仅具有执行对齐访问的指令。考虑到这个问题,C标准已经有了相应的对齐规则,所以编译器可以利用它们来尽可能的
众所周知,当指针值是对齐值的倍数时,用于执行内存访问时使用的CPU性能更好。这种现象仍然存在于当前的CPU中,并且仍有一些仅具有执行对齐访问的指令。考虑到这个问题,C标准已经有了相应的对齐规则,所以编译器可以利用它们来尽可能的生成有效代码。正如我们将在本文中看到的那样,我们在投射指针时需要小心,以确保不会破坏这些规则。本文的目的是通过展示问题并提供一些解决方案来轻松克服它,有一定的教育意义。
对于那些只想查看最终代码的人来说,可以直接跳到【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; }
完整源代码可以在这里下载:https://gist.github.com/aguinet/4b631959a2cb4ebb7e1ea20e679a81af。
它基本上将输入数据作为64位小端整数块进行处理,使用当前哈希值和乘法执行XOR,用剩余的字节填充64位数字。
如果我们想让这个哈希跨体系结构可移植(可移植的意义是它将在每个可能的CPU/OS上生成相同的值),我们需要处理目标的字节顺序——我将在本文末尾回顾这个主题。
让我们在经典的Linux x64计算机上编译并运行该程序:
$ clang -O2 hash.c -o hash && ./hash 'hello world' 527F7DD02E1C1350
一切顺利。现在,让我们在Thumb模式下为具有ARMv5 CPU的Android手机交叉编译此代码并运行它。假设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
调试
我们检索了内核日志以了解详细信息,发现有:
$ dmesg |grep hash_arm [13598.809744] [2: hash_arm:22351] Unhandled fault: alignment fault (0x92000021) at 0x00000000ffdc8977
看来我们在对齐方面存在问题。让我们看一下编译器生成的程序集:
所述LDMIA指令从存储器将数据加载到多个寄存器。在我们的例子中,它将我们的64位整数加载到两个32位寄存器中。该指令的ARM文档[3]指出存储器指针必须是字对齐的(在我们的例子中,一个字是2个字节)。问题出现是因为我们的main函数使用libc加载器传递给argv的缓冲区,它没有保证对齐。
为什么会这样?
为什么编译器会发出这样的指令?是什么让它认为数据指向的内存是字对齐的?
问题发生在load64_le函数中,其中发生了这种强制转换:
uint64_t Ret = *((uint64_t const*)V);
根据C标准[10]:“完整对象类型具有对齐要求,这些要求对可以分配该类型的对象的地址施加限制。对齐是一个实现定义的整数值,表示给定对象可以被分配的连续地址之间的字节数。“ 换句话说,这意味着我们应该这样:
V % (alignof(uint64_t)) == 0
仍然是根据C标准,在不遵守这种对齐规则的情况下,将指针从一种类型转换为另一种类型是未定义的行为(http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf页面74,7)。
在我们的例子中,uint64_t的对齐是8个字节(可以像这样进行检查:https://godbolt.org/z/SJjN9y),因此我们遇到的是未定义的行为。更确切地说,前面的抛出器直接告诉我们的编译器“ ret是8的倍数,因此也是2的倍数。你可以安全的使用LDMIA ”。
在x86-64下不会出现这个问题,因为Intel mov指令支持未对齐的负载[4](如果不启用对齐检查的话[5],就只能由操作系统启用[6])。这就是为什么“老”代码有这么一个不可忽略的隐藏bug,因为它从未出现在x86计算机上(已被开发)。实际上,ARM Debian内核有一种模式可以捕获未对齐的访问并正确处理它们[7]!
解决方案
多次加载
一种经典的解决方案是通过逐字节从内存加载来“手动”生成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编译好的版本。此外,写入有点单调乏味并且容易出错。
无论如何,让我们看看-O2模式中的clang 6.0 为各种架构生成了什么:
· x86-64:mov rax,[rdi](参见https://godbolt.org/z/bMS0jd)。这是我们所期望的,因为x86上的mov指令支持非对齐访问。
· ARM64 ldr x0,[x0](https://godbolt.org/z/qlXpDB)。实际上,ldr ARM64指令似乎没有任何对齐限制[8]。
· Thumb模式下的ARMv5:https://godbolt.org/z/wCBfcV。这基本上就是我们编写的代码,它逐字节的加载整数并构造它。我们可以注意到,这是一些不可忽略的代码量(与之前的情况相比)。
因此,只要优化技术被激活的话,Clang能够检测到这个模式,并且尽可能的生成高效代码(请注意在各种godbolt.org链接中的-o1标志)。
memcpy
另一个解决方案是使用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也有类似的:https: //msdn.microsoft.com/fr-fr/library/a3140177.aspx。
让我们看看-02模式中的clang 6.0 为各种架构生成了什么:
· x86-64:mov rax,[rdi](https://godbolt.org/z/5YKLHE),这就是我们所期望的(见上文)!
· ARM64:ldr x0,[x0](https://godbolt.org/z/2TaFIy)
· Thumb模式下的ARMv5:https://godbolt.org/z/3dX7DY(与上面相同)
我们可以看到编译器理解了memcpy的语义并优化了它,因为对齐规则仍然有效。生成的代码与之前的解决方案基本相同。
C++辅助程序库
在编写了十几次这样的代码之后,我决定编写一个小的只有头文件的C ++辅助程序库,它允许以任何类型的整数以自然/小/大字节顺序加载/存储。它可以在github上找到:https://github.com/aguinet/intmem。虽然它没有花哨的架子,但它有助于节省大家的时间。
它已经在Linux(x86 32/64,ARM和mips)下使用Clang和GCC进行了测试,在Windows(x86 32/64)下使用MSVC 2015进行了测试。
结论
令人遗憾的是,我们仍然需要使用这种“黑客”行为来编写从内存中加载整数的可移植代码。我们需要依赖编译器的优化来生成高效的代码,目前这种状态很糟糕。
实际上,编译人员喜欢说“你要相信编译器会优化你的代码”。这通常是一个值得参考的建议,但是我们描述的解决方案中,最大的问题就是它们不依赖于C标准,而是依赖于现代C编译器优化。因此,没有什么可以强制它们优化我们的memcpy调用或二进制OR的列表以及第一个解决方案的移位,并且任何这些优化中的更改/错误都可能导致我们的代码效率低下。(查看-O0中生成的代码可以了解此代码的内容:https://godbolt.org/z/bUE1LP)。
最后,要确认我们所期望的结果是否实现,其唯一方法是查看最终的程序集,这在实际项目中并不实用。最好有一个更好的自动化方法来检查这种优化,例如通过使用编译指示,或者通过可以由C标准定义并按需激活的一小部分优化(但问题是:是哪一个?如何定义它们?)。或者我们甚至可以为C语言添加标准的可移植内置功能,那就是另外一个课题了。
在某种相关问题上,我还建议阅读David Chisnall的一篇有趣的文章,介绍为什么C不是一种低级语言[9]。
参考
[1] http://pzemtsov.github.io/2016/11/06/bug-story-alignment-on-x86.html
[2] https://research.csiro.au/tsblog/debugging-stories-stack-alignment-matters/
[3] http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.dui0068b/BABEFCIB.html
[4] https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-software-developer-instruction-set-reference-manual-325383.pdf,第690页
[5] https://xem.github.io/minix86/manual/intel-x86-and-64-manual-vol3/o_fe12b1e2a880e0ce-231.html
[6] 据我所知没有x86操作系统可以激活它,这样做可能会导致编译器生成错误的代码!
[7] https://wiki.debian.org/ArmEabiFixes#word_accesses_must_be_aligned_to_a_multiple_of_their_size
[8] http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.dui0802b/LDR_reg_gen.html
[9] https://queue.acm.org/detail.cfm?id=3212479
[10] http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf,第66页,6.2.8.1