簡體   English   中英

在 x86 和 x64 上的同一頁面內讀取緩沖區的末尾是否安全?

[英]Is it safe to read past the end of a buffer within the same page on x86 and x64?

如果允許高性能算法中的許多方法在輸入緩沖區末尾之后讀取少量數據,它們就可以(並且已經)被簡化。 此處,“少量”通常意味着在末尾之后最多W - 1個字節,其中W是以字節為單位的算法字大小(例如,對於以 64 位塊處理輸入的算法,最多 7 個字節)。

很明顯,寫入超過輸入緩沖區的末尾永遠是不安全的,通常,因為您可能會破壞緩沖區1之外的數據。 很明顯,越過緩沖區的末尾讀取到另一個頁面可能會觸發分段錯誤/訪問沖突,因為下一頁可能無法讀取。

然而,在讀取對齊值的特殊情況下,頁面錯誤似乎是不可能的,至少在 x86 上是這樣。 在該平台上,頁面(以及內存保護標志)具有 4K 粒度(更大的頁面,例如 2MiB 或 1GiB,是可能的,但這些是 4K 的倍數),因此對齊讀取將僅訪問與有效頁面相同的頁面中的字節緩沖區的一部分。

這是一些循環的規范示例,該循環對齊其輸入並讀取超過緩沖區末尾的 7 個字節:

int processBytes(uint8_t *input, size_t size) {

    uint64_t *input64 = (uint64_t *)input, end64 = (uint64_t *)(input + size);
    int res;

    if (size < 8) {
        // special case for short inputs that we aren't concerned with here
        return shortMethod();
    }

    // check the first 8 bytes
    if ((res = match(*input)) >= 0) {
        return input + res;
    }

    // align pointer to the next 8-byte boundary
    input64 = (ptrdiff_t)(input64 + 1) & ~0x7;

    for (; input64 < end64; input64++) {
        if ((res = match(*input64)) > 0) {
            return input + res < input + size ? input + res : -1;
        }
    }

    return -1;
}

內部函數int match(uint64_t bytes)未顯示,但它會查找與特定模式匹配的字節,如果找到則返回最低的此類位置 (0-7),否則返回 -1。

首先,為簡單起見,大小 < 8 的案例被典當給另一個函數。 然后對前 8 個(未對齊的字節)進行一次檢查。 然后對剩余的floor((size - 7) / 8)個 8 字節2 的塊進行循環。 此循環最多可以讀取緩沖區末尾之后的 7 個字節(當input & 0xF == 1時發生 7 個字節的情況)。 然而,返回調用有一個檢查,它排除在緩沖區末尾之外發生的任何虛假匹配

實際上,這樣的函數在 x86 和 x86-64 上安全嗎?

這些類型的重讀在高性能代碼中很常見。 避免這種過度讀取的特殊尾碼也很常見。 有時您會看到后一種類型取代前一種,從而使 valgrind 等工具靜音。 有時,您會看到進行此類替換的建議,但會以習語安全且工具有誤(或過於保守) 3為由拒絕該建議

給語言律師的注意事項:

標准中絕對不允許讀取超出其分配大小的指針。 我很欣賞語言律師的回答,甚至偶爾自己寫它們,當有人挖掘出顯示上述代碼是未定義行為的章節和詩句時,我什至會很高興,因此在最嚴格的意義上是不安全的(我會復制詳情在這里)。 最終,這不是我所追求的。 實際上,許多涉及指針轉換、通過此類指針進行結構訪問等的常見習語在技術上是未定義的,但在高質量和高性能代碼中很普遍。 通常沒有替代方案,或者替代方案以半速或更低的速度運行。

如果您願意,請考慮此問題的修改版本,即:

在將上述代碼編譯為 x86/x86-64 程序集,並且用戶已驗證它以預期的方式編譯后(即,編譯器沒有使用可證明的部分越界訪問來做一些真正的事情) 聰明,執行編譯的程序安全嗎?

在這方面,這個問題既是一個 C 問題,也是一個 x86 匯編問題。 我見過的大多數使用這個技巧的代碼都是用 C 編寫的,而 C 仍然是高性能庫的主要語言,很容易超越像 asm 這樣的低級東西和像 <everything else> 這樣的高級東西。 至少在 FORTRAN 仍然打球的硬核數字利基之外。 所以我對這個問題的C-compiler-and-below觀點很感興趣,這就是為什么我沒有將它表述為一個純粹的 x86 匯編問題。

綜上所述,雖然我只是對顯示這是 UD 的標准鏈接感興趣,但我對可以使用此特定 UD 生成意外代碼的實際實現的任何細節非常感興趣。 現在我認為如果沒有一些非常深入的跨過程分析就不會發生這種情況,但是 gcc 溢出的東西也讓很多人感到驚訝......


1即使在明顯無害的情況下,例如寫回相同的值,它也可能破壞並發代碼

2注意此重疊工作需要此函數和match()函數以特定的冪等方式運行 - 特別是返回值支持重疊檢查。 因此“查找第一個字節匹配模式”有效,因為所有match()調用仍然是有序的。 但是,“計數字節匹配模式”方法不起作用,因為某些字節可能會被重復計算。 順便說一句:即使沒有按順序限制,某些函數(例如“返回最小字節”調用)也可以工作,但需要檢查所有字節。

3這里值得注意的是,對於 valgrind 的 Memcheck, 有一個標志--partial-loads-ok用於控制此類讀取實際上是否報告為錯誤。 默認值為yes ,這意味着通常不會將此類加載視為立即錯誤,但會努力跟蹤加載字節的后續使用,其中一些有效,一些無效,並標記錯誤如果使用外的字節范圍。 在上述示例中,在match()訪問整個單詞的情況下,即使結果最終被丟棄,這種分析也會得出訪問字節的結論。 Valgrind通常無法確定是否實際使用了部分加載中的無效字節(並且通常檢測可能非常困難)。

是的,它在 x86 asm 中是安全的,並且現有的 libc strlen(3)實現在手寫 asm 中利用了這一點。 甚至glibc 的后備 C ,但它在沒有 LTO 的情況下編譯,因此它永遠無法內聯。 它基本上是使用 C 作為便攜式匯編程序來為一個函數創建機器代碼,而不是作為具有內聯的較大 C 程序的一部分。 但這主要是因為它也有潛在的嚴格別名 UB,請參閱我在鏈接問答中的回答。 您可能還需要一個 GNU C __attribute__((may_alias)) typedef而不是普通的unsigned long作為您更廣泛的類型,如__m128i等已經使用。

這是安全的,因為對齊的加載永遠不會跨越更高的對齊邊界,並且內存保護發生在對齊的頁面上,因此至少 4k 邊界1任何接觸至少 1 個有效字節的自然對齊的加載都不會出錯。 檢查您是否離下一頁邊界足夠遠以執行 16 字節加載也是安全的,例如if (p & 4095 > (4096 - 16)) do_special_case_fallback 有關更多詳細信息,請參閱以下部分。


據我所知,在為 x86 編譯的 C 中通常也是安全的。 在對象外讀取當然是 C 中的未定義行為,但在 C-targeting-x86 中有效。 我不認為編譯器明確/故意定義行為,但實際上它是這樣工作的。

我認為這不是激進的編譯器認為在優化時不會發生的那種 UB,但是編譯器編寫者在這一點上的確認會很好,特別是對於在編譯時很容易證明訪問消失的情況過了對象的結尾。 (請參閱@RossRidge 評論中的討論:此答案的先前版本斷言它絕對安全,但 LLVM 博客文章並沒有真正以這種方式閱讀)。

這在 asm 中是必需的,以便一次處理一個隱式長度的字符串快於 1 個字節。 在 C 中,理論上編譯器可以知道如何優化這樣的循環,但實際上他們不知道,所以你必須像這樣進行 hack。 在此更改之前,我懷疑人們關心的編譯器通常會避免破壞包含此潛在 UB 的代碼。

當知道對象有多長的代碼看不到重讀時,就不會有危險。 編譯器必須使 asm 適用於我們實際讀取的數組元素的情況。 對於未來可能的編譯器,我可以看到的可能的危險是:內聯后,編譯器可能會看到 UB 並決定永遠不能采用這條執行路徑。 或者必須在最終非完整向量之前找到終止條件,並在完全展開時將其排除。


你得到的數據是不可預測的垃圾,但不會有任何其他潛在的副作用。 只要您的程序不受垃圾字節的影響,就可以了。 (例如,使用bithacks 來查找uint64_t一個字節是否為零,然后使用字節循環來查找第一個零字節,而不管它后面是什么垃圾。)


不尋常的情況下在86 ASM這不會是安全的

  • 在從給定地址加載時觸發的硬件數據斷點(觀察點) 如果您在數組之后立即監視變量,則可能會出現虛假命中。 對於調試正常程序的人來說,這可能是一個小煩惱。 如果您的函數將成為使用 x86 調試寄存器 D0-D3 以及可能影響正確性的結果異常的程序的一部分,那么請注意這一點。

    或者類似地,像 valgrind 這樣的代碼檢查器可能會抱怨在對象之外讀取。

  • 在假設的 16 位或 32 位操作系統下,可以使用分段:分段限制可以使用4k 或 1 字節的粒度,因此可以創建第一個故障偏移為奇數的分段。 (除了性能之外,將段的基址與緩存行或頁面對齊是無關緊要的)。 所有主流 x86 操作系統都使用平面內存模型,x86-64 取消了對 64 位模式的段限制的支持。

  • 內存映射的 I/O 寄存器緊跟在您想要以寬負載循環的緩沖區之后,尤其是相同的 64B 緩存行。 即使您從設備驅動程序(或像 X 服務器這樣映射了一些 MMIO 空間的用戶空間程序)調用這樣的函數,這也是極不可能的。

如果您正在處理一個 60 字節的緩沖區並且需要避免從 4 字節的 MMIO 寄存器中讀取數據,那么您將了解它並將使用volatile T* 這種情況不會發生在普通代碼中。


strlen處理隱式長度緩沖區的循環的典型示例,因此無法在不讀取緩沖區末尾的情況下進行矢量化。 如果您需要避免讀取超過終止的0字節,則一次只能讀取一個字節。

例如,glibc 的實現使用序言來處理直到第一個 64B 對齊邊界的數據。 然后在主循環中(gitweb 鏈接到 asm 源) ,它使用四個 SSE2 對齊加載加載整個 64B 緩存行。 它使用pminub (最小無符號字節)將它們合並為一個向量,因此只有當四個向量中的任何一個具有零時,最終向量才會具有零元素。 在發現字符串的末尾在該緩存行中的某個位置后,它會分別重新檢查四個向量中的每一個以查看位置。 (對全零向量使用典型的pcmpeqb ,並使用pmovmskb / bsf來查找向量內的位置。)glibc 過去有幾種不同的strlen 策略可供選擇,但當前的策略適用於所有 x86-64 CPU。

通常像這樣的循環避免觸及任何他們不需要觸及的額外緩存行,而不僅僅是頁面,出於性能原因,比如 glibc 的 strlen。

一次加載 64B 當然只對 64B 對齊的指針是安全的,因為自然對齊的訪問不能跨越緩存行或頁行邊界


如果您提前知道緩沖區的長度,則可以通過使用在緩沖區最后一個字節處結束的未對齊加載來處理超出最后一個完全對齊向量的字節,從而避免讀取越過末尾。

(同樣,這僅適用於冪等算法,例如 memcpy,它們不關心它們是否將存儲重疊到目標中。就地修改算法通常無法做到這一點,除非將字符串轉換為大寫——使用 SSE2 的情況下,可以重新處理已經被放大的數據。除了存儲轉發停頓,如果您執行與上次對齊的存儲重疊的未對齊加載。)

因此,如果您在已知長度的緩沖區上進行矢量化,通常最好避免過度讀取。

對象的無故障重讀是一種 UB,如果編譯器在編譯時看不到它,它絕對不會受到傷害。 生成的 asm 將像額外的字節是某個對象的一部分一樣工作。

但即使它在編譯時可見,它通常也不會受到當前編譯器的影響。


PS:此答案的先前版本聲稱int *未對齊 deref 在為 x86 編譯的 C 中也是安全的。 事實並非如此 3年前寫那部分時,我有點太傲慢了。 您需要一個__attribute__((aligned(1))) typedef 或memcpy來確保安全。

ISO C 未定義但英特爾內部函數要求編譯器定義的一組內容確實包括創建未對齊的指針(至少對於__m128i*類的類型),但不直接取消引用它們。 硬件 SIMD 向量指針和相應類型之間的“reinterpret_cast”是否是未定義的行為?


檢查指針距離 4k 頁面的末尾是否足夠遠

這對 strlen 的第一個向量很有用; 在此之后,您可以p = (p+16) & -16轉到下一個對齊的向量。 如果p不是 16 字節對齊,這將部分重疊,但有時進行冗余工作是設置高效循環的最緊湊方式。 避免它可能意味着一次循環 1 個字節直到對齊邊界,這當然更糟。

例如檢查((p + 15) ^ p) & 0xFFF...F000 == 0 (LEA / XOR / TEST) 它告訴您 16 字節加載的最后一個字節與第一個字節具有相同的頁面地址位字節。 或者p+15 <= p|0xFFF (LEA / OR / CMP 具有更好的 ILP)檢查加載的最后一個字節地址 <= 包含第一個字節的頁面的最后一個字節。

或者更簡單地說, p & 4095 > (4096 - 16) (MOV / AND / CMP),即p & (pgsize-1) < (pgsize - vecwidth)檢查頁內偏移距末尾是否足夠遠一頁。

您可以使用 32 位操作數大小來保存此檢查或任何其他檢查的代碼大小(REX 前綴),因為高位無關緊要。 一些編譯器不會注意到這種優化,因此您可以uintptr_tunsigned int而不是uintptr_t ,盡管要消除有關不是 64 位干凈的代碼的警告,您可能需要轉換(unsigned)(uintptr_t)p 可以使用((unsigned int)p << 20) > ((4096 - vectorlen) << 20) (MOV / SHL / CMP) 進一步節省代碼大小,因為shl reg, 20是 3 個字節, and eax, imm32為 5,或者任何其他寄存器為 6。 (使用 EAX 也將允許cmp eax, 0xfff的 no-modrm 短格式。)

如果在 GNU C 中執行此操作,您可能需要typedef unsigned long aliasing_unaligned_ulong __attribute__((aligned(1),may_alias)); 以確保進行未對齊的訪問是安全的。

如果您允許考慮非 CPU 設備,那么潛在不安全操作的一個示例是訪問PCI 映射內存頁的越界區域。 無法保證目標設備使用與主內存子系統相同的頁面大小或對齊方式。 例如,如果設備處於 2KiB 頁面模式,則嘗試訪問地址[cpu page base]+0x800可能會觸發設備頁面錯誤。 這通常會導致系統錯誤檢查。

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM