前言
在上一篇文章发出来之后,一大佬和我说,他详细的看了好几遍,完全不知道我在说什么。给我提了一个建议,就是用问题带领读者去思考,我所讲的知识是什么。这次我就按这个思路来写一篇文章。试试效果怎样。
正文
一堆数字的困惑
不知道大家有没有看过这样的一种错误堆栈信息,全都是一些数字,看不出来它所代表的类和函数,还有函数中的哪行代码的问题。然后脑袋里有一个大大的问号,这个究竟是啥呀,该怎么解析呢?这个东西就是elf文件的符号,它需要靠一张符号表来解析成我们人类可以认识的内容。选项现在我们来看看它的字典是什么?
符号表
大家应该知道,真正在计算机上跑起来的东西是一组组二进制文件。我们所编写的什么c#这样的语言其实是属于高级语言,它需要通过一系列的编译变成机器所能执行的机器码。当不同的文件相互糅合的过程,就是将它们之间对地址的引用,或者可以说是对函数和变量的地址的引用进行合并。那怎么来进行这一步的合并呢?人们就想到了这样的一种方法,把每一个文件都解析出一张表,用来记录这个文件中所用到的所有符号以及它们所对应的符号值。当在链接的时候,文件就会去互相寻找自己需要的符号来完成链接。这张表就是符号表。
符号表长啥样子呢?
在汇编阶段,就是生成目标文件的时候,就会产生这张符号表,每一个生成的elf文件都有符号表。符号表中的符号和源码中的变量名和函数名是一一对应的(这种对应关系不是说它们名字是一样的,而是说它们的一种映射关系,在c++编译的时候偶他们生成的就不是完全一样的)。这里是一段测试代码:
//main.c
void test2();
int main(){
test2();
return 0;
}
//test.c
static int a;
int b ;
static void test1(){
return;
}
void test2(){
return;
}
生成目标文件之后,用read -s test.o main.o命令即可查看其中的符号表,如下:
Symbol table '.symtab' contains 11 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS main.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4
5: 0000000000000000 0 SECTION LOCAL DEFAULT 6
6: 0000000000000000 0 SECTION LOCAL DEFAULT 7
7: 0000000000000000 0 SECTION LOCAL DEFAULT 5
8: 0000000000000000 21 FUNC GLOBAL DEFAULT 1 main
9: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_
10: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND test2 //注意这里,Ndx是UND的,且value为0
// test.o的符号表
Symbol table '.symtab' contains 12 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS test.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 2
4: 0000000000000000 0 SECTION LOCAL DEFAULT 3
5: 0000000000000004 4 OBJECT LOCAL DEFAULT 3 a
6: 0000000000000000 7 FUNC LOCAL DEFAULT 1 test1
7: 0000000000000000 0 SECTION LOCAL DEFAULT 5
8: 0000000000000000 0 SECTION LOCAL DEFAULT 6
9: 0000000000000000 0 SECTION LOCAL DEFAULT 4
10: 0000000000000000 4 OBJECT WEAK DEFAULT 3 b
11: 0000000000000007 7 FUNC GLOBAL DEFAULT 1 test2
其中,value叫符号值,对于变量和函数而言,符号值就是他们的地址,size是一个符号值所占字节数,type是符号的类型,像变量的类型就是OBJECT,函数的类型就是FUNC。Bind列是符号的作用域,LOCAL表示是局部符号,GLOBAL表示是全局符号,WEAK表示是弱符号等等。Vis列表示符号的可见性,一般用的比较少。Ndx列
表示符号所属的段的index(.text段,,data段等等)。Name列就是刚才说的与变量函数名一一对应的符号名。
注意一下,在没有链接之前,main.o并不知道test2函数的定义和地址,所以在main.o的符号表里将test2标记为UND(undefine的意思),地址值也缺省为0000000000000000,等到链接的时候寻找到定义了test2函数的目标文件的符号表,就提取出它的地址值。
现在用gcc test.0 main.o将它们链接到一起。导出a.out的符号表
//a.out的符号表
Symbol table '.dynsym' contains 6 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterTMCloneTab
2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.2.5 (2)
3: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
4: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMCloneTable
5: 0000000000000000 0 FUNC WEAK DEFAULT UND __cxa_finalize@GLIBC_2.2.5 (2)
Symbol table '.symtab' contains 66 entries:
Num: Value Size Type Bind Vis Ndx Name
......
31: 00000000000005f0 0 FUNC LOCAL DEFAULT 13 frame_dummy
32: 0000000000200df0 0 OBJECT LOCAL DEFAULT 18 __frame_dummy_init_array_
33: 0000000000000000 0 FILE LOCAL DEFAULT ABS test.c
34: 0000000000201018 4 OBJECT LOCAL DEFAULT 23 a
35: 00000000000005fa 7 FUNC LOCAL DEFAULT 13 test1
36: 0000000000000000 0 FILE LOCAL DEFAULT ABS main.c
37: 0000000000000000 0 FILE LOCAL DEFAULT ABS crtstuff.c
38: 0000000000000834 0 OBJECT LOCAL DEFAULT 17 __FRAME_END__
49: 0000000000201010 0 NOTYPE GLOBAL DEFAULT 22 _edata
50: 0000000000000694 0 FUNC GLOBAL DEFAULT 14 _fini
51: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@@GLIBC_
52: 0000000000201000 0 NOTYPE GLOBAL DEFAULT 22 __data_start
53: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
54: 0000000000201008 0 OBJECT GLOBAL HIDDEN 22 __dso_handle
55: 00000000000006a0 4 OBJECT GLOBAL DEFAULT 15 _IO_stdin_used
56: 0000000000000620 101 FUNC GLOBAL DEFAULT 13 __libc_csu_init
57: 0000000000201020 0 NOTYPE GLOBAL DEFAULT 23 _end
58: 00000000000004f0 43 FUNC GLOBAL DEFAULT 13 _start
59: 0000000000201010 0 NOTYPE GLOBAL DEFAULT 23 __bss_start
60: 0000000000000608 21 FUNC GLOBAL DEFAULT 13 main
61: 0000000000000601 7 FUNC GLOBAL DEFAULT 13 test2 //重点关注
62: 0000000000201010 0 OBJECT GLOBAL HIDDEN 22 __TMC_END__
这里看到有两个符号表,一个是.dynsym,一个是.symtab。这里简单介绍一下,.dynsym是动态符号表,是动态链接的时候用到的,而.symtab是静态符号表,金泰链接时用到的。
那在Unity生成的符号表是啥格式的呢?
通过上面我们知道了,我们要把二进制的那串信息解析成我们能看到的,需要用到符号表来对应起来,那我们就开始去Unity里看看这个小可爱藏在哪里的。
Android 的符号表
官方库的符号表
除了上面去找直接到unity的安装路径下找符号表,我们还能在打包之后在Temp\StagingArea\libs[architecture]路径下的。
我们还可以通过打包选项选择Create symbols.zip之后,打完包的符号表会生成一个.zip文件放在apk同级路径下。
IOS的符号表
当我们在xcode中使用Archive来编译的时候,会生成一个.xcarchive文件,这个文件可以在window页签里点击organizer这个选项看到。然后再.xcarchive文件右键显示包内内容,我们就能看到一个.dsym文件。这个就是我们需要的ios的符号表。
人工找太麻烦了
有些时候我们在使用第三方的bug记录工具的时候,需要上传这个符号表,通过堆栈的解析,我们就可以在第三方的工具上看到错误的类和函数信息。但是如果每次我们都通过手动找到文件,然后去上传。且不说我们上传的版本是不是和我们发布的版本一样,每次都要靠人工去操作,这是对人力的一个极大的浪费。这个时候,我们就可以通过Jenkins这样的流水线的工具来自动搞定它。对于androi的符号表我们还好操作,毕竟它出包了以后,会放在一个固定的路径下。但是ios你就能发现,通过直接生成的.xcarchive文件是放在一个默认的文件夹下,它的名字是根据时间来变化的。年月日时分秒都上了,这如果我们需要匹配到文件名那得循环遍历了,虽然能够解决,但是处理方法及其的不优雅。那我们是不是可以直接把.xcarchive文件生成到我们指定的路径下呢?稍微一查,嘿嘿,我找到了,可以用过官方的编译命令行来处理这件事xcodebuild -exportArchive 这个命令指定.xarchive的生成路径,那后面我们直接拼接固定路径就能找到.dsym文件了。
让我们去定位问题吧
我也不会解析符号,都是通过第三方的工具来解析,网上一堆,就不说了。右手问题,左手是字典,这翻译很快了吧。
怎么会找不到方法
通过翻译之后的堆栈,我们定位到问题是找不到一个静态方法,通过对比代码,我们发现这块用了反射,而且在mono环境下却不会出现。难道是因为打包的姿势不对?那我们去看看IL2CPP究竟是个什么样的可爱玩意。
IL
来我们来进行一波说文解字,头两个单词是IL,那我们去查下IL是个啥东西。通过微软官方的解释,IL是微软出品的一种基于.NET平台的中间语言。那我们来讲讲为啥需要这个中间语言。因为机器能认识的语言是汇编语言,这个语言太晦涩难懂了,入个门都要好长时间,对于当代这么快节奏的社会来说,平常人根本不能忍受,所以这种苦难还是那些牛人去体验吧。所以微软就出了一个稍微能让人看懂的语言IL语言,它比汇编要好认一点,最起码有一些高级特性,泛型呀,类呀方法呀,继承,字符串等等都能认识。那我这种垃圾码农就很容易写出我想要的代码。然后通过解释器,把IL转换成汇编,让CPU去跑吧,反正我才不管你汇编语言是啥玩意,也才不管你机器的CPU架构是啥玩意。某一天,我觉得IL语言也写的很不爽,不自由了。这个时候微软掏出了高级语言(c#,vb,F#),它帮我们把很多需要关注的点(内存管理等等)给管理起来的,我们就可以只去写逻辑(这个时候大部分人就可以进化成逻辑狗),去关注算法怎么实现,不去管理内存究竟是怎么管理的,不需要知道内存怎么回收的,反正高级语言都给你安排的明明白白了。
IL2CPP
那我们知道了IL是什么,那我们来看看IL2CPP是干啥吃的,它是把我们的IL语言编译成机器能跑的机器码。可能有人会问了,上面讲了IL就是会被转换成汇编呀,那现在这步和上面那个步骤有啥区别吗?其实也没事区别上面讲的是一张实时的编译,就是在运行的时候在把IL编译成机器码,而IL2CPP就直接在离线的时候把IL编译成机器码,一个是离线操作,一个是实时操作的。那我们知道了离线操作,和实时操作,那我们来看IL2CPP是怎么把我们的IL编程机器码了。
它通过了一个叫il2cpp.exe这个离线编译器把IL编译成c++的语言,然后再去生成对应平台的机器码。c++那被优化的可太好了,各个平台的c++编译器都优化到极致了,所以相当于站在巨人的肩膀上,歇一口气,我们很多事都可以少做了,直接用现成的。
那丢方法的原因在哪里呢?
通过上面我们了解到,我们的代码是通过AOT来编译的,那在AOT编译的时候,对于那种没有直接引用的方法或者类(比如反射等),它不知道要不要生成机器码,毕竟离线的也就是做一次扫描代码的活,又不是真的跑了一遍代码。所以当它觉得你没有用,它就会通过代码托管剥离来删除它觉得你没有用到的代码。你说它为啥还自作主张去删减代码呀,这不是吃饱了撑的。其实它这样做也是为了能加快我们打包的效率,毕竟代码少了转换成c++的时间也就变少了,构建机器码也就变快了。那我们能做什么操作来明确告知它我们这个代码是要用的,你不能删除。
我告诉你这块代码我要用,上帝来了也不能动
那我明确使用这个方法,扫描的时候,就可以知道这个代码块被引用了,不能被删除了,还有一个通过一个叫Link.xml的文件配置不能被删除的代码块。
结束语
也不知道这样的写的风格大家认不认可。快给一点建议呀!