gcc会跳过有符号整数溢出的检查吗?

3

例如,给定以下代码:

int f(int n)
{
    if (n < 0)
        return 0;
    n = n + 100;
    if (n < 0)
        return 0;
    return n;
}

假设您输入的数字非常接近整数溢出(小于100),编译器会生成能够给您返回负值的代码吗?
以下是来自Simon Tatham《向C之旅》一书中有关此问题的摘录:
“GNU C编译器(GCC)为该函数生成的代码,如果您传递(例如)最大可表示的‘int’值,则可以返回负整数。因为在第一个if语句之后,编译器知道n为正,并且假设不存在整数溢出并使用该假设得出加法后n的值仍然为正,因此它完全删除了第二个if语句并返回未经检查的加法结果。”
这让我想知道C++编译器是否存在同样的问题,以及是否应当注意我的整数溢出检查是否被跳过。

2
当出现未定义的行为时,编译器可能会做出看起来非常奇怪的假设。你可以看看这个例子,看看编译器由于对未定义行为的优化而将一个有限循环变成了无限循环。 - Shafik Yaghmour
2
这里有意思的是,标准故意将某些在硬件上(无论如何,在所有真实的、非虚构的硬件上)完全被定义的东西定义为未定义行为,只是为了让编译器可以进行那种优化。尽管 INT_MAX+1 实际上等于在你能找到的任何 CPU 上的 INT_MIN,但假设它不相等并说这是未定义的,可以让你合法地优化掉以上代码或认为 x+1>x 始终成立,或者让您断言循环迭代是有限的。 - Damon
也许他们认为这个问题之前已经得到了充分的回答,而我没有搜索得足够好? - SaintAnti
@Damon:你这个说法的依据是什么?1989年,大多数硬件上的行为是一致的,但并非全部。标准的作者在解释中指出,使短无符号值升级为有符号值的主要动机之一是大多数实现已经定义了静默环绕溢出语义。 - supercat
1个回答

9

简短回答

在你的示例中,编译器是否一定会优化掉检查,我们不能对所有情况做出断言,但我们可以使用以下代码针对gcc 4.9进行测试,使用godbolt交互式编译器点击此处查看实时演示):

int f(int n)
{
    if (n < 0) return 0;

    n = n + 100;

    if (n < 0) return 0;

    return n;
}

int f2(int n)
{
    if (n < 0) return 0;

    n = n + 100;

    return n;
}

我们发现它为两个版本生成了相同的代码,这意味着它确实省略了第二次检查:

f(int):  
    leal    100(%rdi), %eax #, tmp88 
    testl   %edi, %edi  # n
    movl    $0, %edx    #, tmp89
    cmovs   %edx, %eax  # tmp88,, tmp89, D.2246
    ret
f2(int):
    leal    100(%rdi), %eax #, tmp88
    testl   %edi, %edi  # n
    movl    $0, %edx    #, tmp89 
    cmovs   %edx, %eax  # tmp88,, tmp89, D.2249
    ret

长答案

当你的代码表现出未定义行为或依赖于潜在的未定义行为(在这个例子中是有符号整数溢出),那么是的,编译器可以做出假设并围绕它们进行优化。例如,它可以假设不存在未定义行为,因此根据该假设进行优化。最臭名昭著的例子可能是Linux内核中空检查的删除。代码如下:

struct foo *s = ...;
int x = s->f;
if (!s) return ERROR;
... use s ..

使用的逻辑是,由于已经解除了对的引用,因此它必须不是空指针,否则行为将未定义,因此优化掉了if(!s)检查。链接的文章说:
问题在于第2行中对s的解引用允许编译器推断出s不是null(如果指针为null,则函数未定义;编译器可以简单地忽略此情况)。因此,在第3行中的null检查被静默地优化掉,现在如果攻击者能够找到一种方法来使用null指针调用此代码,内核将包含可利用的错误。
这同样适用于C和C++,它们都有类似于未定义行为的语言。在两种情况下,标准告诉我们未定义行为的结果是不可预测的,尽管在任一语言中具体未定义的内容可能有所不同。draft C++ standard定义了未定义行为如下:
本国际标准没有规定的行为
并包括以下注释(我强调):
当国际标准省略任何明确的行为定义或程序使用错误构造或错误数据时,可能会出现未定义行为。允许的未定义行为范围从完全忽略情况并产生不可预测的结果,到在翻译或程序执行期间以特定于环境的记录方式行事(无论是否发出诊断消息),直至终止翻译或执行(并发出诊断消息)。许多错误的程序结构不会引起未定义的行为;它们需要被诊断。
草案C11标准具有类似的语言。
适当的有符号溢出检查
您的检查不是防止有符号整数溢出的正确方法,您需要在执行操作之前进行检查,并且如果执行该操作会导致溢出,则不执行该操作。Cert有一个良好的参考,介绍了如何防止各种操作的有符号整数溢出。对于加法情况,它建议采用以下方法:
#include <limits.h>

void f(signed int si_a, signed int si_b) {
  signed int sum;
  if (((si_b > 0) && (si_a > (INT_MAX - si_b))) ||
      ((si_b < 0) && (si_a < (INT_MIN - si_b)))) {
    /* Handle error */
  } else {
    sum = si_a + si_b;
  }

如果我们将这段代码插入到godbolt中,我们可以看到检查被省略了,这是我们所期望的行为。

值得一提的是,使用 g++ -O3 进行优化后,它被优化掉了。生成的代码int f(int n) { return n < 0? 0 : n + 100; } 生成的代码完全相同。 - T.C.
@T.C. 我之前没有机会添加来自godbolt的测试结果,但确实是这种情况。 - Shafik Yaghmour
@JeremyFriesner 我同意你的观点,正如我在这里的评论中所述,其中涉及类似的问题。虽然在那种情况下编译器确实会警告未定义的行为,但在许多情况下它并不会,这一点我并不完全理解。 - Shafik Yaghmour
我想知道允许溢出产生无限制行为所带来的效率提升与使用上述代码执行本应简单的“return si_a + si_b;”相比所需付出的代价。 - supercat

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