【Unity】IL2CPP初探

前言

在上一篇文章发出来之后,一大佬和我说,他详细的看了好几遍,完全不知道我在说什么。给我提了一个建议,就是用问题带领读者去思考,我所讲的知识是什么。这次我就按这个思路来写一篇文章。试试效果怎样。

正文

一堆数字的困惑

不知道大家有没有看过这样的一种错误堆栈信息,全都是一些数字,看不出来它所代表的类和函数,还有函数中的哪行代码的问题。然后脑袋里有一个大大的问号,这个究竟是啥呀,该怎么解析呢?这个东西就是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的文件配置不能被删除的代码块。

结束语

也不知道这样的写的风格大家认不认可。快给一点建议呀!

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注