能否在序列点之间多次读取易失性变量?

Elz*_*dir 39 c volatile language-lawyer c11

我正在制作自己的 C 编译器,以尝试尽可能多地了解有关 C 的详细信息。我现在正在尝试准确地了解volatile对象是如何工作的。

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

具有 volatile 限定类型的对象可能会以实现未知的方式进行修改,或者产生其他未知的副作用。因此,引用此类对象的任何表达式都应严格根据抽象机的规则进行评估,如 5.1.2.3 中所述。此外,在每个序列点,最后存储在对象中的值应与抽象机规定的值一致,除非受到前面提到的未知因素的修改。134)构成对具有易失性限定类型的对象的访问的是实现-定义。

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

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

运算符和操作数的分组由语法指示。除非稍后指定,否则子表达式的副作用和值计算是无序的。

示例:b = (c + d) - (e + f)未指定计算添加的顺序,因为它们是无序的。

但是,对未排序对象的评估会产生副作用(例如volatile),行为未定义(C11,6.5p2):

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

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

我尝试看看 CLANG 和 GCC 做了什么。既不抛出错误也不发出警告。输出的 asm 显示变量不是按执行顺序读取的,而是从左到右读取的,如下面的 asm risc-v asm 所示:

const int volatile thingy = 0;
int main()
{
    int new_thing = thingy - (thingy + thingy);
    return new_thing;
}
Run Code Online (Sandbox Code Playgroud)
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
Run Code Online (Sandbox Code Playgroud)

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

Lun*_*din 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 之前的“仅未指定”行为。


And*_*nle 13

根据 C11,这是未定义的行为。

根据5.1.2.3 程序执行,第 2 段(粗体我的):

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

以及 6.5 表达式,第 2 段(再次将我的内容加粗):

如果标量对象上的副作用相对于同一标量对象上的不同副作用或使用同一标量对象的值的值计算而言是无序的,则行为是未定义的

请注意,由于这是您的编译器,因此您可以根据需要自由定义行为。

  • @curiousguy“它没有这么说”=“UB” (4认同)
  • 你推断它是 UB,但它并没有这么说......这取决于主观解释 - 就像 C、C++ 或 C/C++ 中所有有趣的东西一样。 (3认同)

Ste*_*mit 13

正如其他答案所指出的,访问volatile-qualified 变量是一种副作用,并且副作用很有趣,并且在序列点之间具有多个副作用特别有趣,并且在序列点之间影响同一对象的多个副作用是未定义的。

\n

作为如何/为何未定义的示例,请考虑以下用于从输入流读取两字节大端值的(错误)代码ifs

\n
uint16_t val = (getc(ifs) << 8) | getc(ifs);     /* WRONG */\n
Run Code Online (Sandbox Code Playgroud)\n

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

\n

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

\n
volatile uint8_t inputreg;\n
Run Code Online (Sandbox Code Playgroud)\n

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

\n
uint16_t val = (inputreg << 8) | inputreg;       /* ALSO WRONG */\n
Run Code Online (Sandbox Code Playgroud)\n

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

\n


sup*_*cat 6

该标准没有比“未定义行为”更具体的术语来描述应该在某些实现上明确定义的操作,甚至是绝大多数实现,但根据实现定义的标准,在其他实现上可能会出现不可预测的行为。如果说有什么不同的话,那就是该标准的作者竭尽全力避免对此类行为发表任何言论。

该术语还用作潜在有用的优化在某些情况下可能明显影响程序行为的情况的总称,以确保此类优化不会影响任何定义情况下的程序行为。

该标准规定,易失性限定访问的语义是“实现定义的”,并且在某些平台上,volatile如果序列点之间发生多个此类访问,则可能会观察到涉及易失性限定访问的某些类型的优化。举一个简单的例子,一些平台具有读-修改-写操作,其语义可能与执行离散的读、修改和写操作明显不同。如果程序员要写:

void x(int volatile *dest, int volatile *src)
{
  *dest = *src | 1;
}
Run Code Online (Sandbox Code Playgroud)

并且两个指针相等,这样的函数的行为可能取决于编译器是否认识到指针相等并用组合的读取-修改-写入替换离散的读取和写入操作。

可以肯定的是,这种区别在大多数情况下不太可能重要,并且在对象被读取两次的情况下尤其不太可能重要。尽管如此,该标准并没有尝试区分此类优化实际上会影响程序行为的情况,更不用说它们会以任何实际重要的方式影响程序行为的情况,以及不可能检测到此类优化效果的情况。短语“不可移植或错误”排除了不可移植但在目标平台上正确的构造的概念将导致一个有趣的讽刺,即读-修改-写合并等编译器优化在任何“正确”的程序。