易失变量可以在序列点之间被多次读取吗?

39

我正在制作自己的C编译器,以尽可能了解C的详细信息。现在我正在尝试准确理解volatile对象的工作原理。

令人困惑的是,代码中的每个读取访问都必须严格执行(C11,6.7.3p7):

具有易失性限定类型的对象可能以实现未知的方式进行修改或具有其他未知的副作用。因此,任何引用这种对象的表达式都应严格按照抽象机器的规则进行评估,如5.1.2.3所述。此外,在每个序列点上,对象中最后存储的值应与抽象机器所规定的值相同,除非被先前提到的未知因素修改。134)构成具有易失性限定类型的对象的访问是由实现定义的。

例如:在a = volatile_var - volatile_var;中,易失变量必须被读取两次,因此编译器无法优化为a = 0;

同时,序列点之间的评估顺序是不确定的(C11,6.5p3):

运算符和操作数的分组由语法指示。除非另有规定,否则子表达式的副作用和值计算是无序的。
例如:在b = (c + d) - (e + f)中,加法的计算顺序是未指定的,因为它们是无序的。
但是,在评估创建副作用的无序对象时(例如使用volatile),行为是未定义的(C11,6.5p2):
如果标量对象上的副作用与同一标量对象上的不同副作用或使用相同标量对象的值计算无序,则行为是未定义的。如果表达式的多个可允许的子表达式排序,则如果任何这样的无序副作用发生,则行为是未定义的。
这是否意味着像x = volatile_var - (volatile_var + volatile_var)这样的表达式是未定义的?我的编译器是否应该抛出警告?

我尝试了解CLANG和GCC的行为。两者都没有抛出错误或警告。输出的汇编代码显示变量在执行顺序上并未被按顺序读取,而是从左到右,如下所示的risc-v汇编代码:

const int volatile thingy = 0;
int main()
{
    int new_thing = thingy - (thingy + thingy);
    return new_thing;
}

main:
        lui     a4,%hi(thingy)
        lw      a0,%lo(thingy)(a4)
        lw      a5,%lo(thingy)(a4)
        lw      a4,%lo(thingy)(a4)
        add     a5,a5,a4
        sub     a0,a0,a5
        ret

编辑:我不是在问“编译器为什么会接受它”,我在问“如果我们严格遵循C11标准,这是否属于未定义行为”。标准似乎表明这是未定义行为,但我需要更多关于它的精确信息才能正确解释。


我认为“对标量对象的副作用”的意图是改变该对象的值。因此,int x = thingy + (thingy=42); 可能会导致未定义行为,而 int x=thingy - (thingy + thingy) 则不会。 - tstanisl
4
应该被接受吗?这是未定义的行为。你可以做任何事情。格式化硬盘。但是最好还是给出警告。 - KamilCuk
1
@KamilCuk 我会让我的编译器生成龙,然后再加上一点警告。 - Elzaidir
3
@KamilCuk 我认为您无法在编译时这样做,但您可以制作一个执行该操作的可执行文件:-)。 - 现实是:编译器不需要检测未定义的行为结构,因此由编译器创建者确定编译器是否应检测此结构并引发警告甚至错误。顺便说一下,使用未定义的行为编写代码在我所听到的任何国家都不是非法的,而且C标准也允许它(但不定义结果行为)。 - nielsen
6
@Elzaidir 为了更深入地介绍编译器制作,C23 标准对副作用的定义进行了微小修改,详见 DR 476。C23 将规定“通过使用具有 volatile 限定类型的 lvalue 访问对象是 _volatile 访问_。对对象进行的 volatile 访问,包括修改对象、修改文件或调用执行任何上述操作的函数都是_副作用_”。这种变化非常明智,修补了所有语言律师漏洞。 - Lundin
显示剩余4条评论
6个回答

22

按照(ISO 9899:2018)标准的字面意思,这是未定义行为。

C17 5.1.2.3/2 - 副作用的定义:

访问一个 volatile 对象,修改一个对象、修改文件或调用执行任一这些操作的函数都是副作用。

C17 6.5/2 - 操作数的顺序:

如果对标量对象的副作用与同一标量对象上的不同副作用或使用同一标量对象的值进行的值计算在顺序上未被排序,则其行为未定义。如果一个表达式的多个子表达式具有多个允许的排序方式,并且任何排序中出现了此类未排序的副作用,则其行为为未定义。

因此,在字面上阅读标准时,volatile_var - volatile_var 明显是未定义行为。实际上,两次UB,因为上述两个引用的句子都适用。


还请注意,该文本在C11中发生了很大变化。之前,C99说,6.5/2:

在前一个和下一个序列点之间,一个对象在表达式的求值中最多被修改一次其存储的值。此外,之前的值只能读取以确定要存储的值。

也就是说,在C99中,这种行为以前未指定(评估顺序不确定),但在C11中通过更改使其变得未定义。


话虽如此,除了随意重新排序评估之外,编译器没有任何理由对此表达式进行疯狂的处理,因为鉴于使用了volatile,并没有太多可以优化的空间。

作为一种实现质量,在主流编译器中,似乎仍然保持了来自C99的先前的“仅仅未指定”的行为。


很有趣,它是如何改变语义的。例如,volatile_var * 2 不是未定义行为,而 volatile_var + volatile_var 是。 - Eugene Sh.
@EugeneSh。可能存在更复杂的表达式,其中可能存在问题,但如果定义行为以不包括这些边缘情况会过于复杂。 - Barmar
除此之外:什么构成了volatile访问是由实现定义的。在讨论volatile程序文本的Q&A时,这一点经常被忽略。 - philipxy
是的,访问volatile对象是一种副作用,5.1.2.3/2进一步解释了这是执行环境状态的改变。但是为了应用6.5/2,它必须是对该对象(或某些其他标量对象)的副作用,并且规范没有说明。此外,根据6.7.3/8,首先访问易失性对象是实现定义的。因此,也许 volatile_var - volatile_var具有UB,但说它肯定具有UB太过强硬。 - John Bollinger
1
@JohnBollinger 嗯,6.5/2 必须是表达式中相关对象的一部分,就像表达式中一个运算符的一个操作数。至于 5.1.2.3 中的 DR 是由委员会认可并在 C23 中实现的。该文本已更改为“通过使用 volatile-qualified 类型的 lvalue 访问对象是 _volatile 访问_”,删除了必须访问某个特定对象的要求。我甚至没有考虑过这个问题,但在 C23 之前,像 *(volatile int*)address 这样的东西实际上不算作副作用。 - Lundin
显示剩余3条评论

13
正如其他答案所指出的那样,访问一个volatile限定的变量是一种副作用,而副作用是有趣的,而在序列点之间具有多个副作用尤其有趣,而在序列点之间具有影响同一对象的多个副作用是未定义的。
作为一个关于为什么它是未定义的例子,考虑这个(错误的)代码从输入流ifs中读取一个两字节的大端值:
uint16_t val = (getc(ifs) << 8) | getc(ifs);     /* WRONG */

这段代码假设(为了实现大端序),两个getc调用按从左到右的顺序发生,但是这当然不能保证,这就是为什么这段代码是错误的原因。

现在,volatile限定符的一个作用是输入寄存器。所以如果你有一个volatile变量

volatile uint8_t inputreg;

如果每次读取时,您都会在某个设备上获得下一个字节,也就是说,仅仅访问变量inputreg就像在流上调用getc(),那么您可以编写以下代码:

uint16_t val = (inputreg << 8) | inputreg;       /* ALSO WRONG */

而且它几乎和上面的getc()代码一样错误。


没有必要让它成为未定义的行为;指定计算顺序就足以使编译器在将此表达式编译为汇编语言时拥有相同的自由度。(大多数程序不想利用这一点;如果读取确实具有某种副作用,或者即使每次读取都得到相同的值,如果它们阻止编译器将数据加载到寄存器中,则编写像这样的代码仍然是低效的。) 将其设为UB并不会使调试更加容易,相反地,在同一块中优化其他代码可能会导致困惑。 - Peter Cordes
在我看来,最易于调试的行为是警告关于未顺序访问的易失性,并且选择一些顺序,例如从左到右或者方便分析树的任何顺序。或者选择一个导致最有效的汇编代码的顺序,例如在Sandybridge系列上没有8位寄存器的部分寄存器停顿,movzx eax,byte [inputreg] / shl eax,8 / mov al,[inputreg]。对于高8个寄存器没有单独重命名的AMD或Silvermont,movzx eax,byte [inputreg] / mov ah,[inputreg]。 (GCC错过了由无序事物评估顺序的选择允许的优化) - Peter Cordes
当你包含修改对象的副作用时,将其变成未定义行为更有意义。但是说没人应该编写这样的代码也是有道理的,所以我们将其完全未定义。 - Peter Cordes
我认为 (getc(ifs) << 8) || getc(ifs) 不会出现未定义的情况,因为它采用了短路求值。虽然使用逻辑运算符来组合位并没有意义。那么 a | b 的求值顺序是不确定的,但是 a || b 的求值顺序是确定的吗? - Marco

13
根据 C11,这是未定义行为。
根据 5.1.2.3程序执行,第2段(我加粗):

访问易失性对象、修改对象、修改文件或调用执行任何这些操作的函数都是副作用......

6.5表达式,第2段(再次加粗):

如果对标量对象的副作用在与同一标量对象的不同副作用或使用相同标量对象值进行值计算的情况下未排序,则行为未定义

请注意,由于这是您的编译器,您可以自由定义行为。

3
你推测它是未定义行为,但它没有明确说明... 这取决于主观解释 - 就像在 C、C++ 或 C/C++ 中的所有有趣事物一样。 - curiousguy
4
@curiousguy的意思是“它没有这么说”,而“UB”则代表着这个意思。 - philipxy
1
@curiousguy,确实是这样说的:“如果标量对象上的副作用在与同一标量对象上的不同副作用或使用同一标量对象的值计算相对无序,则行为未定义。” - Andrew Henle
@JohnBollinger 我指的是你从第6.5/2段引用的那个术语在语言规范中的确切含义。无论它是什么,这个短语并不限制"副作用"的程度或影响,但我无法从标准中确定"副作用"的定义有明确的限制。这个问题并没有提供太多帮助。我怀疑标准的作者们考虑到了平台/硬件特定的效果,比如访问一个volatile值会改变下一个读取的值。 - Andrew Henle
你似乎在争论仅仅因为规范没有说明对易失对象的读取是该对象的副作用,这并不意味着它不是。我接受这一点,但我不明白你的观点。要根据6.5/2得出结论,即评估问题中所述表达式会产生UB,您需要对易失对象进行副作用。从规范中最好的结果可能是UB,但此答案却做出了不合格的声明“这是未定义的行为”。 - John Bollinger
显示剩余6条评论

6
标准中没有比“未定义行为”更具体的术语来描述某些实现应该被明确定义的操作,或者甚至是大多数实现,但是基于实现定义的标准可能会在其他情况下表现出不可预测的行为。如果有什么问题,标准的作者会尽力避免说关于这种行为的任何事情。
该术语还用作捕捉所有潜在有用的优化可能在某些情况下影响程序行为的情况的总称,以确保此类优化不会影响任何已定义的情况下的程序行为。
标准规定易失性访问的语义是“实现定义的”,并且有些平台在两个序列点之间出现多个易失性访问时涉及某些类型的优化可能是可观察的。 例如,一些平台具有读取-修改-写入操作,其语义可能与进行离散读取,修改和写入操作有所不同。 如果程序员编写了以下代码:
void x(int volatile *dest, int volatile *src)
{
  *dest = *src | 1;
}

如果这个函数的两个指针是相等的,那么这个函数的行为可能取决于编译器是否认识到这些指针是相等的,并用组合的读取-修改-写入替换单独的读取和写入操作。

当然,在大多数情况下,这样的区别不太可能有影响;在对象被读取两次的情况下,这种影响尤其不太可能。尽管如此,标准没有试图区分这种优化实际上会影响程序行为的情况,更不用说那些优化会以任何实际相关的方式影响程序行为的情况和那些无法检测到这种优化效果的情况了。认为“不可移植或有误”的短语排除了构造在目标平台上不可移植但正确的构造将导致一个有趣的反讽,即编译器优化(例如读取-修改-写入合并)将在任何“正确”的程序上完全无用。


3

除非特别说明,否则无需针对具有未定义行为的程序进行诊断。因此,接受此代码是没有问题的。

一般而言,不可能知道是否在序列点之间多次访问了相同的易失存储器(考虑一个不带restrict的两个volatile int*参数的函数,这是无法分析的最简单示例)。

尽管如此,当您能够检测到问题时,用户可能会发现这很有帮助,因此我鼓励您努力获得诊断结果。


0

在我看来,这是合法的,但非常糟糕。

    int new_thing = thingy - (thingy + thingy);

一个表达式中多次使用 volatile 变量是允许的,不需要警告。但从程序员的角度来看,这是一行非常糟糕的代码。

这是否意味着像 x = volatile_var - (volatile_var + volatile_var) 这样的表达式未定义?如果出现这种情况,我的编译器是否应该抛出错误?

不,因为 C 标准没有规定这些读取的顺序。这留给实现者自己决定。我所知道的所有实现都像这个例子一样采用了最简单的方式:https://godbolt.org/z/99498141d


4
为什么这是合法的?一个表达式中有两个未排序的副作用,这就是未定义行为。“这些读取应该如何排序?”没错。而且,“C11,6.5p2”。 - KamilCuk
4
@KamilCuk:这是合法的,因为C标准没有规定程序不能这样做。C标准规定行为未定义。程序可以做各种行为,这些行为不被C标准定义。例如,它们可以链接到其他语言编写的模块,这不是由C标准定义的。它们可以调用操作系统例程,这不是由C标准定义的。它们可以使用编译器扩展,这也不是由C标准定义的。所有这些都是合法的;它们不被C标准禁止。 - Eric Postpischil
4
@AndrewHenle:确实。行为未定义并不意味着程序不能这样做,或者编译器不能定义它。这只是意味着C标准对于发生的情况是“沉默”的。C标准没有强制要求程序不能执行具有未定义行为的操作,也没有要求C实现不能定义它们 - 这些将是要求,但是C标准将“未定义行为”定义为指它本身不强制执行要求。 - Eric Postpischil
6
@AndrewHenle:在编写可移植代码时,人们需要避免未定义行为,因为那是不可移植的。但有些人会更进一步地认为必须始终避免未定义行为。这会演变成认为未定义行为是不允许的。这是不正确的。C标准中没有任何规定。C标准是故意编写的,以允许扩展,包括定义C标准未定义的行为的扩展。 - Eric Postpischil
4
事实上,我提出的问题可能暗示未定义行为是非法的,但它们并不是。我应该问我的编译器是否应该警告未定义的行为,而不是询问它们是否合法。 - Elzaidir
显示剩余3条评论

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