楔子
引入 C 源文件我们已经知道该怎么做了,但如果引入的不是源文件,而是已经存在的静态库或者动态库该怎么办呢?C 语言发展到现在已经拥有非常多成熟的库了,我们可以直接拿来用,这些库可以是静态库、也可以是动态库,这个时候 Cython 要如何和它们勾搭在一起呢?
要搞清楚这一点,我们需要先了解静态库和动态库。并且一个 C 源文件可以被编译成一个可执行文件,那么我们还要先搞清楚在将 C 源文件编译成可执行文件的时候,静态库和动态库是如何起作用的。所以暂时不提 Cython,先来说一下静态库和动态库。
C 的编译过程
假设有一个 C 源文件 main.c,只需要通过 gcc main.c -o main.exe 即可编译生成可执行文件( 如果只写 gcc main.c,那么 Windows 上会默认生成 a.exe、Linux 上会默认生成 a.out ),但是这一步可以拆解成如下步骤:
- 预处理:gcc -E main.c -o main.i,根据 C 源文件得到预处理之后的文件,这一步只是对 main.c 进行了预处理:比如宏定义展开、头文件展开、条件编译等等,同时将代码中的注释删除,注意:这里并不会检查语法;
- 编译:gcc -S main.i -o main.s,将预处理后的文件进行编译、生成汇编文件,这一步会进行语法检测、变量的内存分配等等;
- 汇编:gcc -c main.s -o main.o,根据汇编文件生成目标文件,当然我们也可以通过 gcc -c main.c -o main.o 直接通过 C 源文件得到目标文件;
- 链接:gcc main.o -o main.exe,程序是需要依赖各种库的,可以是静态库也可以是动态库,因此需要将目标文件和其引用的库链接在一起,最终才能构成可执行的二进制文件;
从 C 源文件到可执行文件会经历以上几步,不过我们一般都会将这几步组合起来,整体称之为编译。比如我们常说,将某个源文件编译成可执行文件。
而静态库和动态库是在链接这一步发生的,比如我们在 main.c 中引入了 stdio.h 这个头文件,里面的函数( 比如 printf )不是我们自己实现的,所以在编译成可执行文件的时候还需要将其链接进去。
所以静态库和动态库的作用都是一样的,都是和汇编生成的目标文件( .o 文件)搅和在一起,共同组合生成可执行文件。那么它们之间有什么区别呢?下面就来介绍一下。
在 Windows 上静态库是以 .lib 结尾、动态库是以 .dll 结尾;在 Linux 上静态库则以 .a 结尾、动态库以 .so 结尾。而动态库的生成,两个系统没太大区别,但生成静态库,Windows 系统要麻烦一些。考虑到生产上大部分都是 Linux 系统,并且动态库的使用频率更高,所以这里只以 Linux 系统为例。
静态库
一个静态库可以简单看成是一组目标文件的集合,也就是多个目标文件经过压缩打包之后形成的文件。而静态库最大的特点就是一旦链接成功,那么就可以删掉了,因为它已经链接到生成的可执行文件中了。所以从侧面也可以看出使用静态库会比较浪费空间和资源,说白了就是生成的可执行文件会比较大,因为里面还包含了静态库。
而在 Linux 中静态库是有命名规范的,必须以 lib 开头、.a 结尾。假设你想生成一个名为 hello 的静态库,那么它的文件名就必须是 libhello.a,这是一个规范。
在 Linux 中生成静态库的方式如下:
- 先得到目标文件:gcc -c 源文件 -o 目标文件,比如 gcc -c test.c -o test.o。这里要指定 -c 参数,否则生成的就是可执行文件;
- 通过 ar 工具基于目标文件构建静态库:ar rcs libtest.a test.o,此时就得到了静态库 。但我们说在 Linux 中静态库是有格式要求的,必须以 lib 开头、.a 结尾,所以是 libtest.a;
我们来做一个测试,首先是编写一个 C 文件 test.c,里面内容如下:
// 计算 start 到 end 之间所有整数的和 int sum(int start, int end) { int res = 0; for (; start <= end; start++) { res += start; } return res; }
执行命令:
[root@satori ~]# gcc -c test.c -o test.o [root@satori ~]# ar rcs libtest.a test.o [root@satori ~]# ls | grep test. libtest.a test.c test.o
此时 libtest.a 就成功生成了,然后我们再来编写一个 main.c 直接调用:
#include <stdio.h> int sum(int, int); int main() { printf("%d\n", sum(1, 100)); }
我们看到这里只是声明了 sum,但是具体实现则没有编写,因为它已经在 libtest.a 中实现了,我们只需要在使用 gcc 编译的时候指定即可。
[root@satori ~]# gcc main.c -L . -l test -o main [root@satori ~]# ./main 5050
可以看到执行成功了,打印结果也是正确的,但这里需要解释一下里面的参数。首先 gcc main.c 无需解释,表示对 main.c 文件进行编译。而结尾的 -o main 也无需解释,表示生成的可执行文件的文件名叫 main。
中间的 -L . 表示追加库文件的搜索路径,因为 gcc 在寻找库的时候,只会从标准位置进行查找。而当前所在目录明显不属于标准位置,因此需要通过 -L 参数将写好的静态库所在的路径追加进去,libtest.a 位于当前目录,所以是 -L .。
然后是 -l test,首先 -l 表示要链接的静态库(也可以是动态库,后面会说,目前就只看静态库即可),因为当前的静态库名字叫做 libtest.a,那么把开头的 lib 和结尾的 .a 去掉再和 -l 进行组合即可。
如果我们将静态库改名为 libxxx.a 的话,那么就需要指定 -l xxx;同理,要是我们指定的是 -l foo,那么在链接的时候会自动寻找 libfoo.a。所以从这里也能看出,在 Linux 中创建静态库的时候一定要遵循命名规范,以 lib 开头、.a 结尾,否则链接是会失败的。当然追加搜索路径、链接静态库的数量是没有限制的,比如除了 libtest.a 之外还想链接 libfoo.a,那么就指定 -l test -l foo 即可。
注:-l test 也可以写成 -ltest,即中间没有空格,这种写法更为常见。但这里我为了清晰,之间加了一个空格,对于编译而言是没有影响的。
同理还有头文件,虽然这里没有涉及到,但还是需要说一说,因为导入头文件更常见。如果想导入的头文件不在搜索路径中,我们在编译的时候也是需要指定的。假设 main.c 还引入了一个自定义的头文件,其位于当前目录下的 header 目录里,那么编译的时候为了让编译器能够找得到,我们需要通过 -I 来追加相应的头文件的搜索路径:
注:追加头文件的搜索路径使用的是大写的字母 i,当前文章的字体 让人很容易和小写的字母 l 搞混,因此这里专门画张图,并用一个区分度比较明显的字体。
对于头文件搜索路径、库文件搜索路径、引入的静态库的数量,都是没有限制的,可以指定任意个:-I、-L、-l。
动态库
通过静态库,我们算是实现了代码复用,而且静态库的使用也比较方便。那么问题来了,既然有了静态库,为什么我们还要使用动态库呢?
首先是资源浪费,假设有一个静态库大小是 1M,而它被 1000 个可执行程序依赖,那么这个静态库就相当于被拷贝了 1000 份,因为静态库是需要被链接到可执行文件当中的。然后是静态库的更新和部署会带来麻烦,假设静态库更新了,那么所有使用它的应用程序都必须重新编译、然后发布给用户。即使只改动了一小部分,也要重新编译生成可执行文件,因为要重新链接静态库。
而动态库则不同,动态库在链接的时候不会将自身的内容包含在可执行文件中,而是在程序运行的时候动态加载。相当于只是告诉可执行文件:"你的内部会依赖我,但由于我是动态库,因此我不会像静态库一样被包含在你的内部,而是需要你运行的时候再去查找、加载"。所以多个可执行文件可以共享同一个动态库,因此也就避免了空间浪费的问题,并且动态库是程序运行时动态加载的,我们对动态库做一些更新之后可以不用重新编译生成可执行文件。
有优点就自然有缺点,相信都看出来了,既然是动态加载,就意味着即使在编译成可执行文件之后,依赖的动态库也不能丢。和静态库不同,静态库和最终的可执行文件是完全独立的,因为在编译成可执行文件的时候静态库的内容就已经被链接在里面了;而动态库是要被动态加载的,因此它是被可执行文件所依赖的,所以不能丢。
然后我们来生成一下动态库,生成动态库要比生成静态库简单许多:gcc 源文件 -shared -o 动态库文件,还是以之前的 test.c 为例:
gcc test.c -shared -o libtest.so
在 Linux 中,动态库也具有相同的命名规范,只不过它是以 .so 结尾。但是你真的不按照这个格式命名也是可以的,只不过在使用 gcc 的时候会找不到相应的库。因为编译的时候会按照指定格式去查找库文件,所以我们在生成库文件的时候也要按照相同的格式起名字。
Windows 上生成动态库的方式与 Linux 相同,只需把动态库的后缀 .so 换成 .dll 即可。
然后使用 gcc 对之前的 test.c 源文件进行编译:
[root@satori ~]# gcc test.c -shared -o libtest.so [root@satori ~]# ls libtest.so libtest.so [root@satori ~]# gcc main.c -L . -l test -o main1
我们看到可执行文件成功生成了,这里起名为 main1。引入动态库和引入静态库的方式是一样的,因为 -l 既可以链接静态库、也可以链接动态库(要是静态库和动态库都有怎么办?别急,后面说,目前只考虑动态库)。
[root@satori ~]# ./main1 ./main1: error while loading shared libraries: libtest.so: cannot open shared object file: No such file or directory
但是问题来了,虽然编译成功了,但是执行的时候却报错了,说找不到这个 libtest.so,尽管它就在当前可执行文件所在的目录下。
原因是可执行文件在查找动态库的时候也是会从指定的位置进行查找的,而我们当前目录不在搜索范围内。这时候可能有人会好奇,我们不是在编译的时候通过 -L 参数将当前路径追加进去了吗?
答案是动态库和静态库不同,动态库在链接的时候自身不会被包含在可执行文件当中,我们指定的 -L . -l test 相当于只是在链接的时候告诉即将生成的可执行文件:"在当前目录下有一个 libtest.so,它将来会是你的依赖,你赶紧记录一下"。我们可以通过 ldd 命令查看可执行文件依赖的动态库:
[root@satori ~]# ldd main1 linux-vdso.so.1 => (0x00007ffe67379000) libtest.so => not found libc.so.6 => /lib64/libc.so.6 (0x00007f8d89bf9000) /lib64/ld-linux-x86-64.so.2 (0x00007f8d89fc6000) [root@satori ~]#
我们看到 libtest.so 已经被记录下来了,所以链接动态库时只是记录了动态库的信息,当程序执行时再去动态加载,因此它们会有一个指向。但我们发现 libtest.so 指向的是 not found,这是因为动态库 libtest.so 不在动态库查找路径中,所以会指向 not found。
因此我们还需要将当前目录加入到动态库查找路径中,vim /etc/ld.so.conf,将当前目录( 我这里是 /root )写在里面。或者直接 echo "/root" >> /etc/ld.so.conf,然后执行 /sbin/ldconfig 使得修改生效。
最后再来重新执行一下 main1,看看结果如何:
[root@satori ~]# echo "/root" >> /etc/ld.so.conf [root@satori ~]# /sbin/ldconfig [root@satori ~]# [root@satori ~]# ./main1 5050
可以看到此时成功执行了,因此使用动态库实际上会比静态库要麻烦一些,因为静态库在编译的时候就通过 -L 和 -l 参数直接把自身链接到可执行文件中了。而动态库则不是这样,用大白话来说就是,它在链接的时候并没有把自身内容加入到可执行文件中,而是告诉可执行文件自己的信息、然后让其执行时再动态加载。但是加载的时候,为了让可执行文件能加载的到,我们还需要将动态库的路径配置到 /etc/ld.so.conf 中。
[root@satori ~]# ldd main1 linux-vdso.so.1 => (0x00007ffdbc324000) libtest.so => /root/libtest.so (0x00007fccdf5db000) libc.so.6 => /lib64/libc.so.6 (0x00007fccdf20e000) /lib64/ld-linux-x86-64.so.2 (0x00007fccdf7dd000
此时 libtest.so 就指向 /root/libtest.so 了,而不是 not found。虽然麻烦,但是它更省空间,因为此时只需要有一份动态库,如果可执行文件想用的话直接动态加载即可。除此之外,我们说修改了动态库之后,原来的可执行文件不需要重新编译:
int sum(int start, int end) { int res = 0; for (; start <= end; start++) { res += start; } return res + 1; }
这里我们将返回的 res 加上一个 1,然后重新生成动态库:
[root@satori ~]# ./main1 5050 [root@satori ~]# gcc test.c -shared -o libtest.so [root@satori ~]# ./main1 5051
结果变成了 5051,并且我们没有对可执行文件做修改,因为动态库的内容不是嵌入在可执行文件中的,而是可执行文件执行时动态加载的。如果是静态库的话,那么就需要重新编译生成可执行文件了。
同时指定静态库和动态库
无论是静态库 libtest.a 还是动态库 libtest.so,在编译时都是通过 -l test 进行链接的。那如果内部同时存在 libtest.a 和 libtest.so,-l test 是会去链接 libtest.a 还是会去链接 libtest.so 呢?这里可以猜一下,首先我们上面所有的操作都是在 /root 目录下进行的,而且文件都没有删除。
[root@satori ~]# ls | grep test. libtest.a libtest.so test.c test.o
相信结果很好猜,我们介绍静态库的时候已经生成了 libtest.a,然后 -l test 找到了 libtest.a 这没有任何问题。然后介绍动态库的时候又生成了 libtest.so,但是并没有删除当前目录下的 libtest.a,而 -l test 依然会去找 libtest.so,说明了 -l 会优先链接动态库。如果当前目录不存在相应的动态库,才会去寻找静态库。
// 修改配置,将当前目录给去掉 [root@satori ~]# vim /etc/ld.so.conf [root@satori ~]# /sbin/ldconfig [root@satori ~]# [root@satori ~]# gcc main.c -L . -l test -o main2 [root@satori ~]# ./main2 ./main2: error while loading shared libraries: libtest.so: cannot open shared object file: No such file or directory
我们在 /etc/ld.so.conf 中将当前目录给删掉了,所以编译成可执行文件之后再执行就报错了,因为找不到 libtest.so,证明默认加载的确实是动态库。
但是问题来了,如果同时存在静态库和动态库,而我就想链接静态库的话该怎么做呢?
[root@satori ~]# gcc main.c -L . -static -l test -o main2 [root@satori ~]# ./main2 5050
通过 -static,强制让 gcc 链接静态库。另外,如果执行上面的命令报错了,提示 /usr/bin/ld: cannot find -lc,那么执行 yum install glibc-static 即可。因为高版本的 Linux 系统下安装 glibc-devel, glibc 和 gcc-c++ 时不会安装 libc.a,而是只安装libc.so。所以当使用 -static 时,libc.a 不能使用,因此报错 "找不到 lc"。
我们执行一下:
[root@satori ~]# gcc main.c -L . -static -l test -o main2 [root@satori ~]# ./main2 5050 //删除静态库,依旧不影响执行 //因为它已经链接在可执行文件中了 [root@satori ~]# rm -rf libtest.a [root@satori ~]# ./main2 5050
这里再提一个问题:链接 libtest.a 生成的可执行文件 和 链接 libtest.so 生成的可执行文件 哪一个占用的空间更大呢?好吧,这个问题问的有点幼稚了,很明显前者更大,但是究竟大多少呢?我们来比较一下吧。
//链接 libtest.a [root@satori ~]# size main2 text data bss dec hex filename 770336 6228 8640 785204 bfb34 main2 [root@satori ~]# gcc main.c -L . -l test -o main2 //链接 libtest.so [root@satori ~]# size main2 text data bss dec hex filename 1479 564 4 2047 7ff main2
我们看到大小确实差的不是一点半点,再加上静态库是每一个可执行文件内部都要包含一份,可想而知空间占用量是多么恐怖😱,所以才要有动态库。因此静态库和动态库各有优缺点,具体使用哪一种完全由你自己决定,就我个人而言更喜欢静态库,因为生成可执行文件之后就不用再管了(尽管对空间占用有点不负责任)。
Cython 和静态库结合
然后回到我们的主题,我们的重点是 Cython 和它们的结合,当然先对静态库和动态库有一定的了解是必要的。下面来看看 Cython 要如何引入静态库,这里我们编写斐波那契数列,然后生成静态库。当然为了追求刺激,这里采用 CGO 进行编写。
// 文件名:go_fib.go package main import "C" import "fmt" //export go_fib func go_fib(n C.int) C.double { var i C.int = 0 var a, b C.double = 0.0, 1.0 for ; i < n; i++ { a, b = a + b, a } fmt.Println("斐波那契计算完毕,我是 Go 语言") return a } func main() {}
关于 CGO 这里不做过多介绍,你也可以使用 C 来编写,效果是一样的。然后我们来使用 go build 根据 go 源文件生成静态库:
go build -buildmode=c-archive -o 静态库文件 go源文件
[root@satori ~]# go build -buildmode=c-archive -o libfib.a go_fib.go
然后还需要一个头文件,这里定义为 go_fib.h:
double go_fib(int);
里面只需要放入一个函数声明即可,具体实现在 libfib.a 中,然后编写 Cython 源文件,文件名为 wrapper_gofib.pyx:
cdef extern from "go_fib.h": double go_fib(int) def fib_with_go(n): """ 调用 Go 编写的斐波那契数列 以静态库形式存在 """ return go_fib(n)
函数的具体实现逻辑是以源文件形式存在、还是以静态库形式存在,实际上并不关心。然后是编译脚本 setup.py:
from distutils.core import setup, Extension from Cython.Build import cythonize # 我们不能在 sources 里面写上 ["wrapper_gofib.pyx", "libfib.a"] # 这是不合法的,因为 sources 里面需要放入源文件 # 静态库和动态库需要通过其它参数指定 ext = Extension(name="wrapper_gofib", sources=["wrapper_gofib.pyx"], # 相当于 -L 参数,路径可以指定多个 library_dirs=["."], # 相当于 -l 参数,链接的库可以指定多个 # 注意:不能写 libfib.a,直接写 fib 就行 # 所以命名还是需要遵循规范,要以 lib 开头、.a 结尾, libraries=["fib"], # 相当于 -I 参数 include_dirs=["."]) setup(ext_modules=cythonize(ext, language_level=3))
然后我们执行 python3 setup.py build,因为我现在使用的是 Linux,所以需要输入 python3,要是输入 python 会指向 python2。
执行成功之后,会生成一个 build 目录,我们将里面的扩展模块移动到当前目录,然后进入交互式 Python 中导入它,看看会有什么结果。
此时我们就将 Cython, Go, C, Python 给结合在一起了,当然你可以再加入 C 源文件、或者 C 生成的库文件,怎么样,是不是很好玩呢。如果用 Go 写了一个程序,那么就可以通过编译成静态库的方式,嵌入到 Cython 中,然后再生成扩展模块交给 Python 调用。之前我本人也将 Python 和 Go 结合起来使用过,只不过当时是编译成的动态库,然后通过 Python 的 ctypes 模块调用的。
注意:无论是这里的静态库还是一会要说的动态库,我们举的例子都会比较简单。但实际上我们使用 CGO 的话,内部是可以编写非常复杂的逻辑的,因此我们需要注意 Go 和 C 之间内存模型的差异。因为 Python 和 Go 之间是无法直接结合的,但是它们都可以和 C 勾搭上,所以需要 C 在这两者之间搭一座桥。
但是不同语言的内存模型是不同的,因此当跨语言操作同一块内存时需要格外小心,比如 Go 的导出函数不能返回 Go 的指针等等。所以里面的细节还是比较多的,当然我们这里的主角是 Cython,因此 Go 就不做过多介绍了。
如果大家对 Python 和 Go 联合编程感兴趣,可以后台私信我一下,人多的话我就写几篇这样的文章。
Cython 和动态库结合
然后是 Cython 和 动态库结合,我们还用刚才的 go_fib.go,而 Go 生成动态库的命令如下:
go build -buildmode=c-shared -o 动态库文件 go源文件
[root@satori ~]# go build -buildmode=c-shared -o libfib.so go_fib.go
动态库的话我们只需要生成 libfib.so 即可,然后其它地方不做任何改动,直接执行 python3 setup.py build 生成扩展模块,因为加载动态库和加载静态库的逻辑是一样的。而我们的动态库和刚才的静态库的名字也保持一致,所以整体不需要做任何改动。
整体效果和 C 使用动态库的表现是一致的,仍然优先寻找动态库,并且还要将动态库所在路径加入到 ld.so.conf 中。如果在动态库和静态库同时存在的情况下,想使用静态库的话,那么可以这么做:
ext = Extension( name="wrapper_gofib", sources=["wrapper_gofib.pyx"], # 库的搜索路径 library_dirs=["."], # libraries 参数可以同时指定动态库和静态库 # 但优先寻找动态库,动态库不存在则找静态库 # 如果就想链接静态库,那么可以使用 extra_objects 参数 # 该参数可以链接任意的对象,我们只需要将路径写上去即可 # 注意:此时是文件路径,需要写 libfib.a,不能只写 fib extra_objects=["./libfib.a"], include_dirs=["."])
当然我们这里使用 Go 来生成库文件实际上有点刻意了,因为主要是想展现 Cython 的强大之处。但其实使用 C 来生成库文件也是一样的,因为我们使用 Go 本质上也是将 Go 的代码转成 C 的代码(因此叫 CGO),只不过用 Go 写代码肯定比用 C 写代码舒服,毕竟 Go 是一门带垃圾回收的高级语言。
至于 Go 和 C 之间怎么转,那就不需要我们来操心了,Go 编译器会为我们处理好一切。正如我们此刻学习 Cython 一样,用 Cython 写扩展肯定比用 C 写扩展舒服,但 Cython 代码同样也是要转成 C 的代码,至于怎么转,也不需要我们来操心,Cython 编译器会为我们处理好一切。
以上就是 Cython 和库文件(静态库、动态库)之间的结合,注:Cython 引入库文件的相关操作都是基于 Linux,至于 Windows 如何引入库文件可以自己试一下。
小结
在该系列的最开始我们就说过,其实可以将 Cython 当成两个身份来看待:如果是编译成 C,那么可以看作是 Cython 的 '阴';如果作为胶水连接 C 或者 C++,那么可以看作是 Cython 的 '阳'。
但其实两者之间并没有严格区分,一旦在 cdef extern from 块中声明了 C 函数,就可以像 Cython 本身定义的常规 cdef 函数一样使用。并且对外而言,在使用 Python 调用时,没有人知道里面的方法是我们自己辛辛苦苦编写的,还是调用了其它已经存在的。
这次我们介绍了 Cython 的一些接口特性和使用方法,感受一下它包装 C 函数是多么的方便。而 C 已经存在很多年了,拥有大量经典的库,通过 Cython 我们可以很轻松地调用它们。
当然不只是 C,Cython 还可以调用同样被广泛使用的 C++ 中的库函数,但由于我本人不擅长 C++,因此有兴趣可以自己了解一下。
E N D