你好ARM:探索C++中未定义的、未指定的和实现的行为

随着针对ARM设备的windowsrt的引入,许多Windows软件开发人员将第一次遇到ARM处理器。对于本机C++开发人员来说,这意味着运行损坏的未定义、未指定或实现定义的行为的可能性——如C++语言所定义的——在ARM体系结构上的表达方式与大多数Windows开发人员所熟悉的X86或X64体系结构不同。

null

在C++中,某些操作的结果,在某些情况下,根据语言规范故意模糊。在规范中,这被称为“未定义的行为”,简单地说,这意味着“任何事情都有可能发生,或者什么都没有”。你得靠自己”。一种编程语言中的这种歧义在最初看来是不可取的,但实际上它允许在如此多的不同硬件平台上支持C++而不牺牲性能。由于在这些情况下不需要特定的行为,编译器供应商可以自由地做任何合理的事情。通常,这意味着对于编译器来说最简单,或者对于目标硬件平台来说最有效。但是,由于语言无法预测未定义的行为,因此程序依赖特定平台的表达行为是错误的。至于C++规范,它是完全符合标准的行为,在另一个处理器体系结构上表现不同,在微处理器代之间发生变化,受编译器优化设置的影响,或者根本原因。

另一类行为,称为“实现定义行为”,类似于未定义的行为,因为C++语言规范没有规定特定的表达式,但不同之处在于,规范要求编译器供应商定义和记录其实现方式。这使供应商可以自由地实现他们认为合适的行为,同时 还为实现的用户提供了一个保证,即行为可以 依靠, 即使 行为可能是 非便携式或 可以在下一版本中更改 供应商的编译器。

最后,还有“未指定的行为”,它只是实现定义的行为,不需要编译器供应商对其进行记录。

所有这些未定义、未指定和实现定义的行为都会使代码从一个平台移植到另一个平台成为一个棘手的问题。即使是为一个不熟悉的平台编写新的代码,有时也会让人觉得你掉进了一个与你所认识的平台略有不同的平行世界。有些开发人员可能会例举引用C++语言规范从内存中,但对于我们其余的人来说,在可移植C++与未定义的、未指定的边界之间, 或者,由实现定义的行为可能并不总是出现在我们的脑海中。编写依赖于未定义的、未指定的 或者实现定义的行为,甚至没有意识到。

为了帮助您顺利过渡到Windows RT和ARM开发,我们编译了一些开发人员在“工作”代码中可能遇到(或偶然遇到)未定义、未指定或实现定义的行为的最常见方法,并提供了如何在ARM上表达行为的示例,X86和X64平台采用Visual C++工具链。下面的列表并非详尽无遗,尽管这些示例中引用的特定行为可以在特定的平台上演示,但在您自己的代码中不应依赖这些行为本身。我们之所以包含观察到的行为,只是因为这些信息可能有助于您识别 您自己的代码如何依赖它们。

浮点到整数转换

在ARM体系结构中,当浮点值转换为32位 整数,它饱和;也就是说,如果浮点值超出整数的范围,它将转换为整数可以表示的最近值。例如,当转换为无符号整数时,负浮点值总是转换为 0 , 以及 4294967295 如果浮点值太大,无符号整数无法表示。转换为有符号整数时,浮点值将转换为 -2147483648 如果有符号整数太小,无法表示,或 2147483637 如果它太大。在x86和x64架构上,浮点转换不会饱和;相反,如果目标类型是unsigned或设置为 -2147483648 如果目标类型已签名。

对于小于32位的整数类型,差异更为明显。所讨论的体系结构都不直接支持将浮点值转换为小于32位的整数类型,因此转换就像目标类型是32位宽一样执行, 然后截断到正确的位数。在这里,您可以看到在每个平台上将+/-50亿(5e009)转换为各种有符号和无符号整数类型的结果:

将+5e+009转换为不同大小的有符号和无符号整数的结果
+5e+009电话

手臂 32位

手臂 16位

手臂 8位

x86/x64型 32位

x86/x64型 16位

x86/x64型 8位

未签名 4294967295 65535 255 705032704 0 0
签署 +2147483647 -1 -1 -2147483648 0 0
将-5e+009转换为不同大小的有符号和无符号整数的结果
-5e+009电话

手臂 32位

手臂 16位

手臂 8位

x86/x64型 32位

x86/x64型 16位

x86/x64型 8位

未签名 0 0 0 3589934592 0 0
签署 -2147483648 0 0 -2147483648 0 0

正如你所看到的,没有简单的模式来解释发生了什么,因为饱和并不是在所有情况下都发生的,因为截断并不能保留值的符号。

还有一些值引入了更多的任意转换。在手臂上,当你转换 (非数字)将浮点值转换为整数类型,结果为 0x00000000个 . 在x86和x64上,结果是 0x80000000个 .

浮点转换的底线是,除非知道值符合目标整数类型可以表示的范围,否则不能依赖一致的结果。

轮班操作员

在ARM体系结构上,移位运算符的行为总是如同它们发生在256位模式空间中一样,而不管操作数的大小——也就是说,模式仅每256个位置重复一次或“环绕”。另一种思考方式是,将模式移位到256模的指定位置数,然后,结果当然只包含模式空间的最低有效位。

在x86和x64体系结构上,移位运算符的行为既取决于操作数的大小,也取决于代码是针对x86还是x64编译的。在x86和x64上,大小为32位或更小的操作数的行为相同–模式空间每32个位置重复一次。但是,在为x86和x64体系结构编译时,大小大于32位的操作数表现不同。因为x64体系结构有一个用于移位64位值的指令,所以编译器发出该指令来执行移位;但是x86体系结构没有64位移位指令,因此编译器会发出一个小的软件例程来移位64位 而不是操作数。此例程的模式空间每256个位置重复一次。因此, x86平台的行为与其x64同级平台不太相似 在移位64位操作数时更像ARM。

宽度 每个架构上的模式空间:
可变大小 手臂 x86个 x64个
8 256 32 32
16 256 32 32
32 256 32 32
64 256 256 64

让我们看一些例子。请注意,第一个表中的x86和x64列是相同的,而第二个表中的x86和ARM列是相同的。

给定值为1的32位整数:
移位量 手臂 x86个 x64个
0 1 1 1
16 32768 32768 32768
32 0 1 1
48 0 32768 32768
64 0 1 1
96 0 1 1
128 0 1 1
256 1 1 1
给定值为1的64位整数:
移位量 手臂 x86个 x64个
0 1 1 1
16 32768 32768 32768
32 4294967296 4294967296 4294967296
48 2^48 2^48 2^48
64 0 0 1
96 0 0 4294967296
128 0 0 1
256 1 1 1

为了帮助您避免这个错误,编译器发出 警告C4295 让您知道您的代码使用的移位太大(或负)而不安全,但前提是移位量是常量或文本值。

“易失性”行为

在ARM体系结构中,内存模型是弱有序的。这意味着一个线程按顺序观察自己对内存的写操作,但是其他线程对内存的写操作可以按任何顺序观察,除非采取其他措施来同步线程。另一方面,x86和x64体系结构具有强有序的内存模型。这意味着一个线程同时观察自己的内存写操作,以及其他线程的内存写操作,按照写操作的顺序进行。换句话说,强有序体系结构保证,如果一个线程B将一个值写入位置X,然后再次写入位置Y,那么另一个线程a在看到X的更新之前将不会看到Y的更新。弱有序内存模型不能保证这一点。

这与易失性变量的行为交叉的地方是,结合x86和x64的强有序内存模型,过去有可能(ab)将易失性变量用于某些类型的进程间通信。这是微软编译器中volatile关键字的传统语义,许多软件依赖这些语义来运行。然而,C++ 11语言规范并不要求这样的内存访问在线程之间是强排序的,因此在可移植的、符合标准的代码中依赖这种行为是错误的。

为此,微软C++编译器现在 支持对volatile storage限定符的两种不同解释,您可以通过使用编译器开关进行选择。 /volatile:iso 选择不保证强排序的严格C++标准易失性语义。 /volatile:ms 选择Microsoft扩展的volatile语义,以确保强排序。

因为 /volatile:iso 实现C++标准的易失性语义,可以打开更大的优化门,这是一个最佳实践 /volatile:iso 只要有可能,在需要时与显式线程同步原语结合使用。 /volatile:ms 仅当程序依赖于扩展的强顺序语义时才需要。

这就是事情变得有趣的地方。

在ARM架构中,默认值是 /volatile:iso 因为ARM软件没有依赖扩展语义的遗留问题。但是,在x86和x64体系结构上,默认值是 /volatile:ms 因为过去很多使用微软编译器编写的x86和x64软件都依赖于扩展语义。更改 默认为 /volatile:iso 因为x86和x86会以微妙和意想不到的方式悄悄地破坏软件。

尽管如此,使用 /volatile:ms 语义——例如,重写程序使用显式同步原语的成本可能太高。但要注意,为了实现 /volatile:ms 在ARM体系结构的弱有序内存模型中,编译器必须在程序中插入显式内存屏障,这会增加大量的运行时开销。

同样,不依赖扩展语义的x86和x64代码 应使用 /volatile:iso 为了 确保 更大的便携性和 释放编译器以执行更激进的优化。

参数求值顺序

依赖于以特定顺序评估的函数调用参数的代码在任何体系结构上都是错误的,因为C++标准表示函数参数被评估的顺序未指定。这意味着,对于给定的函数调用 F(A,B) ,不可能知道 A B 将首先进行评估。事实上,即使使用相同的编译器针对相同的体系结构,调用约定和优化设置之类的事情也会影响计算顺序。

虽然该标准未指定此行为,但实际上,编译器根据目标体系结构的属性、调用约定、优化设置和其他因素确定求值顺序。当这些因素保持稳定时,不经意间依赖于特定求值顺序的代码可能会在相当长的一段时间内被忽略。但还是一样 代码待命,你呢 可能会改变评估顺序, 导致它破裂。

幸运的是,许多开发人员已经意识到参数的求值顺序是未指定的,并且注意不要依赖它。即使如此,它仍然可以在一些不直观的地方潜入代码,比如成员函数或重载运算符。这两种构造都由编译器转换为常规函数调用,并以未指定的求值顺序完成。以下面的代码示例为例:

 Foo foo;  foo->bar(*p);

这看起来很明确,但如果 -> * 实际上是重载运算符吗?然后,此代码扩展为如下内容:

 Foo::bar(operator->(foo), operator*(p));

因此,如果 操作员->(foo) 操作员*(p) 以某种方式进行交互,此代码示例可能 依赖于特定的 评估顺序,即使 乍一看就会出现 那个 条形图() 只有一个论点。

可变参数

在ARM架构中,所有的加载和存储都是对齐的。即使是堆栈上的变量也要进行对齐。这与x86和x64不同,在x86和x64上没有对齐要求和 变量紧紧地堆积在堆栈上。对于局部变量和正则参数,类型系统很好地将开发人员与此细节隔离开来。但是对于变量函数-那些接受可变数量参数的函数-附加参数实际上是无类型的,并且 开发商不再与路线细节绝缘。

这个代码示例实际上是一个bug,与平台无关。但这次讨论的有趣之处在于 x86和x64体系结构所表达的行为恰好使代码能够像开发人员可能希望的那样为潜在值的子集发挥作用,而在ARM体系结构上运行的相同代码总是产生错误的结果。下面是一个使用 cstdio公司 功能 打印F :

 // note that a 64-bit integer is being passed to the function, but '%d' is being used to read it. // on x86 and x64, this may work for small values since %d will "parse" the lower 32 bits of the argument. // on ARM, the stack is padded to align the 64-bit value and the code below will print whatever value // was previously stored in the padded position. printf("%d", 1LL);

在这种情况下,可以通过确保使用正确的格式规范来纠正错误,从而确保考虑了参数的对齐方式。以下代码正确:

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

结论

由ARM处理器驱动的WindowsRT是一个令人兴奋的Windows开发人员的新平台。 希望这篇博文揭示了一些微妙的可移植性 这些问题可能隐藏在您自己的代码中,并使您更容易将代码带到WindowsRT和Windows应用商店中。

你有问题,反馈, 或者你自己的便携技巧?请留言!

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享