内存管理

深入了解Tagged Pointer

2020-04-02  本文已影响0人  kikido

objc 源码版本:779.1
当然还是推荐使用这个来学习:可编译的源码

在 2013 年苹果推出了首个使用 64 位架构的双核处理器的手机 iphone 5s。为了节约内存以及提高执行效率,苹果使用了一种叫做 'Tagged Pointer' 的技术,现在跟着我来了解一下它吧。

Tagged Pointer

从 5s 开始,iPhone 均使用 arm64 指令集的处理器。在 64 位系统上,一个指针占 8 个字节,而指针指向的实例变量至少需要 16 个字节,并且还需要执行额外的一些操作,例如:申请内存,销毁内存。为了达到优化的目的,苹果将一些存储数据的类,例如 NSString,NSNumber,当它们需要保存的数据不需要占用那么多的字节时,直接将数据保存在“指针”里面。

下面让我们用代码来证实Tagged Pointer的存在

NSNumber *a = [NSNumber numberWithInt:1];

然后打个断点,使用 lldb 的命令调试,x/8xg a,该命令的意思是从a的起始地址开始,打印 8 个 16进制的 8字节长度的值
输出结果

image

说明指针 a 并不是指向 NSNumber 实例的指针。

或者下面这样更加直观一点

image

可以看到 a 并没有 isa 指针,所以它并不是一个 NSNumber 实例指针。


Tagged Pointer 如何存储数据

这里你最好打开源码对照着看。

LSB

在非 arm64 架构中,将最低位即 LSB 设置为 1,与正常的指针进行区分。
这样做的原因是,OC 类在创建实例最终调用的是 C 标准库中的 calloc 函数,它所返回的内存地址会是 16 的倍数,参考 Aligned memory management?,虽然里面回答的是 malloc() 函数。。。但我也只能这么解释了。这样的结果就是指针地址低 4 位都是 0,用最低位来表示也合理的

非 arm64 架构下标记位的设定

MSB

在 arm64 架构中,将最高位即 MSB 设置为 1,与正常的指针进行区分。
这样做的原因的是因为在 arm64 架构中,指针只用了低位的 48 位。原因可以看下这个 为什么64位机指针只用48个位?

objc-internal.h 372 行以及 384 行,我们 我们可以看到如下的定义来证实:

#if OBJC_MSB_TAGGED_POINTERS
#   define _OBJC_TAG_MASK (1UL<<63)
#   define _OBJC_TAG_INDEX_SHIFT 60
#else
#   define _OBJC_TAG_MASK 1UL
#   define _OBJC_TAG_INDEX_SHIFT 1
#endif

在 objc-runtime-new.mm 7752 行,我们可以看到tagged pointer类型的注册函数

void
_objc_registerTaggedPointerClass(objc_tag_index_t tag, Class cls)
{
    if (objc_debug_taggedpointer_mask == 0) {
        _objc_fatal("tagged pointers are disabled");
    }

    Class *slot = classSlotForTagIndex(tag);
    if (!slot) {
        _objc_fatal("tag index %u is invalid", (unsigned int)tag);
    }

    Class oldCls = *slot;
    
    if (cls  &&  oldCls  &&  cls != oldCls) {
        _objc_fatal("tag index %u used for two different classes "
                    "(was %p %s, now %p %s)", tag, 
                    oldCls, oldCls->nameForLogging(), 
                    cls, cls->nameForLogging());
    }

    *slot = cls;

    // Store a placeholder class in the basic tag slot that is 
    // reserved for the extended tag space, if it isn't set already.
    // Do this lazily when the first extended tag is registered so 
    // that old debuggers characterize bogus pointers correctly more often.
    if (tag < OBJC_TAG_First60BitPayload || tag > OBJC_TAG_Last60BitPayload) {
        Class *extSlot = classSlotForBasicTagIndex(OBJC_TAG_RESERVED_7);
        if (*extSlot == nil) {
            extern objc_class OBJC_CLASS_$___NSUnrecognizedTaggedPointer;
            *extSlot = (Class)&OBJC_CLASS_$___NSUnrecognizedTaggedPointer;
        }
    }
}

从上面的函数我们可以了解到,初始化系统时,会生成两个全局的数组变量,一个用来存储系统内置的Tagged Pointer类型,而另一个数组用来存储自定义扩展的Tagged Pointer类型。这两个数组的定义如下(objc-object.h 45行):

extern "C" { 
    extern Class objc_debug_taggedpointer_classes[_OBJC_TAG_SLOT_COUNT];
    extern Class objc_debug_taggedpointer_ext_classes[_OBJC_TAG_EXT_SLOT_COUNT];
}

存储内置类型的数组大小为 16,存储扩展类型的数组大小为 256。
当属于内置类型时,指针的最高2-4位用来存储类型的索引位置,剩余的 60 位用来存储数据(其实只有 56 位,还有 4 位用来保存数据的类型信息)。内置类型有以下几种:

enum
{
    // 60-bit payloads
    OBJC_TAG_NSAtom            = 0, 
    OBJC_TAG_1                 = 1, 
    OBJC_TAG_NSString          = 2, 
    OBJC_TAG_NSNumber          = 3, 
    OBJC_TAG_NSIndexPath       = 4, 
    OBJC_TAG_NSManagedObjectID = 5, 
    OBJC_TAG_NSDate            = 6,
}

而当指针的最高 4 位均为 1 时,则表示这是一个扩展类型。此时,指针的最高 5-12 位用来存储类型的索引信息,而剩余的 52 位用来存储数据。系统定义好的扩展类型有以下几种

enum
{
    // ...
    // 52-bit payloads
    OBJC_TAG_Photos_1          = 8,
    OBJC_TAG_Photos_2          = 9,
    OBJC_TAG_Photos_3          = 10,
    OBJC_TAG_Photos_4          = 11,
    OBJC_TAG_XPC_1             = 12,
    OBJC_TAG_XPC_2             = 13,
    OBJC_TAG_XPC_3             = 14,
    OBJC_TAG_XPC_4             = 15,
    OBJC_TAG_NSColor           = 16,
    OBJC_TAG_UIColor           = 17,
    OBJC_TAG_CGColor           = 18,
    OBJC_TAG_NSIndexSet        = 19,
    // ...
}

让我们用代码来验证下是否正确

- (void)boo
{
    NSNumber *a = [NSNumber numberWithInt:1];
    NSNumber *b = [NSNumber numberWithInt:2];
    NSNumber *c = [NSNumber numberWithInt:16];
    
    NSLog(@"pointer a is %lx", a);
    NSLog(@"pointer b is %lx", b);
    NSLog(@"pointer c is %lx", c);
    NSLog(@"pointer d is %lx", d);
}

输出结果:

pointer a is ef59c3d36981ed4b
pointer b is ef59c3d36981ed7b
pointer c is ef59c3d36981ec5b

等等,不是说 Tagged Pointer 最高 4 位用来保存类型信息,剩下的几位都只用来保存数据嘛,为什么输出结果看起来这么复杂呢?
原因是从 iOS12 开始,为了系统安全,对Tagged Pointer的值进行混淆。混淆函数如下:

static inline uintptr_t
_objc_decodeTaggedPointer(const void * _Nullable ptr)
{
    return (uintptr_t)ptr ^ objc_debug_taggedpointer_obfuscator;
}

objc_debug_taggedpointer_obfuscator是一个extern关键字的常量,既然被 extern 声明,让我们可以用下面的代码来解码,获取真正的值

extern uintptr_t objc_debug_taggedpointer_obfuscator;

- (void)foo
{
    NSNumber *a = [NSNumber numberWithInt:1];
    NSNumber *b = [NSNumber numberWithInt:2];
    NSNumber *c = [NSNumber numberWithInt:16];

    NSLog(@"pointer a real value is %lx", ((uintptr_t)a ^ objc_debug_taggedpointer_obfuscator));
    NSLog(@"pointer b real value is %lx", ((uintptr_t)b ^ objc_debug_taggedpointer_obfuscator));
    NSLog(@"pointer c real value is %lx", ((uintptr_t)c ^ objc_debug_taggedpointer_obfuscator));
}

输出结果:

pointer a real value is b000000000000012
pointer b real value is b000000000000022
pointer c real value is b000000000000102

从输出结果可以看出,这几个值都是 0Xb 开头,16进制的 b 用 二级制表示为 1011,最高1用来表示这是一个 Tagged Pointer,而剩余 3 位的10进制数为 3,符合之前的定义OBJC_TAG_NSNumber = 3
至于为什么结尾都是 0x2,这个后面再解释。
下面让我们测试下 NSNumber 的 Tagged Pointer 使用多少位来保存数据。从之前的探究我们知道内置类型用 60 位来保存数据,而经过上面的实验我们可以看到还有 4 位用来做别的事了,那么是否剩余的 56 位都用来保存数据了呢?

- (void)foo
{
    NSNumber *d = [NSNumber numberWithLongLong:-0x7FFFFFFFFFFFFF];
    NSLog(@"pointer a real value is %lx", ((uintptr_t)d ^ objc_debug_taggedpointer_obfuscator));
}

输出结果:

pointer d real value is b800000000000013

结果符合预期。至于为什么最高位 b 后面的数字是 8,是因为高位 5 的位置变成了 1,用来表示这个数是负数(0则表示正数), 而 7 的二进制表示为 ob111,高位 5-8 连起来就是 0b1111,也就是16进制的 8 了。
还一个值得注意的是低位第一位的数字变成了 3,而不是之前的正整数 2,由此我们可以推测最低位的 4 位是用来表示存储数据类型的数据,例如 int,float,bool 这几个类型生成的 Tagged Pointer 最低4位数字应该是不同的。用下面的代表再来验证下:

    NSNumber *a = [NSNumber numberWithInt:1];
    NSNumber *b = [NSNumber numberWithShort:2];
    NSNumber *c = [NSNumber numberWithFloat:1.];
    NSNumber *d = [NSNumber numberWithLongLong:-0x7FFFFFFFFFFFFF];

    NSLog(@"pointer a real value is %lx", ((uintptr_t)a ^ objc_debug_taggedpointer_obfuscator));
    NSLog(@"pointer b real value is %lx", ((uintptr_t)b ^ objc_debug_taggedpointer_obfuscator));
    NSLog(@"pointer c real value is %lx", ((uintptr_t)c ^ objc_debug_taggedpointer_obfuscator));
    NSLog(@"pointer d real value is %lx", ((uintptr_t)d ^ objc_debug_taggedpointer_obfuscator));

输出结果

 pointer a real value is b000000000000012
 pointer b real value is b000000000000021
 pointer c real value is b000000000000014
 pointer d real value is b800000000000013

结果符合预期,说明我们的推测是正确的

Tagged Pointer 如何使用方法

经过上面的探究,我们知道了Tagged Pointer只是一个基本数据类型,在栈中分配内存。可我们平时使用时却是拿对象来对待它的,那么,当我们对一个Tagged Pointer对象使用方法时,runtime 是如何处理的呢?

因为消息用到的函数objc_msgSend使用汇编编写的,我看不大懂。。。但大致的流程是,根据指针高位存储的信息得到相应类的 isa 指针,然后找到方法的 IMP,然后在方法中执行操作。因为不懂得汇编以及逆向我这里就不讲了。

参考

NSNumber 与 Tagged Pointer
深入解构 objc_msgSend 函数的实现

希望大家看了有所收获吧。

上一篇下一篇

猜你喜欢

热点阅读