ChatGPT解决这个技术问题 Extra ChatGPT

在 C/C++ 中检测有符号溢出

乍一看,这个问题似乎与 How to detect integer overflow? 重复,但实际上有很大不同。

我发现虽然检测无符号整数溢出非常简单,但检测 C/C++ 中的有符号溢出实际上比大多数人想象的要困难。

最明显但最幼稚的方法是:

int add(int lhs, int rhs)
{
 int sum = lhs + rhs;
 if ((lhs >= 0 && sum < rhs) || (lhs < 0 && sum > rhs)) {
  /* an overflow has occurred */
  abort();
 }
 return sum; 
}

问题在于,根据 C 标准,有符号整数溢出是未定义的行为。换句话说,根据标准,一旦您甚至导致有符号溢出,您的程序就如同取消引用空指针一样无效。所以你不能导致未定义的行为,然后在事后尝试检测溢出,如上面的后置条件检查示例。

尽管上述检查可能适用于许多编译器,但您不能指望它。事实上,因为 C 标准说有符号整数溢出是未定义的,所以一些编译器(如 GCC)会在设置优化标志时 optimize away the above check,因为编译器假定有符号溢出是不可能的。这完全破坏了检查溢出的尝试。

因此,检查溢出的另一种可能方法是:

int add(int lhs, int rhs)
{
 if (lhs >= 0 && rhs >= 0) {
  if (INT_MAX - lhs <= rhs) {
   /* overflow has occurred */
   abort();
  }
 }
 else if (lhs < 0 && rhs < 0) {
  if (lhs <= INT_MIN - rhs) {
   /* overflow has occurred */
   abort();
  }
 }

 return lhs + rhs;
}

这似乎更有希望,因为我们实际上不会将两个整数相加,直到我们事先确保执行这样的相加不会导致溢出。因此,我们不会导致任何未定义的行为。

但是,不幸的是,此解决方案的效率比初始解决方案低很多,因为您必须执行减法运算才能测试您的加法运算是否有效。即使你不关心这个(小)性能损失,我仍然不完全相信这个解决方案是足够的。表达式 lhs <= INT_MIN - rhs 看起来与编译器可能优化掉的那种表达式完全一样,认为有符号溢出是不可能的。

那么这里有更好的解决方案吗?保证 1) 不会导致未定义的行为,以及 2) 不会为编译器提供优化溢出检查的机会?我在想可能有一些方法可以通过将两个操作数都转换为无符号数,并通过滚动你自己的二进制补码算术来执行检查,但我不确定如何做到这一点。

与其试图检测,不如写出没有溢出可能性的代码不是更好的追求吗?
@ArunSaha:进行计算并确保它们不会溢出确实很难,而且在一般情况下也无法证明。通常的做法是使用尽可能宽的整数类型并希望。
@Amardeep:取消引用空指针同样未定义为有符号溢出。未定义的行为意味着,就标准而言,任何事情都可能发生。不能假设系统在签名溢出后不会处于无效和不稳定状态。 OP 指出了这样做的一个后果:优化器删除检测签名溢出的代码是完全合法的。
@Amardeep:我提到了这样的实现。设置优化标志时,GCC 将删除溢出检查代码。所以它基本上会破坏你的程序。这可以说比空指针取消引用更糟糕,因为它可能导致微妙的安全漏洞,而取消引用 null 可能会直接用段错误破坏您的程序。
@Amardeep:根据编译器设置,我确实似乎实现了溢出会导致陷阱。如果语言允许指定特定的无符号变量或数量是否应该(1)干净地包装,(2)错误,或(3)做任何方便的事情,那就太好了。请注意,如果变量小于机器的寄存器大小,则要求无符号数量干净地包装可能会阻止生成最佳代码。

J
Jens Gustedt

不,您的第二个代码不正确,但您很接近:如果您设置

int half = INT_MAX/2;
int half1 = half + 1;

加法的结果是 INT_MAX。 (INT_MAX 始终是奇数)。所以这是有效的输入。但在您的例程中,您将有 INT_MAX - half == half1 并且您会中止。误报。

可以通过在两个检查中输入 < 而不是 <= 来修复此错误。

但是,您的代码也不是最佳的。以下会做:

int add(int lhs, int rhs)
{
 if (lhs >= 0) {
  if (INT_MAX - lhs < rhs) {
   /* would overflow */
   abort();
  }
 }
 else {
  if (rhs < INT_MIN - lhs) {
   /* would overflow */
   abort();
  }
 }
 return lhs + rhs;
}

要确定这是有效的,您必须在不等式的两侧象征性地添加 lhs,这会准确地为您提供结果超出范围的算术条件。


+1 以获得最佳答案。次要:建议 /* overflow will occurred */ 强调整个要点是检测如果代码执行 lhs + rhs 而没有实际求和,则会发生溢出。
你可以做一个小的优化,我想这取决于你的硬件,我不确定哪个更好,但是如果你使用 if 和 else if with (lhs > 0 && rhs > 0) and (lhs < 0 && rhs < 0) 这将允许您在符号不匹配或任一值为 0 的情况下跳过减法,但在这些情况下需要进行 4 次比较,并且在两个值都存在的情况下需要进行额外比较是负面的。哪些硬件更快?比较或算术运算,例如减法?
R
R.. GitHub STOP HELPING ICE

您的减法方法是正确且定义明确的。编译器无法优化它。

如果您有更大的整数类型可用,另一种正确的方法是在较大的类型中执行算术,然后在将其转换回时检查结果是否适合较小的类型

int sum(int a, int b)
{
    long long c;
    assert(LLONG_MAX>INT_MAX);
    c = (long long)a + b;
    if (c < INT_MIN || c > INT_MAX) abort();
    return c;
}

一个好的编译器应该将整个加法和 if 语句转换为 int 大小的加法和单个有条件的溢出跳转,并且从不实际执行更大的加法。

编辑:正如斯蒂芬指出的那样,我在获得一个(不太好的)编译器 gcc 来生成健全的 asm 时遇到了麻烦。它生成的代码不是很慢,但肯定不是最理想的。如果有人知道此代码的变体将使 gcc 做正确的事情,我很乐意看到它们。


对于任何想要使用它的人,请确保您正在查看我的编辑版本。在原版中,我在添加之前愚蠢地省略了对 long long 的演员表。
出于好奇,您是否成功让编译器进行此优化?对几个编译器的快速测试没有发现任何可以做到的。
在 x86_64 上,使用 32 位整数并没有什么低效的地方。性能与 64 位相同。使用小于本机字大小类型的一个动机是处理溢出条件或进位(对于任意精度算术)非常有效,因为溢出/进位发生在可直接访问的位置。
@R.,@Steven:不,OP给出的减法代码不正确,请参阅我的答案。我还在那里给出了一个代码,最多只能进行两次比较。也许编译器会做得更好。
此方法不适用于 sizeof(long long) == sizeof(int) 的不常见平台。 C 仅指定 sizeof(long long) >= sizeof(int)
S
Shafik Yaghmour

对于 gcc 案例,从 gcc 5.0 Release notes 我们可以看到它现在还提供了一个 __builtin_add_overflow 用于检查溢出:

添加了一组新的内置函数,用于具有溢出检查的算术:__builtin_add_overflow、__builtin_sub_overflow 和 __builtin_mul_overflow,并与 clang 以及其他变体兼容。这些内置函数有两个整数参数(不需要具有相同的类型),参数扩展为无限精度有符号类型,+、- 或 * 对它们执行,结果存储在指向的整数变量中通过最后一个论点。如果存储的值等于无限精度结果,则内置函数返回 false,否则返回 true。将保存结果的整数变量的类型可能与前两个参数的类型不同。

例如:

__builtin_add_overflow( rhs, lhs, &result )

我们可以从 gcc 文档 Built-in Functions to Perform Arithmetic with Overflow Checking 中看到:

[...]这些内置函数对所有参数值都有完全定义的行为。

clang 还提供了一组 checked arithmetic builtins

Clang 提供了一组内置函数,它们以在 C 中快速且易于表达的方式为安全关键应用程序实现检查算法。

在这种情况下,内置将是:

__builtin_sadd_overflow( rhs, lhs, &result )

此函数似乎非常有用,除了一件事:int result; __builtin_add_overflow(INT_MAX, 1, &result); 没有明确说明溢出时存储在 result 中的内容,不幸的是在指定 未定义的行为 时保持安静不会发生。当然,这就是意图 - 没有 UB。如果它指定了,那就更好了。
@chux 好点,它指出 here 结果始终是定义的,我更新了我的答案。如果不是这样,那将是相当讽刺的。
有趣的是,您的新参考没有 __builtin_(s/u)addll_overflow(unsigned) long long *result。当然这些都是错误的。让人怀疑其他方面的真实性。 IAC,很高兴看到这些 __builtin_add/sub/mull_overflow()。希望他们有一天能达到 C 规范。
+1 这比您在标准 C 中可以得到的任何东西都要好得多,至少在不依赖编译器的优化器来确定您在做什么的情况下是这样。应该检测此类内置函数何时可用,并且仅在编译器不提供标准解决方案时才使用标准解决方案。
H
Human-Compiler

恕我直言,处理溢出敏感 C++ 代码的最简单方法是使用 SafeInt<T>。这是托管在 code plex 上的跨平台 C++ 模板,可提供您在此处所需的安全保证。

https://github.com/dcleblanc/SafeInt

我发现它使用起来非常直观,因为它提供了许多与正常数值运算相同的使用模式,并通过异常表达溢出和溢出。


t
tbodt

最快的方法是使用 GCC 内置:

int add(int lhs, int rhs) {
    int sum;
    if (__builtin_add_overflow(lhs, rhs, &sum))
        abort();
    return sum;
}

在 x86 上,GCC 将其编译为:

    mov %edi, %eax
    add %esi, %eax
    jo call_abort 
    ret
call_abort:
    call abort

它使用处理器的内置溢出检测。

如果您对使用 GCC 内置函数不满意,下一个最快的方法是对符号位使用位操作。有符号溢出还会在以下情况下发生:

两个操作数具有相同的符号,并且

结果的符号与操作数不同。

如果操作数的符号相同,~(lhs ^ rhs) 的符号位打开,如果结果与操作数的符号不同,lhs ^ sum 的符号位打开。因此,您可以以无符号形式进行加法以避免未定义的行为,然后使用 ~(lhs ^ rhs) & (lhs ^ sum) 的符号位:

int add(int lhs, int rhs) {
    unsigned sum = (unsigned) lhs + (unsigned) rhs;
    if ((~(lhs ^ rhs) & (lhs ^ sum)) & 0x80000000)
        abort();
    return (int) sum;
}

这编译成:

    lea (%rsi,%rdi), %eax
    xor %edi, %esi
    not %esi
    xor %eax, %edi
    test %edi, %esi
    js call_abort
    ret
call_abort:
    call abort

这比在 32 位机器(使用 gcc)上转换为 64 位类型要快得多:

    push %ebx
    mov 12(%esp), %ecx
    mov 8(%esp), %eax
    mov %ecx, %ebx
    sar $31, %ebx
    clt
    add %ecx, %eax
    adc %ebx, %edx
    mov %eax, %ecx
    add $-2147483648, %ecx
    mov %edx, %ebx
    adc $0, %ebx
    cmp $0, %ebx
    ja call_abort
    pop %ebx
    ret
call_abort:
    call abort

C
Community

如果您使用内联汇编程序,您可以检查 overflow flag。另一种可能性是您可以使用 safeint datatype。我建议在 Integer Security 上阅读这篇论文。


+1这是另一种说法,“如果 C 不定义它,那么你将被迫进入特定于平台的行为。”很多在汇编中很容易处理的事情在 C 中是未定义的,以可移植性的名义从鼹鼠山中创造了山。
我对 C 问题的 asm 答案投了反对票。正如我所说,有一些正确的、可移植的方法可以在 C 中编写检查,这将生成与您手动编写的完全相同的 asm。自然,如果您使用这些,性能影响将是相同的,并且它的影响将比您还推荐的 C++ safeint 的东西小得多。
@Matthieu:如果您编写的代码仅用于一种实现,并且该实现保证某些东西会起作用,并且您需要良好的整数性能,那么您当然可以使用特定于实现的技巧。不过,这不是 OP 所要求的。
有充分的理由区分实现定义的行为和未定义的行为,即使 UB 的某些东西在您的实现的当前版本中“有效”,这并不意味着它将在未来的版本中继续有效。考虑 gcc 和签名溢出行为......
由于我的 -1 是基于我们可以让 C 代码生成相同的 asm 的声明,我想只有在所有主要编译器在这方面都证明是垃圾时才收回它是公平的。
J
Jonathan

您可能会更幸运地转换为 64 位整数并测试类似的条件。例如:

#include <stdint.h>

...

int64_t sum = (int64_t)lhs + (int64_t)rhs;
if (sum < INT_MIN || sum > INT_MAX) {
    // Overflow occurred!
}
else {
    return sum;
}

您可能想仔细看看符号扩展在这里是如何工作的,但我认为这是正确的。


从 return 语句中删除按位与和强制转换。他们写的不正确。只要值适合较小的类型,从较大的有符号整数类型到较小的类型的转换是完美定义的,并且不需要显式强制转换。任何发出警告并建议您在刚刚检查值未溢出时添加强制转换的编译器都是损坏的编译器。
@R你是对的,我只是喜欢明确我的演员表。不过,为了正确性,我会更改它。对于未来的读者,返回行显示为 return (int32_t)(sum & 0xffffffff);
请注意,如果您编写 sum & 0xffffffff,则 sum 会隐式转换为类型 unsigned int(假设为 32 位 int),因为 0xffffffff 具有类型 unsigned int。那么按位和的结果是一个unsigned int,如果sum为负数,它将超出int32_t支持的值范围。到 int32_t 的转换随后具有实现定义的行为。
请注意,这在 int 为 64 位的 ILP64 环境中不起作用。
C
Chris Dodd

显而易见的解决方案是转换为无符号,以获得明确定义的无符号溢出行为:

int add(int lhs, int rhs) 
{ 
   int sum = (unsigned)lhs + (unsigned)rhs; 
   if ((lhs >= 0 && sum < rhs) || (lhs < 0 && sum > rhs)) { 
      /* an overflow has occurred */ 
      abort(); 
   } 
   return sum;  
} 

这用实现定义的有符号和无符号之间的超出范围值的转换替换了未定义的有符号溢出行为,因此您需要检查编译器的文档以确切知道会发生什么,但它至少应该被明确定义,并且应该在任何不会在转换时引发信号的二进制补码机器上做正确的事情,这几乎是过去 20 年中构建的每台机器和 C 编译器。


您仍在将结果存储在 sum 中,即 int。如果 (unsigned)lhs + (unsigned)rhs 的值大于 INT_MAX,这将导致实现定义的结果或实现定义的信号。
@R:这就是重点——行为是实现定义的,而不是未定义的,因此实现必须记录它的作用,并始终如一地做。仅当实现记录信号时才能引发信号,在这种情况下必须始终引发信号并且您可以使用该行为。
S
SamB

怎么样:

int sum(int n1, int n2)
{
  int result;
  if (n1 >= 0)
  {
    result = (n1 - INT_MAX)+n2; /* Can't overflow */
    if (result > 0) return INT_MAX; else return (result + INT_MAX);
  }
  else
  {
    result = (n1 - INT_MIN)+n2; /* Can't overflow */
    if (0 > result) return INT_MIN; else return (result + INT_MIN);
  }
}

我认为这应该适用于任何合法的 INT_MININT_MAX(对称与否);功能如图所示,但如何获得其他行为应该很明显)。


+1 可能更直观的替代方法。
我认为这 - result = (n1 - INT_MAX)+n2; - 可能会溢出,如果 n1 很小(比如 0)并且 n2 是负数。
@davmac:嗯......也许有必要打破三种情况:从一个(n1 ^ n2) < 0开始,在二进制补码机器上意味着这些值具有相反的符号并且可以直接相加。如果这些值具有相同的符号,那么上面给出的方法将是安全的。另一方面,我很好奇标准的作者是否期望二进制补码静默溢出硬件的实现会在溢出的情况下以一种不会强制立即异常程序终止但导致其他计算的不可预测的中断。
b
benrg

您的根本问题是 lhs + rhs 没有做正确的事情。但是,如果您愿意假设一个二进制补码机器,我们可以解决这个问题。假设您有一个函数 to_int_modular,该函数将 unsigned 转换为 int,并且保证与从 intunsigned 的转换相反,并且它在运行时优化为无。 (参见下文了解如何实现它。)

如果您使用它来修复原始尝试中的未定义行为,并重写条件以避免 lhs >= 0lhs < 0 的冗余测试,那么您会得到

int add(int lhs, int rhs)
{
 int sum = to_int_modular((unsigned)lhs + rhs);
 if (lhs >= 0) {
  if (sum < rhs)
    abort();
 } else {
  if (sum > rhs)
   abort();
 }
 return sum; 
}

它的性能应该优于 current top-voted answer,因为它具有相似的结构但需要较少的算术运算。

(重新组织 if 应该没有必要,但在 tests on godbolt 中,ICC 和 MSVC 确实自行消除了冗余测试,但 GCC 和 Clang 令人惊讶地没有。)

如果您希望以更大的尺寸计算结果然后进行边界检查,那么进行边界检查的一种方法是

 long long sum = (long long)lhs + rhs;
 if ((int)sum != sum)
  abort();

...除了行为在溢出时未定义。但是您可以使用相同的辅助函数来解决这个问题:

 if (to_int_modular(sum) != sum)

在不够聪明的编译器上,这可能会优于 current accepted answer 以对其进行优化以测试溢出标志。

不幸的是,测试(对 Godbolt 的目视检查)表明 GCC、ICC 和 MSVC 使用上面的代码比使用已接受答案中的代码做得更好,但 Clang 使用已接受答案中的代码做得更好。像往常一样,没有什么是容易的。

这种方法只适用于intunsigned的范围相当大的架构,下面的具体实现也依赖于它的二进制补码。不符合这些规格的机器非常罕见,但无论如何我都会检查它们:

static_assert(INT_MIN + INT_MAX == -1 && UINT_MAX + INT_MIN == INT_MAX);

实现 to_int_modular 的一种方法是

inline int to_int_modular(unsigned u) {
    int i;
    memcpy(&i, &u, sizeof(i));
    return i;
}

所有主要的 x64 编译器都可以毫无问题地优化它,但是当禁用优化时,MSVC 和 ICC 会生成对 memcpy 的调用,如果你经常使用这个函数,这可能会有点慢。此实现还取决于标准可能无法保证的 unsignedint 表示的细节。

另一种方式是这样的:

inline int to_int_modular(unsigned u) {
    return u <= INT_MAX ? (int)u : (int)(u - INT_MIN) + INT_MIN;
}

除了 ICC,所有主要的 x64 编译器都对其进行了优化,这使得它和我能想到的每一个变体都变得一团糟。 ICX 做得很好,而且似乎英特尔正在放弃 ICC 并转向 ICX,所以也许这个问题会自行解决。


您可以添加 C2X 定义有符号整数溢出(因为所有生成的架构现在都在 2s 补码上工作),这可以简化为 Hacker's Delight 的方法:var sum: ST = a +% b;(+% 是包装加法)。 if (((sum ^ a) & (sum ^ b)) < 0) overflow(); return sum;
a
atomsymbol

在添加两个 long 值的情况下,可移植代码可以将 long 值拆分为低 int 部分和高 int 部分(或在 longint 大小相同的情况下拆分为 short 部分):

static_assert(sizeof(long) == 2*sizeof(int), "");
long a, b;
int ai[2] = {int(a), int(a >> (8*sizeof(int)))};
int bi[2] = {int(b), int(b >> (8*sizeof(int))});
... use the 'long' type to add the elements of 'ai' and 'bi'

如果针对特定 CPU,使用内联汇编是最快的方法:

long a, b;
bool overflow;
#ifdef __amd64__
    asm (
        "addq %2, %0; seto %1"
        : "+r" (a), "=ro" (overflow)
        : "ro" (b)
    );
#else
    #error "unsupported CPU"
#endif
if(overflow) ...
// The result is stored in variable 'a'

r
ruslik

对我来说,最简单的检查是检查操作数和结果的符号。

让我们检查一下 sum:溢出可能发生在两个方向,+ 或 -,只有当两个操作数具有相同的符号时。而且,很明显,当结果的符号与操作数的符号不同时,就会发生溢出。

所以,这样的检查就足够了:

int a, b, sum;
sum = a + b;
if  (((a ^ ~b) & (a ^ sum)) & 0x80000000)
    detect_oveflow();

编辑:正如 Nils 所建议的,这是正确的 if 条件:

((((unsigned int)a ^ ~(unsigned int)b) & ((unsigned int)a ^ (unsigned int)sum)) & 0x80000000)

自从当指令

add eax, ebx 

导致未定义的行为? Intel x86 指令集参考中没有这样的东西。


你错过了这里的重点。您的第二行代码 sum = a + b 可能会产生未定义的行为。
如果您在添加测试期间将 sum、a 和 b 转换为无符号,则您的代码将正常工作。
它未定义不是因为程序会崩溃或行为不同。这正是处理器为计算 OF 标志所做的事情。该标准只是试图保护自己免受非标准情况的影响,但这并不意味着您不允许这样做。
@Nils 是的,我想这样做,但我认为四个 (usngined int) 会使它更难读。 (您知道,您首先阅读它,并且只有在您喜欢它时才尝试它)。
未定义的行为在 C 中,而不是在编译为程序集之后
n
nategoose

我认为这有效:

int add(int lhs, int rhs) {
   volatile int sum = lhs + rhs;
   if (lhs != (sum - rhs) ) {
       /* overflow */
       //errno = ERANGE;
       abort();
   }
   return sum;
}

使用 volatile 可以防止编译器优化测试,因为它认为 sum 可能在加法和减法之间发生了变化。

使用用于 x86_64 的 gcc 4.4.3,此代码的程序集确实执行加法、减法和测试,尽管它将所有内容存储在堆栈和不需要的堆栈操作中。我什至试过 register volatile int sum = 但程序集是一样的。

对于只有 int sum =(无 volatile 或寄存器)的版本,该函数没有进行测试,只使用一条 lea 指令进行了加法(lea 是加载有效地址,通常用于在不触及标志寄存器)。

你的版本是更大的代码并且有更多的跳转,但我不知道哪个会更好。


-1 表示滥用 volatile 来掩盖未定义的行为。如果它“有效”,那么您仍然只是“走运”。
@R:如果它不起作用,则编译器没有正确实现 volatile 。我所尝试的只是针对已经回答的问题的一个非常常见的问题的更简单的解决方案。
但是,它可能会失败的地方是一个系统,其数字表示在整数溢出时会换成较低的值。
@nategoose,您断言“如果它不起作用,则编译器无法正确实现 volatile”是错误的。一方面,在二进制补码算术中,即使发生溢出,lhs = sum - rhs 也总是正确的。即使情况并非如此,尽管这个特定示例有点做作,但编译器可能会生成执行加法的代码,存储结果值,将值读回另一个寄存器,将存储的值与读取的值进行比较value 并注意到它们是相同的,因此假定没有发生溢出。
(您还假设导致溢出不会导致之后的比较出错甚至被跳过,这是“未定义的行为”允许的)。