几年前把项目里,埋点事件头文件的 const NSString 对象的 storage-class specifier 从 static 改为 extern。当时是基于一个很朴素想法——定义在 .h 的 static const string 会在所有 import 导的 translation unit 中有一份私有拷贝,改为 extern 能总是引用同一个对象以节省空间。然而事情没有那么简单——最近的一次测试中发现 static 占用更少空间。
新建一个 command line 工程,文件和作用如下:
main.m
Const.h // 「声明 extern 」或「定义 static」 5 个字符串常量
Const.m // 当 .h 为「声明 extern 」时,.m 定义对应的常量
Class0.h
Class0.m // 导入 Const.h,打印全部 5 个常量地址,如 printf("%p\n", &kTestSymbol4);
...
Class7.m // Class0~Class7 相同
Class8.h
Class8.m // 引用两个常量,[kTestSymbol0 stringByAppendingString:kTestSymbol1];
static 和 extern 带来的区别,主要在常量数据段以及引用的代码段,需要探索的内容为两点:
针对上述两个内容的验证方法:
LTO=NO, -O0
没有任何优化的情况下,编译并查看产物体积:
[extern case]
Segment __TEXT: 16384
Section __text: 1628 <--------- 指令更多
...
Segment __DATA_CONST: 16384
Section __got: 8
Section __const: 40 <--------- 常量占据 40 字节
[static case]
Segment __TEXT: 16384
Section __text: 1620 <--------- 指令更少
...
Segment __DATA_CONST: 16384
Section __got: 8
Section __const: 400 <--------- 常量占据 400 字节
上面的数据基本符合「朴素的想法」:
其中引用代码的机器码如下,看起来 extern 会多一些指令:
[extern case]
0x100003848 <+12>: adrp x8, 1
0x10000384c <+16>: add x8, x8, #0x8 ;x8 地址为 0x100004028
0x100003850 <+20>: ldr x0, [x8] ;x8 地址内容为 0x0000000100004030, 即 @"kTestSymbol0" 对象
[static case]
0x100003858 <+20>: adrp x0, 1
0x10000385c <+24>: add x0, x0, #0x198 ; x0 = 0x0000000100004198, @"kTestSymbol0"
LTO=NO, -Os
打开打包常用的 -Os 优化,编译并查看产物体积:
[extern case]
Segment __TEXT: 16384
Section __text: 1404 <--------- 指令更多
...
Segment __DATA_CONST: 16384
Section __got: 8
Section __const: 40 <--------- 常量占据 40 字节
Section __cfstring: 160
[static case]
Segment __TEXT: 16384
Section __text: 1396 <--------- 指令更少
...
Segment __DATA_CONST: 16384
Section __got: 8
Section __const: 320 <--------- 常量占据 320 字节
Section __cfstring: 160
上面的数据说明:
不必要的拷贝具体是怎么样的呢?
其中少拷贝的 10 个应该是:
5 个是 Const.m 没有对 Const.h 的 static var 的引用,故减少 5 个拷贝;
3 个是 Class8.m 只引用了 kTestSymbol0 和 kTestSymbol1,故减少 3 个拷贝;
剩余 2 个比较奇妙,其实就是 kTestSymbol0 和 kTestSymbol1,下面展开说说。
回头看这些编译单元的功能,Class0~Class7 打印地址:
printf("%p\n", &kTestSymbol4);
所以必须在 __DATA_CONST, __const 划分一个空间,可以获取其地址。而 Class8 则直接引用字符串:
[kTestSymbol0 stringByAppendingString:kTestSymbol1];
这样的代码,被优化成了直接读取字符串对象地址,所以 kTestSymbol0 和 kTestSymbol1 也不需要拷贝了。
另外,其中引用代码的机器码如下,看起来 extern 也还是会多一些指令:
[extern case]
0x100003918 <+12>: nop
0x10000391c <+16>: nop
0x100003920 <+20>: ldr x0, #0x6e8 ; @"kTestSymbol0"
-> 0x100003924 <+24>: nop
0x100003928 <+28>: nop
0x10000392c <+32>: ldr x2, #0x6e4
0x100003930 <+36>: nop
[static case]
-> 0x100003920 <+12>: nop
0x100003924 <+16>: ldr x1, #0x4c2c ; "stringByAppendingString:"
0x100003928 <+20>: adr x0, #0x818 ; @"kTestSymbol0"
0x10000392c <+24>: nop
LTO=YES_THIN, -Os
为什么前面实验回强调 LTO 设置呢,因为当 LTO 为 Incremental 时:
[extern case]
-> 0x100003928 <+12>: nop
0x10000392c <+16>: ldr x0, #0x6dc
0x100003930 <+20>: nop
0x100003934 <+24>: ldr x2, #0x6dc
0x100003938 <+28>: nop
0x10000393c <+32>: ldr x1, #0x4c14 ; "stringByAppendingString:"
0x100003940 <+36>: bl 0x100003d88 ; symbol stub for: objc_msgSend
[static case]
-> 0x100003928 <+12>: nop
0x10000392c <+16>: ldr x1, #0x4c24 ; "stringByAppendingString:"
0x100003930 <+20>: adr x0, #0x818 ; @"kTestSymbol0"
0x100003934 <+24>: nop
0x100003938 <+28>: adr x2, #0x830 ; @"kTestSymbol1"
0x10000393c <+32>: nop
0x100003940 <+36>: bl 0x100003d88 ; symbol stub for: objc_msgSend
它们的机器码长度一致了。当然,这个结论只在这个函数实现中有效,因为对比实际项目的产物体积,两种情况还是有些许差距。
针对 NSString *const
常量,可以得到的结论有:
— Aug 18, 2021