做移动端音视频处理,最绕不开的一个库就是FFmpeg。我最近在推进一个视频预处理模块的开发:上传前的视频要抽封面、转分辨率、换封装,还得跑在Android端,这些操作都得在native层完成。问题也随之而来——开发环境是macOS,目标平台却是Android,总不能把整台Linux开发机搬到工位上。唯一的解法就是在这台Mac上做交叉编译,把FFmpeg源码变成Android能加载的.so动态库。
“macOS环境下FFmpeg打包成.so文件”这句话,听起来只是编译一次的问题,真做起来却发现里面藏着不少门道。比如macOS原生动态库的后缀是.dylib而不是.so,所以你在一台Mac上折腾出来的.so,本质上是给Linux/Android用的“舶来品”;比如NDK工具链的选型、ABI架构的规划、configure参数怎么配置才不会被卡住;再比如编译完的so怎么验证,放进工程会不会运行时崩溃。
这篇文章把我从环境准备、方案选型、configure参数、编译执行到产物验证的完整过程都写出来,还会把踩过的坑单独拉出来讲。如果你也正好要在macOS上为Android或Linux编FFmpeg动态库,或者对交叉编译这整套流程还不熟悉,可以照着操作一遍,能少走不少弯路。
1. 为什么放着本机不编,偏要折腾出.so这种“舶来品”
1.1 真实场景:macOS上开发,Android上运行
我手头这个视频预处理模块,核心需求主要是三块:从视频里抽帧生成封面图,把高码率视频转成适合移动端播放的分辨率,以及把不标准的封装格式转换成mp4。技术上这几乎没有第二选择,FFmpeg就是最成熟的方案。
但Android端的现实是:系统本身没有内置FFmpeg命令行工具,普通App也没有权限去调用系统路径下的可执行文件,更不可能指望用户root。所以唯一合理的路径就是自己把FFmpeg源码编译成Android能动态加载的库,然后通过JNI在native层调用。开发者在本机 macOS 上写代码、调试、验证算法,最终交付给Android进程的却是另一个架构、另一个操作系统下的二进制库。
这里还藏着一个很多人没注意到的点:你是不是真的需要“.so”这个格式?如果目标平台是纯Linux的嵌入式设备,那一样是ELF格式的.so;如果目标平台是Android,那一样也是.so。但macOS自己的动态库格式是Mach-O的.dylib。所以你最终拿到的.so,一定是交叉编译出来的,你在macOS终端里直接执行configure和make,只会得到.dylib,这一点先在心里有个底。
1.2 为什么选.so而不是.a:静态库与动态库的取舍
FFmpeg编译时可以输出两种形态的库:静态库.a和动态库.so。很多人会觉得“能用就行”,实际上这两种选择会直接决定后续集成方式。
- .a静态库:在链接阶段把目标代码直接塞进最终的可执行文件里,启动快、不用带一堆文件,但缺点也很明显——编译出来的包体大,每次更新功能都要重新链接整个App,换肤级的小改动都得重新发版。
- .so动态库:运行时动态加载,可以单独升级替换,同一个包内的多个进程还能共享同一份代码。
我这边的项目有一个比较特殊的要求:视频处理模块要做成独立组件,后续可能单独更新算法而不重新打整个App的安装包。所以必须用.so。如果你只是普通App内部集成一个视频转码功能,其实.a就够用了,甚至更省心。做技术选型之前,先想清楚自己的构建和发布流程,别一上来就追复杂方案。
1.3 FFmpeg本身是一个库的集合,不是单一文件
还有一个特别容易忽略的点:FFmpeg编译出来的不是一个.so,而是一组.so。包内通常包含libavcodec(编解码)、libavformat(封装/解封装)、libavutil(公共工具)、libswscale(图像缩放与像素转换)、libswresample(音频重采样)这几个核心成员,它们之间有依赖关系。你带走libavcodec.so的时候,libavutil.so也得跟着走,不然运行时会因为缺依赖直接加载失败。
2. 交叉编译前的方案设计:先把工具链和ABI想清楚
2.1 交叉编译的本质:给目标平台发一张“驾照”
交叉编译在概念上并不复杂。你在macOS上写代码,使用macOS自己的编译器,编出来的是x86_64或者arm64的Mac程序;但咱们的目标是生成Android下的ELF格式动态库,这需要一套能产出目标平台机器码的工具链。
这套工具链,在Android生态里就是NDK(Native Development Kit)。它包含交叉编译器(clang)、目标平台的系统头文件与库文件(sysroot)、链接器、汇编器,以及各种二进制工具。打个比方:你在国内开车有国内驾照,去国外开车得换国际驾照;交叉编译就是拿到目标平台认可的那本“驾照”,而NDK就是发驾照的交管所。
有人会问:为什么不在目标设备上直接编译?很多嵌入式Linux和Android设备,要么没有完整的编译工具链,要么性能太弱编译一次要卡几个小时。在macOS这种开发机上做交叉编译,属于常规操作,效率也高得多。
2.2 NDK版本选择:r25c 是稳妥起点
NDK版本这件事,选错了能把你整到怀疑人生。我先后试过不少版本,最终稳定用的是r25c这个版本(对应版本号25.2.9519653)。
为什么是这个版本?有一条规律:从NDK r23开始,GCC工具链被彻底移除,只剩clang;而FFmpeg官方对clang支持早已成熟,所以用新版NDK是趋势。但版本也不能太新,NDK太新的时候,FFmpeg的configure检测脚本偶尔会水土不服,尤其在某些汇编依赖检测上。
NDK的版本还有一个历史包袱:早期版本号是20.x,到r21开始改成21.x,再往后版本号里直接带上了API级别。所以你在查资料时会看到各种格式的版本号混杂,别被绕晕。如果你跟我一样只是编译FFmpeg这个基本需求,r25c配FFmpeg 6.x系列,实测是比较稳的组合。FFmpeg版本也别追最新master,建议用release tag,比如n6.0、n6.1,master上的代码变化太快,不适合作为稳定交付的基线。
2.3 ABI规划:arm64-v8a为主,x86_64留作调试
Android的CPU架构不止一种,每个架构需要单独编译一份.so,因为机器码完全不同。当前的主流ABI大概就这几个:
| ABI | 对应架构 | configure里的arch参数 | 适用场景 |
|---|---|---|---|
| arm64-v8a | 64位ARM | arm64 | 绝大多数现代真机 |
| armeabi-v7a | 32位ARM | arm | 老设备、低端设备 |
| x86_64 | 64位Intel/AMD | x86_64 | 模拟器调试 |
| x86 | 32位Intel/AMD | x86 | 老模拟器,基本淘汰 |
我的策略非常明确:先只编arm64-v8a。原因很现实——现在的真机市场基本都被64位覆盖,armeabi-v7a只有在兼容老设备时才需要,x86_64纯粹是为了模拟器调试方便。每多编一个ABI,编译时间和后续维护成本都会成倍增加。先把主架构跑通,再做扩展。
2.4 macOS侧的准备:Xcode Command Line Tools、Homebrew与汇编器
macOS这边不需要装什么重型IDE,只需要Xcode Command Line Tools已经就位,终端里能正常执行clang就行。然后通过Homebrew安装两个汇编工具:
brew install nasm yasmnasm是x86架构汇编器,编译x86_64目标时需要;yasm是老牌的汇编工具,FFmpeg某些历史版本还要求它。装了之后,后面交叉编译时会省掉一大堆汇编阶段的报错。Apple Silicon的机器也不用担心,交叉编译本来就和主机架构无关,你的Mac是arm64还是x86_64,都不影响生成目标平台代码。
3. configure参数逐项拆解:每个flag背后都是可追溯的取舍
3.1 源码拉取与版本锁定的正确姿势
git clone https://git.ffmpeg.org/ffmpeg.git ffmpeg cd ffmpeg git checkout n6.0这里有一个细节值得留意:不要直接clone完就编译master分支。FFmpeg仓库的master随时在更新,你今天clone的代码和下周clone的可能行为都不一样,这对做交付非常不友好。锁版本到release tag,后面再复现、排错、升级,都能有一个明确的基线。
3.2 一个实测可用的configure命令,逐参数讲清楚
下面这组命令是我在macOS上给32位和64位ARM Android交叉编译FFmpeg时实际用过的,你可以先抄下来,我再逐个解释:
#!/bin/bash export NDK=/Users/你的用户名/Library/Android/sdk/ndk/25.2.9519653 TOOLCHAIN=$NDK/toolchains/llvm/prebuilt/darwin-x86_64 API=21 ./configure \ --target-os=android \ --arch=arm64 \ --cpu=armv8-a \ --enable-cross-compile \ --cc=$TOOLCHAIN/bin/aarch64-linux-android$API-clang \ --cxx=$TOOLCHAIN/bin/aarch64-linux-android$API-clang++ \ --sysroot=$TOOLCHAIN/sysroot \ --prefix=$(pwd)/android/arm64 \ --enable-shared \ --disable-static \ --disable-programs \ --disable-doc \ --enable-small \ --disable-network \ --disable-everything \ --enable-decoder=h264 \ --enable-decoder=hevc \ --enable-decoder=aac \ --enable-decoder=mp3 \ --enable-encoder=aac \ --enable-parser=h264 \ --enable-demuxer=mov,mp4,m4a,3gp,3g2,mj2 \ --enable-muxer=mp4 \ --enable-protocol=file逐个解释关键参数:
--target-os=android:告诉FFmpeg目标系统是Android。这个flag会触发平台相关的代码分支,比如获取内存、线程调度、字节序处理等。--arch=arm64 --cpu=armv8-a:声明目标CPU架构。arm64-v8a对应Android的arm64-v8a ABI。--enable-cross-compile:显式声明这是交叉编译。不加它,configure可能根据当前主机环境自作主张。--cc、--cxx:指定目标平台的clang编译器。注意NDK r23之后,编译器的命名规则变了,类似aarch64-linux-android21-clang这种东西才是对的,不要去找老的arm-linux-androideabi-gcc。--sysroot:目标平台的头文件和系统库根目录。这是交叉编译的核心,没有它,编译器找不到Android系统库。--prefix:make install时把产物安装到哪里,这决定你的头文件和.so最后出现在哪,方便后续集成。--enable-shared --disable-static:明确只要动态库,不要静态库。--disable-programs:不生成ffmpeg、ffprobe这些命令行可执行文件。咱们要的是库,命令行工具在Android里根本跑不起来,加了反而拖慢编译。--disable-doc:不要文档,单纯为了省事。--enable-small:开启体积优化,编译出来的so更小。--disable-network:关闭网络相关模块。如果只做本地文件解析和转码,网络模块用不上;如果你要做HLS拉流或者RTMP推流,这里要重新打开并额外配置TLS库。--disable-everything:先关闭所有功能,再按需打开。这是裁剪FFmpeg最狠、也最可控的方式。
这里要特别说明一下--disable-everything的用法。它是放在所有--enable-*参数之前的,先把所有功能关掉,然后只开启你需要的那几项。这样做最大的好处是so体积直线下降,我实测裁剪后libavcodec.so能控制在默认体积的三分之一左右,而且加载更快、占用内存更少。
3.3 按需裁剪清单:按行为开关,而不是按库开关
下面这段是根据实际业务来挑选FFmpeg功能的清单,你做项目时直接照着另开即可:
| 你需要的功能 | 需要开的模块 |
|---|---|
| 只转封装(不改编码) | demuxer + muxer + protocol=file,可以不开任何decoder/encoder |
| H.264解码 | decoder=h264 + parser=h264 |
| H.265/HEVC解码 | decoder=hevc |
| AAC音频转码输出 | encoder=aac |
| MP4容器处理 | demuxer=mov,mp4,m4a,3gp,3g2,mj2 + muxer=mp4 |
| 本地文件读取 | protocol=file |
configure结束后,记得看一眼输出里的Enabled decoders、Enabled demuxers这些列表,确认自己开的东西进来了,再开始make。configure这一步本身非常快,几秒钟就出结果,值得多花一点时间确认配置无误。
4. 编译执行与产物核查:make完成不等于事情结束
4.1 make的正确姿势与时间预期
configure通过之后,执行:
make -j8 make install-j后面的数字是并行编译的线程数,建议和CPU核心数对齐。M系列芯片上可以适当拉高,比如-j10,但别无脑拉满,否则编译时内存和CPU都被吃满,机器容易卡死。如果编译过程中卡到没响应,就Ctrl+C,调低并行度重新跑,make会跳过已经编译好的文件,增量编译很快。
我第一次全量编译arm64版本,从configure到make install大约用了8分钟。如果把裁剪项加进去,增量编译通常1-2分钟就能出结果。所以不用怕反复改配置,configure几秒钟的事,make又能增量,迭代成本很低。
4.2 产物清单与文件命名的坑
make install完成后,去android/arm64/lib目录下看,会看到这样一堆文件:
- libavcodec.so
- libavformat.so
- libavutil.so
- libswscale.so
- libswresample.so
同时android/arm64/include目录下是头文件,后续做JNI开发时要一起用。
这里藏着一个新手最容易踩的坑:链接时生成的文件名可能带版本号,比如libavcodec.so.60或者更高版本号。原因在于FFmpeg的动态库有SONAME机制,链接器在生成文件时会把版本号写进文件名。但在Android的System.loadLibrary机制里,它只认不带版本号的libavcodec.so,不会去自动找带版本号的文件名。所以需要手动重命名:
mv libavcodec.so.60 libavcodec.so mv libavformat.so.60 libavformat.so把带版本号的文件名改成不带版本号,再放进jniLibs。这一点如果漏掉,后面接进工程时会出现library "libavcodec.so" not found这类报错,排查起来很让人抓狂。
4.3 file、readelf、nm三件套验证
拿到so文件,先别急着一股脑塞进工程,先用工具确认它到底对不对。
首先看文件类型:
file android/arm64/lib/libavcodec.so正常的输出应该是:
ELF 64-bit LSB shared object, ARM aarch64, dynamically linked, BuildID[sha1]=...只要出现ELF 64-bit LSB shared object和ARM aarch64,就说明平台和架构是对的。如果输出的是ELF 64-bit LSB executable,那说明生成的其实是个可执行文件,多半是configure参数不对。
接着看动态依赖:
$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-readelf -d libavcodec.so关注输出里的NEEDED字段,正常应该包含libavutil.so、libswresample.so这类的依赖,表明它链接到了FFmpeg其他成员库,而不是意外依赖了主机系统的某些动态库。一旦NEEDED里出现libSystem.B.dylib这类macOS专属名字,那就说明交叉编译没做干净。
最后看导出符号:
$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-nm -D libavcodec.so | head -20确认能看到avcodec_find_decoder、avcodec_open2这类关键API的导出符号。有了这些符号,才说明JNI层能够正常调用。
4.4 常见产物异常的初步判断
如果你编出来的so不对劲,多半能从这几类特征里找到线索:
- 编出来不是shared object而是executable:configure时--enable-shared没生效,看看是不是有些依赖库把配置给覆盖了。
- so文件体积只有几十KB:说明裁剪开关出了问题,可能是--disable-everything之后你想enable的模块根本没开成功,回头检查configure输出列表。
- 符号全部缺失:make过程中可能早就报错中断了,只是日志刷得太快没看到,重新make一次,盯着最后几行输出。
5. 踩坑实录:从configure到链接,每一个卡点都是怎么绕开的
5.1 configure时报错:C compiler test failed
这是交叉编译最先遇到的对手,报错信息看起来很吓人,其实原因就那么几类:
第一,--cc路径写错了。一定要先去NDK目录里确认一下编译器文件是否存在、命名是否正确。常见坑是文件叫aarch64-linux-android21-clang,你写成了aarch64-linux-android21-gcc,那肯定找不到。
第二,--sysroot路径不对。NDK的sysroot在$NDK/toolchains/llvm/prebuilt/darwin-x86_64/sysroot,路径写错编译器会连系统头文件都找不到。
第三,环境变量污染。有些用户之前配置过别的交叉编译环境,LD、CFLAGS、CPPFLAGS这些环境变量残留会影响configure判断,排查时先清理干净。
最有效的排查方式是绕过configure,直接手测编译器:
$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android21-clang -v只要这行命令能正常输出clang版本信息,说明工具链本身没毛病,问题基本就出在路径或环境变量上。
5.2 armeabi-v7a遇到汇编错误:gas-preprocessor的问题
给armeabi-v7a(32位ARM)编译时,我遇到过一个典型的汇编错误,报错信息大致是汇编器无法处理某些内联汇编语法。网上很多文章都指向一个叫gas-preprocessor的脚本,说把它放到环境变量里就能解决。
实际验证下来,这个脚本是老版本FFmpeg配老Linux工具链的产物。新版本FFmpeg源码自身已经适配了现代工具链的汇编流程,再引入gas-preprocessor反而可能画蛇添足。如果你卡在32位ARM的汇编报错上,我的建议有两条:一是优先换新版本的FFmpeg源码,大概率直接编过;二是直接给configure增加--disable-asm,先把汇编优化关掉,保证编译通过,性能的事回头再优化。毕竟能跑起来比跑得快更优先。
5.3 API级别太低,链接阶段一堆crt相关报错
第一次试着编的时候,我把API级别设成了16,想着兼容性最大化。结果configure一路绿灯,make到链接阶段炸了锅,出现一大堆类似crtbegin_so.o格式不识别、链接器崩溃的错误。
问题根因在于:新版本NDK对老旧API级别的支持已经不完整了,而且FFmpeg 6.x版本内部使用了API 21才引入的一些接口。把API从16改成21之后,链接错误全部消失。现在的移动端市场,minSdkVersion低于21的App已经很少了,所以API=21是比较合理的折中:既满足FFmpeg的要求,又不会牺牲太多兼容性。
5.4 动态库内部又静态链接:体积和加载性能双输
有一段时间我图省事,把--disable-static从configure命令里去掉了,想着让FFmpeg默认策略来,再叠加--enable-shared。结果编出来的libavformat.so体积大得离谱,而且运行时偶发加载缓慢。
原因在于:动态库内部如果链接了大量静态目标文件,ELF文件的加载开销会显著上升,动态库的“动态”只发生在对外边界,内部其实固化了大量代码。最终我老老实实恢复--disable-static,让FFmpeg对外输出动态库,内部成员之间也以动态方式链接,体积和加载表现立刻回归正常。
5.5 x86_64模拟器构建时卡在汇编阶段
为了调试方便,我还编了一版x86_64给模拟器用。configure改成:
--arch=x86_64 --cpu=x86_64 --cc=$TOOLCHAIN/bin/x86_64-linux-android21-clang结果make到汇编阶段直接崩了。原因是x86架构的汇编优化依赖nasm生成代码,而nasm版本过旧,生成的汇编格式和FFmpeg的期望不匹配。
解法很简单:升级nasm。
brew upgrade nasm升级之后重新编译,问题消失。如果升级后仍然卡住,还有一个兜底方案是给configure补一个--disable-asm,关闭汇编优化。代价是解码性能略降,但至少能让编译顺利产出。
6. 把.so接进真实项目的验证路径
6.1 用JNI写最小验证,确认so真的能加载
编译完一堆so,最怕的就是拿到Android Studio里一跑就崩。所以我会先做一个最小验证项目,路径拉通之后再接入正式模块。
操作思路很简单:新建一个空项目,把arm64的so放进app/src/main/jniLibs/arm64-v8a/,然后写一个极简的JNI调用:
public class FFmpegCheck { static { System.loadLibrary("avcodec"); } public static native String avcodecVersion(); }JNI侧对应函数调用avcodec_version(),把版本号拼成字符串返回给Java层。能跑通,就说明链接、加载、依赖、JNI签名全部OK。
这里有两个细节值得注意。第一,如果有多个.so,加载顺序上最好先加载依赖库再加载主库,比如先System.loadLibrary("avutil")再System.loadLibrary("avcodec"),虽然系统会解析NEEDED,但手动保证顺序更稳妥。第二,jniLibs目录下的ABI子目录必须和so的架构一致,arm64的so放进x86_64目录下,系统加载时会直接拒绝。
6.2 完整的可复用编译脚本
经过几轮调整后,我最终把编译过程收敛成一个可复用的脚本,方便以后改参数、重编、回滚:
#!/bin/bash set -e export NDK=/Users/你的用户名/Library/Android/sdk/ndk/25.2.9519653 TOOLCHAIN=$NDK/toolchains/llvm/prebuilt/darwin-x86_64 API=21 PREFIX=$(pwd)/android/arm64 make clean ./configure \ --target-os=android \ --arch=arm64 \ --cpu=armv8-a \ --enable-cross-compile \ --cc=$TOOLCHAIN/bin/aarch64-linux-android$API-clang \ --cxx=$TOOLCHAIN/bin/aarch64-linux-android$API-clang++ \ --sysroot=$TOOLCHAIN/sysroot \ --prefix=$PREFIX \ --enable-shared \ --disable-static \ --disable-programs \ --disable-doc \ --enable-small \ --disable-network \ --disable-everything \ --enable-decoder=h264 \ --enable-decoder=hevc \ --enable-decoder=aac \ --enable-decoder=mp3 \ --enable-encoder=aac \ --enable-parser=h264 \ --enable-demuxer=mov,mp4,m4a,3gp,3g2,mj2 \ --enable-muxer=mp4 \ --enable-protocol=file make -j8 make install cd $PREFIX/lib for f in *.so.*; do mv "$f" "${f%.*}" done脚本最后那段循环会把带版本号的libavcodec.so.60改名为libavcodec.so,不用手动一个个改。
最后再分享一点经验。交叉编译FFmpeg这件事,第一次做的时候最怕的不是技术多复杂,而是查到的资料全在讲成功路径,没人讲失败点在哪。每多踩一个坑,时间都是按小时计的。如果你跟我一样是在macOS上第一次编.so,建议从arm64版本入手,API=21、NDK r25c、FFmpeg 6.x这套组合,过程会顺很多。等跑通一遍,再去扩展ABI、加功能开关,心里就有底了。另外强烈建议把所有configure参数存成脚本,别手敲,既方便复现,也方便出错时回滚。