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

我刚刚开始围绕C中的函数指针。要了解函数指针的转换是如何工作的,我编写了以下程序。 它基本上创建了一个函数指针,该函数指向一个带有一个参数的函数,将它转换为带有三个参数的函数指针,并调用该函数,提供三个参数。 我很好奇会发生什么:

#include  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,那里的类型检查要严格得多,所以这种行为让我有点困惑。 也许我正在经历文化冲击:-)。

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

基于以下两个事实,这个呼叫有效的事实是纯粹的运气:

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

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

如果你把汽车扔成锤子,编译器会告诉你汽车是锤子但你不会把汽车变成锤子。 编译器可能成功地使用汽车来驱动钉子,但这是依赖于实现的好运。 这仍然是一件不明智的事情。

  1. 是的,它是未定义的行为 – 任何事情都可能发生,包括它似乎“工作”。

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

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

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

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

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

行为由调用约定定义。 如果您使用调用者推送和弹出堆栈的调用约定,那么在这种情况下它将正常工作,因为它只是意味着在调用期间堆栈上有额外的几个字节。 我目前没有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,则堆栈将被破坏。

  1. 我肯定不确定,但你绝对不想利用这种行为,如果运气好, 或者它是特定的编译器。

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

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

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

回答你的问题:

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

  2. 当你使用void *指针并将其强制转换时没有警告。 即使您明确指定了-Wall开关,这在编译器眼中也是非常合法的代码。 编译器假设您知道自己在做什么! 这是秘密。

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