聊聊OC中静态库的链接

本文源于开发中的一个困惑:经历了较大的重构,项目中有两个相同的类文件,竟然可以编译并链接通过。经过反思,回顾,整理了关于静态库的相关技术才解决了此疑问。接下来将聊聊静态库出现的原因,链接的原理及其容易令人困惑的点,同时简要说明OC中方法和类链接的特点。

问题由来

问题一

在iOS开发中,如果项目中有两个相同的类,那么在链接的时候一般会报错:

image.png

可是在我开发的项目,为什么不同目录下有两个相同的类确不会报错,能够正常链接通过。

问题二

在C语言中,如果某个函数没有被定义,那么在链接时候会报符号找不到的错误,OC中如果某个类的方法只写了声明,为什么可以链接通过呢?

问题三

如果在项目中引入的静态库里含有只有分类的文件,为什么会在运行时会报错:unrecognized selector sent to instance

这里这几个问题其实都和静态库有关,我们先看看静态库解决了什么问题,以及静态库的链接是怎样的。

静态库解决了什么问题?

如果没有静态库,我们开发一个项目,会怎样?为了开发一个功能,我们要去某个公共的地方找到实现了相应功能的文件,以及这个文件依赖的文件,然后引入到项目中去,这样做显然太复杂了。后来出现了静态库,它其实是将上述动作交给了链接器,从而节省了人力。总体来说它可以方便引入外部功能,不泄露源码,节省编译时间等几个优点。

方便开发者使用

如果没有静态库,我们该怎样调用某个公共方法?我们首先需要去某个地方查找这个方法的定义,然后将这个方法的.h和.c文件引入到我们的工程,这时如果这个.c文件中还引入了其它的方法,那么我们需要一直寻找下去,这样就会让开发流程及其复杂。有了静态库,我们只需要导入一个库文件即可,链接器会决定究竟需要引入哪些目标文件。

方便编译器开发

有些人可能会感觉,既然程序员逐个来找所需要的源文件这么复杂,为什么编译器不内置所有的文件?我们使用的时候自动给我们链接所需要的目标文件不就可以了?这样做确实是可以,但是这样做其实大大增大了编译器的开发成本和维护成本,它和静态库耦合了,如果某个静态库升级了那么编译器也需要升级。

节省磁盘和内存空间

如果将所有的功能文件都打进一个.o文件,然后链接一次不就解决了开发者的问题,也解决了编译器的问题了吗?是,这样是解决了两者的问题,但是又引入了另外的问题:可执行文件的变得很大,因为它引入了不需要的功能,这占用了磁盘空间,同时在程序运行的时候也会占用内存空间。

不泄露源码

某些框架可能是作为商用产品提供给开发者的,不能直接将代码提供给开发者,这时将源代码编译之后提供给开发者更加安全。其实如果开发者知道了源码的实现逻辑,就可能会依赖没有在.h中暴露出来的代码,这样不规范的用法就导致依赖了不稳定的东西,结果一旦库升级,内部实现逻辑改变,那么上层的使用方就会出bug。

节省编译时间

在大型工程的开发过程中,某个开发者仅仅关注一个模块的功能,如果所有模块都是源码,那么每次运行项目就需要全部编译一遍,项目越大越耗时,所以在很多大型软件开发过程中都会拆开各个模块给不同的团队开发,自己业务不依赖的模块是个静态库,这样就在很大程度上节约开发的时间。

静态库的链接过程

知道了静态库出现的原因,下面说说Unix标准下静态库的链接步骤。将静态库和源文件链接的形式如下:

1
1linux> gcc main.c /usr/lib/libm.a /usr/lib/libc.a

链接器的工作方式是这样的:在链接器收到这个指令之后,会对创建三个集合E,D,U,它们的作用分别是:

E:存放最终要链接成可执行文件的所有目标文件。

D:存放放入E中的目标文件中定义的符号。

U:未被解析的符号引用。

链接器根据传入参数中从前往后**的顺序解析所有的输入项,**如果输入项为.c文件,那么就会被转化成.o文件然后放到E中,如果是.o文件则直接放到E中,同时更新D和U,如果其输入的是静态库,那么它会逐个遍历静态库中的目标文件,以查找U中符号的定义,如果找到了某个目标文件定义了U中的符号,那么就将其加入到E中,并更新D,一直到U和D中的符号稳定(这时说明静态库中所有的目标文件都被处理完了,U和D都不再变化了)。最后链接时候只会链接E中的目标文件,静态库中不在E中的目标文件将会被忽略,从而不占可执行文件的空间。处理到最后,如果U中有符号未被定义,那么就会报符号找不到的错误:

1
1linux> undefined reference to 'some_func'

具体流程如下:

静态库的链接流程.png

静态库链接注意点

从上面链接器对静态库处理链接的描述中,我们会发现几个问题:

链接的顺序很重要

如果某个文件引用了静态库中的函数,然而将静态库的链接放在了引用文件的前面,将会出现链接失败。为什么?因为引用符号的文件出现在了静态库的后面,那么,在静态库链接的时候没有被放到U中,所以静态库中需要被加入链接的目标文件被直接舍弃了。例如在main.c中使用了sumlib.a中的sum方法,而在链接的时候使用:

1
1linux>  gcc sumlib.a main.c

这时会报符号未定义的链接错误,但是这样链接就没有问题:

1
1linux>  gcc main.c sumlib.a

这是因为如果先处理sumlib.a的话,因为没有未引用的符号放到U中,所以这个静态库就直接被忽略了,之后再链接main.o的时候就会出错,关于静态库和.o之间链接可能引起的问题,StackOverflow上的这个回答给了五个Case可供学习 很多现代的链接器优化了删除静态库中无用文件的逻辑,不是在处理完某个静态库就将其中不需要的文件移除,而是在所有输入项中未定义符号处理完之后才会决定是否舍弃静态库中未放入E中的文件(这点是在Mac上使用gcc和clang试验结合参考资料5得来的)。

静态库中可以出现重复的符号

因为静态库在生成时候是没有进行链接的,所以其不会检查是否有符号重复,而在链接时候,只要链接器找到了一个U中符号定义的目标文件,就将其放入到E中,并更新D,因此,之后那个重复的文件没有被链接的可能。所以链接过程中不会有重复目标文件带来的错误,无论这个文件是出现在一个静态库中,还是出现在多个静态库之间(在Xcode中,如果某个静态库的不同问价夹下出现两个相同的类文件,那么编译之后会在类名以某种规则加上字符串,以做区分)如果两个静态库中有相同的类,那么先被用来解引用的类将会被链接,而后被解引用的那个静态库中的文件,因为U中没有了该符号,所以就不会再被链接。

不在静态库中的目标文件都会被放到E中

如果某个源文件是直接链接,而没有打包进静态库,那么即使该文件没有被引用,它还是会被加入到可执行文件,从而增加了应用程序包的大小。

源文件可以替换掉静态库中的文件

如果源文件放到静态库中的前面进行链接,那么最终链接的时候会用源文件,而不会使用静态库中编译好的目标文件,这个方法可以用在静态库的调试过程中。比如我们的项目用到了某个静态库,发现库中有一个bug,如果每修改一次都重新打包同时加入到项目中就会变得很繁琐(静态库有时候不好作为project直接引入到项目中),可以将某个文件直接引入到被使用的项目中去,这样就可以调试,调试完成之后再将源文件编译打包进静态库中就可以了。

本文开头提出的问题原因在哪?

问题一

细心的读者可能已经发现,原因就是我上文说的静态库链接机制,由于我们的开发是在静态库中的,所以可以有两个相同的类被放到不同的目录下,至于哪个类被链接取决于哪个类先被链接器处理。

问题二

问题二的原因在于,在OC中方法是不会被生成符号的,可以生成符号的只有类名和全局变量,所以如果某个类没有.m文件,直接链接错误的。而方法不会,在OC中方法调用,在编译之后会转换成objc_msgSend函数的调用,方法名作为该函数的一个字符串参数被传入,而函数的具体地址是在运行时决定的,这一点和C语言不一样,这也就是OC作为一门动态语言的特点。

问题三

问题三的原因和问题二很像,因为OC不将方法作为符号,所以就不会将其放入U中,因为U中没有静态库中的符号,所以就不会将静态库中相应的目标文件链接进可执行文件,就会在运行的时候报错。关于这个问题及其解决方案,请看苹果的官方解答:https://developer.apple.com/library/archive/qa/qa1490/_index.html

备注:

上面的问题三解决方案是通过配置Xcode中Build Setting,在Other Linker Flags中添加ObjC标识,填了此标识就会加载静态库的所有成员,无论是C还是C++还OC,但它对同一个静态库中的相同类是不会同时链接的,还是遵守上文中链接流程的描述(链接其中先链接到的符号文件),但是对于不同静态库中的文件不会因为其中已经没有未引用的符号而不链接该文件。同时现在绝大多数的项目都会使用CocoaPod,而CocoaPod默认是添加了ObjC标识的,这也就是为什么很多时候两个不同的静态库中有相同的符号会报链接错误。

解决方案

既然在使用静态库的过程中可能会有这些问题,应该如何避免呢?解决某个静态库中相同文件,其实可以在提交前写个脚本执行下,如果发现相同的文件,则不能提交git仓库。然而对于不同静态库中相同类名等问题就需要在持续集成打包的机器上做比价合适,因为其比较耗时,可以使用ar -t或者其它指令来找出所有的.o文件,对比其是否有相同的目标文件,并给出总后的结果(如果开启了ObjC标识,那么其实无需关心不同静态库中相同目标文件的问题,因为链接器会直接报错的)。

参考资料

  1. 深入理解计算机系统: 7.6.3
  2. https://developer.apple.com/library/archive/qa/qa1490/_index.html
  3. https://stackoverflow.com/questions/50292838/multiple-symbol-definitions-and-static-libraries
  4. https://stackoverflow.com/questions/37490157/linker-does-not-emit-multiple-definition-error-when-same-symbol-coexists-in-obje
  5. https://blog.csdn.net/weiyuefei/article/details/76169772
  6. https://segmentfault.com/a/1190000005859469
  7. https://pewpewthespells.com/blog/objc_linker_flags.html