静态编译:构建旧版 Linux 也能跑的程序
0x00 前言:为什么需要静态编译
简而言之,本文的实验目标是:在 Debian 13(2024/12/19 sid,内核 6.12.5,glibc 2.40)上,编译出一个带有大量依赖库的可执行文件, 放到 CentOS 5(2007 年发布,内核 2.6.18,glibc 2.5)执行。换句话说,我们要编译出可以直接运行于绝大部分 Linux 系统的程序。
笔者回想起数年之前,刚刚进入实验室的时候,研究组有一台公用服务器,但我们只有普通用户权限,所以无法给服务器添加 .so
动态链接库。从而,有些软件必须静态编译。在华为实习期间,服务器是 arm 指令集,所以不仅需要静态编译,还需要交叉编译。
静态编译是很现实的需求——作为软件开发者,我们当然希望所有客户都能直接运行自己的软件;身为服务器管理员,面对旧版系统,如果想安装 btop 这样的现代工具,我们也需要在自己电脑上编译出一个版本,传到服务器上执行。而且,我们希望对服务器的影响尽量小,所以我们不会在服务器上安装各种动态库,而是尽量把所有的库都打包进单一的可执行文件。
脚本语言当然不受系统版本的影响,基于 VM 的语言(例如 java)一般也无需关心这种问题,即所谓“一次编译,到处运行”。而 Golang 为了解决兼容性问题,直接把 runtime 打包进了程序,不依赖于任何外部库。我们讨论静态编译,主要是针对 C/C++ 程序来说的。在本文中,我们将会从“打包依赖库”开始,尝试一步步解决问题。
0x01 一次失败的软件分发
在编译复杂软件之前,我们先来做一点小实验:写一个程序,调用 Markdown 解析库 Orc/discount(它的 Debian 包是 discount、libmarkdown2-dev),把 hello, **world**
这个字符串解析成 HTML 输出。首先安装 discount 库:
sudo apt install discount libmarkdown2-dev
编写代码:
#include <mkdio.h>
#include <stdio.h>
int main(void) {
const char a[] = "hello, **world**";
char *html;
MMIOT *doc = mkd_string(a, sizeof(a), 0);
mkd_compile(doc, 0);
mkd_document(doc, &html);
puts(html);
return 0;
}
在 Debian 13 上编译并运行:
gcc app.c -o app -lmarkdown
./app
# <p>hello, <strong>world</strong></p>
输出完全符合我们的要求,所以这个软件的开发算是完成了。现在,把软件传给一位 Debian 12 用户,他的运行结果是:
./app: error while loading shared libraries: libmarkdown.so.2: cannot open shared object file: No such file or directory
提示我们找不到 libmarkdown.so.2
库。我们可以在开发机、客户机上分别运行 ldd
指令,查看 app
程序的依赖库:
# 开发机
neko@dev ~/b/mymark> ldd ./app
linux-vdso.so.1 (0x00007fb4429e6000)
libmarkdown.so.2 => /lib/x86_64-linux-gnu/libmarkdown.so.2 (0x00007fb4429c0000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fb4427ca000)
/lib64/ld-linux-x86-64.so.2 (0x00007fb4429e8000)
# 客户机
neko@center:~$ ldd app
linux-vdso.so.1 (0x00007ffd54874000)
libmarkdown.so.2 => not found
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f67b4447000)
/lib64/ld-linux-x86-64.so.2 (0x00007f67b4632000)
可见,客户机上拥有 linux-vdso.so.1
、libc.so.6
和 ld-linux-x86-64.so.2
,但缺乏 libmarkdown.so.2
,导致执行失败。我们必须把 libmarkdown 提供给用户,要么使用动态链接库,要么静态链接进程序。
0x02 动态链接库、PLT 和 GOT
先试着通过分发动态链接库解决问题。我们把开发机上的 libmarkdown.so.2
复制给客户,让客户设置 LD_LIBRARY_PATH
环境变量,即可正常工作:
LD_LIBRARY_PATH=. ldd ./app
# linux-vdso.so.1 (0x00007ffe51d8d000)
# libmarkdown.so.2 => ./libmarkdown.so.2 (0x00007f507c88c000)
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f507c6a8000)
# /lib64/ld-linux-x86-64.so.2 (0x00007f507c8a7000)
LD_LIBRARY_PATH=. ./app
# <p>hello, <strong>world</strong></p>
当然,也可以使用 LD_PRELOAD
环境变量:
LD_PRELOAD="./libmarkdown.so.2" ./app
# <p>hello, <strong>world</strong></p>
.dll
文件。这是因为,Windows 会加载应用程序所在目录下的 .dll
库,所以 Windows 用户无需设置 LD_PRELOAD
之类的环境变量,就能使用这些第三方库。我们来看看 libmarkdown API 的调用过程。反汇编 app
程序,我们看到三个 call:
被 call 的函数不是 mkd_string
本身,而是它对应的 PLT 表项:
可见它会跳转到 GOT 表记载的位置,而初始状态下,GOT 表中记录的是 plt 条目中的第二条指令:
于是程序跳回 mkd_string@plt+6
位置,执行以下两句汇编:
0x555555555056 <mkd_string@plt+6>: push $0x2
0x55555555505b <mkd_string@plt+11>: jmp 0x555555555020
跟进:
跳转到 *0x2fcc(%rip)
,这个指针里的值是:
跟进:
这个地址在 ld-linux-x86-64.so.2
里面,可见我们已经来到了链接器的地盘。
链接器会去帮我们寻找 mkd_string
的地址,并写回到 GOT 表。以后我们进入 PLT,便会直接跳转到 mkd_string
函数地址。至此,我们追踪完了函数加载过程。
0x03 打包 libmarkdown.a
然而,我们的最终目标是把所有依赖都打包进单个文件。动态链接使用的是 .so
文件,而静态链接使用的是 .a
文件,我们没有 libmarkdown.a
。这没关系,我们自己编译一个:
git clone https://github.com/Orc/discount.git --depth=1
cd discount
./configure.sh
make -j16
du -h libmarkdown.a
# 380K libmarkdown.a
现在,把 libmarkdown.a
打包进软件:
gcc app.c ./discount/libmarkdown.a -o app
现在,我们发现 app
没有 libmarkdown.so
这项依赖了:
du -h app
# 200K app
ldd app
# linux-vdso.so.1 (0x00007fa475a28000)
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fa47580e000)
# /lib64/ld-linux-x86-64.so.2 (0x00007fa475a2a000)
在客户机上亦可正确工作:
neko@center:~$ ./app
# <p>hello, <strong>world</strong></p>
用 IDA 打开 app
看一眼,发现我们使用到的函数已经编译进来了,不再使用 PLT 跳转:
我们确实在 Debian 13 上编译出了 Debian 12 可以运行的程序,但当我们把程序放到 CentOS 5 上运行时,意外发生了。
0x04 从 glibc 到 musl
在 CentOS 5 客户机上的执行结果如下:
./app
Segmentation fault
ldd app
# ./app: /lib64/libc.so.6: version `GLIBC_2.7' not found (required by ./app)
# ./app: /lib64/libc.so.6: version `GLIBC_2.14' not found (required by ./app)
# ./app: /lib64/libc.so.6: version `GLIBC_2.34' not found (required by ./app)
# linux-vdso.so.1 => (0x00007fff139fd000)
# libc.so.6 => /lib64/libc.so.6 (0x00002ac25bf3f000)
# /lib64/ld-linux-x86-64.so.2 (0x0000003444600000)
看起来是 libc.so
的版本太旧。那么,我们故技重施,把开发机上面的 glibc 2.40 拷到客户机上,再次运行:
LD_PRELOAD="./libc.so.6" ./app
# ERROR: ld.so: object './libc.so.6' from LD_PRELOAD cannot be preloaded: ignored.
# ./app: /lib64/libc.so.6: version `GLIBC_2.7' not found (required by ./app)
# ./app: /lib64/libc.so.6: version `GLIBC_2.14' not found (required by ./app)
# ./app: /lib64/libc.so.6: version `GLIBC_2.34' not found (required by ./app)
查找资料,从一篇 stackoverflow 问答中得知,ld.so
的版本需要与 libc.so.6
版本匹配。我们看看客户机上的 ld 版本:
[root@localhost ~]# ld -v
GNU ld version 2.17.50.0.6-26.el5 20061020
[root@localhost ~]# /lib64/libc.so.6
GNU C Library stable release version 2.5, by Roland McGrath et al.
Copyright (C) 2006 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 4.1.2 20080704 (Red Hat 4.1.2-54).
Compiled on a Linux 2.6.9 system on 2014-09-16.
Available extensions:
The C stubs add-on version 2.1.2.
crypt add-on version 2.1 by Michael Glad and others
GNU Libidn by Simon Josefsson
GNU libio by Per Bothner
NIS(YP)/NIS+ NSS modules 0.19 by Thorsten Kukuk
Native POSIX Threads Library by Ulrich Drepper et al
BIND-8.2.3-T5B
RT using linux kernel aio
Thread-local storage support included.
For bug reporting instructions, please see:
<http://www.gnu.org/software/libc/bugs.html>.
而开发机的 ld 版本是 2.40:
neko@dev ~ [1]> ld.so --version
ld.so (Debian GLIBC 2.40-4) stable release version 2.40.
Copyright (C) 2024 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
按照一篇 stackexange 问答的指引,我们把新版本的 ld-linux-x86-64.so.2
也送来,然而:
[root@localhost ~]# LD_LIBRARY_PATH="./" ./ld-linux-x86-64.so.2 ./app
Segmentation fault
我们似乎陷入了僵局。再看一眼开发机的 glibc:
neko@dev ~/b/mymark> /lib/x86_64-linux-gnu/libc.so.6
GNU C Library (Debian GLIBC 2.40-4) stable release version 2.40.
Copyright (C) 2024 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 14.2.0.
libc ABIs: UNIQUE IFUNC ABSOLUTE
Minimum supported kernel: 3.2.0
For bug reporting instructions, please see:
<http://www.debian.org/Bugs/>.
注意这一句 Minimum supported kernel: 3.2.0
,似乎从这个 glibc 编译出的程序,对内核版本是有要求的。而我们客户机的内核是 2.6.18-398.el5
,低于 3.2.0,所以跑不起来也算是预料之中。这篇 stackexange 问答详细解释了这里为什么是 3.2.0,值得一读。
我们是不可能升级内核的,所以通过动态链接库提供高版本 glibc 的路线走到了尽头。那么,我们能把 glibc 像 libmarkdown 一样静态编译进程序吗?很遗憾,这也是不可行的,详情参考这篇 stackoverflow 问答。
既然问题出在 glibc 上,我们考虑用其他可以静态链接的 libc 换掉 glibc。musl 就是一个不错的选择。按照指引,我们用 musl-gcc
工具代替 gcc
:
sudo apt install musl musl-dev musl-tools
musl-gcc app.c ./discount/libmarkdown.a -o app -static
# app.c:1:10: fatal error: mkdio.h: No such file or directory
# 1 | #include <mkdio.h>
# | ^~~~~~~~~
# compilation terminated.
gcc 能找到的头文件,musl-gcc 没有找到。我们通过 -I
参数手动提供 header 目录:
musl-gcc app.c ./discount/libmarkdown.a -I /usr/include/x86_64-linux-gnu -o app -static
du -h app
# 256K app
musl-ldd app
# musl-ldd: app: Not a valid dynamic program
终于,我们的 CentOS 5 也能执行程序了:
[root@localhost ~]# ./app
<p>hello, <strong>world</strong></p>
回顾一遍我们干的事情。首先,我们编译出依赖库的 .a
文件,以便静态链接;接下来,我们用 musl 取代了 glibc,最终获得了一个完全静态链接的程序。不过,上述实验是针对一个小规模程序进行的,只使用了 libmarkdown 这一个依赖;后文将静态编译一些其他的程序。
0x05 静态编译 htop
现在为 CentOS 5 编译一个最新版的 htop。在静态编译之前,我们先走一遍常规编译流程,看看它依赖哪些库:
sudo apt install libncursesw5-dev autotools-dev autoconf automake build-essential
git clone https://github.com/htop-dev/htop.git --depth=1
cd htop
./autogen.sh
./configure
make -j16
du -h ./htop
# 1.5M ./htop
ldd ./htop
# linux-vdso.so.1 (0x00007fd833f7c000)
# libncursesw.so.6 => /lib/x86_64-linux-gnu/libncursesw.so.6 (0x00007fd833ee0000)
# libtinfo.so.6 => /lib/x86_64-linux-gnu/libtinfo.so.6 (0x00007fd833eab000)
# libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007fd833dc5000)
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fd833bcf000)
# /lib64/ld-linux-x86-64.so.2 (0x00007fd833f7e000)
尝试打开 --enable-static
选项,静态链接:
./configure --enable-static
#
# htop 3.4.0-dev-762acbe
#
# platform: linux
# os-release file: /etc/os-release
# (Linux) proc directory: /proc
# (Linux) openvz: no
# (Linux) vserver: no
# (Linux) ancient vserver: no
# (Linux) delay accounting: no
# (Linux) sensors: no
# (Linux) capabilities: no
# unicode: yes
# affinity: yes
# unwind: no
# hwloc: no
# debug: no
# static: yes
make -j16
du -h ./htop
# 2.9M ./htop
ldd ./htop
# not a dynamic executable
确实是静态链接了,然而在 CentOS 上仍然无法运行:
[root@localhost ~]# ./htop
Segmentation fault
考虑使用 musl 代替 glibc。
# ./configure CC=musl-gcc
configure: error: cannot find required curses/ncurses library
提示找不到 ncurses
库,我们需要提供对应的 .a
文件和 header 文件。然而,我们手头只有 /lib/x86_64-linux-gnu/
里面的 libncursesw.a
,它是在 glibc 上编译的。我们需要在 musl 上编译 libncursesw 等依赖库,这是庞大的工作量。换一种思路——既然 Debian 软件仓库有预编译的 glibc 版依赖库,那 Alpine Linux 的软件仓库里面应该也能找到 musl 版依赖库。
我们看一眼 Alpine Linux 里的 htop 包构建脚本和构建日志:
# Contributor: Sören Tempel <soeren+alpine@soeren-tempel.net>
# Maintainer: Carlo Landmeter <clandmeter@alpinelinux.org>
pkgname=htop
pkgver=3.3.0
pkgrel=0
pkgdesc="Interactive process viewer"
url="https://htop.dev/"
arch="all"
license="GPL-2.0-or-later"
makedepends="ncurses-dev python3 linux-headers lm-sensors-dev"
subpackages="$pkgname-doc"
source="https://github.com/htop-dev/htop/releases/download/$pkgver/htop-$pkgver.tar.xz"
options="!check" # no upstream/available test-suite
build() {
./configure \
--build=$CBUILD \
--host=$CHOST \
--prefix=/usr \
--sysconfdir=/etc \
--mandir=/usr/share/man \
--localstatedir=/var \
--enable-cgroup \
--enable-taskstats
make
}
package() {
make DESTDIR="$pkgdir" pixmapdir=/usr/share/icons/hicolor/128x128/apps install
}
sha512sums="
f98d4a4370954969d0ae16993d80ca5ce48670a711f17445de979513ac9caf2b197291732d937ae07d48709ded660ea09601b3a41ad7c48b3abb87e7a67deb65 htop-3.3.0.tar.xz
"
我们跟随这个步骤来操作:
apk add build-base autoconf automake
apk add ncurses-dev python3 linux-headers lm-sensors-dev
./configure
make -j4
du -h htop
# 1.5M htop
ldd htop
# /lib/ld-musl-x86_64.so.1 (0x7f6e9e7c7000)
# libncursesw.so.6 => /usr/lib/libncursesw.so.6 (0x7f6e9e717000)
# libc.musl-x86_64.so.1 => /lib/ld-musl-x86_64.so.1 (0x7f6e9e7c7000)
动态编译成功。现在静态编译:
make clean
./configure --enable-static=yes
# configure: error: cannot find required curses/ncurses library
我们仍然缺少 libncursesw.a
文件。在 Alpine Linux 官网上搜索:
发现 ncurses-static
包里面有我们所需的文件。继续编译:
apk add ncurses-static
./configure --enable-static=yes
# htop 3.4.0-dev
#
# platform: linux
# os-release file: /etc/os-release
# (Linux) proc directory: /proc
# (Linux) openvz: no
# (Linux) vserver: no
# (Linux) ancient vserver: no
# (Linux) delay accounting: no
# (Linux) sensors: yes
# (Linux) capabilities: no
# unicode: yes
# affinity: yes
# unwind: no
# hwloc: no
# debug: no
# static: yes
make -j4
du -h htop
# 2.4M htop
ldd htop
# /lib/ld-musl-x86_64.so.1: htop: Not a valid dynamic program
至此静态编译完成。CentOS 5 成功运行:
0x06 静态编译 btop
htop 是纯 C 写的,而 btop 的主要语言是 C++,因此本文选择 btop 作为 C++ 静态编译的例子。先动态编译:
apk add coreutils-fmt lowdown linux-headers
git clone https://github.com/aristocratos/btop.git --depth=1
cd btop
make ADDFLAGS="-fno-ipa-cp"
# 83% -> obj/btop_tools.o (2.1MiB) (09s)
# 90% -> obj/linux/btop_collect.o (3.2MiB) (12s)
#
# Linking and optimizing binary...
# 100% -> bin/btop (1.8MiB) (17s)
#
# Build complete in (46s)
du -h ./bin/btop
# 1.8M ./bin/btop
ldd ./bin/btop
# /lib/ld-musl-x86_64.so.1 (0x7fa06187b000)
# libstdc++.so.6 => /usr/lib/libstdc++.so.6 (0x7fa061400000)
# libgcc_s.so.1 => /usr/lib/libgcc_s.so.1 (0x7fa0616c9000)
# libc.musl-x86_64.so.1 => /lib/ld-musl-x86_64.so.1 (0x7fa06187b000)
改用静态编译:
make clean
make STATIC=true ADDFLAGS="-fno-ipa-cp"
du -h ./bin/btop
# 4.1M ./bin/btop
ldd ./bin/btop
# /lib/ld-musl-x86_64.so.1: ./bin/btop: Not a valid dynamic program
CentOS 运行成功,不过有点 bug,无法展示程序名:
0x07 静态编译 net-snmp
接下来,我们静态编译一份 net-snmp,包含 snmpd 和 snmpwalk 等工具。先准备源码:
apk add file perl-dev openssl-dev perl-net-snmp perl-tk linux-headers
wget https://github.com/net-snmp/net-snmp/archive/refs/tags/v5.9.4.tar.gz
tar -zxvf v5.9.4.tar.gz
cd net-snmp-5.9.4
尝试动态编译:
./configure --with-defaults
make -j4
编译失败:
libtool: compile: gcc -I../include -I. -I../snmplib -g -O2 -DNETSNMP_ENABLE_IPV6 -fno-strict-aliasing -DNETSNMP_REMOVE_U64 -g -O2 -Ulinux -Dlinux=linux -D_REENTRANT -D_GNU_SOURCE -D_GNU_SOURCE -fwrapv -fno-strict-aliasing -pipe -fstack-protector-strong -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64 -I/usr/lib/perl5/core_perl/CORE -Wall -Wextra -Wstrict-prototypes -Wwrite-strings -Wcast-qual -Wimplicit-fallthrough=2 -Wlogical-op -Wundef -Wno-format-truncation -Wno-missing-field-initializers -Wno-sign-compare -Wno-unused-parameter -c text_utils.c -o text_utils.o >/dev/null 2>&1
In file included from large_fd_set.c:12:
large_fd_set.c: In function 'LFD_SET':
../include/net-snmp/net-snmp-config.h:1614:30: error: unknown type name 'unknown'; did you mean 'union'?
1614 | #define NETSNMP_FD_MASK_TYPE unknown
| ^~~~~~~
large_fd_set.c:97:5: note: in expansion of macro 'NETSNMP_FD_MASK_TYPE'
97 | NETSNMP_FD_MASK_TYPE *fds_array = p->fds_bits;
| ^~~~~~~~~~~~~~~~~~~~
large_fd_set.c:97:39: error: initialization of 'int *' from incompatible pointer type 'long unsigned int *' [-Wincompatible-pointer-types]
97 | NETSNMP_FD_MASK_TYPE *fds_array = p->fds_bits;
| ^
large_fd_set.c: In function 'LFD_CLR':
../include/net-snmp/net-snmp-config.h:1614:30: error: unknown type name 'unknown'; did you mean 'union'?
1614 | #define NETSNMP_FD_MASK_TYPE unknown
| ^~~~~~~
large_fd_set.c:105:5: note: in expansion of macro 'NETSNMP_FD_MASK_TYPE'
105 | NETSNMP_FD_MASK_TYPE *fds_array = p->fds_bits;
| ^~~~~~~~~~~~~~~~~~~~
large_fd_set.c:105:39: error: initialization of 'int *' from incompatible pointer type 'long unsigned int *' [-Wincompatible-pointer-types]
105 | NETSNMP_FD_MASK_TYPE *fds_array = p->fds_bits;
| ^
large_fd_set.c: In function 'LFD_ISSET':
../include/net-snmp/net-snmp-config.h:1614:30: error: unknown type name 'unknown'; did you mean 'union'?
1614 | #define NETSNMP_FD_MASK_TYPE unknown
| ^~~~~~~
large_fd_set.c:113:11: note: in expansion of macro 'NETSNMP_FD_MASK_TYPE'
113 | const NETSNMP_FD_MASK_TYPE *fds_array = p->fds_bits;
| ^~~~~~~~~~~~~~~~~~~~
large_fd_set.c:113:45: error: initialization of 'const int *' from incompatible pointer type 'const long unsigned int *' [-Wincompatible-pointer-types]
113 | const NETSNMP_FD_MASK_TYPE *fds_array = p->fds_bits;
| ^
make[1]: *** [Makefile:100: large_fd_set.lo] Error 1
make[1]: *** Waiting for unfinished jobs....
make[1]: Leaving directory '/root/work/net-snmp-5.9.4/snmplib'
make: *** [Makefile:671: subdirs] Error 1
应用一个 Alpine Linux 打包团队的 patch:
wget https://gitlab.alpinelinux.org/alpine/aports/-/raw/master/main/net-snmp/fix-fd_mask.patch
patch -p1 < fix-fd_mask.patch
./configure --with-defaults
make -j24
动态编译成功。现在改为静态编译:
./configure --with-defaults --with-systemd --enable-static=yes --enable-shared=no
make -j24
du -h ./agent/snmpd
# 8.4M ./agent/snmpd
ldd ./agent/snmpd
# /lib/ld-musl-x86_64.so.1 (0x7f4222478000)
# libcrypto.so.3 => /usr/lib/libcrypto.so.3 (0x7f4221a00000)
# libc.musl-x86_64.so.1 => /lib/ld-musl-x86_64.so.1 (0x7f4222478000)
发现 libc 和 libcrypto 还没有被静态链接进去。我们指定 -static
这个 CFLAGS 再试试:
./configure --with-defaults --with-systemd --enable-static=yes --enable-shared=no --with-openssl --disable-embedded-perl CFLAGS="-static"
然而,编译结果仍然动态链接了 libcrypto.so
。辗转找到一个 iperf 项目的 issue,提到使用 LDFLAGS=--static
以解决问题。重新尝试,果然成功:
./configure --with-defaults --with-systemd --enable-static=yes --enable-shared=no --with-openssl LDFLAGS="--static"
make clean
make -j24
du -h agent/snmpd
# 21.8M agent/snmpd
ldd agent/snmpd
# /lib/ld-musl-x86_64.so.1: agent/snmpd: Not a valid dynamic program
CentOS 可以正常执行 snmpwalk
程序获取其他服务器的信息: