用 C 語言理解 Linux 軟件庫
軟件庫是重復使用代碼的一種簡單而合理的方式。
軟件庫是一種是一直以來長期存在的、簡單合理的復用代碼的方式。這篇文章解釋了如何從頭開始構建庫并使得其可用。盡管這兩個示例庫都以 Linux 為例,但創(chuàng)建、發(fā)布和使用這些庫的步驟也可以應用于其它類 Unix 系統(tǒng)。
這些示例庫使用 C 語言編寫,非常適合該任務。Linux 內核大部分由 C 語言和少量匯編語言編寫(Windows 和 Linux 的表親如 macOS 也是如此)。用于輸入/輸出、網絡、字符串處理、數學、安全、數據編碼等的標準系統(tǒng)庫等主要由 C 語言編寫。所以使用 C 語言編寫庫就是使用 Linux 的原生語言來編寫。除此之外,C 語言的性能也在一眾高級語言中鶴立雞群。
還有兩個來訪問這些庫的示例客戶程序(一個使用 C,另一個使用 Python)。毫無疑問可以使用 C 語言客戶程序來訪問 C 語言編寫的庫,但是 Python 客戶程序示例說明了一個由 C 語言編寫的庫也可以服務于其他編程語言。
靜態(tài)庫和動態(tài)庫對比
Linux 系統(tǒng)存在兩種類型庫:
- 靜態(tài)庫(也被稱為歸檔庫):在編譯過程中的鏈接階段,靜態(tài)庫會被編譯進程序(例如 C 或 Rust)中。每個客戶程序都有屬于自己的一份庫的拷貝。靜態(tài)庫有一個顯而易見的缺點 —— 當庫需要進行一定改動時(例如修復一個 bug),靜態(tài)庫必須重新鏈接一次。接下來要介紹的動態(tài)庫避免了這一缺點。
- 動態(tài)庫(也被稱為共享庫):動態(tài)庫首先會在程序編譯中的鏈接階段被標記,但是客戶程序和庫代碼在運行之前仍然沒有聯(lián)系,且?guī)齑a不會進入到客戶程序中。系統(tǒng)的動態(tài)加載器會把一個共享庫和正在運行的客戶程序進行連接,無論該客戶程序是由靜態(tài)編譯語言(如 C)編寫,還是由動態(tài)解釋語言(如 Python)編寫。因此,動態(tài)庫不需要麻煩客戶程序便可以進行更新。最后,多個客戶程序可以共享同一個動態(tài)庫的單一副本。
通常來說,動態(tài)庫優(yōu)于靜態(tài)庫,盡管其復雜性較高而性能較低。下面是兩種類型的庫如何創(chuàng)建和發(fā)布:
- 庫的源代碼會被編譯成一個或多個目標模塊,目標模塊是二進制文件,可以被包含在庫中并且鏈接到可執(zhí)行的二進制中。
- 目標模塊會會被打包成一個文件。對于靜態(tài)庫,標準的文件拓展名是
.a
意為“歸檔”;對于動態(tài)庫,標準的文件拓展名是.so
意為“共享目標”。對于這兩個相同功能的示例庫,分別發(fā)布為libprimes.a
(靜態(tài)庫)和libshprimes.so
(動態(tài)庫)。兩種庫的文件名都使用前綴lib
進行標識。 - 庫文件被復制到標準目錄下,使得客戶程序可以輕松地訪問到庫。無論是靜態(tài)庫還是動態(tài)庫,典型的位置是
/usr/lib
或者/usr/local/lib
,當然其他位置也是可以的。
構建和發(fā)布每種庫的具體步驟會在下面詳細介紹。首先我將介紹兩種庫里涉及到的 C 函數。
示例庫函數
這兩個示例庫都是由五個相同的 C 函數構建而成的,其中四個函數可供客戶程序使用。第五個函數是其他四個函數的一個工具函數,它顯示了 C 語言怎么隱藏信息。每個函數的源代碼都很短,可以將這些函數放在單個源文件中,盡管也可以放在多個源文件中(如四個公布的函數都有一個文件)。
這些庫函數是基本的處理函數,以多種方式來處理質數。所有的函數接收無符號(即非負)整數值作為參數:
is_prime
函數測試其單個參數是否為質數。are_coprimes
函數檢查了其兩個參數的最大公約數(gcd)是否為 1,即是否為互質數。prime_factors
:函數列出其參數的質因數。glodbach
:函數接收一個大于等于 4 的偶數,列出其可以分解為兩個質數的和。它也許存在多個符合條件的數對。該函數是以 18 世紀數學家 克里斯蒂安·哥德巴赫 命名的,他的猜想是任意一個大于 2 的偶數可以分解為兩個質數之和,這依舊是數論里最古老的未被解決的問題。
工具函數 gcd
留在已部署的庫文件中,但是在沒有包含這個函數的文件無法訪問此函數。因此,一個使用庫的客戶程序無法調用 gcd
函數。仔細觀察 C 函數可以明白這一點。
更多關于 C 函數的內容
每個在 C 語言中的函數都有一個存儲類,它決定了函數的范圍。對于函數,有兩種選擇。
-
函數默認的存儲類是
extern
,它給了函數一個全局域。一個客戶程序可以調用在示例庫中用extern
修飾的任意函數。下面是一個帶有顯式extern
聲明的are_coprimes
函數定義:extern unsigned are_coprimes(unsigned n1, unsigned n2) {
...
}
-
存儲類
static
將一個函數的的范圍限制到函數被定義的文件中。在示例庫中,工具函數gcd
是靜態(tài)的(static
):static unsigned gcd(unsigned n1, unsigned n2) {
...
}
只有在 primes.c
文件中的函數可以調用 gcd
,而只有 are_coprimes
函數會調用它。當靜態(tài)庫和動態(tài)庫被構建和發(fā)布后,其他的程序可以調用外部的(extern
)函數,如 are_coprimes
,但是不可以調用靜態(tài)(static
)函數 gcd
。靜態(tài)(static
)存儲類通過將函數范圍限制在其他庫函數內,進而實現了對庫的客戶程序隱藏 gcd
函數。
在 primes.c
文件中除了 gcd
函數外,其他函數并沒有指明存儲類,默認將會設置為外部的(extern
)。然而,在庫中顯式注明 extern
更加常見。
C 語言區(qū)分了函數的定義和聲明,這對庫來說很重要。接下來讓我們開始了解定義。C 語言僅允許命名函數不允許匿名函數,并且每個函數需要定義以下內容:
- 一個唯一的名字。一個程序不允許存在兩個同名的函數。
- 一個可以為空的參數列表。參數需要指明類型。
- 一個返回值類型(例如:
int
代表 32 位有符號整數),當沒有返回值時設置為空類型(void
)。 - 用一對花括號包圍起來的函數主體部分。在一個特制的示例中,函數主體部分可以為空。
程序中的每個函數必須要被定義一次。
下面是庫函數 are_coprimes
的完整定義:
extern unsigned are_coprimes(unsigned n1, unsigned n2) { /* 定義 */
return 1 == gcd(n1, n2); /* 最大公約數是否為 1? */
}
函數返回一個布爾值(0
代表假,1
代表真),取決于兩個整數參數值的最大公約數是否為 1。工具函數 gcd
計算兩個整數參數 n1
和 n2
的最大公約數。
函數聲明不同于定義,其不需要主體部分:
extern unsigned are_coprimes(unsigned n1, unsigned n2); /* 聲明 */
聲明在參數列表后用一個分號代表結束,它沒有被花括號包圍起來的主體部分。程序中的函數可以被多次聲明。
為什么需要聲明?在 C 語言中,一個被調用的函數必須對其調用者可見。有多種方式可以提供這樣的可見性,具體依賴于編譯器如何實現。一個必然可行的方式就是當它們二者位于同一個文件中時,將被調用的函數定義在在它的調用者之前。
void f() {...} /* f 定義在其被調用前 */
void g() { f(); } /* ok */
當函數 f
被在調用前聲明,此時函數 f
的定義可以移動到函數 g
的下方。
void f(); /* 聲明使得函數 f 對調用者可見 */
void g() { f(); } /* ok */
void f() {...} /* 相較于前一種方式,此方式顯得更簡潔 */
但是當如果一個被調用的函數和調用它的函數不在同一個文件中時呢?因為前文提到一個函數在一個程序中需要被定義一次,那么如何使得讓一個文件中被定義的函數在另一個文件中可見?
這個問題會影響庫,無論是靜態(tài)庫還是動態(tài)庫。例如在這兩個質數庫中函數被定義在源文件 primes.c
中,每個庫中都有該函數的二進制副本,但是這些定義的函數必須要對使用庫的 C 程序可見,該 C 程序有其自身的源文件。
函數聲明可以幫助提供跨文件的可見性。對于上述的“質數”例子,它有一個名為 primes.h
的頭文件,其聲明了四個函數使得它們對使用庫的 C 程序可見。
/** 頭文件 primes.h:函數聲明 **/
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
包含這個頭文件,并盡可能將這條語句其程序源代碼的首部(頭文件將會被導入另一個源文件的“頭”部)。C 語言頭文件可以被導入其他語言(如 Rust 語言)中的 bindgen
,使其它語言的客戶程序可以訪問 C 語言的庫。
總之,一個庫函數只可以被定義一次,但可以在任何需要它的地方進行聲明,任一使用 C 語言庫的程序都需要該聲明。頭文件可以包含函數聲明,但不能包含函數定義。如果頭文件包含了函數定義,那么該文件可能會在一個 C 語言程序中被多次包含,從而破壞了一個函數在 C 語言程序中必須被精確定義一次的規(guī)則。
庫的源代碼
下面是兩個庫的源代碼。這部分代碼、頭文件、以及兩個示例客戶程序都可以在 我的網頁 上找到。
#include <stdio.h>
#include <math.h>
extern unsigned is_prime(unsigned n) {
if (n <= 3) return n > 1; /* 2 和 3 是質數 */
if (0 == (n % 2) || 0 == (n % 3)) return 0; /* 2 和 3 的倍數不會是質數 */
/* 檢查 n 是否是其他 < n 的值的倍數 */
unsigned i;
for (i = 5; (i * i) <= n; i += 6)
if (0 == (n % i) || 0 == (n % (i + 2))) return 0; /* 不是質數 */
return 1; /* 一個不是 2 和 3 的質數 */
}
extern void prime_factors(unsigned n) {
/* 在數字 n 的質因數分解中列出所有 2 */
while (0 == (n % 2)) {
printf("%i ", 2);
n /= 2;
}
/* 數字 2 已經處理完成,下面處理奇數 */
unsigned i;
for (i = 3; i <= sqrt(n); i += 2) {
while (0 == (n % i)) {
printf("%i ", i);
n /= i;
}
}
/* 還有其他質因數?*/
if (n > 2) printf("%i", n);
}
/* 工具函數:計算最大公約數 */
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) {
/* 輸入錯誤 */
if ((n <= 2) || ((n & 0x01) > 0)) {
printf("Number must be > 2 and even: %i is not.\n", n);
return;
}
/* 兩個簡單的例子:4 和 6 */
if ((4 == n) || (6 == n)) {
printf("%i = %i + %i\n", n, n / 2, n / 2);
return;
}
/* 當 n > 8 時,存在多種可能性 */
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);
/* 如果只需要一對,那么用 break 語句替換這句 */
}
}
}
庫函數
這些函數可以被庫利用。兩個庫可以從相同的源代碼中獲得,同時頭文件 primes.h
是兩個庫的 C 語言接口。
構建庫
靜態(tài)庫和動態(tài)庫在構建和發(fā)布的步驟上有一些細節(jié)的不同。靜態(tài)庫需要三個步驟,而動態(tài)庫需要增加兩個步驟即一共五個步驟。額外的步驟表明了動態(tài)庫的動態(tài)方法具有更多的靈活性。讓我們先從靜態(tài)庫開始。
庫的源文件 primes.c
被編譯成一個目標模塊。下面是命令,百分號 %
代表系統(tǒng)提示符,兩個井字符 #
是我的注釋。
% gcc -c primes.c ## 步驟1(靜態(tài))
這一步生成目標模塊是二進制文件 primes.o
。-c
標志意味著只編譯。
下一步是使用 Linux 的 ar
命令將目標對象歸檔。
% ar -cvq libprimes.a primes.o ## 步驟2(靜態(tài))
-cvq
三個標識分別是“創(chuàng)建”、“詳細的”、“快速添加”(以防新文件沒有添加到歸檔中)的簡稱?;貞浺幌拢拔奶岬竭^前綴 lib
是必須的,而庫名是任意的。當然,庫的文件名必須是唯一的,以避免沖突。
歸檔已經準備好要被發(fā)布:
% sudo cp libprimes.a /usr/local/lib ## 步驟3(靜態(tài))
現在靜態(tài)庫對接下來的客戶程序是可見的,示例在后面。(包含 sudo
可以確保有訪問權限將文件復制進 /usr/local/lib
目錄中)
動態(tài)庫還需要一個或多個對象模塊進行打包:
% gcc primes.c -c -fpic ## 步驟1(動態(tài))
增加的選項 -fpic
指示編譯器生成與位置無關的代碼,這意味著不需要將該二進制模塊加載到一個固定的內存位置。在一個擁有多個動態(tài)庫的系統(tǒng)中這種靈活性是至關重要的。生成的對象模塊會略大于靜態(tài)庫生成的對象模塊。
下面是從對象模塊創(chuàng)建單個庫文件的命令:
% gcc -shared -Wl,-soname,libshprimes.so -o libshprimes.so.1 primes.o ## 步驟2(動態(tài))
選項 -shared
表明了該庫是一個共享的(動態(tài)的)而不是靜態(tài)的。-Wl
選項引入了一系列編譯器選項,第一個便是設置動態(tài)庫的 -soname
,這是必須設置的。soname
首先指定了庫的邏輯名字(libshprimes.so
),接下來的 -o
選項指明了庫的物理文件名字(libshprimes.so.1
)。這樣做的目的是為了保持邏輯名不變的同時允許物理名隨著新版本而發(fā)生變化。在本例中,在物理文件名 libshprimes.so.1
中最后的 1 代表是第一個庫的版本。盡管邏輯文件名和物理文件名可以是相同的,但是最佳做法是將它們命名為不同的名字。一個客戶程序將會通過邏輯名(本例中為 libshprimes.so
)來訪問庫,稍后我會進一步解釋。
接下來的一步是通過復制共享庫到合適的目錄下使得客戶程序容易訪問,例如 /usr/local/lib
目錄:
% sudo cp libshprimes.so.1 /usr/local/lib ## 步驟3(動態(tài))
現在在共享庫的邏輯名(libshprimes.so
)和它的物理文件名(/usr/local/lib/libshprimes.so.1
)之間設置一個符號鏈接。最簡單的方式是將 /usr/local/lib
作為工作目錄,在該目錄下輸入命令:
% sudo ln --symbolic libshprimes.so.1 libshprimes.so ## 步驟4(動態(tài))
邏輯名 libshprimes.so
不應該改變,但是符號鏈接的目標(libshrimes.so.1
)可以根據需要進行更新,新的庫實現可以是修復了 bug,提高性能等。
最后一步(一個預防措施)是調用 ldconfig
工具來配置系統(tǒng)的動態(tài)加載器。這個配置保證了加載器能夠找到新發(fā)布的庫。
% sudo ldconfig ## 步驟5(動態(tài))
到現在,動態(tài)庫已為包括下面的兩個在內的示例客戶程序準備就緒了。
一個使用庫的 C 程序
這個示例 C 程序是一個測試程序,它的源代碼以兩條 #include
指令開始:
#include <stdio.h> /* 標準輸入/輸出函數 */
#include <primes.h> /* 我的庫函數 */
文件名兩邊的尖括號表示可以在編譯器的搜索路徑中找到這些頭文件(對于 primes.h
文件來說在 /usr/local/inlcude
目錄下)。如果不包含 #include
,編譯器會抱怨缺少 is_prime
和 prime_factors
等函數的聲明,它們在兩個庫中都有發(fā)布。順便提一句,測試程序的源代碼不需要更改即可測試兩個庫中的每一個庫。
相比之下,庫的源文件(primes.c
)使用 #include
指令打開以下頭文件:
#include <stdio.h>
#include <math.h>
math.h
頭文件是必須的,因為庫函數 prime_factors
會調用數學函數 sqrt
,其在標準庫 libm.so
中。
作為參考,這是測試庫程序的源代碼:
#include <stdio.h>
#include <primes.h>
int main() {
/* 是質數 */
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");
/* 是合數 */
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");
/* 哥德巴赫 */
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
文件到可執(zhí)行文件時,難處理的部分時鏈接選項的順序。回想前文中提到兩個示例庫都是用 lib
作為前綴開始,并且每一個都有一個常規(guī)的拓展后綴:.a
代表靜態(tài)庫 libprimes.a
,.so
代表動態(tài)庫 libshprimes.so
。在鏈接規(guī)范中,前綴 lib
和拓展名被忽略了。鏈接標志以 -l
(小寫 L)開始,并且一條編譯命令可能包含多個鏈接標志。下面是一個完整的測試程序的編譯指令,使用動態(tài)庫作為示例:
% gcc -o tester tester.c -lshprimes -lm
第一個鏈接標志指定了庫 libshprimes.so
,第二個鏈接標志指定了標準數學庫 libm.so
。
鏈接器是懶惰的,這意味著鏈接標志的順序是需要考慮的。例如,調整上述實例中的鏈接順序將會產生一個編譯時錯誤:
% gcc -o tester tester.c -lm -lshprimes ## 危險!
鏈接 libm.so
庫的標志先出現,但是這個庫中沒有函數被測試程序顯式調用;因此,鏈接器不會鏈接到 math.so
庫。調用 sqrt
庫函數僅發(fā)生在 libshprimes.so
庫中包含的 prime_factors
函數。編譯測試程序返回的錯誤是:
primes.c: undefined reference to 'sqrt'
因此,鏈接標志的順序應該是通知鏈接器需要 sqrt
函數:
% gcc -o tester tester.c -lshprimes -lm ## 首先鏈接 -lshprimes
鏈接器在 libshprimes.so
庫中發(fā)現了對庫函數 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 程序
與 C 不同,Python 不是一個靜態(tài)編譯語言,這意味著 Python 客戶示例程序必須訪問動態(tài)版本而非靜態(tài)版本的 primes
庫。為了能這樣做,Python 中有眾多的支持外部語言接口(FFI)的模塊(標準的或第三方的),它們允許用一種語言編寫的程序來調用另一種語言編寫的函數。Python 中的 ctypes
是一個標準的、相對簡單的允許 Python 代碼調用 C 函數的 FFI。
任何 FFI 都面臨挑戰(zhàn),因為對接的語言不大可能會具有完全相同的數據類型。例如:primes
庫使用 C 語言類型 unsigned int
,而 Python 并不具有這種類型;因此 ctypes
FFI 將 C 語言中的 unsigned int
類型映射為 Python 中的 int
類型。在 primes
庫中發(fā)布的四個 extern
C 函數中,有兩個在具有顯式 ctypes
配置的 Python 中會表現得更好。
C 函數 prime_factors
和 goldbach
返回 void
而不是返回一個具體類型,但是 ctypes
默認會將 C 語言中的 void
替換為 Python 語言中的 int
。當從 Python 代碼中調用時,這兩個 C 函數會從棧中返回一個隨機整數值(因此,該值無任何意義)。然而,可以對 ctypes
進行配置,讓這些函數返回 None
(Python 中為 null
類型)。下面是對 prime_factors
函數的配置:
primes.prime_factors.restype = None
可以用類似的語句處理 goldbach
函數。
下面的交互示例(在 Python3 中)展示了在 Python 客戶程序和 primes
庫之間的接口是簡單明了的。
>>> from ctypes import cdll
>>> primes = cdll.LoadLibrary("libshprimes.so") ## 邏輯名
>>> 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 語言庫使用復雜的類型如結構體,如果庫函數傳遞和返回指向結構體的指針,那么比 ctypes
更強大的 FFI 更適合作為一個在 Python 語言和 C 語言之間的平滑接口。盡管如此,ctypes
示例展示了一個 Python 客戶程序可以使用 C 語言編寫的庫。值得注意的是,用作科學計算的流行的 Numpy
庫是用 C 語言編寫的,然后在高級 Python API 中公開。
簡單的 primes
庫和高級的 Numpy
庫強調了 C 語言仍然是編程語言中的通用語言。幾乎每一個語言都可以與 C 語言交互,同時通過 C 語言也可以和任何其他語言交互。Python 很容易和 C 語言交互,作為另外一個例子,當 Panama 項目 成為 Java Native Interface(JNI)一個替代品后,Java 語言和 C 語言交互也會變的很容易。