|
#2023 2月第二周#
导语:
学会拒绝,是走向内心强大的必修课。不想做的事情,可以拒绝;触碰底线的时候,要明确指出。关注自己的内心,不讨好、不迎合,如此才能不被身边的琐事所困扰
背景
包体对游戏下载注册的转换率的影响很大,包体越小下载转换率越高,越省钱。本文的目的是通过分析libUE4.so的文件结构及作用,研究包体优化点。
首先通过readelf命令(https://wangchujiang.com/linux-command/c/readelf.html)看看现在。我们的so中的各section的大小:
sosize = 364m
- .text (代码区)= 146m 存放了代码
- .dynstr(动态链接字符串表)= 76m 存放了一系列字符串,这些字符串代表了符号名称。
- .rela.dyn (重定向表) = 39m
- .rodata (常量区)= 26m 存放了程序运行过程中的常量
- .eh_frame = 22m 存放了Call Frame Information
- .dynsym (动态链接符号表) = 21m
- .data.rel.ro = 16m
- .bss = 6m
- .eh_frame_hdr 5m
下面我们逐一分析每个section是啥,及其优化方案。
elf文件结构的基础知识网上有很多资料,本文就不啰嗦了。推荐大家仔细研读这本书:
重定向表压缩
unreal fest 2022的一篇分享中提到了fornite对重定向表进行了压缩,本文目标是研究压缩技术,并尝试应用在项目中。
相关资料:from:https://www.youtube.com/watch?v=jQX2SFuKN9s&list=PLZlv_N0_O1gZinQS60xPFvf5lYn79lmTs&index=34
官方资料(https://zhuanlan.zhihu.com/p/564055866)表示 ,通过将minsdkversion设定为23,可以使用APS relocation table compression技术(不过这个技术的相关资料并未找到,所以不清楚其原理)
潜在收益:fornite的优化效果是from 62m-》8m,预计我们能减少30m+
负面影响:minsdkfrom 19增长至23,根据Android Platform/API Version Distrubution全球范围内将会损失2%的设备,这是google提供的数据,如果仅看国内市场应该还会更小。
动态链接符号表
dynsym&dynstr与.symtab&.strtab的作用近似,都是为了解决链接问题。前者是解决动态链接问题、后者解决静态链接问题。
dynsym是结构体Elf_Symbol的数组。
typedef struct {
int name; // string table offset,引用.strtab中字符串
int value; // section offset, or VM address
int size; // object size in bytes
char type:4; // data, func, section, or src file name (4 bits)
char binding:4; // local or global (4 bits)
char reserved; // unused
char section; // section header index, ABS, UNDEF or COMMON
} Elf_Symbol;
- Name,字符串表(dynstr)的下标
- Value,对应Section的地址,举例来说,如果函数,那就是在.text里的数组下标。
- Size,对象的大小。举例来说,如果是函数,那就是函数的代码大小。如果是数据,那就是数据结构的大小。
以函数调用举例说明,正常情况代码中调用一个函数:
void main(){func();}
void func(){}
代码编译后的main函数的代码中,会直接跳转到func的函数地址。在这种情况下,符号是无用的。
但如果func的实现是在另外一个库中,main函数由于并不知道func的函数地址,所以会先记录一个func@lib.so的符号占位,表示代码运行到此处时需要去lib.so这个动态库中寻找func的函数地址。
操作系统如何根据一个符号func找到目标的函数地址呢?就得靠上面提到的dynsym或symtab了。即是一个根据Name找到Value的过程。
当然实际上的动态链接、静态链接过程会比这里描述的复杂一些,但知道这些就足够我们理解符号表的作用了。一句话说,符号表是用于解决“链接问题”。
介绍完原理,让我们看看我们的libUE4.so导出了哪些符号表。通过:
readelf -s ibUE4.so -W
可以打印我们项目的符号数据,有93w个符号。除了正常我们手写的代码,大部分有ue4中反射系统自动生成的代码。例如项目中有
19w个_ZN13UScriptStruct13TCppStructOpsXXX的符号,这些是UStruct产生的符号
在UE中,每声明一个USTRUCT对象都会新增48个符号,例如FSGBringInDataOffline一个非常简单的UStruct,它在符号表中有48个符号。
原因是,UBT通过模版函数的方式实现了UStrcut、序列化、网络同步、版本检查等功能。核心代码在Class.h中:
而目前项目中,共有3900个左右的UStruct,合计约19w个符号信息,这里能考虑的优化思路是减少模版类的代码量,例如,我们可以将上图中的
virtual bool HasXXXFlag();
virtual bool HasYYYFlag();
重构为:
virtual bool HasFlag(EFlag){
switch(EFlag){...}
}
预计可以减少一半的函数个数,从而最终减少10w个左右的符号信息,大约能减少9m的符号表和一定的代码大小。
通过代码重构的方式可以精简so文件大小,但是实操较难且收效甚微。此时换一个角度思考,libUE4.so 需要导出这么多符号么?符号存在的目的是让第三方库能够调用到libue4.so的函数。
但在UE4项目中,libUE.so 本质上是一个主程序,它被外部链接的需求是很少的,几乎没有。所以libUE4.so如果没有动态链接符号表也没有关系。
那么如何剔除符号表?gcc编译器提供了参数:
-fvisibility=default|internal|hidden|protected
default相当于public,就是将所有的函数都公开
hidden则相反,默认将所有函数都隐藏。
在工程中搜索“fvisibility=hidden”的关键词,发现UE已经支持相关的编译参数了,而引擎默认没打开。
开启fvisibility之后,so文件减少了100m左右
移除无用代码
.text , .rodata , .data.rel.ro, .bss 是代码,优化这几个段的首选方案是移除无用代码。
在gcc中链接操作是以section作最小的处理单元,只要一个section中的某个符号被引用,该section就会被加入到可执行程序中去。因此,GCC进行无用代码剔除时可以分为两步
- 增加编译器参数-ffunction-sections 和 -fdata-section将每个函数或符号创建为一个sections,其中每个sections名与function、data名保持一致
- 在链接阶段-Wl,-gc-sections指示链接器去掉不用的section,这样就能减少最终的elf文件大小了
在工程中搜索相关keywords,可以发现ue已经集成了相关的编译优化功能,且默认开启。
PS:可以修改ubt,将compling、linking的全部参数打印出来来确认这些编译器优化的状态。
基于Linkmap分析各模块代码体积
即使stripdeadcode之后,代码仍然很大。接下来就需要详细看看游戏中各模块的代码体积了。此时需要用到编译器的另一个工具:linkmap。
什么是linkmap?
我们编写的代码需要经过编译、链接,最终生成一个可执行文件。在编译阶段,每个类会生成对应的 .o文件(目标文件)。在链接阶段,会把.o文件和动态库链接在一起。
linkmap就是记录链接相关信息的纯文本文件,里面记录了目标文件、符号等信息。
gcc编译器通过添加link参数:-Wl,-Map, $MapfilePath,可以在编译之后输出MapFile。同样UE也支持导出MapFile,下面我们来看看我们项目的MapFile文件。
由于Android 生成的Mapfile格式网上资料很少,不易看懂,而iOS的资料比较多,且有不少解析工具,所以这里我们先看iOS的。
iOS的mapfile格式如下:
Path&Arch ,可执行文件的路径及可执行文件的架构。
Object Files,列出了所有的目标文件和系统动态库,第一列是序号(后面Symbols部分有用到),第二列是文件信息,在我们项目中,objfiles共计6117个。
Sections,显示了同代码相关(不是全部)的section的信息。
其中超过1m的section是:
Symbols
Symbols中为符号相关的信息,有4列,分别是:
- Address:内存地址,结合Sections的信息,可以知道此Address对应的Section。
- Size:大小
- File:对应obj files中的序号
- Name:符号名。
Dead Stripped Symbols,与Symbols的结构一致,都是符号表,但Dead意味着符号已经不存在,因此没有记录。这就是上面提到的strip unused code的产物。
github上有较多的解析linkmap的工具,这里我用的是:https://github.com/jayden320/LinkMap。
这个工具的解析规则很简单,就是统计每个obj files的size大小,我新增了两个规则:1)剔除dead symbol 2)增加section信息。得到了按模块划分的统计数据:
简单看看可以发现的优化点有:
- 肉眼可见的无用模块:Chaos 6m、BuildPatchServices 1m、LunaRuntime_UE4 1m。
- Module.ImGui 有一个11mb大小的常量,可以去看看是否合理,全局常量不仅占包体还占内存。
优化潜力:19m
Android、iOS的shipping包的代码组成可能会存在差异,所以研究&分析Android的linkmap也是有必要的,下面是Android的Linkmap的格式:
这是Android sdk中LLVM生成的linkmap,其格式于ios的大不相同。大致推测:
- VMA、LMA 相当于Address
- Size 就是文件大小
- Algn 是对齐大小
- Out、In 含义未知
- Symbol 符号
网上有人提交了一个llvm的code change review,希望给llvm增加输出类似ios link map格式的。https://reviews.llvm.org/D63190
所以可能可以尝试升级最新的sdk,以得到此功能,后续有空时可以尝试下。
eh_frame&eh_framehdr
在LSB(https://refspecs.linuxfoundation.org/LSB_5.0.0/LSB-Core-generic/LSB-Core-generic/ehframechpt.html)中有对eh_frame的详细介绍:
- eh_frame section包含一个或者多个Call Frame Information。每个CFI包含一个Common Informatin Entry Record,每个CIE包含一个或多个Frame Description Entry。
- 通常情况下,CIE对应一个文件,FDE对应一个函数
eh_frame&eh_framehdr是用于实现堆栈回溯的功能的。(详细可以看:https://www.cnblogs.com/pwl999/p/15534946.html)
思考:虽然有点激进,如果我们默认开启了framepointer,可以将eh_frame剔除来节约包体吗?
理论上是可行的:https://stackoverflow.com/questions/39455712/using-fno-unwind-tables-in-conjunction-with-fno-exceptions
但潜在风险是可能会影响c++ exception、buggly相关功能。所以我们默认还是保留吧。
总结
- 应用上述优化方案,预计so大小从360m 压缩到 180m。
- 重定向表压缩、动态链接符号表剔除、dead code strip都是无损优化,对大型UE4项目收益颇多。
- 后续可基于linkmap搭建codesize的长效监控机制。(Android linkmap的解析工具仍需进一步研究)
- 可以去unreal ubt的反射系统中寻找更近一步的优化代码大小的方案。
|
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|