🌚

Kam's Online Notebook


Extern/Static Const, Battle in Header

几年前把项目里,埋点事件头文件的 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 带来的区别,主要在常量数据段以及引用的代码段,需要探索的内容为两点:

  1. static const string var 是否在 __DATA_CONST, __const 占据空间;
  2. static 和 extern 引用处的代码体积如何;

针对上述两个内容的验证方法:

  1. 检查 __DATA_CONST, __const 的体积;
  2. 查看反汇编;

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 字节

上面的数据基本符合「朴素的想法」:

  • 40 字节 = 每个常量空间 8 字节 * 5 常量 * 1 个定义所在编译单元
  • 400 字节 = 每个常量空间 8 字节 * 5 常量 * 10 个导入头文佳的编译单元

其中引用代码的机器码如下,看起来 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

上面的数据说明:

  • 优化后 extern case 的引用代码依然更多;
  • 优化后 static const 占据的常量空间更小,推测剔除了不必要的拷贝;

不必要的拷贝具体是怎么样的呢?

  • 320 字节 = 400 字节 - 每个常量空间 8 字节 * 10 个不必要的拷贝

其中少拷贝的 10 个应该是:

  1. 5 个是 Const.m 没有对 Const.h 的 static var 的引用,故减少 5 个拷贝;

  2. 3 个是 Class8.m 只引用了 kTestSymbol0 和 kTestSymbol1,故减少 3 个拷贝;

  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 常量,可以得到的结论有:

  1. extern case 保持固定常量的存在,只占用一份变量空间(实验中 __DATA_CONST, __const 保持在 40);
  2. 不开启任何编译优化的情况下,所有的 static const var 在每个单元都会有一份 copy;
  3. 开启 -Os 级别的编译优化的情况下,是否拷贝 static const var 根据代码中是否取 const var 地址而定;
  4. static case 中,不拷贝的情况下引用代码直接读取 __DATA_CONST,__cfstring 的地址;拷贝的情况下,拷贝数量根据引用的量决定;
  5. -O0/-Os 的编译条件下,extern case 的引用生成的机器码中会多一些 nop 指令导致体积变大;
  6. LTO=YES_THIN 可能可以抹平机器码体积上的差距,实际跑项目结果 54MB 的 __text secion,static 会少 16kb(可能不到一个 vm page 也算一个 page 的空间)。

EOF

— Aug 18, 2021