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(非数字)到整数类型的转换方面也有所不同。 在 ARM 上,NaN 转换为 0x00000000;在 x86 和 x64 上,它转换为 0x80000000。
仅当你知道浮点值在要转换成的整数类型的范围内时,才可以依赖浮点值转换。
移位运算符 (<<>>) 行为
在 ARM 体系结构上,在模式开始重复之前,可以将值左移或右移最多 255 位。 在 x86 和 x64 体系结构上,除非模式的来源是 64 位变量,否则模式每次都以 32 的倍数重复;而在来源是 64 位变量的情况下,模式在 x64 上每次都以 64 的倍数重复,在 x86 上每次都以 256 的倍数重复,其中采用了软件实现。 例如,值为 1 的 32 位变量左移 32 位,在 ARM 上结果为 0,在 x86 上结果为 1,在 x64 上结果也为 1。 但是,如果该值的来源是 64 位变量,则在这三个平台上的结果都为 4294967296,并且该值只有在 x64 上移动 64 位或在 ARM 和 x86 上移动 256 位时才会“回绕”。
由于未定义移位运算结果超出源类型中位数的行为,因此不要求编译器在所有情况下都具有一致的行为。 例如,如果移位的两个操作数在编译时都是已知的,则编译器可以通过以下方式来优化程序:使用内部例程预先计算移位结果,然后用它替换移位运算的结果。 如果移位量太大或为负,则内部例程的结果可能与 CPU 执行的相同移位表达式的结果不同。
变量参数 (vararg) 的行为
在 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)
之间存在依赖关系,则即使原始代码看起来不可能有依赖关系,此代码也可能依赖于特定的计算顺序。
可变关键字的默认行为
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”,因为已使用 MSVC 为这些体系结构创建的许多软件都依赖于它们。 编译 x86、x64 和 ARM64EC 程序时,可以指定 /volatile:iso 开关,以帮助避免不必要地依赖于传统可变语义,并提升可移植性。