Blogs

理解和预防溢出(昨晚我有太多添加)

杰森萨赫斯 2013年12月4日

感恩节快乐!也许在你的脑海里吃太多火鸡的记忆。如果是这样,这将是谈论的好时机 溢出 .

在浮点算术的世界中,溢出是可能但不是特别常见的。当数字变得太大时,你可以得到它; IEEE双精度浮点数 支持2岁以下的范围1024,如果你超越你有问题:

for k in [10, 100, 1000, 1020, 1023, 1023.9, 1023.9999, 1024]:
    try:
        print "2^%.4f = %g" % (k, 2.0 ** k)
    except OverflowError, e:
        print "2^%.4f ---> %s" % (k,e)
2^10.0000 = 1024
2^100.0000 = 1.26765e+30
2^1000.0000 = 1.07151e+301
2^1020.0000 = 1.12356e+307
2^1023.0000 = 8.98847e+307
2^1023.9000 = 1.67731e+308
2^1023.9999 = 1.79757e+308
2^1024.0000 ---> (34, 'Result too large')

21024 是真的, 真的 大数字。你可能不会需要2个这样的数字1024 除非你在一起 组合学。只是为了比较,这里有一些物理量中发现的数字范围:

本文以PDF格式提供,便于打印

所以双重精确数字应该非常安全。

单精度浮点数 高度低于2128 并在非常大的物理计算中溢出是“脆弱的”。由于精度,而不是范围,您更有可能使用它们。

我们在嵌入式系统世界中的人通常没有使用浮点计算的奢侈品。在CPU执行时间或$$$中,它额外费用。我们辞职使用整数算术。 (我们中的一些人实际上喜欢它!)

所以这篇文章的其余部分是关于整数算术的溢出。下面可能会让你感到惊讶。

溢出来源

通常的嫌疑人:加法和乘法

什么可能导致整数数学溢出?通常的嫌疑人是加法和乘法:

import numpy as np

a = np.int16(32767)
b = np.int16(2)
print "32767 + 2 = %s" % (a+b)
print "32767 * 2 = %s" % (a*b)
32767 + 2 = -32767
32767 * 2 = -2
-c:3: RuntimeWarning: overflow encountered in short_scalars
-c:4: RuntimeWarning: overflow encountered in short_scalars

每个整数格式都具有可增量范围:

  • 签名的16位整数支持范围[-32768,32767]
  • 无符号16位整数支持范围[0,65535]
  • 符号32位整数支持范围[-2147483648,2147483647]
  • 无符号32位整数支持范围[0,4294967295]

如果你超出这个范围,甚至暂时,你需要非常小心。大多数环境优雅地处理溢出,并为您提供“符录16位环境中的”Moduloust“或”Modulo“行为(32767 + 1 = -32768),其中携带位于范围之外的位,并且您将留下低阶留下比特对应于确切结果的比特。

如果您正在使用符合符合的编译器编程 C99标准,可能会感到惊讶地知道未签名的整数数学保证在溢出条件下具有模数行为,但 覆盖整数数学的溢出的行为是未定义的.

将是 语言律师,相关 C99中的部分 are:

第6.5条第5项:

如果 特殊条件 在评估表达式期间发生(即,如果结果未在数学上定义或在其类型的可表示值范围内),则该行为是未定义的。

第6.2.5项9:

涉及无符号操作数的计算可以永远不会溢出,因为不能由生成的无符号整数类型表示的结果减少了一个大于可以由所得到的类型表示的最大值的数字。

(在实践中,现代编制者如 GCC. 无论如何,康复往往会用于基本算术计算,但要小心,如 有时编译器优化将不正确地对比或有条件表达 即使基本算术是“正确”。这种行为的症状可能真的很棘手才能识别;这 LLVM网站 has some explanation of this, along with other bugbears of undefined behavior. If you want to be safe, use the -fwrapv compiler flag, which both gcc and clang support; this guarantees modulo behavior with all aspects of signed arithmetic.)

在C标准中这个小怪癖的历史原因是,在Olden的日子里,一些处理器使用 一员补充 签名号码的表示,其中签名和无符号数字的算术略有不同。如今TwoS补充是规范,并且添加,减法和乘法的实现对于在使用基本字长度的结果时签名和无符号是相同的。但是,C的主要原则之一,无论好坏,都是允许机器特定的行为来维持有效的代码,因此标准是没有保证签署算术溢出结果的保证。另一方面,Java这样的语言具有独立于机器的算术定义。无论您运行哪个处理器,Java中的任何算术运算都会给您相同的答案。此保证的价格是在某些处理器上,需要额外的指示。

By the way, if you're using C for integer math, be sure to #include <stdint.h>使用它包含的typedefs, such as int16_t, uint16_t, int32_t, and uint32_t. These are portable, whereas the number of bits in a short or an int or a long may vary with processor architecture.

如果您使用MATLAB的固定点功能,请注意整数溢出的默认行为是在整数范围的范围内饱和。这避免了一些溢出的问题,但如果您用于提供有关涡卷语义的语言,则可能不会为您提供您期望的结果。

防止溢出

Just because we can use -fwrapv in gcc or clang, and guarantee wraparound behavior, doesn't make it the correct behavior for an application program.

如果我控制阀门,我希望输出增加,我将1添加到一个过程控制变量,使我得到32765,32766,32767,-32768,-32767等。这会产生一个跳跃的不连续性不好。唯一的机器独立的防止这方法是避免溢出。一种方法是向越大的类型大小upcast,检查溢出,并饱存结果:

#include <stdint.h>

int16_t add_and_saturate(int16_t x, int16_t y)
{
    int32_t z = (int32_t)x + y;
    if (z > INT16_MAX)
    {
        z = INT16_MAX;
    }
    else if (z < INT16_MIN)
    {
        z = INT16_MIN;
    }
    return (int16_t)z;
}

您也可以这样做以保持在16位类型的范围内,但它有点棘手,使用更多的操作,我不是100%自信我有我的代码正确:

#include <stdint.h>

int16_t add_and_saturate(int16_t x, int16_t y)
{
   int16_t z = x+y;
   if ((y > 0) && (x > (INT16_MAX - y)))
   {
       z = INT16_MAX;
   }
   else if ((y < 0) && (x < (INT16_MIN - y)))
   {
       z = INT16_MIN;
   }
   return z;
}

处理乘法是相似的,并且真的只有使用类型的变宽方法实际。

减法?

下面的C代码中有一个错误;你能看到为什么?

int16_t calcPI(int16_t command, int16_t measurement)
{
   int16_t error = command - measurement;
   /* other stuff */
}

The problem is with the subtraction. If command = 32767measurement = -32768, the "real" value of error is 65535. And if command = -32768measurement = 32767, then the "real" value of the error is -65535. Just as with addition, in subtraction the result of operating on two k-bit numbers is a (k+1)-bit result. There are a couple of ways of dealing with this to avoid incorrect results.

首先,我们可以使用32位中间计算:

int32_t error = (int32_t)command - measurement;

这具有缺点,即我们添加的操作越多,输出值的范围就会变大。但只要我们在中间计算中没有陷入溢出,我们都很好。

Second, we can saturate the error to the range of an int16_t, so that the limits are -32768 to +32767. This has the drawback that there is no difference between cases at the edge of saturation and deep into saturation. (For example: command = 32767measurement = 0 gives error = 32767, but so does measurement = -32768.) In reality we may want to maintain linearity throughout the whole range.

第三,我们可以修改我们所做的操作,因此结果在限制范围内:

int16_t error = ((int32_t)command - measurement) / 2;

这减少了净增益,但避免了溢出和饱和度。

分配?

师是算术运营中的丑陋野兽。幸运的是,溢流情况并不是太复杂。

如果您正在进行C,其中划分意味着采用n位数并划分另一个n位数,则只有一个可能导致溢出的示例,我们将稍后达到一点。 (它不分为零。如果您划分n / d而不排除d = 0的情况,则您应该得到任何您得到的。)

有些处理器具有内置的分割设施,编译器支持这些功能,“内在”或“内在”功能直接映射到处理器的划分功能。在这里,您可以通过16位整数进行分割32位整数,以产生16位的结果,但您需要确保避免划分一个创建不适合的商的数字在16位结果中。例如:180000/2 = 90000和90000超出了16位数字的界限,包括签名或无符号。

转变?

Don't forget your shift operators <<>>. These won't cause overflow, but in C, don't forget the pesky undefined and implementation-specific behaviors. Here the relevant section from the C99 standard is section 6.5.7 paragraphs 3-5:

3在每个操作数上执行整数促销。结果的类型是升级的左操作数。如果右操作数的值为负或大于或等于升级左操作数的宽度,则该行为是未定义的。

4 E1的结果<<E2是E1左移E2位位置;腾空位填充零。如果E1具有无符号类型,则结果的值是E1× 2E2,减少了一个比结果类型中可表示的最大值的模数。如果E1具有签名类型和非负值,并且E1× 2E2 在结果类型中可表示,那是结果值;否则,行为是未定义的。

5结果的结果>>E2是E1右移E2位位置。如果E1具有无符号类型,或者如果E1具有签名类型和非负值,则结果的值是E1 / 2商的数量部分 E2。如果E1具有签名类型和负值,则结果值是定义的。

That's right! If you have a signed integer E1 containing a negative number, and you shift right, the results are implementation defined. The "sensible" thing to do is an arithmetic shift right, putting 1 bits into the most significant bits to handle sign-extension. But the C standard allows the compiler to produce a logical shift right, putting 0 bits into the most significant bits, and that's probably not what you wanted.

最近像Java和JavaScript这样的语言给你 shift right operators: the regular >> operator for computing arithmetic shift right, and the >>> operator for computing logical shift right to produce an unsigned binary integer with zeros shifted into the most significant bits.

那是吗?哇!

No, that's not it! What else is there? Well, there are the increment and decrement operators ++--, but with -fwrapv they should work with wraparound semantics as expected.

The rest of the overflow items fall into what might be called Pathological Cases. These all involve asymmetry between INT_MININT_MAX that causes unwanted aliasing.

我们讨论过早分割,一个整数划分溢出是当您拍摄-32768并将其除以-1。

# Let's divide -32768 / -1
a=np.int16(-32768)
b=np.int16(-1)
a/b
-32768

D'OH! We should have gotten +32768 but that just won't fit into a 16-bit signed integer, so it aliases to the int16_t bit-equivalent value -32768.

有一件同样的事情发生在一条不足之上:

# Let's negate -32768 
-a
-32768

然后沿同一行有固定点乘法缺陷。假设您正在执行Q15数学,其中整数表示表单的数量\(\ frac {k} {2 ^ {15}}。 C代码如下所示:

int16_t a = ...;
int16_t b = ...;
int16_t c = ((int32_t)a * b) >> 15;

没有溢出,这是否正常工作?好吧,它确实如一,除了一个单案。 \(-32768 \ times -32768 = 2 ^ {30} \),如果我们向右移动15,我们得到\(2 ^ {15} = 32768 \)。但在签名的16位整数中,32768个别名到-32768。哎哟!

我们如何处理此问题?

Q15乘法

嗯,Q15乘法是一个艰难的。如果你是 绝对肯定 你不会评估-32768×-32768,继续使用通常的C代码。一个例子是PI控制循环,其中增益总是非负。或者如果您知道其中一个数字的范围不包括-32768那么您就很好。

或者,如果您愿意额外换班,您可以执行此操作:

int16_t a = ...;
int16_t b = ...;
int16_t c = ((int32_t)a * b) >> 16;

偏移系统的换向系统通常在嵌入式系统中更快,而不是一个指令周期的偏移,因为它通常映射到装配中的“抓取高字”操作,您通常在存储内存时免费获得。 。此操作表示\(a \ times b \ times \ frac {1} {2}如果a和b是q15定点整数,或者如果其中一个值是Q15和\(a \ times b \)其他是Q16。

除此之外,您需要某种方式饱和中间产品,以便换右是安全的:

int16_t a = ...;
int16_t b = ...;
int16_t c = sat30((int32_t)a * b) >> 15;

where sat30(x) limits the results so that they are within the range \( -2^{30} + k_1 \) to \( 2^{30} - 1 - k_2 \), where k1 和 k2 are any numbers between 0 and 32767. (Why this ambiguity? Because the least significant 15 bits don't matter, and certain processors may have quirks that allow you to execute code faster by choosing different values of k. For example, the literal value of \( 2^{30} - 1 \) may not be possible to load in one instruction, whereas the literal value of \( 2^{30} - 32768 \) = 32767 << 15 may be possible to load in one instruction.)

一元减去

一元减去案例有些类似。我们有一些替代方案:

如果我们绝对确定输入不能为-32768,我们就会很好地离开它。

否则,我们必须测试-32768并转换为+32767。

或者,在C中我们可以使用 按位补充运算符 ~, because ~x = -x-1 for signed integers: ~-32768 = 32767, ~0 = -1, ~-1 = 0, ~32767 = -32768. This adjustment by 1 for all inputs may not be acceptable in some applications, but in others it is fine, representing a small offset, and the complement operation is often a fast single-instruction operation.

同样的想法适用于绝对值;代替

#define ABS(x) ((x) < 0 ? (-x) : (x))

我们可以使用按位补充运算符:

#define ABS_Q15(x) ((x) < 0 ? (~x) : (x))

或者,我们必须特殊情况 - 对-32768的测试。

Just a reminder: #define macros are invisible to the compiler (because they happen in the preprocessor) and are also unsafe to use with arguments that have side effects e.g. ABS(++x) which would get incremented three times.

但等等,还有更多!

几乎忘了一件事!到目前为止,我们讨论过的一切都是单一的操作,并包括警示的祸患。

随着复合计算,由多项操作组成,有一件事我们可以使用我们的青睐:

如果您在固定位宽度整数算术(无论是签名或无符号)的复合计算,只有Modulo(Wraparound)语义,只由这些操作组成

  • 添加
  • 减法
  • 乘法
  • 左移
  • 按位和
  • 按位或者
  • 按位Xor.
  • 按位补充

(和 不是 签名扩展,右移或分区),以及以下条件是真的:

  • 中间计算溢出
  • 中间计算不用于任何其他目的
  • 使用整数算法执行相同的复合计算,而无需对位宽的限制,并产生不会溢出的最终结果

然后发生了一个惊人的事情,这是固定位宽度整数算法的最终结果仍然是正确的。事实上,如果我们 在中间结果提供饱和算术,然后每当饱和度激活时,我们可能会得到一个不正确的结果。

这种情况听起来有点又是不可能的,但它真的不是,并且在多项式评估中出现了很多。它是由 一致性关系 我们从模块化算术中获得。 如果从上面的列表中的k位数字上的算术运算产生k比特结果,可能会溢出串扰(modulo)语义,它创建了一个与真实结果的结果一致,modulo \(2 ^ k \ )。

让我们走出更具体的情况。假设我们是计算\(y = x ^ 2 + bx + c \),其中一个,b,c和x被签名4.12固定点数,b,b和c是常数。这相当于写入\(y = \ frac {ax ^ 2} {2 ^ {24}} + \ frac {bx} {2 ^ {12}} + c = \ frac {ax ^ 2 + 2 ^ {12其中bx + 2 ^ {24} c} {2 ^ {24}} \)其中一个,b和c,x是16位数字。还假设A,B和C的值使得对于有效的4.12范围内的任何值(即\(-8.0 \ LEQ x<8.0 \)),结果Y也在该范围内。然后中间计算溢出无关,您仍然会得到最终结果是否正确。

好的,这仍然有点抽象。让我们假设我们是计算\(y = f(x)= \ frac {3} {16} x ^ 2 - \ frac {7} {16} x - \ frac {121} {16} \)。

for \(| x | \ leq 8.0 \),\(f(x)\)的最小值\(-8 + \ frac {35} {192} \)(近\(x = \ frac {4779 } {4096} \))和最大值\(7.9375 = 8 - \ FRAC {1} {16} = 127/16 \)(\(x = -8.0 \)),所以\(f(x )\)不会产生溢出的最终结果。

现在让我们使用两个不同的ALU来计算f(x)for \(x = -8.0 = \ frac {-32768} {4096})。 ALU1相当窄,具有16位寄存器和32位产品寄存器,ALU2使用32位寄存器和64位产品寄存器:

手术 ALU1ALU2
 
x
-32768 -8.0Q12 -32768 -8.0Q12
1.
t1p = x * x
1073741824 64.0Q24. 1073741824 64.0Q24.
2.
t2 = truncate(t1p,16)
16384 64.0Q8 16384 64.0Q8
3.
t3 = 3 * t2
-16384 -64.0q8 = -4.0q12 [溢出!] 49152 192.0Q8 = 12.0Q12
4.
t4p = -7 * x
229376 56.0Q12 = 3.5Q16 229376 56.0Q12 = 3.5Q16
5.
t5 = truncate(t4p,4)
14336 3.5Q12 14336 3.5Q12
6.
t6 = t3 + t5
-2048 -0.5Q12. 63488 15.5Q12
7.
y = t6 - 30976
32512 7.9375Q12 32512 7.9375Q12

这里有几个评论:

  • 以“P”后缀命名的临时结果是产品寄存器结果;剩余的结果是规律的长度。

  • 神秘数字-30976只是Q12表示的\(c = \ frac {-30976} {4096} = \ frac {-121} {16}}。

  • truncate(x,m), for an ALU with k-bit registers and a p-bit product register, is what we get when we select the k bits starting with bit #m: in other words, drop the m least significant bits of x, and then take the k least significant bits of the result, where k is the bit length of the ALU register. For an ALU with 16-bit registers, truncate(x,m) = (int16_t)(x >> m) 和 for an ALU with 32-bit registers, truncate(x,m) = (int32_t)(x >> m). This operation does not introduce any sign-extension as long as k + m < p.

  • 请注意,除了步骤3和第6步之外,所有计算对于alus都是相同的:ALU1溢出,但ALU2没有。两种结果到底融合了。

请注意,我从“安全”操作列表中排除了右转,我没有提到关于截断的任何东西,或者使用两个k位数字来产生2k位产品。在这里,我将不得不传递一点。

关于不安全的右移的部分与签名扩展有关:我们假设我们根据存储的最高有效位了解一个数字的标志。如果可以存在溢出,则我们无法对数字的符号作出任何假设。因此,如果您不签名扩展,或者不使用符号扩展位的任何东西,那么您将保持同一度模数2 ^ k。

另一方面,如果我们将两个k位数乘以2k位产品,则这不是在中间计算中使用它的固有的“安全”操作。让我们使用k = 16,带有= 1 + 65536 * m和b = 1 + 65536 * n,其中m和n是我们真正关心的未知整数,因为我们需要保持中间结果的一切是底部16位,对吧?如果我们乘以A和B的真正值,我们得到(1 + 65536 *(m + n)+ 65536 * 65536 * m * n)。该结果的底部32位为1 + 65536 *(m + n)。哦哦。如果我们想要一个32位的结果,我们真的必须知道更多位。如果我们只关心产品的底部16位,那么我们得到1,我们就可以了。

So in this example we have to be careful; the "we don't care if intermediate results overflow" rule really only applies to steps 3, 6 and 7 (y = (3*t2) + t5 - 30976) and the rest of the steps need to be analyzed more rigorously to ensure there are no overflows with the range of given inputs. For the additions/subtractions/multiplications that use fixed bit width, we can just do them without worrying about overflow, as long as we know the final result is going to end up within valid limits.

我希望有一种简单的方法来向初学者解释这一点。我希望经验丰富的工程师分析这些东西,因为它很难严格,并确保你搞定。也许有人会发现一种易于使用的“溢流微积分”,我们可以转动曲柄而不是想,它会告诉我们我们可以在哪里有问题以及如何解决它们。直到那时,保持注意。

进一步阅读

John Regehr在犹他大学有一个关于嵌入式系统和静态分析和其他Whatnots的非常有趣的博客。他有几篇文章 C和C ++中的未定义行为.

他也是一个有趣的文章的作者之一 “了解C / C ++中的整数溢出” 其中包括Sobering的软件程序列表(包括PHP,SQLite,PostgreSQL,OpenSSL,Firefox,GCC,LLVM和Python)被发现的潜在整数溢出。

概括

  • 意识到溢出!
  • 知道程序中的算术运算的输入范围
  • Use compiler flags to ensure wraparound semantics (-fwrapv in clang and gcc)
  • 适当使用明确的饱和度
  • 谨防涉及的病理案件 INT_MIN
  • 复合整数算术计算可以容忍中间结果中的溢出,只要最终结果在范围内,中间计算是“安全列表”的一部分(见上面的详细讨论)并适用于运算数和固定位长度的结果和结果。

希望你们都有一个快乐的感恩节,并记住,只有 可以防止溢出!


©2013年杰森M. Sachs,保留所有权利。



要发布回复评论,请单击连接到每个注释的“回复”按钮。发布新的评论(不是回复评论),请在评论的顶部查看“写评论”选项卡。

注册将允许您参加所有相关网站的论坛,并为您提供所有PDF下载。

注册

我同意 使用条款隐私政策.

尝试我们偶尔但流行的时事通讯。非常容易取消订阅。
或登录