C ++中的签名溢出和未定义的行为(UB)


56

我想知道以下代码的使用

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

如果循环是随着n时间反复进行的,则将factor其乘以10精确的n时间。但是,factor仅在相乘10了总n-1次数后才使用。如果我们假设factor除了在循环的最后一次迭代中永远不会溢出,而是可能在循环的最后一次迭代中溢出,那么这样的代码是否可以接受?在这种情况下,factor证明溢出后永远不会使用的值。

我正在就是否应接受此类代码进行辩论。可以将乘法放在if语句中,并且在可能溢出时,不对循环的最后一次迭代进行乘法。缺点是它会使代码混乱,并添加了一个不必要的分支,需要在所有先前的循环迭代中进行检查。我还可以减少循环迭代一次,并在循环之后复制一次循环主体,这又使代码复杂化。

有问题的实际代码在一个紧密的内部循环中使用,该循环在实时图形应用程序中消耗大量的总CPU时间。


5
我投票关闭这个问题为离题,因为这个问题应该在codereview.stackexchange.com上,而不是在这里。
凯文·安德森

31
@KevinAnderson,不,在这里是有效的,因为示例代码是固定的,而不仅仅是改进的。
Bathsheba,

1
@harold他们在靠近。
Lightness Races in Orbit

1
@LightnessRaceswithMonica:该标准的作者希望并希望针对各种平台和目的的实现将以对那些平台和目的有用的方式有意义地处理各种动作,从而扩展程序员可用的语义,无论该标准是否要求他们这样做,并且还表示他们不希望贬低非便携式代码。因此,问题之间的相似性取决于需要支持哪些实现。
超级猫

2
@supercat可以肯定地实现定义的行为,并且如果您知道您的工具链具有某些扩展名,则可以使用(并且您不关心可移植性),很好。对于UB?疑。
Lightness Races in Orbit

Answers:


51

编译器确实假定有效的C ++程序不包含UB。考虑例如:

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

如果是,x == nullptr那么取消引用它并分配一个值是UB。因此,可以在有效程序中结束的唯一方法是何时x == nullptr永远不会产生true,并且编译器可以假设在if规则下,上述等效于:

*x = 5;

现在在您的代码中

int result = 0;
int factor = 1;
for (...) {      // Loop until factor overflows but not more
   result = ...
   factor *= 10;
}
return result;

factor在有效程序中不能发生的最后一个乘法(未定义有符号溢出)。因此,分配result也不会发生。由于无法在最后一次迭代之前进行分支,因此先前的迭代也不会发生。最终,正确的代码部分(即,从未发生未定义的行为)是:

// nothing :(

6
“不确定行为”是一个我们在SO答案中听到很多的表达,而没有明确说明它如何影响整个程序。这个答案使事情变得更加清晰。
吉尔斯·菲利普·派瑞

1
如果仅在具有的目标上INT_MAX >= 10000000000调用该函数,而在INT_MAX较小的情况下调用另一个函数,则这甚至可能是“有用的优化” 。
R .. GitHub停止帮助ICE,

2
@Gilles-PhilippePaillé有时候我希望我们可以在上面贴个帖子。 “良性数据竞赛”是我最喜欢的捕获它们的讨厌程度之一。MySQL中还有一个很棒的错误报告,我似乎再也找不到了-缓冲区溢出检查意外地调用了UB。特定编译器的特定版本只是假定UB永远不会发生,并优化了整个溢出检查。
科特·阿蒙

1
@SolomonSlow:UB引起争议的主要情况是,标准和实施文档的某些部分描述了某些操作的行为,而标准的其他部分将其描述为UB。在制定该标准之前,编译器作者通常会有意义地处理此类操作,除非他们的客户将从他们的其他事情中受益,而我也不认为该标准的作者曾想到编译器作者会故意做其他任何事情。 。
超级猫

2
@Gilles-PhilippePaillé:每个C程序员应该从LLVM博客了解有关未定义行为的知识也很不错。它解释了例如有符号整数溢出UB如何使编译器证明i <= n循环始终是非无限的,例如i<n循环。并int i在循环中提升指针宽度,而不必重做符号以将可能的换行数组索引到第一个4G数组元素。
彼得·科德斯

34

int溢出的行为是不确定的。

您是否factor在循环体之外阅读也没关系;如果它已经溢出了,那么未定义溢出之前,之后和之前有些矛盾的代码行为。

保留此代码可能出现的一个问题是,在进行优化时,编译器变得越来越激进。特别是,他们正在养成一种习惯,即他们假设从未发生过未定义的行为。对于这种情况,他们可以for完全删除循环。

你不能使用unsigned类型factor虽然那么你需要担心的不必要的转换int,以unsigned包含两个表达式?


12
@nicomp; 为什么不?
Bathsheba,

12
@Gilles-PhilippePaillé:我的回答不是告诉您这有问题吗?我的开场白不一定是OP所必需的,而是更广泛的社区,并且factor在分配给自己的作业中被“使用”。
Bathsheba,

8
@Gilles-PhilippePaillé,这个答案解释了为什么它有问题
idclev 463035818

1
@Bathsheba你是对的,我误会了你的答案。
吉尔斯·菲利普·派瑞

4
作为未定义行为的示例,当在启用了运行时检查的情况下编译该代码时,它将终止而不是返回结果。要求我关闭诊断功能才能工作的代码已损坏。
西蒙·里希特

23

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

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时有效。这就是我们回到原始问题的地方。因为编译器知道不会发生带符号的溢出,所以它知道循环在32位体系结构上最多可以执行9次。10^10 > 2^32。因此,它可以进行9次迭代循环展开。但是预期的最大值是10次迭代!

可能发生的情况是,您获得了相对跳转到N = 10的汇编指令(9-N),因此偏移量为-1,这是跳转指令本身。哎呀 对于定义良好的C ++,这是完全有效的循环优化,但是给出的示例变成了紧密的无限循环。


9

任何有符号整数溢出都会导致未定义的行为,而不管是否已读取或可能会读取溢出的值。

也许在您的用例中,您可以将第一个迭代移出循环,将其

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
这可能有点技术过高,但是“带符号的溢出是未定义的行为”被简化了。形式上,带有签名溢出的程序的行为是不确定的。也就是说,该标准不会告诉您该程序的功能。溢出的结果不仅有问题,还在于 整个程序有问题。
皮特·贝克尔,

公正的观察,我已经纠正了答案。
elbrunovsky '19

或者更简单,剥开最后一次迭代和去除死factor *= 10;
彼得·科德斯

9

这是UB;用ISO C ++术语来说,整个程序的整个行为是完全不确定的,因为它最终会击中UB。就C ++标准而言,最经典的例子是它可以使恶魔从你的鼻子里飞出来。(我建议您不要使用可能存在鼻恶魔的实现方式)。请参阅其他答案以获取更多详细信息。

编译器可能会在编译时为执行路径“造成麻烦”,从而看到编译路径可见的UB,例如,假定从未到达那些基本块。

另请参见每个C程序员应了解的未定义行为(LLVM博客)。如此处所述,带符号溢出的UB可使编译器证明for(... i <= n ...)循环不是无限循环,即使对于未知循环也是如此n。它还允许他们将int循环计数器“提升”为指针宽度,而不是重做符号扩展。(因此,在这种情况下,如果您希望将UB的带符号包装i放入其值范围内,则UB的结果可能是访问数组的低64k或4G元素之外。)

在某些情况下,编译器将ud2针对某个块发出x86之类的非法指令,如果执行了该块,可证明会导致UB。(请注意,功能可能不会永远被调用,所以编译器一般不能发狂,并通过不打UB功能突破等功能,甚至可能路径。即机器代码,它编译成仍须工作所有不会导致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的幂(无符号类型中的值位数)进行包装。

即使您将其与带符号的类型一起使用,也没关系,特别是在您的unsigned-> signed转换永远不会溢出的情况下。

无符号和2的补码之间的转换是免费的(所有值都使用相同的位模式);由C ++标准指定的int-> unsigned的模数包装简化为仅使用相同的位模式,而不像一个人的补码或符号/幅度。

和unsigned-> signed相似,尽管它是由实现定义的,用于大于的值,但同样也很简单INT_MAX。如果您没有使用上一次迭代产生的巨大的无符号结果,则无需担心。但是,如果是这样,请参阅从未签名转换为未定义符号吗?。值不适合的情况是实现定义的,这意味着实现必须选择一些行为。那些理智的人只需截断(如果需要)无符号的位模式并将其用作带符号的,因为这对于范围内的值以相同的方式起作用,而无需额外的工作。而且绝对不是UB。因此,大的无符号值可以成为负有符号整数。例如,在int x = u; gcc和clang不能优化之后x>=0一如既往地真实,即使没有-fwrapv,因为它们定义了行为。


2
我不明白这里的反对意见。我主要想发布有关剥离最后一次迭代的信息。但是,为了仍然回答这个问题,我对如何使用UB提出了一些意见。请参阅其他答案以获取更多详细信息。
彼得·科德斯

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稍微增加循环承载的依赖链的延迟。或者没有:当它编译成2倍LEA它只是尽可能高效LEA + ADD做f *= 10f*5*2,与test延迟第一隐藏LEA。但是它的确在循环中花费了额外的成本,因此可能存在吞吐量下降的问题(或至少存在超线程友好性问题)
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;
}

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

受到安德烈·亚历山大(Andrei Alexandrescu)的演讲“在人们的思想中发现速度”的启发。


2

考虑以下功能:

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

根据已发布的基本原理,该标准的作者可能希望,如果在(例如)参数为0xC000和0xC000的普通32位计算机上调用此函数,则提升*to 的操作数signed int将导致计算产生-0x10000000 ,将其转换为unsigned会产生0x90000000u-与他们unsigned short提升为相同的答案unsigned。但是,gcc有时会以某种方式优化该功能,如果发生溢出,这种方式会表现得毫无意义。 某些输入组合可能导致溢出的代码必须使用-fwrapv选项进行处理,除非允许故意变形的输入的创建者执行他们选择的任意代码是可接受的。


1

为什么不这样:

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

不会...factor = 1factor = 10,仅100和更高的值运行循环体。如果您想使它起作用,则必须剥离第一个迭代,factor = 1从此开始。
彼得·科德斯

1

未定义行为有很多不同的方面,可接受的方式取决于用法。

紧密的内部循环,在实时图形应用程序中消耗了总CPU时间的很大一部分

就其本身而言,这是一件不寻常的事情,但是可能的是……如果确实如此,则UB最有可能在“允许的,可接受的”范围内。图形编程因黑客和丑陋的东西而臭名昭著。只要它能“工作”并且制作帧的时间不超过16.6毫秒,通常没人会在意。但是仍然要知道调用UB的含义。

首先,有标准。从这个角度来看,没有什么要讨论的,也没有任何理由可以证明,您的代码完全是无效的。没有ifs或whens,它不是有效的代码。从您的角度来看,您可能会说这是中指,而无论如何,您有95-99%的时间会很好。

接下来是硬件方面。在某些不常见的怪异体系结构中,这是一个问题。我说“不寻常,怪异”是因为在占所有计算机80%的一种体系结构(或占所有计算机95%的两种体系结构)上,溢出是“是的,不管,不在乎”硬件层面的东西。您肯定会得到垃圾(尽管仍然可以预测)的结果,但是不会发生任何邪恶的事情。
不是对于每种架构,您很可能会陷入溢出的陷阱(尽管看到您对图形应用程序的看法,采用这种奇特架构的机会很小)。可移植性是个问题吗?如果是这样,您可能要弃权。

最后,还有编译器/优化器端。未定义溢出的原因之一是,将溢出保留在最简单的位置可以很容易地一次解决硬件问题。但另一个原因是,如x+1保证永远是大于x,编译器/优化器可以利用这方面的知识。现在,对于前面提到的情况,确实知道编译器会以这种方式起作用,并且只需剥离完整的块(几年前就存在Linux漏洞利用,正是基于这一原因,编译器已经将某些验证代码完全删除了)。
对于您的情况,我将严重怀疑编译器会进行一些特殊的奇怪的优化。但是,您知道什么,我知道什么。如有疑问,请尝试一下。如果有效,那么您就可以开始了。

(最后,当然还有代码审核,如果不幸的话,您可能不得不浪费时间与审核员讨论这个问题。)

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.