是否允许 C 编译器合并对 volatile 变量的顺序分配?

And*_*eas 64 c volatile compiler-optimization language-lawyer

我有一个由硬件供应商报告的理论上的(非确定性的、难以测试的、实践中从未发生过的)硬件问题,其中对某些内存范围的双字写入可能会破坏任何未来的总线传输。

虽然我没有在 C 代码中明确写任何双字,但我担心编译器被允许(在当前或未来的实现中)将多个相邻的字分配合并为一个双字分配。

编译器不允许重新排序 volatile 的分配,但不清楚(对我而言)合并是否算作重新排序。我的直觉说是,但我之前已经被语言律师纠正过!

例子:

typedef struct
{
   volatile unsigned reg0;
   volatile unsigned reg1;
} Module;

volatile Module* module = (volatile Module*)0xFF000000u;

// two word stores, or one double-word store?
module->reg0 = 1;
module->reg1 = 2;
Run Code Online (Sandbox Code Playgroud)

(我会单独询问我的编译器供应商,但我很好奇标准的规范/社区解释是什么。)

Lun*_*din 50

不,编译器绝对不允许将这两个写入优化为单个双字写入。引用标准有点困难,因为关于优化和副作用的部分写得如此模糊。相关部分见 C17 5.1.2.3:

本国际标准中的语义描述描述了与优化问题无关的抽象机器的行为。

访问易失性对象、修改对象、修改文件或调用执行任何这些操作的函数都是副作用,它们是执行环境状态的变化。

在抽象机中,所有表达式都按照语义指定的方式进行评估。如果一个实际的实现可以推断出它的值未被使用并且没有产生所需的副作用(包括由调用函数或访问易失性对象引起的任何副作用),则它不需要计算表达式的一部分。

对 volatile 对象的访问严格按照抽象机的规则进行评估。

当您访问结构的一部分时,这本身就是一种副作用,可能会产生编译器无法确定的后果。例如,假设您的结构是一个硬件寄存器映射,并且这些寄存器需要按特定顺序写入。例如,一些微控制器文档可能是这样的:“reg0 启用硬件外设,必须先写入,然后才能在 reg1 中配置详细信息”。

volatile对象写入合并为单个对象的编译器将不符合标准且完全损坏。

  • @Andreas如果结构访问是易失性的,那么即使成员没有声明为易失性的,成员访问也将是易失性的。与“const”相同。 (12认同)
  • 噢,没有想到结构访问。在这种情况下,指针不应该是易失性的,只留下成员易失性的(然后我们进入嵌套的易失性兔子洞)。妈的,C太难了。很高兴看到你能够超越这一点。所讨论的“真实”代码不具备这一方面,但它太粗糙,无法作为一个很好的例子。 (3认同)

438*_*427 31

编译器不会允许做两个这样的分配到单个存储器写。内核必须有两个独立的写入。@Lundin 的回答给出了 C 标准的相关参考。

但是,请注意缓存(如果存在)可能会欺骗您。该关键字volatile并不意味着“未缓存”的内存。所以除了使用之外volatile,你还需要确保地址 0xFF000000 被映射为未缓存的。如果地址被映射为缓存,则缓存硬件可以将两个分配转换为单个内存写入。换句话说 - 对于缓存内存,两个核心内存写入操作可能最终作为系统内存接口上的单个写入操作。

  • @Lundin:*C 从未允许推测性或 OoO 执行易失性访问* - 这与“不可缓存”不同。您似乎在谈论不在 asm 中的循环中提升负载/下沉存储。但这与回写式可缓存内存区域上的“硬件”预取完全不同。您可以将其视为 C 保证加载/存储*到缓存一致性域*是可见的副作用,而不是 DRAM 的真实内容。软件无法观察 DRAM(除非可能通过同一物理地址的另一个映射,或者在具有非一致共享内存的假设系统上) (17认同)
  • @Lundin:如果你希望MMIO访问正常工作,你需要确保包括MMIO地址在内的地址范围被映射为不可缓存,即使你是手工编写asm;对于一个 C 编译器来说,为全局的 `volatile int foo;` 做这件事是难以置信和不切实际的。 (13认同)
  • @Lundin您可以将自动变量限定为“易失性”。这是否意味着编译器必须发出代码来关闭堆栈该部分的缓存?以前从未见过这种情况,看起来很荒谬。(一个自动变量限定的“易失性”非常有用,例如,如果您单步执行程序并希望从调试器更改它)。 (7认同)
  • “易失性”绝对意味着未缓存的内存。对“易失性”限定变量进行预取读取的系统是不合规的。必须根据变量周围的序列点执行“易失性”访问。随着 CPU 的发展,硬件和/或编译器供应商一直在尝试将这种类似于内存屏障的行为的负担推给应用程序程序员。但 C 从来不允许推测或无序执行“易失性”访问。如果有人发布了无法执行兼容 C 的硬件,这不是应用程序程序员的错。 (6认同)
  • @Lundin 我喜欢看到一些关于该主张的参考资料,因为我不同意。此外,这个小示例 https://ideone.com/U8Sq9n 显示编译器不会映射 易失性变量 与普通变量有任何不同。 (6认同)
  • @Lundin:您似乎已经确定 DRAM 本身,而不是所有核心共享的内存的缓存一致性视图,才是 C 标准所指的“执行环境”。是的,你的论点是从这个前提出发的。但我没有看到选择它的充分理由,并且在具有一致缓存的系统的 C 实现中,它对我来说没有什么意义。绕过缓存会使“易失性”对很多事情来说变得极其缓慢,并且让用户寻找一些并不可怕的机制。例如,对于诸如“易失性 sig_atomic_t”之类的东西,用于确保存储到映射文件发生。 (2认同)
  • @Lundin:链接器和控制内存类型属性(例如使某些范围不可缓存)的软件为您提供了设置一些不可缓存内存的工具,如果您想要的话,在为具有缓存的系统进行编程时可以从中读取。我根本不相信这种时机论。如果你想要一些额外慢的延迟,请从不可缓存的内存中进行易失性读取,而不仅仅是从任何任意变量中进行。让每个易失性都必须很慢听起来像是我不想要的更糟糕的设计。 (2认同)

Kaz*_*Kaz 15

的行为volatile似乎取决于实现,部分原因是一个奇怪的句子说:“什么构成对具有 volatile 限定类型的对象的访问是实现定义的”。

在 ISO C 99 第 5.1.2.3 节中,还有:

3 在抽象机中,所有表达式都按照语义指定的方式进行评估。如果一个实际的实现可以推断出它的值未被使用并且没有产生所需的副作用(包括由调用函数或访问易失性对象引起的任何副作用),则它不需要计算表达式的一部分。

因此,尽管要求volatile必须按照抽象语义(即未优化)处理对象,但奇怪的是,抽象语义本身允许消除死代码和数据流,这些都是优化的例子!

恐怕要知道什么volatile会做什么不会做什么,您必须查看编译器的文档。


sup*_*cat 9

C 标准不知道易失性对象上的操作和实际机器上的操作之间的任何关系。虽然大多数实现会指定类似的构造*(char volatile*)0x1234 = 0x56;将生成值为 0x56 的字节存储到硬件地址 0x1234,但实现可以在闲暇时为例如 8192 字节数组分配空间,并指定*(char volatile*)0x1234 = 0x56;将立即将 0x56 存储到元素 0x1234那个数组,从来没有对硬件地址 0x1234 做任何事情。或者,一个实现可能包括一些进程,该进程周期性地将该数组的 0x1234 中的任何内容存储到硬件地址 0x56。

一致性所需要的只是在单个线程中对易失性对象的所有操作,从抽象机的角度来看,都被认为是绝对有序的。从标准的角度来看,实现可以以他们认为合适的任何方式将此类访问转换为真实的机器操作。

  • 此外,易失性访问的构成是实现定义的。 (2认同)

P__*_*J__ 6

改变它会改变程序的可观察行为。所以编译器是不允许这样做的。

  • 仅当实现选择这样指定时,实际硬件内存操作的序列才是“可观察的”。没有什么会阻止实现包含其自己的虚拟机,其中易失性存储会立即更新虚拟机状态,但此类更新需要一段时间才能转换为真实机器硬件上的操作。 (6认同)