so16k对齐
Android 15+要求SO文件16K对齐才能上架Google Play。官方文档显示:使用NDK 28+编译时默认已16K对齐,无需特别配置。文档还提供了验证对齐方法、编译配置以及现有SO文件的修改工具。开发者可通过Android官方指南(链接)获取完整解决方案,无需依赖AI问答工具。
16KB页面大小支持是 Android 15(API 级别 35)及更高版本引入的特性,并且主要针对 ARM 64位(arm64-v8a) 架构的设备。系统内存管理的基本单位常用的4KB和16KB,这就是内存页,什么是内存页?你可以把内存页想象成内存管理的“积木块”,操作系统把物理内存和虚拟内存划分成一个个固定大小的块,这些块就叫“内存页”。它是系统进行内存分配、保护、映射和管理的基本单位。就像现实世界中的房子,所有的房间就是空间,如果都按房间号来定位太慢了,所以可以分为小区,再分单元等,这样分了之后找房间就比较容易,所以IT技术中的内存页就是为了更快的访问内存。
从4KB到16KB的转变
- 4KB页面的时代:Android系统基于Linux内核,过去长期使用4KB(4096字节)作为标准内存页大小。这在内存较小的设备上运行良好。
- 16KB页面的到来:随着硬件发展,现代手机拥有更大内存(如8GB、12GB甚至16GB以上)和ARM64架构。继续使用4KB页面会导致页表过于庞大,占用过多内存带宽,产生更多缺页中断,从而成为性能瓶颈。为了提升大内存设备的管理效率和性能,Android从15版本开始引入对16KB页面大小的支持。
为什么要转向16KB页面?
- 性能提升:这是最直观的好处。更大的页面意味着CPU加载应用数据时需要处理的页面数量减少至1/4,显著降低了“缺页中断”的频率,减轻了CPU负载。这使得应用启动更快,系统响应更迅捷,相机启动也更迅速。
- 能效优化:更高效的内存管理直接转化为更低的功耗。CPU和内存控制器工作负担减轻,执行相同任务时消耗的电量更少,有助于延长电池续航。
- 内存管理优化:虽然单次分配的内存可能因向上对齐到16KB而略有增加,但从宏观看,管理16KB页面所需的页表大小仅为4KB页面的四分之一。这对于大内存设备来说,显著减少了内存管理开销,让更多资源可供应用使用,同时减少了内存碎片。
- 硬件协同:现代ARM64架构的内存子系统天然支持更大的页面对齐,16KB页面对齐能更好地匹配现代高性能芯片的物理特性。
并非所有应用都需要开发者主动适配:
- 纯Java/Kotlin应用:通常无需修改代码。操作系统会自动处理其内存分配和管理,它们通常能天然兼容16KB页面。
- 包含原生代码(C/C++)的应用:这是需要重点关注和适配的。这些应用直接操作设备硬件,可能硬编码了固定的内存页大小(如4096)。
总的来说,Android从4KB内存页转向16KB内存页,是一次为了更好匹配现代硬件、提升系统性能和管理效率的底层优化。对用户而言,这意味着设备更快、更流畅、更省电。对开发者来说,如果应用涉及原生代码,检查和确保16KB兼容性是一项必要工作。
项目中依赖了一个库,这个库是我自己写的,有一天,突然看到有黄色警告线,如下:
提示我so没有16K对齐,百度后得到Google在Android 15+要求so16K对齐才能上传到Google Play市场,于是就想看看如何配置把自己的so编译为16K对齐,DeepSeek与ChatGPT都没给出正确答案,最后还是要找到Android官方:https://developer.android.google.cn/guide/practices/page-sizes?hl=zh-cn#compile-r28
这里面给出了答案,有告诉你怎么验证是否有16K对齐的,也有如何编译SO为16K对齐的。
总结一下,如果使用APG 8.5.1+ 和 NDK 28+编译,则默认就是16K对齐的,无需特别配置,官方说明如下:
这里有提到一个 “使用 16 KB 兼容的预构建依赖项” ,“预构建依赖项” 指的是你依赖的第三方库中的so,所以就是说除了你自己编译的so要16K对齐外,你依赖的第三方so也要16K对齐,这样你的应用才是16KB兼容的。
要验证apk中的so是否有16K对齐,使用Android Studio的 Build > Analyze APK
即可,如下:
如上图,共有两个SO,在Alignment
列中,libtinyWRAP.so
什么显示也没有,则它是16K兼容的,而另libc++_shared.so
显示了是4K对齐的。在Alignment
的第一行还显示了 “APK does not support 16KB devices”,意思就是这个apk不支持16KB设备,换句话就是说,如果你在16KB设备上安装非16KB的APK,则这个APK无法运行。虽说android 15开始支持16KB,但是这并不意味着它就是16KB的设备,这要看厂商是否对设备启用16KB。如何验证设备的页面大小,可使用adb命令:adb shell getconf PAGE_SIZE
,如果返回16384
,则说明系统内存页大小为16KB,返回4096,则说明系统内存页大小为4KB。adb shell getprop ro.product.cpu.abilist
可查看设备支持的ABI列表,比如返回:arm64-v8a,armeabi-v7a,armeabi
,adb shell getprop ro.build.version.release
查看设备的Android版本,比如返回:11,则代表当前设备是Android 11。Windows电脑内页也是使用4KB的,在PowerShell中执行命令:[System.Environment]::SystemPageSize
,返回4096。
反过来就不一样了,16KB的APK可以正常运行在4KB的设备上。谷歌使用 “16 KB 兼容 APK” 来描述,“兼容” 正确的理解就是指APK可以在16KB设备上运行就可以了,并不是要求APK本身一定要用16KB对齐,比如64KB对应的应用,它也是16KB兼容的,因为它也能在16KB设备上运行,总结就是高对齐的应用可以兼容低KB的设备。
另外,也可以通过NDK工具查看共享库(即so)的 ELF 段的对齐情况:
-
用压缩软件解压apk,得到so。
-
打开
PowerShell
,cd命令进入到路径:E:\10_Sdk\ndk\28.1.13356709\toolchains\llvm\prebuilt\windows-x86_64\bin
然后执行命令:
.\llvm-objdump.exe -p .\libtinyWRAP.so | Select-String -Pattern "LOAD"
.\libtinyWRAP.so
为要查看的so路径和文件名,这里我把so复制到了bin目录下,所以使用相对路径,也可用绝对路径。命令执行后,显示结果如下:LOAD off 0x0000000000000000 vaddr 0x0000000000000000 paddr 0x0000000000000000 align 2**16 LOAD off 0x0000000000ce5cb0 vaddr 0x0000000000cf5cb0 paddr 0x0000000000cf5cb0 align 2**16
这里主要看最后的
align 2**16
,这称为 “负载段的值”,它的值也可能是:2**14
、2**13
、2**12
等,要确保负载段的值不小于2**14
,在 ELF 程序头(Program Header)里,align
一般是2**N
的形式,表示2的N次幂,它代表这个 段(Segment)在内存中加载时的对齐大小,常见值含义如下:2**12
= 4096 = 4KB,对应传统的ARM64/x86_64 Linux、Android
默认内存页大小,旧版 NDK 里的libc++_shared.so
就是4K对齐的。2**13
= 8192 = 8KB,比较少见,一些特定嵌入式或服务器环境可能用。2**14
= 16384 = 16KB,新一代 ARMv9 SoC、部分 Linux 发行版开始启用,Google 要求“16 KB 兼容”就是为了保证在这类设备上能跑。新版 NDK 里的libc++_shared.so
就是16K对齐的。2**16
= 65536 = 64KB,比16KB大很多,Loader 在加载时,只要硬件支持更小的页(比如 4 KB 或 16 KB),也能对齐成功,所以2**16
是 完全兼容 4KB/16KB 页大小的安全选择。
libc++_shared.so
是Android官方预编译的共享库,所以,如果我们需要16K兼容的,则从NDK 28+中获取即可,路径为:<NDK_ROOT>/toolchains/llvm/prebuilt/<host-tag>/sysroot/usr/lib/<triple>/<api-level>/libc++_shared.so
<host-tag>
我看到的目录只有:windows-x86_64
<triple>
则有多个目录:aarch64-linux-android
,对应的Android ABI
为arm64-v8a
,64位 ARM 处理器 (如高通骁龙8系列,苹果A系列)arm-linux-androideabi
,对应的Android ABI
为armeabi-v7a
,32位 ARM 处理器 (较老的设备)i686-linux-android
,对应的Android ABI
为x86
,32位 Intel/AMD 处理器 (常用于模拟器)x86_64-linux-android
,对应的Android ABI
为x86_64
,64位 Intel/AMD 处理器 (常用于模拟器)riscv64-linux-android
,对应的对应的Android ABI
为riscv64
,64位 RISC-V 处理器 (新兴架构)
<api-level>
也有多个目录, 以API版本号为目录名,比如 21 到 35 的目录,-
在每个
<triple>
目录中都有一个libc++_shared.so
,在每个<api-level>
目录中也有libc++_shared.so
,这些so只是针对不同最低平台版本编译/链接的变体:它们链接到对应 API 的 bionic(libc/libm/libdl)符号集,保证你不会意外用到高于 minSdk 的系统符号,越高版本的目录,允许用到越新的系统符号,功能实现基本一致,不是“功能更全”,只是兼容范围不同。所以我们在选择的时候,应该选择<api-level>
目录中的so,且以你项目中声明的minSdk为目录,比如minSdk为21,则选择21目录下的so。<triple>
目录中的libc++_shared.so
是一个通用的默认版本,实际上它会是某个最低 API level 的副本或链接(通常对应 21,因为 arm64 从 API 21 才开始支持,也就是说从Android 5.0开始的手机才开始使用64位架构,之前的手机即使CPU支持64位,但是系统是32位的,而且现在的手机基本都是64位的了,所以minSdk设置为21算是最低的版本设置了,Android 5.0发布于2014年11月,而且2019年8月Google Play强制要求上传的apk如果有so必须提供64位版本)。如果手动选,还是选择<api-level>
目录里面的,这样更加明确你需要的minSdk版本是什么,如果让Gradle/CMake 自动处理,它也是选择<api-level>
目录里面的。最佳的做法是让构建工具自动帮我们选择,比如我们修改了最小SDK版本,但是却忘记重新导入一个新的libc++_shared.so
,所以最好是让Gradle构建工具自动选择。如何实现自动选择呢?就算我们的项目本身并没有Native代码,只是使用了第三方库中的so,但是为了能让Gradle构建工具帮我们选择一个libc++_shared.so
打包到apk,则我们也需要创建Native代码,我们写一个空壳即可,在main目录下创建cpp,并创建CMakeLists.txt
和dummy.cpp
,取名dummy
表示这只是占位而已,是个空壳,两个文件内容分别如下:cmake_minimum_required(VERSION 3.22.1) # dummy在这里就是占位、填充用的意思,因为我们的项目本身没有native代码,只是想借助 CMake 机制,让Gradle自动把libc++_shared.so打包进APK # 在我工作的一个项目中,因为一个第三方的libtinyWRAP.so依赖libc++_shared.so project("dummy") add_library(${CMAKE_PROJECT_NAME} SHARED dummy.cpp) target_link_libraries(${CMAKE_PROJECT_NAME})
void dummy() {}
然后在
build.gradle.kts
文件中,添加如下配置:android { defaultConfig { // 使用NDK 28+,以便编译的so自动兼容16K ndkVersion = "28.1.13356709" externalNativeBuild { cmake { arguments += "-DANDROID_STL=c++_shared" // 配置使用c++_shared共享库 cppFlags += "-std=c++17" } } } externalNativeBuild { cmake { path = file("src/main/cpp/CMakeLists.txt") version = "3.22.1" } } }
这里主要配置是添加
arguments += "-DANDROID_STL=c++_shared"
配置,这样Gradle就会自动把libc++_shared.so
也打包进来,而且它会根据项目中的minSdk
自动选择一个最合适的so,Java代码里面我们并不需要加载我们的空的so(即libdummy.so
)。另外我还有另一篇文章关于libc++_shared.so的文章:https://blog.csdn.net/android_cai_niao/article/details/128204429 -
然后我通过工具查看打包到apk里的
libdummy.so
和libc++_shared.so
,都是2**14
的,也就是16KB对齐的。这说明NDK28默认编译为16KB对齐,而系统预编译的libc++_shared.so
也是16KB对齐。
-
使用
zipalign
命令工具验证所有的so是否都正确对齐了,工具路径为:sdk\build-tools\35.0.0\
执行命令:
.\zipalign.exe -c -v -P 16 4 .\imsdroid-new-debug.apk
此时会很非常多的输出,我们只看最后一行,如果最后一行为:
Verification succesful
这个输出表示验证成功。
zipalign.exe
是 Android SDK 提供的一个用于对齐 ZIP 包(如 APK)内文件内容的工具。它的核心功能是对 APK 文件中的未压缩资源进行字节边界对齐,从而提升应用在 Android 设备上的运行效率和性能,我们使用zipalign
工具来检查指定的 APK 文件是否正确对齐,确认其内部所有未压缩的文件(特别是 .so动态库)是否根据指定的字节边界进行了正确的对齐。zipalign.exe -c -v -P 16 4 abc.apk
参数说明:-c
仅检查对齐情况 (check
)。使用此参数时,zipalign
会验证 APK 的对齐状态,但不会对文件进行任何修改或生成新文件。-v
详细输出 (verbose
)。让工具在检查过程中输出更详细的信息,便于你查看哪些文件已对齐或未对齐。-P 16
页面大小设置。指定未压缩的 .so(共享库) 文件应按照 16KB (16384字节) 的页面边界进行对齐。有效值为 4、16、64,现代 Android 开发中,为适配 16KB 页面设备,常使用-P 16
。4
强制性对齐参数。指定 APK 中所有其他非 .so文件(如资源文件)应按 4字节 边界进行对齐,这有助于优化系统对资源的访问速度。必须为 4(表示4字节对齐),否则优化可能无效。
需要注意的是使用zipalign
进行的对齐操作,它指的是对未压缩的.so
文件以及其它文件在apk中的存储布局中的对齐情况,比如前面用zipalign
检查对齐操作中,-P 16
参数指定了要验证.so
文件在 APK 中的存储布局是否按 16KB 边界对齐,并不是验证.so
本身的 ELF 段的对齐,如果验证通过,则意味着.so
文件在 APK 包内的偏移地址是 16KB 的整数倍,系统可通过 mmap高效映射。参数4
则指定了要验证APK 中其他所有文件在 APK 中的存储布局是否按 4字节边界对齐,如果验证通过,则意思着这些文件在 APK 包内的偏移地址是 4 字节的整数倍,便于系统高效访问。
所以,如果apk中有个别so是4KB对齐的,用zipalign
也是会返回验证通过的,因为它验证的是该so在apk中的存储布局的边界对齐情况而已,需要验证so自身是否兼容16K,还是得用llvm-objdump.exe
工具,或者用Android studio的APK分析器。
使用zipalign
对齐的目的:通过对齐优化,确保 Android 系统能够使用 mmap()等方式高效直接地访问 APK 中的资源,减少内存复制开销,从而提升应用性能并减少内存占用。在使用Android studio打包apk时,Gradle会自动调用zipalign
对我们的apk进行对齐操作。
使用Android Studio进行打包时,它会自动调用zipalign
工具对我们的apk进行对齐操作,不需要手动操作。zipalign
的操作发生的位置:先打包未签名apk,然后执行zipalign
对未签名Apk进行对齐,然后对Apk进行签名。
so的ELS段对齐是在NDK编译so的时候完成的,这是不可逆的,如果需要修改对齐,则需要对源代码进行重新编译,所以,如果是用到第在方的so,它是4K对齐的,而你需要的是16K对齐,我们自己是没办法修改的,需要找拥有源代码的so提供商重新编译16K的so。而zipalign
对齐是可逆的,如要修改apk对齐,我们自己重新运行zipalign
命令调整不同的对齐参数即可。
另外,有一个需要注意的是,如果在Adnroid Studio
中直接点击绿色的三角运行按钮来直接把项目运行到设备上进行测试的时候,不论你运行的是deubg
变体还是releease
变体,Android studio
都只会打包当前所连接设备的CPU架构对应so到apk,比如你的手机是arm64-v8a
的,则它就只会把项目中arm64-v8a
的so打到apk中,它的初衷是为了减少apk体积,同时也加快打包速度,因为你是点运行按钮直接运行的,所以它知道你是在测试,也知道你是在哪种CPU构架的设备上测试,所以只打包对应CPU构架的so即可,但是这在有些场景下会产生bug,比如项目中只有32位的so,在什么都不配置的情况下,点运行按钮运行到arm64-v8a
的设备,则就会有问题,因为Android Studio
只会找arm64-v8a
的so打包到apk,但是项目中并没有arm64-v8a
的so,只有32位的,但是它也并不会把32位的so打包到apk,所以运行就会报找不到so的错误问题,但是明明arm64-v8a
的设备也是兼容32位so的呀。如果我们不是点击运行按钮直接运行,而只是执行打包操作,则Gradle不知道你打包的apk会运行到什么手机,所以不论你打包的是debug版本还是release版本,都会把所有的so打包到apk。假如项目只有32位so的话,平时测试肯定是直接点运行按钮啊,这种情况的解决办法就是通过ndk.abiFilters
明确指明自己的apk需要使用的so,如下
android {
defaultConfig {
ndk.abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86_64")
}
}
这个配置要加在主项目,如果你的主项目依赖了一个库项目,如果把这个配置写到库项目的build.gradle.kts
中是不起作用的,必须写在主项目,这个配置的功能就是说,不管三七二十一,就算是直接点运行按钮运行项目,也要把我们指定的所有的so打包到到apk中。
如果我们有natvie代码,则ndk.abiFilters
也同样在告诉编译器,只编译这里列出来的cpu框架的so,没列出来的就不编译,就算是打包Release版本也一样。如果不配置ndk.abiFilters
的话,则直接点运行按钮运行项目时,只会编译当前连接设备对应CPU框架的so到apk中,如果不运行,只是打包apk,则会编译所有cpu架构的so到apk中,所有指的是:armeabi-v7a
、arm64-v8a
、x86
、x86_64
,共4种,分别对应arm架构的32位和64位,以及x86架构的32位和64位,如果使用NDK28+编译的话,则4种so都会自动16KB对齐。
手机设备都是arm架构,windows电脑上的Android模拟器一般使用x86_64架构,因为Windows系统的电脑本身就是x86_64架构的,所以用x86_64架构模拟器速度就会比较快,如果用arm64-v8a的模拟器就需要进行命令翻译就比较慢。总结就是我们的apk一般不需要打包x86_64的so,因为手机上用不到,如果打包了x86的就会增加apk大小,但又用不到,就很浪费,当需要在模拟器上测试时可以临时修改以便生成x86的进行测试。或者是一些游戏apk可能就会需要x86的so,因为很多人也需要在模拟器上玩游戏,所以游戏一般要支持x86。
最后再总结就是,不管三七二十一,我们都应该设置ndk.abiFilters
,明确指定我们需要的架构,这样的好处是,我们项目中可以有x86的so,但是ndk.abiFilters
并没有配置x86的,所以打包的apk不会带x86的so,当我需要在模拟器上测试时,我可以临时修改ndk.abiFilters
增加x86配置即可。
另外,当你依赖一些第三方so,且你又有自己的natvie的时候,如果第三方提供的so的cpu构架比较少,但是你自己的native是可以编译为任意cpu构架so的,这就会导致在某些cpu构架自己的natvie有so,而第三方的缺少,这种情况只能找第三方提供商补齐,如果没法补齐的,则只能在代码中做判断,如果运行到某个缺失so的cpu框架,则不加载该缺失的so,相应的就禁用它对应的功能,否则程序就会崩。
什么是预构建库
预构建库,简单来说就是提前为你编译好的代码包。它们是你项目依赖的第三方库或组件,但不是以源代码形式提供,而是已经编译好的二进制文件(如 .so、.a、.jar、.dll文件等)。这个名字很直观,关键在于“预”和“构建”这两个词。
方面 | 说明 |
---|---|
核心概念 | 预先编译好的二进制代码包,可直接在项目中使用,无需从源码重新编译。 |
名字由来 | “预”指时间上提前于你的主项目;“构建”指编译和链接的整个过程。 |
主要优势 | - 提升开发效率:节省大量编译时间。 - 保护知识产权:分发二进制文件而非源代码。 - 简化依赖管理:减少环境配置和依赖冲突的麻烦。 - 保证稳定性:通常经过严格测试,行为稳定可靠。 |
常见类型 | - Android NDK 中的 .so(共享库) 和 .a(静态库) - Windows 开发中的 .lib(静态库) 和 .dll(动态链接库) - Java 的 .jar 包 - Python 的 wheel 文件 (.whl) 或已编译的 C 扩展 |
即然有预构建库,那就有非预构建库,以源代码或原始模块形式存在,在项目构建时由你的构建工具直接编译。
在 Android NDK 开发中,.a
(静态库)和.so
(共享库 / 动态库)主要有以下区别:
对比项 | .a(静态库) | .so(共享库) |
---|---|---|
链接方式 | 编译时会将库的代码完整地嵌入到最终的可执行文件(或其他库)中。 | 编译时仅记录库的引用,运行时才会动态加载到内存中,多个程序可共享同一份库文件。 |
文件体积 | 最终可执行文件因包含库代码,体积较大。 | 可执行文件体积较小,但需要额外携带.so文件。 |
内存占用 | 每个使用该库的程序都会在内存中保留一份库代码,内存占用较高。 | 多个程序共享同一份加载到内存的库,内存占用较低。 |
更新维护 | 若库有更新,依赖它的程序需重新编译才能生效。 | 只需替换.so文件,依赖它的程序无需重新编译即可使用新库(需保证接口兼容)。 |
使用场景 | 适合功能独立、无需共享、对更新频率要求低的模块,或为了避免运行时依赖问题。 | 适合被多个模块/应用共享、需要频繁更新或对文件体积/内存敏感的场景,如公共工具库、第三方SDK等。 |
示例 | 若有一个仅在某应用内部使用的算法库,且不常更新,可打包为.a。 | Android系统中的libc.so 、第三方音视频库(如FFmpeg编译出的.so)等,被多个应用或模块共享。 |
而且不会将 .a
静态库中的所有内容都编译到你的 .so 中,只会包含被实际引用的代码(包括直接调用的函数及其依赖的相关代码)。
官网关于16KB的另一个链接
https://source.android.com/docs/core/architecture/16kb-page-size/16kb?hl=zh-cn
这里面有提到16KB的许多知识,比如在Native代码中,如何获取页大小。比如你申请3KB内存,如果是4KB设备,它会分配给你4KB内存,如果你申请5KB内存,它会分配给你8KB内存。对于16KB设备也一样,如果你申请1KB内存,它会给你分配16KB内存,如果你申请17KB内存,它会给你分配32KB内存。针对 16 KB 页面大小进行优化,比如在使用mmap分配 内存时,请务必始终传递一个为页面大小倍数的参数。启用 16 KB 切换开关,借助 16 KB 切换开关,您可尝试 16 KB 内核。此切换开关可在 4 KB 内核和 16 KB 内核之间切换,可在设置应用的“开发者选项”菜单中找到。启用 16 KB 向后兼容选项,将 android:pageSizeCompat 属性设为 enabled 或 disabled,以在特定应用的 AndroidManifest.xml 中开启或关闭向后兼容模式。设定此属性后,该应用在启动时不会显示向后兼容模式警告。哎,知识点太多,懒得什么都记了,总之,这个链接是个好地方,有时间应该多了解。
Native中对齐内存页的优化示例
答案来自ChatGpt,未验证,没时间,太累了,知识点太多,学了一个又一个,怎么也学不完哪!
假设我想兼容16KB对齐,我在写Native项目时,我在什么地方可以使用16KB对齐的相关代码,以优化性能,能举一些具体的实例吗?并给出具体代码 。
如果你已经决定让 Native 项目兼容 16 KB 页大小,那除了让 .so
本身 16 KB 对齐(链接器、NDK 工具链会帮你搞定),在你写代码时也有一些地方可以用 16 KB 对齐的内存/数据结构 来获得更好的性能,尤其是在 内存分配、缓冲区管理、SIMD 优化 这些场景。
1. 内存分配对齐到 16 KB
在 16 KB 页大小的设备上,malloc/new 出来的内存虽然能用,但它未必对齐到 16 KB 边界。如果你要做 DMA、GPU、音视频缓冲区、文件 IO 这类操作,自己申请 16 KB 对齐的内存块更高效。
C/C++ 示例
#include <cstdlib>
#include <cstdio>
#include <cstdint>
int main() {
void* ptr = nullptr;
size_t alignment = 16 * 1024; // 16 KB
size_t size = 1 * 1024 * 1024; // 1 MB buffer
// posix_memalign: alignment must be power of two and multiple of sizeof(void*)
if (posix_memalign(&ptr, alignment, size) != 0) {
perror("posix_memalign failed");
return -1;
}
printf("Allocated buffer at %p, aligned to %zu bytes\n", ptr, alignment);
// use buffer...
uint8_t* buf = reinterpret_cast<uint8_t*>(ptr);
buf[0] = 123;
free(ptr);
return 0;
}
👉 在 ARM64 16KB page 上,确保大块 buffer(视频帧、网络缓存)按 16KB 对齐,可以减少 TLB miss,提升内存访问效率。
2. OpenGL / Vulkan / MediaCodec Buffer 对齐
如果你把 NV21/NV12 图像送给 GPU 或 MediaCodec,最好保证内存起始地址和 stride 也是 16KB 对齐,这样驱动层的 DMA 映射会更快。
// 申请用于 GPU/MediaCodec 的 YUV 缓冲
void* yuvBuf = nullptr;
size_t alignment = 16 * 1024;
size_t yuvSize = width * height * 3 / 2;
posix_memalign(&yuvBuf, alignment, yuvSize);
// 把 yuvBuf 交给 MediaCodec 或 OpenGL 的纹理上传
3. NEON SIMD 优化时的内存对齐
在用 NEON 处理 YUV → RGB 或水印叠加时,数据对齐到 128bit (16 bytes) 已经是基本要求。
但如果你能确保整个缓冲区是 16KB 对齐 的,那么:
- 在内存分页级别也会更友好;
- 大批量内存访问(prefetch、streaming load)更高效。
示例(叠加水印,每次处理 16 字节):
#include <arm_neon.h>
void add_watermark(uint8_t* dst, const uint8_t* wm, size_t len) {
size_t i = 0;
for (; i + 16 <= len; i += 16) {
uint8x16_t d = vld1q_u8(dst + i);
uint8x16_t w = vld1q_u8(wm + i);
uint8x16_t r = vqaddq_u8(d, w); // 饱和加
vst1q_u8(dst + i, r);
}
// 处理剩余字节
for (; i < len; i++) {
dst[i] = std::min<int>(dst[i] + wm[i], 255);
}
}
⚡ 如果 dst
和 wm
都是 16KB
对齐的缓冲区,CPU prefetch & cache line 填充更稳定。
4. 文件映射 (mmap) 对齐
在处理大文件(比如 H.264/H.265 流,或模型参数)时,你可以按 16KB 边界映射文件,避免跨页访问性能损失:
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
int fd = open("video.h264", O_RDONLY);
size_t length = 16 * 1024; // map 16KB
void* map = mmap(NULL, length, PROT_READ, MAP_PRIVATE, fd, 0);
if (map == MAP_FAILED) {
perror("mmap failed");
} else {
// use mapped data...
munmap(map, length);
}
close(fd);
5. NDK 构建层面的对齐
最后,确保 .so
本身是 16KB ELF 对齐,这部分由 NDK r28+ 处理。
你只需要在 CMakeLists.txt
里配置正确:
cmake_minimum_required(VERSION 3.22.1)
project(MyApp)
set(CMAKE_CXX_STANDARD 17)
add_library(my_native SHARED my_native.cpp)
# 确保使用新版工具链
set(CMAKE_ANDROID_NDK "E:/Sdk/ndk/28.0.12433566")
set(CMAKE_ANDROID_STL_TYPE c++_shared)
编译出来的 .so
会自动是 16KB 对齐。
✅ 总结:你能用 16KB 对齐优化的点
- 大块内存分配(
posix_memalign
) - GPU/MediaCodec 输入输出缓冲区
- NEON SIMD 批量处理时的输入输出缓冲
- 文件映射(mmap)
.so
本身通过 NDK r28+ 自动对齐
要不要我帮你写一个 专门针对 NV21 → NV12 转换 的 16KB 对齐 + NEON SIMD 优化版本示例?这样你能看到在你项目中的真实用法。
好的,帮我帮你写一个 专门针对 NV21 → NV12 转换 的 16KB 对齐 + NEON SIMD 优化版本示例
太好了 — 我把一个 完整的示例 给你:
- 支持 16KB 对齐的缓冲申请(
posix_memalign
), - 用 ARM NEON 指令 高速把 NV21(VU) 转换为 NV12(UV),
- 包含 纯 C++ 回退实现(当 NEON 不可用时),
- 并且给出 JNI 导出 与 CMakeLists.txt,以及 Kotlin/Java 层调用示例,方便直接在 Android Native 项目里使用或做性能对比。
假设:图像宽度
width
必须为偶数(摄像头帧通常如此)。代码对每行的尾部做余数处理以保证安全。
1) native/src/nv21_to_nv12.cpp
// nv21_to_nv12.cpp
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <cstdio>
#include <jni.h>
#include <android/log.h>
#define LOG_TAG "nv21_to_nv12"
#define ALOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
#if defined(__ARM_NEON) || defined(__ARM_NEON__)
#include <arm_neon.h>
#define HAVE_NEON 1
#else
#define HAVE_NEON 0
#endif
extern "C" {
/**
* 直接在内存里把 NV21 -> NV12
* src_nv21: 指向 NV21 数据(Y plane 紧随 chroma VU plane)
* dst_nv12: 输出缓冲区(需至少 width*height*3/2 字节)
* width, height: 图像尺寸,width 必须为偶数
*
* 要求:dst/src 可以是任意对齐,但性能更好时建议 16KB 对齐。
*/
void nv21_to_nv12_generic(const uint8_t* src_nv21, uint8_t* dst_nv12, int width, int height) {
int ySize = width * height;
// 复制 Y 平面(逐字节)
memcpy(dst_nv12, src_nv21, ySize);
const uint8_t* chroma_src = src_nv21 + ySize; // NV21: VU VU ...
uint8_t* chroma_dst = dst_nv12 + ySize; // NV12: UV UV ...
int chroma_h = height / 2;
int chroma_stride = width; // 每行的 UV 字节数 = width (因为每像素对占 2 字节)
for (int row = 0; row < chroma_h; ++row) {
const uint8_t* in = chroma_src + row * chroma_stride;
uint8_t* out = chroma_dst + row * chroma_stride;
int x = 0;
#if HAVE_NEON
// 每次处理 16 字节(即 8 对 VU);使用 vrev16q_u8 可以在每个 16-bit 半字中反转字节(V,U -> U,V)
for (; x + 16 <= chroma_stride; x += 16) {
uint8x16_t v = vld1q_u8(in + x);
uint8x16_t swapped = vrev16q_u8(v); // 在每个 16-bit 小组内交换字节 (b1,b0)...
vst1q_u8(out + x, swapped);
}
#endif
// 处理剩余字节(逐字节交换)
for (; x < chroma_stride; x += 2) {
// NV21: V U -> NV12: U V
uint8_t v0 = in[x]; // V
uint8_t u0 = in[x + 1]; // U
out[x] = u0;
out[x + 1] = v0;
}
}
}
/**
* JNI wrapper: Java 层传入 direct ByteBuffer 地址或 byte[] 也可通过 GetByteArrayElements 传入(这里示例 direct ByteBuffer)
*
* Java side method signature (示例):
* public static native void nativeNv21ToNv12(long srcAddr, long dstAddr, int width, int height);
*/
JNIEXPORT void JNICALL
Java_com_example_nativeconv_NativeLib_nativeNv21ToNv12(JNIEnv *env, jclass /*clazz*/,
jlong srcAddr, jlong dstAddr,
jint width, jint height) {
const uint8_t* src = reinterpret_cast<const uint8_t*>(static_cast<uintptr_t>(srcAddr));
uint8_t* dst = reinterpret_cast<uint8_t*>(static_cast<uintptr_t>(dstAddr));
if (!src || !dst || width <= 0 || height <= 0) {
ALOGE("Invalid args");
return;
}
nv21_to_nv12_generic(src, dst, width, height);
}
/**
* Helper that allocates a 16KB-aligned buffer in native side and returns the pointer as jlong.
* Caller must call nativeFreeAligned(ptr) to free it.
*/
JNIEXPORT jlong JNICALL
Java_com_example_nativeconv_NativeLib_nativeAllocAligned(JNIEnv *env, jclass /*clazz*/, jlong size) {
void* p = nullptr;
size_t alignment = 16 * 1024; // 16KB
if (size <= 0) return 0;
int ret = posix_memalign(&p, alignment, static_cast<size_t>(size));
if (ret != 0) {
ALOGE("posix_memalign failed: %d", ret);
return 0;
}
// optionally touch pages to ensure allocation (not necessary)
return reinterpret_cast<jlong>(reinterpret_cast<uintptr_t>(p));
}
JNIEXPORT void JNICALL
Java_com_example_nativeconv_NativeLib_nativeFreeAligned(JNIEnv *env, jclass /*clazz*/, jlong ptr) {
if (ptr == 0) return;
void* p = reinterpret_cast<void*>(static_cast<uintptr_t>(ptr));
free(p);
}
} // extern "C"
2) CMakeLists.txt(最小示例)
cmake_minimum_required(VERSION 3.18)
project(nativeconv)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(nativeconv SHARED
nv21_to_nv12.cpp
)
find_library(log-lib log)
target_include_directories(nativeconv PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_options(nativeconv PRIVATE
# 启用 NEON 优化(NDK 会自动传入合适的标志,但这里明确一下)
$<$<AND:$<C_COMPILER_ID:GNU>,$<BOOL:TRUE>>:-march=armv8-a -O3 -fomit-frame-pointer>
)
target_link_libraries(nativeconv ${log-lib})
在
app
的externalNativeBuild
中配置好 NDK r28+,并确保abiFilters
包含arm64-v8a
(NEON 在 arm64 默认可用)。
3) Kotlin / Java 层示例(调用接口)
假设你把 JNI 类命名为 com.example.nativeconv.NativeLib
:
// NativeLib.kt
package com.example.nativeconv
object NativeLib {
init {
System.loadLibrary("nativeconv")
}
// native functions
@JvmStatic external fun nativeNv21ToNv12(srcAddr: Long, dstAddr: Long, width: Int, height: Int)
@JvmStatic external fun nativeAllocAligned(size: Long): Long
@JvmStatic external fun nativeFreeAligned(ptr: Long)
}
使用示例(用 DirectByteBuffer 传递地址):
fun convertFrameNV21toNV12(src: ByteArray, width: Int, height: Int): ByteArray {
val frameSize = width * height * 3 / 2
// 分配目标 buffer(直接堆外,以便取地址)
val dstDirect = java.nio.ByteBuffer.allocateDirect(frameSize)
val srcDirect = java.nio.ByteBuffer.allocateDirect(frameSize)
srcDirect.put(src)
srcDirect.position(0)
dstDirect.position(0)
// 获取 native 地址(在 Android 上,DirectByteBuffer 的地址可以通过 reflection or JNI 获取)
// 为简单起见:我们可以把 DirectByteBuffer 传给 JNI(通过 NewDirectByteBuffer 反向)
// 这里示例调用 nativeAllocAligned 并手动复制 src 到 native 内存以获得最佳对齐
val dstPtr = NativeLib.nativeAllocAligned(frameSize.toLong())
val srcPtr = NativeLib.nativeAllocAligned(frameSize.toLong())
if (dstPtr == 0L || srcPtr == 0L) {
throw RuntimeException("nativeAllocAligned failed")
}
// 把 src byte[] 拷贝到 native srcPtr(使用 JNI 是最有效的方式,这里演示简化版:使用 Unsafe 或 JNI)
// 推荐方法:在 JNI 中提供一个 nativeCopyFromJava(byte[] src, long destPtr, int len) 来做拷贝
// 为演示,这里用 ByteBuffer with order:
val srcBuf = java.nio.ByteBuffer.allocateDirect(frameSize)
srcBuf.put(src)
srcBuf.position(0)
// 使用 JNI 把 srcBuf 数据拷贝到 srcPtr(建议实现 native helper),简化示例略去这一步细节。
// 假设已经把数据放到 srcPtr
// 调用转换
NativeLib.nativeNv21ToNv12(srcPtr, dstPtr, width, height)
// 从 dstPtr 读取结果拷贝回 Java(同样建议写一个 nativeCopyToJava(destPtr, byte[] dst, len))
val out = ByteArray(frameSize)
// 读取到 out...
// 释放 native 内存
NativeLib.nativeFreeAligned(srcPtr)
NativeLib.nativeFreeAligned(dstPtr)
return out
}
注:这里 Java <-> native 的数据传输细节(如何把 Java
byte[]
拷贝到 native 对齐缓冲、如何从 native 取回)在生产里建议用两个 JNI 辅助函数nativeCopyFromJava(byte[] src, long destPtr, int len)
,nativeCopyToJava(long srcPtr, byte[] dst, int len)
来做,这样效率高且安全。我可以为你把这两个 JNI 函数也写出来(如果你愿意)。
4) 说明与优化点(为什么这有用)
- 为什么要 16KB 对齐?
- 在 16KB 页设备上,内核/驱动在做 DMA 映射、TLB 管理时按页粒度工作。大缓冲区若按 16KB 边界对齐,能减少跨页访问和 TLB miss(尤其对大帧、连续扫描访问有收益)。
- 为什么在 chroma 层用 vrev16q_u8?
- NV21 的 chroma 是按 16-bit (V,U) 一组排列。
vrev16q_u8
在每个 16-bit 半字内交换两个字节,恰好把 VU -> UV。此方法简单且高效(内置 NEON 指令),每次处理 16 字节(8 个像素对)。
- NV21 的 chroma 是按 16-bit (V,U) 一组排列。
- Y 平面直接 memcpy:
- Y 分量无需变换,直接拷贝最快。若想更节省拷贝(零拷贝),可以考虑在摄像头层/编码层协商格式(但通常摄像头给的是 NV21,编码器需要 NV12,所以转换不可避免)。
- 对齐缓冲的申请:
- 用
posix_memalign
给大缓冲申请 16KB 对齐,配合 JNI 传递给 MediaCodec/GL 层或直接用于 SIMD 计算,能显著提升性能与兼容性。
- 用
5) 可选的扩展(我可以继续给你做)
- 完整 JNI 助手:
nativeCopyFromJava
,nativeCopyToJava
,以便直接用byte[]
互传(零麻烦)。 - 在
CMakeLists.txt
里加入编译选项-O3 -march=armv8-a -funroll-loops
以及对arm64-v8a
的条件编译以获得更高性能。 - 一个基准测试:对比未对齐 vs 16KB 对齐的处理时间(在真实 16KB 设备上测)。
- 如果你希望,我可以把 完整工程 skeleton(包含 JNI 辅助函数与 Kotlin 调用)打包成单文件
zip
给你。
要我现在把 JNI 辅助拷贝函数和更完整的 Kotlin 使用示例补上吗?或者你想直接把这段代码整合到你的项目里让我帮你调整 CMake/Gradle 配置?
更多推荐
所有评论(0)