OC内存管理-Tagged Pointer初探

1,629 阅读5分钟

1. 简介

Tagged Pointer是苹果在64bit设备提出的一种存储小对象的技术,它具有以下特点

  • Tagged Pointer指针的值不再是地址了,而是真正的值。所以,实际上它不再是一个对象了,它只是一个披着对象皮的普通变量而已。
  • 它的内存并不存储在堆中,也不需要 malloc 和 free,不走引用计数那一套逻辑,由系统来处理释放
  • 在内存读取上有着 3 倍的效率,创建时比以前快 106 倍。
  • 可以通过设置环境变量OBJC_DISABLE_TAGGED_POINTERS来有开发者决定是否使用这项技术

2. 源码

源码是基于runtime的obj4-779.1版本

2.1 各种标记位
#if (TARGET_OS_OSX || TARGET_OS_IOSMAC) && __x86_64__
    // 64-bit Mac - tag bit is LSB
#   define OBJC_MSB_TAGGED_POINTERS 0
#else
    // Everything else - tag bit is MSB
#   define OBJC_MSB_TAGGED_POINTERS 1 // 最高有效位
#endif

#define _OBJC_TAG_INDEX_MASK 0x7 // 0b111表示有扩展的标记位,扩展标记位占8位
// array slot includes the tag bit itself
#define _OBJC_TAG_SLOT_COUNT 16
#define _OBJC_TAG_SLOT_MASK 0xf // 0b1111 taggedpointer + 有扩展标记位的mask

#define _OBJC_TAG_EXT_INDEX_MASK 0xff
// array slot has no extra bits
#define _OBJC_TAG_EXT_SLOT_COUNT 256 // 扩展标记位能表示的个数
#define _OBJC_TAG_EXT_SLOT_MASK 0xff // 0b1111 1111

#if OBJC_MSB_TAGGED_POINTERS
#   define _OBJC_TAG_MASK (1UL<<63) // 是否是tagged pointer的标记位 1表示是 0表示不是
#   define _OBJC_TAG_INDEX_SHIFT 60 // 基础tag位的偏移,从2-4bit,结合_OBJC_TAG_INDEX_MASK来获取到基础tag的值
#   define _OBJC_TAG_SLOT_SHIFT 60
#   define _OBJC_TAG_PAYLOAD_LSHIFT 4 // LSHIFT和RSHIFT配合使用用来对数据移位混淆及恢复
#   define _OBJC_TAG_PAYLOAD_RSHIFT 4
#   define _OBJC_TAG_EXT_MASK (0xfUL<<60) // 1111 0000 ... 0000 0000 是否有扩展标记位 tag位111表示有扩展标记位
#   define _OBJC_TAG_EXT_INDEX_SHIFT 52 // 扩展tag位的偏移,从5-12bit共8位,结合_OBJC_TAG_EXT_INDEX_MASK来获取扩展tag的值
#   define _OBJC_TAG_EXT_SLOT_SHIFT 52
#   define _OBJC_TAG_EXT_PAYLOAD_LSHIFT 12
#   define _OBJC_TAG_EXT_PAYLOAD_RSHIFT 12
#else
#   define _OBJC_TAG_MASK 1UL
#   define _OBJC_TAG_INDEX_SHIFT 1
#   define _OBJC_TAG_SLOT_SHIFT 0
#   define _OBJC_TAG_PAYLOAD_LSHIFT 0
#   define _OBJC_TAG_PAYLOAD_RSHIFT 4
#   define _OBJC_TAG_EXT_MASK 0xfUL
#   define _OBJC_TAG_EXT_INDEX_SHIFT 4
#   define _OBJC_TAG_EXT_SLOT_SHIFT 4
#   define _OBJC_TAG_EXT_PAYLOAD_LSHIFT 0
#   define _OBJC_TAG_EXT_PAYLOAD_RSHIFT 12
#endif

定义了很多位信息,我们需要关注的几个:

  • _OBJC_TAG_MASK :标记位标记该指针是否是tagged pointer
  • _OBJC_TAG_INDEX_MASK :tag的值是7表示有扩展的tag位
  • 其他的都是一些定义,用来通过位运算来获取tag的值、ext tag的值的mask以及一些其他的左移右移位
2.2 如何判断是tagged pointer

我们知道有一个标记位来标识指针是否是tagged pointer的

static inline bool 
_objc_isTaggedPointer(const void * _Nullable ptr)
{
    return ((uintptr_t)ptr & _OBJC_TAG_MASK) == _OBJC_TAG_MASK;
}

通过位运算获取标识位的值来确定是否是tagged pointer;需要留意的是不同的架构标记位不太一样,有的是用最低位、有的使用最高位。

2.3 系统对tagged pointer的加密

在iOS12系统之前,我们发现是可以直接打印tagged pointer的值的,可读性非常好,但是12之后再打印就发现完全看不懂了。

- (void)testCase {
	NSString *stringWithFormat1 = [NSString stringWithFormat:@"y"];
    [self formatedLogObject:stringWithFormat1];
}

- (void)formatedLogObject:(id)object {
    if (@available(iOS 12.0, *)) {
        NSLog(@"%p %@ %@", object, object, object_getClass(object));
    } else {
        NSLog(@"0x%6lx %@ %@", object, object, object_getClass(object));
    }
}

上面的测试代码,在12之前输出: 0x79是ASCII对应的y字符的值

0xa000000000000791 y NSTaggedPointerString

iOS12之后输出:

0xcb47b8d98a2fa15f y NSTaggedPointerString

iOS12之前打印指针的值能很清晰的看到数据等信息,iOS12之后系统则打印的完全看不懂了,看了源代码发现苹果是做了混淆,让我们不能直接得到值,从而避免我们去很容易就伪造出一个tagged pointer对象

苹果是如何混淆的了

第一步:位移运算,右左横移

static inline void * _Nonnull
_objc_makeTaggedPointer(objc_tag_index_t tag, uintptr_t value)
{
    // PAYLOAD_LSHIFT and PAYLOAD_RSHIFT are the payload extraction shifts.
    // They are reversed here for payload insertion.

    // ASSERT(_objc_taggedPointersEnabled());
    if (tag <= OBJC_TAG_Last60BitPayload) {
        // ASSERT(((value << _OBJC_TAG_PAYLOAD_RSHIFT) >> _OBJC_TAG_PAYLOAD_LSHIFT) == value);
        uintptr_t result =
            (_OBJC_TAG_MASK | 
             ((uintptr_t)tag << _OBJC_TAG_INDEX_SHIFT) | 
             ((value << _OBJC_TAG_PAYLOAD_RSHIFT) >> _OBJC_TAG_PAYLOAD_LSHIFT));
        return _objc_encodeTaggedPointer(result);
    } else {
        // ASSERT(tag >= OBJC_TAG_First52BitPayload);
        // ASSERT(tag <= OBJC_TAG_Last52BitPayload);
        // ASSERT(((value << _OBJC_TAG_EXT_PAYLOAD_RSHIFT) >> _OBJC_TAG_EXT_PAYLOAD_LSHIFT) == value);
        uintptr_t result =
            (_OBJC_TAG_EXT_MASK |
             ((uintptr_t)(tag - OBJC_TAG_First52BitPayload) << _OBJC_TAG_EXT_INDEX_SHIFT) |
             ((value << _OBJC_TAG_EXT_PAYLOAD_RSHIFT) >> _OBJC_TAG_EXT_PAYLOAD_LSHIFT));
        return _objc_encodeTaggedPointer(result);
    }
}

第二步,再来一个随机数异或运算一下

static inline void * _Nonnull
_objc_encodeTaggedPointer(uintptr_t ptr)
{
    return (void *)(objc_debug_taggedpointer_obfuscator ^ ptr); // objc_debug_taggedpointer_obfuscator 通过异或混淆一下指针的值
}

这个随机数是在dyld加载image的时候去随机产生的,每次程序加载都不一样

// map_images -- map_images_nolock -- _read_images -- initializeTaggedPointerObfuscator
static void
initializeTaggedPointerObfuscator(void)
{
    // 初始化一个taggedpointer的掩码,每次_objc_init的时候都生成一个
    if (sdkIsOlderThan(10_14, 12_0, 12_0, 5_0, 3_0) ||
        // Set the obfuscator to zero for apps linked against older SDKs,
        // in case they're relying on the tagged pointer representation.
        DisableTaggedPointerObfuscation) {
        objc_debug_taggedpointer_obfuscator = 0;
    } else {
        // Pull random data into the variable, then shift away all non-payload bits.
        // 随机一个掩码,然后再做运算;基本上每次app启动获取到的都是不一样的
        arc4random_buf(&objc_debug_taggedpointer_obfuscator,
                       sizeof(objc_debug_taggedpointer_obfuscator));
        objc_debug_taggedpointer_obfuscator &= ~_OBJC_TAG_MASK;
    }
}

简直丧心病狂啊,尝试去hook一下,将这个值拿到或者修改成一个固定的值来好debug,最终没搞成。

混淆之后怎么解密了

正常就是按照上面的混淆的方式,反向操作一波 第一步,异或那个固定的随机数,得到值

static inline uintptr_t
_objc_decodeTaggedPointer(const void * _Nullable ptr)
{
    return (uintptr_t)ptr ^ objc_debug_taggedpointer_obfuscator; // 再次异或就得到了原始的值
}

这里举个例子,就一目了然了;可以看到encode的时候异或一次,在decode的时候再异或一次就得到原始值了。

// 假设 objc_debug_taggedpointer_obfuscator位 0010、原始数据是1001
encode:1001 ^ 0010 = 1011
decode:1011 ^ 0010 = 1001

第二步,再左右横移回去

static inline uintptr_t
_objc_getTaggedPointerValue(const void * _Nullable ptr) 
{
    // ASSERT(_objc_isTaggedPointer(ptr));
    uintptr_t value = _objc_decodeTaggedPointer(ptr);
    uintptr_t basicTag = (value >> _OBJC_TAG_INDEX_SHIFT) & _OBJC_TAG_INDEX_MASK;
    if (basicTag == _OBJC_TAG_INDEX_MASK) {
        return (value << _OBJC_TAG_EXT_PAYLOAD_LSHIFT) >> _OBJC_TAG_EXT_PAYLOAD_RSHIFT;
    } else {
        return (value << _OBJC_TAG_PAYLOAD_LSHIFT) >> _OBJC_TAG_PAYLOAD_RSHIFT;
    }
}

static inline intptr_t
_objc_getTaggedPointerSignedValue(const void * _Nullable ptr) 
{
    // ASSERT(_objc_isTaggedPointer(ptr));
    uintptr_t value = _objc_decodeTaggedPointer(ptr);
    uintptr_t basicTag = (value >> _OBJC_TAG_INDEX_SHIFT) & _OBJC_TAG_INDEX_MASK;
    if (basicTag == _OBJC_TAG_INDEX_MASK) {
        return ((intptr_t)value << _OBJC_TAG_EXT_PAYLOAD_LSHIFT) >> _OBJC_TAG_EXT_PAYLOAD_RSHIFT;
    } else {
        return ((intptr_t)value << _OBJC_TAG_PAYLOAD_LSHIFT) >> _OBJC_TAG_PAYLOAD_RSHIFT;
    }
}
2.4 Tagged Pointer对象

系统通过3bit的标记位来标识tagged pointer对象的类,它的定义在objc_tag_index_t中 比如2表示是NSString、6表示是NSDate,我们知道3bit能表示的最大值是7,这个7系统用来预留,用来标记是否有额外的标记位,这样就能支持更多的类支持tagged pointer

#if __has_feature(objc_fixed_enum)  ||  __cplusplus >= 201103L
enum objc_tag_index_t : uint16_t
#else
typedef uint16_t objc_tag_index_t;
enum
#endif
{
    // 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,

    // 60-bit reserved
    OBJC_TAG_RESERVED_7        = 7, 

    // 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,

    OBJC_TAG_First60BitPayload = 0, 
    OBJC_TAG_Last60BitPayload  = 6, 
    OBJC_TAG_First52BitPayload = 8, 
    OBJC_TAG_Last52BitPayload  = 263, 

    OBJC_TAG_RESERVED_264      = 264
};
#if __has_feature(objc_fixed_enum)  &&  !defined(__cplusplus)
typedef enum objc_tag_index_t objc_tag_index_t;
#endif

3. 延伸

3.1 字符串编码

看博客发现很多都说字符在长度超过10的时候就不再是NSTaggedPointerString了,其实这种说法是不准确的;

TaggedPointer用来表示值的位数payload是固定的,但是采用不同的字符编码格式就能表示的位数也就不一样了;

下面的测试例子,11位的字符串它也是TaggedPointer

NSString *test2 = [NSString stringWithFormat:@"%@", @"11111111111"];
    NSLog(@"%p value:0x%lx %@ %@", test2, _objc_getTaggedPointerValue((__bridge const void *)test2), test2, object_getClass(test2));

输出结果:

0xfa976f93575a8e75 value:0x7bdef7bdef7bdeb 11111111111 NSTaggedPointerString

关于字符串的编码这一块,还是挺复杂的,感兴趣的可以参照这篇博客学习一下系统是如何在创建一个字符串的时候去编码的 Tagged Pointer Strings by Mike Ash

Thus we can see that the structure of the tagged pointer strings is:

If the length is between 0 and 7, store the string as raw eight-bit characters. If the length is 8 or 9, store the string in a six-bit encoding, using the alphabet "eilotrm.apdnsIc ufkMShjTRxgC4013bDNvwyUL2O856P-B79AFKEWV_zGJ/HYX". If the length is 10 or 11, store the string in a five-bit encoding, using the alphabet "eilotrm.apdnsIc ufkMShjTRxgC4013" 摘自Tagged Pointer Strings by Mike Ash

3.2 iOS14对tagged pointer的优化

在intel中tagged pointer指针的数据结构如下:

  • 通过最低位来标记是否是tagged pointer
  • 3位tag来标记数据的class
  • 扩展的8位来表示更多的类型的tagged pointer

在arm中tagged pointer指针的数据结构如下:

iOS13系统:

为什么要做这样的改变了?

主要是针对objc_msgSend的调用的优化,在intel的结构下,判断tagged pointer和nil需要分2个分支去判断;而如果将最高位设置位1了,那么只需要做一次判断就可以判断对象是否是正常指针了。 if (ptrValue <= 0) // is tagged or nil其他情况就是正常的指针

iOS14系统

iOS14跟iOS13的区别则将tag放在了最后三位;tagged pointer的标记位还是保持在最高位

为什么要这样做了?

苹果的解释是:

  • ARM有个特性就是dyld会忽略指针的前8bit(这是由于ARM的Top Byte Ignore特性);
  • 这样布局数据之后,图中的payload就跟普通指针的payload是一毛一样了,也就是tagged pointer的payload(有效负载位)有包含一个正常的指针的能力;
  • 这使得tagged pointer具备了引用二进制文件中的常量数据的能力,例如字符串或其他数据结构,这减少了dirty memory的使用

看到这里我只能说苹果牛逼666