94

如果我有:

unsigned int x;
x -= x;

很明显,在这个表达式之后x 应该为零,但是无论我看哪里,他们都说这段代码的行为是未定义的,而不仅仅是x(直到减法之前)的值。

两个问题:

  • 这段代码的行为是否确实未定义?
    (例如,代码可能会在兼容的系统上崩溃[或更糟]?)

  • 如果是这样,为什么C 说行为未定义,而这里很清楚x应该为零?

    即在这里不定义行为有 什么好处?

显然,编译器可以简单地在变量中使用它认为“方便”的任何垃圾值,它会按预期工作......这种方法有什么问题?

4

7 回答 7

95

是的,这种行为是未定义的,但原因与大多数人所知道的不同。

首先,使用未初始化的值本身并不是未定义的行为,但该值只是不确定的。如果该值恰好是该类型的陷阱表示,那么访问它就是 UB。无符号类型很少有陷阱表示,所以在这方面你会相对安全。

使行为未定义的原因是变量的附加属性,即它“可以用register”声明,即它的地址永远不会被占用。此类变量被特殊处理,因为有些架构具有真正的 CPU 寄存器,这些寄存器具有一种“未初始化”的额外状态,并且与类型域中的值不对应。

编辑:标准的相关短语是 6.3.2.1p2:

如果左值指定了一个可以使用寄存器存储类声明的具有自动存储持续时间的对象(从未使用过它的地址),并且该对象未初始化(未使用初始化程序声明并且在使用之前没有对其进行分配),行为未定义。

为了更清楚起见,以下代码在任何情况下都是合法的:

unsigned char a, b;
memcpy(&a, &b, 1);
a -= a;
  • 这里取a和的地址b,所以它们的值只是不确定的。
  • 由于unsigned char从不存在不确定值只是未指定的陷阱表示,因此任何值都unsigned char可能发生。
  • 最后a 必须保持值0

Edit2: ab具有未指定的值:

3.19.3 unspecified value
相关类型的有效值,本国际标准对在任何情况下选择哪个值没有要求

于 2012-08-15T07:13:13.717 回答
25

C 标准为编译器提供了很大的自由度来执行优化。如果您假设一个天真的程序模型,其中未初始化的内存被设置为某种随机位模式并且所有操作都按照它们写入的顺序执行,那么这些优化的后果可能会令人惊讶。

注意:以下示例仅有效,因为x它的地址从未被占用,因此它是“类寄存器”。x如果类型有陷阱表示,它们也是有效的;对于无符号类型很少出现这种情况(它需要“浪费”至少一位存储空间,并且必须记录在案),对于unsigned char. 如果x具有带符号的类型,则实现可以将不是介于 -(2 n-1 -1) 和 2 n-1 -1 之间的数字的位模式定义为陷阱表示。请参阅Jens Gustedt 的回答

编译器尝试将寄存器分配给变量,因为寄存器比内存快。由于程序使用的变量可能比处理器拥有的寄存器多,编译器执行寄存器分配,这导致不同的变量在不同的时间使用同一个寄存器。考虑程序片段

unsigned x, y, z;   /* 0 */
y = 0;              /* 1 */
z = 4;              /* 2 */
x = - x;            /* 3 */
y = y + z;          /* 4 */
x = y + 1;          /* 5 */

评估第 3 行时,x还没有初始化,因此(编译器的原因)第 3 行必须是某种侥幸,由于编译器不够聪明而无法弄清楚的其他条件,所以不会发生这种侥幸。由于z在第 4 行之后不使用,x也不在第 5 行之前使用,因此两个变量可以使用同一个寄存器。所以这个小程序被编译成对寄存器进行如下操作:

r1 = 0;
r0 = 4;
r0 = - r0;
r1 += r0;
r0 = r1;

的最终值x是 的最终值r0, 的最终值y是 的最终值r1。这些值是 x = -3 和 y = -4,而不是 5 和 4,如果x正确初始化会发生这种情况。

对于更详细的示例,请考虑以下代码片段:

unsigned i, x;
for (i = 0; i < 10; i++) {
    x = (condition() ? some_value() : -x);
}

假设编译器检测到condition没有副作用。由于condition不修改x,编译器知道循环的第一次运行不可能被访问x,因为它还没有初始化。因此循环体的第一次执行相当于x = some_value(),不需要测试条件。编译器可能会编译此代码,就像您编写的一样

unsigned i, x;
i = 0; /* if some_value() uses i */
x = some_value();
for (i = 1; i < 10; i++) {
    x = (condition() ? some_value() : -x);
}

这可以在编译器内部建模的方式是考虑任何依赖的值只要未初始化就x具有方便的值。x因为未定义变量时的行为,而不是仅具有未指定值的变量,所以编译器不需要跟踪任何方便值之间的任何特殊数学关系。因此编译器可以这样分析上面的代码:

  • 在第一次循环迭代期间,被评估x的时间未初始化。-x
  • -x具有未定义的行为,因此它的值是方便的。
  • 优化规则适用,因此此代码可以简化为.condition ? value : valuecondition; value

当遇到您问题中的代码时,同一个编译器会分析,在x = - x评估时, 的值-x是方便的。因此可以优化分配。

我还没有寻找表现如上所述的编译器的示例,但它是优秀编译器尝试做的那种优化。遇到一个我不会感到惊讶。这是您的程序崩溃的编译器的一个不太合理的示例。(如果你在某种高级调试模式下编译你的程序,这可能不是那么令人难以置信。)

这个假设的编译器将每个变量映射到不同的内存页面并设置页面属性,以便从未初始化的变量中读取会导致调用调试器的处理器陷阱。对变量的任何赋值首先确保其内存页被正常映射。此编译器不会尝试执行任何高级优化——它处于调试模式,旨在轻松定位诸如未初始化变量之类的错误。评估时x = - x,右侧会导致陷阱并且调试器会启动。

于 2012-08-15T00:51:07.330 回答
17

是的,程序可能会崩溃。例如,可能存在可能导致 CPU 中断的陷阱表示(无法处理的特定位模式),未处理的中断可能会使程序崩溃。

(C11 后期草案中的 6.2.6.1 说)某些对象表示不需要表示对象类型的值。如果对象的存储值具有这样的表示形式并且由不具有字符类型的左值表达式读取,则行为未定义。如果这种表示是由不具有字符类型的左值表达式修改对象的全部或任何部分的副作用产生的,则行为是未定义的。50) 这种表示称为陷阱表示。

(此解释仅适用于unsigned int可以具有陷阱表示的平台,这在现实世界的系统中很少见;请参阅评论以获取详细信息以及对导致标准当前措辞的替代和可能更常见原因的参考。)

于 2012-08-14T23:50:05.610 回答
12

(此答案针对 C 1999。对于 C 2011,请参阅 Jens Gustedt 的答案。)

C 标准并没有说使用未初始化的自动存储持续时间的对象的值是未定义的行为。C 1999 标准在 6.7.8 10 中说,“如果具有自动存储持续时间的对象未显式初始化,则其值是不确定的。” (本段继续定义静态对象是如何初始化的,所以我们关心的唯一未初始化的对象是自动对象。)

3.17.2 将“不确定值”定义为“未指定的值或陷阱表示”。3.17.3 将“未指定值”定义为“相关类型的有效值,其中本国际标准对在任何情况下选择哪个值没有要求”。

因此,如果未初始化unsigned int x的具有未指定的值,则x -= x必须产生零。这就留下了它是否可能是一个陷阱表示的问题。根据 6.2.6.1 5,访问陷阱值确实会导致未定义的行为。

某些类型的对象可能具有陷阱表示,例如浮点数的信号 NaN。但是无符号整数是特殊的。根据 6.2.6.2,unsigned int 的 N 个值位中的每一个表示 2 的幂,并且值位的每个组合表示从 0 到 2 N -1 的值之一。因此,无符号整数只能由于其填充位(例如奇偶校验位)中的某些值而具有陷阱表示。

如果在您的目标平台上,unsigned int 没有填充位,则未初始化的 unsigned int 不能有陷阱表示,并且使用它的值不会导致未定义的行为。

于 2012-08-15T00:54:54.910 回答
11

是的,它是未定义的。代码可能会崩溃。C 说行为是未定义的,因为没有特定的理由对一般规则做出例外。其优点与所有其他未定义行为的情况相同——编译器不必输出特殊代码即可使其工作。

显然,编译器可以简单地在变量中使用它认为“方便”的任何垃圾值,它会按预期工作......这种方法有什么问题?

为什么你认为这不会发生?这正是所采取的方法。编译器不需要让它工作,但它不需要让它失败。

于 2012-08-14T23:50:30.957 回答
10

对于任何类型的任何变量,未初始化或由于其他原因保持不确定值,以下适用于读取该值的代码:

  • 如果变量具有自动存储持续时间并且没有获取其地址,则代码始终会调用未定义的行为 [1]。
  • 否则,如果系统支持给定变量类型的陷阱表示,代码总是调用未定义的行为 [2]。
  • 否则,如果没有陷阱表示,则变量采用未指定的值。不能保证每次读取变量时这个未指定的值都是一致的。但是,它保证不是陷阱表示,因此保证不会调用未定义的行为 [3]。

    然后可以安全地使用该值而不会导致程序崩溃,尽管这样的代码不能移植到具有陷阱表示的系统。


[1]:C11 6.3.2.1:

如果左值指定了一个可以使用寄存器存储类声明的具有自动存储持续时间的对象(从未使用过它的地址),并且该对象未初始化(未使用初始化程序声明并且在使用之前没有对其进行分配),行为未定义。

[2]:C11 6.2.6.1:

某些对象表示不需要表示对象类型的值。如果对象的存储值具有这样的表示形式并且由不具有字符类型的左值表达式读取,则行为未定义。如果这种表示是由不具有字符类型的左值表达式修改对象的全部或任何部分的副作用产生的,则行为是未定义的。50) 这种表示称为陷阱表示。

[3] C11:

3.19.2
indeterminate value
未指定的值或陷阱表示

3.19.3
unspecified value
相关类型的有效值,其中本国际标准对在任何情况下选择哪个值没有要求
注:未指定的值不能是陷阱表示。

3.19.4
陷阱表示
不需要表示对象类型值的对象表示

于 2016-11-18T10:35:28.130 回答
1

虽然许多答案都集中在捕获未初始化寄存器访问的处理器上,但即使在没有此类陷阱的平台上也可能出现古怪的行为,使用没有特别努力利用 UB 的编译器。考虑代码:

volatile uint32_t a,b;
uin16_t moo(uint32_t x, uint16_t y, uint32_t z)
{
  uint16_t temp;
  if (a)
    temp = y;
  else if (b)
    temp = z;
  return temp;  
}

对于像 ARM 这样的平台的编译器,除了加载和存储之外的所有指令都在 32 位寄存器上运行,可能会以等同于以下方式合理地处理代码:

volatile uint32_t a,b;
// Note: y is known to be 0..65535
// x, y, and z are received in 32-bit registers r0, r1, r2
uin32_t moo(uint32_t x, uint32_t y, uint32_t z)
{
  // Since x is never used past this point, and since the return value
  // will need to be in r0, a compiler could map temp to r0
  uint32_t temp;
  if (a)
    temp = y;
  else if (b)
    temp = z & 0xFFFF;
  return temp;  
}

如果任一 volatile 读取产生非零值,则 r0 将加载 0...65535 范围内的值。否则它将产生调用函数时所持有的任何值(即传递给 x 的值),这可能不是 0..65535 范围内的值。该标准缺少任何术语来描述类型为 uint16_t 但其值超出 0..65535 范围的值的行为,除非说任何可能产生此类行为的操作都会调用 UB。

于 2016-08-08T16:51:56.553 回答