C++中的有符号数溢出和未定义行为(UB)

59

我想了解以下代码的使用

int result = 0;
int factor = 1;
for (...) {
    result = ...
    factor *= 10;
}
return result;
如果循环迭代了n次,则factor将被乘以10,恰好n次。然而,在被乘以10的总计n-1次后,factor只会被使用一次。如果我们假设factor除了在循环的最后一次迭代时可能溢出外,永远不会溢出,那么这样的代码是否可以接受?在这种情况下,factor的值在溢出发生后显然不会再被使用。

我正在就是否应该接受这样的代码进行辩论。可以将乘法放在if语句中,并且在可以溢出的最后一次循环迭代中不执行乘法。缺点是它会使代码混乱,并增加一个不必要的分支,需要在所有先前的循环迭代中进行检查。我也可以少迭代一次循环,并在循环之后再复制一次循环体,但这样会使代码变得更加复杂。

实际问题中的代码用于紧密的内部循环,占用了实时图形应用程序总CPU时间的大部分。


5
我投票关闭此问题,因为这个问题应该在https://codereview.stackexchange.com/而不是在这里。 - Kevin Anderson
31
@KevinAnderson,这里是有效的,因为示例代码需要修复,而不仅仅是改进。 - Bathsheba
1
@harold 他们非常接近了。 - Lightness Races in Orbit
1
@LightnessRaceswithMonica:标准的作者们旨在并期望实现针对不同平台和目的的语义将通过有意义地处理各种操作以对这些平台和目的有用,无论标准是否要求它们这样做,并且还声明他们不希望贬低非可移植代码。因此,问题的相似性取决于需要支持哪些实现。 - supercat
2
@supercat 如果是实现定义的行为,可以,如果您知道您的工具链有一些扩展,您可以使用(而且您不关心可移植性),那么好。针对UB?可疑的。 - Lightness Races in Orbit
显示剩余16条评论
10个回答

58

编译器假设一个有效的 C++ 程序不包含未定义行为。例如:

if (x == nullptr) {
    *x = 3;
} else {
    *x = 5;
}

如果x == nullptr,则对其进行解引用并分配值是未定义行为。因此,这个程序能够正常运行的唯一方法是当x == nullptr永远不会为真,并且编译器可以根据仿佛是规则推断出,上述代码等效于:

*x = 5;

现在在您的代码中

int result = 0;
int factor = 1;
for (...) {      // Loop until factor overflows but not more
   result = ...
   factor *= 10;
}
return result;
< p >最后的< code >factor< /code >乘法不能在有效的程序中进行(有符号溢出是未定义的)。因此,对< code >result< /code >的赋值也无法发生。由于在最后一次迭代之前没有办法分支,因此上一次迭代也无法发生。最终,正确的代码部分(即从不发生未定义行为的部分)为:< /p>
// nothing :(

7
“Undefined Behavior”是我们在Stack Overflow(一个技术问答社区)的答案中经常听到的一个表达,但未清楚地解释它如何影响整个程序。这篇答案使事情更加清晰。 - Gilles-Philippe Paillé
1
如果函数仅在目标值 INT_MAX >= 10000000000 上调用,则这甚至可能是一种“有用的优化”,在 INT_MAX 较小的情况下,可以调用不同的函数。 - R.. GitHub STOP HELPING ICE
2
@Gilles-PhilippePaillé 有时候我希望我们能在那上面贴一个帖子。 Benign Data Races 是我最喜欢的之一,因为它捕捉到了它们有多么令人讨厌。还有一个很棒的 MySQL bug 报告,但我似乎找不到了——一个缓冲区溢出检查意外地调用了 UB。特定版本的特定编译器简单地假设 UB 永远不会发生,并优化掉了整个溢出检查。 - Cort Ammon
1
@SolomonSlow:UB存在争议的主要情况是标准和实现文档描述某些操作的行为,但标准的其他部分将其描述为UB。在标准编写之前的常见做法是编译器编写者会有意义地处理这些操作,除非他们的客户从他们执行其他操作中受益,我认为标准的作者们从未想象过编译器编写者会故意做其他事情。 - supercat
2
@Gilles-PhilippePaillé: What Every C Programmer Should Know About Undefined Behavior 这篇文章来自LLVM博客,也很不错。它解释了例如有符号整数溢出UB如何让编译器证明i <= n循环总是非无限的,就像i<n循环一样。并且在循环中将int i提升为指针宽度,而不必重新进行符号处理以可能包含前4G个数组元素。 - Peter Cordes
显示剩余9条评论

37

int溢出的行为是未定义的。

如果在循环体外读取factor,则不重要;如果它已经溢出,那么你的代码在溢出后,甚至更令人矛盾地说,在溢出之前的行为是未定义的。

保留这个代码可能会引起一个问题,编译器在优化方面变得越来越激进。特别是它们正在养成一种习惯,即假设未定义的行为从不发生。为了实现这一点,它们可能会完全删除for循环。

你不能用unsigned类型来代替factor吗?不过这样做,就需要担心包含两者的表达式中int转换为unsigned的非预期转换


12
为什么不呢? - Bathsheba
12
我的回答难道没有告诉你这是有问题的吗?我的开头语句并不一定是针对提问者,而是针对更广泛的社区。而且factor被用在赋值给自身的操作中。 - Bathsheba
8
@Gilles-PhilippePaillé和这个答案解释了为什么这是有问题的。 - 463035818_is_not_a_number
1
@Bathsheba 您是正确的,我误解了您的回答。 - Gilles-Philippe Paillé
4
作为未定义行为的一个例子,当启用运行时检查编译该代码时,它将终止而不是返回结果。需要我关闭诊断功能才能正常工作的代码是有问题的。 - Simon Richter
显示剩余9条评论

28

考虑现实世界的优化器可能会很有见地。循环展开是一种已知的技术。循环展开的基本思想是:

for (int i = 0; i != 3; ++i)
    foo()

可能更好地在幕后实现为

 foo()
 foo()
 foo()

这是一个简单的情况,具有固定的边界。但是现代编译器也可以为变量边界执行此操作:

for (int i = 0; i != N; ++i)
   foo();

成为

__RELATIVE_JUMP(3-N)
foo();
foo();
foo();

显然,仅当编译器知道N≤3时,此方法才有效。这就回到了最初的问题:

int result = 0;
int factor = 1;
for (...) {
    result = ...
    factor *= 10;
}
return result;

编译器知道有符号数不会溢出,因此它知道在32位架构上循环最多可以执行9次。10^10 > 2^32。因此,它可以对循环进行9次展开。但是预期的最大迭代次数是10次!.

可能的情况是,你会得到一个相对于汇编指令(9-N)的跳转,其中N==10,所以偏移量为-1,这个跳转指令本身。哎呀。这是一个完全有效的循环优化方法,适用于定义明确的C++,但是给出的例子却成了一个紧凑的无限循环。


10

任何有符号整数溢出都会导致未定义行为,无论是否读取了溢出值。

也许在您的用例中,您可以将第一次迭代从循环中提取出来,将其转换为:

int result = 0;
int factor = 1;
for (int n = 0; n < 10; ++n) {
    result += n + factor;
    factor *= 10;
}
// factor "is" 10^10 > INT_MAX, UB

变成这样

int factor = 1;
int result = 0 + factor; // first iteration
for (int n = 1; n < 10; ++n) {
    factor *= 10;
    result += n + factor;
}
// factor is 10^9 < INT_MAX

启用优化后,编译器可能会将上述第二个循环展开为一个条件跳转。


6
这段话可能有点过于技术性,但是“带符号溢出是未定义行为”这个说法有点过于简化了。严格来说,当一个程序存在带符号溢出时,其行为是未定义的。也就是说,标准并没有告诉你这个程序会做什么。不仅仅是溢出的结果有问题,整个程序都有问题。 - Pete Becker
更简单的方法是,将最后一次迭代去除,并删除无效的 factor *= 10; - Peter Cordes

9
这是UB;在ISO C ++术语中,对于最终触发UB的执行,整个程序的行为完全未指定。经典示例是根据C ++标准,它可以让恶魔从你的鼻子里飞出来。(我建议不要使用可能导致鼻部恶魔出现的实现)。有关详细信息,请参见其他答案。
编译器可以在编译时为它们能够看到的导致编译时可见UB的执行路径“引起麻烦”,例如假设那些基本块永远不会被执行。
另请参见 What Every C Programmer Should Know About Undefined Behavior(LLVM博客)。如此解释,有符号溢出UB使编译器能够证明for(... i <= n ...)循环不是无限循环,即使n是未知的。它还允许它们将int循环计数器提升为指针宽度,而不是重新进行符号扩展。(因此,在这种情况下,UB的后果可能是访问数组的低64k或4G元素之外,如果您期望对i进行符号包装,则可能会出现这种情况。)
在某些情况下,编译器会为一个可证明会导致未定义行为的代码块发出类似于x86 ud2的非法指令。(请注意,函数可能永远不会被调用,因此编译器通常不会失控并破坏其他函数,甚至是不触发未定义行为的函数可能路径。也就是说,它编译成的机器码必须对所有不导致UB的输入都有效。)

最有效的解决方案可能是手动剥离最后一次迭代,以避免不必要的factor *= 10

int result = 0;
int factor = 1;
for (... i < n-1) {   // stop 1 iteration early
    result = ...
    factor *= 10;
}
 result = ...      // another copy of the loop body, using the last factor
 //   factor *= 10;    // and optimize away this dead operation.
return result;

如果循环体较大,考虑仅使用无符号类型的factor。然后,您可以让无符号乘法溢出,并且它将执行定义良好的包装到2的某个幂(无符号类型中的值位数)。
即使您将其与有符号类型一起使用,也可以使用此方法,特别是如果您的无符号->有符号转换永远不会溢出。
在无符号和2的补码有符号之间进行转换是免费的(所有值的位模式相同);C++标准指定的int->unsigned的模数包装简化为仅使用相同的位模式,而不像1的补码或符号/大小那样。
而 unsigned->signed 类似于微不足道,但对于大于 INT_MAX 的值,它是实现定义的。如果您没有使用上一次迭代中的巨大无符号结果,则无需担心。但是,如果您正在使用,请参见转换从无符号到有符号是否未定义?。value-doesn't-fit 情况是实现定义的,这意味着实现必须选择某些行为。理智的做法是仅在必要时将无符号位模式截断并将其用作有符号位,因为对于范围内的值,工作方式相同且无需额外工作。这绝对不是 UB。因此,大的无符号值可以变为负的有符号整数。例如,在 int x = u; 之后,gcc 和 clang 不会优化掉 x>=0,即使没有 -fwrapv,因为它们定义了行为。

2
我不明白这里的负投票。我主要想发帖关于剥离最后一次迭代。但为了回答问题,我列举了一些关于如何理解未定义行为的要点。更多细节请参见其他答案。 - Peter Cordes

5
如果您可以容忍循环中的一些额外汇编指令,而不是
int factor = 1;
for (int j = 0; j < n; ++j) {
    ...
    factor *= 10;
}

你可以书写:

int factor = 0;
for (...) {
    factor = 10 * factor + !factor;
    ...
}

为了避免最后一次乘法,!factor 不会引入分支。
    xor     ebx, ebx
L1:                       
    xor     eax, eax              
    test    ebx, ebx              
    lea     edx, [rbx+rbx*4]      
    sete    al    
    add     ebp, 1                
    lea     ebx, [rax+rdx*2]      
    mov     edi, ebx              
    call    consume(int)          
    cmp     r12d, ebp             
    jne     .L1                   

这段代码
int factor = 0;
for (...) {
    factor = factor ? 10 * factor : 1;
    ...
}

优化后也会产生无分支汇编:

    mov     ebx, 1
    jmp     .L1                   
.L2:                               
    lea     ebx, [rbx+rbx*4]       
    add     ebx, ebx
.L1:
    mov     edi, ebx
    add     ebp, 1
    call    consume(int)
    cmp     r12d, ebp
    jne     .L2

(使用GCC 8.3.0 -O3编译)


1
除非循环体较大,否则最好只是去掉最后一次迭代。这是一个巧妙的黑客技巧,但会稍微增加通过“factor”的循环依赖链的延迟。或者不会:当它编译为2x LEA时,与LEA + ADD相比,f* = 10f * 5 * 2操作效率几乎相同,并且第一个“LEA”隐藏了“test”的延迟。但是它确实在循环内部花费了额外的uops,因此可能会降低吞吐量(或至少存在超线程友好性问题)。 - Peter Cordes

4
你没有展示for语句圆括号里的内容,但我假设是这样的:
for (int n = 0; n < 10; ++n) {
    result = ...
    factor *= 10;
}

你可以将计数器的增加和循环终止条件检查放入循环体内部:

for (int n = 0; ; ) {
    result = ...
    if (++n >= 10) break;
    factor *= 10;
}

循环中的汇编指令数量将保持不变。

受安德烈·亚历山德鲁斯库的演讲“速度在人们的头脑中找到”的启发。


2

考虑以下函数:

unsigned mul_mod_65536(unsigned short a, unsigned short b)
{
  return (a*b) & 0xFFFFu;
}

根据已公布的理由,标准的作者预期,如果在 (例如) 一个普通的 32 位计算机上调用这个函数,并将参数设置为 0xC000 和 0xC000,则将操作数 * 提升为有符号整数将导致计算结果为 -0x10000000,而当转换为无符号时则会得到 0x90000000u,这与使用 unsigned short 提升为 unsigned 所得到的相同。然而,gcc 有时会优化该函数,如果发生溢出则会表现出荒谬的行为。任何一种组合的输入可能导致溢出的代码必须使用 -fwrapv 选项处理,除非允许故意格式错误输入的创建者执行其选择的任意代码。

1

未定义行为有许多不同的表现形式,可接受的取决于用途。

在实时图形应用程序中消耗大量CPU时间的紧密内部循环

这本身就有点不寻常,但即使如此...如果确实是这种情况,那么UB最可能存在于“允许、可接受”的领域。图形编程以其黑科技和丑陋的代码而闻名。只要“它有效”且生成一个帧不超过16.6ms,通常没人在意。但是,请注意调用UB的含义。

首先,有标准。从那个角度来看,没有什么可讨论的,也没有任何理由来证明你的代码是有效的。没有任何条件或时间限制,它只是无效的代码。你也许可以说这是你的中指,95-99%的时间你都会顺利过关。

下面是硬件方面。在一些不常见、奇怪的架构中,这是一个问题。我之所以说“不常见、奇怪”,是因为在占据所有计算机80%的一种架构(或者占据所有计算机95%的两种架构)上,溢出在硬件层面上是一个“是的,无论如何都不用管”的事情。你肯定会得到一个垃圾(尽管仍然可预测)的结果,但没有邪恶的事情发生。
但并非在每个架构上都是这样,你很可能会在溢出时触发陷阱(虽然看到你谈论图形应用程序,处于这样奇怪的架构的机会相当小)。可移植性是否是一个问题?如果是,你可能需要弃权。
最后,还有编译器/优化器方面的问题。溢出被定义为未定义的原因之一是,曾经在硬件上处理起来最容易。但另一个原因是,例如 x+1 肯定比 x 大,编译器/优化器可以利用这个知识。现在,对于先前提到的情况,编译器确实已知会采取这种方式,并简单地剥离完整的代码块(几年前存在一个基于编译器由于这个原因而死亡剥夺了一些验证代码的 Linux 漏洞)。
对于您的情况,我严重怀疑编译器是否进行了一些特殊的、奇怪的优化。但是,你知道什么,我知道什么。如果有疑问,请试一下。如果有效,你就可以放心使用。
(最后,当然还有代码审计,如果你不幸的话,可能要浪费时间与审计员讨论这个问题。)

1
为什么不是这样呢:

int result = 0;
int factor = 10;
for (...) {
    factor *= 10;
    result = ...
}
return result;

那不会运行 ... 循环体,其 factor = 1factor = 10 的值,只有100及以上的才会执行。如果您想让它正常工作,您必须剥离第一次迭代,并且仍要从 factor = 1 开始。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接