软件库是一种历史悠久、简单而明智的代码重用方式。本文解释了如何从头开始构建库,并使其可供客户端使用。虽然这两个示例库的目标是 Linux,但创建、发布和使用这些库的步骤适用于其他类 Unix 系统。
示例库是用 C 语言编写的,非常适合这项任务。 Linux 内核主要用 C 语言编写,其余部分用汇编语言编写。(Windows 和 macOS 等 Linux 的近亲也是如此。)用于输入/输出、网络、字符串处理、数学、安全、数据编码等的标准系统库同样主要用 C 语言编写。因此,用 C 语言编写库就是用 Linux 的母语编写。此外,C 语言为高级语言的性能树立了标杆。
还有两个示例客户端(一个用 C 语言编写,另一个用 Python 编写)来访问这些库。C 客户端可以访问用 C 语言编写的库并不奇怪,但 Python 客户端说明,用 C 语言编写的库可以为来自其他语言的客户端提供服务。
静态库与动态库
Linux 系统有两种类型的库
- 静态库(又名库归档文件)在编译过程的链接阶段被烘焙到静态编译的客户端(例如,C 或 Rust 中的客户端)中。 实际上,每个客户端都获得自己的库副本。 如果库需要修改(例如,修复错误),则静态库的一个显着缺点就会显现出来,因为每个库客户端都必须重新链接到静态库。 下面描述的动态库避免了此缺点。
- 动态(又名共享)库在静态编译的客户端程序的链接阶段被标记,但客户端程序和库代码在运行时之前仍然没有连接——库代码没有被烘焙到客户端中。 在运行时,系统的动态加载器将共享库与正在执行的客户端连接起来,无论客户端是来自静态编译的语言(例如 C),还是来自动态编译的语言(例如 Python)。 因此,可以在不给客户端带来不便的情况下更新动态库。 最后,多个客户端可以共享单个动态库的副本。
一般来说,动态库优于静态库,尽管在复杂性和性能方面存在成本。 以下是如何创建和发布每种库类型
- 库的源代码被编译成一个或多个对象模块,这些对象模块是可以包含在库中并链接到可执行客户端的二进制文件。
- 对象模块被打包到一个文件中。 对于静态库,标准扩展名是
.a
,代表“归档文件”。 对于动态库,扩展名是.so
,代表“共享对象”。 两个具有相同功能的示例库发布为文件libprimes.a
(静态)和libshprimes.so
(动态)。 前缀lib
用于这两种类型的库。 - 库文件被复制到标准目录,以便客户端程序可以轻松访问该库。 库(无论静态库还是动态库)的典型位置是
/usr/lib
或/usr/local/lib
; 其他位置也是可能的。
有关构建和发布每种类型库的详细步骤即将推出。 但是,首先,我将介绍两个库中的 C 函数。
示例库函数
这两个示例库由相同的五个 C 函数构建而成,其中四个函数可供客户端程序访问。 第五个函数是其他四个函数的实用程序,它展示了 C 如何支持隐藏信息。 每个函数的源代码都很短,因此这些函数可以包含在单个源文件中,尽管使用多个源文件(例如,每个发布的函数一个源文件)也是一种选择。
库函数是基本的,并且以各种方式处理素数。 所有函数都期望使用无符号(即非负)整数值作为参数
is_prime
函数测试其单个参数是否为素数。are_coprimes
函数检查其两个参数的最大公约数 (gcd) 是否为 1,这定义了互素数。prime_factors
函数列出其参数的素因子。goldbach
函数期望一个 4 或更大的偶数整数值,列出总和为此参数的两个素数; 可能有多个求和对。 该函数以 18 世纪数学家克里斯蒂安·哥德巴赫的名字命名,他的猜想是每个大于 2 的偶数都是两个素数之和,这仍然是数论中最古老的未解决问题之一。
实用程序函数 gcd
驻留在已部署的库文件中,但此函数在其包含文件之外不可访问; 因此,库客户端无法直接调用 gcd
函数。 仔细查看 C 函数可以阐明这一点。
有关 C 函数的更多信息
C 中的每个函数都有一个存储类,该类决定了函数的作用域。 对于函数,有两个选项
- 函数的默认存储类是
extern
,它为函数提供全局作用域。 客户端程序可以调用示例库中的任何extern
函数。 以下是具有显式extern
的函数are_coprimes
的定义
extern unsigned are_coprimes(unsigned n1, unsigned n2) { ... }
- 存储类
static
将函数的作用域限制为定义该函数的文件。 在示例库中,实用程序函数gcd
是static
static unsigned gcd(unsigned n1, unsigned n2) { ... }
只有 primes.c
文件中的函数可以调用 gcd
,并且只有函数 are_coprimes
这样做。 构建和发布静态库和动态库后,其他程序可以调用 extern
函数(例如 are_coprimes
),但不能调用 static
函数 gcd
。 因此,static
存储类通过将函数的作用域限制为其他库函数,从而对库客户端隐藏了 gcd
函数。
primes.c
文件中 gcd
以外的函数不需要指定存储类,这将默认为 extern
。 但是,在库中显式声明 extern
是很常见的。
C 区分函数定义和声明,这对库很重要。 让我们从定义开始。 C 只有命名函数,每个函数都使用以下内容定义
- 唯一名称。 程序中没有两个函数可以具有相同的名称。
- 参数列表,可以为空。 参数是有类型的。
- 返回值类型(例如,
int
表示 32 位有符号整数)或void
(如果没有返回值)。 - 用花括号括起来的主体。 在一个人为的示例中,主体可以是空的。
程序中的每个函数都必须定义一次且仅一次。
以下是库函数 are_coprimes
的完整定义
extern unsigned are_coprimes(unsigned n1, unsigned n2) { /* definition */
return 1 == gcd(n1, n2); /* greatest common divisor of 1? */
}
该函数返回一个布尔值(0 表示假,1 表示真),具体取决于两个整数参数的最大公约数是否为 1。 实用程序函数 gcd
计算整数参数 n1
和 n2
的最大公约数。
与定义不同,函数声明没有主体
extern unsigned are_coprimes(unsigned n1, unsigned n2); /* declaration */
声明以参数列表后的分号结束; 没有用花括号括起来的主体。 函数可以在程序中声明多次。
为什么需要声明? 在 C 中,被调用的函数必须对其调用者可见。 有多种方法可以提供这种可见性,具体取决于编译器有多挑剔。 一种可靠的方法是在被调用的函数和调用函数都位于同一文件中时,在调用函数之上定义被调用的函数
void f() {...} /* f is defined before being called */
void g() { f(); } /* ok */
如果 f
在调用之上声明,则函数 f
的定义可以移动到函数 g
的调用下方
void f(); /* declaration makes f visible to caller */
void g() { f(); } /* ok */
void f() {...} /* easier to put this above the call from g */
但是,如果被调用的函数与其调用者位于不同的文件中怎么办? 鉴于程序中每个函数都必须定义一次且仅一次,如何使在一个文件中定义的函数在另一个文件中可见?
此问题会影响静态库和动态库。 例如,两个 primes 库中的函数在源文件 primes.c
中定义,其中二进制副本位于每个库中; 但是这些定义的函数必须对 C 中的库客户端可见,这是一个具有自己源文件(或多个源文件)的单独程序。
提供跨文件的可见性是函数声明可以做的事情。 对于“primes”示例,有一个名为 primes.h
的头文件,该文件声明了要对 C 中的库客户端可见的四个函数
/** header file primes.h: function declarations **/
extern unsigned is_prime(unsigned);
extern void prime_factors(unsigned);
extern unsigned are_coprimes(unsigned, unsigned);
extern void goldbach(unsigned);
这些声明通过指定每个函数的调用语法来充当接口。
为了方便客户端,文本文件 primes.h
可以存储在 C 编译器搜索路径上的目录中。 典型的位置是 /usr/include
和 /usr/local/include
。 C 客户端将在客户端源代码的顶部附近 #include
此头文件。 (因此,头文件被导入到另一个源文件的“head”中。)C 头文件也可以用作实用程序(例如,Rust 的 bindgen
)的输入,这些实用程序使其他语言的客户端能够访问 C 库。
总之,库函数定义一次且仅一次,但在需要的地方声明; C 中的任何库客户端都需要声明。 头文件应包含函数声明,但不应包含函数定义。 如果头文件确实包含定义,则该文件可能会在 C 程序中包含多次,从而违反了函数必须在 C 程序中定义一次且仅一次的规则。
库源代码
以下是两个库的源代码。 此代码、头文件和两个示例客户端可在我的网站上找到。
库函数
#include <stdio.h>
#include <math.h>
extern unsigned is_prime(unsigned n) {
if (n <= 3) return n > 1; /* 2 and 3 are prime */
if (0 == (n % 2) || 0 == (n % 3)) return 0; /* multiples of 2 or 3 aren't */
/* check that n is not a multiple of other values < n */
unsigned i;
for (i = 5; (i * i) <= n; i += 6)
if (0 == (n % i) || 0 == (n % (i + 2))) return 0; /* not prime */
return 1; /* a prime other than 2 or 3 */
}
extern void prime_factors(unsigned n) {
/* list 2s in n's prime factorization */
while (0 == (n % 2)) {
printf("%i ", 2);
n /= 2;
}
/* 2s are done, the divisor is now odd */
unsigned i;
for (i = 3; i <= sqrt(n); i += 2) {
while (0 == (n % i)) {
printf("%i ", i);
n /= i;
}
}
/* one more prime factor? */
if (n > 2) printf("%i", n);
}
/* utility function: greatest common divisor */
static unsigned gcd(unsigned n1, unsigned n2) {
while (n1 != 0) {
unsigned n3 = n1;
n1 = n2 % n1;
n2 = n3;
}
return n2;
}
extern unsigned are_coprimes(unsigned n1, unsigned n2) {
return 1 == gcd(n1, n2);
}
extern void goldbach(unsigned n) {
/* input errors */
if ((n <= 2) || ((n & 0x01) > 0)) {
printf("Number must be > 2 and even: %i is not.\n", n);
return;
}
/* two simple cases: 4 and 6 */
if ((4 == n) || (6 == n)) {
printf("%i = %i + %i\n", n, n / 2, n / 2);
return;
}
/* for n >= 8: multiple possibilities for many */
unsigned i;
for (i = 3; i < (n / 2); i++) {
if (is_prime(i) && is_prime(n - i)) {
printf("%i = %i + %i\n", n, i, n - i);
/* if one pair is enough, replace this with break */
}
}
}
这些函数充当库的基础。 这两个库完全来自相同的源代码,并且头文件 primes.h
是这两个库的 C 接口。
构建库
构建和发布静态库和动态库的步骤在一些细节上有所不同。静态库只需要三个步骤,而动态库只需要再加两个步骤。构建动态库的额外步骤反映了动态方法的灵活性。让我们从静态库开始。
库源文件 primes.c
被编译成一个目标模块。这是命令,百分号是系统提示符(双井号表示我的注释)
% gcc -c primes.c ## step 1 static
这将生成二进制文件 primes.o
,即目标模块。标志 -c
表示仅编译。
下一步是使用 Linux ar
实用程序来归档目标模块
% ar -cvq libprimes.a primes.o ## step 2 static
这三个标志 -cvq
分别是 "create"(创建)、"verbose"(详细)和 "quick append"(快速追加)的缩写(以防必须将新文件添加到归档文件中)。请记住,前缀 lib
是标准的,但库名称是任意的。当然,库的文件名必须是唯一的,以避免冲突。
该归档文件已准备好发布
% sudo cp libprimes.a /usr/local/lib ## step 3 static
现在客户端可以访问静态库了,稍后将提供示例。(包含 sudo
是为了确保将文件复制到 /usr/local/lib
的正确访问权限。)
动态库也需要一个或多个目标模块进行打包
% gcc primes.c -c -fpic ## step 1 dynamic
添加的标志 -fpic
指示编译器生成位置无关代码,这是一种无需加载到固定内存位置的二进制模块。这种灵活性在具有多个动态库的系统中至关重要。生成的目标模块比为静态库生成的目标模块略大。
这是从目标模块创建单个库文件的命令
% gcc -shared -Wl,-soname,libshprimes.so -o libshprimes.so.1 primes.o ## step 2 dynamic
标志 -shared
表示该库是共享(动态)的,而不是静态的。 -Wl
标志引入了一个编译器选项列表,第一个选项设置动态库的 soname
,这是必需的。 soname
首先指定库的逻辑名称 (libshprimes.so
),然后,在 -o
标志之后,指定库的物理文件名 (libshprimes.so.1
)。目标是保持逻辑名称不变,同时允许物理文件名随新版本而更改。在此示例中,物理文件名 libshprimes.so.1
末尾的 1 表示库的第一个版本。逻辑文件名和物理文件名可以相同,但最佳实践是使用单独的名称。客户端通过其逻辑名称(在本例中为 libshprimes.so
)访问库,稍后我将对此进行说明。
下一步是通过将其复制到相应的目录,例如,再次复制到 /usr/local/lib
,使共享库易于客户端访问:
% sudo cp libshprimes.so.1 /usr/local/lib ## step 3 dynamic
现在在共享库的逻辑名称 (libshprimes.so
) 和其完整物理文件名 (/usr/local/lib/libshprimes.so.1
) 之间建立符号链接。最简单的方法是以 /usr/local/lib
作为工作目录来发出命令
% sudo ln --symbolic libshprimes.so.1 libshprimes.so ## step 4 dynamic
逻辑名称 libshprimes.so
不应更改,但可以根据需要更新符号链接的目标 (libshrimes.so.1
),以用于修复错误、提高性能等的新库实现。
最后一步(预防性步骤)是调用 ldconfig
实用程序,该实用程序配置系统的动态加载器。此配置确保加载器将找到新发布的库
% sudo ldconfig ## step 5 dynamic
现在,动态库已准备好供客户端使用,包括以下两个示例客户端。
C 库客户端
示例 C 客户端是程序测试器,其源代码以两个 #include
指令开头
#include <stdio.h> /* standard input/output functions */
#include <primes.h> /* my library functions */
文件名周围的尖括号表示这些头文件将在编译器的搜索路径中找到(对于 primes.h
,目录为 /usr/local/include
)。如果没有这个 #include
,编译器会抱怨缺少函数(如 is_prime
和 prime_factors
)的声明,这些函数都在两个库中发布。顺便说一句,测试程序可以完全不更改源代码来测试两个库中的每一个。
相比之下,库 (primes.c
) 的源文件以这些 #include
指令开头
#include <stdio.h>
#include <math.h>
需要头文件 math.h
,因为库函数 prime_factors
调用标准库 libm.so
中的数学函数 sqrt
。
为了方便参考,以下是测试程序的源代码
测试程序
#include <stdio.h>
#include <primes.h>
int main() {
/* is_prime */
printf("\nis_prime\n");
unsigned i, count = 0, n = 1000;
for (i = 1; i <= n; i++) {
if (is_prime(i)) {
count++;
if (1 == (i % 100)) printf("Sample prime ending in 1: %i\n", i);
}
}
printf("%i primes in range of 1 to a thousand.\n", count);
/* prime_factors */
printf("\nprime_factors\n");
printf("prime factors of 12: ");
prime_factors(12);
printf("\n");
printf("prime factors of 13: ");
prime_factors(13);
printf("\n");
printf("prime factors of 876,512,779: ");
prime_factors(876512779);
printf("\n");
/* are_coprimes */
printf("\nare_coprime\n");
printf("Are %i and %i coprime? %s\n",
21, 22, are_coprimes(21, 22) ? "yes" : "no");
printf("Are %i and %i coprime? %s\n",
21, 24, are_coprimes(21, 24) ? "yes" : "no");
/* goldbach */
printf("\ngoldbach\n");
goldbach(11); /* error */
goldbach(4); /* small one */
goldbach(6); /* another */
for (i = 100; i <= 150; i += 2) goldbach(i);
return 0;
}
在将 tester.c
编译成可执行文件时,棘手的部分是链接标志的顺序。回想一下,这两个示例库都以 lib
前缀开头,并且每个库都有常用的扩展名:静态库 libprimes.a
为 .a
,动态库 libshprimes.so
为 .so
。在链接规范中,lib
前缀和扩展名将被删除。链接标志以 -l
(小写 L)开头,并且编译命令可能包含许多链接标志。以下是测试程序的完整编译命令,以动态库为例
% gcc -o tester tester.c -lshprimes -lm
第一个链接标志标识库 libshprimes.so
,第二个链接标志标识标准数学库 libm.so
。
链接器是惰性的,这意味着链接标志的顺序很重要。例如,反转链接规范的顺序会生成编译时错误
% gcc -o tester tester.c -lm -lshprimes ## danger!
链接到 libm.so
的标志首先出现,但没有此库中的函数在测试程序中显式调用;因此,链接器不链接到 math.so
库。对 sqrt
库函数的调用仅发生在现在包含在 libshprimes.so
库中的 prime_factors
函数中。编译测试程序的 resulting 错误是
primes.c: undefined reference to 'sqrt'
因此,链接标志的顺序应通知链接器需要 sqrt
函数
% gcc -o tester tester.c -lshprimes -lm ## -lshprimes 1st
链接器在 libshprimes.so
库中获取对库函数 sqrt
的调用,因此,对数学库 libm.so
进行适当的链接。有一种更复杂的链接选项支持任一链接标志顺序;但是,在这种情况下,简单的方法是适当地安排链接标志。
以下是测试客户端运行的一些输出
is_prime
Sample prime ending in 1: 101
Sample prime ending in 1: 401
...
168 primes in range of 1 to a thousand.
prime_factors
prime factors of 12: 2 2 3
prime factors of 13: 13
prime factors of 876,512,779: 211 4154089
are_coprime
Are 21 and 22 coprime? yes
Are 21 and 24 coprime? no
goldbach
Number must be > 2 and even: 11 is not.
4 = 2 + 2
6 = 3 + 3
...
32 = 3 + 29
32 = 13 + 19
...
100 = 3 + 97
100 = 11 + 89
...
对于 goldbach
函数,即使是相对较小的偶数值(例如,18)也可能有多对素数加起来等于它(在本例中为 5+13 和 7+11)。此类多个素数对是使哥德巴赫猜想的证明复杂化的因素之一。
使用 Python 客户端进行总结
Python 与 C 不同,不是静态编译的语言,这意味着示例 Python 客户端必须访问 primes 库的动态版本而不是静态版本。为此,Python 有各种模块(标准模块和第三方模块)支持外部函数接口 (FFI),该接口允许以一种语言编写的程序调用以另一种语言编写的函数。 Python ctypes
是一个标准且相对简单的 FFI,它使 Python 代码能够调用 C 函数。
任何 FFI 都具有挑战性,因为接口语言不太可能具有完全相同的数据类型。例如,primes 库使用 C 类型 unsigned int
,Python 没有该类型; ctypes
FFI 将 C unsigned int
映射到 Python int
。在 primes 库中发布的四个 extern
C 函数中,有两个在 Python 中表现更好,带有显式 ctypes
配置。
C 函数 prime_factors
和 goldbach
具有 void
而不是返回类型,但是 ctypes
默认情况下将 C void
替换为 Python int
。从 Python 代码调用时,这两个 C 函数然后从堆栈返回一个随机(因此,无意义)的整数值。但是,可以将 ctypes
配置为使函数返回 None
(Python 的 null 类型)。以下是 prime_factors
函数的配置
primes.prime_factors.restype = None
类似的语句处理 goldbach
函数。
下面的交互式会话(在 Python 3 中)表明 Python 客户端和 primes 库之间的接口非常简单
>>> from ctypes import cdll
>>> primes = cdll.LoadLibrary("libshprimes.so") ## logical name
>>> primes.is_prime(13)
1
>>> primes.is_prime(12)
0
>>> primes.are_coprimes(8, 24)
0
>>> primes.are_coprimes(8, 25)
1
>>> primes.prime_factors.restype = None
>>> primes.goldbach.restype = None
>>> primes.prime_factors(72)
2 2 2 3 3
>>> primes.goldbach(32)
32 = 3 + 29
32 = 13 + 19
primes 库中的函数仅使用简单的数据类型 unsigned int
。如果此 C 库使用了复杂的类型(例如结构),并且如果结构指针传递到库函数并从库函数返回,则对于 Python 和 C 之间的平滑接口而言,比 ctypes
更强大的 FFI 可能会更好。尽管如此,ctypes
示例表明 Python 客户端可以使用 C 编写的库。实际上,流行的用于科学计算的 NumPy 库是用 C 编写的,然后在高级 Python API 中公开。
简单的 primes 库和高级 NumPy 库都强调 C 仍然是编程语言之间的通用语言。几乎每种语言都可以与 C 通信——并且,通过 C,可以与任何其他与 C 通信的语言通信。 Python 可以轻松地与 C 通信,作为另一个示例,当 Project Panama 成为 Java Native Interface (JNI) 的替代方案时,Java 也可以这样做。
2 条评论