このドキュメントでは、x86 または x64 アーキテクチャから ARM アーキテクチャにコードを移行するときに発生する可能性がある一般的な問題について説明します。 また、これらの問題を回避する方法と、コンパイラを使用してそれらを識別する方法についても説明します。
注
この記事で ARM アーキテクチャを参照する場合は、ARM32 と ARM64 の両方に適用されます。
移行に関する問題の原因
x86 または x64 アーキテクチャから ARM アーキテクチャにコードを移行するときに発生する可能性のある多くの問題は、未定義、実装定義、または未指定の動作を呼び出す可能性があるソース コードのコンストラクトに関連しています。
未定義の動作 は、C++ 標準で定義されていない動作であり、これは、浮動小数点値を符号なし整数に変換したり、昇格された型のビット数を負の数または超える位置に値をシフトしたりするなど、妥当な結果を持たない操作によって発生します。
"実装定義の動作" は、コンパイラ ベンダーが定義して文書化するように C++ の標準で求められている動作です。 プログラムでは、実装定義の動作に依存しても安全です。ただし、そうすることで移植可能でなくなる場合があります。 実装定義の動作の例としては、組み込みデータ型のサイズとそのアラインメント要件があります。 実装定義の動作によって影響を受ける可能性がある操作の例として、可変長引数リストへのアクセスがあります。
指定されていない動作 は、C++ 標準が意図的に非決定的な動作を残す動作です。 動作は非決定的と見なされますが、指定されていない動作の特定の呼び出しはコンパイラの実装によって決定されます。 ただし、コンパイラ ベンダーが結果を事前に決定したり、同等の呼び出し間で一貫した動作を保証したりする必要はなく、ドキュメントの要件もありません。 未指定の動作の例として、関数呼び出しの引数を含むサブ式が評価される順序があります。
移行に関するその他の問題は、C++ 標準との対話方法が異なるという、ARM と x86 または x64 アーキテクチャのハードウェアの違いに起因します。 たとえば、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 (非数) から整数型への変換を処理する方法にも違いがあります。 ARM では、NaN は0x00000000 に変換されますが、x86 と x64 では、0x80000000 に変換されます。
浮動小数点の変換は、値が変換先の整数型の範囲内にあることがわかっている場合にのみ信頼できます。
シフト演算子 (<<
>>
) の動作
ARM アーキテクチャでは、パターンの繰り返しが開始されるまで、値を左または右に 255 ビットまでシフトできます。 x86 および x64 アーキテクチャでは、パターンのソースが 64 ビット変数でない限り、パターンは 32 の倍数ごとに繰り返されます。 その場合、x64 では 64 の倍数ごとに、x86 では 256 の倍数ごとにパターンが繰り返され、そこでソフトウェアの実装が採用されます。 たとえば、値 1 がで左に 32 シフトされた 32 ビット変数の場合、ARM では結果は 0 になり、x86 では結果は 1 になります。x64 の場合も結果は 1 になります。 ただし、値のソースが 64 ビットの変数の場合、3 つのすべてのプラットフォームでの結果は 4294967296 になります。また、値は、64 個 (x64 の場合) または 256 個 (ARM と x86 の場合) シフトされるまで "ラップ" されません。
ソース型のビット数を超えるシフト操作の結果は未定義であるため、コンパイラはすべての状況で一貫した動作を行う必要はありません。 たとえば、シフトの両方のオペランドがコンパイル時にわかっている場合、コンパイラは、内部ルーチンを使用してシフトの結果を事前計算してから、シフト演算の場所に結果を代入することで、プログラムを最適化できます。 シフト量が大きすぎる場合、または負の場合は、内部ルーチンの結果が、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);
この場合、引数のアラインメントが考慮されるように正しいフォーマット指定が使用されるようにすることで、バグを修正できます。 このコードは正しいものです。
// 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
キーワードの既定の動作
MSVC コンパイラでは、コンパイラ スイッチを使用して指定できる volatile
ストレージ修飾子の 2 つの異なる解釈がサポートされています。
/volatile:ms スイッチは、厳密な順序付けを保証する Microsoft 拡張 volatile セマンティクスを選択します。これは、x86 と x64 の強力なメモリ モデルにより、これらのアーキテクチャの従来のケースでした。
/volatile:iso スイッチは、強力な順序付けを保証しない厳密な C++ 標準 volatile セマンティクスを選択します。
ARM アーキテクチャ (ARM64EC を除く) では、ARM プロセッサには弱い順序のメモリ モデルがあり、ARM ソフトウェアには /volatile:ms の拡張セマンティクスに依存するレガシがないため、既定値は /volatile:iso であり、通常はそうするソフトウェアとインターフェイスする必要がないためです。 ただし、拡張されたセマンティクスを使用するように ARM プログラムをコンパイルすることが便利、または必要な場合もあります。 たとえば、ISO C++ セマンティクスを使用するようにプログラムを移植するにはコストがかかりすぎる場合や、ドライバー ソフトウェアが正常に機能するため、従来のセマンティクスに従う必要がある場合があります。 このような場合は、/volatile:ms スイッチを使用できます。ただし、ARM ターゲットに対して従来の volatile セマンティクスを再作成するには、コンパイラによって volatile
変数のすべての読み取りまたは書き込みの周りにメモリ バリアが挿入され、厳密な順序付けが強制される必要があります。これにより、パフォーマンスに悪影響を及ぼす可能性があります。
x86、x64、およびARM64EC アーキテクチャでは、MSVC を使用してこれらのアーキテクチャ用に既に作成されているソフトウェアの多くが依存しているため、既定値は /volatile:ms です。 x86、x64、およびARM64EC プログラムをコンパイルするときに、従来の揮発性セマンティクスへの不要な依存を回避し、移植性を高めるために、 /volatile:iso スイッチを指定できます。