【问题标题】:Signed overflow in C++ and undefined behaviour (UB)C++ 中的有符号溢出和未定义行为 (UB)
【发布时间】:2019-11-29 13:10:32
【问题描述】:

我想知道如何使用如下代码

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

如果循环迭代了n 次,则factor 乘以10 正好是n 次。但是,factor 仅在乘以 10 总共 n-1 次后才会使用。如果我们假设factor 除了在循环的最后一次迭代之外永远不会溢出,但可能在循环的最后一次迭代时溢出,那么这样的代码是否可以接受?在这种情况下,factor 的值在溢出发生后将被证明永远不会被使用。

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

有问题的实际代码用在一个紧凑的内循环中,该循环占用了实时图形应用程序中总 CPU 时间的很大一部分。

【问题讨论】:

  • 我投票结束这个问题,因为这个问题应该在codereview.stackexchange.com 而不是这里。
  • @KevinAnderson,不在这里有效,因为示例代码将被修复,而不仅仅是改进。
  • @harold 他们很亲密。
  • @LightnessRaceswithMonica:标准的作者打算并期望针对各种平台和目的的实现将通过有意义地处理对这些平台和目的有用的各种操作来扩展程序员可用的语义,无论是否该标准要求他们这样做,并表示他们不希望贬低不可移植的代码。因此,问题之间的相似性取决于需要支持哪些实现。
  • @supercat 对于实现定义的行为,当然,如果您知道您的工具链有一些可以使用的扩展(并且您不关心可移植性),那很好。对于UB?值得怀疑。

标签: c++ undefined-behavior


【解决方案1】:

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

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

如果 x == nullptr 则取消引用并分配一个值是 UB。因此,这可能以有效程序结束的唯一方法是当x == nullptr 永远不会产生 true 并且编译器可以在 as 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 :(

【讨论】:

  • “未定义的行为”是我们在 SO 答案中经常听到的一个表达,但没有清楚地解释它如何影响整个程序。这个答案让事情变得更清楚了。
  • 如果该函数仅在具有INT_MAX >= 10000000000 的目标上调用,这甚至可能是一个“有用的优化”,在INT_MAX 较小的情况下调用不同的函数。
  • @Gilles-PhilippePaillé 有时我希望我们可以在上面贴上一个帖子。 Benign Data Races 是我最喜欢捕捉它们有多讨厌的地方之一。 MySQL 中还有一个很棒的错误报告,我似乎再也找不到了——意外调用了 UB 的缓冲区溢出检查。特定编译器的特定版本只是假设 UB 永远不会发生,并优化整个溢出检查。
  • @SolomonSlow:UB 有争议的主要情况是标准和实现的文档的部分描述了某些操作的行为,但标准的其他部分将其描述为 UB。在编写标准之前的常见做法是编译器编写者有意义地处理这些操作,除非他们的客户会从他们做其他事情中受益,而且我认为标准的作者从未想象过编译器编写者会故意做任何其他事情.
  • @Gilles-PhilippePaillé: LLVM 博客中的What Every C Programmer Should Know About Undefined Behavior 也不错。它解释了例如有符号整数溢出 UB 如何让编译器证明i <= n 循环总是非无限的,就像i<n 循环一样。并在循环中将int i 提升为指针宽度,而不必重做符号以可能将数组索引到第一个 4G 数组元素。
【解决方案2】:

int 溢出的行为未定义。

如果你在循环体之外读到factor也没关系;如果到那时它已经溢出,那么你的代码在溢出之前、之后和有点矛盾的是之前的行为是未定义的。

保留此代码可能会出现的一个问题是编译器在优化方面变得越来越激进。特别是他们正在养成一种习惯,他们认为未定义的行为永远不会发生。为此,他们可能会完全删除 for 循环。

您不能对factor 使用unsigned 类型吗?尽管这样您需要担心在包含两者的表达式中将int 不必要地转换为unsigned

【讨论】:

  • @nicomp;为什么不呢?
  • @Gilles-PhilippePaillé:我的回答不是告诉你这是有问题的吗?我的开场白不一定适用于 OP,而是更广泛的社区和 factor 在作业中“使用”回自身。
  • @Gilles-PhilippePaillé 这个答案解释了为什么它有问题
  • @Bathsheba 你说得对,我误解了你的回答。
  • 作为未定义行为的示例,当该代码在启用运行时检查的情况下编译时,它将终止而不是返回结果。需要我关闭诊断功能才能工作的代码已损坏。
【解决方案3】:

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

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

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

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

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

【讨论】:

    【解决方案4】:

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

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

    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
    

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

    【讨论】:

    • 这可能有点过于技术化,但“有符号溢出是未定义的行为”过于简单化了。形式上,带有符号溢出的程序的行为是未定义的。也就是说,标准并没有告诉您该程序的作用。溢出的结果不仅仅是有问题;整个程序有问题。
    • 或者更简单地说,剥离 last 迭代并删除死的factor *= 10;
    【解决方案5】:

    这是 UB;在 ISO C++ 术语中,对于最终命中 UB 的执行,整个程序的整个行为是完全未指定的。最经典的例子是就 C++ 标准而言,它可以让恶魔从你的鼻子里飞出来。 (我建议不要使用真正有可能出现鼻恶魔的实现)。有关详细信息,请参阅其他答案。

    编译器可以在编译时“造成麻烦”,因为他们可以看到导致编译时可见 UB 的执行路径,例如假设这些基本块永远不会到达。

    另见What Every C Programmer Should Know About Undefined Behavior(LLVM 博客)。正如那里所解释的,有符号溢出 UB 让编译器可以证明 for(... i &lt;= n ...) 循环不是无限循环,即使对于未知的 n 也是如此。它还允许他们将 int 循环计数器“提升”为指针宽度,而不是重做符号扩展。 (因此,在这种情况下,UB 的结果可能是访问数组的低 64k 或 4G 元素之外,如果您希望将 i 签名包装到其值范围内。)

    在某些情况下,编译器会发出一个非法指令,如 x86 ud2 用于一个可证明会导致 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(无符号类型的值位数)。

    即使您将它 有符号类型一起使用也很好,尤其是当您的无符号->有符号转换永远不会溢出时。

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

    并且 unsigned->signed 也同样微不足道,尽管它是为大于INT_MAX 的值定义的。如果您没有使用上次迭代的巨大无符号结果,那么您无需担心。但如果你是,请参阅Is conversion from unsigned to signed undefined?。 value-doesn't-fit case 是 implementation-defined,这意味着实现必须选择 some 行为;理智的人只是截断(如有必要)无符号位模式并将其用作有符号,因为它适用于范围内的值,无需额外工作。而且绝对不是UB。所以大的无符号值可以变成负符号整数。例如在int x = u; gcc and clang don't optimize away x&gt;=0 之后总是如此,即使没有-fwrapv,因为他们定义了行为。

    【讨论】:

    • 我不明白这里的反对意见。我主要想发布关于剥离最后一次迭代的信息。但是为了回答这个问题,我总结了一些关于如何理解 UB 的观点。有关详细信息,请参阅其他答案。
    【解决方案6】:

    如果您可以在循环中容忍一些额外的汇编指令,而不是

    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 编译)

    【讨论】:

    • 只剥离最后一次迭代更简单,除非循环体很大。这是一个巧妙的技巧,但通过factor 略微增加了循环携带的依赖链的延迟。与否:当它编译为 2x LEA 时,它的效率与 LEA + ADD 一样有效,f *= 10f*5*2 一样,test 延迟被第一个 LEA 隐藏。但它确实会在循环内花费额外的微指令,因此可能会降低吞吐量(或至少是超线程友好性问题)
    【解决方案7】:

    您没有显示for 语句的括号中的内容,但我假设它是这样的:

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

    您可以简单地将计数器增量和循环终止检查移到正文中:

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

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

    灵感来自 Andrei Alexandrescu 的演讲“速度在人们的头脑中”。

    【讨论】:

      【解决方案8】:

      考虑函数:

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

      根据已发布的基本原理,该标准的作者会预期,如果在(例如)具有 0xC000 和 0xC000 参数的普通 32 位计算机上调用此函数,则将 * 的操作数提升为 @987654323 @ 将导致计算产生 -0x10000000,当转换为 unsigned 时将产生 0x90000000u--与将 unsigned short 提升为 unsigned 的答案相同。尽管如此,gcc 有时会以在发生溢出时表现得毫无意义的方式优化该函数。 任何输入组合可能导致溢出的代码都必须使用-fwrapv 选项处理,除非允许故意输入格式错误的创建者执行他们选择的任意代码。

      【讨论】:

        【解决方案9】:

        为什么不这样:

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

        【讨论】:

        • 这不会为factor = 1factor = 10 运行... 循环体,只有100 或更高。如果你想让它工作,你必须剥离第一次迭代并且仍然以factor = 1开头。
        【解决方案10】:

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

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

        这本身就是一件不寻常的事情,但尽管如此......如果确实如此,那么UB很可能在“允许,可接受”的范围内。图形编程因黑客和丑陋的东西而臭名昭著。只要它“工作”并且生成一帧所需的时间不超过 16.6 毫秒,通常没有人在意。但是,请注意调用 UB 的含义。

        首先,有标准。从这个角度来看,没有什么可讨论的,也没有办法证明,你的代码是无效的。没有如果或何时,它只是不是一个有效的代码。从您的角度来看,您不妨说这是中指,而且 95-99% 的情况下,您无论如何都会顺利进行。

        接下来是硬件方面。有一些不常见的、奇怪的架构存在问题。我说“不常见,很奇怪”,因为 一个 架构占所有计算机的 80%(或 两个 架构加在一起占所有计算机的 95%)溢出是硬件级别的“是的,无论如何,不​​关心”。你肯定会得到一个垃圾(尽管仍然可以预测)的结果,但不会发生任何邪恶的事情。
        并非在每个架构上都是如此,您很可能会遇到溢出陷阱(尽管看到您如何谈论图形应用程序,在这种奇怪的架构上的机会相当小)。便携性是个问题吗?如果是,你可能想弃权。

        最后,还有编译器/优化器方面。溢出未定义的一个原因是,简单地将其留在过去最容易处理硬件。但另一个原因是,例如x+1保证总是大于x,编译器/优化器可以利用这个知识。现在,对于前面提到的情况,编译器确实以这种方式行事并简单地去除完整的块(几年前存在一个 Linux 漏洞,它基于编译器已经死去除了一些验证代码,正因为如此)。
        对于您的情况,我会严重怀疑编译器是否进行了一些特殊的、奇怪的优化。然而,你知道什么,我知道什么。如有疑问,尝试一下。如果可行,您就可以开始了。

        (最后,当然还有代码审计,如果运气不好,你可能不得不浪费时间与审计员讨论这个问题。)

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 2017-05-13
          • 1970-01-01
          • 2011-02-07
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          相关资源
          最近更新 更多