淺談C/C++鏈接庫

作者:施洪寶

一. 說明

  • 1、本文後續代碼的編譯以及執行環境爲Centos 7.6 x86_64, g++ 4.8.5
  • 2、本文後續會用到linux下nm, ldd命令。nm用於查看文件中的符號, 例如變量, 函數名稱。ldd用於查看動態鏈接庫或者可執行文件的依賴庫(動態鏈接庫)。

二. 編譯鏈接

  • 1、程序員寫出的代碼爲.c或者.cpp, 這些文件需要經過: 預處理(處理代碼中的include, 宏等)、編譯(生成彙編代碼)、彙編(將彙編代碼生成二進制文件)、鏈接才能生成可執行程序。本文將預處理、編譯、彙編的過程都看做是編譯, 簡化讀者理解。更多細節可以參考相關資料。
  • 2、生成可執行文件後, 通過終端進行執行
  • 3、g++參數說明,

    • -std=c++11: 使用c++11標準
    • -o: 指定輸出文件名稱
  • 4、鏈接器ld參數:

    • -L: 指定鏈接時搜索的動態鏈接庫路徑
    • -l: 鏈接某個庫, 例如鏈接libmath.so, 寫爲-lmath

2.1 編譯

  • 1、對於c或者c++項目而言, 我們認爲單個c或者cpp文件是一個編譯單元, 通過編譯器(gcc, g++, clang, clang++)可以生成編譯後的二進制文件。例如: 編譯file1.cpp, 可以生成file1.o。對於單個編譯單元而言, 裏面會有一些符號, 例如函數名稱, 變量名稱, 類名。這些符號可以分爲三類:

    • 對外提供的, 也就是說其他的編譯單元可以使用的
    • 對外依賴的, 也就是說本單元需要外部的其他編譯單元提供的符號
    • 自己內部使用的, 這種符號只有本編譯單元自身需要使用, 外部不可見
  • 2、通過nm, 我們可以查看某個編譯單元存在哪些符號

2.2 鏈接

  • 1、C/C++項目中含有很多個c文件或者cpp文件, 這些文件經過編譯生成了對應的二進制文件。需要通過鏈接器將這些文件鏈接, 進而生成可執行程序。
  • 2、linux下鏈接器爲ld, 利用該工具我們可以將這些文件鏈接, 進而生成可執行程序。
  • 3、在進行鏈接時, 每個編譯單元需要的符號, 都需要能夠找到對應的定義。例如: 某個編譯單元需要其他編譯單元提供符號fun1, 這是一個函數, 如果鏈接器沒能從其他編譯單元找到這個符號, 就會報我們經常看到的未定義錯誤。若果出現多次, 則會報出重複定義的錯誤。

2.3 示例

  • 1、math.h
#ifndef _MATH_H_
#define _MATH_H_

int add(int a, int b);

#endif
  • 2、math.cpp
#include "math.h"

int add(int a, int b){
    return a + b;
}
  • 3、main.cpp
#include <iostream>

#include "math.h"

using namespace std;

int main(int argc, char **argv){
    int a = 100, b = 200;
    int result = add(a, b);
    cout << result << endl;
}
  • 4、生成可執行文件

    • 編譯math.cpp: g++ -std=c++11 -c math.cpp, 生成math.o
    • 編譯main.cpp: g++ -std=c++11 -c main.cpp, 生成main.o
    • 生成可以執行的文件: g++ -v math.o main.o -o main, 可以看到g++的編譯鏈接過程
Using built-in specs.
COLLECT_GCC=g++
COLLECT_LTO_WRAPPER=/usr/libexec/gcc/x86_64-redhat-linux/4.8.5/lto-wrapper
Target: x86_64-redhat-linux
Configured with: ../configure --prefix=/usr --mandir=/usr/share/man --infodir=/usr/share/info --with-bugurl=http://bugzilla.redhat.com/bugzilla --enable-bootstrap --enable-shared --enable-threads=posix --enable-checking=release --with-system-zlib --enable-__cxa_atexit --disable-libunwind-exceptions --enable-gnu-unique-object --enable-linker-build-id --with-linker-hash-style=gnu --enable-languages=c,c++,objc,obj-c++,java,fortran,ada,go,lto --enable-plugin --enable-initfini-array --disable-libgcj --with-isl=/builddir/build/BUILD/gcc-4.8.5-20150702/obj-x86_64-redhat-linux/isl-install --with-cloog=/builddir/build/BUILD/gcc-4.8.5-20150702/obj-x86_64-redhat-linux/cloog-install --enable-gnu-indirect-function --with-tune=generic --with-arch_32=x86-64 --build=x86_64-redhat-linux
Thread model: posix
gcc version 4.8.5 20150623 (Red Hat 4.8.5-36) (GCC)
COMPILER_PATH=/usr/libexec/gcc/x86_64-redhat-linux/4.8.5/:/usr/libexec/gcc/x86_64-redhat-linux/4.8.5/:/usr/libexec/gcc/x86_64-redhat-linux/:/usr/lib/gcc/x86_64-redhat-linux/4.8.5/:/usr/lib/gcc/x86_64-redhat-linux/
LIBRARY_PATH=/usr/lib/gcc/x86_64-redhat-linux/4.8.5/:/usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../../../lib64/:/lib/../lib64/:/usr/lib/../lib64/:/usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../../:/lib/:/usr/lib/
COLLECT_GCC_OPTIONS='-v' '-o' 'main' '-shared-libgcc' '-mtune=generic' '-march=x86-64'
/usr/libexec/gcc/x86_64-redhat-linux/4.8.5/collect2 --build-id --no-add-needed --eh-frame-hdr --hash-style=gnu -m elf_x86_64 -dynamic-linker /lib64/ld-linux-x86-64.so.2 -o main /usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../../../lib64/crt1.o /usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../../../lib64/crti.o /usr/lib/gcc/x86_64-redhat-linux/4.8.5/crtbegin.o -L/usr/lib/gcc/x86_64-redhat-linux/4.8.5 -L/usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../../../lib64 -L/lib/../lib64 -L/usr/lib/../lib64 -L/usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../.. main.o math.o -lstdc++ -lm -lgcc_s -lgcc -lc -lgcc_s -lgcc /usr/lib/gcc/x86_64-redhat-linux/4.8.5/crtend.o /usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../../../lib64/crtn.o
  • 其中最後一行調用collect2(對ld進行了包裝)會執行真正的鏈接操作, 我們直接調用這一句也可以生成main可執行文件
  • 可以看出linux下的鏈接操作比較複雜, 不是簡單的ld main.o math.o即可成功的。

三. 問題

通過上面的介紹, 我們知道一個c/cpp文件通過編譯鏈接, 最終生成可執行文件。無論任何語言, 程序員在寫代碼時, 都不可避免需要使用到庫, 本文主要介紹C/C++中的庫, 總體而言, 我們將這些庫分爲靜態鏈接庫(通常以.a結尾),動態鏈接庫(通常以.so結尾)。首先我們來看幾個問題:

  • 1、什麼是靜態鏈接庫?什麼是動態鏈接庫?
  • 2、靜態鏈接庫如何生成?動態鏈接庫如何生成?
  • 3、靜態鏈接庫是否可以依賴其他的靜態鏈接庫? 是否可以依賴其他動態鏈接庫?
  • 4、動態鏈接庫是否可以依賴其他的靜態鏈接庫? 是否可以依賴其他的動態鏈接庫?
  • 5、鏈接靜態庫時?其依賴的庫該如何鏈接?
  • 6、鏈接動態庫時?其依賴的庫該如何鏈接?
  • 7、使用第三方庫時, 使用靜態鏈接庫還是動態鏈接庫?

四. Hello World

本節以hello world爲例,

#include <iostream>
using namespace std;

int main(int argc, char **argv){
    cout << "hello world" << endl;
}
  • 1、編譯程序: g++ -std=c++11 -o main main.cpp
  • 2、使用ldd查看main的依賴: ldd main
linux-vdso.so.1 =>  (0x00007ffcf53fa000)
libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007f7828b3b000)
libm.so.6 => /lib64/libm.so.6 (0x00007f7828839000)
libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007f7828623000)
libc.so.6 => /lib64/libc.so.6 (0x00007f7828256000)
/lib64/ld-linux-x86-64.so.2 (0x00007f7828e42000)
  • 可以看出, 最簡單的hello world程序也需要鏈接一些庫
  • 上述的幾種鏈接庫, 感興趣的可以逐個研究

五. 動態鏈接庫 vs 靜態鏈接庫

  • 1、本節以2.3中的示例代碼爲例, 將math.h, math.cpp打包爲靜態鏈接庫以及動態鏈接庫, 在main.cpp中引用

5.1 靜態鏈接庫

  • 1、編譯: g++ -std=c++11 -fPIC -c math.cpp

    • fPIC用於生成位置無關的代碼, 更多細節可以查找相關資料
  • 2、生成靜態鏈接庫: ar -crv libmath.a math.o
  • 3、使用這個靜態鏈接庫:

    • 使用靜態庫時, 我們需要math.h文件, 這個文件中定義了這個庫對外提供的功能
    • 除了math.h文件, 我們需要在鏈接階段鏈接libmath.a
  • 4、示例: main.cpp中已經導入了math.h文件, 編譯main.c並鏈接libmath.a, g++ -std=c++11 -o main main.cpp -L. -lmath
  • 5、ldd main可以看出, main文件不再依賴libmath.a文件

5.2 動態鏈接庫

  • 1、生成動態鏈接庫: g++ -std=c++11 -shared -fPIC math.cpp -o libmath.so
  • 2、使用動態鏈接庫:

    • 需要使用math.h頭文件, 該文件定義了庫對外提供的功能
    • 鏈接階段需要鏈接libmath.so
  • 3、示例: g++ -std=c++11 -o main main.cpp -L. -lmath
  • 4、執行main, 會發現無法執行
./main: error while loading shared libraries: libmath.so: cannot open shared object file: No such file or directory
  • 5、我們先用ldd 查看main的依賴庫:
linux-vdso.so.1 =>  (0x00007ffd2adde000)
libmath.so => not found
libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007fd3b7ee6000)
libm.so.6 => /lib64/libm.so.6 (0x00007fd3b7be4000)
libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007fd3b79ce000)
libc.so.6 => /lib64/libc.so.6 (0x00007fd3b7601000)
/lib64/ld-linux-x86-64.so.2 (0x00007fd3b81ed000)

很奇怪, libmath.so沒有找到, 我們在第三步編譯時明明將這個庫加入進去了。這個是由於, 在鏈接階段, 鏈接器可以在當前目錄找到libmath.so。執行階段, 搜索動態鏈接庫時, 並沒有包含當前目錄, 所以報錯。我們可以通過export LD_LIBRARY_PATH=/libpath將libmath.so所在路徑放入動態鏈接庫的搜索路徑中。此時即可成功執行。

5.3 對比

  • 1、靜態鏈接庫, 動態鏈接庫都是二進制文件(ELF格式, 詳細信息可以查找相關資料)

從靜態鏈接庫生成的過程來看, 其本質就是將多個編譯單元(.o文件), 打包爲一個新的文件。鏈接靜態鏈接庫時, 會將靜態鏈接庫的代碼合併進程序中。

  • 2、鏈接動態鏈接庫時, 並不會將動態鏈接庫的內容合併進代碼中, 而是在程序執行時, 搜索動態鏈接庫, 再進行鏈接。

六. 庫之間的依賴

6.1 源代碼

  • 1、first.h
#ifndef __FIRST_H_
#define __FIRST_H_

#include <cstdio>

void first();

#endif
  • 2、first.cpp
#include"first.h"

void first()
{
    printf("This is first!\n");
}
  • 3、second.h
#ifndef __SECOND_H_
#define __SECOND_H_
 
#include <cstdio>
void second();

#endif
  • 4、second.cpp
#include"first.h"
#include"second.h"

void second()
{
    printf("This is second!\n");
    first();
}
  • 5、main.cpp
#include"second.h"
int main()
{
    second();
    return 0;
}

6.2 靜態庫依賴靜態庫

  • 1、生成libfirst.a靜態鏈接庫
g++ -std=c++11 -fPIC -c first.cpp
ar -crv libfirst.a first.o
  • 2、生成libsecond.a並鏈接libfirst.a
g++ -std=c++11 -c second.cpp -L. -lfirst
ar -crv libsecond.a second.o
  • 3、main.cpp中使用libsecond.a

執行: g++ -std=c++11 main.cpp -L. -lsecond -o main
會出現以下錯誤:

./libsecond.a(second.o): In function second()': second.cpp:(.text+0xf): undefined reference tofirst()’
collect2: error: ld returned 1 exit status
  • 4、解釋說明

    • 通過nm, 我們查看libsecond.a中的符號, 找出未定義的符號, 執行nm -u libsecond.a, 即可發現first並沒有定義(編譯器編譯後的符號並不是first, 我這裏是_Z5firstv)。我們明明在生成libsecond.a時鏈接了libfirst.a?
    • 主要的原因是: 生成靜態鏈接庫時, 只是將second.cpp生成的second.o打包, 並沒有真正的將libfirst.a中的內容鏈接進libsecond.a
    • 靜態庫不與其他靜態庫鏈接。我們使用archiver工具(例如Linux上的ar)將多個靜態鏈接庫打包爲一個靜態鏈接庫
  • 5、解決方案

    • 將first.cpp, second.cpp打包爲一個靜態鏈接庫: g++ -std=c++11 -fPIC -c first.cpp second.cpp, ar -crv libsecond.a first.o second.o。main中可以直接鏈接libsecond.a即可

同時鏈接libsecond.a, libfirst.a

6.3 動態庫依賴靜態庫

  • 1、生成libfirst.a靜態鏈接庫, 這一步與5.2節相同
  • 2、生成libsecond.so靜態鏈接libfirst.a
g++ -std=c++11 second.cpp -fPIC -shared -o libsecond.so -L. -lfirst
    • nm -u libseond.so, 我們可以看出, 並沒有出現first, 也就是說, libfirst.a已經被鏈接進libsecond.so中了
    • 3、編譯main.cpp
    g++ -std=c++11 main.cpp -L. -lsecond -o main

    6.4 靜態庫依賴動態庫

    • 1、生成libfirst.so
    g++ -std=c++11 first.cpp -shared -fPIC -o libfirst.so
    • 2、生成libsecond.a鏈接libfirst.so
    g++ -std=c++11 -c second.cpp -fPIC -L. -lfirst
    ar crv libsecond.a second.o

    nm -u libsecond.a, 可以看到_Z5firstv, 說明並沒有將libfirst.so中包含進libsecond.a

    • 3、編譯main.cpp
    g++ -std=c++11 main.cpp -L. -lsecond -lfirst -o main

    如果沒有鏈接first, 會發現鏈接錯誤, 找不到first函數的定義

    6.5 動態庫依賴動態庫

    • 1、生成libfirst.so
    g++ -std=c++11 first.cpp -shared -fPIC -o libfirst.so
    • 2、生成libsecond.so鏈接libfirst.so
    g++ -std=c++11 second.cpp -shared -fPIC -o libsecond.so -L. -lfirst

    nm -u libsecond.so, 可以看到_Z5firstv, 這個就是first函數
    ldd libsecond.so, 也可以看到libfirst.so
    可以看出, 使用libsecond.so時, 仍然需要libfirst.so

    • 3、編譯main.cpp
    g++ -std=c++11 main.cpp -L. -lsecond -o main

    可以看出, 能夠成功編譯。
    之前講過libsecond.so需要依賴libfirst.so, 此處爲何我們只鏈接libsecond.so也能成功呢?這裏是因爲鏈接器會自動搜索動態鏈接庫的依賴庫

    七. 總結

    • 1、c或者cpp文件經過編譯、鏈接生成可執行文件
    • 2、單個c文件或者cpp文件是一個編譯單元。每個編譯單元存在3種符號: 自己使用的, 依賴於外部的以及對外提供的。
    • 3、鏈接器是將多個編譯單元的符號相互鏈接以形成可執行文件。
    • 4、庫可以分爲靜態鏈接庫(.a)以及動態鏈接庫(.so)。
    • 5、使用庫時, 除了庫文件, 還需要對應的頭文件。
    • 6、單個c文件或者cpp文件, 可能依賴其他的庫文件, 但是在編譯時, 只需要有聲明, 並不需要有具體的定義。
    • 7、靜態庫沒有鏈接操作, 靜態庫只是將多個.o文件打包, 並沒有其他操作。靜態庫可能依賴其他的靜態庫或者其他的動態庫, 用戶在使用靜態庫時, 需要手動鏈接這些依賴。
    • 8、動態庫有鏈接操作, 創建動態庫時可以鏈接其他的庫, 也可以不鏈接, 如果鏈接靜態庫, 則會將靜態庫的內容全部放入動態庫, 如果鏈接動態庫, 只是放入符號, 在程序初始化時, 將依賴的這些動態庫也加載。如果這個動態庫依賴了其他庫, 但是沒有鏈接, 也可以生成動態庫, 但用戶在使用這個動態鏈接庫時, 需要手動鏈接這些依賴, 由於使用者很難知道這些依賴, 所以通常不使用這種方式。
    • 9、總體而言, 動態庫在程序執行階段纔會裝進程序, 靜態庫則在鏈接階段直接放進程序。動態庫可以由多個程序共享, 節省內存,易於升級。靜態庫外部依賴少, 更易於部署。

    八. 擴展

    • 1、動態庫升級問題?假設現在有2個程序: p1, p2, 一個動態鏈接庫libmath.so.1。如果現在math庫提供了新版本libmath.so.2, 程序p1需要使用libmath.so.2的新功能, p2則不想使用, 此時該如何升級math庫?

      • 如果math不兼容前一版, 則系統中需要同時存在兩個版本的math庫, p1, p2分別鏈接不同的版本
      • 如果math兼容前一版, 系統中是否可以只保留新版的math庫呢?此時p1, p2又是否需要重新編譯呢?這個問題留給讀者自行思考。
    • 2、某個動態鏈接庫lib1動態鏈接了庫libbase, 現在應用程序中使用了lib1以及libbase, 編譯應用程序時, 是否需要鏈接libbase?

      • 應用程序不僅需要鏈接lib1, 也需要鏈接libbase
      • 鏈接lib1只能保證應用程序依賴lib1的部分能夠正確解析
      • 雖然lib1動態鏈接了libbase, 但是動態鏈接真正進行符號解析是在程序執行階段, 編譯階段無法獲取libbase的相關信息, 應用程序中如果也使用了libbase中的函數, 則必須鏈接libbase, 否則會出現符號未定義
      • 如果lib1靜態鏈接了libbase, 也就是說包含了libbase中的函數, 則應用程序不需要在鏈接libbase
    • 3、菱形依賴問題, A依賴於B以及C, B、C都依賴於D, 但是是不同版本, 例如B依賴於D1, C依賴於D2, 這種情況下如何鏈接?

      • D2兼容於D1(ABI層面兼容), 程序直接鏈接D2
      • D2不兼容於D1, 查看B是否可以依賴D2重新編譯

    鏈接器的參數, 直接鏈接兩個版本。ld的參數–default-symver或者–version-script

    • 4、討論

      • 動態鏈接會有大量的依賴問題(windows dll hell)
      • 由於採用模塊化, 又允許升級單個模塊, 菱形依賴問題對於很多語言都是存在的
      • rust, go等語言都開始採用源碼編譯的方式, 解決依賴問題

    九. 參考

    http://blog.chinaunix.net/uid...
    https://www.cnblogs.com/fnlin...
    https://blog.csdn.net/coolwat...
    https://blog.habets.se/2012/0...

    發表評論
    所有評論
    還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
    相關文章