首先说明,本文的分析对象是运行在IA32平台上的程序,试验用的编译器是Visual C++ 6.0中的cl.exe(Microsoft 32-bit C/C++ Optimizing Compiler Version 12.00.8804 for 80x86)。
IA32程序利用程序堆栈来支持过程(procedure)调用,比如创建局部数据、传递参数、保存返回值信息、保存待今后恢复的寄存器等。为了一个过程调用而分配的堆栈空间称为一个stack frame。最顶层的stack frame由两个寄存器标识:ebp保存stack frame的基址,esp保存栈顶地址,因为在过程执行的时候栈顶寄存器的值会经常变化,所以绝大多数的信息都是通过ebp来相对寻址的。图1描绘了stack frame的基本结构。注意在IA32机器上,栈是向低地址方向增长,所以栈顶的地址值小于等于栈底的地址值。
stack "bottom"
________________ ______
| | |
| . | |
| | |
| . | |
| | |--->Earlier frames
| . | |
| | |
| . | |
| | | |
| |________________|__|___
| | . | |
| | . | |
| | . | |
Decreasing |________________| |
address | Argument n | |
| 4m-->|________________| |
| (m为整数) | . | |-->Caller's frame
| | . | |
| |________________| |
| | Argument 1 | |
| +8-->|________________| |
V | Return address | |
+4-->|________________|__|___
| Saved ebp | |
Frame Pointer-->|________________| |
ebp |Saved registers| |
|local variables| |
| and | |-->Current frame
| temporaries | |
|________________| |
| Argument | |
| build area | |
Stack pointer-->|________________|__|___
esp stack "top"
图1
调用C函数时,不管参数类型如何(包括浮点和struct类型)调用者(caller)负责从右至左将参数依次压栈,最后压入返回地址并跳转到被调函数入口处执行,其中每个参数都要按4字节地址对齐。按照地址来说被传递的参数和返回地址都是位于调用者stack frame中的。如果函数的返回值类型是整型(包括char,short,int,long及它们的无符号型)或指针类型的话,那么就利用EAX寄存器来传回返回值。请看下面的函数:
long foo_long(long offset)
{
long val = 2006 + offset;
return val;
}
用 /c /Od /FAsc 的编译选项(下文均同)编译出如下的汇编码:
PUBLIC _foo_long
_TEXT SEGMENT
_offset$ = 8
_val$ = -4
_foo_long PROC NEAR
; 38 : {
push ebp ;保存调用者的stack frame基址
mov ebp, esp
push ecx
; 39 : long val = 2006 + offset;
mov eax, DWORD PTR _offset$[ebp]
add eax, 2006
mov DWORD PTR _val$[ebp], eax
; 40 : return val;
; 将返回值保存进eax寄存器
mov eax, DWORD PTR _val$[ebp]
; 41 : }
mov esp, ebp
pop ebp
ret 0
_foo_long ENDP
_TEXT ENDS
如果函数返回的是结构体数据(非结构体指针)那得通过哪个中转站传递呢?这就要分三种情况:
1、结构体大小不超过4字节,那么仍然使用EAX寄存器传递返回值。比如:
/* 4字节大小结构体 */
typedef struct small_t
{
char m1;
char m2;
char m3;
char m4;
} small_t;
small_t create_small(void)
{
small_t small = {'s','m','a','l'};
return small;
}
void call_small(void)
{
small_t small_obj = create_small();
}
编译出的汇编码是:
;create_small函数
PUBLIC _create_small
_TEXT SEGMENT
_small$ = -4
_create_small PROC NEAR
; 16 : {
push ebp
mov ebp, esp
push ecx
; 17 : small_t small = {'s','m','a','l'};
mov BYTE PTR _small$[ebp], 115 ; 00000073H
mov BYTE PTR _small$[ebp+1], 109 ; 0000006dH
mov BYTE PTR _small$[ebp+2], 97 ; 00000061H
mov BYTE PTR _small$[ebp+3], 108 ; 0000006cH
; 18 : return small;
;依然用EAX保存返回值
mov eax, DWORD PTR _small$[ebp]
; 19 : }
mov esp, ebp
pop ebp
ret 0
_create_small ENDP
_TEXT ENDS
;call_mall函数
PUBLIC _call_small
_TEXT SEGMENT
_small_obj$ = -4
_call_small PROC NEAR
; 22 : {
push ebp
mov ebp, esp
push ecx
; 23 : small_t small_obj = create_small();
call _create_small
;从eax寄存器中取出返回值填充到small_obj变量中
mov DWORD PTR _small_obj$[ebp], eax
; 24 : }
mov esp, ebp
pop ebp
ret 0
_call_small ENDP
_TEXT ENDS
2、结构体超过4字节但不等于8字节时,调用者将首先在栈上分配一块能容纳结构体的临时内存块,然后在传递完函数参数后将该临时内存块的首地址作为隐含的第一个参数最后(因为压栈顺序是从右到左)压栈,接下的动作同前所述。当被调用函数返回时,它会通过第一个隐含参数寻址到临时内存块并将返回值拷贝到其中,然后将保存有返回值内容的临时内存块的首址存进eax寄存器中,最后退出。请看下例:
typedef struct fool_t
{
short a;
short b;
short c;
} fool_t;
fool_t create_fool(short num)
{
fool_t fool = {num,num,num};
return fool;
}
void call_fool(void)
{
fool_t fool_obj = create_fool(2006);
}
汇编码为:
; create_fool函数
PUBLIC _create_fool
_TEXT SEGMENT
_num$ = 12
_fool$ = -8
; 编译器隐含传递的第一个参数(相对于stack frame基址)的偏移值,
; 该参数等于用于传递结构体返回值的临时内存块的首地址。
; $T480是编译器自动生成的标号。
$T480 = 8
_create_fool PROC NEAR
; 9 : {
push ebp
mov ebp, esp
sub esp, 8
; 10 : fool_t fool = {num,num,num};
mov ax, WORD PTR _num$[ebp]
mov WORD PTR _fool$[ebp], ax
mov cx, WORD PTR _num$[ebp]
mov WORD PTR _fool$[ebp+2], cx
mov dx, WORD PTR _num$[ebp]
mov WORD PTR _fool$[ebp+4], dx
; 11 : return fool;
; 将临时内存块的首地址存入eax
mov eax, DWORD PTR $T480[ebp]
; 将结构体返回值的低4字节通过ecx存入临时内存块的低4字节
mov ecx, DWORD PTR _fool$[ebp]
mov DWORD PTR [eax], ecx
; 将结构体返回值的高2字节通过dx存入临时内存块的高2字节
mov dx, WORD PTR _fool$[ebp+4]
mov WORD PTR [eax+4], dx
; 将临时内存块的首地址存入eax,并准备退出
mov eax, DWORD PTR $T480[ebp]
; 12 : }
mov esp, ebp
pop ebp
ret 0
_create_fool ENDP
_TEXT ENDS
; call_fool函数
PUBLIC _call_fool
_TEXT SEGMENT
_fool_obj$ = -8
; 编译器为接纳结构体返回值而自动在栈上临时分配了一块内存,
; 注意fool_t结构体大小虽为6字节,但需要对齐到4字节边界,
; 所以分配了8字节大小的空间。
$T482 = -16
_call_fool PROC NEAR
; 15 : {
push ebp
mov ebp, esp
sub esp, 16 ; 00000010H
; 16 : fool_t fool_obj = create_fool(2006);
push 2006 ; 000007d6H
; 取得临时内存块的首地址并压栈
lea eax, DWORD PTR $T482[ebp]
push eax
; 函数调用完毕后,临时内存块将被填入结构体返回值
call _create_fool
add esp, 8
; 通过ecx将临时内存块的低4字节数据存进fool_obj的低4字节
mov ecx, DWORD PTR [eax]
mov DWORD PTR _fool_obj$[ebp], ecx
; 通过dx将临时内存块的高2字节数据存进fool_obj的高2字节
mov dx, WORD PTR [eax+4]
mov WORD PTR _fool_obj$[ebp+4], dx
; 17 : }
mov esp, ebp
pop ebp
ret 0
_call_fool ENDP
_TEXT ENDS
3、结构体大小刚好为8个字节时编译器不再于栈上分配内存,而直接同时使用EAX和EDX两个寄存器传递返回值,其中EAX保存低4字节数据,EDX保存高4字节数据。请看下面2个函数:
/* 如果编译器的最大对齐模数是8,则该结构体大小为8字节 */
typedef struct big_t
{
char m1;
long m2;
} big_t;
big_t create_big(char c)
{
big_t big = {c, 2006};
return big;
}
void call_big(void)
{
big_t big_obj = create_big('A');
}
编译出的汇编码是:
; create_big函数
PUBLIC _create_big
_TEXT SEGMENT
_c$ = 8
_big$ = -8
_create_big PROC NEAR
; 27 : {
push ebp
mov ebp, esp
sub esp, 8
; 28 : big_t big = {c, 2006};
mov al, BYTE PTR _c$[ebp]
mov BYTE PTR _big$[ebp], al
mov DWORD PTR _big$[ebp+4], 2006 ; 000007d6H
; 29 : return big;
; 通过eax和edx返回 big
mov eax, DWORD PTR _big$[ebp]
mov edx, DWORD PTR _big$[ebp+4]
; 30 : }
mov esp, ebp
pop ebp
ret 0
_create_big ENDP
_TEXT ENDS
;call_big函数
PUBLIC _call_big
_TEXT SEGMENT
_big_obj$ = -8
_call_big PROC NEAR
; 33 : {
push ebp
mov ebp, esp
sub esp, 8
; 34 : big_t big_obj = create_big('A');
push 65 ; 00000041H
call _create_big
add esp, 4
; 通过eax和edx取得返回值
mov DWORD PTR _big_obj$[ebp], eax
mov DWORD PTR _big_obj$[ebp+4], edx
; 35 : }
mov esp, ebp
pop ebp
ret 0
_call_big ENDP
_TEXT ENDS
因为结构体大小与编译时的最大对齐模数选项有关(具体关系请参见《内存对齐与结构体的内存布局》),所以当最大对齐模数改变时返回动作将可能改变。对于本例,在编译时加上/Zp2选项,则big_t结构体类型大小为6字节,create_big函数也将相应地利用临时内存块而非edx寄存器来返回数据。
至于返回值为浮点类型则与整型和结构体型有相当的不同,因为IA32架构CPU有一套特殊的基于浮点寄存器栈操作的浮点指令集。浮点寄存器栈包括8个浮点寄存器,编号从0到7,其中0号为栈顶,7号为栈底,指令可通过编号访问相应寄存器,所有浮点运算的结果均保存在栈顶。取得浮点返回值很简单,只需直接弹出栈顶元素并拷贝到相应的变量中就可以了。我们通过下面这段小程序来验证一下。
double foo(double a)
{
return a + 10;
}
void goo()
{
double b;
b = foo(10.0);
}
; foo函数
PUBLIC _foo
PUBLIC __real@8@4002a000000000000000
EXTRN __fltused:NEAR
; COMDAT __real@8@4002a000000000000000
; File convention.c
CONST SEGMENT
__real@8@4002a000000000000000 DQ 04024000000000000r ; 10
CONST ENDS
_TEXT SEGMENT
_a$ = 8
_foo PROC NEAR
; 19 : {
push ebp
mov ebp, esp
; 20 : return a + 10;
; 从变量a中读入一个double型浮点数并压入浮点栈
fld QWORD PTR _a$[ebp]
; 将浮点栈的栈顶元素与10相加,将结果压入浮点栈
fadd QWORD PTR __real@8@4002a000000000000000
; 21 : }
pop ebp
ret 0
_foo ENDP
_TEXT ENDS
; goo函数
PUBLIC _goo
_TEXT SEGMENT
_b$ = -8
_goo PROC NEAR
; 24 : {
push ebp
mov ebp, esp
sub esp, 8
; 25 : double b;
; 26 : b = foo(10.0);
push 1076101120 ; 40240000H
push 0
call _foo
add esp, 8
; 将浮点栈顶元素弹出并存入变量b,此即保存foo函数返回值的动作
fstp QWORD PTR _b$[ebp]
; 27 : }
mov esp, ebp
pop ebp
ret 0
_goo ENDP
_TEXT ENDS
参考资料:
[1] 《Computer Systems: A Programmer's Perspective》.
Randal E. Bryant, David R. O'Hallaron.
电子工业出版社