为什么函数参数的顺序被颠倒了?

12

我一直在尝试使用函数,发现参数的顺序在内存中被颠倒了。为什么会这样?

stack-test.cpp:

#include <stdio.h>

void test( int a, int b, int c ) {
    printf("%p %p %p\n", &a, &b, &c);
    printf("%d %d\n", *(&b - 1), *(&b + 1) );
}

int main() {
    test(1,2,3);
    return 0;
}

CLang:

$ clang++ stack-test.cpp && ./a.out
0x7fffb9bb816c 0x7fffb9bb8168 0x7fffb9bb8164
3 1

GCC:

$ g++ stack-test.cpp && ./a.out
0x7ffe0b983b3c 0x7ffe0b983b38 0x7ffe0b983b34
3 1

编辑:不是重复问题:计算顺序可能与内存布局不同,因此这是一个不同的问题。


可能是Compilers and argument order of evaluation in C++的重复问题。 - Steephen
1
@Steephen 运算顺序,真的吗? - kravemir
@Miro 这是实现特定的,参数处理顺序由编译器决定。 - Steephen
如果您不知道,*(&b-1)会导致未定义的行为。 - M.M
@MattMcNabb 我知道,如果b是数组中间整数的引用(我可以转到前一个元素),那么它将是有效的。但是,正如我在问题中所述 - 我正在进行实验。 - kravemir
6个回答

9
这种行为是与实现相关的。 在您的情况下,这是因为参数被推送到堆栈上。这里有一篇有趣的文章,展示了进程的典型内存布局,显示了堆栈如何向下增长。因此,第一个被推送到堆栈上的参数将具有最高地址。

谢谢,但那并没有回答“为什么”的问题 :) 显然,行为是与实现相关的。但是,我想知道为什么它被反转了,为什么第一个参数不是在内存中首先呢? - kravemir
@Miro 我已经编辑了一个链接,解释了堆栈向下增长的问题(至少在Windows和Linux上)。 - Christophe

9
调用约定取决于实现。
但为了支持C变参函数(在C++中用形式参数列表中的...椭圆表示),参数通常从右到左推送或为它们保留堆栈空间。这通常被称为C调用约定(C calling convention) (1)。使用此约定和机器堆栈向内存下方增长的常见约定,第一个参数应该出现在最低地址处,与您的结果相反。
当我使用MinGW g++ 5.1编译您的程序时,它是64位的,我得到的结果是:
000000000023FE30 000000000023FE38 000000000023FE40

当我使用32位的Visual C++ 2015编译您的程序时,会出现以下情况:

00BFFC5C 00BFFC60 00BFFC64

这两个结果都符合C调用约定,与您的结果相反。

因此可能的结论是,您的编译器默认使用非C调用约定,至少对于非变参函数如此。

您可以通过在形式参数列表末尾添加...进行测试。


1) C调用约定还包括调用者在函数返回时调整堆栈指针,但这在这里并不相关。


获取参数的地址可能与此有关,因为这意味着它们不能(仅)存储在寄存器中。 - M.M
我尝试使用递归来测试我的堆栈增长方向,结果它在内存中向下增长。因此,它的调用约定与您所述的不同。现在,我将调查调用约定以及为什么会使用不同的约定 :) - kravemir
请注意,如果您不获取ac的地址,它们将根本不会出现在堆栈上...(好吧,至少不会出现在该函数调用帧内的堆栈上,当然它们会出现在main函数的堆栈上) - Mats Petersson
@MatsPetersson:针对x86-64,请参见此答案中的示例,与您的观点不兼容。有关它的一些信息,请参见(https://en.wikipedia.org/wiki/X86_calling_conventions#Microsoft_x64_calling_convention)。另外,您所说的“没有C调用约定”,只是无稽之谈、误导性噪音,抱歉。为了更方便地搜索它,可以尝试使用`cdecl`。但是,您所说的关于寄存器的内容可能是相关的。对于X86-64,在使用寄存器传递参数的情况下即使保留堆栈空间,但并非所有64位约定都是如此。然后就会出现这种效果。 - Cheers and hth. - Alf
关于Mats的说法:(1)我倾向于相信维基百科的x86-64调用约定表,该表列出了所有三种参数推送的“(C)”顺序(C是指Mats认为不存在的通用C调用约定);(2)Mats声称C调用约定与C标准有任何关系是误导性的胡言乱语,纯属废话;(3)Mads的明显引用,由引号字符引入,不是引用,而是一种歪曲。 - Cheers and hth. - Alf
显示剩余2条评论

2

C(和C++)标准未定义参数传递的顺序,或者它们在内存中应该如何组织。这取决于编译器开发人员(通常与操作系统开发人员合作)来想出适用于特定处理器架构的解决方案。

在大多数架构中,堆栈(和寄存器)用于将参数传递给函数,并且同样,在大多数C实现中,堆栈从“高到低”地址增长,传递的参数顺序为“左后右先”,因此如果我们有一个函数

 void test( int a, int b, int c )

然后,参数按顺序传递:
c, b, a

然而,当参数的值被传递到寄存器中,并且使用这些参数的代码正在获取这些参数的地址时,情况变得更加复杂 - 寄存器没有地址,因此您无法获取寄存器变量的地址。因此,编译器将生成一些代码在函数本地将地址存储在栈上[从中我们可以获得该值的地址]。这完全取决于编译器的决定,它以什么顺序执行此操作,我相信这就是您所看到的。

如果我们将您的代码通过clang编译,我们会看到:

define void @test(i32 %a, i32 %b, i32 %c) #0 {
entry:
  %a.addr = alloca i32, align 4
  %b.addr = alloca i32, align 4
  %c.addr = alloca i32, align 4
  store i32 %a, i32* %a.addr, align 4
  store i32 %b, i32* %b.addr, align 4
  store i32 %c, i32* %c.addr, align 4
  %call = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([10 x i8], [10 x i8]* @.str, i32 0, i32 0), i32* %a.addr, i32* %b.addr, i32* %c.addr)
  %add.ptr = getelementptr inbounds i32, i32* %b.addr, i64 -1
  %0 = load i32, i32* %add.ptr, align 4
  %add.ptr1 = getelementptr inbounds i32, i32* %b.addr, i64 1
  %1 = load i32, i32* %add.ptr1, align 4
  %call2 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([7 x i8], [7 x i8]* @.str.1, i32 0, i32 0), i32 %0, i32 %1)
  ret void
}

虽然可能不是完全容易阅读的,但您可以看到测试函数的前几行:

  %a.addr = alloca i32, align 4
  %b.addr = alloca i32, align 4
  %c.addr = alloca i32, align 4
  store i32 %a, i32* %a.addr, align 4
  store i32 %b, i32* %b.addr, align 4
  store i32 %c, i32* %c.addr, align 4

这实际上是在堆栈上创建空间 (%alloca),并将变量 abc 存储到这些位置。

gcc 生成的汇编代码更难阅读,但您可以看到类似的操作:

subq    $16, %rsp           ; <-- "alloca" for 4 integers.
movl    %edi, -4(%rbp)      ; Store a, b and c. 
movl    %esi, -8(%rbp)
movl    %edx, -12(%rbp)
leaq    -12(%rbp), %rcx     ; Take address of ... 
leaq    -8(%rbp), %rdx
leaq    -4(%rbp), %rax
movq    %rax, %rsi
movl    $.LC0, %edi
movl    $0, %eax
call    printf              ; Call printf.

你可能会想知道为什么它要分配4个整数的空间 - 这是因为在x86-64中,堆栈应该始终对齐到16字节。

1

C(和C++)代码使用处理器堆栈将参数传递给函数。

堆栈的操作方式取决于处理器。堆栈可以(理论上)向下或向上增长。因此,您的处理器正在定义地址增长或缩小。最后,不仅仅是处理器架构对此负责,还有用于在架构上运行的代码的调用约定

调用约定规定了如何为一个特定的处理器架构在堆栈上放置参数。这些约定是必要的,以便来自不同编译器的库可以链接在一起。

基本上,对于您作为C用户而言,如果堆栈上的变量地址增长或缩小,通常没有区别。

细节:


C语言并不会做这样的事情。一些实现可能会这样做,但这并不是语言要求的。例如,一些处理器具有非常大的寄存器集,以便于参数传递。 - nobody
@AndrewMedico:我的意思是,对于体系结构,有一些调用约定也适用于C代码。因此,C标准并没有定义这样的约定——但是对于一个体系结构来说,定义这样的标准是必要的,以便不同编译器输出可以链接在一起。请参见我的链接。我澄清了我的句子。 - Juergen
这确实不是由架构定义的,而是由ABI定义的。一个架构可以有多个ABI,处理器对ABI一无所知,它只是程序员和编译器之间的契约。 - Non-maskable Interrupt
@Calvin:当涉及到技术细节时,总是可能会发现一些错误或不清楚的地方。我区分了架构和调用约定,但似乎不够清晰。我也不想写一篇维基百科条目——因此维基百科存在。我还链接到了维基百科,其中描述了所有细节。 - Juergen

1

ABI 定义了如何传递参数。

在您的示例中,由于 x86_64 ABI 默认使用 gcc 和 clang 在寄存器上传递参数(*),因此没有为它们分配地址。

然后您引用这些参数,所以编译器被迫为这些变量分配本地存储空间,而该排序和内存布局也是特定于实现的。

  • 注意:最多 6 个简单参数,如果有更多,则传递到堆栈上。
  • 参考:x86_64 ABI

0

谈论32位x86 Windows

简短回答:指向函数参数的指针不一定是指向实际函数调用时推入堆栈的指针,而可以是编译器重新定位变量的任何位置。

详细回答: 在将我的代码从bcc32(Embarcadero经典编译器)转换为CLANG时遇到了同样的问题。由MIDL编译器生成的RPC代码已经损坏,因为RPC函数参数翻译器通过获取第一个函数参数的指针来序列化参数,假设所有后续参数都会像Serialize(&a)一样跟随。

调试了BCC32和CLANG生成的cdecl函数调用:

  • BCC32:函数参数按正确顺序在堆栈上传递,然后在需要参数地址时直接给出堆栈地址。

  • CLANG:函数参数按正确顺序在堆栈上传递,但是在实际函数中,所有参数的副本都以堆栈相反的顺序在内存中创建,并且在需要函数参数地址时,直接给出内存地址,导致顺序颠倒。

换句话说,不要假设函数参数在内存中如何被处理,这是由C/C++代码的编译器决定的。
在我的情况下,一个可能的解决方案是使用Pascal调用约定(Win32)声明RPC函数,强制MIDL编译器逐个解析参数。不幸的是,MIDL生成的代码很笨重,需要大量调整才能编译(目前还没有完成)。

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