90

考虑以下语句:

*((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可以优化整个语句。这种逆向推理按标准是允许的吗?

4

8 回答 8

67

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

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

对象访问有时是按顺序进行的,但标准描述了程序在时间之外的行为。丹维尔已经引用:

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

这可以解释为:

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

因此,带有 UB 的 unreachable 语句不会给程序 UB。一个可到达的语句(由于输入的值)永远不会到达,不会给程序 UB。这就是为什么你的第一个条件太强了。

现在,编译器一般不能分辨出什么有 UB。因此,为了允许优化器对具有潜在 UB 的语句进行重新排序,如果它们的行为被定义,它们将是可重新排序的,有必要允许 UB “及时返回”并在前一个序列点之前出错(或在 C ++11 术语,UB 影响在 UB 事物之前排序的事物)。因此,您的第二个条件太弱了。

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

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

专门回答你的例子:

  • 仅当读取 3 时行为未定义。
  • 如果基本块包含确定为未定义的操作,编译器可以并且确实将代码消除为死代码。在不是基本块但所有分支都通向 UB 的情况下,它们是允许的(我猜是这样)。PrintToConsole(3)除非以某种方式知道肯定会返回,否则此示例不是候选示例。它可能会抛出异常或其他任何东西。

与您的第二个类似的示例是 gcc option -fdelete-null-pointer-checks,它可以采用这样的代码(我没有检查这个具体示例,认为它说明了一般想法):

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

并将其更改为:

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

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

PS 对“未定义行为何时发生?”这个问题的实际答案。是“在您计划出发前 10 分钟”。

于 2014-04-18T12:48:19.520 回答
10

标准状态为 1.9/4

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

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

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

在这里,它特别提到了“使用该输入执行......”。我将其解释为,一个可能的分支中的未定义行为现在未执行不会影响当前的执行分支。

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

于 2014-04-18T11:57:47.610 回答
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 甚至不会给你一个未初始化值的使用警告!(因为应用上述逻辑的传递在生成未初始化值警告的传递之前运行)

于 2014-04-19T02:46:38.410 回答
4

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

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

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

有两篇关于未定义行为和编译器通常做什么的非常好的文章:

于 2014-04-18T12:02:47.467 回答
3

“行为”一词意味着正在做某事。从未被执行的声明者不是“行为”。

插图:

*ptr = 0;

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

那这个呢?

 if (ptr) *ptr = 0;

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

在做出这个答案的过程中,没有任何 srandardese 受到伤害。

于 2014-04-18T13:24:16.713 回答
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实际返回时发生。编译器希望这不会发生(因为无论如何这都会导致程序执行未定义的行为),因此任何事情都可能发生。

但是,让我们考虑其他事情。假设我们正在做空检查,并在空检查后使用变量。

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 周后崩溃,当您的未定义程序未运行时。

于 2014-05-18T11:47:01.617 回答
1

如果程序到达调用未定义行为的语句,则不会对程序的任何输出/行为提出任何要求;它们是否会发生在“之前”或“之后”未定义的行为被调用并不重要。

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

于 2014-04-18T23:15:13.573 回答
1

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

不幸的是,C 和 C++ 标准倾向于回避对事物的描述,虽然不是 100% 需要,但应该期望不记录相反行为的高质量实现。实现应该做某事的建议可能被视为暗示那些不逊色的,并且在通常显而易见的情况下,在给定的实现上,哪些行为有用或实用,而不是不切实际和无用,有该标准几乎没有必要干预此类判断。

一个聪明的编译器可以符合标准,同时消除任何无效的代码,除非代码接收到不可避免地导致未定义行为的输入,但“聪明”和“愚蠢”不是反义词。标准的作者认为可能存在某些类型的实现,其中在给定情况下有用的行为将是无用和不切实际的,这并不意味着对此类行为是否应被视为对其他人实用和有用的任何判断。如果一个实现可以维护一个行为保证,除了失去“死分支”修剪机会之外没有任何成本,那么用户代码可以从该保证中获得的几乎任何价值都将超过提供它的成本。在它不会的情况下,死分支消除可能会很好,但是如果在给定的情况下,用户代码可以处理除死分支消除之外的几乎所有可能的行为那么用户代码为避免 UB 所付出的任何努力都可能超过从 DBE 获得的价值。

于 2017-05-12T21:54:35.070 回答