调用方(caller)函数通过栈向被调用方(callee)函数传递参数。本章介绍被调用方函数获取参数的具体方式。
指令清单8.1 范例
#include <stdio.h>
int f (int a, int b, int c)
{
return a*b+c;
};
int main()
{
printf ("%d\n", f(1, 2, 3));
return 0;
};
使用MSVC 2010 Express编译上述程序,可得到汇编指令如下。
指令清单8.2 MSVC 2010 Express
_TEXT SEGMENT
_a$ = 8 ; size = 4
_b$ = 12 ; size = 4
_c$ = 16 ; size = 4
_f PROC
push ebp
mov ebp, esp
mov eax, DWORD PTR _a$[ebp]
imul eax, DWORD PTR _b$[ebp]
add eax, DWORD PTR _c$[ebp]
pop ebp
ret 0
_f ENDP
_main PROC
push ebp
mov ebp, esp
push 3 ; 3rd argument
push 2 ; 2nd argument
push 1 ; 1st argument
call _f
add esp, 12
push eax
push OFFSET $SG2463 ; '%d', 0aH, 00H
call _printf
add esp, 8
; return 0
xor eax, eax
pop ebp
ret 0
_main ENDP
main()函数把3个数字推送入栈,然后调用了f(int, int, int)。被调用方函数f()通过_a$=8一类的汇编宏访问所需参数以及函数自定义的局部变量。只不过从被调用方函数的数据栈的角度来看,外部参考的偏移量是正值,而局部变量的偏移量是负值。可见,当需要访问栈帧(stack frame)以外的数据时,被调用方函数可把汇编宏(例如_a$)与EBP寄存器的值相加,从而求得所需地址。
当变量a的值存入EAX寄存器之后,f()函数通过各参数的地址依次进行乘法和加法运算,运算结果一直存储于EAX寄存器。此后EAX的值就可以直接作为返回值传递给调用方函数。调用方函数main()再把EAX的值当作参数传递给printf()函数。
本节演示OllyDbg的使用方法。当f()函数读取第一个参数时,EBP的值指向栈帧,如图8.1中的红色方块所示。栈帧里的第一个值是EBP的原始状态,第二个值是返回地址RA,第三个值开始的三个值依次为函数的第一个参数、第二个参数和第三个参数。在访问第一个参数(当需要访问第一个参数)时,计算机需要把EBP的值加上8(2个32位words)。
图8.1 使用OllyDbg观察f()函数
OllyDbg能够识别出外部传递的参数。它会对栈里的数据进行标注,添加上诸如“RETURN from”“Arg1”之类的标注信息。
故而,被调用方函数所需的参数并不在自己的栈帧之中,而是在调用方函数的栈帧里。所以,被OllyDbg标注为Arg的数据都存储于其他函数的栈帧。
我们使用GCC 4.4.1编译上述源程序,然后使用IDA查看它的汇编指令。
指令清单8.3 GCC 4.4.1
public f
f proc near
arg_0 = dword ptr 8
arg_4 = dword ptr 0Ch
arg_8 = dword ptr 10h
push ebp
mov ebp, esp
mov eax, [ebp+arg_0] ; 1st argument
imul eax, [ebp+arg_4] ; 2nd argument
add eax, [ebp+arg_8] ; 3rd argument
pop ebp
retn
f endp
public main
main proc near
var_10 = dword ptr -10h
var_C = dword ptr -0Ch
var_8 = dword ptr -8
push ebp
mov ebp, esp
and esp, 0FFFFFFF0h
sub esp, 10h
mov [esp+10h+var_8], 3 ; 3rd argument
mov [esp+10h+var_C], 2 ; 2nd argument
mov [esp+10h+var_10], 1 ; 1st argument
call f
mov edx, offset aD ; "%d\n"
mov [esp+10h+var_C], eax
mov [esp+10h+var_10], edx
call _printf
mov eax, 0
leave
retn
main endp
GCC的编译结果和MSVC的编译结果十分相似。
不同之处是两个被调用方函数(f和printf)没有还原栈指针SP。这是因为函数尾声的倒数第二条指令——LEAVE指令(参见附录A.6.2)能够还原栈指针。
x86-64系统的参数传递过程略有不同。x86-64系统能够使用寄存器传递(前4个或前6个)参数。就这个程序而言,被调用方函数会从寄存器里获取参数,完全不需要访问栈。
启用优化选项后,MSVC编译的结果如下。
指令清单8.4 Optimizing MSVC 2012 x64
$SG2997 DB '%d', 0aH, 00H
main PROC
sub rsp, 40
mov edx, 2
lea r8d, QWORD PTR [rdx+1] ; R8D=3
lea ecx, QWORD PTR [rdx-1] ; ECX=1
call f
lea rcx, OFFSET FLAT:$SG2997 ; '%d'
mov edx, eax
call printf
xor eax, eax
add rsp, 40
ret 0
main ENDP
f PROC
; ECX - 1st argument
; EDX - 2nd argument
; R8D - 3rd argument
imul ecx, edx
lea eax, DWORD PTR [r8+rcx]
ret 0
f ENDP
我们可以看到,f()函数通过寄存器获取了全部的所需参数。此处求址的加法运算是通过LEA指令实现的。很明显,编译器认为LEA指令的效率比ADD指令的效率高,所以它分配了LEA指令。在制备f()函数的第一个和第三个参数时,main()函数同样使用了LEA指令。编译器无疑认为LEA指令向寄存器赋值的速度比常规的MOV指令速度快。
我们再来看看MSVC未开启优化选项时的编译结果。
指令清单8.5 MSVC 2012 x64
f proc near
; shadow space:
arg_0 = dword ptr 8
arg_8 = dword ptr 10h
arg_10 = dword ptr 18h
; ECX - 1st argument
; EDX - 2nd argument
; R8D - 3rd argument
mov [rsp+arg_10], r8d
mov [rsp+arg_8], edx
mov [rsp+arg_0], ecx
mov eax, [rsp+arg_0]
imul eax, [rsp+arg_8]
add eax, [rsp+arg_10]
retn
f endp
main proc near
sub rsp, 28h
mov r8d,3 ; 3rd argument
mov edx,2 ; 2nd argument
mov ecx,1 ; 1st argument
call f
mov edx, eax
lea rcx, $SG2931 ; "%d\n"
call printf
; return 0
xor eax, eax
add rsp, 28h
retn
main endp
比较意外的是,原本位于寄存器的3个参数都被推送到了栈里。这种现象叫作“阴影空间/shadow space”[1]。每个Win64程序都可以(但非必须)把4个寄存器的值保存到阴影空间里。使用阴影空间有以下两个优点:1)通过栈传递参数,可避免浪费寄存器资源(有时可能会占用4个寄存器);2)便于调试器debugger在程序中断时找到函数参数(请参阅:https://msdn.microsoft.com/en-us/library/ew5tede7%28v=VS.90%29.aspx
)。
大型函数可能会把输入参数保存在阴影空间里,但是小型函数(如本例)可能就不会使用阴影空间了。
在使用阴影空间时,由调用方函数分配栈空间,由被调用方函数根据需要将寄存器参数转储到它们的阴影空间中。
在启用优化选项后,GCC生成的代码更为晦涩。
指令清单8.6 Optimizing GCC 4.4.6 x64
f:
; EDI - 1st argument
; ESI - 2nd argument
; EDX - 3rd argument
imul esi, edi
lea eax, [rdx+rsi]
ret
main:
sub rsp, 8
mov edx, 3
mov esi, 2
mov edi, 1
call f
mov edi, OFFSET FLAT:.LC0 ; "%d\n"
mov esi, eax
xor eax, eax ; number of vector registers passed
call printf
xor eax, eax
add rsp, 8
ret
不开启优化选项的情况下,GCC生成的代码如下。
指令清单8.7 GCC 4.4.6 x64
f:
;EDI - 1st argument
; ESI - 2nd argument
; EDX - 3rd argument
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov DWORD PTR [rbp-8], esi
mov DWORD PTR [rbp-12], edx
mov eax, DWORD PTR [rbp-4]
imul eax, DWORD PTR [rbp-8]
add eax, DWORD PTR [rbp-12]
leave
ret
main:
push rbp
mov rbp, rsp
mov edx, 3
mov esi, 2
mov edi, 1
call f
mov edx, eax
mov eax, OFFSET FLAT:.LC0 ; "%d\n"
mov esi, edx
mov rdi, rax
mov eax, 0 ; number of vector registers passed
call printf
mov eax, 0
leave
ret
阴影空间只是微软的概念,System V *NIX[参见参考文献Mit13]里没有这种规范或约定。GCC只有在寄存器数量容纳不下所有参数的情况下,才会使用栈传递参数。
指令清单8.1的源程序采用的是32位int整型参数,所以以上的汇编指令使用的寄存器都是64位寄存器的低32位(即E-字头寄存器)。如果把参数改为64位数据,那么汇编指令则会略有不同:
#include <stdio.h>
#include <stdint.h>
uint64_t f (uint64_t a, uint64_t b, uint64_t c)
{
return a*b+c;
};
int main()
{
printf ("%lld\n", f(0x1122334455667788,
0x1111111122222222,
0x3333333344444444));
return 0;
};
指令清单8.8 Optimizing GCC 4.4.6 x64
f proc near
imul rsi, rdi
lea rax, [rdx+rsi]
retn
f endp
main proc near
sub rsp, 8
mov rdx, 3333333344444444h ; 3rd argument
mov rsi, 1111111122222222h ; 2nd argument
mov rdi, 1122334455667788h ; 1st argument
call f
mov edi, offset format ; "%lld\n"
mov rsi, rax
xor eax, eax ; number of vector registers passed
call _printf
xor eax, eax
add rsp, 8
retn
main endp
相应的汇编指令使用了整个寄存器(R-字头寄存器)。其他部分基本相同。
.text:000000A4 00 30 A0 E1 MOV R3, R0
.text:000000A8 93 21 20 E0 MLA R0, R3, R1, R2
.text:000000AC 1E FF 2F E1 BX LR
...
.text:000000B0 main
.text:000000B0 10 40 2D E9 STMFD SP!, {R4,LR}
.text:000000B4 03 20 A0 E3 MOV R2, #3
.text:000000B8 02 10 A0 E3 MOV R1, #2
.text:000000BC 01 00 A0 E3 MOV R0, #1
.text:000000C0 F7 FF FF EB BL f
.text:000000C4 00 40 A0 E1 MOV R4, R0
.text:000000C8 04 10 A0 E1 MOV R1, R4
.text:000000CC 5A 0F 8F E2 ADR R0, aD_0 ; "%d\n"
.text:000000D0 E3 18 00 EB BL __2printf
.text:000000D4 00 00 A0 E3 MOV R0, #0
.text:000000D8 00 00 A0 E3 LDMFD SP!, {R4,PC}
主函数只起到了调用另外2个函数的作用。它把3个参数传递给了f()函数。
前文提到过,在ARM系统里,前4个寄存器(R0~R3)负责传递前4个参数。
在本例中,f()函数通过前3个寄存器(R0~R2)读取参数。
MLA(Multiply Accumulate)指令将前两个操作数(R3和R1里的值)相乘,然后再计算第三个操作数(R2里的值)和这个积的和,并且把最终运算结果存储在零号寄存器R0之中。根据ARM指令的有关规范,返回值就应该存放在R0寄存器里。
MLA是乘法累加指令[2],能够一次计算乘法和加法运算,属于非常有用的指令。在SIMD技术[3]的FMA指令问世之前,x86平台的指令集里并没有类似的指令。
首条指令“MOV R3, R0”属于冗余指令。即使此处没有这条指令,后面的MLA指令直接使用有关的寄存器也不会出现任何问题。因为我们没有启用优化选项,所以编译器没能对此进行优化。
BL指令把程序的控制流交给LR寄存器里的地址,而且会在必要的时候切换处理器的运行模式(Thumb模式和ARM模式之间进行模式切换)。被调用方函数f()并不知道它会被什么模式的代码调用,不知道调用方函数属于ARM模式的代码还是Thumb模式的代码。所以这种模式切换的功能还是必要的。如果它被Thumb模式的代码调用,BX指令不仅会进行相应的跳转,还会把处理器模式调整为Thumb。如果它被ARM模式的指令[4]调用,则不会进行模式切换。
.text:00000098 f
.text:00000098 91 20 20 E0 MLA R0, R1, R0, R2
.text:0000009C 1E FF 2F E1 BX LR
在启用最大幅度的优化功能(-O3)之后,前面那条MOV指令被优化了,或者说被删除了。MLA直接使用所有寄存器的参数,并且把返回值保存在R0寄存器里。调用方函数继而可从R0寄存器获取返回值。
.text:0000005E 48 43 MULS R0, R1
.text:00000060 80 18 ADDS R0, R1, R2
.text:00000062 70 47 BX LR
因为Thumb模式的指令集里没有MLA指令,所以编译器将它分为两个指令。第一条MULS指令计算R0和R1的积,把运算结果存储在R0寄存器里。第二条ADDS计算R1和R2的和,并且把计算结果存储在R0寄存器里。
Optimizing GCC (Linaro) 4.9
ARM64的情况简单一些。MADD指令可以一次进行乘法和加法的混合运算,与前文的MLA指令十分类似。全部3个参数由X-字头寄存器的低32位传递。这是因为这些参数都是32位整型数据。函数的返回值存储在W0寄存器。
指令清单8.9 Optimizing GCC (Linaro) 4.9
f:
madd w0, w0, w1, w2
ret
main:
; save FP and LR to stack frame:
stp x29, x30, [sp, -16]!
mov w2, 3
mov w1, 2
add x29, sp, 0
mov w0, 1
bl f
mov w1, w0
adrp x0, .LC7
add x0, x0, :lo12:.LC7
bl printf
; return 0
mov w0, 0
; restore FP and LR
ldp x29, x30, [sp], 16
ret
.LC7:
.string "%d\n"
我们再来看看参数为64位 uint64_t的情况:
#include <stdio.h>
#include <stdint.h>
uint64_t f (uint64_t a, uint64_t b, uint64_t c)
{
return a*b+c;
};
int main()
{
printf ("%lld\n", f(0x1122334455667788,
0x1111111122222222,
0x3333333344444444));
return 0;
};
f:
madd x0, x0, x1, x2
ret
main:
mov x1, 13396
adrp x0, .LC8
stp x29, x30, [sp, -16]!
movk x1, 0x27d0, lsl 16
add x0, x0, :lo12:.LC8
movk x1, 0x122, lsl 32
add x29, sp, 0
movk x1, 0x58be, lsl 48
bl printf
mov w0, 0
ldp x29, x30, [sp], 16
ret
.LC8:
.string "%lld\n"
这两段代码的f()函数部分相同。在采用64位参数之后,程序使用了整个64位 X寄存器。程序通过两条指令才能把长数据类型的64位值存储到寄存器里。本书在28.3.1节会详细介绍64位数据的有关操作。
Non-optimizing GCC (Linaro) 4.9
在没有启用优化选项的情况下,编译器生成的代码稍显冗长:
f:
sub sp, sp, #16
str w0, [sp,12]
str w1, [sp,8]
str w2, [sp,4]
ldr w1, [sp,12]
ldr w0, [sp,8]
mul w1, w1, w0
ldr w0, [sp,4]
add w0, w1, w0
add sp, sp, 16
ret
函数f()把传入的参数保存在数据栈里,以防止后期的指令占用W0~W2寄存器。这可防止后续指令覆盖函数参数,起到保护传入参数的作用。这种技术叫作“寄存器保护区/Register Save Area”[参见ARM13c]。但是,本例的这种被调用方函数可以不这样保存参数。寄存器保护区与8.2.1节介绍的阴影空间十分相似。
在启用优化选项后,GCC 4.9会把这部分寄存器存储指令删除。这是因为优化功能判断出后续指令不会再操作函数参数的相关地址,所以编译器不再另行保存W0~W2中存储的数据。
此外,上述代码使用了MUL/ADD指令对,而没有使用MADD指令。
指令清单8.10 Optimizing GCC 4.4.5
.text:00000000 f:
; $a0=a
; $a1=b
; $a2=c
.text:00000000 mult $a1, $a0
.text:00000004 mflo $v0
.text:00000008 jr $ra
.text:0000000C addu $v0, $a2, $v0 ; branch delay slot
; result in $v0 upon return
.text:00000010 main:
.text:00000010
.text:00000010 var_10 = -0x10
.text:00000010 var_4 =-4
.text:00000010
.text:00000010 lui $gp, (__gnu_local_gp >> 16)
.text:00000014 addiu $sp, -0x20
.text:00000018 la $gp, (__gnu_local_gp & 0xFFFF)
.text:0000001C sw $ra, 0x20+var_4($sp)
.text:00000020 sw $gp, 0x20+var_10($sp)
; set c:
.text:00000024 li $a2, 3
; set a:
.text:00000028 li $a0, 1
.text:0000002C jal f
; set b:
.text:00000030 li $a1, 2 ; branch delay slot
; result in $v0 now
.text:00000034 lw $gp, 0x20+var_10($sp)
.text:00000038 lui $a0, ($LC0 >> 16)
.text:0000003C lw $t9, (printf & 0xFFFF)($gp)
.text:00000040 la $a0, ($LC0 & 0xFFFF)
.text:00000044 jalr $t9
; take result of f() function and pass it as a second argument to printf():
.text:00000048 move $a1, $v0 ; branch delay slot
.text:0000004C lw $ra, 0x20+var_4($sp)
.text:00000050 move $v0, $zero
.text:00000054 jr $ra
.text:00000058 addiu $sp, 0x20 ; branch delay slot
函数所需的前4个参数由4个A-字头寄存器传递。
MIPS平台有两个特殊的寄存器:HI和LO。它们用来存储MULT指令的乘法计算结果——64位的积。只有MFLO和MFHI指令能够访问HI和LO寄存器。其中,MFLO负责访问积的低32位部分。本例中它把积的低32位部分存储到$V0寄存器。
因为本例没有访问积的高32位,所以那半部分被丢弃了。不过我们的程序就是这样设计的:积是32位的整型数据。
最终ADDU(Add Unsigned)指令计算第三个参数与积的和。
在MIPS平台上,ADD和ADDU是两个不同的指令。此二者的区别体现在异常处理的方式上,而符号位的处理方式反而没有区别。ADD指令可以触发溢出处理机制。溢出有时候是必要的[5],而且被Ada和其他编程语言支持。ADDU不会引发溢出。因为C/C++不支持这种机制,所以本例使用的是ADDU指令而非ADD指令。
此后$V0寄存器存储这32位的运算结果。
main()函数使用到了JAL(Jump and Link)指令。JAL和JALR指令有所区别,前者使用的是相对地址——偏移量,后者则跳转到寄存器存储的绝对地址里。JALR的R代表Register。由于f()函数和main()函数都位于同一个object文件,所以f()函数的相对地址是已知的,可以被计算出来。
[1] 请参阅MSDN https://msdn.microsoft.com/en-us/library/zthk2dkh%28v=vs.80%29.aspx。
[2] 请参见http://en.wikipedia.org/wiki/Multiply%E2%80%93accumulate_operation。
[3] “单指令流多数据流”的缩写,请参见https://en.wikipedia.org/wiki/FMA_instruction_set。
[4] ARM12,附录A2.3.2。
[5] http://blog.regehr.org/archives/1154。