ARTICLE DETAIL

资讯详情

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

Android HIDL完全解析:从Treble架构到硬件抽象层接口实战

Android HIDL完全解析:从Treble架构到硬件抽象层接口实战 我做系统开发这几年接触最多的东西之一就是 HIDL。它是 Android 8.0 引入 Project Treble 时的一个核心设计全称 HAL Interface Definition Language专门用来定义硬件抽象层接口。简单说以前 HAL 和 framework 之间是“你中有我、我中有你”的耦合状态升级系统经常要连带驱动一起重编HIDL 出现以后两边被切成一个稳定接口来通信vendor 实现和系统镜像可以分开演进升级系统不再动底层 HAL。这篇文章适合正在做 Android 系统开发、ROM 移植、HAL 驱动适配或者想搞懂 Treble 机制底层原理的工程师。我会从背景、机制、实际动手写 HIDL 接口、版本演进、再到避坑经验把 HIDL 讲透。1. HIDL 是什么从传统 HAL 到 Treble 的必然选择1.1 传统 HAL 机制为什么撑不住了在 Android 7.0 及更早版本里HAL 就是一堆.so动态库framework 通过hw_get_module去加载拿到hw_module_t和hw_device_t结构体然后直接调用函数指针。这套方案有问题吗有而且问题很大。它表面上做了“硬件抽象”实际上 framework 进程和 HAL 库在同一个进程里共享同一份内存互相之间可以随意调用、甚至直接访问内部数据结构。这意味着什么意味着今天芯片厂商在 HAL 层做了一点小改动比如改了一个结构体字段、换了一下函数指针的顺序明天系统镜像升级的时候如果 framework 还按老结构体去访问轻则功能异常重则直接 crash。对用户来说就是系统升级卡死、蓝牙打不开、相机黑屏。对整个行业来说系统升级反而成了“供应链绑架”的重灾区哪家芯片厂商不愿意适配哪款老设备就只能停在旧版本上。传统 HAL 还有一个看不见的隐患framework 对 HAL 的调用没有任何“边界”。同一个进程里vendor 代码和 system 代码混在一起跑驱动一旦越界写坏一块内存问题会出现在完全不相干的模块里排查起来极其痛苦。我在老项目上就遇到过一次某个传感器 HAL 的线程栈溢出最后表现出来的是 SystemUI 随机重启查了一段时间才定位到污染来源。这种问题在 HIDL 架构下基本不会发生因为双方不共享地址空间了。1.2 HIDL 在 Treble 架构中的位置Project Treble 的目标很明确把 Android 系统镜像拆成两块一块是 framework 所在的 system 分区一块是厂商定制化的 vendor 分区。两块分区各自独立升级而连接它们的就是一个“稳定接口层”HIDL 就是定义这个稳定接口的语言和工具链。具体到进程架构上HIDL 接口的典型形态是把 HAL 实现放到独立进程里运行通过 Binder 内核驱动通信。framework 侧拿到的是 Binder 代理对象BpHwInterfaceHAL 侧实现的是BnHwInterface。调用接口方法时参数会被序列化到 Parcel 里穿过 Binder在对端进程里反序列化再调用真正的实现函数返回结果再穿回来。这样两边内存天然隔离不会出现上面说的“越界污染”问题。vndk这一层也跟着变了。vendor 分区里的代码不能再随便链接 system 分区里的私有库只能依赖 VNDKVendor NDK这个被固定下来的公共库集合。HIDL 的出现让“vendor 实现到底该用哪些库、依赖什么版本”这件事有了一个清晰边界接口用 HIDL 描述清楚之后系统侧和厂商侧只要各自遵守这个契约就能独立演进。1.3 HIDL 和 AIDL 的关系很多人第一次接触 HIDL 的时候会问这不就跟 AIDL 差不多吗其实 HIDL 的语法和 AIDL 很接近但它比 AIDL 复杂得多。AIDL 主要面向应用层或者框架层的跨进程调用而 HIDL 是为系统级 HAL 接口设计的所以它多了版本管理、接口继承、软件包package、分区内服务发现hwservicemanager这些机制。AIDL 默认不处理版本兼容HIDL 则把“版本化”刻进了语言本身每个接口都必须挂在某个版本下。可以这么理解AIDL 是“进程内通信工具”HIDL 是“系统级接口契约”。后面 Android 又在高版本里给 AIDL 增加了“稳定性”选项让 AIDL 也能用于 vendor 和 system 之间这是后话下一节我会专门分析。2. HIDL 接口的本质与核心机制2.1 三种运行模式从 passthrough 到 binderizedHIDL 接口在设备上有两种主要运行方式passthrough直通和 binderized绑定化。binderized 是把 HAL 做成独立服务进程隔离通过 Binder 通信是 Treble 最终推荐的形态。好处显而易见system 和 vendor 完全隔离稳定性、安全性都大幅提升。缺点是每次调用都要走 Binder 序列化性能有损耗不适合高频短小调用。passthrough 是一种过渡模式。它不让 HAL 变成独立进程而是进程内打开 HAL 的.so库通过函数指针直接调用相当于在“兼容旧 HAL”和“拥抱新接口”之间妥协。早期很多设备厂商因为时间紧、任务重都先用 passthrough 把 HAL 从老接口包装到 HIDL之后再逐步改成 binderized。passthrough 模式下 HIDL 接口管道更薄但因为两边还在同一个进程里隔离性基本没有。注意这里的 passthrough 和普通 JNI 直调还是有区别的它至少让接口契约被 HIDL 固定下来为后续平滑迁移到 binderized 留了路。实际开发中只要不是对性能极其敏感的场景我都会建议直接用 binderized。现在的 Binder 传输在memcpy和共享内存辅助之下已经足够快不值得为了一点性能牺牲掉进程隔离带来的稳定性。我自己实测过普通的 HIDL 接口调用一次往返大概在 10-50 微秒级别对于传感器事件、按键上报这种低频场景完全够用高吞吐的摄像头、音频数据流则通常不走 HIDL 主通道而是用 shared memory 或者 DMA buf 做数据面HIDL 只负责控制面。2.2 软件包、接口与版本化设计HIDL 接口的命名遵循一套固定格式一般长这样android.hardware.light2.0::ILight拆开看就是三个部分完整软件包名android.hardware.light、版本号2.0、接口名ILight。包名本身还带有命名空间的含义如果是厂商自定义接口通常会写成vendor.company.hardware.moduleversion::IModule。版本化是 HIDL 的灵魂。每个接口文件必须声明自己属于哪个版本比如package vendor.company.hardware.hello1.0; interface IHello { helloWorld(); };如果你想让接口从 1.0 升级到 1.1不能直接在原文件里改而是要新建一个1.1目录在 1.1 接口里extends1.0 的旧接口再追加新方法。这样做的意义在于老版本接口永远保持原样旧的实现和旧的 framework 仍然可以对接新版本接口只需要在新设备上使用。HIDL 的兼容性就是这么一层层叠出来的。这种设计给我最大的感受是“强迫你把兼容性想清楚”。平时在应用层写接口随手加个字段、改个返回类型觉得无所谓但到了 HIDL 这种系统级接口上任何改动都可能影响数以千万计的在网设备。版本化所带来的约束本质上是在保护整个生态的稳定性。2.3 核心数据类型速查HIDL 的基础类型基本覆盖 C 常用的那些包括整型、浮点、字符串另外还专门提供了一组容器类型用来安全地在进程间传递结构化数据类型说明典型使用场景hidl_vecT动态数组等价于 C 的std::vectorT批量数据上报hidl_arrayT, N静态数组编译期固定长度传感器校准参数hidl_stringUTF-8 字符串设备信息读取hidl_handle文件描述符的句柄封装传递 FD 给对端进程MemoryBlock共享内存块描述大数据零拷贝传输hidl_memory共享内存引用相机帧、音频数据第一次接触 HIDL 的时候最容易犯的错是把std::string直接写在接口方法里。HIDL 接口文件里只能用 HIDL 自己定义的类型编译hidl-gen的时候直接报错。原因也合理std::string的内存布局跨进程传过去根本不可信模板序列化的时候还是走hidl_string最稳定。类似的C 的裸指针、引用也不能出现在接口里能传的不是对象本身而是对象被序列化之后的字节流。还有一个细节值得注意理论上接口方法可以传复杂结构体但每次跨进程都要做完整的序列化和反序列化。如果结构体特别大、字段特别深性能损耗会很明显。我通常会建议接口参数尽量扁平化、字段少一点高频数据走共享内存控制信息走 HIDL两边分工明确。2.4 hidl-gen从 hal 到代码的流水线HIDL 的代码生成工具叫hidl-gen它读取.hal接口文件生成 C也能生成 Java的接口代码、Binder 代理和桩stub模板以及构建脚本。它就像 AIDL 和 protobuf 里的protoc是整个 HIDL 体系的“编译器”。hidl-gen常见的几个参数-o path指定输出目录-L language指定生成语言c表示生成接口代码c-impl生成实现模板androidbp生成 Android.bp 构建文件java生成 Java 版本-r package:path告诉工具去哪里找软件包对应的源码目录比较典型的调用是这样的hidl-gen -Landroidbp -rvendor.company.hardware:vendor/company/hardware/interfaces \ vendor.company.hardware.hello1.0 hidl-gen -Lc -rvendor.company.hardware:vendor/company/hardware/interfaces \ -o vendor/company/hardware/interfaces/hello vendor.company.hardware.hello1.0第一条用于生成Android.bp第二条用于生成 C 接口代码。-r参数比较关键它建立了“软件包根路径”和“实际源码目录”的映射关系少了它工具就不知道去哪找依赖的接口定义。在旧版 Android 8.0 时代还要配合make hidl-gen先编译出正确版本的工具后来 Android 10 之后hidl-gen通常是预编译的但如果改动过.hal头文件里引用的其他接口建议重新编译一下工具链避免版本不一致。3. 手写一个 HIDL 接口并跑起来光讲概念不过瘾下面我以一个实际的vendor.company.hardware.hello1.0为例从IHello.hal开始把接口定义、代码生成、服务端实现、服务注册、客户端调用完整走一遍。这个例子的业务逻辑很简单客户端传一个名字进去服务端返回一句欢迎语但麻雀虽小五脏俱全。3.1 定义接口文件 IHello.hal首先创建目录结构vendor/company/hardware/interfaces/hello/1.0/default/在1.0目录下创建IHello.halpackage vendor.company.hardware.hello1.0; interface IHello { helloWorld(string name) generates (string result); };这里generates是 HIDL 定义返回值的关键字表示这个方法有返回结果。HIDL 方法定义里没有 C 那种返回值写在函数名左边的写法而是用generates单独声明。这一点和 AIDL 类似但比 AIDL 更直观的是可以同时返回多个值getStatus() generates (int32_t code, string message);对应到 C 代码里其实就是输出参数。3.2 生成代码与目录结构在vendor/company/hardware/interfaces目录下写一个根Android.bp声明hidl_package_roots然后执行命令hidl-gen -Landroidbp -rvendor.company.hardware:vendor/company/hardware/interfaces \ vendor.company.hardware.hello1.0 hidl-gen -Lc -rvendor.company.hardware:vendor/company/hardware/interfaces \ -o vendor/company/hardware/interfaces vendor.company.hardware.hello1.0执行完之后hello/1.0目录下会生成一串文件IHello.hal Android.bp IHello.h IHello.cpp BpHwHello.h BpHwHello.cpp BnHwHello.h BnHwHello.cpp HelloAll.cpp里面的逻辑不用一个个看完需要关注的是IHello.h定义了接口抽象类BnHwHello是服务端桩类BpHwHello是客户端代理类。我们自己写的 HAL 实现类需要继承IHello然后实现接口方法。3.3 实现 HAL 服务端并注册在default目录下创建Hello.cpp继承IHello并实现方法#include vendor/company/hardware/hello/1.0/IHello.h #include hidl/MQDescriptor.h #include hidl/Status.h #include utils/Log.h #include string namespace vendor { namespace company { namespace hardware { namespace hello { namespace V1_0 { namespace implementation { using ::android::sp; using ::android::hardware::hidl_string; using ::android::hardware::Return; struct HelloImpl : public IHello { Returnvoid helloWorld(const hidl_string name, hidl_string result) override { std::string msg Hello, std::string(name.c_str()) !; result msg.c_str(); ALOGI(helloWorld called, result: %s, msg.c_str()); return ::android::hardware::Void(); } }; } // namespace implementation } // namespace V1_0 } // namespace hello } // namespace hardware } // namespace company } // namespace vendor然后写服务入口service.cpp注册服务并启动线程池#include hidl/HidlTransportSupport.h #include vendor/company/hardware/hello/1.0/IHello.h #include HelloImpl.h using namespace vendor::company::hardware::hello::V1_0; using namespace vendor::company::hardware::hello::V1_0::implementation; int main() { android::spIHello service new HelloImpl(); android::status_t status service-registerAsService(default); ALOGI(hello service register status: %d, status); if (status ! android::OK) { return -1; } android::hardware::configureRpcThreadpool(1, true); android::hardware::joinRpcThreadpool(); return 0; }这段代码有几个关键点。registerAsService这个名字是有讲究的它把当前的IHello实现注册到hwservicemanager注册时的实例名是default。客户端那边拿服务的时候也需要用同一个实例名去拿。理论上一个 HAL 可以提供多个实例比如摄像头可能有前摄、后摄两个实例但一般场景都用default。configureRpcThreadpool(1, true)的意思是配置 RPC 线程池第一个参数是线程池最大线程数第二个参数是“是否把调用线程也加入线程池”。这句不要漏漏了服务端可能处理不了并发 Binder 请求。joinRpcThreadpool则是把当前线程挂进去让服务进程常驻等待请求。3.4 init.rc 与 VINTF manifest 配置有了代码还不够要让系统开机自动拉起这个 HAL 服务需要两个配置文件。init.rc放在/vendor/etc/init/下service vendor.hello-hal /vendor/bin/hw/vendor.company.hardware.hello1.0-service class hal user system group system seclabel u:r:hal_hello_default:s0 shutdown critical这个文件告诉 init有一个叫vendor.hello-hal的服务它的可执行文件路径在/vendor/bin/hw/vendor.company.hardware.hello1.0-service需要以system用户身份启动并且归属hal这个启动类别。hal类别让服务在开机阶段被统一拉起不用每个硬件模块各自写启动时序。VINTF manifest 也要改路径在/vendor/etc/vintf/manifest.xml里补一段manifest version1.0 typedevice hal formathidl namevendor.company.hardware.hello/name version1.0/version interface nameIHello/name instancedefault/instance /interface /hal /manifestVINTF manifest 的作用有点像“服务注册表”。hwservicemanager在业务层面已经知道服务存在但 VINTF 是从系统层面确认“这个 HAL 是合法的、可以启动、应该被发现”。如果 manifest 里没有声明即使服务进程已经跑起来了客户端用IHello::getService()也可能拿不到因为系统会认为这不是一个被授权的服务。这个坑我踩过不少次后面问题排查部分再展开。3.5 客户端调用和验证客户端侧代码相对简单拿到服务实例后直接调用方法#include vendor/company/hardware/hello/1.0/IHello.h using namespace vendor::company::hardware::hello::V1_0; void testHello() { android::spIHello hello IHello::getService(); if (hello nullptr) { ALOGE(failed to get hello service); return; } hidl_string result; hello-helloWorld(Android, result); ALOGI(call helloWorld result: %s, result.c_str()); }IHello::getService()默认拿的是default实例。如果想拿指定实例可以用IHello::getService(instance1)。拿到的hello是一个代理对象调用方法时实际是通过 Binder 把参数发给服务端进程等服务端执行完再把结果传回来。验证服务是否正常注册最直接的方法是在设备上执行lshallshal会列出当前设备上所有已注册的 HIDL 服务你会在列表里看到vendor.company.hardware.hello1.0::IHello/default。如果能看到这一行说明服务已经成功注册。看不到就按下面第五部分的排查流程走。4. 版本演进与 AIDL 化趋势4.1 HIDL 版本衍进从 1.0 到 1.6HIDL 从 Android 8.0 落地到现在只经历过大版本 1.x 的演进没有做过 2.0 这种推翻重来的改动。每个小版本对应 Android 系统版本的一次迭代需求。Android 8.0 / 8.1 时代是 1.0HIDL 刚出现在设备上很多接口还是 passthrough 模式。Android 9 的 1.1 和 1.2 增加了不少辅助工具类和更好的序列化推演比如让接口可以返回更复杂的联合体、对某些类型支持得更完整。Android 10 的 1.4 和 1.5 继续补强尤其是让 HIDL 有了明确的“退出策略”——后面我会细说。Android 11 之后 HIDL 基本定型为 1.6后面主要是修修补补没有再增加大特性。虽然 HIDL 本身就是为版本化而生的但它自己的迭代也暴露出一个问题接口语言版本和 Android 系统版本绑定得太紧。系统在想升级接口语言能力之前要担心旧设备上的旧 HAL 会不会因为新语法而无法识别所以每加一个能力都要做大量兼容性测试。这种“背负历史包袱”的沉重感也促使 Google 后来下定决心推动 AIDL 化。4.2 为什么 Google 又转向 AIDLAndroid 10 开始Google 官方建议新的 HAL 接口使用基于 AIDL 的稳定机制不再建议用 HIDL 写新接口。到 Android 11 之后趋势更明显AIDL 添加了对“稳定性”的支持可以用于 vendor 与 system 之间的跨进程通信。原因不难理解。HIDL 是 Treble 时期的“特定解决方案”它和 AIDL 是两套并行的 IDL 体系维护成本非常高。系统侧同时维护frameworks里的 AIDL 接口和hardware/interfaces下的 HIDL 接口开发者要在两套语法之间不停切换工具链也各搞各的。对平台团队来说能统一就统一AIDL 是现成的、大家都熟的、跨层通用的方案给它加上版本化和稳定性约束后完全可以替代 HIDL 的职责。从实际开发者的角度来说AIDL 的语法更简洁工具链更成熟生态也更广。HIDL 里面那套packageversion::interface的命名规则、hidl_interface构建模板、VINTF manifest的格式在 AIDL 稳定机制里都被重新包装成更易用的形态。接口文件还是.aidl但多了VintfStability注解来标识这是一个系统级稳定接口。4.3 存量 HIDL 代码怎么维护如果你是老项目的开发者现在手头全是 HIDL 接口不可能一夜之间全改成 AIDL。我的经验是分几步走。第一不要把 HIDL 当成“马上要被淘汰的技术”而不去学恰恰相反存量设备里的 HIDL 代码在未来五年内仍然会大量存在至少要能读懂、能排查、能修 bug。第二新接口确实应该优先考虑 AIDL。比如新增一个vendor.company.hardware.foo1.0如果公司内部没有强制要求用 HIDL直接用稳定 AIDL 写更省事后面维护也轻松。第三老接口不主动迁移但可以在架构重构时顺势迁。比如某个 HAL 本来就要做重大改动与其在旧的 HIDL 包里加版本不如直接把它重写成 AIDL 接口同时把老的 HIDL 服务降级为兼容层双轨运行一段时间等所有客户端都迁移完毕再删掉老服务。lshal命令不管接口是 AIDL 还是 HIDL 都能看到我经常用它来确认“服务到底以什么形式挂在系统里”排查问题的时候非常有用。5. 常见问题与排查实录5.1 客户端报 Transport error客户端拿服务时最常见的报错是Transport error: -19 (No such device)这种错误通常不是真的“没有设备”而是服务还没有注册成功或者 VINTF 配置文件没更新。我的排查顺序是这样的先看服务进程是否活着用ps -A | grep hello找进程。如果进程都没启动去看 logcat 里 init 的报错多半是可执行文件路径不对或者缺少 so 库。如果进程起来了但列表里没有服务用lshal确认再检查registerAsService的返回值不是OK就说明注册这步出问题了。最后一步再怀疑 SELinux。VINTF 配了、服务也注册了但客户端还是拿不到多半就是 sepolicy 权限问题常见的是hal_hello_default这类 domain 没有配置好hwservicemanager不允许系统进程访问它。这种情况 logcat 里会有明显的avc: denied日志直接用adb shell dmesg | grep avc看。5.2 频繁跨进程调用性能上不去HIDL 绑定式调用本质是 Binder 事务每次往返都有序列化、拷贝、线程调度开销。如果业务上有高频短小调用比如每秒上千次上报你就得转变设计思路。我的经验是尽量把高频调用改成批量接口方法一次接收一个hidl_vec把多次上报合并成一次调用数据量大又要求高吞吐的场景优先上共享内存。HIDL 里的hidl_memory可以把自己进程里的共享内存块信息传给对端对端通过IMemory映射后直接读写Binder 只负责传“元数据”流量就小很多。另外注意线程池配置。configureRpcThreadpool(1, true)的线程数是服务端能同时处理的请求数如果请求积压调大这个数字会有明显改善但也不建议盲目调大毕竟每个线程都要占栈空间开太多反而增加调度成本。5.3 结构体跨进程传输出问题传简单结构体问题不大一旦结构体里有嵌套的std::vector或者深层对象序列化就容易出幺蛾子。HIDL 对结构体字段的要求比较死板必须全部用 HIDL 表达式语言里能表示的类型不能混用 C 类型。我之前遇到过一个问题接口里定义了一个VecVecint32_t嵌套数组服务端一直收到空数据。后来才发现问题不在接口定义而在客户端组装数据的时候用了std::vector转hidl_vec的赋值方式不对。直接用hidl_vec的构造函数从std::vector构造或者用hidl_vec::setToExternal把已有内存包进去都比手动一个个 push 要稳。还要注意跨进程传fd的场景一定要用hidl_handle而不是裸int。裸int对 Binder 来说只是个数字传过去文件句柄并没有真正“迁移”对端拿到一个不可用的 fdhidl_handle会正确让 Binder 传递文件描述符的所有权这是别人踩过的坑写代码时直接记牢就好。5.4 调试方法论与常用命令HIDL 开发调试其实有套路可循最好用的几个命令我已经反复用过很多次lshal列服务dumpsys -l偶尔也看adb shell dmesg | grep avc查权限logcat -b all | grep -i hello看日志。服务端问题优先打ALOGI/ALOGE客户端问题优先确认能不能拿到服务对象能拿到正常情况下剩下就是业务逻辑问题了。我还总结过一个“五分钟定位法”第一步lshal确认服务在不在第二步看 logcat 里服务注册返回值第三步看 dmesg 有没有 avc 拒绝第四步确认 init.rc 和 manifest 有没有拼写错误。四步走完绝大多数问题都已经浮出水面。这四步里最容易忽略的是 manifest 的拼写name和interface标签的字符串必须和.hal里的package、接口名完全一致多一个少一个字符都可能导致服务不可发现。5.5 避坑清单根据这些年的踩坑经验我列一个精简清单写 HIDL 的时候照着自查接口文件里的包名、接口名、版本号必须一致大小写也不能错hidl-gen的-r映射必须配置正确否则生成代码时找不到依赖registerAsService返回值必须检查失败要立刻打日志configureRpcThreadpool不能漏漏了服务端并发处理会罢工manifest 里hal段必须和实际接口一致vndk 版本也要匹配参数类型禁止混用 C 标准库类型一律用 HIDL 类型文件描述符传递用hidl_handle不要裸传 int高频调用尽量批量大流量数据走共享内存SELinux 问题不要靠setenforce 0蒙混过关迟早要还我在实际项目里遇到过最诡异的一次是服务进程正常启动lshal也能看到服务但客户端偶尔能拿到、偶尔拿不到。排查到最后发现是registerAsService和joinRpcThreadpool之间的时序问题线程池还没完全就绪就注册了导致早期请求失败。解决办法是把registerAsService放到configureRpcThreadpool之后或者注册前加一小段等待逻辑。这个时序问题非常隐蔽正常测试很难复现只有压力测试才暴露。现在回头再看 HIDL 这套体系我更愿意把它理解成一次“接口工程”的示范通过语言层面的版本约束、进程边界的强制隔离、集中式的服务注册和发现把最混乱的硬件抽象层梳理得井井有条。它的问题在于历史和生态包袱太重最终被更轻量的 AIDL 稳定机制接力但“稳定接口、独立升级、安全隔离”这些设计思想无论换多少种 IDL 都不会过时。我记得第一次完整跑通 HIDL 服务的时候其实花了一天多大部分时间都耗在 VINTF manifest 拼写和 SELinux 规则上。后来多看lshal、多打日志、多读dmesg整个思路就通透了。如果你现在正准备开始学 HIDL别急着啃文档找个真实设备从一个最简单的接口写起来跑通一遍再去翻官方文档会发现一切都顺理成章。
返回列表