前言
首先抛出几个个问题:
1.什么是header serach path
,什么又是framework search path
?答案
2.#import
,#include
, @class
和@import
的区别是什么。循环#import
一定会导致编译失败吗?答案
3.可以同时通过#import <>
和使用header seach path来#import ""
动态库里的文件吗?答案
4.如果编译时主工程(注意不是来自外部link的库)中有个Class的.m文件没有被加入到指定的target的complie files里会导致什么问题,如果是一个Category的文件呢?答案
5.如果一个类里的方法只有声明没有定义,则编译时会出现undefined symbol
吗, 为什么?答案
6.如果一个静态库里面有两个相同的类会出现duplictate symbols
错误吗?如果是动态库呢,如果是可执行文件呢?答案
7.如果主项目和连接的静态库有相同的类和相同的符号时会出现什么问题?动态库也会这样吗?答案
8.动态库和静态能呈现多层的链式链接关系吗?在链式链接的关系中能否跨层级直接调用到链接链中任意库文件中的方法吗?可以从链接链后面的库文件直接调用前面的库文件中的方法吗?答案
9.什么Other Link Flag
,什么是_all-load
, 什么是-Objc
, 什么是-force-load
,什么是-dead_strip
?答案
什么是链接
如果有注意过xcode的编译输出可以看到,其实是把每一个.m文件编译成.o文件再通过ld工具链接成mach-O
文件。那么合成mach-O
文件这一步即是链接的过程。
还是以一个小Demo说明,首先我们创建一个main.c:
1 | extern int shared; |
然后再创建一个test.c:
1 | int shared = 1; |
然后通过clang分别编译出对应的目标文件:
1 | clang -c main.c test.c |
会出现warnig警告,主要是因为编译器swap方法是test.c内部的,而没有进行显式声明导致的,如果是显式的对方法进行了定义,并且进行include则不会提示,当然我们也可以通过加入-Wno
来屏蔽掉这个警告:
1 | clang -Wno-implicit-function-declaration -c main.c |
这个时候编译器已经把源代码信息存储在目标文件main.o
和test.o
中了,并且不同的信息存储在不同段中了。我们利用otool查看分别查看两个目标文件的load commands
:
1 | otool -r main.o |
只截取LC_SEGMENT_64
这个command,对于Load command
不了解的可以查看这里。可以看到结果如下:
1 | #main.o的`LC_SEGMENT_64`cmd输出 |
main.o表示的是物理地址为页中偏移472,大小为152。 虚拟地址为0和大小为152。test.0表示的是物理地址为页中偏移552,大小为144。 虚拟地址为0和大小为144。 而且代码段总是在地址起始位置。
链接过程
我们都知道程序在启动时需要被load到内存中,而虚拟地址是从0开始的,因此如果现在main.o
和test.o
链接到一起,则不可能同时存在两个从虚拟地址0位开始的。可见链接时会对段进行合并与重定位。
LLVM的做法是把相似的段合并到一起,把两者的text
段,data
段,符号表等等分别进行合并。利用LD工具可以把目标文件进行链接:
1 | LD main.o test.o -e _main -o main.out -lSystem /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.0/lib/darwin/libclang_rt.osx.a |
-e
指定了程序起始的符号,即main函数。同时链接还依赖libclang_rt.osx.a这个库。
这么简单的一行语句实际需要经过以下步骤:
1.取出所有目标文件的符号表合成全局符号表
为了验证我们在main.c
文件的main函数之前插入几句代码,并在main函数用使用printf标准函数来输出:
1 | 2 void test() { |
重新编译得到main1.o,并查看其符号,此时编译器已经给符号附上了不同的链接属性,并且赋值为对应段中偏移:
1 | clang -c main.c -o main1.o |
由于这shared和swap这两个符号是未能找到定义的因此在目标文件中修饰为undefined,并且不会被放入当前目标文件的段表中,因为它们既没有代码,又没值。 而test()
,age1
由于可以被外部可见被修饰为external。age2虽然没有加external
修饰符,但是是一个external的符号,common修饰的一般是未定义的值,它的值表示它需要的字节数,没有包含在section中,在链接的过程中才会进行分配。而test1()
,age1
由于不被外部可见,直接连符号都省了。符号是链接时查找到指定代码的依据,因此为了合并目标文件,第一步必须把符号表整合起来。而符号名是符号的唯一标志,因此必须不能冲突,也不缺失。
作为对比,我们查看一下生成的Maco-O文件的符号表:
1 | nm -nm main.out |
链接之后,undefined符号终于找到了自己的归属。。。age2也找到了自己的归属。而printf也找到,只是标记了需要从libSystem
标准库中去找。这样符号表就组合到了一起。
2.合并section进行地址重定位
在链接的过程会地址进行重定位,如上可以看到的符号对应的地址已经发生了改变我们通过size工具可以查看到此时各个segment的情况:
1 | size -x -l -m main.out |
可以看到代码段已经变成从逻辑地址0x10000000开始了。原来是一个叫做_PAGEZERO
的segement占了,这一段主要是系统的区域,因此不允许访问。它的地址是0x0,这也是为啥我们访问空指针是会提示EXC_BAD_ACCESS 0x0
这样类似的错误。动态连接器调用相关的代码,代码中的一些字符串常量__cstring
,数据常量区,数据区都被插入进来了也被插入进来。__la_symbol_pt表示的是延迟符号指针,可以用于调用一些可执行文件中没有定义的函数,例如前面的
printf`,它可以允许动态连接器进行延迟链接。
小结
简单说链接器就是把每个单独的目标文件进行重装,包括了符号表合并,段合并与地址重定位,动态链接器的调用等等。
那么得找的到对应的目标文件,也得找得到对应的依赖库文件,对应的符号,且不能冲突。解决这些问题,基本就不会出现什么链接问题了。
iOS的编译
头文件引用
clang在进行预处理时会把import的头文件引入并展开,这样就可以在当前文件中使用这些声明的方法了。而import的时候只是传入了一个字符串,编译器是如何找到对应的头文件的呢,那就得依靠搜索路径。
Clang编译器具有和GCC相同搜索指令。对于#include ""
修饰首先在当前文件夹内查找,然后在自定义的路径中进行搜索。对于#include <>
修饰的首先在系统标准库中进行搜索,然后是自定义的路径。
但是在使用iOS工程时我们发现只要是在工程的根目录文件下的文件都可以直接通过#include ""
到,而不需要像C,C++里面一下指定详细的相对路径。这是因为hmap这个东西。默认Xcode的build setting
里有一个选项是Uses Headers Map
打开的。我们build一下当前的工程并导出编译log可以看到:
在编译源文件之前,先会创建一些列hmap文件到derivedData中,我们先关注这个工程名+-project-headers.hmap
的文件中。
1 | hexdump -C Build/Intermediates.noindex/TestLink.build/Debug-iphonesimulator/TestLink.build/TestLink-project-headers.hmap |
我们只截取后面一部分字符串部分如下:
1 | 000000d0 00 00 00 00 00 00 00 00 00 54 65 73 74 53 75 62 |.........TestSub| |
可以看到这里把所有的头文件,和它所属的文件夹全部列了出来。 并且这里还做了一些优化,用一个头文件后面来接它所属的文件夹层级,其他的同级文件直接跟在文件夹目录后面,这样同级的文件就不用再加文件夹路径了。否则如果每个头文件都接一个对应路径这个hmap文件会爆炸,而且非常不利于路径的查找。
由于这个hmap,只要是工程根目录的头文件都可以通过hmap查找到指定的路径,因此我们再编写iOS代码,只需要直接#import ""
即可直接引入对应的头文件。关于Header map的实现可以参考Clang官方文档。
有意思的是,如果我们仅仅在主工程添加另一个Header File的引用,同样会在Hmap中添加相应的搜索路径。因此我们可以把在工程中嵌套工程,并通过直接拉文件引用的方式,来直接调用另一个工程的功能。但是这样有两个问题:1.必须保证两一个工程产生是的Mach-O文件link到主工程,否则会找不到符号。2.这样会导致工程及其混乱,一层套一层,而且由于import是一个递归的过程,因此直接引用的头文件不能import其它不可见的头文件,否则仍然编译不过。
@class
另一种引用方式是@class XXX.h
。这种引用不会把头文件引入,它只是一种前向声明,只是告诉编译器有这么一个类,然后就可以在代码中使用引用这个类了,但是能知道的仅仅是这个类名。更神奇的是由于只是作为一个符号引用,编译器根本不会对它做任何检查,即使是一个根本不存在类一样可以。
它一般用在有两个类互相都需要引用对方,或者是Protocol写在头文件之前时这样,需要知道到对方的符号,而并不想import对方。因为如果循环引用可能导致编译不过,因为在查找符号时,编译器会发现彼此都需要对方,而不知道到底该先编译哪一个文件了。但是循环#import
一定会导致编译问题吗?答案是不一定,如果仅仅只是引用了头文件而没有在代码中引用具体符号,唯一的影响只是白白做了预处理时的头文件展开。而一旦引用到了具体符号,则编译器必须要编译引入的文件以查找符号。那么就必然产生鸡生蛋蛋生鸡的问题。
所以使用的@class
引入的好处:1是可以避免头文件引入混乱,比如A,B两个文件互相引用了对方,并在代码中使用到了对方,则会导致编译器提示某个文件不存在,因为这时候互相依赖导致编译器不知道要先编译哪个对象,此时两个源文件都没有目标文件。2:减少预处理时间,比如A.h中有一个#import B.h
,则如果C.h文件import了A.h就会潜在的把B.h也import进来,而@class 仍然只是插入一个前向声明而已,可以看到预处理之后的文件也仅仅只是插入了一行@class XXXX
, 类似一个助记符。
这里涉及到我们的一个编程规范,尽量减少暴露头文件,尽量在头文件中使用@class,而在.m中才#import头文件。对于有些不想Public出去的属性和方法,可以利用extension来处理,例如拉出一个A_Private.h来提供某些类使用。
@import Framework
还有一种比较少见的引用方式为@import framework
。首先可以首先可以看下Build Setting
里有个LinkFrameworks Automatically
默认是Yes的,这也是为啥我们把一个framework导入工程时会自动在Build Phases
中进行Link,而Enable Modules
选项可以允许我们通过@import来引入framework。
根据WWDC2013的描述,利用这种比#import
更加安全效率更高,因为对于import仍然还是简单的递归的拷贝头文件,而@import
使用把framework作为modules的方式进行自动链接,仅仅在代码中真正使用了对应的framework
中的文件时才会进行import。而且对于第三方也可以通过这种方式进行引入,当前仅仅引入才会触发自动动态链接。
关于Modules是什么可以通过这篇文章进行了解。我们可以在动态库的build文件夹中找到module.modulemap
。在通过Xcode构建动态库时会自动打开Defines Module
,自动生成modulemap文件,描述动态库的结构和一个umbrella header
,只需要在umbrella header
中import其他公开头文件即可,并且如果有遗漏的,编译器还会提示warning。可见苹果推荐的还是在umbrella header
。
1 | cat /Users/joey.cao/Desktop/Learning/LLVM/dyld/Demo2/DerivedData/MainProject/Build/Intermediates.noindex/Function1.build/Debug-iphonesimulator/Function1.build/module.modulemap |
关于它是怎么实现的,仍然是类似Hmap
一样的优化,通过特定的文件把把所有的framework的头文件分成一个个module写入ModuleCache.noidex文件下。打开DerivedData文件下ModuleCache.noindex
。这里存放都是预编译module文件,可以看到工程中用到的系统的标准库文件,包括引入DJISDK.framework也有一个对应的.pcm文件。虽然它们是data文件,比较幸运的是,我们用hexdump
竟然可以看到其中的一些字符:
1 | hexdump -C /DerivedData/ModuleCache.noindex/3SRZO6DXX9MMT/DJISDK-LFXKH73HE9T0.pcm |
至少可以看到它内部存储了Framework的所有public头文件,后面应该是Framework的二进制文件。那么@import module时就可以找到指定的头文件。而且由于这些文件放在Derived文件下,因此多次编译都会公用这些.pcm文件,而提高预编译的效率。同样在Intermediates.noindex
中也会存放一些编译的中间产物,比如hmap,目标文件文件,目标文件的clang诊断信息等以提供重复使用。
正是由于编译cache,clang才能做到快速的增量编译,仅仅编译发生修改的文件,但是当一个头文件被修改时所有import它或者间接import它的文件都需要重新编译。因此尽量减少在头文件进行修改,尽可能少的在头文件暴露信息,或是利用extension创建Private头文件减少import的数量,可以有效减少编译时间。
库文件引用
我们在工程中创建一个Frameworks文件,然后拖入一个framework。然后可以发现Link Binary With Libraries
中多出了这个framework,Frameworks Search Paths
中也自动添加了这个Frameworks目录的路径。这时我们就可以直接引用库里的头文件。但是当我们想运行时,咔,会报错image not found
1 | Referenced from: /Users/joey.cao/Library/Developer/CoreSimulator/Devices/235EABB7-445A-4D9E-A268-EDB7ADD28846/data/Containers/Bundle/Application/5B95AE34-9A8D-4DB8-97F8-A4579D71759B/TestLink.app/TestLink |
原因看起来很简单找不到引入的DJISDK这个库文件,但是我们命名已经Link了这个动态库啊。由于DJISDK.framework是动态库,只有在代码中使用到它才会在运行时通过动态链接器dyld进行动态链接。我们打开生成程序bundle,看到它的内部并没有DJISDK.framework,而运行时程序访问的只有xxx.app这个bundle里的资源和系统标准库中的资源,所以由于动态库根本没有拷贝进程序bundle,所以查找不到。此时只需要把第三方引入的动态库选中Embed and Sign
即可。这是个很重要的点,尤其是在使用Cmake等工具进行OC工程的构建时一定要注意>
当我们Embed之后,再次build可以看到,首先会在.app这个bundle下构建一个frameworks的文件,在编译完成之后会把embed的库文件拷贝过去,并进行重签名,正是由于签名机制导致不能在运行时动态的加载包含代码的动态库,但是可以加载只含有资源的bundle文件。动态库在运行时使用到动态库时才进行链接,因此不会出现编译时的符号冲突。由于动态库不参与编译,所以有改动时不需要引用的工程重新编译。当然缺点就是可能会产生可怕的运行时崩溃,因此一定要控制源代码和依赖的动态库的库版本对齐。
库文件的搜索路径在Frameworks Search Paths
中,当出现库文件找不到时,可以检查是不是路径没有添加。
当提示库文件里Undefined Symbols
问题,如果确保库文件已经embed,应该检查一下库文件的Fat file
或者thin file
是否当前运行环境的架构。
如果库文件工程源码在同一个workspace下,或者嵌在同一个工程中,我们甚至还可以通过修改header search path
或者直接添加一个头文件或头文件引用的方式直接调用库里没有Public的代码。
现在回到问题3:
如果同时使用#import <XXXFramework/AAA.h>
和#import BBB.h
会导致问题吗?答案是,会导致重复定义。
样例如下,Function1和Method1都是Function1这个framework下的文件,通过直接拉头文件引用来#import Method1.h
,通过库引用得到方式来引用Function1:
结果编译会出错,重复定义了Method1这个类,为了避免因为Function1.h
是Umbrella header
的原因,我们改成另一个header,发现依然还是一样的问题:
首先这种引用方式当然是不合理的,建议要么都用 “”, 要么都用<>。但是奇怪的是如果我们把#import Method1.h
这一句放在上面就不会报错,这里我们首先要理解在#import
的时候Clang做了什么,由于Function1是一个动态库,所以在构建时创建了Modules。当我们通过import <Function1/xxx.h>
方式import时就会直接把module引入。而在#import "Method1.h"
只是添加hmap并查找这个符号。作为证据我们删除掉DerviedData,并删掉import <Function1/xxx.h>
这一句。重新编译,可以看到ModuleCache里已经没有Function1了。
而当我们把import <Function1/xxx.h>
加上时在编译:
可见只有#import <>
的方式才会产生modulecache。因此当再次import ""
时,由于modulecache中已经把Framework的二进制文件缓存起来了,因此提示重复定义。而为什么import ""
放在前面时不会提示这个错误,这个我目前还不知道,只能猜测ModuleCache在链入时跟静态库链接一样,出现重复强符号,只采用首个出现的。
经过分析其实是ModuleCache导致的,所以把Defines Modules
关掉就可以解决,但是这并不是真正的解决之道。
这一切得原因都是import方式不规范导致,只要规范下import方式即可解决。
iOS的链接
库文件的编译
当一个静态库有两个相同命名的类是,是不会出现duplicate symbol
的,我们创建一个Method3
文件和一个Method3_Copy
文件,两者内容是一模一样的,看一下静态库文件编译过程,如下,可以发现它只是通过libtool
把Link file list
中的文件进行了拷贝进行了生成.a文件:
我们通过ar工具查看生成的静态库的成员:
1 | ar -v -t /Users/joey.cao/Desktop/Learning/LLVM/dyld/Demo2/DerivedData/MainProject/Build/Products/Debug-iphonesimulator/libFunction3.a |
可以看到内部仅仅只有一个符号表和直接拷贝进来的源码编译出的目标文件。然后我们查看.a文件的符号如下:
1 | nm -nm /Users/joey.cao/Desktop/Learning/LLVM/dyld/Demo2/DerivedData/MainProject/Build/Products/Debug-iphonesimulator/libFunction3.a |
可以看到符号表也是分开的,即没有进行链接操作,所以不会提示duplicate symbols
问题。当它被link到一个项目时,会重新拷贝内部这些目标文件并进行链接,可以理解把它再内嵌到目标项目中的。所以一旦静态库发生改变,目标项目也必须重新进行编译。同样的由于静态库不会进行链接,所以引用一些标准库等时还需要主项目手动添加这些库的连接,否则会导致undefined symbols
。
而动态库的编译过程如下,可以看到它确实对所有目标文件进行了链接,在符号表重组时,直接发现了duplicate symbols
的问题:
所以动态库其实是对内部的目标文件进行了链接,形成了一个整体在运行时通过动态连接器链接到目标项目中的,因此动态库重新编译并不会导致目标项目重新编译。
那么当我们把静态库链接到可执行文件中时会出现duplicate symbols
错误码,答案仍然是不会。并且发现我们再调用Mehtod3
的方法时,执行的是Method3
这个类里实现的方法。但是当我们调整一下Function3
这个静态库里编译文件的顺序,把Method3_Copy
放到前面时,可以看到执行的变成了Method3_Copy
里的方法。此时我们查看一下Function3
这个镜头库的构成:
1 | nm -nm /Users/joey.cao/Desktop/Learning/LLVM/dyld/Demo2/DerivedData/MainProject/Build/Products/Debug-iphonesimulator/libFunction3.a |
所以可以推断,编译文件时会按顺序写入LinkFileList
,而静态库依据LinkFileList
把目标文件拷贝进静态库,再进行和主项目的链接。而出现重复符号时只会依据第一个强符号来作为唯一符号进行处理。所以才会出现这样的问题。
库文件的链接
现在回到问题6:
根据上面的结论可以知道当Clang在链接库文件时遇到主项目和库文件符号出现重复或者库本身有重复符号时,不会出现duplicate symbols
错误,而是根据遇到的第一个强符号来处理,并且由于优先编译主工程内部文件,因此主工程中的符号将会优先被加入到全局符号表中,因此执行出来的反而是主工程的方法。
我们在主项目中创建一个链接的静态库有相同类Method3
,然后运行,结果正常运行,但是在通过输出可以看到,正在执行的却是我们新添加的Method3
中的方法。
1 | objc[9953]: Class Method1 is implemented in both /Users/joey.cao/Desktop/Learning/LLVM/dyld/Demo2/DerivedData/MainProject/Build/Products/Debug-iphonesimulator/Function1.framework/Function1 (0x1077fd100) and /Users/joey.cao/Library/Developer/CoreSimulator/Devices/235EABB7-445A-4D9E-A268-EDB7ADD28846/data/Containers/Bundle/Application/073E9CF2-4B5C-4C71-819C-B11196B76B09/TestLink.app/TestLink (0x1074e38b8). One of the two will be used. Which one is undefined. |
好在Xcode帮我们指出了有重复方法声明,但是可惜的是通过它并不能定位出到底是哪个方法发生重复了。更可怕的是当库文件本身发生符号冲突时,一点警告都没有。那么当我们引用的第三方库中符号发生冲突,则可能导致方法执行错误而我们不自知的情况,尤其是不同的第三方库使用同一个库的不同版本,可能导致运行时奇怪的崩溃,甚至完全代码跑偏。
如果这些库文件都是开源的,那么还可以通过脚本对依赖的库进行比对,从而找到不同的地方,并根据实际情况进行选择。比如之前MSDK在进行跨平台开发时,发现有一些文件在*nux
系统和安卓系统上时不一样的,可以通过预编译宏进行控制,更麻烦的可能还需要手动添加一些实现来弥补平台的缺失。
而对于闭源的第三方库,我们知道.a只是一个目标文件集合,因此我们可以通过ar把指定的不兼容的目标文件删除掉,具体实现可以参照这篇文章。
为了避免这些问题,作为SDK开发时,或者封装库文件时一定要指定好依赖的第三方库文件的版本。或是通过给文件加前缀来避免符号重复,就好像加了命名空间一样。当然还可以把依赖的库文件分离出来,让用户进行引入,例如DJIWidget
中使用FFMpeg
,避免多份库文件的存在。
库文件多层链接
回到问题8:
库文件可以多层嵌套吗,答案是可以的。
我们测试如下,主工程Link一个动/静态库,静态库再Link一个静态库,静态库再Link一个静态库。然后依次链式调用下一级静态库里的方法,可以发现可以正常调用。
由于的工程是4个工程平铺的,甚至可以在任意一个层级的工程中拉一个另一个低层级库中头文件的引用,以实现直接跨层级的调用。
1 | 2019-12-01 20:42:24.097954+0800 TestLink[43537:9645427] __+[Method1 sum:b:] |
上面在中文log之前,是在主工程依次按层级调用到了四层Link的库。之后是在第一层的动态库直接调用第3层的动态库。
所以无论link多少层静态库,本质上都是在运行时把所有的目标文件的符号整合到一起,通过符号找到对应的机器指令,因此只要符号找得到就能调用到。
但是问题又来了,能否从层级3或者4调用到层级2中方法?其实是不可以的,因为这样会导致互相依赖。
链接选项
回到问题9:
在Build Setting
中有一个选项为Other Link Flag
,很多人碰到过引用第三方库中有Category时发生Unrecongnized Selector
崩溃,都知道可以通过添加-Objc
来进行解决,那么这里到底是干了什么呢?
查看编译log可以看到,添加-Objc
之后会在为Clang添加-Objc
这个编译选项,同理添加-all_load
, -force_load
都只是链接器添加一个选项而已。因为编译器为了避免生成的可执行文件太大,默认只连接Class,c, C++相关的目标文件,而Category和一些未使用的文件符号都会被Strip掉。因此第三方库虽然存在Category的符号,但是链接时被Strip掉了,因此会出现Unrecongnized Selector
。而-all_load
则是把库文件所有的目标文件以及所有依赖的第三方库全部链接,比较常见的场景是使用静态库时,发现找不到符号,需要添加一些第三方库,网上有些回答添加-all_load
。当然这样可以解决,但是带来的问题也很明显,它会导致所有的库文件都执行这样的操作,会导致可执行文件的增大,并且如果两个静态库中的目标文件有相同的符号就会导致重复符号错误。另一种解决方法就是-force_load
,指定某一个第三方库进行all_load。 这里还有一个重要的应用场景是, 动态库会丢弃内部没有使用的其它静态库文件或者是静态库文件的Object对象,可能会导致运行时崩溃,为了解决就得使其all_load。
还有一个选项-dead_strip
,它可以把使用不到的代码,block,以及目标文件进行strip,可以参见苹果的release note。
模块化管理工程
工程变庞大之后,可以考虑按功能模块,或者架构层级把子模块编译成库文件,然后让主工程依赖它,以根据需求按需加载模块。而且可以针对子模块的库文件构建独立的单元测试项或是测试界面。
一种解决方式是通过脚本来进行管理工程,通过脚本独立的仓库依次拉下来,然后构建一个workspace,再手动去添加依赖关系。然后在主工程中有一个位置存放各个版本依赖的各个仓库的分支或者是标签。只需要根据切换指定版本之后,在依据保存的此时的各个仓库情况利用脚本一次仓库切换分支即可实现模块化的管理。
另一种方式是利用cocoapods进行维护。cocoapods可以帮我们拉取代码,并创建workspace,只需要构建合适的Podfile即可利用cocoapods帮我们管理依赖关系。
OC类与分类的编译
这里我们先回到问题4, 5:
1.如果编译时一个Class的文件没有被加入到指定的target,则只要这个文件被别的地方引用了一定会导致编译不过,因为符号找不到。但是如果是Category的话则可以正常编过。
2.如果一个方法没有被定义,即使被其他地方调用,仍然可以正常编译,只是会在运行时崩溃。
我们以一个例子来分析为什么:
首先我们只为test1添加定义。build一下之后去derivedData中/Build/Intermediates.noindex/TestLink.build/Debug-iphonesimulator/TestLink.build/Objects-normal/x86_64
文件就下寻找编译的中间产物:
首先我们查看Test.o的符号:
1 | nm -nm /Users/joey.cao/Desktop/Learning/LLVM/dyld/Demo2/TestLink/DerivedData/TestLink/Build/Intermediates.noindex/TestLink.build/Debug-iphonesimulator/TestLink.build/Objects-normal/x86_64/Test.o |
不意外数据段存放了class,metaclass和私有的Ivar。 而程序段也有Property自动生成的getter和setter。但是需要注意的是:这些方法并不是external的,前面我们说了external表示的是其可见性,这里表明了这些方法其实外部是并不可见的。说明在链接的时候并不会链接类的方法的符号,只有类名作为external的符号参与链接,因此即使没有为方法添加定义仍然可以正常编译。
但是class文件仍然是external的,因此如果被外部引用到了,但是没有参与编译直接会报错 Undefined symbols _OBJC_CLASS_$_Test
。而我们查看一下Test的分类的符号:
1 | nm -nm /Users/joey.cao/Desktop/Learning/LLVM/dyld/Demo2/TestLink/DerivedData/TestLink/Build/Intermediates.noindex/TestLink.build/Debug-iphonesimulator/TestLink.build/Objects-normal/x86_64/Test+test.o |
可以惊奇的发现它是没有类符号的,同样可以看到添加的属性都生成了Properlist,但是却没有生成对应Ivars, 也没有getter和setter。因此我们可以分类添加的property属性列表里看到有这些属性,但是无法get, set。
分类并不是类,他没有特有的类符号,所以即使import了Category, 并调用了其中的方法,也不会导致编译不过,因为他没有一个符号是外部可见的。
而这一切都是由于OC的Runtime机制,它只会对类名符号进行链接,而所有的method都是non-external
的,只是保存在method_list
中,通过msg_send
进行调用。因此即使没有对方法进行定义,或者是分类没有参与编译都不会导致链接失败,只是数据段的method_list缺失相应的方法而导致运行时崩溃。
关于编译出的Mach-O文件具体代表什么,可以参考这边文章。
参考资料
1.程序员的自我修养(https://item.jd.com/10067200.html)
2.https://opensource.apple.com/source/cctools/cctools-622.5.1/RelNotes/CompilerTools.html?txt
3.https://samsymons.com/blog/understanding-objective-c-modules/
6.https://forums.developer.apple.com/thread/98369
7.https://lief.quarkslab.com/doc/latest/tutorials/11_macho_modification.html