Files
ligent/tools/build-static-deps.sh
2026-10-03 14:56:56 +08:00

364 lines
18 KiB
Bash
Executable File

#!/bin/sh
# build-static-deps.sh - 造一份"musl 工具链编出来的静态 libcurl", 给 make static 用.
#
# 为什么需要它: 发行版的 libcurl.a 是针对 glibc 编的, 跟 musl 混link会得到一堆
# "undefined reference to __libc_start_main". 所以必须自己用 musl 工具链把
# libcurl 和它的依赖 (zlib、OpenSSL)编一遍.
#
# 产物在 build/static-prefix/, 之后 `make static` 就能链了.
#
# 用法:
# ./tools/build-static-deps.sh # 用默认版本
# ZLIB=1.3.1 OPENSSL=3.0.15 CURL=8.11.1 ./tools/build-static-deps.sh
# CC=x86_64-linux-musl-gcc ./tools/build-static-deps.sh
# PREFIX=/opt/musl-static ./tools/build-static-deps.sh
#
# 需要: musl 工具链、make、perl (OpenSSL 的 Configure 是 perl 写的)、以及
# **能联网** (要下载三个源码包). 构建大约几分钟.
set -eu
ZLIB_VER=${ZLIB:-1.3.1}
OPENSSL_VER=${OPENSSL:-3.0.15}
CURL_VER=${CURL:-8.11.1}
ROOT=$(cd "$(dirname "$0")/.." && pwd)
PREFIX=${PREFIX:-$ROOT/build/static-prefix}
SRC=${SRC:-$ROOT/build/static-src}
JOBS=${JOBS:-$( (nproc 2>/dev/null) || echo 2 )}
# musl-gcc 是包装脚本; 交叉工具链要自己指定
if [ -z "${CC:-}" ]; then
if command -v musl-gcc >/dev/null 2>&1; then
CC=musl-gcc
elif command -v x86_64-linux-musl-gcc >/dev/null 2>&1; then
CC=x86_64-linux-musl-gcc
else
echo "找不到 musl 工具链. " >&2
echo " Artix/Arch: sudo pacman -S musl # 提供 musl-gcc" >&2
echo " 或指定: CC=x86_64-linux-musl-gcc $0" >&2
exit 1
fi
fi
need() {
command -v "$1" >/dev/null 2>&1 || { echo "缺少 $1, 装一下" >&2; exit 1; }
}
need make
need "$CC"
need perl # OpenSSL 的 Configure
# 下载靠它. 以前这里写的是 `need curl || true` —— 那是**假的容错**: need 失败时
# 直接 exit, `|| true` 根本执行不到 (那句注释"下面会再确认"也不成立). 于是缺
# curl 时脚本会一路跑到 fetch, 最后报"所有候选地址都不可用 / 要么是没网, 要么
# 是版本号写错了", 把人引去查网络和版本号 —— 而真正的原因是工具没装.
need curl
need tar
# ---------------------------------------------------------------- 架构
# OpenSSL 的 Configure 要一个明确的 target 名, 它**不做**自动探测. 以前这里
# 写死 linux-x86_64, 于是在 aarch64/riscv64 上会去编 x86 的汇编, 直接失败 ——
# 而这个脚本的产物恰恰是"拷到别的机器也能跑"的静态件.
# 认不出来时**不猜**: 猜错的后果是编出一堆看着像成功、其实不对的东西.
OPENSSL_TARGET=${OPENSSL_TARGET:-}
if [ -z "$OPENSSL_TARGET" ]; then
case "$(uname -m)" in
x86_64|amd64) OPENSSL_TARGET=linux-x86_64 ;;
i?86) OPENSSL_TARGET=linux-elf ;;
aarch64|arm64) OPENSSL_TARGET=linux-aarch64 ;;
armv7l|armv6l) OPENSSL_TARGET=linux-armv4 ;;
riscv64) OPENSSL_TARGET=linux64-riscv64 ;;
ppc64le) OPENSSL_TARGET=linux-ppc64le ;;
s390x) OPENSSL_TARGET=linux64-s390x ;;
loongarch64) OPENSSL_TARGET=linux64-loongarch64 ;; # 3.1+ 才有
*)
echo "认不出这台机器的架构 ($(uname -m)), 而 OpenSSL 的 Configure" >&2
echo "需要一个明确的 target 名. 请显式指定, 例如: " >&2
echo " OPENSSL_TARGET=linux-aarch64 $0" >&2
echo " (可用的 target 见 OpenSSL 源码里的 Configurations/10-main.conf)" >&2
exit 1 ;;
esac
fi
# 刻意**不带** -fno-pie. 依赖库编成默认的 PIE 兼容代码就行 —— PIC 代码放进
# 非 PIE 可执行文件是合法的, 最后那一步由 ligent 自己用 -no-pie 决定.
# 反过来 (依赖 -fno-pie、链接却不带 -no-pie)会立刻炸: 这些项目在 make 里
# 还会顺手链几个测试程序, 而那些链接用的是默认的 PIE, 于是
# relocation R_X86_64_32 against ... can not be used when making a PIE object
# 而且 zlib 的 configure 探测 vsnprintf 也会因此失败, 悄悄退化成不安全的
# vsprintf 版本 (实测踩过).
# 体积优先. 静态产物是"拷过去就能跑"的发布件, 不是拿来调试的, 所以这里的
# 取舍和普通构建相反 —— 目标是越小越好. 三组开关要一起上才有意义:
# -Os 别做以体积换速度的展开/内联
# -ffunction-sections 依赖库也按函数分段, 这样最后链接时
# -fdata-sections --gc-sections 才**丢得掉**没被引用的部分
# -fno-*-unwind-tables C 代码不需要 C++ 异常和栈回溯, .eh_frame 全省
# 实测 (连同 Makefile 那侧的 strip 等一起): 6.24 MB → 3.29 MB.
# 其中这一半的功劳很大: libcurl+OpenSSL 的 .text 从 3.45 MB 掉到 2.41 MB ——
# 静态归档里本来就有一大堆我们永远走不到的算法和协议实现.
# 想回到调试友好 (-O2、保留 unwind 表): CFLAGS_COMMON="-O2 $0"
CFLAGS_COMMON=${CFLAGS_COMMON:-"-Os -ffunction-sections -fdata-sections -fno-asynchronous-unwind-tables -fno-unwind-tables"}
# musl-gcc 只搜 /usr/lib/musl/include, **不搜 /usr/include**, 于是内核的 UAPI
# 头 (linux/mman.h、asm/*.h 这些)找不到 —— OpenSSL 就死在这一步:
# fatal error: linux/mman.h: No such file or directory
# 用 -idirafter: musl 自己的头优先, /usr/include 只在 musl 没有时兜底.
# 直接写 -I 会让 glibc 的头盖过 musl 的, 那是另一个坑.
UAPI_INC="-idirafter /usr/include"
# ---- configure 参数 (抽出来是为了进 WANT, 见下面的 stamp 机制)----
#
# 以前这些参数直接写在各自的 configure 调用处, 于是"改了参数"这件事**只有人
# 记得把 BUILD_DEFS 加一**才能被发现; 忘了就继续用旧参数编出来的库, 症状是
# "我明明加了个 --disable-x, 怎么产物一点没变".
# zlib 只要 --static: 我们要的是 libz.a
ZLIB_CONFIGURE_ARGS="--static"
# OpenSSL: no-shared 只出 .a; no-tests 少编一堆测试程序 (省几分钟);
# no-legacy 去掉已淘汰的算法 (体积).
OPENSSL_CONFIGURE_ARGS="no-shared no-tests no-legacy"
# curl: ligent 只用 chat/completions 一个端点, 所以只要能发 HTTPS 的那部分.
# --disable-* FTP/邮件/字典/… 这些协议编进来纯属占地
# --without-* 静态链接下每个"可选增强"都会再拖一串依赖进来 (libpsl、idn2、
# brotli、zstd、nghttp2/3…), 而 ligent 一个都不用
# --with-ca-* 把 CA 路径编进去 —— 静态件跑到别的机器上时得知道去哪找根证书
CURL_CONFIGURE_ARGS="
--disable-shared --enable-static
--with-openssl=$PREFIX --with-zlib=$PREFIX
--disable-ldap --disable-ldaps --disable-rtsp --disable-dict
--disable-telnet --disable-tftp --disable-pop3 --disable-imap
--disable-smtp --disable-gopher --disable-mqtt --disable-manual
--without-libpsl --without-libidn2 --without-brotli --without-zstd
--without-nghttp2 --without-ngtcp2 --without-nghttp3
--with-ca-bundle=/etc/ssl/certs/ca-certificates.crt
--with-ca-path=/etc/ssl/certs"
mkdir -p "$SRC" "$PREFIX"
# 编译选项变了就得把依赖整个重建. 否则下面每个守卫都会看到 libz.a 已存在而
# 直接跳过, 于是你一直用着旧选项编出来的库, 而且**看不出来** —— 症状是
# "我明明加了 -Os, 怎么产物一点没小".
# 这和 Makefile 里"对象目录按编译器/插桩分标签"是同一个坑的两处: make 和这个
# 脚本都只看文件在不在, 看不见命令行变了.
STAMP="$PREFIX/.build-flags"
# BUILD_DEFS: 配方版本号, 留给"改了脚本逻辑、但 WANT 里的文本一个字都没变"的
# 改动 (比如换了一种绕坑的处理方式). 凡是会改变产物内容的改动都要加一.
#
# 注意它现在**不再是唯一的触发源**: 编译选项、三个版本号、OpenSSL target、
# 每个库的 configure 参数都直接进了下面的 WANT, 改它们会自动触发重建, 不需要
# 谁记得来这里加一 —— 以前 configure 参数不在 WANT 里, 全靠这个手写数字兜着,
# 而人是会忘的 (忘了就是"一直用旧参数编出来的库, 看不出来").
# 已有的历史值: 1 = 加了体积优先的 CFLAGS; 2 = configure 期间藏起 .pc 文件;
# 3 = 架构与 configure 参数进指纹 (本版).
BUILD_DEFS=3
# 多行参数折叠成一行: 好塞进戳文件, 也好肉眼比对
flatten() {
printf '%s' "$1" | tr -s '[:space:]' ' ' | sed 's/^ *//; s/ *$//'
}
WANT="defs=$BUILD_DEFS | CC=$CC | zlib=$ZLIB_VER openssl=$OPENSSL_VER curl=$CURL_VER \
| target=$OPENSSL_TARGET | cflags=$CFLAGS_COMMON | uapi=$UAPI_INC \
| zlib:[$(flatten "$ZLIB_CONFIGURE_ARGS")] \
| openssl:[$(flatten "$OPENSSL_CONFIGURE_ARGS")] \
| curl:[$(flatten "$CURL_CONFIGURE_ARGS")]"
if [ ! -f "$STAMP" ] && [ -f "$PREFIX/lib/libz.a" ]; then
# 有库却没有选项记录 —— 那是**旧版脚本**编的, 不知道用的什么选项.
# 这一条不能省: 没有它, 下面的判断会认为"stamp 不存在 = 不用重建",
# 然后我们就给旧库写上一个声称是新选项的 stamp, 把它洗白成"新的".
echo "== 这个前缀没有编译选项记录 (旧版脚本编的), 重建以确认选项一致"
rm -rf "$PREFIX"
mkdir -p "$PREFIX"
elif [ -f "$STAMP" ] && [ "$(cat "$STAMP" 2>/dev/null)" != "$WANT" ]; then
echo "== 编译选项变了, 重建依赖"
echo " 原: $(cat "$STAMP")"
echo " 新: $WANT"
rm -rf "$PREFIX"
mkdir -p "$PREFIX"
fi
printf '%s' "$WANT" > "$STAMP"
# ---- 被打断时把藏起来的 .pc 放回去 ----
# curl 那一段会在 configure 期间把 pkgconfig 目录藏起来 (原因见下面那段长注释).
# 那一步要几分钟, 中途 Ctrl-C 很常见 —— 不兜住的话, 用户的 openssl.pc 就会在这
# 段时间里"消失" (他编别的东西时会找不到). 下一轮跑本脚本虽然会自愈, 但空窗期
# 是真实存在的, 而且没人知道自己在等什么.
PCDIR="$PREFIX/lib/pkgconfig"
PCHIDE="$PREFIX/lib/pkgconfig.hidden"
restore_pc() {
if [ -d "$PCHIDE" ] && [ ! -d "$PCDIR" ]; then
echo "== 把 pkgconfig 目录放回原位 (上一次跑本脚本被打断过)" >&2
mv "$PCHIDE" "$PCDIR"
fi
}
trap 'restore_pc' EXIT INT TERM HUP
# fetch <输出文件> <url...> —— 多个地址依次试.
# 各家下载站的目录结构会变 (zlib 就把旧版本挪到了 /fossils/), 写死一个地址
# 迟早会 404, 而那时的报错是 curl 的一句 "error: 404", 看不出该改哪.
fetch() {
out=$1; shift
[ -f "$out" ] && { echo " 已有 $(basename "$out"), 跳过下载"; return; }
for url in "$@"; do
echo " 下载 $url"
if curl -fL --retry 3 --connect-timeout 20 -o "$out.part" "$url"; then
mv "$out.part" "$out"
return 0
fi
echo " 这个地址不行, 换下一个"
rm -f "$out.part"
done
echo " 下载 $(basename "$out") 失败: 所有候选地址都不可用. " >&2
echo " 要么是没网, 要么是版本号写错了. 可以指定版本重来, 例如: " >&2
echo " ZLIB=1.3.1 OPENSSL=3.5.0 CURL=8.11.1 $0" >&2
exit 1
}
echo "== 目标前缀: $PREFIX"
echo "== 工具链 : $CC ($($CC --version 2>&1 | head -1))"
echo
# ---------------------------------------------------------------- zlib
if [ ! -f "$PREFIX/lib/libz.a" ]; then
echo "== 编 zlib $ZLIB_VER"
fetch "$SRC/zlib-$ZLIB_VER.tar.gz" \
"https://zlib.net/zlib-$ZLIB_VER.tar.gz" \
"https://zlib.net/fossils/zlib-$ZLIB_VER.tar.gz" \
"https://github.com/madler/zlib/releases/download/v$ZLIB_VER/zlib-$ZLIB_VER.tar.gz"
rm -rf "$SRC/zlib-$ZLIB_VER"
tar -xzf "$SRC/zlib-$ZLIB_VER.tar.gz" -C "$SRC"
( cd "$SRC/zlib-$ZLIB_VER" &&
CC="$CC" CFLAGS="$CFLAGS_COMMON" \
./configure $ZLIB_CONFIGURE_ARGS --prefix="$PREFIX" &&
make -j"$JOBS" libz.a && make install )
else
echo "== zlib 已就绪"
fi
# ------------------------------------------------------------- OpenSSL
# curl 要 HTTPS 就得有个 TLS 后端. 选 OpenSSL 是因为它最普遍; 想更小的话
# 可以换 mbedTLS (configure 里把 --with-openssl 换成 --with-mbedtls).
# 守卫必须看 OpenSSL **实际**装到哪. x86_64 上 OpenSSL 3 默认装 lib64,
# 而下面 --libdir=lib 把它拉回 lib —— 只查 lib 就会每次重编 OpenSSL (几分钟).
if [ ! -f "$PREFIX/lib/libssl.a" ]; then
echo
echo "== 编 OpenSSL $OPENSSL_VER (这一步最慢)"
fetch "$SRC/openssl-$OPENSSL_VER.tar.gz" \
"https://www.openssl.org/source/openssl-$OPENSSL_VER.tar.gz" \
"https://github.com/openssl/openssl/releases/download/openssl-$OPENSSL_VER/openssl-$OPENSSL_VER.tar.gz"
rm -rf "$SRC/openssl-$OPENSSL_VER"
tar -xzf "$SRC/openssl-$OPENSSL_VER.tar.gz" -C "$SRC"
( cd "$SRC/openssl-$OPENSSL_VER" &&
CC="$CC" ./Configure "$OPENSSL_TARGET" $OPENSSL_CONFIGURE_ARGS \
--prefix="$PREFIX" --openssldir="$PREFIX/ssl" --libdir=lib \
$CFLAGS_COMMON "$UAPI_INC" &&
make -j"$JOBS" && make install_sw )
else
echo "== OpenSSL 已就绪"
fi
# ---------------------------------------------------------------- curl
if [ ! -x "$PREFIX/bin/curl-config" ]; then
echo
echo "== 编 curl $CURL_VER"
fetch "$SRC/curl-$CURL_VER.tar.gz" \
"https://curl.se/download/curl-$CURL_VER.tar.gz" \
"https://github.com/curl/curl/releases/download/curl-$CURL_VER/curl-$CURL_VER.tar.gz"
rm -rf "$SRC/curl-$CURL_VER"
tar -xzf "$SRC/curl-$CURL_VER.tar.gz" -C "$SRC"
# ---- configure 期间把 .pc 文件藏起来, 别让它走 pkg-config ----
#
# curl 的 configure 是这么挑 OpenSSL 的 (--with-openssl 给的是自定义前缀):
# OPENSSL_PCDIR="$OPT_OPENSSL/lib/pkgconfig"
# if test -f "$OPENSSL_PCDIR/openssl.pc"; then PKGTEST="yes" # 用 pkg-config
# ...
# if test "$PKGTEST" != "yes"; then
# SSL_LDFLAGS="-L$PREFIX_OPENSSL/lib" # 直接给目录, 路径原样
#
# 走 pkg-config 那条路会出事: configure 开头就 `LC_ALL=C; export LC_ALL`
# (autoconf 的标准开头, 脚本外面设 LC_ALL 压不住它), 而 pkg-config 在
# C locale 下用 isprint() 判断字节要不要转义 —— 每个 >= 0x80 的字节都被判成
# "不可打印", 于是它给路径里每个非 ASCII 字节前面加个反斜杠:
# -L/…/开发/… -> -L/…/\开\发/…
# \ 后面跟 E5 不是合法续字节, 所以那串是**非法 UTF-8**. curl 拿到就写进
# curl-config、libcurl.pc 和一堆 Makefile (实测 26 个文件).
#
# 后果不只是难看: 之后 `make static` 里 `curl-config --static-libs` 一打印,
# 坏字节就顺着工具输出进了对话历史, 服务端会以
# invalid unicode code point
# 拒掉**整个**请求, 而且坏数据已在历史里, 之后每次请求都失败.
#
# 本仓库路径带中文, 所以必中; 纯 ASCII 路径的人永远碰不到.
# 让 .pc 暂时消失, curl 就退回上面那条"直接给目录"的路, 路径干干净净.
# 藏而不是删: 这些 .pc 是 OpenSSL/zlib 正常安装的一部分, 别人可能要用.
# PCDIR/PCHIDE 和恢复用的 trap 在脚本开头 (restore_pc): 这几步要几分钟,
# 中途 Ctrl-C 很常见, 不能让 .pc 在没人知道的情况下"消失"一段.
restore_pc # 上一次被打断留下的隐藏目录, 先放回去
if [ -d "$PCDIR" ]; then mv "$PCDIR" "$PCHIDE"; fi
rc=0
# shellcheck disable=SC2086 # configure 参数就该按空白摊开成多个词
( cd "$SRC/curl-$CURL_VER" &&
CC="$CC" CFLAGS="$CFLAGS_COMMON" \
CPPFLAGS="-I$PREFIX/include $UAPI_INC" LDFLAGS="-L$PREFIX/lib" \
./configure --prefix="$PREFIX" $CURL_CONFIGURE_ARGS \
&& make -j"$JOBS" && make install ) || rc=$?
restore_pc
if [ "$rc" -ne 0 ]; then
echo "curl 构建失败 (退出码 $rc)" >&2
exit "$rc"
fi
else
echo "== curl 已就绪"
fi
# ------------------------------------------------------------ 自检
# 光看文件在不在不够 —— 要真的**链一个用 curl 的静态程序**, 才能证明这份
# 前缀是可用的 (静态链接最常栽在依赖顺序和缺库上, 而那只在链接时暴露).
echo
echo "== 自检: 编一个最小程序并静态链接"
CHECK="$SRC/check.c"
cat > "$CHECK" <<'EOF'
#include <curl/curl.h>
int main(void) {
CURL *c = curl_easy_init();
if (!c) return 1;
curl_easy_setopt(c, CURLOPT_URL, "https://example.com/");
curl_easy_cleanup(c);
return 0;
}
EOF
LIBS=$("$PREFIX/bin/curl-config" --static-libs)
# shellcheck disable=SC2086
"$CC" -static -o "$SRC/check" "$CHECK" -I"$PREFIX/include" $LIBS -pthread
if ldd "$SRC/check" 2>&1 | grep -q "not a dynamic executable\|statically linked\|不是动态可执行文件"; then
echo " 静态链接成功, 且确实不依赖动态库"
else
echo " 注意: ldd 没说它是静态的, 检查一下下面这行输出: " >&2
ldd "$SRC/check" 2>&1 | head -3 >&2
fi
# curl-config 的输出必须是**合法 UTF-8**.
# 它在 `make static` 里会被原样打印出来 (recipe 里那句 `echo " 依赖: $LIBS"`),
# 于是会进工具输出、进对话历史、最后进请求体. 一旦含非法字节, 服务端会以
# "invalid unicode code point" 拒掉整个请求 —— 而且坏数据已经留在历史里,
# 之后每次请求都会失败. 上面 curl 那段的长注释解释了坏字节是怎么来的.
# 用 iconv 做往返校验: 截断的/被转义的序列都会被它拒掉. 没有 iconv 就跳过.
if command -v iconv >/dev/null 2>&1; then
if "$PREFIX/bin/curl-config" --static-libs 2>/dev/null \
| iconv -f UTF-8 -t UTF-8 > /dev/null 2>&1; then
echo " curl-config --static-libs 输出是合法 UTF-8"
else
echo " 错误: curl-config --static-libs 的输出含非法 UTF-8. " >&2
echo " 这通常意味着 configure 走了 pkg-config 那条路 (见上面 curl 那段). " >&2
echo " 删掉这个前缀重跑本脚本: rm -rf \"$PREFIX\"" >&2
exit 1
fi
fi
echo
echo "依赖就绪: $PREFIX"
echo "接下来: make static"