编译器是否允许优化掉本地的易失变量?

83

根据 C++17 标准,编译器是否允许对此进行优化:

int fn() {
    volatile int x = 0;
    return x;
}

变成这样?

int fn() {
    return 0;
}

如果是,为什么?如果不是,为什么不是?


以下是对此主题的一些思考:当前编译器将fn()编译为放在堆栈上的局部变量,然后返回它。例如,在x86-64上,gcc创建了这个:

mov    DWORD PTR [rsp-0x4],0x0 // this is x
mov    eax,DWORD PTR [rsp-0x4] // eax is the return register
ret    

据我所知,标准并没有规定本地易失变量应该放在堆栈上。因此,这个版本同样可以:

mov    edx,0x0 // this is x
mov    eax,edx // eax is the return
ret    
在这里,edx 存储 x。但现在,为什么要止步于此呢?由于 edxeax 都是零,所以我们可以这样说:

Here, edx stores x. But now, why stop here? As edx and eax are both zero, we could just say:

xor    eax,eax // eax is the return, and x as well
ret    

我们将fn()转换为优化版本。这种转换是否有效?如果无效,哪个步骤是无效的?

Translated text:

我们将fn()转换为优化版本。这种转换是否有效?如果无效,哪个步骤是无效的?


1
评论不适合进行长时间的讨论;此对话已被移至聊天室 - user3956566
2
相关:*MCU编程-C++ O2优化破坏while循环* - Peter Mortensen
我不知道你想表达什么。你的问题和评论没有以有意义的方式构建代码生成的问题。标准将程序映射到可观察序列。如果目标架构(比如)忽略易失性访问,因为它没有相关的硬件访问,那么它可以忽略关键字。 - philipxy
@philipxy:很简单。编译器是否允许为包含易失变量的fn()发出简单的xor eax,eax; ret?这是否符合标准?这是一个是或否的问题。如果您认为允许,请写下答案,因为根据我理解,当前最受欢迎的答案表明相反。 - geza
1
@philipxy:我想澄清一下我的问题,它是关于标准的。这种问题通常都隐含着这个意思。我对标准的规定很感兴趣。 - geza
显示剩余9条评论
6个回答

67

访问volatile对象被视为可观察行为,与I/O没有区别,局部变量和全局变量之间也没有特别的区别。

符合规范的实现要求最低限度为:

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

[...]

这些统称为程序的可观察行为。

N3690,[intro.execution],¶8

如何确切地观察到这一点超出了标准的范围,并且直接进入特定于实现的领域,正如 I/O 和访问全局volatile对象一样。 volatile的意思是“你认为你知道这里发生的一切,但事实并非如此;相信我,在不太聪明的情况下进行这些操作,因为我正在使用你的字节进行我的秘密操作”。这实际上在[dcl.type.cv] ¶7中得到了解释:

[注意: volatile是提示实现避免涉及该对象的激进优化的一种方式,因为该对象的值可能会被实现无法检测到的手段改变。此外,对于某些实现,volatile可能表示需要特殊的硬件指令来访问

对象。有关详细语义,请参见1.9节。一般来说,volatile在C++中的语义意图与C中相同。-- 结束说明 ]


2
由于这是最受欢迎的问题,并且该问题已通过编辑进行了扩展,因此最好将此答案编辑以讨论新的优化示例。 - hyde
正确的答案是“是”。这个答案没有清楚地区分抽象机器可观察性和生成的代码。后者是实现定义的。例如,为了与给定的调试器一起使用,保证一个易失性对象在内存和/或寄存器中;例如,在相关目标架构下,易失性对象的写入和/或读取在指定特殊内存位置的#pragma时是有保证的。实现定义了访问如何在代码中反映;它决定了对象何时以及如何“可能被实现无法检测到的手段改变”。(请参见我对问题的评论。) - philipxy

12
这个循环可以通过as-if规则进行优化,因为它没有可观察的行为:
for (unsigned i = 0; i < n; ++i) { bool looped = true; }
这个不能做到:
for (unsigned i = 0; i < n; ++i) { volatile bool looped = true; }

第二个循环在每次迭代时都会执行某些操作,这意味着该循环需要 O(n) 的时间。我不知道常数是多少,但可以测量一下,然后就有了一种(大致)已知时间的忙等待方法。

我之所以能这样做,是因为标准规定需要按顺序访问 volatile。如果编译器决定在这种情况下不适用标准,我认为我有权提出错误报告。

如果编译器选择将 looped 放入寄存器中,我想我没有反对的好理由。但它仍然必须为每个循环迭代设置该寄存器的值为 1。


所以,您是说问题中的最终xor ax,ax(其中ax被认为是“易失性x”)版本是有效还是无效?换句话说,您对这个问题的答案是什么? - hyde
@hyde:根据我的理解,问题是“变量是否可以被消除”,我的答案是“不行”。对于引发volatile能否放置在寄存器的x86实现,我并不完全确定。即使它被简化为xor ax,ax,该操作码也不能被消除,即使它看起来毫无用处,也不能合并。在我的循环示例中,编译后的代码将必须执行n次xor ax,ax以满足可观察行为规则。希望这个编辑回答了你的问题。 - rici
是的,由于编辑扩展了问题,但由于您在编辑后回答,所以我认为这个答案应该覆盖新的部分... - hyde
2
@hyde:实际上,在基准测试中,我确实会使用volatile来避免编译器优化掉一个本来什么也不做的循环。所以我真的希望我的想法是正确的 :=) - rici
标准规定,对volatile对象的操作本身就是一种副作用。实现可以以不需要生成任何实际CPU指令的方式定义它们的语义,但访问一个带有volatile限定符的对象的循环具有副作用,因此不能被省略。 - supercat
但标准也说:“通过易失性glvalue访问的语义是实现定义的。” - philipxy

11

我不同意多数人的观点,尽管我充分理解volatile表示可观察的I/O。

如果你有这段代码:

{
    volatile int x;
    x = 0;
}

我相信编译器在as-if规则下可以将其优化掉,前提是:

  1. volatile变量不会通过指针等方式在外部被展示(在给定的范围内不存在这种情况)。

  2. 编译器不会为你提供访问该volatile的机制。

其基本原理只是因为由于标准#2,您无论如何都无法观察到差异。

但是,在您的编译器中,标准#2可能无法满足!编译器可能会尝试为您提供有关从"外部"观察volatile变量的额外保证,例如通过分析堆栈等方式。在这种情况下,该行为确实是可观察到的,因此不能被优化掉。

现在问题是,以下代码是否与上述代码有任何不同?

{
    volatile int x = 0;
}

我认为在Visual C++中,就优化而言,我观察到了不同的行为,但我不是完全确定基础是什么。这可能是因为初始化不算作“访问”吗?我不确定。如果您有兴趣,这可能值得一个单独的问题,否则我认为上面所述的答案是正确的。


7
我将为as-if规则和volatile关键字添加详细参考。 (在这些页面底部,按照“另请参阅”和“参考文献”跟踪到原始规范,但我发现cppreference.com更易于阅读/理解。)

特别是,我希望你阅读这个部分。

易失性对象 - 其类型为易失性修饰符的对象,或易失性对象的子对象,或常易失性对象的可变子对象。通过易失性修饰符类型的glvalue表达式进行的每个访问(读取或写入操作,成员函数调用等)都被视为优化的可见副作用(也就是说,在单个执行线程内,易失性访问不能被优化掉或与先序或后续的另一个可见副作用重新排序。这使得易失性对象适合与信号处理程序通信,但不适合与另一个执行线程通信,请参见std::memory_order)。任何试图通过非易失性glvalue引用或指向非易失性类型的指针来引用易失性对象的尝试都会导致未定义的行为。

因此,易失性关键字专门用于禁用glvalues上的编译器优化。在这里,易失性关键字可能会影响的仅仅是return x,编译器可以随意处理函数的其余部分。

编译器能够优化返回的程度取决于编译器在这种情况下被允许优化x的访问程度(因为它没有重新排序任何内容,严格来说,也没有删除返回表达式。有访问,但是它正在读写堆栈,应该能够简化。)因此,我认为这是一个灰色地带,编译器被允许优化的程度可以很容易地争论双方。

顺便提一下:在这些情况下,总是假设编译器会做与你想要/需要相反的事情。您应该禁用优化(至少对于此模块),或尝试找到更明确定义的行为。 (这也是单元测试非常重要的原因)。如果您认为这是缺陷,则应向C ++开发人员提出。


这些内容仍然很难理解,因此我尝试包含我认为相关的内容,以便您可以自己阅读。

glvalue glvalue表达式是lvalue或xvalue之一。

属性:

glvalue可以通过lvalue-to-rvalue、array-to-pointer或function-to-pointer隐式转换为prvalue。glvalue可以是多态的:它所标识的对象的动态类型不一定是表达式的静态类型。glvalue可以具有不完整的类型,在表达式允许的情况下。


以下表达式为xvalue表达式: 函数调用或重载运算符表达式,其返回类型是对象的右值引用,例如std::move(x); a[n],内置下标表达式,其中一个操作数是数组右值; a.m,对象成员表达式,其中a是右值,m是非引用类型的非静态数据成员; a.*mp,对象指针成员表达式,其中a是右值,mp是指向数据成员的指针; a ? b : c,三元条件表达式,对于某些b和c(详见定义); 转换表达式为对象类型的右值引用,例如static_cast(x); 任何指定临时对象的表达式,在临时材料化后。(自C++17起) 属性: 与rvalue相同; 与glvalue相同; 特别地,像所有rvalue一样,xvalue绑定到rvalue引用,并且像所有glvalue一样,xvalue可以是多态的,非类xvalue可以是cv限定的。

以下表达式是lvalue表达式:

变量、函数或数据成员的名称,不考虑类型,例如std::cin或std::endl。即使变量的类型是rvalue引用,由其名称组成的表达式也是lvalue表达式;函数调用或重载运算符表达式,其返回类型为lvalue引用,例如std::getline(std::cin, str)、std::cout << 1、str1 = str2或++it;a = b、a += b、a %= b和所有其他内置的赋值和复合赋值表达式;++a和--a,内置的前缀递增和前缀递减表达式;*p,内置的间接寻址表达式;a[n]和p[n],内置的下标表达式,除非a是数组rvalue(自C++11以来);a.m,对象成员表达式,除非m是成员枚举器或非静态成员函数,或者a是rvalue且m是非引用类型的非静态数据成员;p->m,指针成员表达式,除非m是成员枚举器或非静态成员函数;a.*mp,对象成员指针表达式,其中a是lvalue,mp是数据成员指针;p->*mp,内置的指向指针成员的指针表达式,其中mp是数据成员指针;a、b,内置的逗号表达式,其中b是lvalue;a ? b : c,三元条件表达式,对于一些b和c(例如,当两者都是相同类型的lvalue时,请参见定义以获取详细信息);字符串字面量,例如“Hello, world!”;转换为lvalue引用类型的强制转换表达式,例如static_cast(x);函数调用或重载运算符表达式,其返回类型为rvalue引用到函数;转换为rvalue引用到函数类型的强制转换表达式,例如static_cast(x)。 (自C++11以来)属性:

与glvalue(下面)相同。可以取lvalue的地址:&++i1和&std::endl是有效的表达式。可修改的lvalue可以用作内置赋值和复合赋值运算符的左操作数。可以使用lvalue初始化lvalue引用;这将一个新名称与由表达式标识的对象关联起来。


仿佛规则

C++编译器可以对程序进行任何更改,只要以下条件仍然成立: 1)在每个序列点上,所有易失对象的值都是稳定的(先前的评估已完成,新的评估未开始) (自C++11以前) 2)对易失性对象的访问(读取和写入)严格按照它们出现的表达式的语义进行。特别地,它们不会与同一线程上的其他易失性访问重新排序。 (自C++11以后) 3)在程序终止时,写入文件的数据与按原样执行程序完全相同。 4)发送到交互设备的提示文本将在程序等待输入之前显示。 5)如果支持ISO C pragma #pragma STDC FENV_ACCESS并将其设置为ON,则浮点环境(浮点异常和舍入模式)的更改保证被观察到浮点算术运算符和函数调用,就像按原样执行一样,除非 除了转换和赋值之外的任何浮点表达式的结果可能具有不同于表达式类型的浮点类型的范围和精度(请参见FLT_EVAL_METHOD) 尽管如上,任何浮点表达式的中间结果都可以计算为无限范围和精度(除非#pragma STDC FP_CONTRACT为OFF)。
如果您想阅读规格,我认为这些是您需要阅读的规格参考:
引用
C11标准(ISO / IEC 9899:2011): 6.7.3类型限定符(p:121-123)
C99标准(ISO / IEC 9899:1999): 6.7.3类型限定符(p:108-110)
C89 / C90标准(ISO / IEC 9899:1990): 3.5.3类型限定符

可能不符合标准,但是任何依赖于在执行期间堆栈被其他东西触碰的人都应该停止编码。我认为这是一个标准缺陷。 - meneldal
1
@meneldal:这个说法太笼统了。例如,使用_AddressOfReturnAddress需要分析堆栈。人们分析堆栈有合理的原因,并不一定是因为函数本身依赖它来保证正确性。 - user541686
1
glvalue在这里:return x; - geza
这是你自己回答中的一句话 :) “以下表达式是lvalue表达式:变量的名称…” - geza
但标准也说:“通过易失性glvalue访问的语义是由实现定义的。” - philipxy
显示剩余3条评论

6
理论上,中断处理程序可以:
  • 检查返回地址是否落在fn()函数内。它可以通过插桩或附加的调试信息访问符号表或源代码行号。
  • 然后更改x的值,该值将存储在距离堆栈指针可预测的偏移量处。
这样就可以使fn()返回一个非零值。

1
或者你可以通过在fn()中设置断点来更轻松地完成这个任务。使用volatile会产生类似于变量的gcc -O0的代码生成:在每个C语句之间进行溢出/重新加载。(-O0仍然可以在不破坏调试器一致性的情况下将多个访问组合到一个语句中,但是volatile不允许这样做。) - Peter Cordes
或者更简单地说,使用调试器 :) 但是,哪个标准规定变量需要可观察呢?我的意思是,一个实现可以选择它必须是可观察的。另一个实现可能会说,它不可观察。后者是否违反了标准?也许不是。标准没有明确规定,局部的易失性变量如何才能被观察到。 - geza
甚至,“observable”是什么意思?它应该放在堆栈上吗?如果一个寄存器保存了“x”,怎么办?如果在x86-64上,“xor rax,rax”保存了零(我是说返回值寄存器保持“x”),这当然可以被调试器轻松地观察/修改(即,调试符号信息表明“x”存储在“rax”中)。这是否违反了标准? - geza
2
任何对 fn() 的调用都可以被内联。在使用 MSVC 2017 和默认的发布模式时,它会被内联。因此,在 fn() 函数内部没有“within the fn() function”的概念。但是,由于变量是自动存储的,因此没有“可预测的偏移量”。 - Cheers and hth. - Alf
如果它是内联的,那么所有实例都会在调试信息中列出。尝试在“return x”上设置断点,即使内联也可以工作。(除非有太多实例,当调试器抱怨没有足够的硬件断点可用时,但这仍然只是调试器的限制)@Cheersandhth.-Alf - followed Monica to Codidact
1
@berendi:是的,你说得对,我错了。对于这一点我很抱歉(犯了两次错误)。但是,在我看来,争论编译器如何支持通过其他软件访问是毫无意义的,因为它可以在不考虑volatile的情况下实现这一点,并且volatile并不强制要求提供该支持。所以我撤回了我的负评(我错了),但我不会点赞,因为我认为这种推理方式并没有澄清问题。 - Cheers and hth. - Alf

-1

我认为我从未见过使用volatile的本地变量,除非它是指向volatile的指针。就像这样:

int fn() {
    volatile int *x = (volatile int *)0xDEADBEEF;
    *x = 23;   // request data, 23 = temperature 
    return *x; // return temperature
}

我知道的唯一其他使用volatile的情况是在信号处理程序中编写的全局变量。那里没有涉及指针。或者访问链接器脚本中定义的符号,以便在与硬件相关的特定地址处。

在这种情况下,更容易理解为什么优化会改变可观察效果。但是,对于您的本地volatile变量,同样的规则适用。编译器必须表现得好像对x的访问是可观察的,并且不能将其优化掉。


3
但那不是一个本地易失变量,而是指向已知地址上的本地非易失指向易失整数的指针。 - Useless
这使得推理正确行为变得更加容易。正如所说,访问 volatile 的规则对于本地变量和被解引用的指向 volatile 变量的指针是相同的。 - Goswin von Brederlow
我只是在回应你答案的第一句话,它似乎暗示你代码中的 x 是一个“本地易失变量”。但实际上并不是。 - Useless
当 int fn(const volatile int argument) 无法编译时,我感到非常生气。 - Joshua
4
编辑使您的答案并没有错误,但它并没有回答问题。这是“volatile”的典型用例,并且与它是局部变量无关。在全局范围内,它同样可以使用静态易失性常量指针,例如static volatile int *const x = ...,您所说的一切仍然完全相同。这就像是必须理解问题的额外背景知识,我猜可能不是每个人都有,但它并不是一个真正的答案。 - Peter Cordes
显示剩余5条评论

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