那个周四下午,CI 流水线已经跑了 47 分钟还没结束。我打开日志一看,QEMU 里模拟的 ARM 编译器正在以大约 1/20 的原生速度吭哧吭哧地编译我们的 C++ 项目。一个 make -j4 在 x86 服务器上 3 分钟搞定的事,在模拟环境里变成了一个多小时。这不是第一次了——但这是我们决定把所有 ARM 构建全部切到交叉编译的那一天。

交叉编译不是新鲜事物,但很多人对它的理解停留在"装个交叉编译器然后改一下 CC 变量"的水平。真正动手时会发现:头文件找不到、链接器报奇怪的错误、configure 脚本在各种检测中翻车。这篇文章要做的,是把交叉编译的完整链条拆开——从工具链三元组到 sysroot,从 CMake toolchain file 到容器化构建,每一步都有可复现的命令和踩过的坑。

一、先搞清楚一个核心事实

交叉编译的本质一句话:编译器和目标平台之间隔着一个 ABI 鸿沟,你要做的所有事情都是在桥接这条鸿沟。

很多人以为交叉编译就是"换一个编译器"。不是。编译器只是产生目标平台指令的那一环。真正麻烦的是这四件事:

问题 为什么麻烦 表现
目标平台的头文件 /usr/include 是你宿主机的,不能直接用 fatal error: stdio.h: No such file or directory
目标平台的库 libc.solibpthread.so 都是宿主机的 链接时报 undefined reference 或 wrong ELF class
构建系统的探测逻辑 ./configure 会编译并运行测试程序 能编译但无法运行,配置检测全部失败
运行时行为差异 sizeof(long)、字节序、页大小可能不同 编译通过但运行时崩溃

这四个问题中,前两个靠 sysroot 解决,第三个靠正确的工具链变量和 cache 文件,第四个只能靠对目标平台的了解。下面逐个击破。

二、工具链三元组——名字里藏着一切

交叉编译器通常长这样:aarch64-linux-gnu-gcc。这个名字不是一个随意的标签,它精确描述了三件事,而且顺序是固定的:

1
2
架构-供应商-系统(ABI)
aarch64-linux-gnu
组成部分 含义 常见取值
架构 CPU 指令集 aarch64armv7lriscv64x86_64mips
供应商 工具链提供方,实际影响很小 linuxappleunknown
系统/ABI 操作系统 + C 库 + ABI 约定 gnu(glibc)、muslandroidgnueabihf(ARM 硬浮点)

重点在第三段。gnueabihf 里的 hf 表示硬浮点(hard-float)——浮点参数通过 FPU 寄存器传递;没有 hf 则是软浮点,通过整数寄存器传递。两者二进制不兼容。如果你把硬浮点的 .so 链接到软浮点的程序上,链接器会给你一个含义模糊的"incompatible"错误。

ARM 上尤其容易搞混,因为 ARM 的 ABI 变体太多:

三元组后缀 浮点方式 适用场景
gnueabi 软浮点 无 FPU 的老旧 ARM 芯片
gnueabihf 硬浮点 Cortex-A 系列,带 VFP/NEON
musleabi 软浮点 + musl 极小体积的嵌入式 Linux
musleabihf 硬浮点 + musl 需要硬浮点性能 + 小体积

判断原则:如果你在给 Raspberry Pi 4(Cortex-A72)或更新的 ARM 板子编译,选 gnueabihf。不确定时,到你目标设备的 /lib 下找一个 .so 文件跑 readelf -h,看 Flags 字段有没有 hard-float ABI

有了正确的工具链,接下来要解决的是——编译器怎么找到目标平台的文件。

三、Sysroot——交叉编译中最被低估的概念

sysroot 是一个目录,它模拟了目标平台的根文件系统。编译器在 sysroot 下找头文件和库,而不是在宿主机的 /usr/include/usr/lib 下找。

1
2
3
4
5
6
7
8
9
10
11
sysroot/
├── usr/
│ ├── include/ ← 目标平台的头文件
│ │ ├── stdio.h
│ │ └── ...
│ └── lib/ ← 目标平台的库
│ ├── libc.so
│ ├── libpthread.so
│ ├── crt1.o ← 关键:启动代码
│ └── ...
└── lib/ ← 系统级库(ld-linux.so 等)

获取 sysroot 的三种方式

方式 做法 适用场景
从目标设备复制 rsync -avz raspberrypi:/lib /sysroot/lib 已有运行中的目标设备
使用工具链自带 Linaro、ARM 官方工具链通常自带 标准开发
用构建系统生成 Buildroot/Yocto 编译时自动产出 嵌入式 Linux 发行版定制

第一种最简单直接,也是我推荐初次尝试的做法。插一句:不要只复制 /usr/lib/lib 下的 ld-linux-*.socrt*.o 缺一个就会在链接阶段翻车。建议整根复制:

1
2
3
4
5
6
# 从 Raspberry Pi 拉取完整 sysroot(Pi 上需开启 SSH)
rsync -avz --copy-links \
pi@192.168.1.100:/lib \
pi@192.168.1.100:/usr/include \
pi@192.168.1.100:/usr/lib \
~/rpi-sysroot/

--copy-links 很关键——目标设备上的库符号链接指向具体版本(如 libc.so -> libc-2.31.so),不用这个参数复制过来的是死链接,链接器找不到真实文件。

验证 sysroot 是否正确

拿到 sysroot 后,用一行命令验证:

1
2
3
4
5
6
7
# 编译一个最小程序,指定 sysroot
aarch64-linux-gnu-gcc --sysroot=$HOME/rpi-sysroot \
-x c - <<< 'int main(){return 0;}' -o /tmp/test_arm

# 检查产物是否是 ARM 二进制
file /tmp/test_arm
# 输出应为: ELF 64-bit LSB executable, ARM aarch64, ...

如果 file 输出显示 ARM aarch64,说明工具链和 sysroot 都对齐了。如果链接阶段报 cannot find /lib/ld-linux-aarch64.so.1,说明 sysroot 残缺——检查你是否漏了 /lib 下的动态链接器。

四、CMake 工具链文件——一次配置,永远告别手动设变量

手工传 --sysroot-I-L 只适合验证阶段。真项目必须用 CMake toolchain file。它把目标平台的所有信息写进一个文件,之后 cmake -DCMAKE_TOOLCHAIN_FILE=... 一行搞定。

以下是一个可直接用于 Raspberry Pi 4(aarch64)的 toolchain 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# rpi4-toolchain.cmake
# 用法: cmake -DCMAKE_TOOLCHAIN_FILE=rpi4-toolchain.cmake -B build

# 目标系统
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)

# 工具链前缀——编译器就是 ${前缀}gcc,链接器就是 ${前缀}ld
set(TOOLCHAIN_PREFIX /usr/bin/aarch64-linux-gnu)
set(CMAKE_C_COMPILER ${TOOLCHAIN_PREFIX}-gcc)
set(CMAKE_CXX_COMPILER ${TOOLCHAIN_PREFIX}-g++)

# sysroot:关键!覆盖默认的头文件和库搜索路径
set(CMAKE_SYSROOT /home/user/rpi-sysroot)

# 告诉 cmake 不要尝试运行编译出的程序来探测系统能力
# 这是交叉编译下最重要的一个变量——不设这行,绝大多数 cmake 检测会失败
set(CMAKE_CROSSCOMPILING TRUE)

# 跳过 try_run:只编译不运行
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)

# 指定 pkg-config 搜索路径(很多库依赖 pkg-config 提供编译选项)
set(ENV{PKG_CONFIG_PATH} "${CMAKE_SYSROOT}/usr/lib/aarch64-linux-gnu/pkgconfig")
set(ENV{PKG_CONFIG_SYSROOT_DIR} "${CMAKE_SYSROOT}")

# 查找程序时只在 sysroot 内找,不要找到宿主机的程序
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)

最后四个 CMAKE_FIND_ROOT_PATH_MODE_* 值得展开说一下:

变量 取值 含义
PROGRAM NEVER 不找宿主机的程序(如代码生成器应该在宿主机编译)
LIBRARY ONLY 只从 sysroot 找库
INCLUDE ONLY 只从 sysroot 找头文件
PACKAGE ONLY 只从 sysroot 找 CMake 包配置

PROGRAMNEVER 有一个重要的含义:如果你的项目在构建时需要运行一个宿主机上的代码生成工具(比如 protobuf 编译器、自定义的 IDL 处理器),你需要单独为宿主机编译它,而不是在交叉编译时连带它一起编译。实践中可以把这类工具拆成独立的 CMake 子项目,先 native 编译安装,再交叉编译主项目时通过 find_program 找到它。

配置和构建

1
2
3
4
5
cmake -DCMAKE_TOOLCHAIN_FILE=rpi4-toolchain.cmake \
-DCMAKE_BUILD_TYPE=Release \
-B build/rpi4

cmake --build build/rpi4 -j$(nproc)

如果你的 CMakeLists.txt 写得规范(使用 find_package 而不是硬编码路径),切到交叉编译通常只需要这一个 toolchain 文件。额外需要关心的只是目标平台的依赖库是否都已存在于 sysroot 中。

五、Autotools 和 Makefile 的交叉编译——老项目的生存指南

不是所有项目都用了 CMake。大量 C 基础设施(OpenSSL、libffi、zlib 等)还在用 autotools 或手写 Makefile。它们不认 CMake toolchain file,你得用环境变量告诉它们一切。

Autotools(configure 脚本)

1
2
3
4
5
6
7
8
9
10
11
12
13
# 三个关键环境变量
export CC=aarch64-linux-gnu-gcc
export CXX=aarch64-linux-gnu-g++
export AR=aarch64-linux-gnu-ar

# configure 参数
./configure \
--host=aarch64-linux-gnu \ # 目标平台三元组
--prefix=/usr \ # 安装前缀(进入 sysroot 的路径)
--with-sysroot=$HOME/rpi-sysroot

make -j$(nproc)
make install DESTDIR=$HOME/rpi-sysroot # 安装进 sysroot

--host--build 的区别经常让人搞混:

参数 含义 什么时候需要显式指定
--build 当前编译发生的机器(宿主机) configure 通常能自动检测,一般不用设
--host 产物将要运行的机器(目标) 交叉编译必须设,否则 configure 不知道你在交叉编译
--target 编译器本身产出的代码跑在什么机器上 只在编译编译器时用(如编译交叉 GCC 本身)

绝大多数情况你只需要设 --host。设了它之后,configure 会知道你交叉编译,跳过那些需要运行程序的探测。

手写 Makefile

手写 Makefile 的交叉编译支持通常靠变量约定:

1
2
3
4
5
make CC=aarch64-linux-gnu-gcc \
CXX=aarch64-linux-gnu-g++ \
AR=aarch64-linux-gnu-ar \
CFLAGS="--sysroot=$HOME/rpi-sysroot" \
LDFLAGS="--sysroot=$HOME/rpi-sysroot"

不规范的 Makefile 可能硬编码了 gcc 而不是用 $(CC) 变量,这种只能改 Makefile 或者用 PATH 欺骗:

1
2
3
# 不优雅但有效:临时覆盖 PATH
export PATH=/usr/arm-toolchain/bin:$PATH
# 然后创建一个名为 gcc 的符号链接指向交叉编译器

老实说,遇到硬编码编译器的 Makefile,我的建议是:花 30 分钟给它写个 CMakeLists.txt,比跟它较劲三小时强。

六、容器化交叉编译——CI 环境的终极方案

在 CI 流水线里,你不会想在每个 runner 上手动装工具链和 sysroot。容器化是正道。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Dockerfile.cross-arm64
FROM debian:bookworm

# 安装 ARM64 交叉工具链
RUN dpkg --add-architecture arm64 && \
apt update && \
apt install -y \
crossbuild-essential-arm64 \
cmake make \
# 目标平台的运行时库(这部分会成为 sysroot 的一部分)
libstdc++6:arm64 \
libc6-dev:arm64 \
libssl-dev:arm64

# 编译器自动安装在 /usr/bin/aarch64-linux-gnu-*
# 库和头文件在 /usr/lib/aarch64-linux-gnu/ 和 /usr/include/
# Debian 的多架构体系已经天然形成了一个 sysroot

ENV PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig

Debian/Ubuntu 的 multiarch 体系是交叉编译的神器。apt install libssl-dev:arm64 会把 ARM64 版本的 libssl 头文件和库装到正确的位置,和宿主 x86_64 的文件互不干扰。你的 sysroot 就是根目录本身——编译器通过三元组前缀自动找到正确的架构子目录。

配套的 CMake toolchain file 可以精简很多:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Debian cross toolchain —— 极简版
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)

set(CMAKE_C_COMPILER aarch64-linux-gnu-gcc)
set(CMAKE_CXX_COMPILER aarch64-linux-gnu-g++)

# Debian multiarch 下不需要单独指定 sysroot
# 编译器自己知道去 /usr/lib/aarch64-linux-gnu/ 找

set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)

配合 CI 使用:

1
2
3
4
5
6
7
8
9
10
11
# .github/workflows/build-arm.yml
build-arm64:
runs-on: ubuntu-latest
container:
image: my-cross-arm64:latest
steps:
- uses: actions/checkout@v4
- run: cmake -DCMAKE_TOOLCHAIN_FILE=cmake/debian-cross-arm64.cmake -B build
- run: cmake --build build -j$(nproc)
- run: file build/myapp
# 输出应包含 ARM aarch64

这套方案的优势是 sysroot 管理完全交给了包管理器——apt install 什么,编译器就能找到什么。不用手动 rsync、不用手动解压 tar 包,依赖关系 apt 帮你理清楚。

七、五个我踩过的坑

1. try_run 失败不一定是坏事——但你要知道它为什么失败。

CMake 在做 check_c_source_runs 时会尝试编译+运行。交叉编译下运行必然失败,但 CMake 会把"运行失败"等同于"功能不支持",导致 HAVE_PTHREAD 之类的宏被错误定义为 0。用 CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY 让它只编译不运行,然后用 cache 文件手动指定你已经知道的平台能力。

2. 静态链接时 crt0.o 架构不匹配。

如果你选择静态链接(-static),链接器会找一个叫 crt0.ocrt1.o 的启动文件。如果你混用了宿主机和目标的 crt 文件,链接没问题,但程序跑不起来。确保 --sysroot 正确,或者用 gcc -print-sysroot 确认编译器实际在用什么 sysroot。

3. pkg-config 悄悄找到了宿主机的 .pc 文件。

交叉编译的经典翻车场景:你忘了设 PKG_CONFIG_SYSROOT_DIRpkg-config --cflags openssl 返回的是 /usr/include 而不是 sysroot 下的路径。编译不报错,但链接时发现二进制混合了两种架构的代码。你必须同时PKG_CONFIG_PATHPKG_CONFIG_SYSROOT_DIR

1
2
3
4
5
export PKG_CONFIG_PATH=/sysroot/usr/lib/aarch64-linux-gnu/pkgconfig
export PKG_CONFIG_SYSROOT_DIR=/sysroot
# 然后验证:
pkg-config --cflags openssl
# 输出路径应该以 /sysroot 开头,而不是 /

4. sizeof() 相关的宏在交叉编译下是猜的。

autotools 的 AC_CHECK_SIZEOF 和 CMake 的 check_type_size 在交叉编译下编译了测试程序但没法运行,只能猜测。如果你的代码依赖 sizeof(void*)sizeof(long),建议手工在 cache 文件或 CMake 中指定——或者更根本地,用 uintptr_t 代替 long,用固定宽度整数类型消除平台差异。

5. Go 和 Rust 的交叉编译比 C/C++ 简单一个数量级。

这不是坑,是一个提醒。如果你开始一个新项目且需要交叉编译,Go 的 GOOS=linux GOARCH=arm64 go build 和 Rust 的 cargo build --target aarch64-unknown-linux-gnu 几乎零配置。它们自带 sysroot(标准库静态编译进产物)且不依赖系统 C 库(Go 完全自举,Rust 用 musl 可去掉 glibc 依赖)。在某些场景下,换语言比折腾工具链更划算。

交叉编译真正的门槛从来不是编译器本身,而是你对"一个程序从源码到可执行文件到底依赖了什么"这件事的理解深度。工具链只是镜子,照出你知识链上的每一处模糊。