可以将具有未定义行为的分支视为不可达并优化为死代码吗?


88

考虑以下语句:

*((char*)NULL) = 0; //undefined behavior

它显然会调用未定义的行为。给定程序中存在这样的语句是否意味着整个程序是未定义的,或者行为仅在控制流命中该语句后才变为未定义?

如果用户从不输入数字,以下程序是否定义明确3

while (true) {
 int num = ReadNumberFromConsole();
 if (num == 3)
  *((char*)NULL) = 0; //undefined behavior
}

还是无论用户输入什么,都是完全不确定的行为?

另外,编译器可以假定未定义的行为永远不会在运行时执行吗?这将允许往后倒推:

int num = ReadNumberFromConsole();

if (num == 3) {
 PrintToConsole(num);
 *((char*)NULL) = 0; //undefined behavior
}

在这里,编译器可能会推断出万一num == 3我们总是调用未定义的行为。因此,这种情况必须是不可能的,并且不需要打印号码。整个if语句可以优化。根据标准是否允许这种向后推理?


19
有时我想知道是否有很多代表的用户是否会在问题上获得更多赞誉,因为“哦,他们有很多代表,这一定是一个好问题”……但是在这种情况下,我读了这个问题并认为“哇,这很棒我什至没有看问问问者。
turbulencetoo

4
我认为,时间在不确定的行为出现,是不确定的。
eerorika

6
C ++标准明确指出,在任何时候都具有未定义行为的执行路径是完全未定义的。我什至将其解释为说,路径上具有未定义行为的任​​何程序都是完全未定义的(包括其他部分的合理结果,但不能保证)。编译器可以自由使用未定义的行为来修改程序。blog.llvm.org/2011/05/what-every-c-programmer-should-know.html包含一些不错的示例。
詹斯2014年

4
@Jens:这实际上意味着执行路径。否则你就会陷入麻烦const int i = 0; if (i) 5/i;
MSalters 2014年

1
编译器通常无法证明PrintToConsole不会调用,std::exit因此必须进行调用。
MSalters 2014年

Answers:


65

给定程序中存在这样的语句是否意味着整个程序是未定义的,或者仅在控制流命中该语句后,行为才变得未定义?

都不行 第一个条件太强,第二个条件太弱。

对象访问有时是有序的,但是该标准描述了程序在时间之外的行为。Danvil已引用:

如果任何这样的执行包含未定义的操作,则本国际标准对使用该输入执行该程序的实现没有任何要求(即使对于第一个未定义的操作之前的操作也没有要求)

这可以解释为:

如果程序的执行产生未定义的行为,则整个程序将具有未定义的行为。

因此,用UB无法访问的语句不会给程序UB。永远不会达到(由于输入值的原因)的可达语句不会给程序UB。这就是为什么您的第一个条件太强的原因。

现在,编译器通常无法分辨出具有UB的对象。因此,为了使优化器可以对具有可能的UB的语句进行重新排序(如果定义了它们的行为,这些UB可以重新排序),则必须允许UB“返回时间”并在前一个序列点之前出错(或在C中) ++ 11术语,用于UB影响在UB事物之前排序的事物)。因此,您的第二个条件太弱了。

一个主要的例子是优化器依赖严格的别名。严格的别名规则的全部要点是允许编译器对可能有问题的指针使用同一内存进行别名的操作进行重新排序,而这些操作将无法有效地重新排序。因此,如果使用非法别名指针,并且确实发生了UB,则它很容易影响UB语句“之前”的语句。就抽象机而言,UB语句尚未执行。就实际的目标代码而言,它已部分或完全执行。但是该标准并未尝试详细说明优化器对语句进行重新排序的含义,或对UB的影响。它只是给实施许可提供许可,请尽快使它出错。

您可以将其视为“ UB具有时间机器”。

专门回答您的示例:

  • 仅当读取3时,行为才是未定义的。
  • 如果基本块包含某些不确定的操作,则编译器可以并且确实消除了代码失效的情况。在不是基本块但所有分支均通向UB的情况下,允许使用它们(我猜是这样)。除非PrintToConsole(3)以某种方式知道一定会返回,否则此示例不是候选人。它可能会引发异常或任何异常。

与您的第二个示例类似的示例是gcc选项-fdelete-null-pointer-checks,它可以采用这样的代码(我没有检查过这个特定的示例,请考虑一下它说明了总体思想):

void foo(int *p) {
    if (p) *p = 3;
    std::cout << *p << '\n';
}

并将其更改为:

*p = 3;
std::cout << "3\n";

为什么?因为如果p为null,则代码无论如何都具有UB,因此编译器可以假定其不为null,并进行相应的优化。linux内核为此跳了一下(https://web.nvd.nist.gov/view/vuln/detail?vulnId=CVE-2009-1897),这主要是因为它在应该引用空指针的模式下运行如果是UB,则预期会导致内核可以处理的已定义硬件异常。启用优化后,gcc要求使用,-fno-delete-null-pointer-checks以提供超出标准的保证。

PS对“何时发生未定义行为的问题”的实际答案。是“您打算离开当天前10分钟”。


4
实际上,过去由于这个原因存在很多安全问题。尤其是,任何事后溢出检查都可能因此被优化掉。例如, void can_add(int x) { if (x + 100 < x) complain(); }可以完全进行优化,因为如果x+100 没有溢出,则什么也不发生;如果x+100 发生溢出,则根据标准是UB,因此什么不会发生。
fgp

3
@fgp:是的,这是一个优化,如果人们越过它,人们会痛苦地抱怨它,因为它开始感觉像编译器正在故意破坏您的代码以惩罚您。“如果我要您删除它,为什么我要这样写呢!” ;-)但是,我认为有时在处理较大的算术表达式时,它对于优化器很有用,假定没有溢出并避免了仅在那些情况下才需要的任何昂贵的事情。
史蒂夫·杰索普

2
如果用户从不输入3,那么说程序不是不确定的,是否正确,如果在执行过程中输入3,整个执行就变得不确定了,这是正确的吗?只要100%确定该程序将调用未定义的行为(并且不得早于该时间),该行为就可以成为任何东西。我的这些陈述是100%正确的吗?
usr

3
@usr:我相信这是正确的,是的。对于您的特定示例(并对正在处理的数据的不可避免性做出一些假设),我认为一种实现原则上可以在缓冲的STDIN中向前看3是否愿意,并在看到它后立即打包回家。传入。
史蒂夫·杰索普

3
为您的PS多加+1(如果可以的话)
Fred Larson

10

标准规定为1.9 / 4

[注意:本国际标准对包含未定义行为的程序的行为没有任何要求。—尾注]

有趣的一点可能是“包含”的含义。稍后在1.9 / 5,它指出:

但是,如果任何这样的执行包含未定义的操作,则此国际标准对使用该输入执行该程序的实现没有任何要求(即使对于第一个未定义的操作之前的操作也没有要求)

在这里,它专门提到“使用该输入执行...”。我会解释为,在一个可能的分支中未定义的行为目前尚未执行,这不会影响当前的执行分支。

但是,另一个问题是基于代码生成期间未定义行为的假设。有关更多信息,请参见Steve Jessop的答案。


1
如果从字面上看,那就是所有存在的真实程序的死刑判决。
usr 2014年

6
我认为问题不在于UB是否可能实际到达代码之前出现。据我了解,问题是,如果甚至无法达到该代码,UB是否会出现。当然,答案是“否”。
sepp2k

好吧,标准在1.9 / 4中对此不太清楚,但是1.9 / 5可以解释为您所说的。
Danvil

1
注释是非规范性的。1.9 / 5胜过音符在1.9 / 4
MSalters

5

一个有启发性的例子是

int foo(int x)
{
    int a;
    if (x)
        return a;
    return 0;
}

当前的GCC和当前的Clang都将对此进行优化(在x86上)以

xorl %eax,%eax
ret

因为它们从控制路径的UB 推论得出x始终为零if (x)。GCC甚至不会给您使用未初始化的值警告!(因为应用上述逻辑的过程在生成未初始化值警告的过程之前运行)


1
有趣的例子。启用优化隐藏警告是很讨厌的。甚至没有记录-GCC文档仅说启用优化会产生更多警告。
sleske 2014年

@sleske,我很讨厌,但是众所周知,未初始化的值警告很难“正确地做到”- 完美地执行警告等同于停止问题,并且程序员对于在误报中添加“不必要的”变量初始化感到非常不理性,因此,编译器作者忙得不亦乐乎。我曾经在GCC上黑客,我回想起每个人都害怕弄乱未初始化的价值警告通行证。
zwol 2014年

@zwol:我想知道消除这种死代码会导致多少“优化”实际上使有用的代码变小,又有多少最终导致程序员使代码变大(例如,a即使在所有情况下,通过添加代码进行初始化)未初始化a会传递给函数,函数将永远不会对其执行任何操作)?
supercat

@supercat在大约10年的时间里我一直没有深入从事编译器工作,并且几乎不可能从玩具示例中得出优化的理由。如果我没记错的话,这种类型的优化往往会使实际应用程序的整体代码大小减少2-5%。
zwol 2015年

1
随着这些事情的发展,@ supercat 2-5%很大。我见过人们出汗为0.1%。
zwol

4

当前的C ++工作草案在1.9.4中指出

本国际标准对包含未定义行为的程序的行为没有任何要求。

基于此,我想说一个在任何执行路径上包含未定义行为的程序,在每次执行时都可以做任何事情。

关于未定义行为和编译器通常会执行的工作,有两篇非常好的文章:


1
这是没有意义的。该函数int f(int x) { if (x > 0) return 100/x; else return 100; }当然不会调用未定义的行为,即使100/0它当然是未定义的。
fgp

1
@fgp标准(尤其是1.9 / 5)说的是,如果可以达到未定义的行为,则何时达到就无关紧要。例如,printf("Hello, World"); *((char*)NULL) = 0 不能保证打印任何内容。这有助于优化,因为编译器可以自由地重新排序它知道最终将要发生的操作(当然要受依赖关系约束),而不必考虑未定义的行为。
fgp

我要说的是,具有您函数的程序不会包含未定义的行为,因为没有输入将评估100/0。
詹斯(Jens)2014年

1
确实-因此重要的是UB是否可以实际触发,而不是理论上是否可以触发。或者您是否准备争论int x,y; std::cin >> x >> y; std::cout << (x+y);说“ 1 + 1 = 17”,仅是因为有些输入x+y溢出(因为它int是带符号类型,所以它是UB )。
fgp

形式上,我会说该程序具有未定义的行为,因为存在触发它的输入。但是您说对了,在C ++上下文中这是没有意义的,因为如果没有未定义的行为,就不可能编写程序。当C ++中未定义的行为较少时,我会喜欢它,但是那不是该语言的工作方式(这有很多充分的理由,但是它们与我的日常用法无关)。
詹斯(Jens)2014年

3

单词“行为”是指东西正在。永远不会执行的状态陈述不是“行为”。

插图:

*ptr = 0;

那是未定义的行为吗?假设我们100%确定ptr == nullptr在程序执行期间至少一次。答案应该是。

那这个呢?

 if (ptr) *ptr = 0;

那是不确定的吗?(ptr == nullptr至少记得一次?)我当然希望不会,否则您将根本无法编写任何有用的程序。

在做出这个答案时,并没有损害任何南美白兰地。


3

无论程序接下来发生什么,程序都会导致未定义行为时,将触发未定义行为。但是,您给出了以下示例。

int num = ReadNumberFromConsole();

if (num == 3) {
 PrintToConsole(num);
 *((char*)NULL) = 0; //undefined behavior
}

除非编译器知道的定义PrintToConsole,否则它不能删除if (num == 3)条件的。假设您的LongAndCamelCaseStdio.h系统标头带有以下声明PrintToConsole

void PrintToConsole(int);

没什么太大的帮助,好的。现在,通过检查此函数的实际定义,让我们看看供应商有多邪恶(或者可能不是那么邪恶,未定义的行为可能更糟)。

int printf(const char *, ...);
void exit(int);

void PrintToConsole(int num) {
    printf("%d\n", num);
    exit(0);
}

编译器实际上必须假设编译器不知道其任意功能可能会退出或引发异常(对于C ++)。您会注意到*((char*)NULL) = 0;它将不会被执行,因为PrintToConsole调用后执行不会继续。

未定义的行为会在以下情况时发生 PrintToConsole实际返回。编译器希望不会发生这种情况(因为这将导致程序无论如何执行未定义的行为),因此任何事情都可能发生。

但是,让我们考虑其他问题。假设我们正在执行null检查,并在null检查之后使用该变量。

int putchar(int);

const char *warning;

void lol_null_check(const char *pointer) {
    if (!pointer) {
        warning = "pointer is null";
    }
    putchar(*pointer);
}

在这种情况下,很容易注意到lol_null_check需要一个非NULL指针。分配给全局非易失性warning变量不会导致程序退出或引发任何异常。该pointer也是非易失性的,所以它不能在神奇的功能中改变它的值(如果是这样,这是不确定的行为)。调用lol_null_check(NULL)将导致未定义的行为,这可能导致未分配变量(因为在这一点上,程序执行未定义行为的事实是已知的)。

但是,未定义的行为意味着程序可以执行任何操作。因此,没有什么可以阻止未定义的行为回到过去,并在int main()执行第一行之前使程序崩溃。这是未定义的行为,没有必要。在键入3之后,它也可能崩溃,但是未定义的行为会回到过去,甚至在您键入3之前就会崩溃。谁知道,也许未定义的行为会覆盖系统RAM,并在2周后导致系统崩溃,未定义的程序未运行时。


所有有效点。PrintToConsole这是我尝试插入程序外部副作用的尝试,该副作用即使在崩溃后仍然可见,并且具有强烈的顺序。我想创建一种情况,我们可以确定该语句是否已优化。但是您说对了,因为它可能永远不会返回。您编写全局变量的示例可能会受到与UB不相关的其他优化的影响。例如,可以删除未使用的全局变量。您是否有一种以保证返回控制的方式创建外部副作用的想法?
usr

可以由编译器自由承担收益的代码产生任何外界可以观察到的副作用吗?以我的理解,即使是简单地读取volatile变量的方法也可以合法地触发I / O操作,从而可能立即中断当前线程。然后,中断处理程序可以在线程有机会执行其他任何操作之前将其杀死。我认为没有任何理由可以使编译器在此之前推送未定义的行为。
超级猫

从C标准的角度来看,具有“未定义的行为”会导致计算机向某些人发送消息,这些人将跟踪并销毁程序以前的操作的所有证据,这并没有违法行为,但是如果某个操作可以终止线程,则在该操作之前进行排序的所有内容都必须在此操作之后发生的任何未定义行为之前发生。
超级猫

1

如果程序到达调用未定义行为的语句,则对程序的任何输出/行为都没有要求;调用未定义行为是在“发生之前”还是“发生之后”都无所谓。

您对所有三个代码段的推理都是正确的。特别是,编译器可以将任何无条件调用GCC对待未定义行为的语句视为__builtin_unreachable():作为该语句不可访问的优化提示(因此,无条件通向该语句的所有代码路径也是不可访问的)。当然,其他类似的优化也是可能的。


1
出于好奇,什么时候__builtin_unreachable()开始产生随时间向前和向后进行的效果?给定类似的东西,extern volatile uint32_t RESET_TRIGGER; void RESET(void) { RESET_TRIGGER = 0xAA55; __memorybarrier(); __builtin_unreachable(); }我可以认为builtin_unreachable()让编译器知道它可以省略return指令是很好的,但这与说可以省略前面的代码有很大不同。
2015年

@supercat,因为RESET_TRIGGER是易失性的,对该位置的写入会产生任意副作用。对于编译器,这就像一个不透明的方法调用。因此,无法证明(并非如此)__builtin_unreachable已达到。该程序已定义。
usr

@usr:我认为低级编译器应该将易失性访问视为不透明的方法调用,但是clang和gcc都不会这样做。除其他事项外,不透明的方法调用可能会导致使用写入地址已暴露给外部世界且实时restrict指针既未访问也不会被其访问的任何对象的所有字节unsigned char*
超级猫

@usr:如果编译器不将易失性访问作为对公开对象的访问的不透明方法调用,则我认为没有特别的理由期望它可以将其用于其他目的。该标准不需要执行此操作,因为在某些硬件平台上,编译器可能会知道可变访问的所有可能影响。但是,适合嵌入式使用的编译器应认识到,易失性访问可能会触发编写该编译器时尚未发明的硬件。
超级猫

@supercat我认为你是对的。易失性操作似乎“对抽象机没有影响”,因此不能终止程序或引起副作用。
usr

1

使用与IETF RFC 2119相似的命名法,许多事物的许多标准都花了很多精力来描述实现不应该或不应该实现的事物(尽管不一定引用该文档中的定义)。在许多情况下,对实现应该做的事情的描述比那些对所有要求都更为重要的描述更为重要,除非它们是无用的或不切实际的。符合实现的实现必须符合。

不幸的是,C和C ++标准倾向于避开对事物的描述,尽管这些描述不是100%必需的,但是对于那些没有相反行为证明的质量实现,应该期望这些描述。关于实现应该做某事的建议可能被认为暗示着那些不逊色的行为,并且在通常很明显的情况下,对于给定的实现,哪些行为是有用或可行的,而不是不切实际和无用的行为,几乎没有人认为标准需要干扰这种判断。

聪明的编译器可以符合标准,同时消除任何无效的代码,除非代码收到不可避免地导致未定义行为的输入,但“聪明”和“笨拙”不是反义词。该标准的作者认为,可能存在某些实施方式,在给定情况下有用的行为将是无用且不切实际的,这一事实并不意味着对是否应将这种行为视为对他人有用和有用的判断。如果实现可以在不损失“死枝”修剪机会的情况下维持任何行为的行为保证,那么几乎任何用户代码可以从该保证中获得的价值都将超过提供它的成本。在不这样做的情况下,消除死枝可能会很好,但如果在特定情况下的用户代码可能已经处理了几乎所有可能的行为不是死了分支消除,任何努力用户代码将不得不花费,避免UB可能会超过从DBE实现价值。


避免UB会给用户代码带来成本,这是一个很好的观点。
usr

@usr:这是现代主义者完全错过的一点。我应该添加一个例子吗?例如,如果代码需要评估x*y < z何时x*y不溢出,并且在溢出情况下以任意方式产生0或1且没有副作用的情况,则在大多数平台上没有理由为什么满足第二个和第三个要求应该比第二个更昂贵满足第一个条件,但以任何方式编写表达式以确保在所有情况下都保证标准定义的行为将在某些情况下增加大量成本。写表达式(int64_t)x*y < z可能会使计算成本增加四倍...
超级猫

...在某些平台上进行编写,这(int)((unsigned)x*y) < z将阻止编译器采用原本可能有用的代数替换(例如,如果知道xz相等且为正,则可以将原始表达式简化为y<0,但是使用unsigned的版本会强制编译器执行乘法)。如果编译器可以保证即使标准没有强制要求,它将支持“无副作用的0或1收益”要求,则用户代码可以为编译器提供其他方式无法获得的优化机会。
超级猫

是的,在这里似乎有些温和的不确定行为形式会有所帮助。程序员可以打开一种模式,该模式会x*y在溢出的情况下发出正常值,但根本不会发出任何值。C / C ++中的可配置UB对我而言似乎很重要。
usr

@usr:如果C89标准的作者如实地说,将短的无符号值提升为签名是最严重的突破性变化,而不是愚昧无知的傻瓜,这意味着他们希望在平台具有以下特征的情况下一直在定义有用的行为保证,此类平台的实现已将这些保证提供给程序员,并且程序员一直在利用它们,无论标准是否要求它们,此类平台的编译器都将继续提供此类行为保证。
超级猫
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.