谜团:将 GNU C 标签指针转换为函数指针,并使用内联 asm 在该块中放置一个 ret。块被优化掉?

Posted

技术标签:

【中文标题】谜团:将 GNU C 标签指针转换为函数指针,并使用内联 asm 在该块中放置一个 ret。块被优化掉?【英文标题】:Mystery: casting a GNU C label pointer to a function pointer, with inline asm to put a ret in that block. Block being optimized away? 【发布时间】:2019-11-29 11:09:31 【问题描述】:

首先:这段代码被认为是纯粹的乐趣,请不要在生产中做这样的事情。在任何环境下编译和执行这段代码后对您、您的公司或您的驯鹿造成的任何损害,我们概不负责。下面的代码不安全,不可移植,而且很危险。被警告。下面长文。你被警告了。

现在,在免责声明之后:让我们考虑以下代码:

#include <stdio.h>

int fun()

    return 5;


typedef int(*F)(void) ;

int main(int argc, char const *argv[])


    void *ptr = &&hi;

    F f = (F)ptr;

    int  c = f();
    printf("TT: %d\n", c);

    if(c == 5) goto bye;
    //else goto bye;     /*  <---- This is the most important line. Pay attention to it */

hi:
    c = 5;
    asm volatile ("movl $5, %eax");
    asm volatile ("retq");

bye:
    return 66;

一开始我们有函数fun,我创建它纯粹是为了获取生成的汇编代码的参考。

然后我们声明一个函数指针 F 指向不带参数并返回一个 int 的函数。

然后我们使用不太知名的 GCC 扩展 https://gcc.gnu.org/onlinedocs/gcc/Labels-as-Values.html 来获取标签 hi 的地址,这也适用于 clang。然后我们做了一些坏事,我们创建了一个函数指针F,叫做 f 并将它初始化为上面的标签。

最糟糕的是,我们实际上调用了这个函数,并将它的返回值分配给一个名为C的局部变量,然后我们将其打印出来。

下面是if,用于检查分配给c的值是否真的是我们需要的,如果是,则转到bye,以便应用程序正常退出,退出代码为66。如果是可以认为是正常的退出代码。

下一行被注释掉了,但我可以说这是整个应用程序中最重要的一行。

标签hi之后的那段代码是将c的值赋值为5,然后两行汇编将eax的值初始化为5并真正从“函数”调用中返回。如前所述,有一个参考函数fun 会生成相同的代码。

现在我们编译这个应用程序,并在我们的在线平台上运行它:https://gcc.godbolt.org/z/K6z5Yc

它会生成以下程序集(-O1 已打开,O0 给出了类似的结果,尽管时间更长一些):

# else goto bye  is COMMENTED OUT
fun:
        mov     eax, 5
        ret
.LC0:
        .string "TT: %d\n"
main:
        push    rbx
        mov     eax, OFFSET FLAT:.L3
        call    rax
        mov     ebx, eax
        mov     esi, eax
        mov     edi, OFFSET FLAT:.LC0
        mov     eax, 0
        call    printf
        cmp     ebx, 5
        je      .L4
.L3:
        movl $5, %eax
        retq
.L4:
        mov     eax, 66
        pop     rbx
        ret

重要的几行是mov eax, OFFSET FLAT:.L3,其中L3 对应于我们的hi 标签,之后的行:call rax 实际调用它。

运行如下:

ASM generation compiler returned: 0
Execution build compiler returned: 0
Program returned: 66
    TT: 5

现在,让我们重温一下应用程序中最重要的一行并取消注释。

使用-O0,我们得到以下程序集,由 gcc 生成:

# else goto bye  is UNCOMMENTED
# even gcc -O0  "knows" hi: is unreachable.
fun:
        push    rbp
        mov     rbp, rsp
        mov     eax, 5
        pop     rbp
        ret
.LC0:
        .string "TT: %d\n"
main:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 48
        mov     DWORD PTR [rbp-36], edi
        mov     QWORD PTR [rbp-48], rsi
        mov     QWORD PTR [rbp-8], OFFSET FLAT:.L4
        mov     rax, QWORD PTR [rbp-8]
        mov     QWORD PTR [rbp-16], rax
        mov     rax, QWORD PTR [rbp-16]
        call    rax
        mov     DWORD PTR [rbp-20], eax
        mov     eax, DWORD PTR [rbp-20]
        mov     esi, eax
        mov     edi, OFFSET FLAT:.LC0
        mov     eax, 0
        call    printf
        cmp     DWORD PTR [rbp-20], 5
        nop
.L4:
        mov     eax, 66
        leave
        ret

以及以下输出:

ASM generation compiler returned: 0
Execution build compiler returned: 0
Program returned: 66

所以,你可以看到我们的printf 从未被调用过,罪魁祸首是mov QWORD PTR [rbp-8], OFFSET FLAT:.L4 行,其中L4 实际上对应于我们的bye 标签。

从生成的程序集中我可以看到,在生成的代码中添加了hi 之后的部分中没有一段代码。

但至少应用程序可以运行,并且至少有一些代码可以将c 与 5 进行比较。

在另一端,clang 与 O0 生成以下噩梦,顺便崩溃:

# else goto bye  is UNCOMMENTED
# clang -O0 also doesn't emit any instructions for the hi: block
fun:                                    # @fun
        push    rbp
        mov     rbp, rsp
        mov     eax, 5
        pop     rbp
        ret
main:                                   # @main
        push    rbp
        mov     rbp, rsp
        sub     rsp, 48
        mov     dword ptr [rbp - 4], 0
        mov     dword ptr [rbp - 8], edi
        mov     qword ptr [rbp - 16], rsi
        mov     qword ptr [rbp - 24], 1
        mov     rax, qword ptr [rbp - 24]
        mov     qword ptr [rbp - 32], rax
        call    qword ptr [rbp - 32]
        mov     dword ptr [rbp - 36], eax
        mov     esi, dword ptr [rbp - 36]
        movabs  rdi, offset .L.str
        mov     al, 0
        call    printf
        cmp     dword ptr [rbp - 36], 5
        jne     .LBB1_2
        jmp     .LBB1_3
.LBB1_2:
        jmp     .LBB1_3
.LBB1_3:
        mov     eax, 66
        add     rsp, 48
        pop     rbp
        ret
.L.str:
        .asciz  "TT: %d\n"

如果我们开启一些优化,例如O1,我们会从gcc得到:

# else goto bye  is UNCOMMENTED
# gcc -O1
fun:
        mov     eax, 5
        ret
.LC0:
        .string "TT: %d\n"
main:
        sub     rsp, 8
        mov     eax, OFFSET FLAT:.L3
        call    rax
        mov     esi, eax
        mov     edi, OFFSET FLAT:.LC0
        mov     eax, 0
        call    printf
.L3:
        mov     eax, 66
        add     rsp, 8
        ret

应用程序崩溃,这是可以理解的。同样,编译器完全删除了我们的 hi 部分(mov eax, OFFSET FLAT:.L3 蹑手蹑脚到 L3 对应于我们的 bye 部分),不幸的是,它决定在 ret 之前增加 rsp 是个好主意,所以以确保我们最终到达一个完全不同的地方。

而 clang 提供了一些更可疑的东西:

# else goto bye  is UNCOMMENTED
# clang -O1
fun:                                    # @fun
        mov     eax, 5
        ret
main:                                   # @main
        push    rax
        mov     eax, 1
        call    rax
        mov     edi, offset .L.str
        mov     esi, eax
        xor     eax, eax
        call    printf
        mov     eax, 66
        pop     rcx
        ret
.L.str:
        .asciz  "TT: %d\n"

1 ? clang 到底是怎么做到的?

在某种程度上,我了解编译器在 if 之后决定了死代码,其中 ifelse 不需要转到同一位置,但我的知识和见解在这里停止了。

所以现在,亲爱的 C 和 C++ 大师、汇编爱好者和编译器粉碎者,问题来了:

为什么?

如果我们添加了else 分支,您为什么认为编译器会决定将这两个标签视为等效,或者为什么 clang 会在其中放置 1,最后但并非最不重要的一点是:对C 标准可能会指出这段代码严重偏离常态的地方,以至于我们最终陷入了这种非常奇怪的情况。

【问题讨论】:

我假设您正在寻找比“未定义的行为使优化器做奇怪的事情”更彻底的东西。 @user253751 是的,有点……:D 对于使用编译器扩展大量混淆控制流的代码,该标准没有太多说明。我可以建议的最好的方法是生成中间表示转储(对于 gcc,除了所有其他选项外,还使用 ​​-fdump-tree-all -fdump-rtl-all 编译;我不记得如何使用 clang 获得等价的),然后通读所有文件并找出哪个优化器做错了事情。然后,您可以在 gcc-help@gcc.gnu.org 或类似的 clang 上询问该优化的目标是什么。 我还要指出,asm ("ret") 官方不支持;请参阅gcc.gnu.org/onlinedocs/gcc/Extended-Asm.html 的部分,它说“asm 语句可能不会执行跳转 [除了明确的标签列表]”。 asm volatile ("movl $5, %eax"); 在 EAX 上缺少一个clobber。您踩到了编译器的脚趾,即 UB。 GNU C Basic asm(无约束/破坏)的用例是像cli(禁用中断)这样的指令,而不是任何涉及整数寄存器的指令。但是可以肯定的是,如果您想将任意内容扔到编译器的 asm 输出中并查看会发生什么,您可以。只是不要期望任何明智的事情。哦,但是您在另一个函数中使用它就像 __attribute__((naked)) 函数,正常执行无法到达。 【参考方案1】:

对 C 标准有深刻理解的人可能会指出这段代码在哪里严重偏离了常态,以至于我们最终陷入了这种非常奇怪的情况。

您认为 ISO C 标准对这段代码有什么要说的吗?它充满了 UB 和 GNU 扩展,尤其是指向本地标签的指针。

将标签指针转换为函数指针并通过它调用显然是UB。 GCC 手册并没有说你可以做到这一点。这也是 UB 到 goto 另一个函数中的标签。

您只能通过诱使编译器认为可能会到达该块以使其不会被删除,然后使用 GNU C Basic asm 语句在那里发出 ret 指令来完成这项工作。

即使禁用优化,GCC 和 clang 也会删除死代码;例如if(0) ... 不会发出任何指令来实现 ...

另请注意,hi: 中的 c=5 编译时完全禁用优化(并且 else goto bye 已注释)为 asm,如 movl $5, -20(%rbp)。即使用调用者的 RBP 修改调用者栈帧中的局部变量。所以你有一个嵌套函数。

GNU C 允许您define nested functions 可以访问其父作用域的本地变量。 (如果您喜欢从实验中获得的 asm,那么您会喜欢 GCC 存储到堆栈的机器代码的可执行蹦床,如果您使用指向嵌套函数的指针,则使用 mov-immediate!)


asm volatile ("movl $5, %eax"); 在 EAX 上缺少一个破坏者。你踩到编译器的脚趾,如果这个语句被正常到达,那将是 UB,而不是好像它是一个单独的函数。

GNU C Basic asm(无约束/破坏者)的用例是像 cli(禁用中断)这样的指令,而不是任何涉及整数寄存器的指令,绝对不是 ret

如果要使用内联 asm 定义可调用函数,可以在全局范围内使用 asm(""),或作为 __attribute__((naked)) 函数的主体。

【讨论】:

以上是关于谜团:将 GNU C 标签指针转换为函数指针,并使用内联 asm 在该块中放置一个 ret。块被优化掉?的主要内容,如果未能解决你的问题,请参考以下文章

如何使用 C++ lambda 将成员函数指针转换为普通函数指针以用作回调

将函数指针的 C 结构体转换为 JNA 代码

如何将指向c数组的指针转换为python数组

使用 c 库的 C++ 程序 - 将智能指针转换为原始 c 样式指针?

将c ++引用的地址传递给指针

如何将指针函数转换为类函数? C++