C/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