C方法的调用参数与ARM汇编(下篇)

va_list

以上都是确定参数,那么如果是不确定参数,又是怎么传递的呢?

AAPCS 64文档里有明确的说明,但是这里我们从汇编的角度来看这个问题。

int mutableAragsFunc(int arg, ...) {
    va_list list;
    va_start(list, arg);
    int ret = arg;
    while(int a = va_arg(list, int)) {
        ret += a;
    }
    va_end(list);
    return ret;
}
mutableAragsFunc(1, 2, 3, 0);

在函数入口打断点,打印参数寄存器:

x0 = 0x0000000000000001
x1 = 0x000000016fce7930
x2 = 0x000000016fce7a18
x3 = 0x000000016fce7a90
x4 = 0x0000000000000000
x5 = 0x0000000000000000
x6 = 0x0000000000000001
x7 = 0x00000000000004b0

可以发现除了x0是正确的第一个参数,其他都是随机的,那么说明参数肯定被放到了栈上。

cfunction`main:
    0x100121be4 <+0>:   sub    sp, sp, #0xa0             ; =0xa0 
    0x100121be8 <+4>:   stp    x29, x30, [sp, #0x90]
    0x100121bec <+8>:   add    x29, sp, #0x90            ; =0x90 
    0x100121bf0 <+12>:  mov    w8, #0x0
    0x100121bf4 <+16>:  stur   w8, [x29, #-0x4]
    0x100121bf8 <+20>:  stur   w0, [x29, #-0x8]
    0x100121bfc <+24>:  stur   x1, [x29, #-0x10]
    0x100121c00 <+28>:  mov    x1, sp
    0x100121c04 <+32>:  mov    x9, #0x0
    0x100121c08 <+36>:  str    x9, [x1, #0x10]           ; 压栈 0
    0x100121c0c <+40>:  orr    w8, wzr, #0x3
    0x100121c10 <+44>:  mov    x9, x8
    0x100121c14 <+48>:  str    x9, [x1, #0x8]            ; 压栈 3
    0x100121c18 <+52>:  orr    w8, wzr, #0x2
    0x100121c1c <+56>:  mov    x9, x8
    0x100121c20 <+60>:  str    x9, [x1]                  ; 压栈 2
    0x100121c24 <+64>:  orr    w0, wzr, #0x1             ; arg = 1
    0x100121c28 <+68>:  bl     0x1001218d8               ; mutableAragsFunc at main.mm:67

也就是表明被明确定义的参数,是按照上面所说的规则传递,而...参数全部按照栈方式传递。这从实现原理上也比较容易理解,在取va_arg的时候,只需要将栈指针+sizeof(type)就可以了。

错误的函数签名

那么现在,我们回过头来看看第一个问题。C语言为什么会有函数签名?

函数签名决定了参数以及返回值的传递方式,同时还决定了函数栈帧的分布与大小,所以如果不确定函数签名,我们也就无法知道如何去传递参数了。

那么错误的函数签名会导致什么样的后果呢?运行时是否会崩溃?我们来看:

int arg1_func(int a) {
    return a;
}
int arg2_func(int a, int b) {
    return a+b;
}

void arg_test_func() {
    int ret1 = ((int (*)(int, int))arg1_func)(1, 2);
    int ret2 = ((int (*)(int))arg2_func)(1);
    int ret3 = ((int (*)())arg1_func)();
    int ret4 = ((int (*)())arg2_func)();

    printf("%d, %d, %d, %d\n", ret1, ret2, ret3, ret4);
}

首先说结果,结果是一切运行正常,只是结果值有部分是错误的。那么我们来看看汇编代码:

cfunction`arg_test_func:
    0x1003462cc <+0>:   sub    sp, sp, #0x50             ; =0x50 
    0x1003462d0 <+4>:   stp    x29, x30, [sp, #0x40]
    0x1003462d4 <+8>:   add    x29, sp, #0x40            ; =0x40 
                                                         ; 以上都是处理栈帧

    0x1003462d8 <+12>:  orr    w0, wzr, #0x1             ; w0 = 1
    0x1003462dc <+16>:  orr    w1, wzr, #0x2             ; w1 = 2
    0x1003462e0 <+20>:  bl     0x100346298               ; arg1_func at main.mm:87
    0x1003462e4 <+24>:  orr    w1, wzr, #0x1             ; w1 = 1
    0x1003462e8 <+28>:  stur   w0, [x29, #-0x4]          ; 将结果存入临时变量 ret1
                                                         ; 按照寄存器的状态,这里相当于调用了 arg1_func(1)
                                                         ; 其结果是正确的,只是可能没有符合预期

    0x1003462ec <+32>:  mov    x0, x1                    ; x0 = 1
    0x1003462f0 <+36>:  bl     0x1003462ac               ; arg2_func at main.mm:90
    0x1003462f4 <+40>:  stur   w0, [x29, #-0x8]          ; 将结果存入临时变量 ret2
                                                         ; 相当于 arg2_func(1, 1) = 2
                                                         ; 第二个参数取决于上一次x1的状态
                                                         ; 所以结果应该是随机的

    0x1003462f8 <+44>:  bl     0x100346298               ; arg1_func at main.mm:87
    0x1003462fc <+48>:  stur   w0, [x29, #-0xc]          ; 相当于 ret3 = arg1_func(2) = 2

    0x100346300 <+52>:  bl     0x1003462ac               ; arg2_func at main.mm:90
    0x100346304 <+56>:  stur   w0, [x29, #-0x10]         ; 相当于 ret4 = arg2_func(2, 1) = 3

所以结果应该是1, 2, 2, 3

这里的结果不能代表任何在其他环境下的结果,可以说其结果是难以预测的。这里没有奔溃也只是随机参数并不会带来奔溃的风险。

所以我们是不能用其他函数签名来传递参数的。

obj_msgSend

接下来,我们来说说iOS中最著名的函数obj_msgSend,可以说,这个函数是objc的核心和基础,没有这个方法,就不存在objc。

根据我们上面的分析,理论上我们不能改变obj_msgSend的函数签名,来传递不同类型和个数的参数。那么苹果又是怎么实现的呢?

以前我们一直说obj_msgSend用汇编来写是为了速度,但这并不是主要原因,因为retain,release也是非常频繁使用的方法,为什么不把这几个也改为汇编呢。其实更重要的原因是如果用C来写obj_msgSend根本实现不了!

我们翻开苹果objc的源码,查看其中arm64.s汇编代码:

    ENTRY _objc_msgSend
    MESSENGER_START

    cmp    x0, #0               // nil check and tagged pointer check
    b.le    LNilOrTagged        //  (MSB tagged pointer looks negative)
    ldr    x13, [x0]            // x13 = isa
    and    x9, x13, #ISA_MASK   // x9 = class    
LGetIsaDone:
    CacheLookup NORMAL          // calls imp or objc_msgSend_uncached

LNilOrTagged:
    b.eq    LReturnZero         // nil check

    // tagged
    adrp    x10, _objc_debug_taggedpointer_classes@PAGE
    add    x10, x10, _objc_debug_taggedpointer_classes@PAGEOFF
    ubfx    x11, x0, #60, #4
    ldr    x9, [x10, x11, LSL #3]
    b    LGetIsaDone

LReturnZero:
    // x0 is already zero
    mov    x1, #0
    movi    d0, #0
    movi    d1, #0
    movi    d2, #0
    movi    d3, #0
    MESSENGER_END_NIL
    ret

    END_ENTRY _objc_msgSend

看出于上面其他C方法编译出来的汇编的区别了吗?

那就是obj_msgSend居然不存在栈帧!同时也没有任何地方修改过X0-X7,X8,LR,SP,FP

而且当找到真正对象上的方法的时候,并不像其他方法一样使用BL,而是使用了

.macro CacheHit
br    x17            // call imp

也就是说并没有修改LR。这样做的效果就相当于在函数调用的时候插入了一段代码!更像是c语言的宏。

由于obj_msgSend并没有改变任何方法调用的上下文,所以真正的objc方法就好像是被直接调用的一样。

可以说,这种想法实在是太精彩了。

objc_msgSend对nil对象的处理

大家都知道,向空对象发送消息,返回的内容肯定都是0。那么这是为什么呢?

还是来看obj_msgSend的源代码部分,第一行就判断了nil:

    cmp    x0, #0               // nil check and tagged pointer check
    b.le    LNilOrTagged        //  (MSB tagged pointer looks negative)

其中tagged pointer技术并不是我们本期的话题,所以我们直接跳到空对象的处理方法上:

LReturnZero:
    // x0 is already zero
    mov    x1, #0
    movi    d0, #0
    movi    d1, #0
    movi    d2, #0
    movi    d3, #0
    MESSENGER_END_NIL
    ret

他将可能的保存返回值的寄存器全部写入0!(为什么会有多个寄存器,是因为ARM其实是支持向量运算的,所以在某些条件下会用多个寄存器保存返回值,具体可以去参考ARM官方文档)。

这样我们的返回值就只能是0了!

等等,还缺少一个类型,struct!如果是栈上的返回,上文已经分析过是保存在X8中的,可是我们并没有看到任何有关X8的操作。那么我们来写一个demo尝试一下:

void struct_objc_nil(Test *t) {
    struct BigStruct retB;
    printf("stack: %d,%d,%d,%d,%d,%d,\n", retB.arg1, retB.arg2, retB.arg3, retB.arg4, retB.arg5, retB.arg6);
    retB = ((struct BigStruct(*)(Test *, SEL))objc_msgSend)(t, @selector(retStruct));
    printf("msgSend: %d,%d,%d,%d,%d,%d,\n", retB.arg1, retB.arg2, retB.arg3, retB.arg4, retB.arg5, retB.arg6);
    retB = [t retStruct];
    printf("objc: %d,%d,%d,%d,%d,%d,\n", retB.arg1, retB.arg2, retB.arg3, retB.arg4, retB.arg5, retB.arg6);
}

首先我们打开编译优化-os(非优化状态,栈空间会被清0)。其结果居然是:

stack: 50462976,185207048,0,0,0,0,
msgSend: 1,0,992,0,0,0,
objc: 0,0,0,0,0,0,

struct类型两者的返回并不一致!按照我们阅读源码来推论,随机数值才是正确的结果,这是为什么呢?

我们还是来看汇编,我将关键部分特意标注了出来:

cfunction`struct_objc_nil:
    0x10097e754 <+0>:   sub    sp, sp, #0x90             ; =0x90 
    0x10097e758 <+4>:   stp    x20, x19, [sp, #0x70]
    0x10097e75c <+8>:   stp    x29, x30, [sp, #0x80]
    0x10097e760 <+12>:  add    x29, sp, #0x80            ; =0x80 
    0x10097e764 <+16>:  bl     0x10097e9d4               ; symbol stub for: objc_retain
    0x10097e768 <+20>:  mov    x19, x0
    0x10097e76c <+24>:  adr    x0, #0x1730               ; "stack: %d,%d,%d,%d,%d,%d,\n"
    0x10097e770 <+28>:  nop    
    0x10097e774 <+32>:  bl     0x10097e9f8               ; symbol stub for: printf
    0x10097e778 <+36>:  nop    
    0x10097e77c <+40>:  ldr    x20, #0x262c              ; "retStruct"
    0x10097e780 <+44>:  add    x8, sp, #0x30             ; =0x30 
    0x10097e784 <+48>:  mov    x0, x19
    0x10097e788 <+52>:  mov    x1, x20
    0x10097e78c <+56>:  bl     0x10097e9b0               ; symbol stub for: objc_msgSend
    0x10097e790 <+60>:  ldp    w8, w9, [sp, #0x30]
    0x10097e794 <+64>:  ldp    w10, w11, [sp, #0x38]
    0x10097e798 <+68>:  ldp    w12, w13, [sp, #0x40]
    0x10097e79c <+72>:  stp    x12, x13, [sp, #0x20]
    0x10097e7a0 <+76>:  stp    x10, x11, [sp, #0x10]
    0x10097e7a4 <+80>:  stp    x8, x9, [sp]
    0x10097e7a8 <+84>:  adr    x0, #0x170f               ; "msgSend: %d,%d,%d,%d,%d,%d,\n"
    0x10097e7ac <+88>:  nop    
    0x10097e7b0 <+92>:  bl     0x10097e9f8               ; symbol stub for: printf

    //////////////////////////////////////////////////////////
->  0x10097e7b4 <+96>:  cbz    x19, 0x10097e7d8          ; <+132> at main.mm:134
                                                         ; 这里的意思是:
                                                         ; IF X19 == NULL THEN
                                                         ;    GOTO 0x10097e7d8
                                                         ; 而 0x10097e7d8 就是内存清0的地方!
                                                         ; X19 在 0x10097e768 被赋值为 objc 对象 'nil'
                                                         ; 而在第一次调用 'obj_msgSend' 就没有这一段!
                                                         ; (由于优化,有些逻辑和代码中有变化)
    //////////////////////////////////////////////////////////

    0x10097e7b8 <+100>: add    x8, sp, #0x30             ; =0x30 
    0x10097e7bc <+104>: mov    x0, x19
    0x10097e7c0 <+108>: mov    x1, x20
    0x10097e7c4 <+112>: bl     0x10097e9b0               ; symbol stub for: objc_msgSend
    0x10097e7c8 <+116>: ldp    w8, w9, [sp, #0x30]
    0x10097e7cc <+120>: ldp    w10, w11, [sp, #0x38]
    0x10097e7d0 <+124>: ldp    w12, w13, [sp, #0x40]
    0x10097e7d4 <+128>: b      0x10097e800               ; <+172> at main.mm:135


                                                         ; 这里有一段清0的代码!正好就是返回值的局部变量地址
    0x10097e7d8 <+132>: mov    w13, #0x0
    0x10097e7dc <+136>: mov    w12, #0x0
    0x10097e7e0 <+140>: mov    w11, #0x0
    0x10097e7e4 <+144>: mov    w10, #0x0
    0x10097e7e8 <+148>: mov    w9, #0x0
    0x10097e7ec <+152>: mov    w8, #0x0
    0x10097e7f0 <+156>: stp    xzr, xzr, [sp, #0x60]
    0x10097e7f4 <+160>: stp    xzr, xzr, [sp, #0x50]
    0x10097e7f8 <+164>: stp    xzr, xzr, [sp, #0x40]
    0x10097e7fc <+168>: stp    xzr, xzr, [sp, #0x30]
    0x10097e800 <+172>: stp    x12, x13, [sp, #0x20]
    0x10097e804 <+176>: stp    x10, x11, [sp, #0x10]


    0x10097e808 <+180>: stp    x8, x9, [sp]
    0x10097e80c <+184>: adr    x0, #0x16c8               ; "objc: %d,%d,%d,%d,%d,%d,\n"
    0x10097e810 <+188>: nop    
    0x10097e814 <+192>: bl     0x10097e9f8               ; symbol stub for: printf
    0x10097e818 <+196>: mov    x0, x19
    0x10097e81c <+200>: bl     0x10097e9c8               ; symbol stub for: objc_release
    0x10097e820 <+204>: ldp    x29, x30, [sp, #0x80]
    0x10097e824 <+208>: ldp    x20, x19, [sp, #0x70]
    0x10097e828 <+212>: add    sp, sp, #0x90             ; =0x90 
    0x10097e82c <+216>: ret    
    0x10097e830 <+220>: b      0x10097e834               ; <+224> at main.mm
    0x10097e834 <+224>: mov    x20, x0
    0x10097e838 <+228>: mov    x0, x19
    0x10097e83c <+232>: bl     0x10097e9c8               ; symbol stub for: objc_release
    0x10097e840 <+236>: mov    x0, x20
    0x10097e844 <+240>: bl     0x10097e98c               ; symbol stub for: _Unwind_Resume

到这里我们就能够明白了,为什么struct返回值也会变成0。是编译器给我们加入了一段判定的代码!

那么'objc空对象的返回值一定是0'这个判定就需要在一定条件下了。

总结

对这一部分的探索一直持续了很久,一直是迷糊状态,不过经过长时间的多次探索,慢慢思考,总算有一个比较基础的认识了。

参考资料

armasm_user_guide

ABI

AAPCS

GNU C & ASM

相关阅读:C方法的调用参数与ARM汇编(上篇)

本文来自网易实践者社区,经作者段家顺授权发布。