c/c++中的都是从右往左入栈的,在调用函数时,若参数需要运算则先运算,然后入栈,再调用函数。
1. 入栈顺序
程序中栈顶在低位,栈低在高位。
程序入栈顺序:
void push(int a, int b, int c)
{
printf("a = %d, ptr = 0X%x\r\n", a, &a);
printf("b = %d, ptr = 0X%x\r\n", b, &b);
printf("c = %d, ptr = 0X%x\r\n", c, &c);
}
先入栈的在栈底,应该存储在高位;后入栈的在栈顶,应该存储在低位。
位置顺序:c > b > a
所以:c最先入栈,a最后入栈
函数调用是从右往左入栈的
2. 函数执行顺序
- 函数参数有运算,则先计算完参数中的全部运算再执行函数
- 函数参数有运算需要区分是调用前执行还是调用后执行的函数(++i,i++)
- 入栈的是临时变量还是实际变量
分析下面代码的运算结果:
int main()
{
/*
1. 入栈顺序,参数从右往左入栈
2. 计算顺序,先入栈的先计算;计算完后再入栈
3. 传递的参数:实际变量,还是临时变量
*/
int i=1;
// 先计算,两次i++后i的值为3;传递的是实际变量i的值
printf("%d,%d\n", ++i, ++i); //3,3
// i++入栈的是i,然后执行i++,所以需要临时变量将i的值保存下来,再计算i++;++i先进行计算,入栈为变量i的值
printf("%d,%d\n", ++i, i++); //5,3
/*
1.后一个i++保存临时变量i2=5,然后i自增=6
2.前一个i++先保存临时变量i1=6,然后i自增=7
3.右边的参数先入栈push i2
4.左边的参数入栈push i1
*/
printf("%d,%d\n", i++, i++); //6,5
/*
1.后一个++i,先自增i=8
2.前一个i++;先保存临时变量i1=8,然后i自增=9
3.右边入栈push i
4.左边入栈push i1
*/
printf("%d,%d\n", i++, ++i); //8,9
}
也可从编译后的汇编代码中看出:
# printf("%d,%d\n", ++i, ++i);
# 取出i的值到寄存器
mov eax, DWORD PTR _i$[ebp]
# 寄存器自增1
add eax, 1
# 写回到i
mov DWORD PTR _i$[ebp], eax
# 取出i的值到寄存器
mov ecx, DWORD PTR _i$[ebp]
# 寄存器自增1
add ecx, 1
# 写回到i
mov DWORD PTR _i$[ebp], ecx
# 读取i的值到寄存
mov edx, DWORD PTR _i$[ebp]
# 入栈
push edx
# 读取i的值到寄存
mov eax, DWORD PTR _i$[ebp]
# 入栈
push eax
push OFFSET $SG3750
# 调用_printf
call _printf
# printf("%d,%d\n", ++i, i++);
# 取出i的值
mov ecx, DWORD PTR _i$[ebp]
# 保存到临时变量
mov DWORD PTR tv71[ebp], ecx
# 取出i的值,并自增1,写回i
mov edx, DWORD PTR _i$[ebp]
add edx, 1
mov DWORD PTR _i$[ebp], edx
# 取出临时变量的值,并入栈
mov eax, DWORD PTR tv71[ebp]
push eax
mov ecx, DWORD PTR _i$[ebp]
push ecx
push OFFSET $SG3751
call _printf
再思考一下下面的代码执行结果:
#include <stdio.h>
void push(int a, int b, int c) { printf("%d,%d,%d\n", a, b, c); }
int main() {
int i = 0;
push(i++, i++, i++); // 输出:0,0,0
i = 0;
push(++i, ++i, ++i); // 输出:3,3,3
i = 0;
push(++i, i++, ++i); // 输出:2,1,2
i = 0;
push(i++, ++i, ++i); // 输出:2,2,2
}
// 以上结果是使用msvc++ 12.0 / vc++ 2013编译的,gcc(gcc version 9.3.0)编译的结果如下:
/*
2,1,0 // 此结果可以看出gcc中参数从右往左入参
3,3,3
3,1,3
2,3,3
*/
关于上面的代码msvc编译和gcc编译得到的结果不同,我又把两个的汇编代码搞出来看了一下:
# 只看 main 开始到 push(i++, i++, i++);
# mscv ( Microsoft Visual Studio 12.0 / msvc++ 2013 )
_main PROC
; File d:\users\administrator\desktop\main.cpp
; Line 5
push ebp
mov ebp, esp
sub esp, 24 ; 00000018H
; Line 6
mov DWORD PTR _i$[ebp], 0 # 赋值 0
; Line 7
mov eax, DWORD PTR _i$[ebp]
mov DWORD PTR tv65[ebp], eax
mov ecx, DWORD PTR _i$[ebp]
mov DWORD PTR tv68[ebp], ecx
mov edx, DWORD PTR _i$[ebp]
mov DWORD PTR tv71[ebp], edx
mov eax, DWORD PTR tv65[ebp]
push eax
mov ecx, DWORD PTR tv68[ebp]
push ecx
mov edx, DWORD PTR tv71[ebp]
push edx
call ?push@@YGXHHH@Z ; push # push之前传参,push之后自增+1
mov eax, DWORD PTR _i$[ebp]
add eax, 1
mov DWORD PTR _i$[ebp], eax
mov ecx, DWORD PTR _i$[ebp]
add ecx, 1
mov DWORD PTR _i$[ebp], ecx
mov edx, DWORD PTR _i$[ebp]
add edx, 1
mov DWORD PTR _i$[ebp], edx
# gcc ( gcc version 9.3.0 )
main:
.LFB1:
.loc 1 5 12
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
.loc 1 6 7
movl $0, -4(%rbp) # i = 0
.loc 1 7 7
movl -4(%rbp), %edx # edx赋值,保存栈地址
leal 1(%rdx), %eax # eax = rdx+1
movl %eax, -4(%rbp) # exa入栈
movl -4(%rbp), %ecx
leal 1(%rcx), %eax
movl %eax, -4(%rbp) #
movl -4(%rbp), %eax
leal 1(%rax), %esi
movl %esi, -4(%rbp)
movl %ecx, %esi
movl %eax, %edi
call _Z4pushiii # 先运算,后调用push
函数参数列表中表达式的运算顺序并没有在C/C++中明确定义,每个编译器可以自由发挥,有自己的实现。至少我测试的gcc 9.3.0和vc++ 2013测试的结果是不一致的。
3. 分析为什么入栈顺序是从右往左入栈
很多答案都提到是为了函数可以使用动态参数。
动态参数是如何实现的?
函数是通过栈传入参数的,动态参数需要压入个数和类型都未知的参数到栈中。而我们知道第一个参数一定是最后一个入栈的,在其他参数之上。
- 如何读取动态参数?
参数肯定在栈里,但是要如何读取到栈中的参数呢?
熟悉汇编的可能会考虑通过ESP寄存器,因为ESP指向栈顶位置。但是在函数内,第一个参数一定不是在栈顶位置:
- 在参数是在函数调用前入栈的
- 进入函数后,需1. 之前的栈底EBP入栈;2. 修改栈底值为当前栈顶值,用于函数调用完后的栈平衡
所以第一个参数的地址最接近ESP也只能是ESP-12(ESP指向栈顶,即下一个入栈的位置,栈是自高地址向低地址增长,所以是-12,需要多大内存就减多少)
如果没有在函数最开始就获取第一个参数的地址,在程序运行中ESP还可能会变,所以通过ESP获取一个参数的位置是不可取的,而且不同的编译器实现还不一定都一样。
// 通过ESP读取参数值
void push(int a, int b, int c)
{
int i1;
__asm
{
mov eax,esp
mov eax,[esp+12]
mov i1,eax
}
printf("a = %d\r\n", i1);
printf("a = %d, ptr = 0X%x\r\n", a, &a);
printf("b = %d, ptr = 0X%x\r\n", b, &b);
printf("c = %d, ptr = 0X%x\r\n", c, &c);
}
通过上面的分析,要实现动态参数,我们需要有一个确切地址的参数,用来定位其他参数。
所以在c++的实现中,带有动态参数的函数,第一个参数一定是个很明确的参数。
而且参数从右往左入栈,第一个参数位置就非常明确,且可以快速定位第一个参数的位置(ESP+12)。
结论:
- 参数从右往左入栈可以快速确定第一个参数的位置,非常适合带动态参数的函数
- 从左往右入参可以快速定位最后一个参数的位置;但如果是动态参数,则无法定位最后一个参数的位置(类型不确定,无法明确参数size)
动态参数的缺陷:
- 无法确定参数长度
- 无法确定参数类型
printf是通过格式化来确定输出的参数长度和类型。
4. stdcall、cdecl、fastcall传参入栈方式
在写dll时经常会用stdcall、cdecl声明函数,为什么要这样?其实就是声明函数参数的传递及销毁方式。
他们之间的区别:
- stdcall:参数从右往左入栈,在函数返回前由被调用者清理栈(恢复到调用前的栈状态);C++默认调用方式。
- cdecl:参数从右往左入栈,由调用者清理栈;是C语言的默认函数调用方式。
- fastcall:一种快速调用方式,前两个参数由寄存器ecx、edx来传递,其余参数由栈传递;不同编译器的实现有不同。
5. 类成员函数this指针传递方式
类的成员函数和非成员函数及普通函数在编译后都是一样的,类成员函数特殊的地方在于函数调用时会通过寄存器(ecx)传递this指针。