易失性变量的语义学

3
为了回答这个问题,我们只考虑对volatile变量的读取。在我所阅读的所有讨论中,唯一的结论是:多次读取同一个声明为volatile的变量不能被优化为单个效果。
但我认为这有点太严格了。考虑两个变量的读取,它们之间没有任何副作用或者没有读取其他的volatile变量。现在我们知道,volatile变量中的值随时可能改变(编译器并不知情)。但程序员无法确保更改会发生在两次读取之间。这意味着,两次读取看到相同的值是程序的有效行为。
难道编译器不能强制执行这种行为吗?只做一次读取并使用两次值。
例如:
int foo(volatile int * x) {
    return *x + *x;
}

在这种情况下,编译器是否只进行一次读取操作呢?

我希望我的问题表达清楚了。

此外,我假设系统中读取本身没有副作用(如计数器的增量或值每次读取发生更改)。这样的系统存在吗?

我已经查看了gccclang生成的汇编代码,即使进行最大程度的优化,它们仍会插入两个读取操作。我的问题是它们是否过于保守?

编辑:为了不让我的问题变得复杂,并避免子表达式的实现定义顺序引起混淆,我们可以看以下示例 -

int foo(volatile int * x) {
    int a = *x;
    int b = *y;
    return a + b;
}

但我也保留了之前的例子,因为一些答案和评论引用了它。


Volatile就像高级汇编语言一样。如果你真的想要“优化”,就自己将其存储在寄存器中。 - Tatsuyuki Ishi
@TatsuyukiIshi 我同意!但程序员不会看到任何可观察到的定义行为的变化。 - Ajay Brahmakshatriya
如果你有一个标记为“爆炸物”的盒子,邮递员能否将其视为非爆炸性物品,因为它并不经常爆炸?当然可以!111!!! - joop
它需要进行两次读取。易失性访问不能被优化掉。它们就像IO。 - Petr Skocik
1
可能是为什么C语言需要使用volatile关键字?的重复问题。很明显,声明为volatile的变量在使用时将始终执行读取命令。即使使用了序列!就这样。故事结束。 - ringbuffer_peek
显示剩余6条评论
3个回答

4

从内存位置读取数据可能会产生副作用。当然,程序必须使用比标准C更多的内容。只有在依赖于实现定义行为的程序中,读取才可能产生副作用。

一个常见的例子是从内存映射I/O读取。在许多架构中,当数据被读取或写入特定的内存位置范围时,主处理器与外围设备交换数据。如果一个内存位置被映射到一个外围设备,那么做两次读取会向外围设备发送两个读请求。每次读取,外围设备可能执行一个不幂等的操作。

例如,从串行通信外围设备读取一个字节会传输外围设备输入队列中的下一个字节。因此,如果将该串行外围设备的字节读取寄存器地址传递给foo,则它会从外围设备的读缓冲区拉取两个连续的字节。编译器不允许更改该行为以仅读取一个字节。

除了因为在读取之间没有序列点,且从易失性变量中读取是一种副作用而导致行为未定义之外,这个行为是正确的。一个正确的函数应该是

int foo2(volatile int *x) {
    int x1 = *x;
    int x2 = *x;
    return x1 + x2;
}

我希望大多数编译器生成的foo代码与foo2代码相同。


因此,我添加了这段话:“同时,我假设系统本身没有副作用(例如计数器的增量或每次读取时值的更改)。这样的系统存在吗?”感谢您提供这样一个系统的示例。 - Ajay Brahmakshatriya
现在,这些东西如何适应标准呢?它们是否都适用于某些“实现定义行为”?这让我得出结论,volatile仅应在为特定目标编程时使用。对于可移植程序,它对程序的影响很小。 - Ajay Brahmakshatriya
我同意我应该使用两个序列读取,以避免混淆评估顺序。 - Ajay Brahmakshatriya
我并不认为foo()存在问题,因为+是对称的,但如果使用值的操作不对称,显式的读取和保存符号可能很容易就会变得必要。 - Jonathan Leffler
@JeanBaptisteYunes 如果只是线程的问题,那么单个读取应该没问题,因为另一个线程(改变值的线程)可能会在两次读取后被调度,这将具有类似于单个读取的可观察行为。 - Ajay Brahmakshatriya
显示剩余9条评论

3
现在我们知道,volatile变量中的值随时都可能改变(而编译器并不知情)。但程序员无法确保这种变化会发生在两次读取之间。这意味着对于程序来说,两次读取看到相同的值是一种有效的行为。
你从中得出了错误的结论。 有可能值不会改变,但你不知道,编译器也不知道。
如果编译器不知道,为什么编译器可以假设任何关于更改的事情呢? 因此很明显:NO!编译器不能在此处组合任何读访问。
这是一个奇怪的假设。 如果你不能确保所有兔子都是白色的,那么所有兔子都是黑色的这个假设怎么可能是有效的呢?
第一次读取本身也可能导致值的改变。
如果你看一些硬件,分别进行读取访问可能是至关重要的。 某些计时器或中断控制器在读取时会清除一些位。
另外,UART或以太网控制器可能通过一个地址提供整个接收缓冲区。你必须从同一个地址多次读取。
而volatile关键字是防止编译器做手脚的手段。

我的论点是,如果两个读取都可以看到值,那么程序员就不能抱怨他们被迫看到相同的值。类比的情况是如果不能保证所有兔子都是白色的,我可以让一只兔子变成黑色(强制性)。既然标准没有说所有的兔子都应该是白色的,那么这就没问题了。 - Ajay Brahmakshatriya
关于后半部分,我同意。这也是我在问题中提到的。我的附加问题是有哪些读取具有即时副作用的系统示例?感谢您提供的示例。 - Ajay Brahmakshatriya
@AjayBrahmakshatriya。不,你的假设仍然是错误的。如果我不能确保所有兔子都是白色的,我可能仍然可以确保只有一只黑兔子存在。编译器(或者你)没有权利使两只兔子都变成黑色。你做出了无效的假设。也许程序员并不知道将读取哪些结果,编译器肯定不知道,而且这不是编译器进行花式猜测的事情。 - Gerhardh

2
int foo(volatile int * x) { return *x + *x; }

必须生成两个读取。易失性访问不能被优化掉。它们就像IO。

6.7.3p7:

具有易失性限定类型的对象可能会被以实现未知方式修改或具有其他未知副作用。因此,任何引用这样的对象的表达式都应严格按照抽象机器的规则进行评估,如5.1.2.3所描述。此外,在每个序列点上,存储在对象中的最后一个值应与由抽象机器规定的值一致,除非受前述未知因素的影响而被修改。什么构成访问具有易失性限定类型的对象是由实现定义的。
当然,您可以通过引入一个临时变量来使其仅执行一次读取:

允许它执行一次读取:

int foo(volatile int * x) { int x_cp = *x; return x_cp + x_cp; }

根据生成的汇编代码,gcc 在优化级别为 -O1 或更高时会采取优化措施。

这没有任何意义。不要将参数声明为volatile。我知道你有自己的原因。编译器也会抱怨它。 - 0___________
@PeterJ,这是有道理的。如果他想让foo仅执行一次读取操作,无论出于何种原因,那么这就是方法。当然,如果这是一个单独编译的函数,他可以简单地删除volatile限定符,但是在没有上下文的情况下,foo可能成为内联的候选对象,在这种情况下,如果没有volatile,就可能根本没有读取操作。 - Petr Skocik
很不幸,我无法理解你的意思,因为你写的句子没有任何意义。 - 0___________
如果你不需要volatile,就不要使用它;如果你需要,也不要滥用它。简单明了。 - 0___________
@PeterJ 如果 foo 应该始终在 x 上生成恰好一个读取,则“只是不要将参数声明为 volatile” 是没有意义的。如果 foo 可能是可内联的,则需要使用 volatile 限定符。 - Petr Skocik
显示剩余2条评论

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