代码之家  ›  专栏  ›  技术社区  ›  Mário Feroldi

在运行时调用代码中未调用的函数

  •  24
  • Mário Feroldi  · 技术社区  · 7 年前

    以下程序如何调用 format_disk 如果从来没有 在代码中调用?

    #include <cstdio>
    
    static void format_disk()
    {
      std::puts("formatting hard disk drive!");
    }
    
    static void (*foo)() = nullptr;
    
    void never_called()
    {
      foo = format_disk;
    }
    
    int main()
    {
      foo();
    }
    

    这因编译器而异。使用Clang编译 函数上的优化 never_called 在运行时执行。

    $ clang++ -std=c++17 -O3 a.cpp && ./a.out
    formatting hard disk drive!
    

    但是,使用GCC编译时,此代码会崩溃:

    $ g++ -std=c++17 -O3 a.cpp && ./a.out
    Segmentation fault (core dumped)
    

    编译器版本:

    $ clang --version
    clang version 5.0.0 (tags/RELEASE_500/final)
    Target: x86_64-unknown-linux-gnu
    Thread model: posix
    InstalledDir: /usr/bin
    $ gcc --version
    gcc (GCC) 7.2.1 20171128
    Copyright (C) 2017 Free Software Foundation, Inc.
    This is free software; see the source for copying conditions.  There is NO
    warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
    
    2 回复  |  直到 5 年前
        1
  •  36
  •   Mário Feroldi    5 年前

    程序包含未定义的行为,如取消引用空指针 (即呼叫 foo() 在main中没有为其分配有效地址 事先)是UB,因此本标准没有规定任何要求。

    正在执行 format_disk 在运行时,当 未定义的行为已被命中,它与崩溃一样有效(如 使用GCC编译时)。好吧,但为什么叮当要这么做?如果你 在关闭优化的情况下编译,程序将不再输出 “格式化硬盘”,并将崩溃:

    $ clang++ -std=c++17 -O0 a.cpp && ./a.out
    Segmentation fault (core dumped)
    

    此版本生成的代码如下所示:

    main:                                   # @main
            push    rbp
            mov     rbp, rsp
            call    qword ptr [foo]
            xor     eax, eax
            pop     rbp
            ret
    

    它尝试调用 foo 点和as foo公司 初始化为 nullptr (或者如果没有任何初始化, 情况仍然如此),其值为零。此处,未定义 行为被击中,所以任何事情都可能发生,程序 变得无用。通常,打电话到这样一个无效的地址 导致分段错误,因此我们在 执行程序。

    现在,让我们检查同一个程序,但在以下方面进行优化:

    $ clang++ -std=c++17 -O3 a.cpp && ./a.out
    formatting hard disk drive!
    

    此版本生成的代码如下所示:

    never_called():                         # @never_called()
            ret
    main:                                   # @main
            push    rax
            mov     edi, .L.str
            call    puts
            xor     eax, eax
            pop     rcx
            ret
    .L.str:
            .asciz  "formatting hard disk drive!"
    

    有趣的是,不知何故,优化修改了程序 main 电话 std::puts 直接地但为什么叮当这样做?为什么是 never_called 编译为单个 ret 指示

    让我们回到标准(特别是N4660)上来。什么 它说的是未定义的行为吗?

    3.27未定义的行为 [defns.undefined]

    本文件未对其提出要求的行为

    [注: 可能会出现未定义的行为 当本文件省略时 任何明确的行为定义或 当程序使用错误的 构造或错误数据。 允许的未定义行为范围 从…起 完全忽视情况 如果结果不可预测 翻译过程中的行为 或以文件化的方式执行程序 环境特征(无论是否签发 诊断消息),以终止转换或执行(使用 发出诊断消息)。许多错误的程序构造 不要产生未定义的行为;需要对其进行诊断。 常量表达式的求值永远不会显式显示行为 指定为未定义([表达式常量])。结束注释]

    强调我的。

    表现出未定义行为的程序变得无用,因为一切 到目前为止,它已经做了,并且将进一步做,如果它包含 错误的数据或构造。记住这一点,一定要记住 编译器可能会完全忽略未定义行为的情况 被命中,这实际上是在优化 程序例如 x + 1 > x (其中 x 是有符号整数)将优化为常数, true ,即使 x个 在编译时未知。推理 编译器希望针对有效的情况进行优化 该构造有效的方法是当它不触发算术时 溢出(即如果 x != std::numeric_limits<decltype(x)>::max() ). 这 是优化器中新学到的事实。基于此,构造为 经证明,评估结果总是正确的。

    笔记 :无符号整数不能进行相同的优化,因为溢出整数不是UB。也就是说,编译器需要保持表达式的原样,因为当发生溢出时,它可能有不同的求值(无符号是模块2 N ,其中N是位数)。为无符号整数优化它将不符合标准(感谢aschepler)。

    这很有用,因为它允许 tons of optimizations to kick in . 所以 很好,但如果 x个 在运行时保持其最大值? 嗯,这是一种未定义的行为,所以试图推理是毫无意义的 它,因为任何事情都可能发生,标准没有规定任何要求。

    现在我们有足够的信息,以便更好地检查您的故障 程序我们已经知道访问空指针是未定义的 行为,这就是导致运行时出现有趣行为的原因。 所以,让我们试着理解为什么Clang(或技术上的LLVM)优化了 程序的运行方式。

    static void (*foo)() = nullptr;
    
    static void format_disk()
    {
      std::puts("formatting hard disk drive!");
    }
    
    void never_called()
    {
      foo = format_disk;
    }
    
    int main()
    {
      foo();
    }
    

    记住你可以打电话 从未调用 主要的 进入 开始执行。例如,在声明顶级变量时, 您可以在初始化该变量的值时调用它:

    void never_called();
    int x = (never_called(), 42);
    

    如果在程序中编写此代码段,则程序编号 较长时间显示未定义的行为 “硬格式 磁盘驱动器!" 显示,并启用或禁用优化。

    那么这个程序有效的唯一方法是什么?有这个 never_caled 分配地址的函数 格式化磁盘 foo公司 ,所以我们可能 在这里找到一些东西。请注意 foo公司 标记为 static ,这意味着 具有内部链接,无法从此翻译外部访问 单元相反,功能 从未调用 具有外部链接,并且可能 从外部访问。如果另一个翻译单元包含代码段 与上面的一样,此程序也将生效。

    很酷,但没人打电话 从未调用 从外面。即使这样 事实上,优化器认为这个程序 有效的条件是 从未调用 之前已调用 主要的 执行,否则 只是未定义的行为。这是一个新的已知事实,因此编译器假设 从未调用 实际上被称为。基于这些新知识,其他优化 踢进可以利用它。

    例如,当 constant folding 是 应用时,它会看到 foo() 仅在以下情况下有效 foo公司 可以正确初始化。唯一的办法就是 从未调用 在这个翻译单元之外调用,所以 foo = format_disk .

    Dead code elimination interprocedural optimization 可能会发现如果 foo == format_disk ,然后是内部代码 从未调用 是不必要的, 所以函数的主体被转化为一个 ret公司 指示

    Inline expansion 优化 看到了吗 foo==格式化磁盘 ,因此 foo公司 可以更换 用它的身体。最后,我们会得到这样的结果:

    never_called():
            ret
    main:
            mov     edi, .L.str
            call    puts
            xor     eax, eax
            ret
    .L.str:
            .asciz  "formatting hard disk drive!"
    

    这在某种程度上相当于启用优化的Clang的输出。当然,Clang真正做的可能(也可能)有所不同,但优化仍然能够得出相同的结论。

    在对GCC的输出进行优化的情况下进行检查,似乎并不费心调查:

    .LC0:
            .string "formatting hard disk drive!"
    format_disk():
            mov     edi, OFFSET FLAT:.LC0
            jmp     puts
    never_called():
            mov     QWORD PTR foo[rip], OFFSET FLAT:format_disk()
            ret
    main:
            sub     rsp, 8
            call    [QWORD PTR foo[rip]]
            xor     eax, eax
            add     rsp, 8
            ret
    

    执行该程序会导致崩溃(分段错误),但如果调用 从未调用 在main执行之前的另一个翻译单元中,该程序不再显示未定义的行为。

    随着越来越多的优化被设计出来,所有这些都会发生疯狂的变化,因此不要相信编译器会处理包含未定义行为的代码,这也可能会把你搞砸(并真正格式化你的硬盘!)


    我建议你阅读 What every C programmer should know about Undefined Behavior A Guide to Undefined Behavior in C and C++ ,这两个系列文章都提供了丰富的信息,可能会帮助您了解最先进的技术。

        2
  •  0
  •   supercat    7 年前

    除非实现指定尝试调用空函数指针的效果,否则它可能会表现为对任意代码的调用。这种任意代码的行为完全可以像调用函数“foo()”一样。虽然C标准的附录L要求实现区分“关键UB”和“非关键UB”,并且一些C++实现可能会应用类似的区分,但在任何情况下调用无效的函数指针都是关键UB。

    请注意,这个问题中的情况与例如。

    unsigned short q;
    unsigned hey(void)
    {
      if (q < 50000)
        do_something();
      return q*q;
    }
    

    在后一种情况下,当执行达到 return 语句,因此它也可以调用 do_something() 无条件地。虽然附件L写得很糟糕,但其意图似乎是禁止这种“优化”。然而,在调用无效函数指针的情况下,即使在大多数平台上直接生成的代码也可能具有任意行为。