非對齊訪問和Alignment Fault
什麼是對齊異常?
簡單來說,當CPU訪問記憶體地址時,如果發現訪問的地址是不對齊的,硬體(部分)就會自動觸發對齊異常。對齊即要求被訪問的地址滿足其資料型別的位寬要求,比如要訪問一個4位元組int型的資料,但是提供的地址不是4位元組對齊的,那就是不對齊了。也就是說要訪問的資料的位寬長度是多少,那麼訪問的地址就必須是按這個位寬長度對齊的。如果是char型別的,那就沒有沒有對齊要求了。
為什麼在部分硬體上出現?
部分CPU硬體支援非對齊訪問,典型的就是X86,X86硬體會自動處理非對齊訪問情況,對軟體透明,代價是犧牲效率。硬體處理簡單來說就是通過多次訪存操作,結合拼接(或拆分)操作實現,比如要讀取一個4位元組的int型資料,當地址在2位元組的邊界時,則需要進行兩次記憶體讀取操作,將邊界前後的兩個4位元組的資料讀取出來,然後取出其中的部分資料進行拼接,才能得到想要的資料,X86中,這些操作雖然都是由硬體自動完成,但是相對於對齊的資料訪問來說,其效能損失也是非常明顯的。
部分CPU“部分支援”非對齊訪問,典型的就是ARM,其“單指令”操作支援非對齊,但“群指令”操作(SIMD)則不支援(必須對齊訪問),如LDM、STM、LDRD、STRD。ARMv5指令集的CPU(一般是arm9架構)預設不支援非對齊記憶體訪問,ARMv6及以上的CPU預設支援處理大部分的非對齊記憶體地址訪問。
部分CPU硬體不支援對齊訪問,但通過軟體支援。典型的就是部分mips架構,其通過核心中對alignment fault異常處理流程中進行處理,比如將非對齊的資料訪問,通過多次訪存操作和拼接操作來處理,也可以使用類似memcpy的方式來處理,當然代價是更嚴重的效能損失。ARM架構核心中也有類似的處理分支,可以通過相關的配置來控制其處理方式。
定位方法
核心中的手段
Linux核心中有alignment=啟動引數和/proc/cpu/alignment引數,用於控制出現alignment fault時預設的處理行為,具體定義如下:
alignment= [KNL,ARM]
Allow the default userspace alignment fault handler behaviour to be specified. Bit 0 enables warnings, bit 1 enables fixups, and bit 2 sends a segfault.
該引數由3位組成,第一位控制是否列印warning,第二位控制是否通過軟體修復,第3位控制是否觸發段錯誤。
通常,在出現alignment fault時,需要分析定位原因,而不能簡單的通過核心的fixup或者忽略,由於由此帶來的效能損耗是非常大的,當然如果您的環境中不在乎效能,那就另當別論了。
所以,通常在分析定位alignment fault異常時,需要設定bit0和bit2,即:
echo 5 > /proc/cpu/alignment
如此設定後,在出現alignment fault時,就能在messages中有較詳細的列印,同時,正常情況下(除非禁用了),如果是出現在使用者態,還會有core檔案生成,如果是出現在核心態,則會除非die(),最終觸發panic和kdump,生成vmcore,便於後續的深入分析。
分析思路
此類問題的具體分析思路為:
- 在蒐集到core(或vmcore)檔案後,使用gdb(或crash)工具進行分析。
- 確認出現問題的PC指標值
- 確認PC指標處觸發問題的指令
- 確認PC指標處對應的具體程式碼行
- 分析程式碼邏輯,確認是否有可能導致出現對齊異常的程式碼編寫問題,比如不同的指標型別直接的轉換,或者是結構體中padding問題。
為什麼出現?
我們瞭解,計算機中,CPU是通過匯流排訪問記憶體的,而alignment fault正式匯流排控制器返回給CPU core的。不同的硬體,匯流排控制器的實現和配置不同,導致不同硬體上,對於非對齊訪問的表現也不同,前面也做了說明。
那為什麼部分CPU至今仍堅持不支援非對齊訪問呢,最主要的原因肯定是效能問題了。如之前所說,非對齊訪問帶來的效能損耗是相當明顯的,在目前主流的計算機體系下,其是一個明顯的效能損失點,這也是效能調優過程中需要重點關注的點,特別是當CPU自身效能不濟時,需要尤其關注,這就對程式猿們提出了更高的要求。
由於alignment fault對效能的影響,所以很多CPU中,會將此類問題當做一種異常上報,目的就是告訴使用者:這裡有效能隱患了,雖然我可能為您修復,但需要您的關注,建議您修正程式碼,以提升效能。
由什麼引起?
出現alignment fault問題,通常是使用者編寫的程式碼導致。估計很多程式猿在編寫程式碼(特別是c/c++程式碼)時,從未考慮過這樣的問題,那是因為多數可能都在X86架構下的進行程式碼開發,而且沒有考慮過程式碼的移植性,如前面所說X86硬體會自動處理非對齊問題,使用者感知不到,但這種情況下,由此帶來的效能損耗,使用者可能也關注不到了。另一方面,部分情況下,編譯器也會自動做padding處理(如對結構體的自動填充對齊),這也進一步讓程式猿們減少了對alignment fault的關注。
最常見的可能導致alignment fault的程式碼編寫方式如:
-
指標轉換:將低位寬型別的指標轉換為高位寬型別的指標,如:將char * 轉為int *,或將void *轉為結構體指標。這類操作是導致alignment fault的最主要的來源,在分析定位問題時,需要特別關注。對於出現異常卻又必須這樣使用的場景,對這類轉換後的指標進行訪問時,如果不能確認其對應的地址是對齊的,則應該使用memcpy訪問(memcpy方式不存在對齊問題)。另外,建議轉換後立即使用,不要將其傳遞到其他函式和模組,防止擴充套件,帶來潛在的問題。
-
使用packed屬性或者編譯選項。這樣的操作會關閉編譯器的自動填充功能,從而使結構體中各個欄位緊湊排列,如果排列時未處理好對齊,則可能導致alignment fault。一些場景下(核心中也較常見)確實需要使用者自行緊湊排列結構體,可節省空間(在記憶體資源稀缺的場景下,很有用),此時需要特別關注對齊問題,建議通過填充的方法儘量對齊,如此可能會導致空間浪費,但是會提升訪問效能,典型的“以空間換時間”的思路。如果對空間有強烈要求,而可以接受效能損失,也可以不考慮對齊,不做padding,但在訪問這些結構體的資料時,需要全部使用memcpy的方式。
解決方案
通常,對於alignment fault有如下幾種處理方法,不同的方法對效能影響不同,如下按效能從高到低描述:
程式猿保證對齊
這是最理想的解決方案,沒有效能損失(但可能會有一定的空間浪費),對程式猿們的要求也比較高,但確實非常非常有必要。
寫程式碼時需要記住:資料地址應該至少對齊到與訪問寬度相同的水平。即:1位元組訪問無需對齊,2位元組訪問需要地址能被2整除,4位元組訪問需要地址能被4整除,8位元組訪問需要地址能被8整除。
另一方面,主流編譯器通常會自動的通過填充pad來輔助處理對齊問題,程式猿們程式設計程式碼時,通常只需要關注:儘量將資料寬度大的欄位(也即較長的double/longlong型變數)放到結構體的前面即可,如此,資料寬度較小的欄位無需編譯器補齊,從而可以節約記憶體。
此外,暫存器寬度也對對齊有影響,通常情況下,暫存器寬度即代表了最高的對齊要求,例如32位的arm,在載入8位元組的資料(如longlong型)時,也只需要4位元組對齊。另外需要注意一些cpu的擁有SIMD指令,這些指令對應的暫存器寬度往往要遠大於cpu自己的核心暫存器,因而也會有更高的對齊要求。
還有,雖然memcpy(或memset)的方式不要求對齊,但對於device型別(linux核心中分配記憶體時指定)的記憶體,部分架構下(如ARM64)不能使用memcpy(或memset)的方式訪問,否則也會出現alignment fault,具體案例請參考我的另一篇文章。
硬體處理
如前所述,一些CPU硬體自身已經支援非對齊訪問,並且在多數情況下能夠支援“快速非對齊訪問”。這裡的“快速”,指的是使用單個不對齊訪問指令快於(拆分不對齊訪問後產生的)兩條對齊訪問指令的情況。在這樣的硬體下,我們通常無需在軟體層面上做出特殊的佈置和調整。但是,對於效能有要求的程式碼,還是需要慎重考慮是否新增pad來消除不對齊,因為到目前為止不對齊訪問仍然明顯的慢於對齊訪問。
編譯器或程式碼拆分
當cpu不能進行快速不對齊訪問時,為了提高程式碼執行效率,應該在軟體層面拆分不對齊訪問指令。
現代的編譯器通常都有對不對齊訪問的特殊處理(例如gcc中的munaligned-access等選項)。當編譯器檢查到不對齊的訪問(並且對應的目標硬體不支援快速不對齊訪問時),會自動生成拆分訪問的程式碼。但是,需要特別注意,編譯器在編譯時無法獲知所有指標的地址資訊,因此不能完全對齊對齊問題。
程式碼拆分,需要程式猿自行將不對齊的記憶體訪問拆分成對齊的變數訪問。例如一個int x指標,當我們知道x只是雙位元組對齊時,就要將其拆分兩個short,如果不知道其地址的對齊情況,可以先對該指標地址進行4或者2的求餘再來決定如何拆分,當然,這種情況下,也可以直接呼叫memcpy進行拷貝。在現代編譯器中memcpy等常用函式已經被編譯器高度優化了,其實現邏輯和我們前面手寫程式碼是完全一樣的。
核心處理
如前面描述,Linux核心(部分架構,如ARM)自身提供了對alignment fault的異常處理機制,對於核心來說alignment fault被當做一種異常來處理,類似於缺頁異常,通常,該異常由硬體觸發(x86等硬體自動處理的架構不會觸發此類異常),核心捕獲後進行相應處理,比如進行一些fixup操作,如果修復成功,則萬事大吉,使用者感知不到(但效能上損耗嚴重),如果不能修復,則進行後續處理,大致流程為:
- 判斷是核心態還是使用者態觸發
- 如果是使用者態,則給使用者程序傳送Sigbus訊號,使用者程序收到訊號後觸發coredump,蒐集core檔案。
- 如果是核心態,則直接進入die()流程,最終觸發panic和kdump,蒐集vmcore