gcc 涉及使用const_cast的未定义行为示例

wixjitnu  于 2023-06-23  发布在  其他
关注(0)|答案(4)|浏览(126)

为了说明的目的,我一直试图通过使用gcc找到一个例子,其中在启用和不启用优化(启用和不启用-O3)时,程序的输出是不同的。找到这样的例子的目的是展示优化如何使一个明显正确的程序在优化已经激活后的行为不同,如果代码包含未定义的行为。
我一直在尝试以下程序的不同“组合”:

// I have tried defining blind in this and in a separate module. The result is the same.
void blind(int const* p) { ++*const_cast<int*>(p); }

#include <iostream>

int constant() { return 0; }

int main()
{
    int const p = constant();
    blind(&p);
    std::cout << p << std::endl;
    return 0; 
}

我希望在没有启用优化的情况下,这个程序会显示1,但是启用优化的情况下(-O3),它会显示0(直接用std::cout << 0替换std::cout << p),但事实并非如此。如果我用int const p = 0替换初始化,它将在启用和不启用优化的情况下打印0,因此行为再次相同。
我尝试过不同的替代方法,比如做算术运算(期望编译器更喜欢“预计算”值或其他东西),多次调用blind等等。但都不管用。

  • 我想找到一个变化的程序上面的行为改变时,激活优化。
  • 或者...另一个不同的例子,可以帮助说明优化可以改变程序的可观察行为,如果这样的程序包含未定义的行为。
  • 注意:* 最好是一个例子,其中程序不会在优化版本中崩溃。
jaxagkaj

jaxagkaj1#

现在也许是我发光的时候了。I asked this question一段时间前,它似乎完美地展示了一个例子,你正在寻找一个非常短/简单的程序,我将包括在下面的完整性:

#include <iostream>

int broken_for_loop(){
    for (int i = 0; i < 10000; i+= 1000){
        std::cout << i << std::endl;
    }
}

int main(int argc, char const *argv[]){
    broken_for_loop();
}

你可以在那里看到讨论/解释(长话短说,我没有从应该返回int的函数返回),但我认为它很好地展示了一些UB如何在优化的二进制文件中表现自己,如果你不考虑它/注意编译器警告的话。
在不清楚的情况下添加:在没有优化的情况下编译时,程序打印0...9000,然后正确退出。使用-O3编译时,循环将永远运行。
编译:g++ (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0

zqdjd7g9

zqdjd7g92#

const int的初始化器是一个常量表达式(如0)时,语言规则说它变成了constexpr(感谢@Artyer指出这一点)。因此,const int p = 0;const int p = 0;在C++语义上存在差异。const int p = foo();,除非你声明了constexpr int foo(){...},这可能就是为什么编译器在实践中对它们进行了不同的优化。
blind()的定义对优化器不可见时,我认为这仍然是GCC(以及clang、ICC和MSVC)遗漏的优化。他们可以选择假设没有任何东西可以修改const,就像它假设没有任何东西可以修改constexpr一样,因为这样做的程序具有未定义的行为。
blind()在没有__attribute__((noinline,noipa))的同一个编译单元中时,如果启用了优化,UB在编译时是可见的,所以所有的赌注都被取消了,没有多少奇怪之处是特别令人惊讶的。
但是只有一个blind()的原型,编译器必须使asm适用于没有未定义行为的blind(),所以看看他们做了什么假设/优化是很有趣的。并考虑是否允许它们以您期望的方式编译。
对于const int p = 0;,GCC和clang将该常量传播给以后在同一函数中使用p(即使禁用了优化),正确地假设没有其他东西可能改变const对象的值。(甚至不是调试器,这是gcc和clang的-O0默认代码生成器设计来支持非常量变量;这也是为什么separate blocks of asm for each statement不跨语句在寄存器中保存任何东西原因之一。)
我认为在同一情况下,在将constant()内联到常量0之后,不常量传播const int p = constant();是一个错过的优化。它仍然是一个const int对象,所以它仍然是UB,任何其他修改它。
当然,在调试构建中不会发生这种情况;如果不内联constant(),它们在编译时不知道实际值是多少,因此它们不能将其用作后续指令的立即操作数。所以编译器从p的通常地址加载它,这个地址和它们传递给blind()的地址相同。因此,他们在调试构建中使用修改后的值,这是意料之中的。
在优化的构建中,它们不需要call constant,而是存储一个立即的0来初始化堆栈空间,将堆栈空间的地址传递给blind(),正如我们所期望的那样。**但在调用之后,他们重新加载它,而不是使用另一个立即0。**这是错过的优化。
对于一个大型对象,使用内存中存在的副本而不是再次生成它可能会更有效,尤其是如果将其传递给通过引用传递的print函数。但int的情况并非如此;将寄存器归零作为std::cout::operator<<( int )的值传递的arg比从堆栈重新加载更有效。

constexpr更改行为(调试和优化)

对于constexpr int constant(){ return 0; },GCC和clang将const int p = constant();const int p = 0;完全相同,因为constant()0一样是常量表达式。它甚至与gcc -O0内联,并且在调用blind()之后使用常量0,而不是重新加载p
仍然不是在-O0-O3
显然,对于编译器内部来说,它是用一个“常量表达式”初始化的,无论是文本还是constexpr函数返回值。但这不是基本的,无论const int是如何初始化的,修改它仍然是UB。
我不确定编译器是有意避免这种优化,还是这只是一个怪癖.也许不是故意的,而是出于某种原因避免某种类型的事情的附带损害?
或者仅仅是因为出于常量传播的目的,直到内联constant()之后才知道const int p是否有一个在编译时已知的值。但是对于constexpr int constant(),编译器可以将函数调用视为常量表达式的一部分,因此它可以肯定地假设它将具有一个已知的值,以便以后使用p。这种解释似乎过于简单化,因为通常情况下,常量传播即使对不是constexpr的东西也有效,GCC/clang将程序逻辑转换为SSA form作为编译的一部分,在此做了大部分优化工作,这应该可以很容易地看到一个值是否被修改。
也许在考虑将地址传递给函数时,他们并不考虑底层对象是const,而只考虑它是否是用constexpr初始化的。如果所讨论的对象仅通过引用这个函数传递或返回,比如const int *pptr = foo();blind(pptr),那么底层对象可能不是const,在这种情况下blind()可以修改*pptr而不使用UB。

我发现GCC和clang都错过了这个优化,这让我很惊讶,但我很有信心blind()修改指向的const int实际上是未定义的行为,即使它在自动存储中。(不是静态的,它实际上可能在只读页面中,并在实践中崩溃。
我甚至检查了MSVC和ICC 2021(经典的,不是基于LLVM的),它们与GCC/clang相同,不是在blind()上进行常量传播,除非您使用常量表达式初始化p,使其成为constexpr。(针对其他ISA的GCC/clang当然是一样的;该优化决策发生在目标无关的中间端)。
我猜他们都只是根据是否是constexpr来进行优化选择,尽管所有4个编译器都是独立开发的。

为了使asm在Godbolt编译器资源管理器上看起来更简单,我将cout<<p更改为volatile int sink = p;,以查看gcc/clang是否将mov dword ptr [rsp+4], 0作为常量零,或者将load+store从p的地址复制到sinkcout << p << '\n'更简单,但与那个

查看常量与加载+存储是我们最终感兴趣的行为,所以我宁愿直接看到它,而不是看到0或1,并且必须仔细考虑我在哪种情况下所期望的步骤。您可以将鼠标悬停在volatile int sink = p;行上,它将在asm输出窗格中突出显示相应的指令。
我本来可以只做return p,特别是从一个不叫main的函数,所以它并不特殊。事实上,这甚至更容易,使asm变得更简单(但是load vs. 0个指令而不是2个指令尽管如此,它避免了GCC隐式地将main视为__attribute__((cold))的事实,假设真实的程序不会将大部分时间花费在main上。但是错过的优化仍然存在于int foo()中。
如果你想看看UB在编译时可见的情况(我没有),你可以看看当blind()内联时它是否存储了一个常量1。我想是的

ppcbkaq5

ppcbkaq53#

几个案例显示了从调试到非调试/优化代码的不同行为。未定义的行为并不是发生这种情况的唯一原因,因为它在一些答案和评论中暗示了这一点。
1.它会跑得更慢。如果结果取决于代码运行的时间,就像在优化中一样,结果将系统地不同。
这种情况在FPGA“编译”时经常发生,因为布局/布线阶段本质上只是一个优化循环。
例如:让我们用我自己的古怪版本的交替谐波级数来计算log(2)。我在给定的时间过去后停止该系列。

#include <iostream>
#include <cstdint>
#include <cmath>
#include <array>

double calcln2() {
    constexpr size_t N = 1000000;
    std::array<double,N> values;
    for ( double& x : values ) x = 0;
    uint64_t t0 = __builtin_ia32_rdtsc();
    for  ( size_t j=1; __builtin_ia32_rdtsc() - t0 < 10000000ULL; j++ ) {
        for ( double& x : values ) { 
            if ( j%2==0 ) {
                x -= 1/double(j);
            } else {
                x += 1/double(j);
            }
        }
    }
    double sum = 0;
    for ( double& x : values ) sum += x;
    return sum/N;
}

int main() {
    std::cout << log(2) - calcln2() << std::endl;
}

main()函数基本上会输出计算误差。一个调试运行的例子将给予我0.193147,而在发布运行将导致0.0399365,少得多。
Godbolt:https://godbolt.org/z/zMc5dPns6
我可以想到其他情况,但我不会深入到为每个生成示例代码。
1.优化通常意味着快速的数学运算,这可能会使舍入问题变得更糟。另一方面,优化可能会将整个级数(例如上面的交替调和级数)折叠在其闭合公式中,在这种情况下,它将更精确。
1.可执行文件的大小将更大,这可能会产生副作用,如果
1.Assert只会在调试模式下触发,所以它会在一个模式下崩溃,而不会在另一个模式下崩溃

fnvucqvd

fnvucqvd4#

下面是一个很好的、非常简单的例子,它与我正在寻找的例子相匹配:

#include <iostream>
#include <climits>

bool check(int i)
{
    int j = i + 1;
    return j < i;
}

int main()
{
    std::cout << check(INT_MAX) << std::endl;
    return 0;
}

如果不启用优化,check返回1,因为确实发生了溢出。启用优化后,即使使用-O1check也会返回0
我从:

#include <iostream>
#include <climits>

bool check(int i)
{
    return i + 1 < i;
}

int main()
{
    std::cout << check(INT_MAX) << std::endl;
    return 0;
}

由于有符号整数溢出是UB,编译器直接返回0,即使没有启用优化,也没有执行实际的比较:

由于有优化和没有优化的情况下行为都是一样的,所以我决定将i + 1的计算移到一个新的变量j上:

bool check(int i)
{
    int j = i + 1;
    return j < i;
}

现在,编译器,在一个非优化的构建中,被迫实际计算j,以便可以用调试器检查变量,并实际执行比较,这就是为什么它返回1
然而,对于-O1,编译器将check翻译成它的等价形式return i + 1 < i,它变成了return 0,就像在程序的前一个变体中一样。

相关问题