#!/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 <输出文件> —— 多个地址依次试。 # 各家下载站的目录结构会变(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 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"