【问题标题】:Why does left shift operation invoke Undefined Behaviour when the left side operand has negative value?为什么当左侧操作数为负值时,左移操作会调用未定义行为?
【发布时间】:2011-04-16 15:31:27
【问题描述】:

在 C 中,当左侧操作数为负值时,按位左移操作调用未定义行为。

来自 ISO C99 (6.5.7/4) 的相关引用

E1 E2,减少模 比结果类型中可表示的最大值大一。如果 E1 有一个签名 类型和非负值,并且 E1 × 2E2 在结果类型中是可表示的,那么就是 结果值;否则,行为未定义

但在 C++ 中,行为是明确定义的。

ISO C++-03 (5.8/2)

E1

意思是

int a = -1, b=2, c;
c= a << b ;

在 C 中调用未定义行为,但该行为在 C++ 中已明确定义。

是什么迫使 ISO C++ 委员会认为这种行为与 C 中的行为相反?

另一方面,当左操作数为负时,按位右移操作的行为是implementation defined,对吧?

我的问题是为什么左移操作调用 C 中的未定义行为,为什么右移操作符只调用实现定义的行为?

P.S:请不要给出“这是未定义的行为,因为标准是这样说的”这样的答案。 :P

【问题讨论】:

  • C 和 C++ 是由不同委员会标准化的不同语言。我看不出有什么令人惊讶的地方。
  • 此外,C++ 基于 C89/C90。 C 委员会随后朝着 C99 的不同方向发展。 C99 和 C++ 都基于原始的 C 标准,但分歧根本不协调。
  • 您的 C++ 引用仅定义类型为无符号时的行为。您是否忘记复制有关带符号值的段落?
  • @R.. 文本定义了第一句签名的行为。然后,它进一步详细说明了其他句子中未签名的行为。
  • 我看到早期的标准有这种差异,但 C99 和 C++11 标准对于有符号和无符号整数类型的左移和右移都要求相同的行为。

标签: c++ c language-lawyer undefined-behavior bit-shift


【解决方案1】:

您复制的段落是关于无符号类型的。行为在 C++ 中未定义。来自上一个 C++0x 草案:

E1的值E2,再模减一 比可表示的最大值 在结果类型中。否则,如果 E1 有符号类型且非负数 值,并且 E1×2E2 可以表示为 结果类型,那就是 结果值; 否则, 行为未定义

编辑:看看 C++98 论文。它根本没有提到签名类型。所以它仍然是未定义的行为。

右移负数是实现定义的,对。为什么?在我看来:实现定义很容易,因为左边的问题没有截断。当您向左移动时,您不仅必须说出从右侧移动的内容,还必须说出其余位发生的情况,例如用二进制补码表示,这是另一回事。

【讨论】:

  • 此段落既不在C++03 中也不在C++98 中。
  • @Prasoon Saurav:该段是当前 C++0x 最终草案的一部分,它表明 C++ 标准委员会认为这是当前标准中的一个缺陷,并通过实际说明来修复它它是未定义的——而不是隐含地不定义结果。
  • @David “编辑:看看 C++98 论文。它根本没有提到签名类型。所以它仍然是未定义的行为。”我不同意这种解释。 “E1
  • @JohannesSchaub-litb:由于 5p5,它明确未定义:“如果在计算表达式期间,结果未在数学上定义 或不在其类型的可表示值范围内,行为未定义”。您是正确的,第一部分适用于所有类型,第二部分强制对无符号类型进行操作以生成可表示的值,因为否则,如果任何位溢出,无符号左移也会导致未定义的行为。
【解决方案2】:

在 C 中按位左移操作调用未定义行为时 左侧操作数具有负值。 [...] 但是在 C++ 中,行为是明确定义的。 [...] 为什么[...]

简单的答案是:因为标准是这样说的。

更长的答案是:这可能与 C 和 C++ 都允许除 2 的补码之外的其他负数表示的事实有关。对将要发生的事情提供更少的保证,可以在其他硬件(包括晦涩和/或旧机器)上使用这些语言。

出于某种原因,C++ 标准化委员会想为位表示如何变化添加一点保证。但是由于负数仍然可以通过 1 的补码或符号+数量来表示,因此结果值的可能性仍然不同。

假设 16 位整数,我们将有

 -1 = 1111111111111111  // 2's complement
 -1 = 1111111111111110  // 1's complement
 -1 = 1000000000000001  // sign+magnitude

左移3,得到

 -8 = 1111111111111000  // 2's complement
-15 = 1111111111110000  // 1's complement
  8 = 0000000000001000  // sign+magnitude

是什么迫使 ISO C++ 委员会很好地考虑了这种行为 定义为与 C 中的行为相反?

我猜他们做出了这样的保证,以便您在知道自己在做什么时(即当您确定您的机器使用 2 的补码时)可以适当地使用

另一方面,行为是按位定义的实现 左操作数为负数时右移操作,对吧?

我必须检查标准。但你可能是对的。在 2 的补码机器上没有符号扩展的右移并不是特别有用。因此,当前状态肯定比要求空位为零填充更好,因为它为进行符号扩展的机器留出了空间——即使不能保证。

【讨论】:

  • 编写标准时的目标之一是尽可能确保,如果任何实现在特定情况下做了有用的事情,则应允许符合要求的实现表现同样。实现可能有用地以标准管辖范围之外的方式陷入困境的情况被标记为调用未定义行为。 C 标准的作者可以想象,当左移至少一些负值时,某些实现可能会陷入陷阱,并且有人可能会发现这很有用,因此该行为未定义。
  • 一些现有的实现在右移时用零填充,而另一些则用符号扩展,并且因为为以前的实现编写的一些代码可能依赖于行为,所以它被保留为实现定义。我认为 C++ 委员会在他们意识到虽然某些平台可能会在左移负值时陷入陷阱的情况下修复了左移行为,但实际上没有人这样做,并且允许未来的实现开始没有任何好处这样做。
【解决方案3】:

如标题中所述回答您的真正问题:对于有符号类型的任何操作,如果数学运算的结果不适合目标类型(不足或溢出),则此行为未定义。有符号整数类型就是这样设计的。

对于左移运算,如果值为正数或0,则将运算符定义为2的幂次乘法是有道理的,所以一切正常,除非结果溢出,这不足为奇。

如果该值为负数,您可以对乘以 2 的幂有相同的解释,但如果您只考虑位移位,这可能会令人惊讶。显然,标准委员会希望避免这种歧义。

我的结论:

  • 如果你想做真正的位模式 操作使用无符号类型
  • 如果你想乘以一个 值(签名与否)乘以 2 的幂,只做 那个,像

    i * (1u

无论如何,您的编译器都会将其转换为体面的汇编器。

【讨论】:

  • 设置二进制补码的符号位相当于向左设置无限位。 32 位数字中可表示的值是第 31 位左侧的所有位都具有相同值的值。移位负二进制补码值并没有什么异常或异常,除非存在超出符号位的值其状态与符号位不匹配的情况。
【解决方案4】:

许多此类事情是在普通 CPU 可以在单个指令中实际支持的内容与足以期望编译器编写者保证即使需要额外指令的有用内容之间取得平衡。通常,使用位移运算符的程序员希望它们映射到具有此类指令的 CPU 上的单个指令,这就是为什么存在未定义或实现行为的原因,其中 CPU 对“边缘”条件进行了各种处理,而不是强制执行行为并进行操作出乎意料的慢。请记住,即使对于更简单的用例,也可能会制作额外的前/后或处理说明。如果某些 CPU 生成陷阱/异常/中断(不同于 C++ 的 try/catch 类型异常)或通常无用/莫名其妙的结果,则可能需要未定义的行为,而如果标准委员会当时考虑的 CPU 集均提供于至少一些定义的行为,然后他们可以定义行为实现。

【讨论】:

  • 据我所知,在某些 CPU 上,左移 N 指令将执行 N 次移位。如果 N 是一个持有 -1 的 long,那将需要大约 40 亿个周期才能完成。拥有一条通常需要几微秒的指令而不是将 CPU 锁定几分钟就足以产生奇怪的副作用,将其视为“未定义的行为”是公平的,而不仅仅是说该值是“实现-定义”,特别是因为一条指令执行这么长时间可能会导致看门狗之类的东西重置 CPU。
  • 好吧,多亏了 as-if 规则,编译器只需在这样的架构上添加与数字中的位数一样多的移位指令。因此对于 64 位数字,它最多可以将其实现为 64 次移位(或者设置为 0 或最多移位 63,具体取决于编译器选择实现它的方式)。
  • 不幸的是,自从你写了上面的内容之后,情况发生了变化。即使在带有左移指令的处理器上运行时,其行为与二进制补码算术中的预期完全一样,超现代编译器哲学表明没有理由让这种左移的行为遵守以下定律时间和因果关系。现代哲学规定,给定if (x &gt;= 0) launch_missiles(); x&lt;&lt;=1;,编译器应该认识到,如果 x 为负数,它可以做任何它喜欢做的事情,因此它可以无条件地发射导弹。
  • 就个人而言,我觉得这种超现代的思想令人痛苦。跳转表的场景最多只能处理 63 个并且超出此范围的任何内容都会下降,这可能是一个合理的借口,但屏蔽在最坏的情况下会在最好的情况下为 4-5 指令序列添加一条指令。
【解决方案5】:

我的问题是为什么左移操作调用 C 中的未定义行为,为什么右移操作符只调用实现定义的行为?

LLVM 的人员推测,由于指令在各种平台上的实现方式,移位运算符受到限制。来自What Every C Programmer Should Know About Undefined Behavior #1/3

...我的猜测是,这是因为各种 CPU 上的底层移位操作对此做了不同的事情:例如,X86 将 32 位移位量截断为 5 位(因此 32 位移位是相同的作为 0 位的移位),但 PowerPC 将 32 位的移位量截断为 6 位(因此 32 的移位产生零)。由于这些硬件差异,C 完全未定义行为...

Nate 讨论的是关于移位大于寄存器大小的数量。但这是我发现的最接近权威的解释转变限制的方法。

认为第二个原因是 2 的恭维机器上的潜在符号变化。但我从来没有在任何地方读过它(@sellibitze 无意冒犯(我碰巧同意他的观点))。

【讨论】:

  • 您似乎在讨论 right 操作数的签名;这个问题只看左边的。
【解决方案6】:

在 C89 中,在二进制补码平台上明确定义了左移负值的行为,该平台没有在有符号和无符号整数类型上使用填充位。有符号和无符号类型的共同值位必须位于相同的位置,而有符号类型的符号位唯一可以放在的位置与无符号类型的上位值位相同,这反过来又不得不位于其他所有事物的左侧。

C89 强制行为对于没有填充的二进制补码平台是有用且明智的,至少在将它们视为乘法不会导致溢出的情况下。该行为在其他平台上或在寻求可靠地捕获有符号整数溢出的实现上可能不是最佳的。 C99 的作者可能希望在 C89 强制行为不太理想的情况下允许实现灵活性,但基本原理中没有任何内容表明质量实现不应该继续以旧方式运行的意图没有令人信服的理由不这样做。

不幸的是,尽管 C99 从未有任何不使用补码数学的实现,但 C11 的作者拒绝定义常见情况(非溢出)行为; IIRC,声称这样做会阻碍“优化”。让左移运算符在左操作数为负数时调用未定义行为允许编译器假设只有当左操作数为非负数时才能进行移位。

我怀疑这种优化真正有用的频率,但这种有用性的稀有性实际上有利于让行为未定义。如果二进制补码实现不会以普通方式表现的唯一情况是优化实际上有用的情况,并且如果实际上不存在这种情况,那么无论是否有授权,实现都会以普通方式表现,并且没有需要强制行为。

【讨论】:

  • 但是会触发 UB 的是 return x&lt;&lt;4; 行,编译器几乎无法更改该行之前代码的明确定义的语义。我对-O2-O3 都进行了测试,至少 gcc 没有执行您建议的优化。
  • @BjörnLindqvist:gcc 的当前主线版本并没有像标准允许的那样积极地执行死代码消除,但是标准中添加了语言,明确指出如果执行代码给定的输入将导致未定义的行为,那么标准对程序的任何行为都没有要求,即使在 UB 发生之前也是如此。我个人认为,如果目前 UB 的大部分内容都受到足够的限制,那么标准会好得多,以至于有可能满足以下要求的程序......
  • "(1) 给定有效输入时,产生有效输出;(2) 即使给定无效输入,也要尊重时间和因果规律",即使发生算术溢出等情况也能满足这样的要求,但是该标准没有规定这样的要求。
  • @BjörnLindqvist:只要编译器能够确定 UB 对于它要接收的输入是不可避免的,它就可以开始做它想做的任何事情。允许 UB 在一定程度上免除 时间 的规律是合理的,因为它允许编译器执行诸如提升循环不变表达式之类的事情,而不必首先验证它们不会触发溢出。然而,标准的语言并没有限制编译器可以“利用”未定义行为的程度,一些作者试图最大限度地利用这种机会。
  • @BjörnLindqvist:根据我的阅读,似乎负数的左移最初是未定义的行为,以允许在某个地方可能存在一台机器会触发陷阱;鉴于缺乏此类机器曾经存在过的证据,委员会考虑更改规范以使其仅产生未指定的值,但编译器作者反对该更改,称其将“阻碍优化”。我的回答可能过于刻薄,但鉴于编译器研究人员正在努力寻找修剪代码的方法,这将......
【解决方案7】:

C++03 中的行为与 C++11 和 C99 中的行为相同,您只需要超越左移规则即可。

标准第 5p5 节规定:

如果在计算表达式期间,结果未在数学上定义或不在其类型的可表示值范围内,则行为未定义

在 C99 和 C++11 中被特别称为未定义行为的左移表达式与计算结果超出可表示值范围的相同。

事实上,关于使用模运算的无符号类型的语句专门用于避免生成超出可表示范围的值,这将自动成为未定义的行为。

【讨论】:

  • 在二进制补码概念中,-1 的按位表示是 ...111[111].000... 计算机通常只存储中间部分,将 MSB 复制到左侧,并且用零填充右边;向左移动一位应该给...111 [110] .000 ...即-2。在一个补码表示法中,-1 是 ...111[110].111... 计算机存储中间部分并在两侧复制最左边的位。将左移一个应该给出 ...[101].111...,即 -2,尽管某些实现可能会移入零而不是复制符号位。
  • 无论如何,任一操作的结果都应该在指定类型的范围内。只有符号幅度系统才会有真正的问题。
【解决方案8】:

移位的结果取决于数字表示。仅当数字表示为二进制补码时,移位的行为类似于乘法。但问题并不只存在于负数。考虑一个以超过 8 表示的 4 位有符号数(也称为偏移二进制)。数字 1 表示为 1+8 或 1001 如果我们将其作为位左移,我们得到 0010 这是-6的表示。类似地,-1 表示为 -1+8 0111 变成 1110 左移时,+6 的表示。位行为是明确定义的,但数值行为高度依赖于表示系统。

【讨论】:

  • 我看到我收到了一些关于这篇文章的负面评价。我预计这是由于与规范中的语句“E1
  • 在 C89 下,您的陈述是正确的。然而,C99 同时添加了一个明确的声明,即左移一个负值会产生未定义的行为,同时有效地禁止在字长为 64 位或更少的机器上执行二进制补码以外的任何操作(据我所知,非人为的非二进制补码 C99 实现的数量为零)。
  • @Prasoon Saurav 这不是 C++20 标准的正确答案吗?
猜你喜欢
  • 2023-03-04
  • 2014-04-07
  • 2011-09-03
  • 2011-03-12
  • 2018-10-19
相关资源
最近更新 更多