☰
FFmpeg iOS 四架构编译裁剪与 XCFramework 打包
2026/10/26 12:22:26 网站建设 项目流程

简介:面向 iOS 音视频开发者的 FFmpeg 多架构交叉编译资料包,围绕 armv7、armv7s、arm64 与 i386 四种目标环境整理,适合需要在真机与模拟器上统一集成音视频处理能力的中高级开发者参考。压缩包共 2000 个文件,约 50.66MB,以 1782 个 C 源文件、1167 个头文件及 90 个汇编文件为主体,并包含 configure、Makefile、pc 配置、测试样本和少量脚本,能够覆盖从源码配置、交叉编译到多架构产物合并的学习链路。目录中保留了 libavcodec、libavformat 等模块及 test02ffmpeg 验证文件,便于对照架构参数、编译选项和 lipo 合并结果,排查真机与模拟器适配问题。已有 380 人学习下载,可作为 iOS 端 FFmpeg 编译实践与工程集成的参考资料。

1. 为什么要自己编 FFmpeg:四个架构不是复制粘贴,是四份不同的产物

第一次接到ffmpeg 编译 armv7/armv7s/arm64/i386这种需求,很多人的第一反应是:编一次,把.a复制四份,改个名字不就完了。真动手走三步就会断——lipo -info一看,四个库里躺着的全是同一种机器码,链到 32 位模拟器上直接报符号缺失。

这四个架构对应的是三套完全不同的工具链配置:armv7 和 armv7s 共用 32 位 ARM 后端,但 CPU 特性表不一样;arm64 是另一套寄存器模型和汇编文件;i386 干脆换到了 x86 后端,还得用模拟器 SDK 里的 sysroot。配置错一位,编译可能照样过,但运行时崩在第一条 NEON 指令上,排查成本比重新编一次高得多。这篇笔记讲的是怎么用一套脚本把四条分支跑顺、怎么裁剪体积、以及哪几个坑几乎人人都会踩一遍。

2. 四套指令集不是四份配置:先把 armv7、armv7s、arm64、i386 的边界画清楚

2.1 一张表看清四个 target 的位宽、指令集与真实运行位置

很多人把 armv7 和 armv7s 当成"差不多",把 i386 当成"就是模拟器版",这两种理解都会导致后面的参数乱填。先把差异摆到台面上。

架构位宽关键指令集特性实际运行位置现阶段还要不要
armv732 位ARMv7-A,NEON 可选,VFPv332 位真机(早期 A 系列芯片)只在老工程/老设备矩阵里还有意义
armv7s32 位ARMv7-A + VFPv4,针对特定核心优化极少数 32 位机型基本可以砍掉
arm6464 位ARMv8-A,AArch64,NEON 为标配现代真机 + 新 Mac 上的模拟器必须有
i38632 位x86,SSE2 起步老 Intel Mac 上的 32 位模拟器老工具链才编得出来

armv7s 的特殊之处在于它是"同一代指令集的一个超集变体":给某个特定核心加了 VFPv4 和若干调度优化。它跑在 armv7 设备上没问题,但反过来 armv7 的二进制跑不了它的专属指令——所以真正有意义的做法是 armv7 的库作为兜底,armv7s 在lipo合并时作为独立 slice 放进去,让系统按设备挑。合并时它不是"更高级的替代品",只是多一条分支。

i386 的坑在于它跟真机 SDK 完全是两套 sysroot。iphoneos的 SDK 里根本没有 x86 目标,iphonesimulator的 SDK 里也没有 ARM 目标。所以四个架构不是"同一份脚本改一个变量",而是"三份参数 + 一个 SDK 切换"。

还有一个容易忽略的事实:arm64 现在同时存在"真机 arm64"和"模拟器 arm64"(新 Mac 上跑模拟器时编译出来的产物)。两者架构名一样,平台不一样,链接器认的是平台而不是名字。这一点放到最后一章单独说,因为它是 fat 库方案崩掉的地方。

2.2 交叉编译的三件套:toolchain、sysroot、target 各管一段

交叉编译这件事,本质上要同时告诉三拨人三件事,缺一件就编不对:

  • 告诉编译器(clang)输出什么机器码:靠-arch armv7这类参数。
  • 告诉编译器和链接器去哪找系统头文件和系统库:靠--sysroot=<SDK 路径>。
  • 告诉 FFmpeg 自己的构建系统该启用哪些汇编文件、哪些内联汇编分支:靠--arch=和--cpu=。

这里最容易翻车的是第二组和第三组参数不一致。举个例子:--arch=arm、--cpu=cortex-a9会让 FFmpeg 打开libavcodec/arm/下面一批 NEON 汇编;但如果--extra-cflags里没带-arch armv7,clang 会按宿主机的默认架构走,编出来的是宿主机的目标文件,等到lipo合并时才发现 slice 名字对不上。所以我的习惯是:--arch/--cpu决定"编哪些源文件",-arch决定"编成什么",两边必须逐条对照。

对照关系大致是这样:

目标 sliceconfigure 的--archconfigure 的--cpuclang 的-archSDK
armv7armcortex-a9armv7iphoneos
armv7sarmswiftarmv7siphoneos
arm64arm64arm64arm64iphoneos
i386x86不指定i386iphonesimulator

--cpu=swift这个值听起来奇怪,但它是 FFmpeg 里对那一代核心的代号,用来打开 VFPv4 相关的分支。arm64 那条我一般显式写--cpu=arm64,不写也能过,但写了之后生成的 config.h 更容易一眼看懂。

模拟器那两条我刻意不写--cpu,让它按 SDK 的默认目标走。原因是 x86 后端的--cpu一旦写成i386,会顺带关掉 SSE 系列优化,编出来的库能跑但慢一截,而模拟器只在开发期用,这条分支的收益本来就不在性能上。

2.3 32 位这三条分支到底该留还是该扔

新手最常见的浪费是把四个架构一直当必选项供着,每次 CI 跑满四遍。判断标准其实很简单:

如果你的最低支持版本落在还需要 32 位设备的区间,armv7 必须留;armv7s 只在你有明确的老设备矩阵、且做过对比测试证明它有收益时才留,否则它就是纯体积负担——合并进 fat 库后每个.a都会变大,而绝大多数设备永远走不到那一条 slice。

i386 更干脆:新版本工具链已经把 32 位模拟器的目标从 SDK 里拿掉了,你连编都编不出来。留着它的唯一场景是你被锁在老工具链上,还得维护老工程。

至于 arm64,没有讨论余地。真机和模拟器两条 arm64 都要,只是打包方式要换,这一点第 6 章会展开。

3. 把 FFmpeg 砍到能装进包里:configure 参数、裁剪顺序与外部库取舍

3.1 一条能跑通的最小 configure 命令

先给一条能直接跑起来的命令,以 arm64 为例。跑通它比读十页参数说明有用。

#!/usr/bin/env bash # 单架构最小可跑示例:arm64 真机 set -euo pipefail SDK=$(xcrun --sdk iphoneos --show-sdk-path) # 取当前选中的 SDK 真实路径 MIN_IOS=11.0 ./configure \ --prefix="$(pwd)/build/arm64" \ --target-os=darwin \ --arch=arm64 \ --cpu=arm64 \ --cc="xcrun -sdk iphoneos clang" \ --cxx="xcrun -sdk iphoneos clang++" \ --sysroot="$SDK" \ --enable-cross-compile \ --enable-pic \ --disable-shared --enable-static \ --disable-programs --disable-doc \ --disable-avdevice --disable-postproc \ --disable-swscale-alpha \ --disable-network \ --disable-everything \ --enable-decoder=h264,aac \ --enable-demuxer=mov \ --enable-parser=h264,aac \ --enable-protocol=file \ --enable-swresample \ --enable-small \ --extra-cflags="-arch arm64 -miphoneos-version-min=$MIN_IOS" \ --extra-ldflags="-arch arm64 -miphoneos-version-min=$MIN_IOS" make -j"$(sysctl -n hw.ncpu)" make install

逻辑上分四段看:第一段--target-os/--arch/--cpu是给 FFmpeg 自己的构建系统看的,决定启用哪些源码分支;第二段--cc/--sysroot/--enable-cross-compile是告诉它"我在交叉编译,别拿宿主机试着玩";第三段是一串--disable-*,负责体积;第四段--extra-cflags和--extra-ldflags里的-arch必须和第一段完全一致,否则前面白配。

那几个参数里最容易写错的是--cc。直接写clang也能过,但它用的是宿主机的默认 SDK,--sysroot一覆盖反而互相打架,症状是头文件里出现一堆莫名其妙的类型冲突。写成xcrun -sdk iphoneos clang之后,工具链和 sysroot 就锁在同一套 SDK 上了。

3.2 参数逐条拆:哪些决定架构,哪些决定体积,哪些决定 ABI

参数一多就容易抄完不知道为什么。按作用分组记会稳得多。

分组参数作用写错的后果
架构--arch/--cpu选择源码里的汇编与优化分支编出来的库缺 NEON 路径,跑得慢
架构--extra-cflags里的-arch决定 clang 输出的机器码库能出,但lipo认不出 slice
架构--sysroot指定系统头文件与库的根头文件冲突、链接期找不到系统符号
ABI-miphoneos-version-min标记最低系统版本部署版本不一致,链接期告警甚至拒绝加载
ABI--enable-pic生成位置无关代码静态库被链进动态库时可能重定位失败
体积--disable-everything关掉全部编解码组件不配--enable-*就一个功能都没有
体积--enable-small优先体积而非极限速度编解码速度小幅下降
体积--disable-programs不产出可执行程序多出几 MB 无用产物
功能--disable-network去掉网络协议栈需要拉流时功能缺失
功能--disable-avdevice去掉设备采集层这个模块在移动端基本用不上

--disable-everything是体积控制的核心杠杆,但它只关组件(decoder、demuxer、parser、protocol、filter 这类),不会关掉 avformat、avcodec 这些库本身。所以正确姿势是先全关,再按白名单一个个开回来。开的时候注意"能解 mp4 里的 H.264"这件事需要三样东西同时到位:mov解复用器、h264解析器、h264解码器。少开一个,avformat_open_input那步就返回失败。

另外一个常被忽略的是--enable-swresample。做音频播放时音频格式几乎一定要重采样,这个库如果不是默认开着,就得显式打开,否则链接期才发现缺符号。

3.3 裁剪顺序:从二十多兆到几兆的删除次序

裁剪不要一次删到底,按下面的顺序一层层砍,每砍一层跑一次链接验证,出问题能立刻定位到是哪一层。

第一层砍外围:--disable-programs --disable-doc --disable-avdevice --disable-postproc。这几个是纯收益,移动端几乎不用,砍完体积掉得最明显。

第二层砍功能域:--disable-network --disable-encoders --disable-muxers。如果你的场景只做本地文件解码播放,网络协议栈和全部编码器、封装器都可以整块去掉。这一层通常是从十几兆掉到个位数兆的关键。

第三层开白名单:--disable-everything之后按业务逐个--enable-decoder=/--enable-demuxer=/--enable-parser=/--enable-protocol=file。这一层要克制,宁可先少开,跑通主链路再补,别一上来把常见格式全开回来,等于白砍。

第四层才是微调:--enable-small、--disable-swscale-alpha、关掉不用的 filter。这一层单看每一项只省一点点,但叠起来在几兆的基数上占比不小。

砍的过程中有个反直觉的现象:关掉汇编(--disable-asm)能让编译少一堆麻烦,但体积不一定变小,性能会明显下降。所以汇编相关的问题优先去修工具链,别拿--disable-asm当万能药。

3.4 config.log 是黑匣子:configure 失败时先看哪三行

configure挂在C compiler test failed是最常见也最没信息量的一句。真正的答案永远在源码目录下的ffbuild/config.log里,翻到末尾往前找这三类内容:

  • 编译测试命令的完整展开行。看-arch、--sysroot、-miphoneos-version-min是不是都带上了,缺哪个一目了然。
  • 第一条 error 而不是第一条 warning。很多人被前面的警告带偏,真正致命的是后面一行error:。
  • 链接阶段的符号缺失。如果编译测试过了、链接测试没过,通常是 sysroot 指错了 SDK,或者最低版本参数和 SDK 支持区间冲突。

养成习惯:每次改完参数,先make distclean再重新configure。configure 会把上一次的探测结果缓存进config.mak,不清干净的话新参数有一部分不生效,症状是"我明明改了,产物却没变",这类问题最耗时间。

4. 一条脚本编四个架构:循环、lipo 合并与头文件导出

4.1 build.sh 骨架:循环架构、清理、独立前缀

四个架构逐个手敲命令太低效,也容易漏。用一个数组把差异项集中管理,其余部分共用。

#!/usr/bin/env bash # 四架构批量编译骨架 set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) SRC="$ROOT/ffmpeg" # 源码目录,只读,不在里面改文件 OUT="$ROOT/build" MIN_IOS=11.0 # 格式:名称|configure的arch|configure的cpu|clang的arch|SDK名|最低版本参数 CONFIGS=( "armv7|arm|cortex-a9|armv7|iphoneos|-miphoneos-version-min=$MIN_IOS" "armv7s|arm|swift|armv7s|iphoneos|-miphoneos-version-min=$MIN_IOS" "arm64|arm64|arm64|arm64|iphoneos|-miphoneos-version-min=$MIN_IOS" "i386|x86||i386|iphonesimulator|-mios-simulator-version-min=$MIN_IOS" ) cd "$SRC" for row in "${CONFIGS[@]}"; do IFS='|' read -r NAME ARCH CPU CLANGARCH SDKNAME MINVER <<< "$row" SDK=$(xcrun --sdk "$SDKNAME" --show-sdk-path) PREFIX="$OUT/$NAME" echo "==> building $NAME" make distclean >/dev/null 2>&1 || true # 必须清,否则上一个架构的缓存会污染 CPU_FLAG="" [ -n "$CPU" ] && CPU_FLAG="--cpu=$CPU" ./configure \ --prefix="$PREFIX" \ --target-os=darwin \ --arch="$ARCH" \ $CPU_FLAG \ --cc="xcrun -sdk $SDKNAME clang" \ --cxx="xcrun -sdk $SDKNAME clang++" \ --sysroot="$SDK" \ --enable-cross-compile \ --enable-pic \ --disable-shared --enable-static \ --disable-programs --disable-doc \ --disable-everything \ --enable-decoder=h264,aac \ --enable-demuxer=mov \ --enable-parser=h264,aac \ --enable-protocol=file \ --enable-swresample \ --enable-small \ --extra-cflags="-arch $CLANGARCH $MINVER" \ --extra-ldflags="-arch $CLANGARCH $MINVER" make -j"$(sysctl -n hw.ncpu)" make install done

这段脚本里有两个设计决定值得说清楚。

一是make distclean放在 configure 之前,而不是放在循环末尾。放在末尾的话,如果某个架构编到一半失败退出,下一个架构启动时面对的是一个半污染的状态,报错会非常莫名其妙。放在开头,无论上一轮怎么失败的,这一轮都是干净起点。

二是CPU_FLAG用一个空值判断来处理。i386 那条不需要--cpu,如果硬塞一个空字符串进去,configure 会把它当成非法参数直接退出。这种"四个架构里三个长得像、一个不一样"的情况,用数据驱动加一个条件分支比写四份重复命令好维护得多。

再有就是--prefix每个架构一个独立目录。有人图省事共用同一个前缀,make install后一个架构覆盖前一个,等到合并时发现四个目录里躺的是同一份头文件,架构信息早丢了。

4.2 lipo 合并:fat 库的生成与同名 slice 冲突

四个架构编完,每个前缀下的lib/里都有一整套.a。合并就是把同一个库的四个版本捏成一个 fat 文件。

LIBS="libavcodec libavformat libavutil libswresample libswscale" mkdir -p "$OUT/universal" for lib in $LIBS; do lipo -create \ "$OUT/armv7/lib/$lib.a" \ "$OUT/armv7s/lib/$lib.a" \ "$OUT/arm64/lib/$lib.a" \ "$OUT/i386/lib/$lib.a" \ -output "$OUT/universal/$lib.a" done # 头文件取任意一个架构的即可,四个架构的公开头文件内容一致 cp -R "$OUT/arm64/include" "$OUT/universal/include"

逻辑上,lipo -create做的是"把不同架构的目标文件塞进同一个容器",它不会做任何符号层面的合并。所以有两条硬约束:

其一,参与合并的每个输入必须是不同的架构。如果两个输入都是 arm64,lipo会直接报 "same architectures" 并拒绝输出。这条限制在后面遇到"真机 arm64 和模拟器 arm64"时会变成死路,只能换打包方式。

其二,四个架构的 configure 参数里,除了架构相关那几项,其余必须完全一致。如果 armv7 关了swscale而 arm64 开着,合并出来的 fat 库在不同 slice 上导出的符号集合不同,链接期表现会随目标架构变化,极难排查。我的做法是把公共参数抽成一个变量,四个架构共用同一份,只让差异项走数组。

4.3 产物自检:lipo -info、file、nm 三条命令该看到什么

合并完不要急着往工程里拖,先跑三条命令。

# 1) 看 fat 容器里有哪几个 slice lipo -info build/universal/libavcodec.a # 期望:Architectures in the fat file: ... are: i386 armv7 armv7s arm64 # 2) 看每个 slice 的真实文件类型与目标平台 file build/universal/libavcodec.a # 3) 看某个 slice 里有没有你要的符号 nm -g --arch arm64 build/universal/libavcodec.a | grep -c " T _avcodec_"

lipo -info只能证明容器里有四条记录,证明不了内容对。曾经遇到过的情况是:lipo -info显示四个架构齐全,但链接时 32 位那条报符号缺失——原因是 armv7 那一轮make其实失败了,脚本因为某处吞掉了退出码继续往下走,lipo拿一个空壳或者旧文件凑数。所以set -euo pipefail这行不是装饰,lipo之后一定要配一条nm检查符号数量,数量级明显偏低就是有问题。

file那条的用处是看平台标记。同样的 arm64,真机产物和模拟器产物在file的输出里平台描述不一样,这也是判断有没有混入错误 slice 最快的方式。

5. 编译期避坑:五条血泪经验,每条都能让你卡半天

5.1 现象:configure 报 C compiler test failed,日志里却全是警告

原因通常不是编译器坏了,而是交叉编译的汇编预处理环节缺工具。32 位 ARM 那两条分支在打开汇编时,需要额外的汇编预处理脚本参与,缺了它,编译测试在汇编阶段就挂,日志里前面的 C 编译测试却是通过的,很容易误判成编译器问题。

解决:先确认工具链路径没问题(xcrun --sdk iphoneos --find clang能返回路径),再把汇编预处理脚本放到PATH里,或者干脆先用--disable-asm跑通全流程验证参数,确认参数无误后再把汇编打开、单独修这一项。顺序不要反。

5.2 现象:链接期报symbol(s) not found for architecture armv7,但库明明合进去了

原因几乎总是--extra-ldflags里漏了-arch。configure 阶段的链接测试用的是--extra-ldflags,只写--extra-cflags的话,编译能过、链接测试会用宿主机默认架构去试,结果要么测试失败,要么侥幸通过但产物里混了宿主机目标文件。等到真正链接 App 时,armv7 这条 slice 里缺符号。

解决:把-arch同时写进--extra-cflags和--extra-ldflags,最低版本参数也一样。改完make distclean重来,别指望增量编译能修好。

5.3 现象:新工具链上压根编不出 armv7 / armv7s / i386

原因很直接:新版工具链的 SDK 里已经不再包含这几个 32 位目标的支持文件,clang -arch armv7直接报无效架构。这不是参数问题,是环境不支持。

解决:先确认业务是否真的还需要 32 位。如果只是历史包袱,直接把脚本里那三条删掉,维护成本立刻降一半。如果确实需要,就在构建环境里固定一套还支持 32 位的老工具链,并且把版本号写进脚本注释——这种依赖最怕的就是某天环境被悄悄升级,然后整条流水线集体失败却找不到原因。

5.4 现象:lipo报两个输入是相同架构,无法合并

原因:真机 arm64 和模拟器 arm64 的架构名都是 arm64,但平台不同。lipo只认架构名,不认平台,所以它认为你在重复塞同一个 slice。

解决:这两条分支不能用 fat 库的方式共存,必须换成新的打包格式(下一章展开)。如果暂时不想改,就把模拟器分支单独出一套库,靠构建配置切换,但这属于临时方案,长期会越维护越乱。

5.5 现象:库能编能链,跑起来解码第一帧就崩

原因多半是 CPU 特性假设错了。--cpu写得太激进(比如给 armv7 指了一个只有更新核心才有的特性),编译期不会报错,因为指令本身是合法的,只是运行的那台设备不支持。这类问题的特点是"换台设备就好了",非常像玄学。

解决:armv7 这条分支保守一点,用cortex-a9这类覆盖面广的目标;想榨性能就在运行时做能力检测,而不是在编译期一刀切。真机上定位这类问题的办法是抓崩溃栈,看崩在哪条汇编指令,再去对config.h里打开的特性宏。

6. 从“编出来”到“敢上线”:符号验收与 XCFramework 收尾

编出四个.a只是中间态,能不能上线取决于两件事:符号是否完整、打包格式是否被工具链接受。

先做符号验收。合并后每个库都跑一遍:

检查项命令判据
slice 数量lipo -info x.a与脚本里的架构数一致
平台标记file x.a真机与模拟器产物可区分
导出符号nm -g --arch arm64 x.a | grep -c " T _"四个架构数量级接近,不出现某条明显偏低
依赖外部库nm -u --arch arm64 x.a只剩系统库符号,不该有第三方库残留

第三行是关键。四个架构的符号数量应该在同一量级,如果 armv7 那条只有别人的零头,说明那一轮编译其实没成功,只是脚本没拦住。

然后是打包。前面说过真机 arm64 和模拟器 arm64 不能进同一个 fat 文件,现在的标准做法是打成一个多平台的库包:

# 先各自合出两个平台的库:真机一套,模拟器一套(模拟器内部再 lipo 合并 i386 与 x86_64) xcodebuild -create-xcframework \ -library build/device/libavcodec.a -headers build/device/include \ -library build/simulator/libavcodec.a -headers build/simulator/include \ -output FFmpeg.xcframework

-create-xcframework接受多个-library,每个条目对应一个平台切片,平台信息由输入产物自带的标记决定,不再靠架构名猜。工程里直接引这个包,链接器会按当前构建目标挑对应的那一份,模拟器和真机同时可用。

我自己踩得最深的一次,是死磕lipo合并真机与模拟器的 arm64,反复报 same architectures,试了改名、试了拆目录,折腾半天才想明白问题不在参数——架构名一样就是不能合并,是格式层面的限制。后来定了个习惯:只要架构列表里同时出现真机和模拟器,就别用 fat 库,直接上库包格式,省下的时间够把裁剪参数调三轮。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询