做 Android 底层开发的人,迟早会碰到 AIDL、NDK、HAL 这三个词凑到一块儿的场景。通常是你刚拿到一块新板子,或者要接一颗新外设、新传感器的时候,需求长得很固定:App 要能控制硬件,Java/Kotlin 这层得有人去调 C/C++,最终还得有东西能跟内核驱动对上话。这条链路看着不复杂,真从零开始搭,一堆构建问题能把人磨到没脾气。
这篇我就按自己实际踩过的路径,把 AIDL 接口、NDK 本地库、HAL 层实现从无到有过一遍,重点放在 Android Studio 工程里怎么组织代码、CMake 怎么配、AIDL 为什么生成失败、NDK 版本到底怎么选,以及最后把 HAL 这一层用模拟实现串起来。不是教科书式讲概念,就是一套能直接拿去改的实战流程,适合刚接触 Android 底层开发、或者接了板子不知道怎么下手的朋友参考。
1. 先看清套路:AIDL、NDK、HAL 在一条链上各自干什么
1.1 三个概念的分工,用一句话就能说清
AIDL(Android Interface Definition Language)解决的是跨进程接口定义问题。Android 里的 Binder 机制让不同进程能像调用本地方法一样互相调,但跨进程传参需要定义好接口语言,AIDL 就是干这个的。你写一个 .aidl 文件,构建系统会帮你生成 Java 或 C++ 的 Stub/Proxy 代码,通信细节全部封装好。
NDK(Native Development Kit)是让你用 C/C++ 写 Android 代码的工具链。它提供了交叉编译环境、一系列 native API 和 JNI 绑定机制。JVM 跑在虚拟机里,C/C++ 跑在真实 CPU 上,中间靠 JNI 这座桥连通。NDK 干的事情就是帮你把 .c/.cpp 编译成 .so,并在 Java 层能 load 进去调用。
HAL(Hardware Abstraction Layer)是硬件抽象层。它定义了一套稳定的 C 结构体接口,让上层的 Android 框架不直接面对千奇百怪的内核驱动。驱动是仓库内部怎么码货都行,HAL 是仓库对外开的统一窗口,上层只需要知道窗口长什么样,不用管仓库里怎么摆。
1.2 为什么这三个东西经常一起出现
实际项目里你很少单独用 AIDL,也很少单独写一个孤立 NDK 库。最常见的组合是:App 希望控制一个 Linux 驱动设备,但出于权限和安全考虑,App 不能直接 open("/dev/xxx"),于是中间要隔好几层。
完整链路通常长这样:App 通过 bindService 绑定系统服务,服务端实现 AIDL 接口,接口实现里调用 JNI,JNI 进入 native 层,native 层再通过 HAL 的 hw_get_module 或直接 dlsym 加载厂商库,最终下发 ioctl 或 sysfs 操作到内核。每一层都是边界,每一层都有自己的一套规范和坑。
AIDL 和 HAL 的关系在 Android 11 之后也有变化。Android 8 引入 Treble 结构时主要推 HIDL,后来 Android 11 开始允许用 AIDL 作为稳定的 HAL 接口语言(AIDL Stable)。现在新项目我更推荐直接用 AIDL,少学一套语法,构建也更统一,Google 官方也在往这个方向收拢。
1.3 开发形态决定你后面踩的坑
先分辨你是在哪种环境里干活,这比写代码还重要。第一种是 AOSP 源码树内开发,用 Android.bp 或 Android.mk 编译,HAL 模块、系统服务基本都走这种,好处是能直接进系统镜像,坏处是编译一次系统要等很久,调试链路长。第二种是 Android Studio 独立工程,App 模块加本地 C/C++ 库,用 Gradle 和 CMake 构建,适合做系统应用、厂商调试工具、或者先在用户态把逻辑验证通。
我下面的实战以 Android Studio 工程为主线,因为大多数学习场景和中等规模项目都在这里,HAL 层用模拟实现替代。你要真上了 AOSP,原理完全一样,只是编译脚本换一下。
2. 从零搭工程骨架:模块怎么切、构建体系怎么选
2.1 模块划分思路,避免一锅炖
不要把所有东西塞进一个 app 模块。至少分成三层:
- app 层:纯 Java/Kotlin,只管 UI、权限申请、bindService,不碰任何 native 细节。
- aidl 接口层:定义跨进程契约,包括主接口和回调接口。
- native 层:实现 JNI 逻辑,编译成 .so,内部再分业务逻辑和 HAL 模拟实现。
如果以后要复用 native 代码,可以把 native 抽成独立模块,也可以把 aidl 单独做成 library module 给多个 app 用。项目初期如果就我一个人写,我会先保证包结构清晰,不做过度拆分,但 aidl 目录和 cpp 目录一定要分开,不要让 Java 和 C++ 文件混在一起。
一个能直接参考的目录结构长这样:
app/ ├── src/main/ │ ├── aidl/ │ │ └── com/example/led/ │ │ ├── ILedControl.aidl │ │ └── ILedStatusCallback.aidl │ ├── java/com/example/led/ │ │ ├── LedService.java │ │ └── MainActivity.java │ ├── cpp/ │ │ ├── CMakeLists.txt │ │ ├── led_jni.cpp │ │ ├── hal_led.h │ │ └── hal_led_stub.c │ └── AndroidManifest.xml └── build.gradle2.2 构建方式选型,直接决定坑的类型
同一个 native 功能,你有三种构建路径:
- 预编译 .so:厂商已经编好 HAL 和 JNI 库,你只负责放 jniLibs。省事,但架构不匹配时会报 UnsatisfiedLinkError,或者在别的 ABI 设备上直接闪退。
- CMake 源码编译:库和 app 一起构建,能自己控制编译器参数、链接选项,是本篇的重点。缺点是要处理 CMake 和 NDK 的兼容性。
- AOSP 源码编译:Android.bp / Android.mk,能编进系统,但没人想为了验证一个接口去刷一整天系统。
我建议学习阶段全部走 CMake 源码编译路线,对构建过程有完全掌控力,出现符号丢失、链接顺序问题也容易复现和调试。后面真要进系统,再把 CMake 里的编译命令翻译成 Android.bp 即可。
2.3 Gradle 与 SDK/NDK 版本匹配是最容易忽略的坑
Android Studio 工程里,最烦的不是代码,是 Gradle、SDK、NDK、CMake 四者之间的版本匹配。Gradle 插件版本和 AGP(Android Gradle Plugin)版本要匹配,AGP 和 Build Tools 版本要匹配,NDK 又对 minSdkVersion 有要求。这些版本链条只要断一环,报错信息就千奇百怪。
我自己现在的基线配置比较保守:AGP 8.x 配 Gradle 8.x,NDK 用 26.x 或 27.x,CMake 用 3.22.1 或 AS 内置版本,minSdk 24 以上。这套组合在近两年新项目里基本不会遇到版本兼容的恶心问题。老项目迁移时,尽量先按原项目的版本曲线走,不要一上来就升 AGP,否则副作用比收益大得多。
3. AIDL 接口从编写到生成:文件放错位置,报错到怀疑人生
3.1 AIDL 文件目录规则和语法要点
AIDL 文件放置目录是硬性规定:必须放在src/main/aidl/<包名路径>/下。这里的包名路径要和文件里声明的 package 完全一致,不一致就会导致 aidl 文件生成失败,或者生成的 Java 类找不到。
AIDL 支持的语法并不复杂,基本类型、String、Parcelable、List、Map,接口方法支持 in/out/inout 定向 tag。初学者最容易漏掉 import,比如回调接口写在另一个文件里,主接口里使用它,却没有 import,ide 有时候不报红,编译时才炸锅。
一个实际的 LED 控制接口我建议这样写:
// ILedControl.aidl package com.example.led; import com.example.led.ILedStatusCallback; interface ILedControl { void setOn(boolean on); void setBrightness(int level); // 0-255 int getBrightness(); void registerCallback(ILedStatusCallback callback); void unregisterCallback(ILedStatusCallback callback); }// ILedStatusCallback.aidl package com.example.led; interface ILedStatusCallback { void onStatusChanged(boolean on, int level); }这里有个细节:AIDL 里定义的接口方法参数默认是 in,如果只读,可以不写;如果服务端要改这个参数再返回给调用方,就用 out;要双向就用 inout。跨进程的开销和 inout 的复杂度成正比,能用 in 就别用 out。
3.2 aidl 生成失败,基本逃不过这几个原因
我统计过自己项目里遇到过的 AIDL 生成失败原因,基本集中在这几类:
首先是路径和包名不匹配。文件在src/main/aidl/com/example/led/下,package 必须是com.example.led,哪怕少一层目录也会失败。其次是 import 缺失,两个 aidl 文件互相引用时最容易犯。第三是定向 tag 写错,比如 oneway 接口方法里用了 out/inout 参数,编译器直接报错。第四是使用了不支持的泛型或者嵌套类型,AIDL 的泛型支持非常有限,List<Map<String, Parcelable>> 这种组合经常出问题。
还有一类隐藏很深:文件编码。AIDL 文件必须是 UTF-8,不要带 BOM。Windows 上记事本编辑过的文件经常带着 BOM 头,Build 时会把 BOM 当作包名的一部分,报一个让你完全摸不着头脑的错。
3.3 如何验证 AIDL 是否成功生成
在 Android Studio 里执行 Build 后,生成代码在app/build/generated/aidl_source_output_dir/debug/目录下。能看到类似ILedControl.java的文件就说明生成成功,然后 app 代码里就可以直接ILedControl.Stub和ILedControl.Proxy了。
如果生成目录里找不到文件,先从 Build 输出面板搜索 "aidl" 关键字,通常会有具体的错误行号。注意 Android Studio 有时候不会实时刷新生成目录,我在 Windows 上遇到过一次,文件已经生成了,编辑器里还是报红,执行一次File -> Sync Project with Gradle Files就正常了。
自己从 eclipse 时代项目迁移过来的人要特别小心:老项目经常把 aidl 放在src/根目录下,Android Studio 默认不认这个路径,需要你在 build.gradle 里手动指定 sourceSets。这也是很多“移植 Android Studio 项目”之后 AIDL 报错的根源。
3.4 顺带说一句 AIDL 和 HIDL 的关系
我见过不少人把 AIDL 和 HIDL 混在一起问,其实核心区别在于服务对象。HIDL 是 Treble 时代为 vendor 和 system 分区解耦而设计的接口语言;AIDL Stable 在 Android 11 开始支持,就是把 AIDL 语法用于系统组件和 HAL 之间的稳定接口。如果你现在新写一个软件层面的跨进程接口,我建议直接用 AIDL,不需要再学 HIDL 那套半自动的构建方式。除非你是要维护老的 vendor 实现,否则 AIDL 是更省心的一条路。
4. NDK 配置与 C/C++ 本地库构建:这章全是干货
4.1 ndk not configured 这类报错,其实是 NDK 没装对
“ndk not configured. download it with sdk manager. preferred ndk version is ...” 这个报错,十个人里有九个都见过。字面意思是说你没装 NDK,或者 NDK 版本和项目要求的不一致。
处理方式很简单:打开 SDK Manager,切到 SDK Tools 标签页,勾选 NDK 和 CMake 装上。然后到 build.gradle 的 android 块里显式声明ndkVersion "26.1.10909125"。有的老教程让你在 local.properties 里写ndk.dir=...,AGP 4.2 之后已经不建议这么干,优先用 ndkVersion 属性。
NDK 版本选择上我的建议是:不要盲目追最新。r23 开始移除了 GCC,只留 clang;r26 之后对 minSdkVersion 有更高要求。如果项目里要集成某些老的第三方库,比如老版本 OpenSSL、FFmpeg,它们的编译脚本可能只适配特定 NDK,强行升级会导致一堆编译错误。先确认依赖库的要求,再选 NDK,而不是反过来。
4.2 build.gradle 里 native 构建配置模板
app 模块的 build.gradle 里需要配两处:CMake 路径和 ABI 过滤。
android { ndkVersion "26.1.10909125" defaultConfig { externalNativeBuild { cmake { cppFlags "-std=c++17" arguments "-DANDROID_STL=c++_shared" } } ndk { abiFilters "arm64-v8a", "x86_64" } } externalNativeBuild { cmake { path "src/main/cpp/CMakeLists.txt" } } }ABI 过滤器一定要加。我早期图省事不限制 ABI,结果构建时间翻倍,还经常编出 x86 版本在某些 ARM 设备上闹笑话。开发调试阶段只留 arm64-v8a 和 x86_64 基本够用,真机覆盖也主要是 arm64。
4.3 CMakeLists.txt 里怎么链接 HAL 库
如果你手里的 HAL 是厂商已经编好的 .so,正确做法是用 IMPORTED 目标链接,而不是直接把 so 扔到 jniLibs 里让 app 加载。
cmake_minimum_required(VERSION 3.22.1) project(led_driver) add_library(led_jni SHARED led_jni.cpp hal_led_stub.c ) add_library(hal_led SHARED IMPORTED) set_target_properties(hal_led PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libhal_led.so ) find_library(log-lib log) target_link_libraries(led_jni hal_led ${log-lib} )为啥用 IMPORTED 而不是直接链接?因为 import 目标能把“这个库是外部提供的”和“我的代码怎么编译”解耦开,构建系统不会尝试去重新编译它,也方便你后面替换不同版本的 hal 库。直接把 so 放 jniLibs 的缺点是,它会被当作 app 的 native 依赖打进 APK,但 HAL 本来就该由系统/vendor 分区提供,不应该出现在 app 里,逻辑上就错了。
4.4 JNI 层绑定 HAL,推荐 RegisterNatives 方式
JNI 函数有两种注册方式。第一种是动态命名,函数名写成Java_com_example_led_LedNative_setOn,JVM 按约定自动找到;第二种是用RegisterNatives在JNI_OnLoad里手动注册。
我强烈推荐RegisterNatives。它避免了一长串名字,效率更高,而且能让你在加载时做统一初始化检查。代码长这样:
#include <jni.h> #include <cstring> #include "hal_led.h" static led_device_t* g_led = nullptr; extern "C" JNIEXPORT jboolean JNICALL nativeInit(JNIEnv* env, jobject thiz) { if (g_led != nullptr) return JNI_TRUE; g_led = hal_led_open(); return g_led != nullptr; } extern "C" JNIEXPORT void JNICALL nativeSetOn(JNIEnv* env, jobject thiz, jboolean on) { if (g_led != nullptr && g_led->set_on != nullptr) { g_led->set_on(g_led, on ? 1 : 0); } } static const JNINativeMethod methods[] = { {"nativeInit", "()Z", (void*) nativeInit}, {"nativeSetOn", "(Z)V", (void*) nativeSetOn}, }; extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { JNIEnv* env = nullptr; if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) { return JNI_ERR; } jclass clazz = env->FindClass("com/example/led/LedNative"); if (clazz == nullptr) return JNI_ERR; if (env->RegisterNatives(clazz, methods, sizeof(methods) / sizeof(methods[0])) != JNI_OK) { return JNI_ERR; } return JNI_VERSION_1_6; }JNI 方法签名要特别小心,()Z表示返回 boolean 无参,(Z)V表示接收 boolean 返回 void,类型表记错了RegisterNatives会静默失败,Java 层调用时直接报UnsatisfiedLinkError。
4.5 构建本地依赖时的高频坑
构建本地依赖时最常见的报错是 Gradle 下载 zip 或依赖包中途失败,报 “Zip file ... is corrupt” 之类。多数情况是缓存损坏,清理~/.gradle/caches下对应目录,重新构建就能解决。如果反复失败,可以切换到网络更稳的镜像仓库。这里多说一句,公司内网环境容易出现代理导致的证书问题,报错里会带 “PKIX path building failed”,这种情况优先检查镜像源的 HTTPS 证书,别一开始就怀疑代码。
另一个高频坑是 C++ 运行时库不匹配。你 CMake 里选了c++_shared,但 APK 里没打包libc++_shared.so,安装到设备上运行时就报library "libc++_shared.so" not found。处理方式要么把ANDROID_STL改成c++_static,要么在构建配置里显式打包。我的习惯是用c++_shared加jniLibs手动放入对应 so,因为多个 so 共享同一个 STL 可以减少包体积和符号冲突。
还有 CMake 找不到构建程序这个错,CMake was unable to find a build program corresponding to "Ninja",基本是 CMake 版本和 AGP 默认版本不匹配。去 SDK Manager 里装一个 3.22.1 的 CMake,再指定cmake { version "3.22.1" }即可。
5. HAL 层怎么接:用模拟实现把整条链路走通
5.1 HAL 到底长什么样,先看传统结构
传统 Android HAL 本质是一个 C 接口库,核心是hw_module_t和hw_device_t两个结构体。hw_get_module根据模块 ID 和厂商配置去/vendor/lib64/hw/下加载对应的.so,加载完后你从 module 的方法列表里拿到设备结构体,再调用结构体里的函数指针。
要注意的是,现在很多搜索里的“hal 库”其实指的是 STM32 标准外设库,和 Android HAL 完全不是一回事。我在做嵌入式的时候也用过那种 hal 库,那是芯片厂商封装好的寄存器操作 API。别搞混了,本篇里的 HAL 都是指 Android 硬件抽象层。
5.2 手写一个 HAL 模拟实现,不开真机也能验证
我们假设要控制一个 LED,先定义 HAL 接口头文件:
// hal_led.h #ifndef HAL_LED_H #define HAL_LED_H #include <stdint.h> #ifdef __cplusplus extern "C" { #endif typedef struct led_device { int (*set_on)(struct led_device* dev, int on); int (*set_brightness)(struct led_device* dev, int level); int (*get_brightness)(struct led_device* dev); void* priv; } led_device_t; led_device_t* hal_led_open(void); void hal_led_close(led_device_t* dev); #ifdef __cplusplus } #endif #endif然后写一个纯内存模拟的实现。这里我特意不直接操作真实驱动,就是为了让你在 Android Studio 里也能编译、运行、验证整个链路。真实环境里,这个文件里的操作会变成 open("/dev/led")、ioctl、sysfs 读写等等。
// hal_led_stub.c #include "hal_led.h" #include <stdlib.h> #include <string.h> static int stub_set_on(led_device_t* dev, int on) { // 真实环境: write sysfs or ioctl return 0; } static int stub_set_brightness(led_device_t* dev, int level) { if (level < 0 || level > 255) return -1; return 0; } static int stub_get_brightness(led_device_t* dev) { return 128; } led_device_t* hal_led_open(void) { led_device_t* dev = (led_device_t*)calloc(1, sizeof(led_device_t)); if (dev == NULL) return NULL; dev->set_on = stub_set_on; dev->set_brightness = stub_set_brightness; dev->get_brightness = stub_get_brightness; return dev; } void hal_led_close(led_device_t* dev) { if (dev) free(dev); }这样就有了一个不依赖硬件的 HAL 实现,JNI 层直接hal_led_open()就能拿到设备句柄。等真机驱动就绪,替换 stub 内部实现即可,上层代码完全不用动。这种“先跑通结构再替换内部逻辑”的做法,是我做底层开发以来最推荐的做事方式。
5.3 Java 服务层把 AIDL、JNI、HAL 串起来
有了 native 层基础,Java 侧就清晰了。LedService作为 AIDL 服务端继承ILedControl.Stub,内部调用LedNative静态方法。LedNative声明 native 方法并用System.loadLibrary("led_jni")加载 CMake 编出来的 so。
public class LedNative { static { System.loadLibrary("led_jni"); } public static native boolean nativeInit(); public static native void nativeSetOn(boolean on); public static native int nativeGetBrightness(); }这里有个使用习惯要注意,JNI 环境里的 native 状态要小心多进程问题。如果 LedService 跑在独立进程里,nativeInit 初始化的是该进程内的全局变量,另一个进程即使 bind 了同一个 Service,调用的是远端进程里的 native 函数,不会有数据串台。这其实是 Binder 设计的好处,但也意味着你用静态变量缓存 native 状态时,要以进程为边界思考,而不是以 app 为边界。
5.4 关于真实系统集成,简化 demo 和 AOSP 的差异
在真机系统里,App 直接dlopenHAL 库是不合规的,生产环境应该通过系统服务间接访问,而且要处理 SELinux 权限、HAL 进程上下文等各种限制。我这个 demo 之所以能这么直白地打通,是因为它面向的是开发调试和自用工具。如果你想把它变成一个正式系统能力,标准路径是:HAL 实现放 vendor 分区,由独立的 HAL service 进程托管,系统服务通过 AIDL 接口访问 HAL service,App 只能和系统服务通信。
不同 SoC 平台(比如 RK3576 这类)还会有自己的 HAL 命名规范和权限配置,定制系统镜像时还要处理 vendor 和 system 分区的依赖关系。这些都是在 AOSP 源码树里完成的,不可能在 Studio 工程里一步到位。建议先在这个简化工程里把通信链路、JNI 绑定、接口设计都验证清楚,再去 AOSP 里平移代码,效率会高很多。
6. 构建与运行常见问题速查表:能救命的排错经验
6.1 Android Studio 和 Gradle 层面的报错
ndk not configured、CMake was unable to find a build program、SDK location not found,这类属于环境问题,基本都是 SDK/NDK/CMake 没装全或路径不对。处理顺序是:先检查 local.properties 里的sdk.dir,再确认 SDK Manager 里 NDK 和 CMake 是否安装,最后看 build.gradle 里的版本声明是不是写错了或冲突了。
Gradle 报 zip 损坏,优先清缓存重试。不要一上来就删整个 Gradle 目录,先把报错里提到的那一个文件删掉。比如报~/.gradle/wrapper/dists/gradle-8.7-bin/hash/gradle-8.7-bin.zip损坏,就把对应版本目录删了让它重新下载。
6.2 AIDL 生成失败,重点查这四处
文件路径是否在src/main/aidl/<包名路径>/下;文件内 package 是否和路径一致;引用其他 aidl 的 import 是否齐全;编码是否是 UTF-8 无 BOM。还有一个容易被坑的点是文件名必须和接口名一致,不能ILed.aidl里定义的接口叫LedControl,编译器会直接报接口名不匹配。
6.3 运行期崩溃,先分清是哪一层的问题
运行期崩溃最怕的是无头绪。我的排查顺序是:先看 logcat 里是否报UnsatisfiedLinkError,有的话说明 so 没打进 APK 或 JNI 方法名没对上;没有的话看是否报JNI DETECTED ERROR,这是 JNI 类型用错了;再看有没有 SIGSEGV,这基本就是 native 层空指针或数组越界了。
HAL 相关的权限问题通常表现为open: Permission denied或者hw_get_module返回负值。真机上遇到这类问题,大概率是 SELinux policy 没放行,先用adb shell setenforce 0验证一下(仅限 userdebug 固件),能跑就说明是策略问题,然后去补 sepolicy。
6.4 高频问题速查表
| 现象 | 常见原因 | 解决方向 |
|---|---|---|
| ndk not configured | NDK 未安装或版本不符 | SDK Manager 安装,build.gradle 指定 ndkVersion |
| AIDL 生成 Java 类找不到 | 包名与路径不一致 / import 缺失 | 修正 aidl 目录结构,补 import |
| UnsatisfiedLinkError | so 未打包 / JNI 签名错误 | 检查 jniLibs 和 abiFilters,核对 RegisterNatives 签名 |
| libc++_shared.so not found | STL 选择 shared 但未打包 | 改用 c++_static 或手动打包 so |
| Gradle zip 损坏 | 下载中断 / 缓存损坏 | 删除对应缓存重新下载 |
| CMake 找不到 Ninja | CMake 版本不匹配 | 安装 3.22.1,指定 cmake version |
| open /dev 节点被拒 | SELinux 或权限问题 | 检查 sepolicy,userdebug 固件可先 setenforce 0 验证 |
| JNI DETECTED ERROR | JNI 层类型用错 / deleteLocalRef 问题 | 检查 JNIEnv 使用,确认没有跨线程用 JNIEnv |
最后分享一个我自己坚持的习惯:每次工程新加一层,我一定留一个最小可执行的测试入口。AIDL 写完先写一个纯 Java 的 fake 实现,验证 binder 通信没问题;JNI 写完先做 native 单元测试,不经过 App;HAL 写完先写一个命令行小工具直接调 hal_led_open。这样做的好处是,一旦整条链路哪个环节出问题,我能快速定位到具体层,而不是在 App 到内核之间来回猜。开发底层链路,排查能力比编码能力更决定你的速度。