啰嗦一下


模块化开发不光要求代码级的模块化,比如区分各种功能,然后把功能的实现分散在各个对象或文件。大部分情况也要求部署级的模块化,使之能够通过使用库的方式模块化加载,模块化部署。在这一波"模块化"大潮中,各种不同类型的库被广泛使用着。如果你希望你的程序能够支持插件功能,其本质也依旧是通过库来实现。

其实库的本质是一堆函数实现的集合,他们被编译在某一个文件里,然后根据使用方式的不同,分为静态库动态库两类。在动态库里,又分为动态链接库动态加载库




静态库 vs 动态库


一般来说,build一个项目的过程是先compile然后再link,然后才有一个可执行文件。link的时候要做的一件事情就是把各种函数符号转换成函数调用地址,然后最终生成的可执行文件就能够直接调用到函数了。

静态库是在build的时候就把库里面的代码链接进可执行文件

动态库的做法跟静态库的做法不一样,不会在build的时候就把代码link进可执行文件。然而对于两种不同的动态库而言,它们又有所区别:


  • 对于动态链接库而言,build可执行文件的时候需要指定它依赖哪些库,当可执行文件运行时,如果操作系统没有加载过这些库,那就会把这些库随着可执行文件的加载而加载进内存中,供可执行程序运行。如果多个可执行文件依赖同一个动态链接库,那么内存中只会有一份动态链接库的代码,然后把它共享给所有相关可执行文件的进程使用,所以它也叫共享库(shared library)。比如pthread就是一个这样的库。

  • 对于动态加载库而言,build可执行文件的时候就不需要指定它依赖哪些库,当可执行文件运行时,如果需要加载某个库,就用dlopendlsymdlclose等函数来动态地把库加载到内存,并调用库里面的函数。各大软件的插件模块基本上就都是这样的库。事实上,静态库和动态链接库也可以被动态加载,只是由于使用方式的不同,才多了一个动态加载库这样的类别。



他们的区别说白了就是加载库的时机,动态链接库在可执行文件得到运行的时候就加载,动态加载库在可执行文件运行期间的任何一个阶段都可以动态加载。大部分情况业界推荐使用动态库,至于动态链接还是动态加载,那就可以根据具体需要来。推荐更多使用动态库的原因如下:


  • 在静态库方案下,库的版本更新之后,需要重新编译程序,才能使得更新后的代码起作用。而动态库只需要编译对应的库代码,然后重启程序即可。
  • 使用动态库能够减少可执行文件的体积,因为公共功能被独立了出来,使得每个相关可执行文件不必把这部分代码也编入,从而减小了体积。


但是使用动态库也会带来一个缺点,那就是debug的时候特么超级麻烦。我个人是喜欢在debug的时候不使用库方案来生成可执行文件去debug,直接用.o文件链接生成可执行文件。部署的时候才会使用动态链接方案生成可执行文件。这两者的区别就只是几条编译指令的区别而已,在makefile里面写好就好了。




孔乙己


我在翻阅各种资料的时候,发现关于各种库的术语有特别多,我觉得有必要在这里做个辨析,如果大家都已经熟悉了那就可以跳过这一节。

关于常见的DLL,有的时候大家会把它理解成Dynamic Linked Library(动态链接库),也有的时候会把它理解成Dynamic Load Library(动态加载库),这个就要自己看上下文去区分了。

关于Shared Library,那就是指Dynamic Load Libraryso(Shared Object)也是Dynamic Load Library

Linux/Unix下动态库文件的后缀名大多是sosdylib。静态库文件后缀名多数是a。windows我不熟悉也不知道也不愿意去查。





准备工作


会写C会写编译命令。





静态库


```
    clang -c log.c -o log.o
    clang -c memory.c -o memory.o
    ar rcs libmylib.a log.o memory.o
```

静态库本质上就是一堆函数的集合,所以把相关文件编译成.o文件之后,就可以使用ar来把这些文件集合成.a文件。ar rcs libmylib.a log.o. memory.o的意思就是把log.omemory.o塞进libmylib.a里面,事实上一个静态库就是各种.o文件的集合。

当你生成好libmylib.a之后,就可以把它加进去了:

```
    clang -o demo.run demo.c -lmylib -L/your/lib/path
    or
    clang -o demo.run demo.c /path/to/your/static/library.a
```



要注意的地方:


  • 要是不加-L来添加库的搜索路径的话,会找不到你的静态库。因为编译器会跑去系统默认的路径去找,然而默认路径并不包括你的当前目录。
  • -l-L要放在demo.c的后面,否则就会报莫名其妙的错误。
  • -l参数会自动帮你在前面补上lib,在后面补上.a
  • 你要是直接用ld来链接也可以,但是ld的参数接口经常会变,所以还是让编译器去链接吧。


其实使用静态库的场合不多,大多数时候是作为第三方提供SDK给别人,但又不希望别人看到源代码,才会用静态库交付。但是在iOS开发领域,静态库用得还是蛮多的,当一个app是由很多业务线组成的时候,最好还是交付静态库而不是代码,这样能节省很多编译的时间。





动态链接库


动态链接库是动态库的一种(先这么区分吧,因为静态库也能动态加载),我们也习惯叫它共享库(Shared Library),当程序加载进内存的时候,动态加载库也会跟着被加载进内存。当动态加载库加载到内存之后,如果后面的程序也起来了,而且也依赖这个动态加载库的话,就不会重复加载。

动态链接库相对于静态库来说更加灵活和复杂,因为在实际应用的时候对动态链接库会有以下要求:


  • 更新动态库之后,依然需要支持那些需要依赖旧版本动态库的程序的正常运行。
  • 当程序运行的时候,需要允许覆盖特定的库,甚至特定的函数。
  • 在程序使用现有库运行时,依然能够需要支持以上两点。


为了达到以上目的,业界制定了一套规范,这套规范主要从两个方面着手:


  • 命名规范。一个动态库会有不同的名字,他们分别起到了不同的作用。
  • 路径规范。一个动态库要放在特定路径下,内核才能够在加载的时候去这个特定路径找到这个动态链接库。




命名规范


一个库有三个分别起到不同作用的名字:sonamereal namelink name。在Mac OSX系统下,soname又被称为install_name



soname


其实就是Shared Object NAME,在MAC OSX下是install_name

这个名字的规范就是lib+库名+so+大版本号,它是用来标示动态链接库的主版本的,用于给内核加载动态库选择版本时提供参考。soname是在编译的时候传递给链接器的,如果你不在编译命令里面设置soname的话,默认会用生成的文件名作为soname,这不是个好的做法,原则上都是要设置一下soname的。编译之后,soname就会被写入到你的库文件里面去,你用vim直接打开库文件就能看到这个库的soname,它跟你ls时候看到的文件名不是同一个东西。

另外,可执行文件具体依赖哪样的库也是在编译的时候被编译进可执行文件的,编译命令会找到对应的库,并把这个库的soname转存到可执行文件的依赖列表中。

举个例子:你有一个库,库名叫做casa,然后当前版本号是1,那么它的soname就应该是libcasa.so.1

soname是怎么起作用的呢,先不着急,我会在实例的地方讲这个。



real name


其实就是真实的文件名,命名规范就是soname+小版本号。它是你动态库的文件名,也就是你ls的时候看到的那个。以上面的libcasa.so.1为例,它的real name就是soname再加小版本号,可以是libcasa.so.1.0或者libcasa.so.1.1这样。

它的具体作用就是让内核加载动态库时使用的名字,所以必须是文件名。



link name


我们在build一个可执行文件的时候,需要在可执行文件里面记录这个可执行文件依赖于哪些动态库,这样内核在加载可执行文件的时候,才知道有哪些动态库需要加载。在写这条编译命令的时候,是不需要带版本号的。

还是举soname是libcasa.so.1的库为例子,它的link name就是libcasa.so。具体编译指令长这样:

```
    clang -o demo.run -c demo.c -L./ -lcasa
```

因为有命名规范,所以链接器会自动在前面加上lib,在后面加上.so,这样我们就只要给出库名字casa就好了。




路径规范


由于动态加载库是动态加载的,当一个可执行文件被执行的时候,系统就要能够找得到它依赖的动态库在哪里。一般情况下,GNU标准要求系统去/usr/local/lib这个地方找。在FHS(Filesystem Hierarchy Standard)中,对这个又做了很多规定:大多数内核依赖的动态库应该被放在/usr/lib下,在内核启动时就依赖的动态库应该放在/lib下,然后不属于内核依赖的动态库才放在/usr/local/lib下。

这两个规范其实不冲突,我们按照最详细的那个来就好了,FHS。




生成shared library和实例


demo.c:

#include "stdio.h"
#include "libcasa.h"

int main(int argc, char *argv[]) {
    return print_a_message("hello world");
}



libcasa.c:

#include "libcasa.h"
int print_a_message(const char *data) {
    int i = 0;

    printf("here i am\n\n");

    for (i = 0; data[i] != '\0'; i++) {
        printf("%c", data[i]);
    }

    printf("\n");

    return 0;
}



libcasa.h:

#include "stdio.h"
int print_a_message(const char *data);



makefile:

shared:
    clang -Wall -fPIC -c libcasa.c -o libcasa.o
    clang -shared -Wl,-install_name,libcasa.so.1 -o libcasa.so.1.0 libcasa.o
    ln -sf libcasa.so.1.0 libcasa.so.1
    ln -sf libcasa.so.1.0 libcasa.so
    clang -Wall -fPIC -g demo.c -o demo.run -lcasa -L./

static:
    clang -c libcasa.c -o libcasa.o
    ar rcs libcasa.a libcasa.o
    clang -o demo.run demo.c libcasa.a

clean:
    rm -rf *.o *.dSYM *.run *.a *.so *.so.1 *.so.1.0



写好这些文件之后,在命令行输入make shared就会生成基于动态链接库的可执行文件demo.run。输入make static就会生成基于静态库的可执行文件demo.run

你会发现,决定一个库是静态还是动态,其实就仅仅取决于它的编译指令的不同。下面我着重解释一下动态库的编译指令。



clang -Wall -fPIC -c libcasa.c libcasa.o


  • -fPIC:这是一个编译器指令,让编译器生成定位无关的代码,具体的定位操作由可执行文件加载时再重定位。要编译动态链接库,必须要有这个参数。-fpic也可以用,但这两者不等价,-fpic生成的文件会比-fPIC小一些,但牺牲了平台兼容性。所以用-fPIC生成的文件大就大一点了,比较保险。



clang -shared -Wl,-install_name,libcasa.so.1 -o libcasa.so.1.0 libcasa.o


  • -shared:告诉编译器你要生成的是一个sharde library,也就是动态加载库。
  • -Wl,install_name,libcasa.so.1:这里就是给你的动态库设置soname的地方。-Wl表示后面跟的字符串是传递给连接器的参数,设置了install_namelibcasa.so.1,这将会出现在编译之后的库文件里面。因为我当前使用的是Mac,如果你用的是Linux的话,就应该是-Wl,soname,libcasa.so.1。对的,install_name就是soname,不同的系统不同而已。编译完之后,你可以使用vim直接打开libcasa.so.1.0,你会找到libcasa.so.1这个字样。如果你不传递这个参数也能编译通过,它就会把文件名作为soname。但是原则上不能省略这个soname的配置,它会引起库版本管理错乱。
  • -o libcasa.so.1.0:这就是动态库的real name,表示生成出来的库的文件名为libcasa.so.1.0,但它的soname是libcasa.so.1哦。
  • ln -sf libcasa.so.1.0 libcasa.so.1:这里做了一个符号链接,把soname跟real name做了关联。这样在程序运行要加载动态库的时候,它会去加载libcasa.so.1,由于有了符号链接,实际上内核会加载libcasa.so.1.0,以后如果有更新的版本,但soname还是没变的话,内核就能自动加载最新版本了。
  • -sf libcasa.so.1.0 libcasa.so:这里做了一个符号链接,把link name跟real name做了关联。这样在后面的-lcasa的时候,就会按照命名规范组装成libcasa.so,然后找到libcasa.so做链接。



clang -Wall -fPIC -g demo.c -o demo.run -lcasa -L./

  • -fPIC:这里的-fPIC其实不写也可以,但是GCC4.5的手册要求你最好还是写上,具体原因倒是没细说。
  • -lcasa:编译器会通过这条指令组装动态库的link name,在前面加上lib,在后面加上.so,最终形成libcasa.so,编译器就会根据这个名字找到这个库,然后把这个动态库的soname读取出来,加到可执行文件的依赖列表里面去。
  • -L./:告诉编译器搜索动态库的地址在当前目录



这时我们终于可以强调一下soname的规范了:


  • 如果你更新的动态库内容变化并没有添加或删除API,只是修改了API的实现,那么soname可以不用变,只要改变你的动态库的real name就好。然后把对应soname做符号链接到你的新版本动态库中,下次启动这个可执行文件时,内核就会加载到最新的动态库了。
  • 如果你更新的动态库里面有添加新的API,可以再在soname后面添加一个小版本号,soname就可以变成libcasa.so.1.1。即使原来的可执行文件还是依赖于libcasa.so.1,那也不影响使用。
  • 如果你更新的动态库已经不兼容就版本了,那么soname后面的数字就要改改了,比如改成libcasa.so.2

所以你一定要给你的动态库在编译的时候设置soname,不然soname就跟着文件名走了,到后面涉及版本管理的时候你就坑了。

MAC下直接跑生成成功的demo.run没问题,Linux下要做一些额外的操作,具体看下面的小贴士。




小贴士


  • Linux下make shared之后要想正常运行demo.run,还需要一些额外的操作


你需要把你的libcasa.so.1(real name)拷贝到/usr/local/bin下面,然后运行一次ldconfig。ldconfig帮你建立了一些必要的符号链接,刷新了你的ld.so.cache,然后你再跑demo.run就没问题了。

另外,我习惯在/usr/local/lib里为单独的一套库创建新的文件夹来存放,比如我会新建一个libcasa这个文件夹,把跟libcasa相关的库都放到这里面去。这么做的话,在install的时候就需要在/etc/ld.so.conf里面添加一行/usr/local/lib/libcasa,然后再跑ldconfig了。这么做的好处就是/usr/local/lib看上去很干净。

因为去不同的路径下面找各自的动态库很影响效率,所以Linux专门有这个cache来记录动态库都在哪儿,这样加载的时候就快了。发展到后面就变成,你不建立这个cache,内核干脆就不找了,直接跟你说没这个动态库。



  • 在Linux下使用ldd命令能够看出一个可执行文件它依赖于哪些动态库,在Mac下使用otool -L来查看:


首先是忠告:你不要拿ldd去看你不信任的可执行文件,因为它会执行这个可执行文件。

这里是采用动态链接库方案编译的结果:

 $  make shared 
    clang -Wall -fPIC -c libcasa.c -o libcasa.o
    clang -shared -Wl,-install_name,libcasa.so.1 -o libcasa.so.1.0 libcasa.o
    ln -sf libcasa.so.1.0 libcasa.so.1
    ln -sf libcasa.so.1.0 libcasa.so
    clang -Wall -fPIC -g demo.c -o demo.run -lcasa -L./

 $  otool -L demo.run 
    demo.run:
    libcasa.so.1 (compatibility version 0.0.0, current version 0.0.0)
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1213.0.0)

可以看到demo.run这个可执行文件依赖于libcasa.so.1这个soname的库。下面是采用静态库方案编译的结果:

 $  make static 
    clang -c libcasa.c -o libcasa.o
    ar rcs libcasa.a libcasa.o
    clang -o demo.run demo.c libcasa.a
 $  otool -L demo.run 
    demo.run:
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1213.0.0)

可以看到此时demo.run这个可执行文件并不依赖于libcasa.so这个动态库,因为它已经被静态编译进这个可执行文件了。



  • 使用nm能够看到一个库里面都有些啥。


    ➜ $  nm libcasa.so.1.0 
        0000000000000ed0 T _print_a_message
                         U _printf
                         U dyld_stub_binder



  • 使用/etc/ld.so.preload来覆盖一些函数


你可以把针对某个库中某些函数的实现编译成.o文件,填进去。不过我试了几次没有成功,这种做法一般是在调试bug的时候会用到,发行出去的动态库要杜绝这种做法。





动态加载库


动态加载库(Dynamic Load Library)是在程序运行时,由程序自己调用系统API去加载的,而不是内核加载程序时顺便加载的。本质上跟静态库和动态链接库没什么区别,只是使用方式有所不同而已。由于可以由程序自主调用,这种方案使得插件机制和动态模块机制的实现成为了可能。



涉及的API


#include <dlfcn.h>

void* dlopen(const char* path, int mode);
void* dlsym(void* handle, const char* symbol);
int dlclose(void* handle);
char* dlerror(void);

Link with -ldl

大致流程就是先dlopen一个库,然后用dlsym从这个库里面根据函数名找到对应的函数,然后返回一个指向这个函数的指针,拿到这个指针直接调用就好了。库用完之后调用dlclose关闭就可以。记得要在编译可执行文件时添加一个-ldl参数,其他就没什么了。

man里面写得非常好,还有用例,我就不搬运了。





结束


这篇文章主要讲了如何去实现不同类型的库,IBM的知识库也有一篇类似的文章着重介绍了shared library,这篇文章的文末提供了一些其他文章,那里有很多更深的内容,推荐大家去看一下。









评论系统我用的是Disqus,不定期被墙。所以如果你看到文章下面没有加载出评论列表,翻个墙就有了。




本文遵守CC-BY。

请保持转载后文章内容的完整,以及文章出处。本人保留所有版权相关权利

如果您觉得文章有价值,可以通过支付宝扫描下面的二维码捐助我。


Comments

comments powered by Disqus