【坑】C++静态链接(Hello World水平)
前言
我们的希冀:写出来的代码可以一次编译到处运行。 希望随便在哪一台机器上,只需要把编译出来的程序拷过去,然后执行
./XXX.out
,代码就可以顺利跑了
然而,实际的生活是:Life is FXXking me, 我在想Peach🍑🍑。
本文探讨如何满足我们所希冀的目标
,对C++静态编译进行讨论。
一个最简单的计算机体系是由: 硬件->内核(Kernel)->Shell->应用软件,按如此层级划分。通常,我们把Kernel与Shell组成的层级成为操作系统。
运行代码的目标机器会部署有不同的操作系统:比如Windows系、Unix系、Linux系等等。 不同系统间会有直接的兼容性问题,最明显的就是Win系和*nix系。直观的看,当然就是vs studio编译出来的是exe,*nix用cc编译出来的默认是a.out
。
P.S. 现在Windows有WSL等黑魔法加持(有好事者称其为最优秀的Linux开发者桌面发行版),可以直接在win下面跑linux的代码了。
Win 内核是闭源的魔法,\*nix
的内核则与之不同,二者Binary不一致,对应的硬件的驱动也是不一致的。Win虽然也支持POSIX接口,但是还有许多不一样的天地魔法,例如windows.h
。
而Minix与Linux的诞生则是为了Free,基于当时Unix的系统、接口、文件等做了照猫画虎,以达到类似Copy Unix不走样的目的,让大家可以低成本地、少更改地进行类Unix系统的体验,最终在开源的浪潮下迎来了爆发,其后又由互联网厂商们进一步发扬光大。然而Unix系则反之,在90年代初Unix家族一直在对知识产权问题发生争执,商业派与学术派一直在对其“如何分发”打官司,错过了Free的主升一浪和二浪。现在还剩BSD系的Unix还在苟延残喘,不断地暗中发育;商业派的Unix基本是死的死,吞的吞。最后,现在反过来,是Unix系在不断地做Linux的兼容(例如FreeBSD的兼容性做法)。
咱俩谁是谁的爹?我是你的爹。咱俩谁是谁的儿?你是我的儿。咱俩谁管谁叫爹?你管我叫爹。咱俩谁管谁叫儿?我管你叫儿
再从从更底层的角度而言,运行代码的目标机器其基础构架也会不一样,现在最流行的是分为 x86_64
& aarch64
,当然还有Power
、MIPS
、RISC-V
等等其他构架。
不同的构架拥有不同的指令集、接受并运行不同的机器码。 这是写出来的代码可以一次编译到处运行
是白日做梦的根本原因。
但是为了人类的梦想,巨佬们不懈的努力,做出了很多的解决方案:
- VM类,JVM。PVM等。通过语言VM,将脚本输入VM,然后通过VM再翻译成机器码执行。跨平台难点主要在语言VM等基础设施构建。原本的代码可以不用改动,且代码可以通常与底层隔离,从而减少心智负担,快乐编码!
- 纯纯编译类:直接将代码翻译成机器码,难点落在两个部分:1. 如何与目标机器硬件进行高性能地交互;2. 编译器等基础设施构建。原本的代码需要根据目标构架的不同,进行相应的改动。
从可移植性角度来说,VM类的解决方案是代码高通用性,对应的缺点是极限上,无法像编译类可以极限榨干机器性能。(我感觉Java是天,保守估计可能是治市面上85%疑难杂症的良药!个人观点:目前要纯纯榨干机器性能的实用场景并没有那么多。)
致命三连:
- 你的代码有多少人用?
- 你有多少业务量?
- 你公司还能活多久?
但是,人还是要有梦想,我想当Cool Boy
,因为我感觉编译类语言:
- 和硬件交互很
Cool
! - 榨干机器性能很
Cool
! - 编译很
Cool
!
可移植性存在的坑点
为了能够解决一部分移植通用性的坑点,不得不给自己加上“自我约束”。把需要移植的平台限制在Linux系统、X86_64构架下,那么外部混沌的干扰会大大下降。
所谓自我约束,是指再向某些困难发起挑战时,故意给自己戴上一个枷锁。把自己逼到穷途末路,制定属于自己的规矩。正因为有了这个枷锁,你才会认真地面对挑战,而一旦失败,你就能通过实践那个规矩,让自己得到严格的锻炼,使自己不断进步,这才叫自我约束–迈特戴
当然了,即便是只在Linux系统内部,仍然是坑中坑,狗中狗。 Kernel开发是Linus亲自抓的,仍有MainLine, LTS, Dev等等分支的区别。
落到发行版是各种粑粑山,本来发行版的出发点是希望帮助大家更便捷地、快乐地用上Linux! 但是现在的怪圈是发行版有不同的Kernel Version,LIBC Version, LIBC++ Version、 桌面系统等等。发行版互相之间,兼容性极差,即便是发行版自己的版本之间,做到顺滑的兼容也是异常困难;或者为了保证某几个不同发行版之间的兼容性,做出一个基于XX发行版的换皮兼容系统,继续大玩特玩“谁是谁的爹,谁是谁的儿”游戏。
有人的地方就有江湖。
C语言的标准已经基本固定,最常用的是C99,编译器支持早已成熟。虽然已经有C11甚至是C17了,但是从系统兼容与实际使用的角度来说,主流仍然是C99,对于c语言唯一的坑点就在于系统底层libc的区别,例如gnu的glibc,freebsd的libc,但是都是基于POSIX的,坑点远远小于C++的坑点(前提为非纯做driver类、或者kernel类)。
CentOS 6的glibc版本是2.12,对内核的最低版本是2.6.32,系统的内核版本是2.6.32 CentOS 7的glibc版本是2.17,对内核的最低版本是2.6.32,系统的内核版本是3.10.0 CentOS 8的glibc版本是2.28,自glibc-2.24以后,对内核最低的要求3.2,因为2.6.32 kernel已经EOL,统的内核版本是4.18.0。
RHEL 提出的兼容性准则跨一个版本的ABI一致性,在这些glibc版本的配置下,是达成了的。例如7->6的兼容,8->7的兼容。
C++ 由于不同系统gcc版本、glibc版本、C++标准不一致的锅,导致C++的部署经常出现神坑。而且通常是无解的。GCC有着较为不错的向下兼容性与粑粑一样的向上兼容性。
从最简单的helloworld程序开始,默认使用的都是的动态链接,传入-fPIC
等等cxxflags,虽然在一开始可以快速地完成代码编译与链接,但是由于利用了大量系统中已有的so库,导致其移植性一直是问题。正如前文所说,最主要的就是libc.so
与libstdc++.so
可以利用如下命令来观察glibc与libstdc++的差异,结果很清晰的表明了这两个库也是向下兼容性很好与粑粑一样的向上兼容性。
strings /lib64/libc.so.6 | grep ^GLIBC
strings /lib64/libstdc++.so.6|grep GLIBCXX
如果你没遇到过运行C/C++代码时报出
GLIBC_2.XX NOT FOUND
、GLIBCXX_3.4.XX NOT FOUND
,那么你的生命是不完整的!
Orcale Linux 8的结果(省略版): //GLIBC GLIBC_2.2.5 … GLIBC_2.17 GLIBC_2.18 GLIBC_2.22 … GLIBC_2.28 GLIBC_PRIVATE … GLIBC_2.9 GLIBC_2.7 GLIBC_2.6 GLIBC_2.18 … GLIBC_2.13 GLIBC_2.2.6 //LIBSTDC++ GLIBCXX_3.4 GLIBCXX_3.4.1 … GLIBCXX_3.4.25
CentOS 7的结果(省略版): //GLIBC GLIBC_2.2.5 … GLIBC_2.16 GLIBC_2.17 GLIBC_PRIVATE … GLIBC_2.17 GLIBC_2.13 GLIBC_2.2.6 //LIBSTDC++ GLIBCXX_3.4 GLIBCXX_3.4.1 … GLIBCXX_3.4.19
CentOS 6的结果(省略版): //GLIBC GLIBC_2.2.5 … GLIBC_2.11 GLIBC_2.12 GLIBC_PRIVATE //LIBSTDC++ GLIBCXX_3.4 GLIBCXX_3.4.1 … GLIBCXX_3.4.13
开始可移植的静态链接(Hello World)
1. 配置基础环境
a. 启用Powertools Repo(RHEL 8及以上的系统)
# For Oracle Linux 8
sudo dnf config-manager --enable ol8_codeready_builder
# For CentOS8/Rocky 8
sudo dnf config-manager --set-enabled powertools
b. 安装glibc、libstdc++静态库(重要!!!)
sudo dnf install -y glibc-static libstdc++-static
c. 可选地,安装更高版本的gcc
sudo dnf install -y gcc-toolset-12 # 安装gcc12
source /opt/rh/gcc-toolset-12/enbale # 在.bashrc中激活gcc12的方法
scl enable gcc-toolset-12 bash # 在命令行中激活gcc12的方法
d. 安装xmake(也可以用cmake等)
export xmakeVER=2.7.3 #xmake的版本
wget hhttps://github.com/xmake-io/xmake/releases/download/v$xmakeVER/xmake-master.zip ; unzip xmake-master.zip -d xmake && cd xmake && make -j$(nproc) && sudo make install prefix=/usr/local #编译安装至/usr/local目录
e. 可选地,安装vagrant,或者其他虚拟化软件、物理机等等,用于不同版本的系统切换。
最后,本次实验,我选择的环境是:
- CentOS-7.9
- devtoolset-11 (GCC-11)
- C++17标准(没用上)
2. Hello World 程序
V1.0 动态编译版Hello world
创建hello项目。其中xmake.lua
是项目编译脚本
$ xmake create hello
create hello ...
[+]: xmake.lua
[+]: src/main.cpp
[+]: .gitignore
create ok!
$ cd hello
--xmake.lua
add_rules("mode.debug", "mode.release")
target("hello")
set_languages("c++17")
set_kind("binary")
set_optimize("fastest")
add_files("src/*.cpp")
xmake默认就创建了一个hello world的CPP文件.
//src/main.cpp
#include <iostream>
using namespace std;
int main(int argc, char** argv)
{
cout << "hello world!" << endl;
return 0;
}
另外,如果你的gcc非系统默认的gcc,但是在环境变量中,例如SCL的机制。建议使用如下命令配置一把:
$ xmake f --cc=$(which gcc) --cxx=$(which g++) --ld=$(which g++)
checking for platform ... linux
checking for architecture ... x86_64
接下来进行编译并运行,证明成功!
$ xmake
[ 25%]: cache compiling.release src/main.cpp
[ 50%]: linking.release hello
[100%]: build ok!
$ xmake run
hello world!
接下来分析一把v1.0动态编译版的helloworld,发现其动态库链接状态与文件属性如下。显而易见,这个版本的helloworld是和平台强相关的。但是优势在于其总体积小,一个简单的helloworld程序只有15k大小。
$ ldd build/linux/x86_64/release/hello
linux-vdso.so.1 => (0x00007ffc70551000)
libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007fee4a127000)
libm.so.6 => /lib64/libm.so.6 (0x00007fee49e25000)
libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007fee49c0f000)
libc.so.6 => /lib64/libc.so.6 (0x00007fee49841000)
/lib64/ld-linux-x86-64.so.2 (0x00007fee4a42f000)
$ file build/linux/x86_64/release/hello
build/linux/x86_64/release/hello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), BuildID[sha1]=d0ef66e81db5ac069343913e0dc057d89c45f5db, for GNU/Linux 2.6.32, stripped
$ ls -lh build/linux/x86_64/release/hello
-rwxrwxr-x. 1 vagrant vagrant 15K Dec 29 20:44 build/linux/x86_64/release/hello
V2.0 静态编译版Hello world
修改xmake.lua
的项目配置文件,仅仅添加了add_ldflags
的相关选项,用于静态链接。
--xmake.lua
add_rules("mode.debug", "mode.release")
target("hello")
set_kind("binary")
set_languages("c++17")
set_optimize("fastest")
add_ldflags("-static","-pie","-static-libgcc","-static-libstdc++")
add_files("src/*.cpp")
重新编译后,查看其文件属性。被标注为“非动态可执行文件”、“静态链接”。
$ ldd build/linux/x86_64/release/hello
not a dynamic executable
$ file build/linux/x86_64/release/hello
build/linux/x86_64/release/hello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, BuildID[sha1]=3d35ed8e112b9ad13c13f416b5e345a3662d7f0e, for GNU/Linux 2.6.32, stripped
$ ls -lh build/linux/x86_64/release/hello
-rwxrwxr-x. 1 vagrant vagrant 1.7M Dec 29 20:36 build/linux/x86_64/release/hello
此时,我们就完成了静态、可移植的helloworld程序! 但是静态编译的helloworld程序嵌入这几个“标准库”以后,体积变大了1700/15≈113倍。
我们付出了程序体积膨胀的代价,得到了一个可以到处跑的helloworld程序!
V3.0 静态编译版Hello world-OpenMP
是男人就要当Cool GUY!下面再改一版OpenMP的静态版的helloworld出来!
修改src/main.cpp
//src/main.cpp
#include <iostream>
#include <omp.h>
using namespace std;
int main(int argc, char** argv)
{
#pragma omp parallel
{
cout << "hello world! From "<< omp_get_thread_num() <<" in " << omp_get_num_threads() <<endl;
}
return 0;
}
修改xmake.lua
add_rules("mode.debug", "mode.release")
target("hello")
set_kind("binary")
set_languages("c++17")
set_optimize("fastest")
add_cxxflags("-fopenmp")
add_ldflags("-static","-pie","-static-libgcc","-static-libstdc++",'-l:libgomp.a','-l:libpthread.a',"-l:libdl.a")
add_files("src/*.cpp")
重新编译后,查看其文件属性。被标注为“非动态可执行文件”、“静态链接”。 文件尺寸进一步膨胀为2M。
$ ldd build/linux/x86_64/release/hello
not a dynamic executable
$ file build/linux/x86_64/release/hello
build/linux/x86_64/release/hello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, BuildID[sha1]=d50c845e515aa619f7e0eb4d8251dd8117f4bc0f, for GNU/Linux 2.6.32, stripped
$ ls -lh build/linux/x86_64/release/hello
-rwxrwxr-x. 1 vagrant vagrant 2.0M Dec 29 21:07 build/linux/x86_64/release/hello
CentOS 7 GCC-4.8.5的坑点
GCC-4.8.5静态编译的时候,竟然不支持 -pie
! pie是指Position Independent Executables。简单来说,如果不加-pie
选项,那么每次执行都会加载到相同的地址,这显然不利于移植。
本次踩坑的体会
- 以前以为GLIBC提供的接口已经是Linux的底层了,现在切身体会了比GLIBC更底层的是Kernel。由Kernel接受并执行GLIBC传入的各类指令。
- GCC 4.8.5 真的是👎👎👎
- Redhat 确实做到了跨一个版本的兼容(可向上,也可下),但没保证过跨两个版本兼容。因为一个LTS的Linux Kernel整体的生命周期不会超过10年。
- 棒棒的书《程序员的自我修养—链接、装载与库》。
- 有用的命令
ldd
,file
。 - 变强总是有代价的。为了可移植性也是要付出代价的。