在循环中的什么地方整数溢出成为未定义的行为?
这是一个例子来说明我的问题,涉及一些更复杂的代码,我不能在这里发表。
#include <stdio.h> int main() { int a = 0; for (int i = 0; i < 3; i++) { printf("Hello\n"); a = a + 1000000000; } }
这个程序在我的平台上包含未定义的行为,因为第三个循环会溢出。
这是否使整个程序有未定义的行为,或者只有溢出实际发生后 ? 编译器是否可能解决了a
会溢出的问题,所以它可以声明整个循环是未定义的,即使它们都发生在溢出之前,也不打算运行printfs。
(标签C和C ++虽然不同,因为如果两种语言不同,我会对这两种语言的答案感兴趣)。
如果你对纯粹的理论答案感兴趣,C ++标准允许未定义的行为来“时间旅行”:
[intro.execution]/5:
执行格式良好的程序的一致性实现将产生与具有相同程序和相同input的抽象机器的相应实例的可能执行之一相同的可观察行为。 然而, 如果任何这样的执行包含未定义的操作,则本国际标准不要求执行那个程序的实现(即使在第一个未定义的操作之前的操作也是如此)
因此,如果你的程序包含未定义的行为,那么你的整个程序的行为是不确定的。
首先,让我纠正这个问题的标题:
未定义的行为不是(特别是)执行的领域。
未定义的行为会影响所有步骤:编译,链接,加载和执行。
一些例子来巩固这一点,请记住,没有一个部分是详尽的:
- 编译器可以假设包含未定义行为的代码部分永远不会执行,因此假设导致它们的执行path是死代码。 看看每个C程序员都应该知道什么是未定义的行为 ,而不是由Chris Lattner。
- 链接器可以假设在存在多个弱符号定义(由名称识别)的情况下,所有的定义都是相同的,这要归功于一个定义规则
- 加载器(如果你使用dynamic库)可以假设相同的,因此挑选它find的第一个符号; 这通常是(ab)用于在Unix上使用
LD_PRELOAD
技巧拦截调用 - 执行可能会失败(SIGSEV)你应该使用悬挂指针
这就是未定义行为的可怕之处 :几乎不可能事先预测到会发生什么样的行为,而且这个预测必须在每次更新工具链,底层操作系统时重新进行…
我build议你看Michael Spencer(LLVM开发者)的video: CppCon 2016:My Little Optimizer:未定义的行为是魔术 。
针对16位int
的积极优化的C或C ++编译器会知道向int
types添加1000000000
的行为是未定义的 。
任何标准都允许做任何事情,包括删除整个程序,留下int main(){}
。
但是更大的int
呢? 我不知道这样做的编译器(我不是C和C ++编译器devise方面的专家),但是我想象一下,一个针对32位int
或更高的编译器会发现循环是无限的( i
不会改变) ,所以最终会溢出。 所以再一次,它可以优化输出到int main(){}
。 我试图在这里指出的一点是,随着编译器优化逐渐变得越来越激进,越来越多的未定义行为构造以意想不到的方式体现出来。
你的循环是无限的这个事实本身并没有定义,因为你正在写循环体的标准输出。
从技术上讲,在C ++标准下,如果一个程序包含未定义的行为, 即使在编译时 ( 甚至在程序执行之前),整个程序的行为也是未定义的。
实际上,因为编译器可能会假定(作为优化的一部分)溢出不会发生,所以至less在循环的第三次迭代(假设一个32位机器)上程序的行为将是未定义的,尽pipe它很可能你会在第三次迭代之前得到正确的结果。 然而,由于整个程序的行为在技术上是未定义的,所以没有什么能够阻止程序产生完全不正确的输出(包括没有输出),在执行期间的任何时刻在运行时崩溃,甚至没有完全编译(因为未定义的行为扩展到编译时间)。
未定义的行为为编译器提供了更多的优化空间,因为它们消除了关于代码必须执行的某些假设。 在此过程中,依赖于涉及未定义行为的假设的程序不能保证按预期工作。 因此,您不应该依赖任何被认为是不符合C ++标准的特定行为。
要理解为什么未定义的行为可以像@TartanLlama充分说明的那样“时间旅行”,那么让我们来看一下'as-if'规则:
1.9程序执行
1本标准中的语义描述定义了一个参数化的非确定性抽象机器。 本国际标准对合规实施的结构没有要求。 特别是,他们不需要复制或模拟抽象机器的结构。 相反,需要符合的实现来模拟(仅)抽象机器的可观察行为,如下所述。
有了这个,我们可以把程序视为一个带有input和输出的“黑匣子”。 input可以是用户input,文件和许多其他的东西。 输出结果是标准中提到的“可观察行为”。
该标准只定义了input和输出之间的映射,没有别的。 它通过描述一个“示例黑盒子”来做到这一点,但明确地说,具有相同映射的任何其他黑盒子同样有效。 这意味着黑匣子的内容是不相关的。
考虑到这一点,说在某个时刻发生未定义的行为是没有意义的。 在黑盒的示例实现中,我们可以说出它发生的地点和时间,但是实际的黑盒子可能是完全不同的东西,所以我们不能说出它何时何地发生了。 理论上,编译器可以决定枚举所有可能的input,并预先计算结果输出。 那么未定义的行为将在编译期间发生。
未定义的行为是input和输出之间不存在映射。 一个程序可能对某些input有未定义的行为,但为其他行为定义了行为。 那么input和输出之间的映射就是不完整的。 有没有映射到输出存在的input。
问题中的程序对于任何input都有未定义的行为,所以映射是空的。
假设int
为32位,未定义的行为发生在第三次迭代。 因此,例如,如果循环只是条件可达的,或者在第三次迭代之前可以有条件地终止,那么除非实际达到第三次迭代,否则不会有未定义的行为。 但是,在未定义行为的情况下, 程序的所有输出都是未定义的,包括相对于调用未定义行为的“过去”的输出。 例如,在你的情况下,这意味着不能保证在输出中看到3个“Hello”消息。
TartanLlama的答案是正确的。 即使在编译期间,未定义的行为也可能随时发生。 这可能看起来很荒谬,但这是一个允许编译器做他们需要做的事情的关键特性。 成为编译器并不总是那么容易。 每一次你都必须按照规范所说的去做。 但是,有时要certificate一个特定的行为正在发生是非常困难的。 如果你还记得暂停的问题,那么开发一个你不能certificate是否完成的软件,或者是在进行一个特定的input时,是否进入了一个无限循环,是相当无足轻重的。
我们可以让编制者悲观,不断编译恐惧,下一条指令可能就是这样一个停滞不前的问题之一,但这是不合理的。 相反,我们给编译器一个通行证:在这些“未定义的行为”主题上,他们从任何责任中解脱出来。 未定义的行为包含了所有那些非常邪恶的行为,以至于我们无法将它们与真正令人讨厌的恶意停止问题等等分开。
有一个我喜欢发布的例子,虽然我承认我失去了源,所以我不得不解释一下。 它来自特定版本的MySQL。 在MySQL中,他们有一个循环的缓冲区,充满了用户提供的数据。 他们当然想要确保数据不会溢出缓冲区,所以他们有一个检查:
if (currentPtr + numberOfNewChars > endOfBufferPtr) { doOverflowLogic(); }
它看起来很健全。 但是,如果numberOfNewChars真的很大,并溢出? 然后它绕过并成为比endOfBufferPtr
更小的指针,所以溢出逻辑永远不会被调用。 所以他们在这之前加了第二张支票:
if (currentPtr + numberOfNewChars < currentPtr) { detectWrapAround(); }
它看起来像你照顾缓冲区溢出错误,对不对? 但是,提交了一个错误,指出这个缓冲区在Debian的特定版本上溢出了! 仔细的调查表明,这个版本的Debian是第一个使用gcc特别stream血的版本。 在这个版本的gcc上,编译器认识到currentPtr + numberOfNewChars 永远不会是比currentPtr更小的指针,因为指针的溢出是未定义的行为! 这足以让gcc优化整个检查,并且突然间, 即使您编写了代码来检查它 ,您也不会受到缓冲区溢出的保护!
这是规范的行为。 一切都是合法的(尽pipe从我听说,gcc在下一个版本中回滚了这个变化)。 这不是我认为的直觉行为,但是如果你稍微扩展一下你的想象力,很容易看出这种情况的一个微小的变体可能会成为编译器的一个暂停问题。 正因为如此,规范作者将它定义为“未定义的行为”,并表示编译器可以做任何令人满意的事情。
除了理论上的答案之外,一个实际的观察将是,很长一段时间,编译器已经在循环中应用了各种变换,以减less在它们内部完成的工作量。 例如,给出:
for (int i=0; i<n; i++) foo[i] = i*scale;
编译器可能会将其转换为:
int temp = 0; for (int i=0; i<n; i++) { foo[i] = temp; temp+=scale; }
从而在每次循环迭代中保存一个乘法。 编译器以不同程度的侵略性进行调整的另一种forms的优化将会变成:
if (n > 0) { int temp1 = n*scale; int *temp2 = foo; do { temp1 -= scale; *temp2++ = temp1; } while(temp1); }
即使在有溢出的无声环绕的机器上,如果有一些小于n的数字,就会出现故障,当按比例乘以时,就会产生0.如果从内存中读取比例超过一次,那么它也可能变成无限循环意外地改变了它的值(在任何情况下,“scale”可以在不调用UB的情况下改变中间循环,编译器将不被允许执行优化)。
虽然大多数这样的优化在两个短无符号types相乘以产生介于INT_MAX + 1和UINT_MAX之间的值的情况下不会有任何问题,但是gcc有一些情况,在循环内这样的乘法可能导致循环提前退出。 我没有注意到这样的行为源于生成的代码中的比较指令,但是在编译器使用溢出来推断一个循环最多可以执行4次或更less的情况下是可观察到的; 如果某些input会导致UB,而其他input不会,那么默认情况下不会生成警告,即使其推理导致循环的上限被忽略。
根据定义,未定义的行为是灰色的。 你根本无法预测它会做什么或不会做什么 – 这就是“未定义行为”的含义 。
自古以来,程序员一直试图从一个不确定的情况中拯救定义的残余。 他们已经得到了一些他们真正想使用的代码,但事实certificate这个代码是不确定的,所以他们试图说:“我知道这个代码是不确定的,但是肯定的是,最坏的情况是这样或者这样做,它永远不会这样做“。 有时候这些论点或多或less都是正确的 – 但往往是错误的。 而且,随着编译器变得更聪明,更智能(或者,有些人可能会说,偷偷摸摸,偷偷摸摸),问题的界限就会不断变化。
所以,如果你想编写可以工作的代码,而且这个代码可以长时间工作,那么只有一个select:不惜一切代价避免不确定的行为。 实际上,如果你涉足,它会回来困扰你。
你的例子不考虑的一件事是优化。 a
在循环中设置,但从未使用过,优化器可以解决这个问题。 因此,优化器完全舍弃是合理的,在这种情况下,所有未定义的行为都会像boojum的受害者一样消失。
然而,这当然是不确定的,因为优化是不确定的。 🙂
最好的答案是一个错误的(但常见的)误解:
未定义的行为是一个运行时属性*。 它不能 “时间旅行”!
某些操作(由标准定义)具有副作用并且不能被优化。 执行I / O或访问volatile
variables的操作属于此类别。
但是 ,有一个警告:UB可以是任何行为,包括解除先前操作的行为。 在某些情况下,这可能会产生类似的结果,以优化以前的代码。
实际上,这与顶部答案(重点是我的)中的引用是一致的:
执行一个格式良好的程序的一致性实现应该产生与具有相同程序和相同input的抽象机器的相应实例的可能执行之一相同的可观察行为。
但是,如果任何这样的执行包含未定义的操作,则本国际标准不要求执行该程序的实现 (即使是在第一个未定义的操作之前的操作)。
是的,这个引用确实表示“甚至没有关于第一个未定义操作之前的操作” ,但是请注意,这是专门关于正在执行的代码,而不是仅仅编译的。
毕竟,未定义的行为实际上没有达到什么目的,对于包含UB的行实际到达,其前面的代码必须先执行!
所以是的, 一旦UB被执行 ,以前的操作的任何影响变得不确定。 但在此之前,程序的执行是明确的。
但是,请注意,导致这种情况发生的程序的所有执行都可以针对等效程序进行优化,包括执行以前操作但不执行其效果的任何程序。 因此,前面的代码可能会被优化,只要这样做等效于撤消它们的效果 ; 否则,它不能。 看下面的例子。
*注意:这与编译时发生的UB不一致。 如果编译器确实可以certificateUB代码将始终为所有input执行,那么UB可以延长编译时间。 但是,这需要知道所有以前的代码最终返回 ,这是一个强有力的要求。 再次,看下面的例子/解释。
为了使这个具体,请注意下面的代码必须打印foo
并等待您的input,无论其后的任何未定义的行为:
printf("foo"); getchar(); *(char*)1 = 1;
但是,还要注意的是,在UB出现之后,不能保证foo
将保留在屏幕上,或者input的字符将不再存在于input缓冲区中。 这两个操作都可以“撤消”,这与UB“时间旅行”具有相似的效果。
如果getchar()
行不在那里,那么只要这些行与输出foo
不可区分 ,然后“不行” 就可以将行优化掉。
两者是否无法区分完全取决于实现(即在您的编译器和标准库上)。 例如, printf
可以在等待另一个程序读取输出的同时阻塞您的线程? 还是会立即返回?
-
如果可以在这里阻塞,那么另一个程序可以拒绝读取其全部输出,并且可能永远不会返回,因此UB可能永远不会实际发生。
-
如果它可以立即返回,那么我们知道它必须返回,因此优化它是完全无法区分执行它,然后不起作用。
当然,由于编译器知道它的特定版本的printf
允许哪些行为,因此可以相应地进行优化,因此在某些情况下printf
可能会被优化,而不是其他的。 但是,再次,理由是这与UB无法与之前的操作区分开来, 而不是由于UB而导致先前的代码“中毒”。