第8章 参数获取

调用方(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; 
};

8.1 x86

8.1.1 MSVC

使用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()函数。

8.1.2 MSVC+OllyDbg

本节演示OllyDbg的使用方法。当f()函数读取第一个参数时,EBP的值指向栈帧,如图8.1中的红色方块所示。栈帧里的第一个值是EBP的原始状态,第二个值是返回地址RA,第三个值开始的三个值依次为函数的第一个参数、第二个参数和第三个参数。在访问第一个参数(当需要访问第一个参数)时,计算机需要把EBP的值加上8(2个32位words)。

..\TU\0801.tif{}

图8.1 使用OllyDbg观察f()函数

OllyDbg能够识别出外部传递的参数。它会对栈里的数据进行标注,添加上诸如“RETURN from”“Arg1”之类的标注信息。

故而,被调用方函数所需的参数并不在自己的栈帧之中,而是在调用方函数的栈帧里。所以,被OllyDbg标注为Arg的数据都存储于其他函数的栈帧。

8.1.3 GCC

我们使用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)能够还原栈指针。

8.2 x64

x86-64系统的参数传递过程略有不同。x86-64系统能够使用寄存器传递(前4个或前6个)参数。就这个程序而言,被调用方函数会从寄存器里获取参数,完全不需要访问栈。

8.2.1 MSVC

启用优化选项后,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

)。

大型函数可能会把输入参数保存在阴影空间里,但是小型函数(如本例)可能就不会使用阴影空间了。

在使用阴影空间时,由调用方函数分配栈空间,由被调用方函数根据需要将寄存器参数转储到它们的阴影空间中。

8.2.2 GCC

在启用优化选项后,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.2.3 GCC: uint64_t型参数

指令清单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-字头寄存器)。其他部分基本相同。

8.3 ARM

8.3.1 Non-optimizing Keil 6/2013 (ARM mode)
.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]调用,则不会进行模式切换。

8.3.2 Optimizing Keil 6/2013 (ARM mode)
.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寄存器获取返回值。

8.3.3 Optimizing Keil 6/2013 (Thumb mode)
.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寄存器里。

8.3.4 ARM64

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.4 MIPS

指令清单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。