Share via


Visual C++ ARM 移轉時常見的問題

使用 Microsoft C++ 編譯器 (MSVC) 時,相同的 C++ 原始程式碼可能會在 ARM 架構上產生與 x86 或 x64 架構不同的結果。

移轉問題的來源

將程式碼從 x86 或 x64 架構移轉至 ARM 架構時,可能會遇到的許多問題都與原始程式碼建構相關,這些建構可能會叫用未定義、實作定義或未指定的行為。

未定義的行為 是 C++ 標準未定義的行為,而且是由沒有合理結果的作業所造成:例如,將浮點值轉換成不帶正負號的整數,或將值由負數位置移位,或超過其升階類型中的位數。

實作定義的行為 是 C++ 標準要求編譯器廠商定義和記載的行為。 程式可以安全地依賴實作定義的行為,即使這樣做可能不是可攜式的。 實作定義行為的範例包括內建資料類型的大小及其對齊需求。 受實作定義行為影響的作業範例是存取變數引數清單。

未指定的行為 是 C++ 標準刻意不具決定性的行為。 雖然行為被視為不具決定性,但編譯器實作會決定未指定行為的特定調用。 不過,編譯器廠商不需要預先決定結果或保證可比較調用之間的一致行為,也不需要檔。 未指定行為的範例是評估子運算式的順序,其包含函式呼叫的引數。

其他移轉問題可以歸因於 ARM 與 x86 或 x64 架構之間的硬體差異,這些架構會以不同的方式與 C++ 標準互動。 例如,x86 和 x64 架構的強記憶體模型會 volatile 提供限定變數一些額外的屬性,這些屬性過去曾用來協助某些種類的執行緒間通訊。 但是 ARM 架構的弱式記憶體模型不支援這項使用,C++ 標準也不需要它。

重要

雖然 volatile 有一些屬性可用來在 x86 和 x64 上實作有限形式的執行緒間通訊,但這些額外的屬性不足以一般實作執行緒間通訊。 C++ 標準建議改用適當的同步處理基本類型來實作這類通訊。

因為不同的平臺可能會以不同的方式表達這類行為,因此,如果平臺之間的軟體取決於特定平臺的行為,則移轉軟體可能會很困難且容易出錯。 雖然可以觀察到許多這類行為,而且可能看起來穩定,但依賴它們至少是不可移植的,而且在未定義或未指定的行為的情況下,也是錯誤。 即使是本檔所引用的行為也不應該依賴,而且在未來的編譯器或 CPU 實作中可能會變更。

範例移轉問題

本檔的其餘部分說明這些 C++ 語言元素的不同行為如何在不同的平臺上產生不同的結果。

將浮點轉換成不帶正負號的整數

在 ARM 架構上,將浮點值轉換成 32 位整數,會飽和到整數可以表示的最接近值,如果浮點值超出整數可以表示的範圍。 在 x86 和 x64 架構上,如果整數不帶正負號,則轉換會四處換行,如果整數帶正負號,則轉換為 -2147483648。 這些架構都不支援將浮點值轉換成較小的整數類型;相反地,轉換會執行到 32 位,而結果會截斷為較小的大小。

針對 ARM 架構,飽和度和截斷的組合表示,當不帶正負號類型飽和 32 位整數時,轉換成不帶正負號的類型會正確飽和較小的不帶正負號型別,但會產生大於較小型別的值截斷結果,但太小而無法飽和完整的 32 位整數。 32 位帶正負號整數的轉換也會正確飽和,但飽和、帶正負號整數的截斷會產生 -1,而負飽和值則為 0。 轉換成較小的帶正負號整數會產生無法預測的截斷結果。

針對 x86 和 x64 架構,不帶正負號整數轉換的包裝行為組合,以及溢位上帶正負號整數轉換的明確估值,以及截斷,讓大部分移位的結果在太大時無法預測。

這些平臺在處理 NaN (Not-a-Number) 轉換成整數類型的方式也有所不同。 在 ARM 上,NaN 會轉換成 0x00000000;在 x86 和 x64 上,它會轉換成 0x80000000。

如果您知道值位於要轉換成的整數類型範圍內,則只能依賴浮點轉換。

Shift 運算子 ( <<>> ) 行為

在 ARM 架構上,值可以在模式開始重複之前,向左或向右移位至 255 位。 在 x86 和 x64 架構上,除非模式的來源是 64 位變數,否則模式會每 32 個倍數重複一次;在此情況下,模式會在 x64 上的每 64 個倍數重複,而 x86 上則重複 256 的倍數,其中會採用軟體實作。 例如,對於值為 1 的 32 位變數,在 ARM 上,結果為 0、在 x86 上為 1,而 x64 則結果也是 1。 不過,如果值的來源是 64 位變數,則這三個平臺上的結果都會4294967296,而且值不會「四處換行」,直到它在 x64 上移位 64 個位置,或在 ARM 和 x86 上移動 256 個位置為止。

因為移位作業的結果超過來源類型中的位數未定義,所以編譯器不需要在所有情況下都有一致的行為。 例如,如果在編譯時期已知這兩個移位運算元,編譯器可能會使用內部常式來預先計算移轉的結果,然後取代結果來取代移位作業, 來優化程式。 如果班次數量太大或負數,內部常式的結果可能會與 CPU 所執行的相同班次運算式結果不同。

變數引數 (varargs) 行為

在 ARM 架構上,堆疊上傳遞之變數引數清單中的參數會受到對齊。 例如,64 位參數會對齊 64 位界限。 在 x86 和 x64 上,在堆疊上傳遞的引數不受限於對齊並緊密封裝。 如果變數引數清單的預期版面配置不完全相符,可能會讓 variadic 函式讀取記憶體位址,像是 printf 讀取 ARM 上的記憶體位址,即使它可能適用于 x86 或 x64 架構上某些值的子集也一樣。 請考慮此範例:

// notice that a 64-bit integer is passed to the function, but '%d' is used to read it.
// on x86 and x64 this may work for small values because %d will "parse" the low-32 bits of the argument.
// on ARM the calling convention will align the 64-bit value and the code will print a random value
printf("%d\n", 1LL);

在此情況下,您可以確定使用正確的格式規格來修正 Bug,以便考慮引數的對齊方式。 此程式碼正確:

// CORRECT: use %I64d for 64-bit integers
printf("%I64d\n", 1LL);

引數評估順序

因為 ARM、x86 和 x64 處理器是如此不同,因此它們可能會對編譯器實作提出不同的需求,以及優化的不同機會。 因此,除了呼叫慣例和優化設定等其他因素之外,編譯器可能會在不同的架構上或變更其他因素時,以不同的順序評估函式引數。 這可能會導致依賴特定評估順序的應用程式行為意外變更。

當函式的引數有影響其他引數至相同呼叫中函式的副作用時,可能會發生這種錯誤。 通常這種相依性很容易避免,但有時可能會因為難以辨識的相依性或運算子多載而遮蔽。 請考慮下列程式碼範例:

handle memory_handle;

memory_handle->acquire(*p);

這看起來是妥善定義的,但如果 ->* 是多載運算子,則此程式碼會轉譯為類似如下的內容:

Handle::acquire(operator->(memory_handle), operator*(p));

如果 和 operator*(p) 之間 operator->(memory_handle) 有相依性,則程式碼可能會依賴特定的評估順序,即使原始程式碼看起來沒有可能的相依性也一樣。

volatile 關鍵字預設行為

MSVC 編譯器支援使用編譯器參數指定之儲存體限定詞的兩種不同的解譯 volatile/volatile:ms 參數會選取 Microsoft 擴充的揮發性語意,保證強式排序,因為這些架構上的強記憶體模型是 x86 和 x64 的傳統案例。 /volatile:iso 參數會選取不保證強序的嚴格 C++ 標準動態語意。

在 ARM 架構上(ARM64EC除外),預設值為 /volatile:iso ,因為 ARM 處理器有弱式排序的記憶體模型,而且 ARM 軟體沒有依賴 /volatile:ms 擴充語意的 舊版,而且通常不需要與軟體進行介面。 不過,編譯 ARM 程式以使用擴充語意,有時還是需要它。 例如,移植程式使用 ISO C++ 語意的成本可能太高,或者驅動程式軟體可能必須遵守傳統語意才能正確運作。 在這些情況下,您可以使用 /volatile:ms 參數;不過,若要在 ARM 目標上重新建立傳統的動態語意,編譯器必須在變數的每個讀取或寫入 volatile 周圍插入記憶體屏障,以強制執行強式排序,這可能會對效能產生負面影響。

在 x86、x64 和 ARM64EC 架構上,預設值為 /volatile:ms ,因為大部分已針對這些架構建立的軟體都依賴這些架構。 當您編譯 x86、x64 和 ARM64EC 程式時,您可以指定 /volatile:iso 參數,協助避免不必要的依賴傳統動態語意,以及提升可攜性。

另請參閱

針對 ARM 處理器設定 Visual C++