【问题标题】:How are variable arguments implemented in gcc?gcc 中的变量参数是如何实现的?
【发布时间】:2012-09-04 11:40:03
【问题描述】:
int max(int n, ...)

我使用cdecl 调用约定,调用者在被调用者返回后清理变量。

我很想知道宏 va_endva_startva_arg 是如何工作的?

调用者是否将参数数组的地址作为第二个参数传递给 max?

【问题讨论】:

  • 这篇博文,amd64 and va_arg 对用于变量参数的va_arg 函数集如何在机器架构和调用约定(与特定处理器一起使用的 ABI)之间存在差异进行了很好的讨论.与旧的 x86 架构相比,具有更多可用寄存器的现代处理器允许在寄存器和堆栈中传递参数。
  • 很遗憾,这个问题的所有答案或多或少都是错误的。 (它们对于很久以前定义的 ABI 模糊准确,但是对于过去 20 多年定义的任何 ABI,调用约定要复杂得多,并且涉及将内容放入寄存器和堆栈中。是的,即使对于“CISC ”处理器。)我会写一个更好的答案,但这需要我整个下午,我今天需要做我的实际工作:-P

标签: c gcc variadic-functions calling-convention


【解决方案1】:

如果你看一下 C 语言在堆栈上存储参数的方式,宏的工作方式应该会变得清晰:-

Higher memory address    Last parameter
                         Penultimate parameter
                         ....
                         Second parameter
Lower memory address     First parameter
       StackPointer  ->  Return address

(注意,根据硬件的不同,堆栈指针可能向下一行,高低可以交换)

参数总是像这样1 存储的,即使没有... 参数类型。

va_start 宏只是设置了一个指向第一个函数参数的指针,例如:-

 void func (int a, ...)
 { 
   // va_start
   char *p = (char *) &a + sizeof a;
 }

这使得p 指向第二个参数。 va_arg 宏执行以下操作:-

 void func (int a, ...)
 { 
   // va_start
   char *p = (char *) &a + sizeof a;

   // va_arg
   int i1 = *((int *)p);
   p += sizeof (int);

   // va_arg
   int i2 = *((int *)p);
   p += sizeof (int);

   // va_arg
   long i2 = *((long *)p);
   p += sizeof (long);
 }

va_end 宏只是将p 的值设置为NULL

注意事项:

  1. 优化编译器和一些 RISC CPU 将参数存储在寄存器中,而不是使用堆栈。 ... 参数的存在将关闭此功能并让编译器使用堆栈。

【讨论】:

  • 这确实是特定于平台的,因为 许多 调用约定(包括常见的 x64、PPC、ARM)在寄存器中传递它们的大部分参数。许多平台不将返回地址放在堆栈上,一两个平台的堆栈向上而不是向下增长,并且一些调用约定以相反的顺序将参数放在堆栈上。
  • @DietrichEpp:我知道。但希望它能得到一些基础知识。我在答案中添加了一些注释,以反映堆栈的各种工作方式。尽管如此,要涵盖编译器实现的大多数不同方式,这将需要更长的答案。简单的方法是找到宏定义并查看它们的扩展情况,希望不会发生令人毛骨悚然的编译器魔法。
  • 在 64 位上,可变参数仍然是可能的,但 va_arg() implementation would be very complex,需要编译器支持,而不仅仅是用户模式代码。
  • ... 参数的存在将关闭此功能并让编译器使用堆栈。 实际上不是,在 x86-64 上 Windows 和 System V调用约定仍然在相同的寄存器中传递参数以用于可变参数或不可变参数。 Windows 调用约定要求堆栈参数(如果有)之前返回地址上方的“影子空间”,因此可变参数函数可以将 4 个寄存器转储到影子空间并将其参数索引为数组。 (调用者需要在整数和 XMM 寄存器中复制 FP args 以用于可变参数函数)。
  • 所有主要的 C 编译器都选择遵循标准调用约定/ABI,因此您可以在同一目标平台上由不同编译器编译的库中调用库函数。在 x86-64 上,那些调用约定(x86-64 System V 和 x86-64 Windows 约定)都使用寄存器参数,即使对于可变参数函数也是如此。有关调用 printf 的示例,请参阅 stackoverflow.com/questions/6212665。所以不,编译器不能只是编造一些东西,不,... 不会禁用寄存器参数传递。是的,当然不同的目标有不同的调用约定。
【解决方案2】:

当参数在堆栈上传递时,va_“函数”(它们大部分时间都以宏的形式实现)简单地操作一个私有堆栈指针。这个私有堆栈指针存储在传递给va_start 的参数中,然后va_arg 在迭代参数时从“堆栈”中“弹出”参数。

假设您使用三个参数调用函数max,如下所示:

max(a, b, c);

max函数内部,栈基本上是这样的:

+-----+ | c | |乙 | |一个 | |回复 | SP -> +-----+

SP 是真正的堆栈指针,实际上堆栈上的不是abc,而是它们的值。 ret是返回地址,函数执行完毕后跳转到哪里。

va_start(ap, n) 所做的是获取参数的地址(函数原型中的n)并从中计算下一个参数的位置,因此我们得到一个新的私有堆栈指针:

+-----+ | c | ap -> |乙 | |一个 | |回复 | SP -> +-----+

当您使用va_arg(ap, int) 时,它会返回私有堆栈指针指向的内容,然后通过将私有堆栈指针更改为现在指向下一个参数来“弹出”它。堆栈现在看起来像这样:

+-----+ ap -> | c | |乙 | |一个 | |回复 | SP -> +-----+

这个描述当然是简化了,但说明了原理。

【讨论】:

  • 如果没有调用者清理约定,va_arg 肯定无法将其从堆栈中弹出。
  • @Joachim:您能否提供一些插图或更详细地描述您的答案。我无法想象你在说什么。
  • @DeadMG 当然不是,这就是为什么我把流行音乐放在引号内。 :)
  • @Bruce 修改了我的答案,希望你现在能更好地理解。
  • 这就是它在 32 位平台上的工作方式。在 64 位平台上,一些参数通过寄存器传递,其他参数通过堆栈传递,因此 64 位实现更加复杂。
【解决方案3】:

一般来说,我如何理解 target.def,当使用 ( ,...) 声明函数原型时,编译器会设置一个标有 varargs 标志的解析树并引用命名参数的类型。对于严格的 C 一致性,每个命名参数都应该获得附加的任何附加信息,以在该参数是 va_start 的命名字段并作为可能返回到 va_arg() 时附加设置 va_list,但大多数编译器只是为最后一个命名参数生成此信息.当函数被定义时,它的序言生成器注意到 varargs 标志已设置,并添加必要的代码来设置它添加到帧中的任何隐藏字段,这些字段具有 va_start 宏可以引用的已知偏移量。

当它找到对该函数的引用时,它会为表示 ... 的每个参数创建额外的解析和代码生成树,这可能会引入附加到字段的运行时类型信息的额外隐藏字段,例如数组边界为命名参数设置 va_start 和 va_arg。该组合树确定生成哪些代码以将参数值复制到框架上,序言设置 va_start 创建从任意或最后命名的参数开始的 va_list 所必需的内容,并且每次调用 va_arg() 都会生成引用的内联代码用于在编译时验证预期返回的任何参数特定隐藏字段是与正在编译的表达式用法兼容的赋值,并执行任何所需的参数提升/强制。命名字段值大小和隐藏字段大小的总和决定了在调用之后编译什么值,或者在被调用者清理模型的函数尾声中编译,以在返回时调整帧。

每个步骤都有处理器和调用约定依赖项,封装在 config/proc/proc.c 和 proc.h 文件中,它们覆盖 va_start() 和 va_arg() 的简单默认定义,假设每个参数都有一个固定大小在堆栈上的第一个命名参数上方分配了一些距离。对于某些平台或语言,实现为单独的 malloc() 的参数框架比固定大小的堆栈更可取。还要注意这些用法不是线程安全的;将 va_list 引用传递给另一个线程是不安全的,而没有未指定的方法来确保参数帧不会由于函数返回或线程中止而无效。

【讨论】:

  • 这个答案似乎没有解决 OP 实际提出的问题。
猜你喜欢
  • 2014-02-06
  • 1970-01-01
  • 1970-01-01
  • 2013-07-30
  • 1970-01-01
  • 1970-01-01
  • 2020-08-14
  • 1970-01-01
相关资源
最近更新 更多