如果我转换一个函数指针,改变参数的数量会发生什么?

10

我刚开始了解C语言中的函数指针。为了理解函数指针的类型转换,我编写了以下程序。它基本上创建了一个指向一个只有一个参数的函数的指针,将其转换为具有三个参数的函数指针,并调用该函数,提供三个参数。我很好奇会发生什么:

#include <stdio.h>

int square(int val){
  return val*val;
}

void printit(void* ptr){
  int (*fptr)(int,int,int) = (int (*)(int,int,int)) (ptr);
  printf("Call function with parameters 2,4,8.\n");
  printf("Result: %d\n", fptr(2,4,8));
}


int main(void)
{
    printit(square);
    return 0;
}

这段代码在Linux/x86上经过编译并且运行时没有出现错误或警告信息。我的系统输出结果为:

Call function with parameters 2,4,8.
Result: 4

显然,这些多余的参数只是被悄无声息地丢弃了。

现在我想了解到底发生了什么。

  1. 就合法性而言:如果我正确理解了将函数指针强制转换为另一种类型的答案,那么这只是未定义行为。因此,这能够运行并产生合理结果纯粹只是运气吗?(或者说编译器作者很好心)
  2. 为什么gcc甚至不会通过Wall选项警告我这个问题呢?这是编译器无法检测到的东西吗?为什么?

我来自Java,那里类型检查要严格得多,所以这种行为让我有点困惑。也许我正在经历文化冲击 :-)。


1
如果这已经对你来说是一种文化冲击,那么等你发现在C++中,成员函数的指针可能比void*更大时,你会更加震惊,尽管它们不携带this指针... - OregonGhost
8个回答

15

额外的参数不会被丢弃,它们会被正确地放置在堆栈上,就好像调用一个期望三个参数的函数一样。但是,由于您的函数只关心一个参数,它只查看堆栈顶部,并且不使用其他参数。

这次调用成功的事实纯粹是基于两个事实的运气:

  • 第一个参数的类型对于函数和转换指针是相同的。如果您更改函数以接受指向字符串的指针并尝试打印该字符串,则会发生崩溃,因为代码将尝试引用地址为2的内存指针。
  • 默认情况下使用的调用约定是调用方清理堆栈。如果更改调用约定,使被调用者清理堆栈,则最终会导致调用者在堆栈上推送三个参数,然后被调用者清理(或尝试清理)一个参数。这可能会导致堆栈损坏。

编译器无法警告您可能出现的此类问题,原因很简单 - 在一般情况下,它不知道指针的值,在编译时无法评估指针指向的内容。想象一下,函数指针指向在运行时创建的类虚拟表中的方法?因此,如果您告诉编译器它是一个带有三个参数的函数指针,那么编译器将相信您。


@JasonC 是的,正是我在第二个要点中所说的。 :-) - Franci Penov

12

如果将一辆汽车视为锤子,则编译器会相信您的话,认为这辆汽车是一把锤子,但这并不能使汽车变成一把锤子。编译器可能会成功地使用汽车来钉钉子,但这取决于实现的好运。这仍然是一个不明智的行为。


3
  1. 是的,这是未定义行为——任何事情都可能发生,包括看起来“正常工作”。

  2. 强制转换会阻止编译器发出警告。而且,编译器不必诊断可能导致未定义行为的原因。原因是要么无法这么做,要么这样做过于困难和/或引起了太多开销。


3
你所犯的最严重错误就是将数据指针转换为函数指针。这比签名更改更糟糕,因为无法保证函数指针和数据指针的大小相等。与许多理论上的未定义行为不同,这种情况可以在实际情况中遇到,甚至在先进的机器上也可能会遇到(不仅限于嵌入式系统)。
在嵌入式平台上很容易遇到不同大小的指针。甚至有些处理器的数据指针和函数指针指向不同的区域(其中一个指向RAM,另一个指向ROM),这种被称为哈佛结构。在x86的实模式下,你可以混合使用16位和32位的指针。Watcom-C有一种特殊的DOS扩展模式,其中数据指针宽度为48位。尤其是在C语言中,应该知道并非所有内容都是POSIX标准,因为在某些奇特硬件上,C语言可能是唯一可用的语言。
一些编译器允许混合内存模型,在此模型下,代码被保证在32位大小内,而数据则可以使用64位指针进行寻址,或反之亦然。
编辑:结论,永远不要将数据指针转换为函数指针。

+1 有趣,我不知道数据指针和函数指针之间的区别。你有一些相关参考资料可以推荐吗?此外,对于函数指针来说,“void”的等效物是什么?或者说是否存在像数据指针中的“通用”函数指针,类似于“void”? - sleske
标准的 C 语言并不保证 'sizeof(void *) == sizeof(void (*)(void))',但幸运的是 POSIX 是这样的。 - Jonathan Leffler
第6.3.2.3节“指针”说:“一个类型的函数指针可以转换为另一种类型的函数指针,然后再转换回来;结果应该与原始指针相等。如果使用转换后的指针调用其所指向的类型不兼容的函数,则行为是未定义的。”请注意,问题明确引用了未定义的行为-要小心鼻子恶魔。关于对象指针的类似段落(较早)。C标准从未说过函数指针可以转换为对象指针或反之亦然。 - Jonathan Leffler
在嵌入式平台上,你可能很容易遇到不同大小的指针。甚至有些处理器的数据指针和函数指针地址指向不同的东西(一个指向RAM,另一个指向ROM),这就是所谓的哈佛架构。在x86实模式下,可以混合使用16位和32位。Watcom-C有一个专门为DOS扩展设计的特殊模式,其中数据指针宽度为48位。尤其是在使用C语言时,应该知道并非所有都符合POSIX标准,因为C语言可能是在异乎寻常的硬件上唯一可用的语言。 - Patrick Schlüter
2
关于通用的void函数指针,实际上是不存在的。最接近的可能是int (*)()。请注意,参数列表中没有void,因为这会表示该函数不带参数,而使用()只是表示它具有未知数量的参数。这是C和C++之间的一个语义差异。 - Patrick Schlüter
@tristopia:你好,我把你有用的评论编辑成了答案。希望你不介意。 - sleske

2

行为由调用约定定义。如果您使用的是调用方推送和弹出堆栈的调用约定,则在此情况下它将正常工作,因为这只意味着在调用期间堆栈上有一些额外的字节。我目前手头没有gcc,但是使用微软编译器,此代码:

int ( __cdecl * fptr)(int,int,int) = (int (__cdecl * ) (int,int,int)) (ptr);

下面的汇编代码是为该调用生成的:
push        8
push        4
push        2
call        dword ptr [ebp-4]
add         esp,0Ch

注意调用之后栈增加了12个字节(0Ch)。在这之后,栈是正常的(假设被调用者在这种情况下是__cdecl,因此不会尝试清理栈)。但是对于以下代码:
int ( __stdcall * fptr)(int,int,int) = (int (__stdcall * ) (int,int,int)) (ptr);

add esp,0Ch在汇编中未生成。如果被调用者在此情况下为__cdecl,则堆栈将被破坏。


+1 感谢您提供有关调用约定的信息。我不知道有多种约定。 - sleske

1
  1. 虽然我不确定,但如果这是运气或者与编译器相关的行为,你绝对不想利用它。

  2. 这不值得警告,因为强制转换是显式的。通过强制转换,你告诉编译器你更清楚。特别是,你在进行一个void*的强制转换,这意味着“将这个指针表示的地址,与另一个指针相同”——强制转换只是告诉编译器你确信目标地址上的内容实际上是相同的。尽管在这里,我们知道这是错误的。


1

我应该在某个时候刷新一下 C 调用约定的二进制布局,但我非常确定正在发生以下情况:

  • 1:这不是纯粹的运气。C 调用约定是明确定义的,堆栈上的额外数据对调用点不是因素,尽管它可能会被被调用者覆盖,因为被调用者不知道它。
  • 2:使用括号进行“硬”转换告诉编译器你知道自己在做什么。由于所有所需数据都在一个编译单元中,编译器可能足够聪明,能够发现这显然是非法的,但 C 的设计者并没有专注于捕捉角落情况的可验证的不正确性。简而言之,编译器相信你知道自己在做什么(也许对于许多 C/C++ 程序员来说是不明智的)。

这只是纯属运气。参数的数量并不是唯一重要的事情,传递和期望的参数的类型、大小和语义也很重要。例如从f1(char *)转换为f2(int, int, int)会导致问题。或者从f1(MYVERYLARGESTRUCT)转换为f2(char)也会有问题。 - Franci Penov
我并不是在一般情况下倡导这段代码,但其中发生的事情并非纯粹的运气。 'C'调用约定(在MS C++中为__cdecl)保证方法调用的第一个参数将始终由被调用方首先找到,而不管后续参数有多少个。这种行为完全可预测和可重复,这与我的“运气”相反。 - David Gladfelter
1
要明确一下,以下是关于调用栈的情况,从esp寄存器地址开始,在此代码调用square()函数时看起来像这样:第1个4字节:调用的返回地址;第2个4字节:整数'2'的值;第3个4字节:整数'4'的值;第4个4字节:整数'8'的值。 "square()"函数只知道esp后面的前8个字节,但这8个字节正是它需要正确运行的数据。这是完全安全和可重复的行为,但它令人困惑到难以推荐。 - David Gladfelter

0

回答你的问题:

  1. 纯粹的运气 - 你很容易就会破坏栈并覆盖返回指针到下一个执行的代码。由于你用3个参数指定了函数指针并且调用了函数指针,剩下的两个参数被“丢弃”,因此行为是未定义的。想象一下第二个或第三个参数包含了一个二进制指令,并从调用过程栈上弹出....

  2. 没有警告,因为你使用了一个 void * 指针并将其转换。在编译器看来,这是相当合法的代码,即使你已经明确指定了 -Wall 开关。编译器假设你知道自己在做什么! 这就是秘密。

希望有所帮助, 最好的问候, 汤姆。


他们应该建立一个编译器,它知道不要相信人类。 :-) - Franci Penov
@Franci:这就是 C 语言的本质,只要你提供一个看起来合法的语法,编译器就会欣然接受它...看看 http://www.ioccc.org 就知道我在说什么了。 - t0mm13b

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接