从汇编角度查看C语言函数调用过程

为了防止出现不必要的代码影响汇编语言的查看,所以程序中不使用任何库函数,以保持汇编代码的简洁。

这里所使用的汇编是VC的MASM。

默认函数调用方式__cdecl

1
2
3
4
5
6
7
8
int add(int a, int b) {
return a + b;
}

int main() {
int a = 1, b = 2;
return add(a,b);
}

对应汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
; Listing generated by Microsoft (R) Optimizing Compiler Version 19.10.25019.0

; 伪指令,如指定处理器指令,存储模型等
TITLE D:\C&C++\CppLearn\CLang\FunctionStack.c
.686P
.XMM
include listing.inc
.model flat

; 导入必要的静态库,如C语言运行时
INCLUDELIB MSVCRTD
INCLUDELIB OLDNAMES

; 所有函数过程的声明
PUBLIC _add
PUBLIC _main
EXTRN __RTC_CheckEsp:PROC
EXTRN __RTC_InitBase:PROC
EXTRN __RTC_Shutdown:PROC
; COMDAT rtc$TMZ
rtc$TMZ SEGMENT
__RTC_Shutdown.rtc$TMZ DD FLAT:__RTC_Shutdown
rtc$TMZ ENDS
; COMDAT rtc$IMZ
rtc$IMZ SEGMENT
__RTC_InitBase.rtc$IMZ DD FLAT:__RTC_InitBase
rtc$IMZ ENDS

_TEXT SEGMENT
; 局部变量的偏移地址
_b$ = -20 ; size = 4
_a$ = -8 ; size = 4
; main函数过程定义
_main PROC ; COMDAT

; 函数调用前必要的初始化,如寄存器入栈以保存CPU运行现场
push ebp ; 保存原来的基栈指针
mov ebp, esp ; 将原来的栈顶指针作为新的基栈指针
sub esp, 216 ; 000000d8H,为堆栈分配内存
push ebx
push esi
push edi
; 对堆栈进行初始化
lea edi, DWORD PTR [ebp-216]; 获取堆栈地址存入edi寄存器中
mov ecx, 54 ; 00000036H
mov eax, -858993460 ; ccccccccH
rep stosd ; rep指令表示重复执行后面的指令,重复次数为ecx中的值
; stosd表示使用eax中的值对es:[edi]指向的地址进行初始化,单位为dword
; 所以36H * 4 = d8H

; int a = 1, b = 2; 变量赋值
mov DWORD PTR _a$[ebp], 1
mov DWORD PTR _b$[ebp], 2

; return add(a,b);
; 可以看出函数调用的入栈顺序:从右到左
mov eax, DWORD PTR _b$[ebp]
push eax ;函数参数入栈
mov ecx, DWORD PTR _a$[ebp]
push ecx ;函数参数入栈
call _add ;调用add函数
add esp, 8 ;直接还原栈顶指针,不需要出栈数据

; 函数执行完成后,寄存器出栈,恢复执行现场
pop edi
pop esi
pop ebx
add esp, 216 ; 000000d8H,回收堆栈内存
cmp ebp, esp ; 比较基栈指针与栈顶指针是否相等
call __RTC_CheckEsp ; 堆栈检测,防止栈溢出
mov esp, ebp ; 恢复栈顶指针
pop ebp ; 恢复基栈指针
ret 0
_main ENDP
_TEXT ENDS

_TEXT SEGMENT
; 局部变量的偏移地址
_a$ = 8 ; size = 4
_b$ = 12 ; size = 4
; add函数过程定义
_add PROC ; COMDAT

; 函数调用前必要的初始化,如寄存器入栈以保存CPU运行现场
push ebp ; 保存原来的基栈指针
mov ebp, esp ; 将原来的栈顶指针作为新的基栈指针
sub esp, 192 ; 000000c0H,为堆栈分配内存
push ebx
push esi
push edi
; 对堆栈进行初始化
lea edi, DWORD PTR [ebp-192]
mov ecx, 48 ; 00000030H
mov eax, -858993460 ; ccccccccH
rep stosd ; rep指令表示重复执行后面的指令,重复次数为ecx中的值
; stosd表示使用eax中的值对es:[edi]指向的地址进行初始化,单位为dword
; 所以30H * 4 = c0H

; return a + b;
mov eax, DWORD PTR _a$[ebp]
add eax, DWORD PTR _b$[ebp] ; eax作为返回值

; 函数执行完成后,寄存器出栈,恢复执行现场
pop edi
pop esi
pop ebx
mov esp, ebp ; 恢复栈顶指针
pop ebp ; 恢复基栈指针
ret 0 ; 函数返回
_add ENDP
_TEXT ENDS
END
  • VC默认的函数调用方式就是__cdecl

  • 微软的__cdecl 与GNU的__attribute__ ((cdecl))

    __cdecl想必大家都不陌生了,gcc出于兼容,可以使用__attribute__ ((cdecl))达到同样的效果。

    该种规约不使用任何寄存器传递参数,参数全部在栈上(从右到左依次入栈),其规则完全符合ABI规定。

__stdcall函数调用方式

1
2
3
4
5
6
7
8
int __stdcall add(int a, int b) {
return a + b;
}

int main() {
int a = 1, b = 2;
return add(a,b);
}

对应汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
; 省略前面的若干伪指令
...

; COMDAT _main
_TEXT SEGMENT
_b$ = -20 ; size = 4
_a$ = -8 ; size = 4
_main PROC ; COMDAT

; 函数调用前必要的初始化,如寄存器入栈以保存CPU运行现场
push ebp
mov ebp, esp
sub esp, 216 ; 000000d8H
push ebx
push esi
push edi
lea edi, DWORD PTR [ebp-216]
mov ecx, 54 ; 00000036H
mov eax, -858993460 ; ccccccccH
rep stosd

; int a = 1, b = 2;
mov DWORD PTR _a$[ebp], 1
mov DWORD PTR _b$[ebp], 2

; return add(a,b);
; 可以看出函数调用的入栈顺序:从右到左
mov eax, DWORD PTR _b$[ebp]
push eax
mov ecx, DWORD PTR _a$[ebp]
push ecx
call _add@8
; 不用自己还原堆栈,在函数执行ret指令时还原堆栈状态

pop edi
pop esi
pop ebx
add esp, 216 ; 000000d8H
cmp ebp, esp
call __RTC_CheckEsp
mov esp, ebp
pop ebp
ret 0
_main ENDP
_TEXT ENDS

; COMDAT _add@8
_TEXT SEGMENT
_a$ = 8 ; size = 4
_b$ = 12 ; size = 4
_add@8 PROC ; COMDAT

; 函数调用前必要的初始化,如寄存器入栈以保存CPU运行现场
push ebp
mov ebp, esp
sub esp, 192 ; 000000c0H
push ebx
push esi
push edi
lea edi, DWORD PTR [ebp-192]
mov ecx, 48 ; 00000030H
mov eax, -858993460 ; ccccccccH
rep stosd

; return a + b;
mov eax, DWORD PTR _a$[ebp]
add eax, DWORD PTR _b$[ebp]

pop edi
pop esi
pop ebx
mov esp, ebp
pop ebp
ret 8 ; 返回时,清除堆栈
_add@8 ENDP
_TEXT ENDS
END
  • 微软的__stdcall 与GNU的__attribute__ ((stdcall))

    __attribute__ ((cdecl))__attribute__ ((stdcall))也是用来兼容微软的。

    在寄存器保护问题上同__cdecl,不使用任何寄存器传递参数且符合ABI。

__fastcall函数调用方式

1
2
3
4
5
6
7
8
int __fastcall add(int a, int b) {
return a + b;
}

int main() {
int a = 1, b = 2;
return add(a,b);
}

对应汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
; 省略前面的若干伪指令
...

; COMDAT _main
_TEXT SEGMENT
_b$ = -20 ; size = 4
_a$ = -8 ; size = 4
_main PROC ; COMDAT

push ebp
mov ebp, esp
sub esp, 216 ; 000000d8H
push ebx
push esi
push edi
lea edi, DWORD PTR [ebp-216]
mov ecx, 54 ; 00000036H
mov eax, -858993460 ; ccccccccH
rep stosd

; int a = 1, b = 2;
mov DWORD PTR _a$[ebp], 1
mov DWORD PTR _b$[ebp], 2

; return add(a,b);
; 使用特定寄存器的方式传递参数,edx和ecx用来传递前两个参数
; 如果参数超过两个,其余参数就使用堆栈传递
; 因为堆栈在内存中分配,计算机访问内存速度远慢于访问寄存器的速度
; 所以使用寄存器传递参数比堆栈传递参数要快
mov edx, DWORD PTR _b$[ebp]
mov ecx, DWORD PTR _a$[ebp]
call @add@8
; 因为没有使用入栈的方式传递参数,所以不需要清除堆栈

pop edi
pop esi
pop ebx
add esp, 216 ; 000000d8H
cmp ebp, esp
call __RTC_CheckEsp
mov esp, ebp
pop ebp
ret 0
_main ENDP
_TEXT ENDS

; COMDAT @add@8
_TEXT SEGMENT
_b$ = -20 ; size = 4
_a$ = -8 ; size = 4
@add@8 PROC ; COMDAT

; _a$ = ecx
; _b$ = edx
;
push ebp
mov ebp, esp
sub esp, 216 ; 000000d8H
push ebx
push esi
push edi
push ecx
lea edi, DWORD PTR [ebp-216]
mov ecx, 54 ; 00000036H
mov eax, -858993460 ; ccccccccH
rep stosd
pop ecx
mov DWORD PTR _b$[ebp], edx
mov DWORD PTR _a$[ebp], ecx

; return a + b;
mov eax, DWORD PTR _a$[ebp]
add eax, DWORD PTR _b$[ebp]

pop edi
pop esi
pop ebx
mov esp, ebp
pop ebp
ret 0
@add@8 ENDP
_TEXT ENDS
END
  • 微软的__fastcall 与GNU的__attribute__ ((fastcall))

    同前,__attribute__ ((fastcall))用来兼容微软。

    该方法使用ECX和EDX传递前两个参数(从左到右),其余参数在栈上(从右到左依次入栈)。

    根据ABI,调用者不需要保护ECX和EDX,因此该规约并不违反ABI。

其他参数传递方式

  • thiscall 调用规约(仅C++)

    必须注意,微软的thiscall和g++的thiscall在二进制上是不兼容的。

    微软的thiscall采用ECX作为this指针,其余参数全部在栈上(从右到左依次入栈)。

    而g++的thiscall就是__cdecl,它把this指针当作第一个参数,连同其他参数一起被放在栈上(从右到左依次入栈)。

    显然这两种方法都不违背ABI。

  • 微软__declspec(naked) 与GNU的__attribute__ ((naked))

    __attribute__ ((naked))是g++对微软的兼容。

    对于这种类型的函数,对于调用者(C/C++程序),编译器保证其生成代码是符合ABI规定的。

    但是被调用函数也就是naked函数本身,编写者必须自己实现对ABI的兼容。

  • GNU的__attribute__ ((regparm(n)))

    在i386下这里的n取值只能是0、1、2、3。它表示该函数使用几个寄存器来传递参数。

    当n=0时,所有参数都在栈上(从右到左依次入栈)。也就是__cdecl

    当n=1时,第1个参数在EAX,其余参数在栈上(从右到左依次入栈)。

    当n=2时,第1个参数在EAX,第2个参数在EDX,其余参数在栈上(从右到左依次入栈)。

    当n=3时,第1个参数在EAX,第2个参数在EDX,第3个参数在ECX,其余参数在栈上(从右到左依次入栈)。

    由于EAX、EDX、ECX都是不需要被调用者保护的寄存器,所以这里也不违背ABI规定。

  • Linux内核(i386)的asmlinkagefastcall

    asmlinkage被定义为__attribute__ ((regparm(0)))

    fastcall被定义为__attribute__ ((regparm(3)))

Windows API按照平台不同,选择不同的函数调用方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 以下是minwindef.h头文件中的部分代码
#if (!defined(_MAC)) && ((_MSC_VER >= 800) || defined(_STDCALL_SUPPORTED))
#define pascal __stdcall
#else
#define pascal
#endif

#if defined(DOSWIN32) || defined(_MAC)
#define cdecl _cdecl
#ifndef CDECL
#define CDECL _cdecl
#endif
#else
#define cdecl
#ifndef CDECL
#define CDECL
#endif
#endif

#ifdef _MAC
#define CALLBACK PASCAL
#define WINAPI CDECL
#define WINAPIV CDECL
#define APIENTRY WINAPI
#define APIPRIVATE CDECL
#ifdef _68K_
#define PASCAL __pascal
#else
#define PASCAL
#endif
#elif (_MSC_VER >= 800) || defined(_STDCALL_SUPPORTED)
#define CALLBACK __stdcall
#define WINAPI __stdcall
#define WINAPIV __cdecl
#define APIENTRY WINAPI
#define APIPRIVATE __stdcall
#define PASCAL __stdcall
#else
#define CALLBACK
#define WINAPI
#define WINAPIV
#define APIENTRY WINAPI
#define APIPRIVATE
#define PASCAL pascal
#endif

__pascal 是 Pascal 语言(Delphi)的函数调用方式,也可以在 C/C++ 中使用,参数压栈顺序从左往右。返回时的清栈方式与 __stdcall 相同。

参考链接:

  • 维基百科-X86调用约定:https://en.wikipedia.org/wiki/X86_calling_conventions
  • __stdcall__cdecl__pascal__fastcall的区别:http://c.biancheng.net/cpp/html/2847.html
  • i386 ABI之寄存器保护规则:http://blog.csdn.net/axx1611/article/details/5138618
  • StackOverflow-Windows头文件中MAC宏是什么:https://stackoverflow.com/questions/2376478/whats-with-ifdef-mac-in-windows-header-files

附录

1.VC版本清单

_MSC_VER _MSC_FULL_VER VC版本 备注
600 —- —- C/C++Compiler 6.0
700 —- —- C/C++Compiler 7.0
800 —- VC1.0 —-
900 —- VC2.0 —-
1000 —- VC4.0 —-
1010 —- VC4.1 —-
1020 —- VC4.2 —-
1100 —- VC5.0 Visual Studio 97
1200 12008804 VC6.0 Visual Studio 6.0
1300 13009466 VC7.0 Visual Studio.net 2002
1310 13102292 VC7.1 Visual Studio.net 2003
1400 140050320 VC8.0 Visual Studio 2005
1400 140050727 VC8.0 Visual Studio 2005 SP1
1500 150021022 VC9.0 Visual Studio 2008
1500 150030729 VC9.0 Visual Studio 2008 Update1
1600 160030319 VC10.0 Visual Studio 2010
1600 160040219 VC10.0 Visual Studio 2010 Update1
1700 170050727 VC11.0 Visual Studio 2012
1700 170051106 VC11.0 Visual Studio 2012 Update1
1700 170060315 VC11.0 Visual Studio 2012 Update2
1700 170060610 VC11.0 Visual Studio 2012 Update3
1700 170061030 VC11.0 Visual Studio 2012 Update4
1800 180021005 VC12.0 Visual Studio 2013 RTM / Update1
1800 180030501 VC12.0 Visual Studio 2013 Update2
1800 180030723 VC12.0 Visual Studio 2013 Update3
1800 180031101 VC12.0 Visual Studio 2013 Update4
1800 180040629 VC12.0 Visual Studio 2013 Update5
1900 190023026 VC14.0 Visual Studio 2015
1900 190023506 VC14.0 Visual Studio 2015 Update1
1900 190023918 VC14.0 Visual Studio 2015 Update2
1900 190024210 VC14.0 Visual Studio 2015 Update3