深入了解Tagged Pointer

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

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

Tagged Pointer

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

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

1
NSNumber *a = [NSNumber numberWithInt:1];

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

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

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

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

Tagged Pointer 如何存储数据

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

LSB

在非 arm64 架构中,将最低位即 LSB 设置为 1,与正常的指针进行区分。
这样做的原因是,OC 类在创建实例最终调用的是 C 标准库中的 calloc 函数,它所返回的内存地址是 16 字节对齐的,参考 Aligned memory management?。这样的结果就是指针地址低 4 位都是 0,用最低位来表示也合理的

非 arm64 架构下标记位的设定

MSB

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

objc-internal.h 372 行以及 384 行,我们 我们可以看到掩码 _OBJC_TAG_MASK 的定义:

1
2
3
4
5
6
7
#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_debug_taggedpointer_classes 和 objc_debug_taggedpointer_ext_classes。一个用来存储系统内置的Tagged Pointer类型,而另一个数组存储扩展的Tagged Pointer类型。

1
2
3
4
extern "C" { 
extern Class objc_debug_taggedpointer_classes[_OBJC_TAG_SLOT_COUNT];
extern Class objc_debug_taggedpointer_ext_classes[_OBJC_TAG_EXT_SLOT_COUNT];
}
  • 内置类型数组的数目为 8,使用高 2 - 高 4 的 3 个 bit 来存储数据
  • 扩展类型数组的数目为 256,,使用高 5 - 高 12 的 8 个 bit 来存储数据

是否剩余的 bit 都用来保存数据了呢?答案是否定的,拿 NSUmber 举个例子,它属于内置类型,其 tagged pointer 额外使用 低 1 - 低 4 的 5 个 bit 用来保存数字的类型信息,即使用 56 个 bit 来保存数据。

内置类型如下:

1
2
3
4
5
6
7
8
9
10
11
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,
}

而当指针的高 1 - 高 4 的 4 个 bit 位均为 1 时,表示这是一个扩展类型。系统自带的扩展类型如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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,
// ...
}

数据混淆

运行下面的代码

1
2
3
4
5
6
7
8
9
10
11
- (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);
}

输出结果:

1
2
3
pointer a is ef59c3d36981ed4b
pointer b is ef59c3d36981ed7b
pointer c is ef59c3d36981ec5b

等等,不是说 Tagged Pointer 最高 4 位用来保存类型信息,剩下的几位都只用来保存数据嘛,为什么输出结果看起来这么复杂呢?
原因是从 iOS12 开始,为了系统安全,将数据与一个随机数进行 异或(^) 操作进行混淆,混淆函数如下:

1
2
3
4
5
static inline void * _Nonnull
_objc_encodeTaggedPointer(uintptr_t ptr)
{
return (void *)(objc_debug_taggedpointer_obfuscator ^ ptr);
}

objc_debug_taggedpointer_obfuscator是一个extern关键字的常量,因为被 extern 声明,所以我们可以用下面的代码来解码,得到真正的值

1
2
3
4
5
6
7
8
9
10
11
12
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));
}

输出结果:

1
2
3
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 位都用来保存数据了呢?

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

输出结果:

1
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位数字应该是不同的。用下面的代表再来验证下:

1
2
3
4
5
6
7
8
9
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));

输出结果

1
2
3
4
pointer a real value is b000000000000012
pointer b real value is b000000000000021
pointer c real value is b000000000000014
pointer d real value is b800000000000013

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

参考

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

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

作者

千行

发布于

2020-04-02

更新于

2022-10-21

许可协议

评论