ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入理解Android HIDL:从Treble架构到HAL接口设计

深入理解Android HIDL:从Treble架构到HAL接口设计 老老实实说我最早看到“HIDL”这三个字母时心里想的是“AIDL换了个马甲又来折腾人了”。当时我正对着 Android 8.0 的一堆 vendor 分区改动发愁手里那台老设备的 HAL 代码还在用最传统的hw_get_module方式加载升级一次系统要跟着调半天供应商库。直到我真正把一个硬件功能从老式 HAL 迁移到 HIDL 接口之后才明白这套东西解决的不只是“换一种写法”而是从根上把系统框架和硬件实现之间的耦合关系给拆开了。HIDL 全称是 HAL Interface Definition Language也就是硬件抽象层接口定义语言。它是 Android Treble 架构的核心组成部分专门用来定义系统框架与硬件供应商实现之间的通信接口。你在 AOSP 源码里常见到android.hardware.camera.provider2.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.example1.0;这里有个很关键的细节包名里的1.0不是随便写的版本号它参与完整的类型命名。比如你定义了一个接口IFoo那完整引用形式是android.hardware.example1.0::IFoo。这串名字会反映到生成的头文件命名空间、Binder 服务名、以及 hidl-gen 代码生成规则里可以说是 HIDL 的“身份证号”。一个典型的自定义接口文件大概长这样package vendor.example.hello1.0; interface IHello { hello() generates (string message); setBrightness(uint8_t percent) generates (int32_t status); readConfig(uint32_t index) generates (vecuint8_t data); writeConfig(uint32_t index, vecuint8_t data) generates (int32_t result); };HIDL 支持的类型包括void、bool、整数类型、float、double、string、枚举enum、结构体struct、联合体union还有容器类型vecT和hidl_array。如果你长过 C这些类型看起来非常亲切但它们不是普通 C 类型——HIDL 编译器会把这些类型映射成一套跨进程安全的表示比如string会被封装成带长度信息的hidl_stringvecuint8_t会变成hidl_vecuint8_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.bphidl-gen -o . -Landroidbp -rvendor.example:vendor/example/interfaces \ vendor.example.hello1.0-r参数指定的是包名到源码目录的映射关系。这一步会把接口编译成可供其他模块链接的“HIDL 包”。然后生成本地实现骨架hidl-gen -o . -Lc-impl -rvendor.example:vendor/example/interfaces \ vendor.example.hello1.0执行完你会得到一个类似Hello.cpp的骨架文件里面每个方法都写了 TODO 或者默认返回你只需要往里面填自己硬件的实际逻辑。新手最容易搞混的是这些文件之间的层次关系。以IHello为例完整链路应该是.hal文件定义了契约 → hidl-gen 生成IHello的抽象接口类和BpHelloBinder 代理类 → 你的实现类继承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::Returnvoid这样的返回类型。实际的方法定义通常是这样的Returnvoid Hello::hello(hello_cb _hidl_cb) { _hidl_cb(hello from vendor HAL); return Void(); }HIDL 的方法返回类型使用ReturnT包装这既是为了表达跨进程调用错误状态也为了支持异步回调。同步返回场景下调用方会阻塞等待结果异步场景则把回调函数作为参数传进来。3.2 服务注册和客户端获取实现完类之后必须把服务注册到 hwservicemanager——这是 HIDL 世界的服务总管相当于一个专门管理 HIDL 服务的 ServiceManager。注册代码一般写在main()里int main() { android::spIHello 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::spIHello 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.hello1.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 --typesalldumpsys对 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包名不过是同一套设计思想的反复应用而已。
返回列表