(为什么)使用未初始化变量的未定义行为?


82

如果我有:

unsigned int x;
x -= x;

很显然,在此表达式之后x 应该为零,但是我所看到的所有地方都说该代码的行为是不确定的,而不仅仅是x(直到减法之前)的值。

两个问题:

  • 此代码的行为确实未定义吗?
    (例如,代码是否可能在兼容的系统上崩溃?)

  • 如果是这样,为什么C明确表示该行为x为零,为什么还说行为未定义?

    即,通过在此不定义行为有什么好处

显然,编译器可以在变量内部简单地使用它认为“方便”的任何垃圾值,并且可以按预期工作……该方法有什么问题?



3
通过在此处定义行为的特殊情况有什么好处?当然,要让所有程序和库都变大和变慢,因为@Mehrdad希望避免在一种特定且罕见的情况下初始化变量。
Paul Tomblin,2012年

9
@ W'rkncacnter我不同意这种欺骗。不管它取什么值,OP都希望它在之后为零x -= x。问题是,为什么访问所有未初始化的值都是UB。
Mysticial

6
有趣的是,语句x = 0; 通常在汇编中转换为xor x,x。它几乎与您要在此处执行的操作相同,但是使用xor而不是减法。
0xFE 2012年

1
“即在此不定义行为有什么好处?'-我以为该标准的优点是不会列出不依赖于一个或多个变量的值的表达式的无穷大之处。同时,@ Paul,对标准的这种更改不会使程序和库变得更大。
Jim Balter 2012年

Answers:


90

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

首先,使用单位化的值本身并不是不确定的行为,但是该值只是不确定的。如果该值恰好是该类型的陷阱表示,则访问该对象将是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: a并且b具有未指定的值:

3.19.3 在本国际标准不要求在任何情况下选择哪种值的情况下,相关类型的未指定值
有效值


6
也许我缺少了一些东西,但在我看来unsigneds肯定可以具有陷阱表示形式。您能指出标准的那一部分吗?我在§6.2.6.2/ 1中看到以下内容:“对于无符号char以外的符号整数类型,对象表示的位应分为两组:值位和填充位(后者无须再填充)。 ...这将被称为值表示。未指定任何填充位的值。“”带有注释的注释:“⁴⁴⁾填充位的某些组合可能会生成陷阱表示”。
conio

6
继续评论:“例如,如果一个填充位是奇偶校验位,则填充位的某些组合可能会生成陷阱表示。不管如何,对有效值的算术运算都不能生成陷阱表示,除非是作为特殊条件的一部分,例如溢出,而对于无符号类型则不会发生。” -一旦我们有了一个有效的值就可以了,但是不确定的值可能是初始化之前的陷阱表示(例如,奇偶校验位设置错误)。
conio

4
@conio您对除之外的所有其他类型都是正确的unsigned char,但是此答案正在使用unsigned char。不过请注意:严格合规的程序可以sizeof(unsigned) * CHAR_BIT基于来计算和确定UINT_MAX特定实现不可能具有的陷阱表示unsigned。在该程序确定之后,即可继续执行此答案的确切工作unsigned char

4
@JensGustedt:是不是memcpy很分散注意力,也就是说,如果您的示例被替换,它是否仍然适用*&a = *&b;
R .. GitHub停止帮助ICE

4
@R ..我不确定了。C委员会的邮件列表上正在进行讨论,所有这些似乎都是一团糟,也就是说,预期的行为与实际记录之间存在很大的差距。但是,很清楚的是,访问内存unsigned char并因此有所memcpy帮助,因此*&不清楚。一旦解决,我会报告。
詹斯·古斯特

24

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行时,第3x行必须进行初始化,因此(由于编译器的原因)第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评估时,右侧会导致陷阱,调试器将启动。


+1不错的解释,标准特别注意了这种情况。有关该故事的继续,请参阅下面的答案。(时间太长,无法发表评论)。
詹斯·古斯特

@JensGustedt哦,您的回答很重要,我(和其他人)都忽略了这一点:除非该类型具有陷阱值(对于一个无符号类型,它至少需要“浪费”一位)x具有一个未初始化的值,但是访问时的行为会如果x没有类似寄存器的行为,则定义它。
吉尔斯(Gillles)“所以-别再邪恶了”

@Gilles:至少clang进行了您提到的优化:(1)(2)(3)
Vlad13年

1
以这种方式执行clang处理事物有什么实际的优势?如果下游代码从不使用的值x,则无论其值是否已定义,都可以忽略对其的所有操作。如果后面的代码对于在产生零的情况下可能包含的if (volatile1) x=volatile2; ... x = (x+volatile3) & 255;任何值0-255同样满意,我认为允许程序员省略不必要的写入的实现应被认为比其质量更高。会表现...xvolatile1x
超级猫

...在那种情况下完全无法预测。在某些情况下,可以可靠地在这种情况下实现实现定义的陷阱的实现可能被视为具有更高的质量,但是在我看来,完全不可预测地表现为几乎任何目的的最低质量的行为形式。
超级猫

16

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

(在C11最新草案中有6.2.6.1所述)某些对象表示形式不必表示对象类型的值。如果对象的存储值具有这种表示形式,并且由不具有字符类型的左值表达式读取,则该行为是不确定的。如果这种表示是由副作用产生的,该副作用通过不具有字符类型的左值表达式修改对象的全部或任何部分,则该行为是不确定的。50)这种表示称为陷阱表示。

(此说明仅适用于unsigned int可以使用陷阱表示的平台,这在现实世界的系统中很少见;有关详细信息和参考,请参见注释,以获取导致标准当前措辞的其他可能更常见的原因。)


3
@VladLazarenko:这是关于C的,而不是特定的CPU。任何人都可以琐碎地设计一个具有整数位模式的CPU,这会使它发疯。考虑一个在其寄存器中具有“疯狂位”的CPU。
大卫·史瓦兹

2
那么,我能否说在整数和x86的情况下行为已得到很好的定义?

3
好吧,从理论上讲,您可以让一个编译器决定只使用28位整数(在x86上),并添加特定的代码来处理每个加法,乘法(依此类推),并确保这4位未使用(否则发出SIGSEGV )。未初始化的值可能会导致这种情况。
eq-

4
我讨厌有人侮辱别人,因为有人不理解这个问题。行为是否未定义完全取决于标准的规定。哦,关于eq的场景根本没有任何实用价值……这完全是人为的。
Jim Balter 2012年

7
@Vlad Lazarenko:Itanium CPU的每个整数寄存器都有一个NaT(不是东西)标志。NaT标志用于控制推测执行,并且可能会在使用前未正确初始化的寄存器中徘徊。从设置了NaT位的此类寄存器读取会产生异常。参见blogs.msdn.com/b/oldnewthing/archive/2004/01/19/60162.aspx
Nordic Mainframe,

13

(此答案的答案为C1999。有关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,无符号int的N个值位中的每个表示2的幂,并且值位的每个组合表示从0到2 N -1的值之一。因此,无符号整数只能由于其填充位(例如奇偶校验位)中的某些值而具有陷阱表示形式。

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


如果x有陷阱表示,那么x -= x可能会陷阱,对吗?尽管如此,用于指出没有多余位的无符号整数的+1必须具有已定义的行为-显然与其他答案相反,并且(根据引号)这似乎是标准所暗示的含义。
user541686

是的,如果类型x具有陷阱表示,则x -= x可能会陷阱。即使简单地x用作值也可能会陷阱。(x用作左值是安全的;写入对象不会受到其中的陷阱表示的影响。)
Eric Postpischil 2012年

无符号类型很少具有陷阱表示形式
Jens Gustedt 2012年

报价单 雷蒙德·陈Raymond Chen)的话:“在ia64上,每个64位寄存器实际上是65位。多余的位称为“ NaT”,表示“不是物”。当该寄存器不包含有效值时,该位置1。可以将其视为浮点NaN的整数形式。...如果您有一个值为NaT的寄存器,而您却以错误的方式对其进行了呼吸(例如,尝试将其值保存到内存中),则处理器将引发STATUS_REG_NAT_CONSUMPTION异常”。即,陷阱位可以完全在值之外。
干杯和健康。-Alf

-1语句“如果在您的目标平台上,无符号int没有填充位,则未初始化的无符号int不能具有陷阱表示,并且使用其值不能导致未定义的行为。” 无法考虑类似x64 NaT位的方案。
干杯和健康。-Alf

11

是的,它是未定义的。代码可能崩溃。C表示行为是不确定的,因为没有特殊原因可以对通用规则进行例外处理。优点是与其他所有未定义行为的情况具有相同的优点-编译器无需输出特殊代码即可完成此工作。

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

您为什么认为那不会发生?这就是所采用的方法。不需要编译器使其正常工作,但不需要使编译器失败。


1
不过,编译器也不必为此使用特殊的代码。简单地分配空间(一如既往)而不初始化变量会使其具有正确的行为。我认为这不需要特殊的逻辑。
user541686

7
1)当然可以。但是我想不出任何能使它变得更好的论点。2)平台知道无法依赖未初始化内存的值,因此可以自由更改它。例如,它可以在后台将未初始化的内存清零,以便在需要时可以使用零页。(请考虑是否发生这种情况:1)我们读取要减去的值,说得到3。2)由于未初始化页面而将其清零,因此将值更改为0。3)我们执行了原子减法,分配了页面并使值-3。糟糕!)
David Schwartz 2012年

2
-1,因为您根本没有为您的要求提供任何理由。在某些情况下,可以期望编译器仅采用在内存位置中写入的值。
詹斯·古斯特

1
@JensGustedt:我不明白你的评论。你能澄清一下吗?
大卫·史瓦兹

3
因为您只是声称有一条通用规则,而没有引用它。因此,这只是“权威证明”的尝试,这并不是我对SO的期望。并且因为没有有效地争论为什么这不是一个不确定的值。在一般情况下,这是UB的唯一原因是x可以声明为register,即从不使用其地址。我不知道您是否意识到这一点(如果您将其有效地隐藏起来),但是必须给出正确的答案。
詹斯·古斯特

6

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

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

    然后可以安全地使用该值,而不会导致程序崩溃,尽管此类代码不适用于具有陷阱表示形式的系统。


[1]:C11 6.3.2.1:

如果左值指定了可以使用寄存器存储类声明的自动存储持续时间的对象(从未使用其地址),并且该对象未初始化(未使用初始化器声明,并且在使用前未对其进行任何赋值) ),则行为未定义。

[2]:C11 6.2.6.1:

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

[3] C11:

3.19.2
不确定值
(未指定值)或陷阱表示

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

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


我认为这可以解决为“它始终是未定义的行为”,因为C抽象机可以具有陷阱表示形式。仅仅因为您的实现不使用它们,就不会定义代码。实际上,严格的阅读甚至不能从我无法告诉的内容中坚持认为陷阱表示必须在硬件中,我看不到为什么编译器无法确定特定位模式是陷阱,因此每次读取变量时都要对其进行检查并调用UB。

2
@Vality在现实世界中,所有计算机中的99.9999%是没有陷阱表示的二进制补码CPU。因此,没有陷阱表示是常态,并且讨论此类现实计算机上的行为非常重要。假设使用异国情调的计算机是常态,这无济于事。现实世界中的陷阱表示非常罕见,以至于术语“陷阱表示”在标准中的存在通常被认为是从1980年代继承的标准缺陷。如对个人补码和数字计算机的支持。
伦丁

2
顺便说一句,这是为什么 stdint.h应始终使用它而不是C的本机类型。因为stdint.h强制使用2的补码且没有填充位。换句话说,stdint.h不允许这些类型充满垃圾。
伦丁

2
委员会再次对缺陷报告做出回应:“对问题2的回答是,对不确定值执行的任何操作都将具有不确定值。” 和“问题3的答案是,当对不确定的值使用库函数时,它们将表现出不确定的行为。”
安蒂·哈帕拉

2
DR 451和260
Antti Haapala

2

尽管许多答案都集中在捕获未初始化注册访问的处理器上,但是即使在没有此类陷阱的平台上也可能会出现古怪的行为,使用的编译器不会特别努力地利用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;  
}

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


有趣。那么,您是说接受的答案是错误的吗?还是您说的理论上是正确的,但实际上编译器可能会做些奇怪的事情?
user541686 '16

@Mehrdad:实现的行为通常超出没有UB可能发生的行为的界限。我认为,如果标准认识到部分不确定的值的概念会有所帮助,该值的“已分配”位的行为将在最坏的情况下(未指定)表现出来,而其他高位位的行为则不确定(例如,如果上述函数的结果存储到类型为的变量中uint16_t,该变量有时可能读作123,有时也读作6553623。如果结果最终被忽略了……
超级猫

...或以某种方式被使用,以使其可能被读取,都将产生符合要求的最终结果,存在部分不确定的价值不成问题。另一方面,该标准中没有任何内容允许在该标准强加任何行为要求的任何情况下都存在部分不确定的值。
超级猫

在我看来,您所描述的正是接受的答案中的内容-如果可以使用声明变量register,则它可能具有多余的位,从而使行为可能未定义。那正是你在说的,对吗?
user541686 '16

@Mehrdad:可接受的答案集中于其寄存器具有额外的“未初始化”状态的体系结构,如果加载了未初始化的寄存器,则会捕获该体系结构。这样的架构存在,但并不普遍。我描述了一种场景,在这种场景中,普通硬件可能表现出的行为超出了C标准所考虑的范围,但是如果编译器未在混合环境中添加其自身的附加功能,则会受到有用的限制。例如,如果一个函数具有选择要执行的操作的参数,而某些操作返回有用的数据,而其他操作则没有,……
supercat
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.