共用方式為


常見的 Microsoft C++ ARM 移轉問題

本文件說明將程式代碼從 x86 或 x64 架構移轉至 ARM 架構時可能會遇到的一些常見問題。 它也會說明如何避免這些問題,以及如何使用編譯程式來協助識別這些問題。

備註

當本文參考 ARM 架構時,它會同時套用至 ARM32 和 ARM64。

移轉問題的來源

將程式代碼從 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 上,在堆疊上傳遞的參數不受對齊限制,並且緊密排列。 如果變數參數清單的預期版面配置不完全相符,可能會導致像 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->(memory_handle)operator*(p) 之間存在相依性,那麼程式碼可能會依賴特定的評估順序,即使原始程式碼看起來不像有可能的相依性。

volatile 關鍵字預設行為

Microsoft C++(MSVC)編譯器支援兩種不同的儲存體限定詞解譯,您可以使用編譯器開關來指定。 /volatile:ms 參數會選擇 Microsoft 擴展的 volatile 語意,以保證強排序,這對於 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,因為大部分使用 MSVC 為這些架構所建立的軟體都依賴於此。 當您編譯 x86、x64 和 ARM64EC 程式時,您可以指定 /volatile:iso 參數,協助避免不必要的依賴傳統動態語意,以及提升可移植性。

另請參閱

為 ARM 處理器設定 Microsoft C++