为什么在通过类型转换的函数指针调用此函数后,该函数调用的行为还是明智的?

时间:2019-06-23 19:12:05

标签: c++ gcc function-pointers

我有以下代码。有一个函数需要两个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

如我所见,第一个函数的签名需要两个整数,第二个函数需要三个字符。 Char小于int,我想知道为什么a和b仍然等于10和20。

4 个答案:

答案 0 :(得分:36)

正如其他人指出的那样,这是未定义的行为,因此所有赌注都没有涉及原则上可能发生的情况。但是假设您使用的是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)则要求被叫者进行清理。但是,这在这里都不重要,因为堆栈没有被触及。

答案 1 :(得分:9)

正如其他人指出的那样,这可能是未定义的行为,但是老派的C程序员知道这种事情是可以解决的。

此外,因为我可以感觉到语言律师正在为我要说的事情起草诉讼文件和法院请愿书,所以我将使用undefined behavior discussion拼写。轻敲我的鞋子时说了三遍undefined behavior即可投射。这使得语言律师不见了,所以我可以解释为什么奇怪的事情在没有被起诉的情况下会发生。

回到我的答案:

我下面讨论的一切都是编译器特定的行为。我所有的模拟都是使用Visual Studio编译为32位x86代码的。我怀疑它在类似的32位体系结构上也可以与gcc和g ++一起使用。

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

  1. 当函数调用参数被压入堆栈时,它们以相反的顺序被压入。正常调用f时,编译器会生成代码,以将b自变量推入堆栈,并移至a自变量之前。这有助于简化可变参数参数,例如printf。因此,当您的函数f访问ab时,它只是访问堆栈顶部的参数。通过g调用时,有一个额外的参数被压入堆栈(30),但首先被压入。接下来是20,然后是栈顶的10。 f仅查看堆栈中的前两个参数。

  2. IIRC(至少在经典的ANSI C中是char和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

1420,而0A10。而且我怀疑1e是您的30被压入堆栈的原因。因此,当通过g调用参数时,这些参数被压入了堆栈,但是以某种编译器特定的方式合并了。 (再次未定义的行为,但是您可以看到参数已被推送)。

  1. 当您调用一个函数时,通常的行为是,从被调用函数返回时,调用代码将修复堆栈指针。同样,这是出于可变函数和与K&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参数从堆栈中弹出。返回时,堆栈指针已损坏。

答案 2 :(得分:1)

正如其他人指出的那样,这完全是未定义的行为,而您得到的将取决于编译器。仅当您具有特定的调用约定时才有效,该约定不使用堆栈,而是通过注册来传递参数。

我使用Godbolt查看生成的程序集,您可以完整检入here

相关的函数调用在这里:

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]

答案 3 :(得分:0)

第一个C编译器以及C标准发布之前的大多数编译器将通过按从右到左的顺序推入参数来处理函数调用,并使用平台的“调用子例程”指令来调用该函数,然后在函数返回后,弹出任何推送的参数。函数会从“调用”指令所推送的任何信息开始,按顺序将地址分配给其自变量。

即使在经典Macintosh之类的平台上,弹出参数的责任通常应由被调用函数负责(并且未能正确推入参数的数量通常会破坏堆栈),C编译器通常会使用行为规范的调用约定像第一个C编译器一样。在调用以其他语言(例如Pascal)编写的代码或由其调用的函数时,需要使用“ pascal”限定符。

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

int foo(x,y) int x,y
{
  printf("Hey\n");
  if (x)
  { y+=x; printf("y=%d\n", y); }
}

并以例如foo(0)foo(0,0),前者要快一些。尝试将其称为例如foo(1);可能会破坏堆栈,但是如果函数从未使用过对象y,则无需传递它。但是,并非所有平台都支持这种语义,并且在大多数情况下,参数验证的好处超过了成本,因此标准不要求实现能够支持该模式,而是允许那些可以支持该模式的实现。方便地扩展语言。