如何在C语言中检查行为是否未定义?

8

我知道以下代码是未定义的,因为我尝试在同一表达式中读取和写入变量的值,即

int a=5;
a=a++;

但如果是这样的话,为什么下面的代码片段不是未定义的?
int a=5;
a=a+1;

在这里,我也试图同时修改a的值并向其写入。

同时解释一下为什么标准没有修复或删除这种未定义行为,尽管他们知道它是未定义的。


5
在第二个表达式的右侧,您没有修改a的值。那里没有序列违规,所以您的“also”是错误的。 - WhozCraig
1
至于第二个问题:答案可能是“因为它非常方便”,C语言的哲学有时似乎是“如果用户绝对想要开枪打自己的脚,我们提供所有方便的手段让他这样做”;)。顺便说一下,如果我用gcc -Wall编译你的前两行代码,我会得到以下警告: 警告:对'a'进行多次未排序的修改[-Wunsequenced] a = a++; ~ ^ - mfro
5个回答

6
它被定义为未定义的原因不是读和写,而是写了两次。
'a++' 表示读取a并在读取后对其进行递增,但我们不知道 '++' 是否会在使用 '=' 进行赋值之前发生(在这种情况下,'=' 将用 a 的旧值进行覆盖) 或之后发生,在这种情况下a将被递增。
只需使用 'a++;' :)
'a = a + 1' 没有问题,因为 a 只被写入一次。

1
讽刺的是,这个特定的例子是一个难题。它违反了标准的序列点要求,但如何做到并不一定直观。a ++并不意味着读取然后增加。它一概而论地意味着(1)读取至temp, (2)增加, 并且(3)将从(1)返回的temp作为表达式值。在所有例子中,OP可以选择其中之一,这是比较扭曲的之一,每5.2.6 [expr.post.incr] "++表达式的值计算在操作数对象被修改之前被定序"。没错,就像那样清楚明了。 - WhozCraig
@WhozCraig 执行语句 a = a++; 后,变量 a 的值要么是旧值,要么是旧值加一,这取决于增量是否先执行还是赋值先执行。那么它不应该是未指定的行为吗,而不是未定义的行为? - ajay
@WhozCraig 你可能有些困惑。自1999年以来,所有C标准中的第5节都是“环境”(在C89中,没有第5节)。此外,你引用的内容不是C中后置递增的定义。 - Pascal Cuoq
@PascalCuoq,您对我的困惑说得很对。我引用的是C++11标准,而不是C(我真的需要整理一下桌面,这么多文档!)。我对所述描述产生了浓厚的兴趣,但您完全正确,它并不来自C标准,因此我深表歉意。 - WhozCraig
@ajay:如果a比机器字长更大(在8位机器上,即使是int也会有两个字),像*p=b++;这样的语句可以有很多不同的处理方式。如果pb别名,并且b保持0x04FF,则合理的结果不仅可以是0x500和0x4FF,还可以是0x5FF和0x400。 - supercat

6

why the following code snippet is not undefined

int a=5;
a=a+1;  
标准规定:
在前后序列点之间,对象的存储值最多只能被一个表达式的计算修改一次。此外,先前的值只能被访问以确定要存储的值。
a = a + 1的情况下,a仅被修改了一次,并且先前的a值只是为了确定要存储在a中的值而进行访问。 而在a=a++;的情况下,a被修改了多次——通过子表达式a++中的++运算符和通过=运算符将结果分配给左侧的a。现在无法确定首先进行哪种修改(既可以是++,也可以是=
几乎所有带有标志-Wall的现代编译器在编译第一个代码片段时都会发出警告,如下所示:
[Warning] operation on 'a' may be undefined [-Wsequence-point]

更多阅读:如何理解本节中的复杂表达式并避免编写未定义的表达式?


请给我一些细节。 - OldSchool
@haccks 在语句 a = a++; 执行后,变量 a 的值不管是先自增还是先赋值,都会比执行前多 1。那么它为什么被认为是未定义的呢?是因为标准规定了吗? - ajay
1
@ajay; 部分地,是的。对于初学者来说,您可以说它会调用UB,因为标准规定了。为什么会调用UB的答案在于CPU执行语句(CPU架构)。 - haccks

3
长话短说,您可以在标准中找到每个定义的行为。在那里未被提及的所有内容都是未定义的。
对您例子的直观解释:
a=a++;

您想在单个语句中两次修改变量 a

1) a= //first time
2) a++ //second time

如果您在这里看:
a=a+1;

你只修改了变量a一次:

a= // (a+1) - doesn't change the value of a

为什么标准没有定义 a=a++ 的行为?

可能的原因之一是:编译器可以执行优化。您在标准中定义的情况越多,编译器优化代码的自由度就越小。因为不同的架构可能有不同的增量指令实现,在某些情况下,编译器不会使用所有处理器指令,以防止破坏标准行为。或者在某些情况下,编译器可以更改评估顺序,但这种限制将迫使编译器禁用此类优化,如果您想两次修改内容。


未指定的行为对于优化是有好处的,因为在任何允许的行为都能满足程序要求的情况下,它可以发挥作用。未定义的行为通常对于优化来说不太有用,因为它迫使程序员指定他们并不关心的事情,以满足他们确实关心的要求。 - supercat

3

++运算符会将a加1,这意味着变量a将变成a+1。实际上,以下两个语句是相等的:

a++;
a = a + 1;

最后一个语句a + 1不会使a增加,它仅仅会生成一个值为a + 1的结果。如果你想让a变成a + 1,你需要使用赋值运算符将a + 1的结果赋给a。

a = a + 1;

你所说的第一条语句行不通的原因是你写了类似于以下的内容:
a = (a = a + 1);

2

其他人已经谈论了您特定示例的细节,因此我将添加一些通用信息和工具,以帮助捕捉未定义的行为。

没有最终的工具或方法可以捕捉未定义的行为,因此即使您利用所有这些工具,也不能保证您的代码中没有未定义的内容。但是根据我的经验,这些工具可以捕捉许多常见问题。我不会列出软件开发的标准良好实践,例如单元测试,因为您无论如何都应该使用它们。

  • clang(-analyze)有多个选项可帮助捕捉未定义的行为,包括编译时和运行时。它具有-ftrapv,它具有新获得的对canary值的支持,其地址sanitizer,--fcatch-undefined-behaviour等。

  • gcc还有几个选项可用于捕捉未定义的行为,例如mudflaps,其地址sanitizer,堆栈保护程序。

  • valgrind是一个非常好的工具,可在运行时找到与内存相关的未定义行为。

  • frama-c是一种静态分析工具,可查找和可视化未定义的行为。它发现死代码的能力(未定义的行为通常会导致其他代码部分变得死亡)是追踪潜在安全问题的相当有用的工具。frama-c还具有许多更高级的功能,但使用起来可能比...

  • 存在其他商业静态分析工具,可以捕捉未定义的行为,例如PVS-studio、klocwork等。虽然这些通常成本很高。

  • 使用不同的编译器和奇怪的架构进行编译。如果可以的话,为什么不在8位AVR芯片上编译和运行您的代码?树莓派(32位ARM)?使用emscripten将其编译为javascript,并在V8中运行它?这样做往往是捕捉可能会导致崩溃的未定义行为的实际方式(但对于捕捉潜伏的UB,例如可能导致安全问题的UB,则几乎没有任何作用)。

现在,关于未定义行为存在的本体原因...... 基本上是出于性能和易于实现的原因。在C中许多UB的事情允许编译器优化某些其他语言无法优化的东西。例如,如果您比较Java、Python和C处理有符号整数类型溢出的方式,您会发现在一个极端情况下,Python以方便程序员的方式完全定义它--整数实际上可以变得无限大。另一方面,C将其留为空白--您有责任永远不要溢出有符号整数。Java介于两者之间。

另一方面,这意味着在Python中不知道“int + int”操作在执行时实际执行的工作。它可能执行数百条指令,通过操作系统进行往返以分配一些内存等等。如果您非常关心性能,或更具体地说,一致的性能,那么这是相当糟糕的。另一方面,C允许编译器将“+”映射到CPU本地指令以添加整数(如果存在)。当然,不同的CPU可能以不同的方式处理溢出,但由于C将其定义为未定义,所以没问题 - 作为程序员,您必须注意不要溢出您的整数。这意味着C给了编译器在几乎所有CPU上将您的“int + int”操作编译为单个机器指令的选项 - 编译器可以并且确实利用这一点。
请注意,C不保证+实际上直接映射到本机CPU指令,它只是保留编译器以这种方式进行操作的可能性 - 显然,任何编译器编写者都会渴望利用这一点。 Java定义有符号整数溢出的方法比Python更不可预测(就性能而言),但可能不会导致+在许多CPU类型上被转换为单个CPU指令,而C则允许这样做。
因此,本质上,C试图拥抱未定义的行为,并选择(一致的)速度和易于实现,而其他语言则选择安全性或可预测的行为(从程序员的角度来看)。这并不一定是关于安全/安全方面的好决策,但这就是C的立场。它归结为“了解手头工作的适当工具”,在许多情况下,C给您带来的性能可预测性绝对至关重要。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接