你有没有遇过这种情况:手边是一台 x86 架构的 Ubuntu 20.04 电脑,开发板却是 ARM 的,刚写好一个 C++ 程序,想在板子上跑,结果直接拿系统的 gcc 编了一下,拷上去执行就报Exec format error。其实原因不复杂——你用的是 x86 的编译器,编出来的二进制基于 x86 指令集,ARM 处理器根本读不懂。
这套东西,行业里叫ARM 架构与交叉编译。所谓交叉编译,就是在一台 CPU 架构与目标机器不同的主机上,编译出目标机能够直接运行的二进制。ARM 架构则是目前嵌入式领域应用最广泛的芯片体系,手机、路由器、电视盒子、智能门锁、各类开发板,背后几乎都是它。这篇文章我会从 ARM 架构的基础差异讲起,说清楚交叉编译的核心机制、工具链怎么选、参数怎么配,然后带你从 hello world 一路走到 Qt 5.12.10、OpenSSL、strongswan 这些真实项目里高频出现的场景。适合刚入门嵌入式 Linux、想把程序从 x86 电脑搬到 ARM 开发板的同学,也适合被工具链报错和链接库折腾过、想真正弄明白背后原理的人。
1. 先搞明白:ARM 架构与交叉编译到底解决什么问题
1.1 ARM 架构不是一块芯片,而是一整套体系
很多人第一次接触 ARM 的时候,会以为 ARM 就是某个具体的芯片型号,比如“我手上这块板子是 ARM 的”。其实 ARM 是一家做 IP 授权的公司,自己不直接生产芯片,而是把指令集架构授权给高通、联发科、全志、瑞芯微、博通这些芯片厂商。所以你会看到同样是 ARM 平台,树莓派、RK3399、STM32 的内部实现差异巨大,但它们底层的指令集规范是同源的,编译出来的机器码都遵循 ARM 定义的规则。
从应用场景上,ARM 家族一般分成三条线:
- Cortex-A:面向应用处理器,运行 Linux、Android,性能最强,比如树莓派里的 SoC、手机主控。
- Cortex-R:面向实时控制场景,常用于汽车电子、工业控制。
- Cortex-M:面向微控制器,功耗和成本极低,典型代表就是 STM32 单片机。
从指令集维度看,ARMv7 及之前的版本基本都是 32 位,ARMv8 开始引入 64 位的 AArch64 执行状态。这个 32 位和 64 位的区别,会直接决定你选哪一套交叉编译工具链。很多老开发板比如 NanoPi NEO、树莓派 2B,默认跑的是 32 位 ARMv7 系统;而 RK3399、树莓派 4B 的 64 位系统就是 AArch64。拿到板子第一步不是急着编译,而是先确认它是什么架构、什么 ABI,这一步错后面全错。
1.2 为什么不能拿着 x86 的编译器直接编
原因一句话:CPU 不认识对方的机器码。x86 和 ARM 采用的是完全不同的指令集体系,x86 是复杂指令集 CISC,指令长度可变、指令功能复合;ARM 是精简指令集 RISC,指令定长、指令语义更简单。C/C++ 源码经过编译器处理后,会先变成目标 ISA(指令集架构)的汇编指令,再被汇编器转成对应的机器码。换一个 ISA,目标 CPU 就完全没有办法解释执行这些机器码。
那为什么不直接在开发板上编译呢?两个原因。第一是性能问题,嵌入式设备的 CPU 和大内存远不如 x86 工作站,编译一个 Qt 或者 llama.cpp 这种体量的项目,在板子上可能要跑一晚上,主机上几分钟十几分钟就能完成。第二是很多嵌入式板子根本没装完整的编译工具链,你连gcc都未必找得到。就算装了,内存不够也会把编译直接 OOM 掉。
所以交叉编译就成了嵌入式 Linux 开发的日常工作。你需要的其实是三样东西:交叉编译器、交叉汇编器、交叉链接器,以及目标机对应的头文件和库。它们组合在一起,就是一套完整的交叉编译工具链。
2. 交叉编译工具链:选型、安装与核心参数
2.1 工具链三段式命名规则:看名字就知道能不能用
交叉编译工具链的命名是有规则的,基本上遵循“目标架构-目标系统-C库/ABI”这个三段式。以最常见的arm-linux-gnueabihf-gcc为例,拆开看:
arm:目标架构是 ARM,一般在 32 位场景下出现。linux:目标操作系统是 Linux。gnueabihf:C 库用的是 glibc,并且遵循 EABI(嵌入式应用二进制接口),hf代表 hard-float,硬浮点。
如果你看到的是aarch64-linux-gnu-gcc,那就是 64 位 ARMv8 的工具链,aarch64就是 AArch64 的代号。还有一类叫arm-none-eabi-gcc的,none表示没有操作系统,常用于裸机开发,比如 STM32 这类 Cortex-M 单片机的编译,和嵌入式 Linux 是不同的分支。
这里有个非常容易踩的坑:gnueabi和gnueabihf看着只差两个字母,编出来的程序能不能在目标板上运行完全是两回事。下面这个表是常见的工具链选型参考:
| 工具链名称 | 目标平台 | 典型应用场景 |
|---|---|---|
| arm-linux-gnueabi-gcc | 32 位 ARM,软浮点 | 老 ARM 板、armel 系统、不带 VFP/NEON 的芯片 |
| arm-linux-gnueabihf-gcc | 32 位 ARM,硬浮点 | 树莓派 32 位官方系统、大部分主流嵌入式 Linux 开发板 |
| aarch64-linux-gnu-gcc | 64 位 ARMv8 | 树莓派 64 位系统、RK3399/RK3588 等高性能平台 |
| arm-none-eabi-gcc | 无操作系统裸机 | STM32、Cortex-M 单片机开发 |
选错了很容易出现两种情况:要么编译时直接报浮点 ABI 不兼容,要么编出来的程序拷到板子上跑起来就Illegal instruction崩溃。后面第 5 章我详细说怎么排查。
2.2 Ubuntu 上一条命令装好工具链,但版本坑要注意
Debian/Ubuntu 的软件仓库里直接有打包好的交叉编译工具链,不用自己跑去官网下载。我用的最多的是这两个命令:
# 32 位 ARMv7 硬浮点工具链 sudo apt install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf binutils-arm-linux-gnueabihf # 64 位 ARMv8 工具链 sudo apt install gcc-aarch64-linux-gnu g++-aarch64-linux-gnu binutils-aarch64-linux-gnu装完之后可以验证一下:
arm-linux-gnueabihf-gcc --version which arm-linux-gnueabihf-gccUbuntu 20.04、22.04、24.04 都能直接装,但你得留个心眼:不同 Ubuntu 版本自带的交叉编译器版本不一样,比如 20.04 默认是 GCC 9,24.04 可能已经是 GCC 13。编译器版本差异带来的最大问题不是语法,而是生成的二进制依赖的 glibc 版本。如果你在 Ubuntu 24.04 上用aarch64-linux-gnu-gcc编出来的程序,拿到一个 glibc 版本较老的板子系统上运行,会报类似version 'GLIBC_2.34' not found的错误。这种情况要么升级板子的系统,要么换老版本的工具链,要么直接把程序静态编译,把 libc 一起打包进二进制。
2.3 编译参数背后的意义:编出来但跑不起来的最常见原因
交叉编译的几个关键参数,很多人知道要加,但完全不清楚为什么。这里按重要性排序讲一下。
-march指定目标 CPU 的架构级别。比如-march=armv7-a表示针对 ARMv7-A 架构优化。不指定的时候编译器会用一个比较保守的默认值,但如果板子的 CPU 支持新特性,正确指定能让性能提升不少。
-mfpu指定浮点运算单元。ARM 平台的浮点运算可以走 VFP、NEON 或纯软件模拟,像树莓派等多数板子都带 NEON,配置参数时可以写-mfpu=neon。如果芯片压根不支持 NEON,你却编译了带 NEON 指令的二进制,运行时会直接崩溃。
-mfloat-abi指定浮点 ABI 模式,可选soft、softfp、hard。这个参数必须和工具链以及目标系统一致。硬浮点模式下,浮点参数直接用 VFP/NEON 寄存器传递,速度快;软浮点模式下浮点运算交给通用寄存器模拟,兼容性好但性能差。如果目标系统是 armhf 硬浮点,你用软浮点工具链编出来的程序,链接动态库时会报浮点 ABI 不匹配。
除了这三个,还有两个工程里绕不开的:--sysroot和-Wl,-rpath。--sysroot的作用是告诉编译器“目标机的根文件系统在哪个目录”,编译器会到sysroot/usr/include找头文件、到sysroot/usr/lib找库,而不是去宿主机的/usr目录翻 x86 的库。-Wl,-rpath则是把动态库搜索路径写进编译后的二进制里,这样运行时不设LD_LIBRARY_PATH也能找到库。这两个参数是解决交叉编译“编过了但跑不起来”问题的核心,后面实操章节我会带你看具体效果。
3. 实操:从 hello world 到工程级交叉编译
3.1 第一步:编一个能在 ARM 板子上跑的 hello world
先说最简单的场景。写一个 hello.c:
#include <stdio.h> int main() { printf("hello arm\n"); return 0; }要编出 ARM 版本,直接用交叉编译器编译,注意这里建议先加-static做静态链接:
arm-linux-gnueabihf-gcc -static -o hello hello.c编完用file命令看一眼产物格式:
file hello正常情况下会看到类似这样的输出:
ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linked, not stripped看到ARM和statically linked就说明编译目标是正确的。这时候把 hello 拷到开发板上,chmod +x hello后直接执行,就能打印出hello arm。
这里我强烈建议第一次交叉编译时先试静态链接。因为静态链接把所有依赖的 C 库代码都打进了二进制,不依赖板子上的 glibc 版本,可以排除掉很多环境问题。不过要心里有数,静态编译的 hello world 大概 700KB 左右,比动态版大得多,而且如果工程里用到 NSS 这类需要动态加载模块的库,静态链接会产生各种奇怪问题。所以静态版只是用来验证工具链是否可用,真实项目大多还是动态链接。
再看动态编译的情况:
arm-linux-gnueabihf-gcc -o hello_dyn hello.c file hello_dyn readelf -d hello_dyn你会看到动态版依赖libc.so.6,而且程序解释器的路径通常指向/lib/ld-linux-armhf.so.3。如果目标板系统的动态链接器不在这个路径,运行就会报No such file or directory,这个坑我后面专门讲。
3.2 带第三方库的工程:-I、-L和 sysroot 怎么配合
实际项目里很少只依赖 libc,可能还要链接 libcurl、libssl、libsqlite3 之类的东西。很多人第一反应是像本机编译一样用-L/usr/lib指定库路径,结果编译器把 x86 的库拿过来了,链接报错或者运行崩溃,非常难受。
交叉编译时头文件和库必须来自目标机的 sysroot。正确做法是这样的:
从开发板上把根文件系统整体拷到一个本地目录,比如
/opt/arm-sysroot。rsync -avz root@<板子IP>:/ /opt/arm-sysroot/注意这一步可能要排除
/proc、/sys、/dev这些虚拟文件系统,不然拷出来的 sysroot 会很脏。编译时指定
--sysroot:arm-linux-gnueabihf-gcc --sysroot=/opt/arm-sysroot -o app app.c -lcurl
编译器拿到--sysroot=/opt/arm-sysroot之后,头文件会自动去/opt/arm-sysroot/usr/include找,库会自动去/opt/arm-sysroot/usr/lib找。这比手动-I、-L可靠得多,因为你手动指定的路径很容易混入宿主机上的 x86 库,而 sysroot 是个封闭目录,里面全是目标板的东西,从源头上杜绝了架构错配。
如果项目用 Makefile,推荐把工具链和参数统一写在变量里:
CROSS_COMPILE = arm-linux-gnueabihf- CC = $(CROSS_COMPILE)gcc SYSROOT = /opt/arm-sysroot CFLAGS = --sysroot=$(SYSROOT) -march=armv7-a -mfpu=neon -mfloat-abi=hard LDFLAGS = --sysroot=$(SYSROOT) -Wl,-rpath,/usr/local/lib app: app.c $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) -lcurl3.3 工程化方案:CMake 交叉编译文件怎么写
现在很多项目用 CMake,交叉编译时不需要改源码,只需要提供一个 toolchain 文件。我常用的写法是这样,文件名叫toolchain-armhf.cmake:
set(CMAKE_SYSTEM_NAME Linux) set(CMAKE_SYSTEM_PROCESSOR arm) set(CMAKE_C_COMPILER arm-linux-gnueabihf-gcc) set(CMAKE_CXX_COMPILER arm-linux-gnueabihf-g++) set(CMAKE_SYSROOT /opt/arm-sysroot) set(CMAKE_FIND_ROOT_PATH /opt/arm-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)然后构建时指定这个文件:
cmake -DCMAKE_TOOLCHAIN_FILE=toolchain-armhf.cmake .. make -j$(nproc)这里有几个地方要解释清楚。CMAKE_SYSTEM_NAME设为 Linux 就是因为 CMake 检测到目标系统不是宿主机系统,才会启用交叉编译模式。CMAKE_FIND_ROOT_PATH告诉 CMake 去哪儿找目标机的库和头文件。CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER表示查找程序时还是用宿主机的工具,比如查找sed、awk这些辅助工具;LIBRARY和INCLUDE设为ONLY,表示找库和头文件时只能在 sysroot 目录里找,绝不能跑去宿主机/usr/lib/x86_64-linux-gnu翻,这是避免交叉编译工程“串味”的关键。
实际踩过的坑是:如果漏掉CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY,CMake 很可能在宿主机上找到了某个 x86 库,连接阶段报一堆skipping incompatible ...的警告,看起来像没装库,实际是架构不匹配。
4. 高频实战场景:Qt、OpenSSL、strongswan 的交叉编译
4.1 Qt 5.12.10 交叉编译环境搭建要点
Qt 是嵌入式 Linux 图形界面开发绕不开的框架,也是交叉编译里比较痛苦的环节。热搜里看到很多人搜ubuntu-20.04 安装 qt 交叉编译环境,我在 Ubuntu 20.04 上配过 Qt 5.12.10,这里把核心流程和坑位说一下。
首先准备三样东西:
- Qt 源码包:
qt-everywhere-src-5.12.10.tar.xz - 目标板 sysroot:至少要包含完整的
/usr和/lib - 交叉工具链:前面装好的
arm-linux-gnueabihf就行
解压后进入源码目录,执行 configure:
./configure -prefix /opt/qt5.12.10-arm \ -xplatform linux-arm-gnueabihf-g++ \ -sysroot /opt/arm-sysroot \ -opensource -confirm-license \ -no-opengl -nomake examples -nomake tests解释下几个关键参数。-prefix指定编译产物的安装目录,之后用来编应用的 qmake 就在这个目录里。-xplatform是 Qt 专门为交叉编译设计的平台参数,必须写成linux-arm-gnueabihf-g++,如果写成宿主机平台,整个编译过程都会用宿主的 g++,编出来的 Qt 库在 ARM 板上一跑就崩。-sysroot指定目标机根文件系统。-no-opengl是为了省事,如果板子 GPU 支持 ES2,可以用-opengl es2,但服务器上调试环境一般先关掉。
配置通过之后,编译安装:
make -j4 make install这一步非常耗时,我实测 Qt 5.12.10 完整编译在 8 核机器上也要一个小时以上,内存建议至少 8G。编译过程中如果报缺少zlib、libstdc++之类的头文件,多半是 sysroot 里没有对应的开发包,需要在板子上apt install对应-dev包后再重新同步 sysroot。
应用层编译时,千万不要用系统自带的 qmake 去编 ARM 应用,要用刚编译出来的/opt/qt5.12.10-arm/bin/qmake,否则链接的全是 x86 的 Qt 库,报错千奇百怪。把编译出来的整个 Qt 库目录拷到板子上,运行前设置LD_LIBRARY_PATH=/opt/qt5.12.10-arm/lib,这个环境就彻底配通了。
4.2 OpenSSL 与 strongswan:autoconf/Configure 类项目的交叉编译
OpenSSL 和 strongswan 这类老牌开源项目,交叉编译的套路稍微不一样。Qt 用的是自己那套 xplatform 机制,而 OpenSSL 用的是Configure脚本,strongswan 用的是 autoconf 体系。但它们底层逻辑一样:告诉构建系统目标平台是谁。
OpenSSL 的交叉编译命令大致是这样的:
./Configure linux-armv4 \ -march=armv7-a -mfpu=neon -mfloat-abi=hard \ --cross-compile-prefix=arm-linux-gnueabihf- \ shared no-asmlinux-armv4是 OpenSSL 自带的平台名,表示 32 位 ARM 架构(虽然写了 armv4,但实际支持到 ARMv7)。--cross-compile-prefix指定交叉工具链前缀,OpenSSL 会自动在后面补全gcc、ld、ar等命令。no-asm表示关闭汇编优化,如果你没有针对目标芯片仔细核对过汇编代码,建议先关掉,避免编出跑不了的二进制。
strongswan 的交叉编译用 autoconf 的套路:
./configure \ --host=arm-linux-gnueabihf \ --prefix=/usr/local \ CC=arm-linux-gnueabihf-gcc \ CFLAGS="-march=armv7-a -mfpu=neon -mfloat-abi=hard --sysroot=/opt/arm-sysroot"这里--host是最关键的参数。autoconf 体系里,--build表示编译机,--host表示目标机,交叉编译的本质就是让--host跟目标板一致、--build保持为宿主机。很多人设了--build=armxxx反而编出幺蛾子,就是没搞明白这两个参数的分工。如果目标机系统是 64 位,--host=aarch64-linux-gnu即可。
4.3 顺带聊聊 llama.cpp 在 ARM 设备上的编译
llama.cpp 这两年很火,不少人想把它弄到 ARM 开发板上跑本地大模型。它本身就是为本地推理设计的,支持 ARM 上的 NEON 优化,但如果直接在本机用 x86 编译器编了再拷到 ARM 板子,照样跑不起来,还是绕不开交叉编译。
llama.cpp 用 CMake 构建,理论上一个 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++) set(CMAKE_FIND_ROOT_PATH /opt/arm64-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)编译时注意加一个关键参数:
cmake .. -DCMAKE_TOOLCHAIN_FILE=toolchain-aarch64.cmake -DGGML_NATIVE=OFFGGML_NATIVE不开的话,构建系统会针对编译机做-march=native优化,生成 x86 特有指令,拷到 ARM 上直接非法指令崩溃。还有一个经验是:如果目标是 32 位 ARMv7,llama.cpp 编译需要确认芯片是否支持 NEON,老一点的 Cortex-A7 跑起来性能会很挣扎;AArch64 就舒服很多,现代 64 位 ARM 芯片基本都有完整的 NEON 支持。
5. 常见问题与排查技巧实录
5.1 三个高频编译报错与排查思路
交叉编译遇到报错,很多新手第一反应是去搜索引擎复制粘贴,但效率其实很低。我习惯按照下面的顺序排查,命中率很高:
| 报错特征 | 可能原因 | 排查命令 |
|---|---|---|
cannot find -lXXX | 没有该库,或-L指定的路径不对 | find /opt/arm-sysroot -name "libXXX*" |
fatal error: XXX.h: No such file or directory | 头文件路径指向宿主机,或 sysroot 缺开发包 | 检查--sysroot、-I,确认 sysroot 里有对应头文件 |
拷贝到板子后报No such file or directory | 动态链接器路径不存在,或二进制和系统架构不符 | file 二进制、readelf -l 二进制查看 INTERP |
第一类报错大多是库没装全,去 sysroot 里找一下库文件是否存在。特别提醒,有些库只装到了宿主机,没进 sysroot,这时候要在板子上装好对应包再同步一次 sysroot。第二类多半是头文件找错了地方,检查编译命令里有没有手动加-I/usr/include这类路径,如果有,果断删掉,让编译器只从 sysroot 里找。第三类最迷惑,明明文件存在却报No such file or directory,其实这不是文件不存在,而是 ELF 里指定的解释器ld-linux路径不存在。用readelf -l一看便知。
5.2 armel 与 armhf、硬浮点和软浮点,混用的后果很严重
交叉编译最隐蔽的问题不是编译不过,而是编译过了、运行崩溃。最典型的就是 armel 和 armhf 混用。armel 对应软浮点 EABI,armhf 对应硬浮点 EABI,这两个 ABI 浮点参数的传递方式完全不一样。
如果用 32 位软浮点工具链arm-linux-gnueabi-gcc编出的程序,放到 armhf 硬浮点系统上运行,动态库加载阶段就会报浮点 ABI 不匹配,因为板子上的 libc.so.6 是硬浮点编译的,期望浮点参数从 VFP 寄存器传入,而你的程序却用通用寄存器传参。反过来用硬浮点工具链编程序放软浮点系统,问题更严重,可能在初始化阶段就非法指令崩溃。
确认一个交叉编译产物是否硬浮点,可以这么做:
arm-linux-gnueabihf-readelf -A hello | grep Tag_ABI_VFP_args如果输出Tag_ABI_VFP_args: VFP registers,说明是硬浮点;如果没有这行或写着Tag_ABI_VFP_args: (none),就是软浮点。另外还要注意,在 64 位处理器上运行 32 位 ARM 程序,需要内核开启CONFIG_COMPAT兼容选项,有些精简内核没开,32 位程序直接报Exec format error。
5.3 动态库运行时找不到,不一定要改代码
程序编译成功后,在板子上运行时提示error while loading shared libraries: libxxx.so.1: cannot open shared object file,这问题很常见。首先确认库文件有没有拷到板子上,其次看搜索路径。
最粗暴的方式是设置环境变量:
export LD_LIBRARY_PATH=/opt/mylibs:$LD_LIBRARY_PATH ./app但如果每次启动都要手动 export,不优雅。更好的办法是在链接阶段把路径写进二进制,这样运行时不依赖系统环境:
arm-linux-gnueabihf-gcc -Wl,-rpath,/opt/mylibs -o app app.c -lxxx-Wl表示把后续参数传给链接器,-rpath就是运行时库搜索路径。这里有个值得注意的陷阱:-rpath指定的路径会原样写进二进制,如果路径相对于板子是/opt/mylibs,那板子上的库就也得放在这个目录,不能随意改。如果想让路径相对可执行文件位置做偏移,也可以考虑$ORIGIN变量,但对于嵌入式场景,固定路径往往更直观。
还有一类情况:程序编译时链接的是静态库,但运行时报找不到动态库,这通常是库依赖关系没理清。比如-lssl实际还会依赖libcrypto.so,你只拷了 libssl 没拷 libcrypto,就会有这种“明明编过了,运行时却报缺库”的现象。排查时可以现场执行ldd app看依赖列表,对比板子/usr/lib下的文件。
5.4 我的一个避坑习惯
最后分享一个我自己踩了不少次坑之后养成的习惯:拿到一块新 ARM 板子,第一件事不是跑程序,而是先搞清楚它到底用的是什么系统、什么 ABI、是 32 位还是 64 位。在板子上执行uname -a看内核架构,执行file /bin/ls看 rootfs 的二进制格式,执行ldd --version看 glibc 版本。把这三样确认清楚了,再去选工具链、配 sysroot,交叉编译的成功率会提升一个量级。
我现在每次交叉编译完,都会顺手对生成的二进制做三件事:file看架构,readelf -A看 ABI 和浮点标识,readelf -d看动态依赖和解释器路径。这三条命令加起来不到十秒,但能帮我避免无数个“明明编译不过,其实是参数不对”的无效调试。希望这篇文章能让你少走一点弯路,把精力花在真正有意思的业务代码上。