为什么此函数通过类型函数指针调用后,呼叫明智地行为

Why does this function call behave sensibly after calling it through a typecasted function pointer?

本文关键字:函数 呼叫 调用 类型 为什么 指针      更新时间:2023-10-16

我有以下代码。有一个函数需要两个INT32。然后,我将其指向它,然后将其施加到一个需要三个INT8并称呼它的函数。我期望运行时错误,但程序效果很好。为什么这甚至可能?

main.cpp:

#include <iostream>
using namespace std;
void f(int32_t a, int32_t b) {
    cout << a << " " << b << endl;
}
int main() {
    cout << typeid(&f).name() << endl;
    auto g = reinterpret_cast<void(*)(int8_t, int8_t, int8_t)>(&f);
    cout << typeid(g).name() << endl;
    g(10, 20, 30);
    return 0;
}

输出:

PFviiE
PFvaaaE
10 20

我可以看到第一个功能的签名需要两个int,第二个功能需要三个字符。char比int小,我想知道为什么A和B仍然等于10和20。

正如其他人指出的那样,这是未定义的行为,因此,所有原则上可能发生的事情都没有下注。但是,假设您在X86机器上,则有一个合理的解释,说明为什么要看到这个。

在x86上,G 编译器并不总是通过将其推到堆栈上来传递参数。相反,它将前几个论点藏在寄存器中。如果我们拆卸f函数,请注意,前几个说明将参数从寄存器中移出并明确地将其移到堆栈上:

    push    rbp
    mov     rbp, rsp
    sub     rsp, 16
    mov     DWORD PTR [rbp-4], edi  # <--- Here
    mov     DWORD PTR [rbp-8], esi  # <--- Here
    # (many lines skipped)

同样,请注意main中如何生成调用。这些论点放入这些寄存器中:

    mov     rax, QWORD PTR [rbp-8]
    mov     edx, 30      # <--- Here
    mov     esi, 20      # <--- Here
    mov     edi, 10      # <--- Here
    call    rax

由于整个寄存器都用于保存参数,因此参数的大小在这里无关。

此外,由于这些论点是通过寄存器通过的,因此不担心以不正确的方式调整堆栈的大小。一些呼叫约定(cdecl(使呼叫者进行清理,而另一些人(stdcall(要求Callee进行清理。但是,这里都不重要,因为堆栈没有触摸。

正如其他人指出的那样,它可能是不确定的行为,但是老式的C程序员知道这种类型的工作。

另外,因为我可以感觉到语言律师起草他们的诉讼文件和法院请愿书,所以我要施放undefined behavior discussion的咒语。这是通过说undefined behavior的三遍,同时敲打我的鞋子。这使语言律师消失了,所以我可以解释为什么奇怪的事情只是在不被起诉的情况下工作。

回到我的答案:

我在下面讨论的所有内容都是编译器特定的行为。我的所有模拟都是在Visual Studio中编译为32位X86代码。我怀疑它在类似的32位体系结构上与GCC和G 的工作。

这就是为什么您的代码恰好工作和一些警告的原因。

  1. 当功能调用参数被推到堆栈上时,它们会以相反的顺序推动。当正常调用f时,编译器会在a参数之前生成代码将b参数推向堆栈。这有助于促进诸如printf之类的变异参数函数。因此,当您的功能f访问ab时,它只是访问堆栈顶部的参数。当通过g调用时,堆栈有一个额外的论点(30(,但首先被推了。接下来将20个推开,其次是堆栈顶部的10。f仅查看堆栈上的前两个参数。

  2. iirc,至少在经典的ANSI C,Chars和Shorts中,在被放置在堆栈上之前,请始终晋升为INT。这就是为什么当您使用g调用它时,将文字10和20作为全尺寸的INT而不是8位INT放置在堆栈上。但是,当您重新定义f进行64位的朗(而不是32位INT(时,程序的输出会更改。

    void  f(int64_t a, int64_t b) {
        cout << a << " " << b << endl;
    }

导致您的主(与我的编译器(

获得输出
85899345930 48435561672736798

如果您转换为十六进制:

140000000a effaf00000001e

14200A10。我怀疑1e是您的30被推到堆栈上。因此,当通过g调用时,这些论点被推到了堆栈,但以某种编译器的方式被弹出。(未定义的行为,但是您可以看到参数被推(。

  1. 当您调用函数时,通常的行为是调用代码将在从称为函数返回时修复堆栈指针。同样,这是为了variadic函数和其他遗留原因与k&amp; r C. printf不知道您实际传递了多少参数,并且它依赖呼叫者在返回时修复堆栈。因此,当您通过g调用时,编译器会生成代码将3个整数推向堆栈,调用功能,然后代码将相同的值弹出。当时,您将编译器选项更改为清理堆栈(Visual Studio上的ALA __stdcall(:
    void  __stdcall f(int32_t a, int32_t b) {
        cout << a << " " << b << endl;
    }

现在,您显然处于不确定的行为领域。通过g调用将三个INT参数推入堆栈上,但是编译器仅生成f的代码,以在返回时将两个INT参数从堆栈中弹出。返回后,堆栈指针会损坏。

正如其他指出的那样,这完全是未定义的行为,您得到的将取决于编译器。它仅在您有特定的呼叫约定时才起作用,不使用堆栈而是寄存器传递参数。

我用Godbolt查看了产生的组件,您可以在此处全部检查

相关功能调用在这里:

mov     edi, 10
mov     esi, 20
mov     edx, 30
call    f(int, int) #clang totally knows you're calling f by the way

它不会在堆栈上推动参数,而只是将它们放入寄存器中。最有趣的是,mov指令不仅不会更改寄存器的下部8位,但是所有这些都不是32位移动。这也意味着无论以前的登记册中的内容如何,当您像F一样阅读32位时,您将始终获得正确的价值。

如果您想知道为什么32位移动,事实证明,在几乎每种情况下,在X86或AMD64体系结构上,编译器将始终使用32位字面移动或64位字面移动(仅当值时太大了32位(。移动8位值不会使寄存器的上部(8-31(归零,并且如果该值最终被提升,则可能会产生问题。使用32位文字指令要比首先要归零的额外说明更简单。

您必须记住的一件事是,它确实在尝试调用f,就好像它具有8位参数一样,因此,如果您放置了一个大价值,它将截断字面意思。例如,1000将成为-24,因为1000的较低位是E8,使用签名整数时,即-24。您还将获得警告

<source>:13:7: warning: implicit conversion from 'int' to 'signed char' changes value from 1000 to -24 [-Wconstant-conversion]

第一个C编译器以及在C标准发布之前的大多数编译器,将通过以左右订单推动参数,使用平台的"使用"来处理函数调用"调用subroutine"指令调用该功能,然后在返回功能后,弹出所有参数。函数将按顺序分配给其参数的地址刚刚从刚从"呼叫"指令推动的任何信息开始。

即使在诸如经典的Macintosh之类的平台上,弹出参数的责任通常都在于称为函数(并且在不推动正确数量的参数通常会损坏堆栈的情况下(,C编译器通常使用表现出的呼叫约定像第一个C编译器。打电话时需要使用" pascal"预选赛,或者用其他语言编写的代码(例如pascal(来调用的函数。

在标准之前存在的大多数语言实现中,可以写一个函数:

int foo(x,y) int x,y
{
  printf("Heyn");
  if (x)
  { y+=x; printf("y=%dn", y); }
}

并将其称为例如foo(0)foo(0,0),前者稍快。试图称其为例如foo(1);可能会损坏堆栈,但是如果该功能从未使用过对象y,则无需通过它。但是方便地扩展语言。