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 上的 gcc -Wall)。我的系统上的输出是:

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

显然,多余的论点只是默默地丢弃了。

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

  1. 至于合法性:如果我正确理解将函数指针转换为另一种类型的答案,这只是未定义的行为。所以这个运行并产生合理结果的事实只是纯粹的运气,对吗?(或编译器编写者的好意)
  2. 为什么 gcc 不会警告我这一点,即使是 Wall?这是编译器无法检测到的吗?为什么?

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

4

8 回答 8

15

额外的参数不会被丢弃。它们被正确地放置在堆栈上,就好像调用一个需要三个参数的函数一样。但是,由于您的函数只关心一个参数,因此它只查看堆栈顶部,而不会触及其他参数。

基于以下两个事实,此调用起作用的事实纯属运气:

  • 函数和强制转换指针的第一个参数的类型相同。如果您将函数更改为获取指向字符串的指针并尝试打印该字符串,您将得到一个很好的崩溃,因为代码将尝试取消引用指向地址内存 2 的指针。
  • 默认情况下使用的调用约定是让调用者清理堆栈。如果您更改调用约定,以便被调用者清理堆栈,最终将导致调用者将三个参数压入堆栈,然后被调用者清理(或更确切地说是尝试)一个参数。这可能会导致堆栈损坏。

由于一个简单的原因,编译器无法警告您这样的潜在问题 - 在一般情况下,它在编译时不知道指针的值,因此它无法评估它指向的内容。想象一下,函数指针指向的是在运行时创建的类虚拟表中的一个方法?所以,如果你告诉编译器它是一个指向具有三个参数的函数的指针,编译器会相信你。

于 2010-01-22T17:04:22.597 回答
12

如果你拿一辆车把它当作锤子,编译器会告诉你这辆车是锤子,但这不会把汽车变成锤子。编译器可能会成功地使用汽车钉钉子,但这是依赖于实现的幸运。这仍然是不明智的做法。

于 2010-01-22T17:03:02.623 回答
3
  1. 是的,这是未定义的行为——任何事情都可能发生,包括它看起来“有效”。

  2. 强制转换防止编译器发出警告。此外,编译器不需要诊断可能导致未定义行为的原因。这样做的原因是它要么不可能这样做,要么这样做太困难和/或导致过多的开销。

于 2010-01-22T16:57:29.650 回答
3

你的演员最糟糕的罪行是将数据指针转换为函数指针。它比签名更改更糟糕,因为无法保证函数指针和数据指针的大小相等。与许多理论上的未定义行为相反,这种行为可以在野外遇到,甚至在高级机器上(不仅在嵌入式系统上)。

您可能会在嵌入式平台上轻松遇到不同大小的指针。甚至有些处理器的数据指针和函数指针确实寻址不同的东西(一个是 RAM,另一个是 ROM),即所谓的哈佛架构。在实模式下的 x86 上,您可以混合使用 16 位和 32 位。Watcom-C 有一个用于 DOS 扩展器的特殊模式,其中数据指针为 48 位宽。尤其是对于 C,人们应该知道并非所有东西都是 POSIX,因为 C 可能是奇异硬件上唯一可用的语言。

一些编译器允许混合内存模型,其中代码保证在 32 位大小内,数据可使用 64 位指针寻址,反之亦然。

编辑:结论,永远不要将数据指针转换为函数指针。

于 2010-01-22T17:23:45.080 回答
2

行为由调用约定定义。如果您使用调用者推送和弹出堆栈的调用约定,那么在这种情况下它会正常工作,因为这意味着在调用期间堆栈上有额外的几个字节。我现在手头没有 gcc,但是使用 microsoft 编译器,这段代码:

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,则堆栈将被破坏。

于 2010-01-22T17:37:59.500 回答
1
  1. 诚然,我不确定,但是如果运气好或者它是特定于编译器的,你肯定不想利用这种行为。

  2. 它不值得警告,因为演员表是明确的。通过强制转换,您可以告知编译器您更了解。特别是,您正在转换 a void*,因此您是在说“获取此指针表示的地址,并使其与另一个指针相同” - 转换只是通知编译器您确定在实际上,目标地址是相同的。虽然在这里,我们知道这是不正确的。

于 2010-01-22T16:55:20.803 回答
1

我应该在某个时候刷新我对 C 调用约定的二进制布局的记忆,但我很确定这是正在发生的事情:

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

要回答您的问题:

  1. 纯属运气 - 您可以轻松地踩踏堆栈并覆盖下一个执行代码的返回指针。由于您使用 3 个参数指定了函数指针,并调用了函数指针,其余两个参数被“丢弃”,因此行为未定义。想象一下,如果第二个或第三个参数包含一条二进制指令,并从调用过程堆栈中弹出......

  2. void *当您使用指针并投射它时,没有警告。即使您明确指定了-Wallswitch,这在编译器眼中也是相当合法的代码。编译器假定您知道自己在做什么!这就是秘密。

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

于 2010-01-22T17:01:04.260 回答