老老实实说,我最早看到“HIDL”这三个字母时,心里想的是“AIDL换了个马甲又来折腾人了”。当时我正对着 Android 8.0 的一堆 vendor 分区改动发愁,手里那台老设备的 HAL 代码还在用最传统的hw_get_module方式加载,升级一次系统要跟着调半天供应商库。直到我真正把一个硬件功能从老式 HAL 迁移到 HIDL 接口之后,才明白这套东西解决的不只是“换一种写法”,而是从根上把系统框架和硬件实现之间的耦合关系给拆开了。
HIDL 全称是 HAL Interface Definition Language,也就是硬件抽象层接口定义语言。它是 Android Treble 架构的核心组成部分,专门用来定义系统框架与硬件供应商实现之间的通信接口。你在 AOSP 源码里常见到android.hardware.camera.provider@2.4::ICameraProvider这么一长串东西,它就是 HIDL 接口的标准命名。这篇文章想和你聊清楚 HIDL 到底在设计什么、怎么用、有哪些坑,以及为什么现在新项目又开始转向 AIDL HAL。不管你是做 ROM 移植、系统定制,还是准备啃 vendor 代码,这篇都值得在你动手之前先读一遍。
1. 在谈 HIDL 之前,先看老式 HAL 怎么把你“焊死”的
1.1 老式 HAL 的加载方式:简单但脆弱
老式 HAL 的结构其实不复杂。Android 系统的硬件模块被编译成一个个.so动态库,放在/vendor/lib/hw、/system/lib/hw这些目录下,框架进程通过hw_get_module()按名称找到对应的模块库,然后拿到一个hw_module_t结构体,再通过它里面的methods去打开具体的设备实例。
这个模式最直接的问题在于:框架和 HAL 之间根本没有稳定的二进制接口契约。hw_module_t是 C 结构体,里面挂着各种函数指针,厂商实现时可以自由发挥。上层想着“我调open拿到设备句柄就行”,下层却可能把私有数据塞进结构体扩展字段里。只要某次系统升级改动了结构体布局或者调用约定,厂商的.so就必须重新编译,否则轻则功能异常,重则直接 dlopen 失败。我记得带过的一台设备就是升级到某个 Android 小版本后,指纹 HAL 返回的device指针偏移对不上了,开机一路崩,最后只能让厂商重新出包。
更麻烦的是进程边界。老式 HAL 默认是“同进程直调”,也就是系统服务器直接 dlopen 厂商库,厂商代码跑在 system_server 的进程空间里。这意味着厂商库里的一个内存越界,能把整个系统服务带走。当年不少“重启大法”能解决的诡异问题,根源就在这种架构上。
1.2 Treble 要解决的真正问题
Google 做 Treble 的初衷除了稳定,更重要的是升级效率。以前 Android 系统升级时,框架部分和供应商内核驱动、HAL 库是高度耦合的。厂商如果要适配新版 Android,得跟着把硬件相关代码重编一遍,这个周期往往比 Google 发新版系统还长。这就导致很多设备停留在老旧系统上。
Treble 的思路是把“系统框架”和“供应商实现”变成两个可以独立更新的部分:框架只依赖一套稳定声明的接口,供应商提供符合这个接口的独立服务进程。接口用 HIDL 定义并编进版本号,只要版本兼容,框架升级就不需要动厂商代码。换句话说,HIDL 是这条稳定边界上的“合同文本”,双方照着同一份合同办事,谁也不用理会对方内部怎么折腾。
这套设计现在看来理所当然,但在 Android 8.0 刚推出的时候,对厂商的震动是很大的——因为你不能再用“我改我的私有结构体”这种办法偷懒了,必须老老实实把接口拿出来晒在太阳底下。
2. HIDL 的底层设计:包、版本、接口才是真正的语言核心
2.1 一个 .hal 文件到底写了什么
HIDL 语言本身并不复杂,复杂的是它的组织方式。一个 HIDL 接口以.hal文件为单位,开头必须先声明包名,格式固定为:
package android.hardware.example@1.0;这里有个很关键的细节:包名里的@1.0不是随便写的版本号,它参与完整的类型命名。比如你定义了一个接口IFoo,那完整引用形式是android.hardware.example@1.0::IFoo。这串名字会反映到生成的头文件命名空间、Binder 服务名、以及 hidl-gen 代码生成规则里,可以说是 HIDL 的“身份证号”。
一个典型的自定义接口文件大概长这样:
package vendor.example.hello@1.0; interface IHello { hello() generates (string message); setBrightness(uint8_t percent) generates (int32_t status); readConfig(uint32_t index) generates (vec<uint8_t> data); writeConfig(uint32_t index, vec<uint8_t> data) generates (int32_t result); };HIDL 支持的类型包括void、bool、整数类型、float、double、string、枚举enum、结构体struct、联合体union,还有容器类型vec<T>和hidl_array。如果你长过 C++,这些类型看起来非常亲切,但它们不是普通 C++ 类型——HIDL 编译器会把这些类型映射成一套跨进程安全的表示,比如string会被封装成带长度信息的hidl_string,vec<uint8_t>会变成hidl_vec<uint8_t>,这样数据在跨进程传输时才能被可靠地序列化和反序列化。
另一个容易忽略的点是generates关键字。HIDL 里返回值不叫 return,而是叫“生成”。这是因为 HIDL 的方法调用在线程模型上跟纯函数式调用有区别,它允许同步返回,也允许通过 callback 异步通知。语法上foo() generates (int32_t status)表达的是“调用 foo,可能得到一个 int32_t 状态码”,这种表达方式从一开始就给异步交互留了空间。
2.2 hidl-gen 和构建系统:代码不是手写的
你不可能手写 HIDL 的实现类,也不应该手写。AOSP 提供了一整套代码生成工具链,核心工具叫hidl-gen。它的职责是读取.hal文件,生成 C++ 或 Java 的头文件、代理类、桩类、以及 Android.bp 构建脚本。
常见用法是通过hidl-gen生成接口的 Android.bp:
hidl-gen -o . -Landroidbp -rvendor.example:vendor/example/interfaces \ vendor.example.hello@1.0-r参数指定的是包名到源码目录的映射关系。这一步会把接口编译成可供其他模块链接的“HIDL 包”。然后生成本地实现骨架:
hidl-gen -o . -Lc++-impl -rvendor.example:vendor/example/interfaces \ vendor.example.hello@1.0执行完你会得到一个类似Hello.cpp的骨架文件,里面每个方法都写了 TODO 或者默认返回,你只需要往里面填自己硬件的实际逻辑。
新手最容易搞混的是这些文件之间的层次关系。以IHello为例,完整链路应该是:.hal文件定义了契约 → hidl-gen 生成IHello的抽象接口类和BpHello(Binder 代理类) → 你的实现类继承IHello抽象类 → 客户端通过IHello::getService()拿到BpHello代理 → 跨进程调用由 hwbinder 驱动。
这里顺便提一句,不要把 HIDL 的生成代码和普通 C++ 库混在一起编。HIDL 包必须用hidl_package_root管理,编译时用的是hidl_interface模块类型,直接扔进cc_library会导致链接阶段到处找符号。
3. 动手做一个 HIDL 服务:从 .hal 文件到客户端调用
3.1 先搭接口,再搭骨架
假设我们要给一个自定义开发板添加一个简单的“问候”服务,硬件功能是控制一个 LED 灯的亮度。第一步自然是在源码目录下建好包结构,比如:
vendor/example/interfaces/hello/1.0/default/ vendor/example/interfaces/hello/1.0/Android.bp vendor/example/interfaces/hello/1.0/IHello.hal然后编写上面的.hal文件。写完接口定义后,用 hidl-gen 生成构建脚本和 C++ 实现骨架。这一步生成的文件里有一个关键文件叫HelloAll.cpp,里面包含了一个空实现类,它就是我们编写业务逻辑的落点。
填充实现时,需要注意父类方法的签名跟骨架文件不完全一样,比如生成的方法可能带了::android::hardware::Return<void>这样的返回类型。实际的方法定义通常是这样的:
Return<void> Hello::hello(hello_cb _hidl_cb) { _hidl_cb("hello from vendor HAL"); return Void(); }HIDL 的方法返回类型使用Return<T>包装,这既是为了表达跨进程调用错误状态,也为了支持异步回调。同步返回场景下调用方会阻塞等待结果,异步场景则把回调函数作为参数传进来。
3.2 服务注册和客户端获取
实现完类之后,必须把服务注册到 hwservicemanager——这是 HIDL 世界的服务总管,相当于一个专门管理 HIDL 服务的 ServiceManager。注册代码一般写在main()里:
int main() { android::sp<IHello> service = new Hello(); android::status_t status = service->registerAsService("default"); if (status != android::OK) { ALOGE("Failed to register IHello service"); return -1; } // 进入消息循环 android::hardware::joinRpcThreadpool(); return 0; }registerAsService可以传入实例名,默认是"default"。如果同一个接口有多个实例,比如两个不同传感器节点,你可以用"left"、"right"这样的名字区分。客户端获取时也要带上相同实例名,否则会拿到空指针。这一步相当容易踩坑,后文我会专门说。
客户端代码则简单得多:
android::sp<IHello> service = IHello::getService("default"); if (service == nullptr) { // 服务不可用,处理错误 return -1; } int32_t status; service->setBrightness(50, [&](int32_t ret) { status = ret; });调用getService()时,客户端会通过 hwbinder 向 hwservicemanager 查询服务。如果服务还没注册,这里可能返回空,也可能阻塞等待——这取决于 HIDL 版本和 transport 配置。
3.3 把服务拉起来:init 脚本和 SELinux
服务代码写完只是第一步。你没有把进程拉起来,客户端依然拿不到服务。通常的做法是在/vendor/etc/init/下放一个.rc文件,用service关键字启动它。需要注意的有三点:一是服务必须在on boot阶段之后启动,否则 hwservicemanager 可能还没就绪;二是要确认启动类型不要设置成oneshot,因为 HIDL 服务是常驻进程;三是 SELinux 上下文必须配置正确。
我处理过很多“服务起不来”的问题,最后发现都不是代码错误,而是 SELinux policy 不允许进程访问 hwservicemanager,日志里只留下一句avc: denied。这种情况下dmesg | grep avc能看到确切的拒绝原因,然后把对应的 allow 规则加进vendor/hal_hello.te就行。这段经验我建议每个做 HAL 开发的都先背下来,省得对着 logcat 干瞪眼。
4. 绑定式与直通式:模式的边界决定了性能的瓶颈
4.1 两种模式到底在说什么
HIDL 一共有两种运行模式:绑定式(Binderized)和直通式(Passthrough)。
绑定式模式就是我们前面讲的,HAL 实现跑在独立进程中,客户端通过 hwbinder 跨进程调用。这种模式隔离性好,厂商实现崩溃了不会拖垮 framework,而且支持服务热更新。但它有跨进程开销,一次调用可能要经历序列化、Binder 传输、反序列化三个步骤。
直通式模式则是另一种玩法:HAL 库以.so形式存在,进程直接 dlopen 加载,接口调用在同一个进程内完成,没有 Binder 传输,性能更好。但这也意味着厂商代码和客户端运行在同一个进程空间里,隔离性几乎为零。
在绑定式模式下,服务端需要单独进程,客户端通过 hwservicemanager 查找;在直通式模式下,则要提供一个HIDL_FETCH_IFoo函数,让客户端通过该函数拿到接口实例。
4.2 为什么现在绑定式成了主流
Android 版本迭代中,Google 一直在推动 HAL 向绑定式迁移。绑定式的优点太明显了:独立进程意味着厂商代码崩溃可以被拦截,框架不会跟着崩;服务可以动态注册和取消;框架升级时不需要动 HAL 库;甚至可以支持一个接口在多个实例之间切换。
直通式适合什么场景?适合延迟敏感、调用频繁、且不能忍受 Binder 开销的场景。典型例子是 audio HAL 的某些处理路径,虽然 AudioFlinger 也经过 HIDL 之后最终走的是快照/直通优化混合方案。普通外设控制,比如灯、马达、传感器,绑定式完全够用,毫秒级延迟根本感受不到。
选型时我的建议很简单:能绑定式就绑定式;如果性能测试确实不够,再针对热点路径做直通。千万不要一开始就图省事全部写直通,否则后面想加 SE 策略或者并发控制会非常痛苦。
记住一个原则:直通式的“快”换来的代价是耦合,绑定式的“慢”换来的是稳定。硬件控制的频率通常远低于 1kHz,这点 Binder 开销根本抠不出来什么性能优势。
5. HIDL 与 AIDL 的纠葛:新项目还要用 HIDL 吗
5.1 AIDL HAL 是来取代 HIDL 的吗
如果你是从 Android 11 往后的版本开始接触 HAL,会发现 AOSP 里同时存在 AIDL 和 HIDL 两种 HAL 定义方式。Android 11 开始官方正式支持使用 AIDL 定义 HAL 接口,到了 Android 13、14,越来越多核心 HAL 都迁移到了 AIDL 版本。
为什么?因为 AIDL 比 HIDL 更成熟、工具链更好用,而且 AIDL 本身就能生成 C++/Java/Rust 多语言绑定。想想看,如果你用 HIDL,写框架层绑定要用 Java 版本,写 vendor 层要用 C++ 版本,两边的类型映射都要维护。AIDL 通过aidl_interface模块直接搞定,语言一致,还有一个Stability属性控制是否暴露为 vendor 接口。
但这不意味着 HIDL 立刻被淘汰。存量市场里还有大量儿童设备、车机、物联网设备使用基于 HIDL 的 HAL。厂商虽然已经被高通、MTK 的新 BSP 带着转向 AIDL,但老平台的维护工作还是绕不开 HIDL。尤其你在供应链公司做外包,碰到 Android 10 以下的平台几乎是必然的。
5.2 新项目的推荐选择
如果你现在要新定义一套 HAL 接口,我建议优先考虑 AIDL,并声明stability = "vendor"。理由有三:第一,AIDL 有更好的工具链和更活跃的社区;第二,新版本 Android 框架服务对 AIDL HAL 的支持更顺滑;第三,招聘时懂 AIDL 的工程师比懂 HIDL 的更多。
但如果你的项目是基于一个已经全面使用 HIDL 的现有 BSP,那就老实用 HIDL,不要强行把 HIDL 接口包装成 AIDL,否则要维护两层转换,得不偿失。业内有个不成文的规律:“改接口的成本永远大于改实现”。能基于现有接口扩展版本,就不要另起炉灶。
另外说句大实话:HIDL 并不会因为 AIDL 的普及而彻底消失,很多底层android.hardware.***接口即便有了 AIDL 版本,老 HIDL 接口依然要保留,因为还有大量老应用和设备依赖。作为开发者,你最好两种都能看懂,至少做到“看到@1.0::IFoo不慌,看到IFoo.aidl也能接”。
6. 调试 HIDL 服务踩过的坑:服务起不来、客户端拿到空指针的套路与解法
6.1 getService 返回 nullptr 的排查顺序
客户端调用IHello::getService()返回空,这是最常见的 HIDL 问题。排查思路我总结成一条固定路线,照着走基本能定位:
第一,先确认服务进程是否真的在运行。用ps -A | grep hello看进程是否存在,如果不在,八成是 init 脚本或 SELinux 问题;如果在,进入下一步。
第二,确认服务是否成功注册。终端执行lshal | grep -i hello,这个命令会列出 hwservicemanager 里所有注册的 HIDL 服务。如果列表里没有,说明registerAsService没有执行成功,最可能是因为 SELinux 拒绝进程与 hwservicemanager 通信。
第三,看客户端和服务端的包名、接口名是否完全一致。注意vendor.example.hello@1.0::IHello中间的空格、大小写、版本号都不能差。跨进程查找服务用的是完整字符串,大小写搞错就是找不到。
第四,确认 transport 类型。如果服务注册的是直通模式,而客户端用绑定模式去 getService,也是拿不到的。反过来同样。这个可以通过查看.hal文件生成的types.hal或 manifest 验证。
6.2 服务反复重启和崩溃的问题
HIDL 服务进程崩溃的情况,多数不是业务逻辑崩的,而是 hwbinder 线程池处理没配好。标准写法是在main()里调用joinRpcThreadpool(),并且确保服务注册后没有直接 return。很多人把注册代码写完,忘了阻塞线程,服务进程注册完就退出了,结果看到的就是“进程存在一下然后消失”。
还有一种情况是服务被 SELinux 杀掉,表现为服务反复出现、又反复死掉。遇到这种先抓logcat -b crash和dmesg,看看avc: denied和Fatal signal的时间点能不能对上。
6.3 调试效率工具:lshal、dumpsys 和 hidl_trace
调 HIDL 比调普通 Binder 省心的地方在于有专门的调试工具。最常用的是lshal,它不仅能列出服务,还能查看每个服务所在的进程 PID、是否 alive、所属的 transport 类型。数据非常直观:
adb shell lshal adb shell lshal --types=alldumpsys对 HIDL 本身帮助有限,但如果你实现的是 audio、camera 这类上层有 native service 的 HAL,可以通过 dumpsys 那层看到更上层调用链。
hidl_trace是个常被忽略的利器。它类似于atrace,专门跟踪 HIDL 方法调用,命令格式:
adb shell hidl_trace -f /data/local/tmp/trace.dat开启后,代码里调用 HIDL 接口的耗时、返回值、线程切换都会记录进去。性能调优时非常有用,比如分析某个 HAL 调用占总耗时的比例。
6.4 接口升级时最容易犯的错误
HIDL 的版本策略是:主版本号不兼容可变更,次版本号只能增加向后兼容的方法。也就是@1.1可以添加新方法,但不能删除@1.0里已有的方法,也不能修改已有方法签名。这个规则表面简单,实际操作中很容易坏在“改了实现忘了改版本号”上。
比如你给setBrightness增加了一个参数表示渐变时间,这属于接口变更,必须把次版本号升到 1.1,并且保留 1.0 的旧接口实现。否则客户端按新签名调用,服务端还是旧实现,轻则拿到UNKNOWN_TRANSACTION错误,重则直接 binding 失败。
再说一个版本管理的反模式:有人图省事,直接在原有接口结构体里加字段,声称“反正没人用”,这在 HIDL 里是明确违规的。因为接口产物是 ABI,不是 API,改一个字段就可能让所有已编译的客户端代码错乱。真要扩展,就新建一个 versionized 接口或者新包,别在旧接口上打补丁。
最后的一点个人体会
做 HAL 和 HIDL 相关的工作,跟写普通 App 最大的区别是:你不能只对自己写的代码负责,还要对接口契约负责。一个.hal文件一旦发布出去,就等同于和系统框架、和厂商 BSP、和无数上游下游模块签了一份长期合同。很多问题表面上看是代码写错了,本质上是契约没设计好——版本号没把控住、实例名没统一、接口粒度太粗或太细。
我自己在迭代过一版 LED 控制 HIDL 接口后,最大的收获是:先花半天想清楚接口稳定性,比省下这半天去写代码划算得多。接口设计本质上是在定义“什么能变”和“什么不能变”的边界,想清楚这一层,HIDL 的学习曲线就只是操作问题,而不是理解问题。
如果你也是刚开始接触 Treble 架构,建议先拿一个最简单的 LED 或 GPIO 控制功能练手,走一遍“定义接口 → 生成骨架 → 注册服务 → 客户端调用 → lshal 验证”的完整流程。跑通之后,再去看真正的 sensor、camera、audio 这类复杂 HAL,你会突然觉得源码里那些层层叠叠的@x.x包名,不过是同一套设计思想的反复应用而已。