Linux C++ 應用二進位制相容實踐
本文將介紹一些在開發多 Linux 平臺 C++ 應用時可能遇到的相容性問題和相關的解法。雖然是以 C++ 為講述物件,但相容性這個問題,在沒有 VM 幫你做這些髒活累活的情況下,是所有 C-like 語言(比如 Go、Rust 等)都可能遇到的。
受個人經驗所限,本文所討論內容僅限於 x86 架構下,但相信相關的原理和規則在其他架構下也是相通的,可作借鑑參考。
Linux 二進位制相容
首先,我們來看看什麼叫二進位制相容?
眾所周知,不同的 Linux 發行版會攜帶不同的基礎庫版本,以最常用的 g++ 工具鏈為例,基於它們的應用會附帶地依賴上 libc, libgcc, libstdc++ 等庫。顯然,當應用使用了高版本才具備的功能後,編譯得到的二進位制內容在低版本環境中執行時,將產生相容問題,最常見的表現就是
無法執行
。
簡而言之,當所提供的應用 binary 在目標平臺上無法正常執行(包括跑不起來這種最差的情況),我們就認為這是一種不相容的情況。
多平臺相容的常用方法
為了讓應用相容多平臺,從開發者的角度一般有以下三個方法 [1]。
1。 為每個目標平臺提供特定的 Binary
顧名思義,對於每個目標平臺,這種方法都要提供相應的 binary。
這種方法的好處在於每個 binary 或是安裝包都能夠對目標平臺進行針對性適配,在承諾支援的範圍內基本不需要擔心發生不相容的情況。
但這種方式的缺點也很明顯,維護代價較大。應用每新增一個目標平臺,在釋出流程中就要為之構建相應的編譯打包環境,即便是藉助一些手段(比如容器映象)來實現流程自動化,維護諸多的編譯環境本身也會帶來不小的工作量。
2。 低版本環境編譯
此方法要求開發者將編譯環境設定在目標平臺中版本最低的環境上,此處的版本主要指的編譯工具鏈。比如我們期望提供 CentOS 5。x 到 7。x 都能執行的應用,那麼可以將編譯環境設定在 5。0 上。
這個方法源於對 Linux 向後相容能力的信任,根據經驗,在低版本上編譯得到的 binary,在高版本上有很大機率能夠正常執行。
此方法的缺陷是應用能夠使用的功能受限於編譯環境,包括所能夠使用的語言特性和系統功能。比如:
如果環境上的 gcc 工具鏈仍在 4。1。x 版本,我們顯然無法使用 C++11 等特性。
某些系統庫(比如 journal)需要更高的核心版本支援,那麼在低版本環境下將無法使用。
3。 靜態連結
嚴格來說,這不算是一個獨立解決多平臺相容的方法,因為它完全可以結合前兩個方法一併使用,但考慮到這是一個非常常用的辦法,在此我們簡單地說兩句。
此方法解決相容問題的基本思路是將應用所依賴的各種庫都進行靜態連結,這樣在釋出應用時僅需要提供一個單獨的 binary,而無需附帶上一系列關聯的動態庫(so 檔案),能夠有效地降低不相容問題出現的機率。
但靜態連結並非萬能,拋開體積膨脹以外,它還有這樣兩個問題。一方面,有些庫的 license 中會限制靜態連結,另一方面,即使我們可以對大部分庫進行靜態連結,但隨系統釋出的
libc.so
[2] 是無法這樣做的,它也會帶來一些相容問題 。
我們的多平臺相容思路
本節將簡要介紹在開發 Logtail(SLS 採集 agent)的過程中,我們和多平臺相容「鬥爭」時做出的一些選擇。
1。 不排斥高版本編譯器(只要穩定)
最初,我們僅採用了方法 2 來做到儘可能地相容多平臺,效果很好。但隨著 C++ 標準的不斷演進,我們面臨了一個直接問題:
低版本環境「落後」的語法支援和日益瞭解的新特性之間的矛盾
。在低版本環境下,由於僅支援 C++98,我們:
沒法在恰當的地方引入 move 語義,只能依靠註釋。
重複地敲打著 auto 就能替換的迭代器型別宣告。
。。。
但經過調研和實踐後,我們發現,其實只需要藉助
靜態連結標準庫+手動構建編譯工具
,就能夠在保證相容性地情況下,開心地使用新特性。
2。 儘可能地靜態連結(注意版權)
雖然靜態連結會導致 binary 產生一定程度的體積膨脹,但相比它能夠帶來的相容能力的提升,這些額外的空間開銷我們認為是值得的。
對於版權,豐富的開源生態並沒有讓我們失望,暫未遇到任何這方面的限制。
3。 符號替換
細數我們所遇到的相容性問題,大多數都是在執行環境中缺失所需符號或是符號版本不一致導致的,此時符號替換將是一個很好的解決思路,事實上,我們也是藉此方法來解決 libc。so 帶來的一些問題。
操作實踐
對於一篇實踐類的文章,單純使用文字來介紹總是匱乏的,也無法清楚地描述實際的問題。因此,本節將透過一個示例來對前述內容進行補充說明。
示例應用程式碼
在示例應用中,我們使用了 C++11 的一些特性,包括 uniform initialization, lambda (with capture), for auto 等。
#include
#include
#include
#include
using
namespace
std
;
int
main
()
{
vector
<
string
>
vec
=
{
“b”
,
“a”
,
“d”
};
auto
printVec
=
[
&
vec
]()
{
for
(
auto
&
s
:
vec
)
{
std
::
cout
<<
s
<<
std
::
endl
;
}
};
for
(
int
i
=
0
;
i
<
10
;
++
i
)
{
vec
。
push_back
(
to_string
(
i
));
}
std
::
cout
<<
“===== Before =====”
<<
std
::
endl
;
printVec
();
sort
(
vec
。
begin
(),
vec
。
end
());
std
::
cout
<<
“===== After =====”
<<
std
::
endl
;
printVec
();
return
0
;
}
編譯及執行環境
如下是示例所使用的兩個環境,我們將在 CentOS 7 上使用 g++ 4。8。5 對應用進行編譯,然後把得到的 binary 放到 CentOS 5 上執行。
# 在兩個環境上分別執行此命令
$ cat /etc/redhat-release
;
uname -r
;
g++ ——version
|
grep g++
;
ld ——version
|
grep ld
# 編譯環境(高版本)
CentOS Linux release 7。5。1804
(
Core
)
3。10。0-862。3。2。el7。x86_64
g++
(
GCC
)
4。8。5
20150623
(
Red Hat 4。8。5-28
)
GNU ld version 2。27-27。base。el7
# 執行環境(低版本)
CentOS release 5。7
(
Final
)
2。6。18-274。el5
g++
(
GCC
)
4。1。2
20080704
(
Red Hat 4。1。2-51
)
GNU ld version 2。17。50。0。6-14。el5
20061020
原始版本(v1)
執行
g++ -o main_v1 -std=c++11 main。cpp
進行編譯,將得到的結果複製到執行環境執行,結果如下:
。/main_v1: /usr/lib64/libstdc++。so。6: version `GLIBCXX_3。4。14‘ not found (required by 。/main_v1)
這個報錯表示所連結的 libstdc++。so 無法滿足版本要求。對此,分別檢視一下 libstdc++。so 和 main_v1 中 GLIBCXX 的版本情況:
$ strings main_v1
|
grep
“GLIBCXX_”
GLIBCXX_3。4。5
GLIBCXX_3。4。14
GLIBCXX_3。4
$ strings /usr/lib64/libstdc++。so。6
|
grep
“GLIBCXX_”
GLIBCXX_3。4
GLIBCXX_3。4。1
。。。
GLIBCXX_3。4。8
GLIBCXX_FORCE_NEW
可以看到,main_v1 要求 3。4。14 而執行環境上的 libstdc++。so 僅支援到 3。4。8,所以產生了這個錯誤。
對於這個問題,由於執行環境的不可控,我們無法透過更新 libstdc++。so 來解決,只能透過修改自己的應用來進行相容。
解決辦法:靜態連結 libstdc++.a。
此處我們使用 nm 來進一步分析 main_v1 究竟依賴了哪些 3。4。14 版本的符號(配合 c++filt 進行 demangle),結果如下:
$ nm main_v1
|
grep
“GLIBCXX_3。4。14”
U _ZNSsaSEOSs@@GLIBCXX_3。4。14
U _ZNSsC1EOSs@@GLIBCXX_3。4。14
$ c++filt _ZNSsaSEOSs
std::basic_string
=(
std::basic_string
&&)
$ c++filt _ZNSsC1EOSs
std::basic_string
(
std::basic_string
&&)
可以發現,這是與 string 相關的兩個以右值引用為引數的方法,所以在不支援 C++11 的低版本環境上,libstdc++。so 顯然不可能有這些符號。
靜態連結 libstdc++(v2)
一般來說,編譯環境中是不會自帶 libstdc++。a,需要做一些額外的安裝,比如 CentOS 7 可以直接透過 yum 安裝。
如下是做了靜態連結後的執行結果:
# 安裝 + 靜態連結
$ sudo yum install -y libstdc++-static
$ g++ -o main_v2 -static-libstdc++ -std
=
c++11 main。cpp
# 執行
。/main_v2: /lib64/libc。so。6: version
`
GLIBC_2。14
’
not found
(
required by 。/main_v2
)
和 v1 類似的錯誤,藉助同樣的方法可以發現,這次是 libc。so 的版本不支援導致的,main_v2 需要 2。14 而執行環境上僅支援到 2。5。
$ strings main_v2
|
grep
“GLIBC_”
GLIBC_2。3
GLIBC_2。14
GLIBC_2。3。2
GLIBC_2。2。5
$ strings /lib64/libc。so。6
|
grep
“GLIBC_”
GLIBC_2。2。5
GLIBC_2。2。6
GLIBC_2。3
GLIBC_2。3。2
GLIBC_2。3。3
GLIBC_2。3。4
GLIBC_2。4
GLIBC_2。5
GLIBC_PRIVATE
作為一個隨系統釋出的庫,libc.so 帶來的相容性問題一般無法透過靜態連結解決(理論上或許可行),我們只能尋求其他的方法。
符號替換(v3)
為了解決 v2 的問題,我們先用 nm 看看究竟是哪個符號需要 GLIBC 2。14,結果如下:
$ nm main_v2
|
grep
“GLIBC_2。14”
U memcpy@@GLIBC_2。14
可以看到,只有 memcpy 這一個符號,直覺上這個方法的實現不太可能跟著版本在不停更新。在檢視 glibc 原始碼後可以發現,string/memcpy。c 在 2。2。5 -> 2。14 之間都沒有任何變化。因此,低版本環境上的 libc。so 其實已經提供了我們需要的 memcpy 的實現,唯一需要解決的就是繞過版本的檢查。
對於這一點,可以藉助
內聯彙編 + 符號指定
來實現。出於篇幅,此處我們直接給出相應地解決程式碼,具體分析工作可以參考舊版glibc相容旅程 - CSDN部落格。
#ifdef v3
extern
“C”
{
#include
asm
(
“。symver memcpy, memcpy@GLIBC_2。2。5”
);
void
*
__wrap_memcpy
(
void
*
dest
,
const
void
*
src
,
size_t
n
)
{
return
memcpy
(
dest
,
src
,
n
);
}
}
#endif
編譯及執行結果:
$ g++ -o main_v3 -static-libstdc++ -Wl,——wrap
=
memcpy -Dv3 -std
=
c++11 main。cpp
$ 。/main_v3: symbol lookup error: 。/main_v3: undefined symbol: _ZNSbIwSt11char_traitsIwESaIwEE4_Rep20_S_empty_rep_storageE
還是無法執行……我們來分析一下,顯然,這是一個 C++ mangled 符號,按道理應該在我們靜態連結 libstdc++ 時已經解決了,為什麼依舊會出現呢?
搜了一番後發現了這樣一個帖子:SERVER-11641 undefined symbol: _ZNSbIwSt11char_traitsIwESaIwEE4_Rep20_S_empty_rep_storageE - MongoDB。有興趣的同學可以細看一下帖子的內容,就基本能理解這個問題了,這裡我簡單地複述一遍。
我們把 main_v3 複製到兩個環境中,然後使用 nm 來檢視一下這個符號:
$ nm main_v3
|
grep
“_ZNSbIwSt11char_traitsIwESaIwEE4_Rep20_S_empty_rep_storageE”
# 上面的是編譯環境,下面是執行環境
0000000000680cc0 u _ZNSbIwSt11char_traitsIwESaIwEE4_Rep20_S_empty_rep_storageE
0000000000680cc0 ? _ZNSbIwSt11char_traitsIwESaIwEE4_Rep20_S_empty_rep_storageE
可以發現,中間那個字元有所不同,在高版本的編譯環境上,中間的符號是 u,而低版本的執行環境上則是 ?。
從 man nm 中可知,u 表示這個符號是 GNU unique global symbol 型別,這是 GNU 對 ELF 的一個擴充套件,它會影響到動態連結的過程,換句話說,它會影響到 ld 對動態連結過程的處理。
因為 ld/nm 等命令也是基礎環境之一,兩個環境上的版本也有不同,低版本的 2。17。50 並沒有支援這個擴充套件,所以 nm 檢視的結果顯示為未知(?),而 ld 在做動態連結時會拋棄掉這種未知的符號,所以也就出現了未定義符號的問題。
對於這個問題,和 libc。so 一樣,我們也沒辦法去更新 ld,所以還是隻能在編譯環境中解決此問題。
解決的思路就是讓 gcc 不要生成這種擴充套件型別的符號
,讓執行環境中的 ld 能夠識別並連結它。
不生成 Unique Global Symbol(v4)
對於這個需求,從 gcc mail list 的回覆中可以看到,並沒有這樣的編譯選項,唯一可行的途徑是在編譯 gcc 的時候,指定一個
--disable-gnu-unique-object
引數,因此,解決辦法就是重新編譯一個 gcc。。。
$ wget http://ftp。tsukuba。wide。ad。jp/software/gcc/releases/gcc-4。8。5/gcc-4。8。5。tar。bz2
$ tar -xjvf gcc-4。8。5。tar。bz2
$
cd
gcc-4。8。5
&&
。/contrib/download_prerequisites
$ mkdir build-result
&&
cd
build-result
$ 。。/configure ——enable-checking
=
release ——enable-languages
=
c,c++ ——disable-multilib ——disable-gnu-unique-object ——prefix
=
/usr/local/gcc-4。8。5
$ make
&&
sudo make install
$
export
PATH
=
/usr/local/gcc-4。8。5:
$PATH
唯一需要注意的一點是選擇好安裝的目錄,並且將安裝目錄的內容 export 到 PATH 中。
使用編譯得到的 g++,使用 v3 的編譯命令得到 main_v4 後,在執行環境中成功執行。
最後,我們可以直接 nm 比較一下 v3, v4:
$ nm main_v3
|
grep
“_ZNSbIwSt11char_traitsIwESaIwEE4_Rep20_S_empty_rep_storageE”
0000000000680cc0 u _ZNSbIwSt11char_traitsIwESaIwEE4_Rep20_S_empty_rep_storageE
$ nm main_v4
|
grep
“_ZNSbIwSt11char_traitsIwESaIwEE4_Rep20_S_empty_rep_storageE”
000000000067dcc0 V _ZNSbIwSt11char_traitsIwESaIwEE4_Rep20_S_empty_rep_storageE
在 v4 中的符號型別發生了變化,V 代表的 weak object,這個型別可以相容低版本的 ld。
小結
就我個人感受而言,鑽研二進位制相容性更多是個熟悉和理解編譯工具以及作業系統所定義規則的過程,遠不及設計和實現它們時的難度。但考慮到這個探索的過程也算挺折騰的,所以儘量把能夠總結的內容透過本文進行了整理,希望能讓讀者在後續做相關事情時少才踩些坑。
由於側重於介紹方法和分析的思路,文中所使用的應用示例比較簡單(只考慮了工具鏈依賴庫的範疇),後續有時間會補一篇針對較完善應用的相容性改造過程,敬請期待。
參考
Creating portable Linux binaries
此處的 libc。so 來源於 glibc,而非 Linux 歷史上的其他來源,對這段歷史感興趣的同學可以看一下 libc(7)
舊版glibc相容旅程 - CSDN部落格
SERVER-11641 undefined symbol: _ZNSbIwSt11char_traitsIwESaIwEE4_Rep20_S_empty_rep_storageE - MongoDB
Re: ——no-gnu-unique option to disable STB_GNU_UNIQUE