news 2026/10/12 7:46:54

Android HIDL虚拟HAL实战:system_server与native进程双向通信服务端实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android HIDL虚拟HAL实战:system_server与native进程双向通信服务端实现

1. 项目缘起与整体设计思路

做过Android系统层开发的朋友大概率都遇到过这样一个尴尬局面:system_server里的Java层代码想跟一个native进程交换数据,却发现常规的Binder调用怎么绕都不太顺手。Java层直接bind一个native service?可以,但权限、SELinux、生命周期管理一堆事。用socket?能通,但协议得自己定,稳定性全靠自觉。用管道?单向的,双向还得开两条,管理起来更麻烦。

这个项目要解决的就是这个问题:在Android frameworks层,用HIDL新建一个虚拟HAL,让system_server和一个独立的native进程实现稳定、规范的双向通信。所谓“虚拟HAL”,意思是这个HAL并不对应真实的硬件,它只是一个符合HAL规范的通信中间层。之所以选择HIDL而不是AIDL,是因为在Android 8.0到11.0这段区间里,HIDL是HAL层的主流接口描述语言,工具链成熟,hidl-gen能自动生成服务端和客户端的框架代码,省去大量手写Binder模板的工作。即便在AIDL逐渐接班的今天,大量存量项目和厂商定制代码仍然跑在HIDL上,掌握这套方法依然有很强的现实意义。

整个方案的核心思路是这样的:native进程作为HIDL服务端,向hwservicemanager注册一个服务;system_server通过HIDL客户端拿到这个服务的代理,调用它的接口;同时服务端通过回调接口反向通知system_server。这样一条链路就同时具备了“system_server调native”和“native回调system_server”两个方向的能力,也就是标题里说的双向通信。

为什么要把服务端放在native进程而不是直接放在system_server里?这里有个很实际的考量。system_server本身已经非常臃肿,把业务逻辑塞进去会加剧它的负担,而且system_server里的native代码调试起来远不如一个独立进程方便。把服务端独立成一个native进程,好处是:崩溃隔离(native进程挂了不会拖垮system_server)、调试方便(可以单独gdb、单独打日志)、权限清晰(SELinux域独立配置)。代价是多了一层IPC开销,但对于大多数非高频场景,这点开销完全可以接受。

这篇文章聚焦服务端的实现,也就是native进程这一侧:怎么定义HAL接口、怎么用hidl-gen生成代码、怎么实现服务、怎么注册到hwservicemanager、怎么处理回调。客户端(system_server侧)的接入放到下一篇讲,因为那一侧涉及Java和native的桥接,坑更多,值得单独展开。

适合谁看?如果你正在做Android系统定制、HAL层开发、或者需要打通system_server和native进程的通信,这篇文章能直接给你一套可复现的模板。前提是你对Android构建系统(Android.bp/Android.mk)、Binder机制、SELinux有基本的了解,完全没接触过HIDL也没关系,关键步骤我会把原理讲清楚。

2. HIDL接口定义与工程结构搭建

2.1 为什么接口定义是整个项目的基石

HIDL的全称是HAL Interface Definition Language,它的本质是用一门中间语言描述接口,然后由工具生成C++(或Java)的桩代码。这个思路跟Protocol Buffers、Thrift是一回事:你只描述“有什么方法、参数是什么、返回值是什么”,至于Binder的序列化、反序列化、线程管理、死亡通知,全部由生成的代码兜底。

这一步做得好不好,直接决定了后面实现顺不顺。接口定义一旦发布就很难改(HIDL有版本管理机制,改接口意味着升版本),所以在动手写代码之前,一定要把接口想清楚:需要哪些方法、参数用什么类型、哪些是同步哪些是异步、回调怎么设计。

我踩过的一个坑是:一开始图省事,把所有方法都塞进一个接口里,结果服务端实现类变得巨大,后来想拆分又因为版本兼容问题不敢动。所以建议从一开始就按职责拆分接口,比如“控制类方法”和“查询类方法”分开,回调单独一个接口。

2.2 目录结构与包命名规范

HIDL对目录结构有硬性要求,hidl-gen会根据包名去固定路径下找.hal文件。标准布局是这样的:

hardware/interfaces/<模块名>/<版本>/<接口名>.hal

假设我们给这个虚拟HAL起名叫vcom(virtual communication的缩写),版本用1.0,那么路径就是:

hardware/interfaces/vcom/1.0/ ├── Android.bp ├── IVcom.hal ├── IVcomCallback.hal └── default/ ├── Android.bp ├── Vcom.cpp └── service.cpp

包名规则是android.hardware.<模块名>@<版本>,所以这里就是android.hardware.vcom@1.0。这个包名会贯穿整个项目:生成的C++命名空间、注册服务时的实例名、SELinux标签,全都跟它挂钩,所以命名要慎重,一旦定下来后面改起来很痛苦。

提示:模块名尽量用全小写、无下划线的短词,避免跟已有HAL冲突。可以先在hardware/interfaces/下grep一下确认没重名。

2.3 接口方法的设计取舍

先看主接口IVcom.hal。假设我们的需求是:system_server能向native进程发送命令、查询状态,native进程能主动上报事件。那么主接口至少要有:

package android.hardware.vcom@1.0; interface IVcom { // 初始化,传入回调 init(IVcomCallback callback) generates (bool success); // 发送一条命令,同步返回结果 sendCommand(string cmd) generates (int32_t result, string reply); // 查询当前状态 getStatus() generates (string status); // 释放资源 release(); };

回调接口IVcomCallback.hal:

package android.hardware.vcom@1.0; interface IVcomCallback { // native进程主动上报事件 onEvent(string event, int32_t code); };

这里有几个设计决策值得展开说。

第一,为什么init要传入回调,而不是单独一个registerCallback方法?因为回调的注册和服务的初始化在语义上是绑定的——服务没初始化好,回调注册了也没意义。合并成一个方法,能保证顺序,也少一次IPC。当然如果你有“运行中动态换回调”的需求,那就得拆开,这是取舍。

第二,sendCommand为什么用同步返回而不是异步回调?因为命令类调用通常需要立即知道结果,同步语义更直观。但要注意,同步调用会阻塞调用线程,如果命令执行很慢,会拖住system_server的Binder线程。所以约定:sendCommand只处理快命令,慢操作走异步事件上报。这个约定要写进接口注释里,否则后面维护的人容易踩坑。

第三,参数类型的选择。HIDL支持的类型有限,string、vec<uint8_t>、基本整型、以及用@1.0定义的struct。不要试图传复杂对象,需要的话就序列化成string或byte数组。我见过有人想在HAL接口里传Parcelable,那是AIDL的思路,HIDL里行不通。

2.4 用hidl-gen生成桩代码

接口文件写好后,用hidl-gen生成C++代码。命令大致是:

hidl-gen -o hardware/interfaces/vcom/1.0/default \ -L c++-impl \ -r android.hardware:hardware/interfaces \ -r android.hidl:system/libhidl/transport \ android.hardware.vcom@1.0

这条命令会在default/目录下生成Vcom.h、Vcom.cpp、VcomCallback.h等文件。-L c++-impl表示生成实现骨架,-r是告诉工具去哪里找依赖的包。

生成的Vcom.cpp里会有一堆TODO,比如:

Return<bool> Vcom::init(const sp<IVcomCallback>& callback) { // TODO implement return bool {}; }

这些就是你要填的肉。注意:生成的文件不要手动改结构,只填方法体,否则下次重新生成会覆盖掉你的改动。如果确实需要加辅助函数,加在头文件的private区,或者单独开一个helper文件。

实操心得:hidl-gen的路径参数很容易写错,报错信息又很含糊。如果生成失败,先检查-r的路径是不是相对于当前工作目录正确,再检查.hal文件里的package声明跟目录结构是否严格对应。我在这上面浪费过整整一个下午。

3. 服务端核心实现与注册流程

3.1 实现类的骨架与线程模型

生成的Vcom.h里,Vcom类继承自IVcom的Stub。Stub是HIDL运行时提供的基类,它已经帮你处理了Binder的收发、线程池、死亡通知。你要做的只是实现那几个纯虚函数。

// Vcom.h(生成后手动补充的部分) struct Vcom : public IVcom { Vcom(); ~Vcom(); Return<bool> init(const sp<IVcomCallback>& callback) override; Return<void> sendCommand(const hidl_string& cmd, sendCommand_cb _hidl_cb) override; Return<void> getStatus(getStatus_cb _hidl_cb) override; Return<void> release() override; private: std::mutex mLock; sp<IVcomCallback> mCallback; std::string mStatus; };

注意sendCommand和getStatus的签名跟.hal里写的不一样——因为HIDL的同步返回在C++侧是用回调函数(_hidl_cb)实现的。这是HIDL的一个特点:接口描述里写generates,C++实现里就是最后一个参数传回调。生成的代码会自动把回调的结果打包回Binder reply,你只需要调用_hidl_cb(...)。

线程模型方面,HIDL服务默认跑在一个Binder线程池上,多个调用可能并发进来。所以共享状态必须加锁。上面代码里的mLock就是干这个的。我见过有人忘了加锁,结果mCallback在init和onEvent之间出现竞态,偶发空指针崩溃,查了很久。

3.2 init方法的实现细节

Return<bool> Vcom::init(const sp<IVcomCallback>& callback) { std::lock_guard<std::mutex> lock(mLock); if (callback == nullptr) { return false; } mCallback = callback; // 注册死亡通知,客户端挂了要清理 mCallback->linkToDeath(this, 0); mStatus = "initialized"; return true; }

这里有个关键点:linkToDeath。HIDL的回调对象本质上是一个Binder代理,如果客户端进程(system_server)挂了,这个代理就失效了,继续调用会返回错误。注册死亡通知后,客户端死亡时会回调serviceDied,你可以在那里清理mCallback,避免后续onEvent往一个死掉的代理发消息。

serviceDied是hidl_death_recipient的虚函数,Vcom类需要额外继承它:

struct Vcom : public IVcom, public hidl_death_recipient { void serviceDied(uint64_t cookie, const wp<IBase>& who) override { std::lock_guard<std::mutex> lock(mLock); mCallback = nullptr; mStatus = "client died"; } };

注意:serviceDied是在Binder线程里回调的,跟业务线程可能并发,所以同样要加锁。而且不要在serviceDied里做耗时操作,它阻塞的是Binder线程。

3.3 sendCommand与getStatus的实现

Return<void> Vcom::sendCommand(const hidl_string& cmd, sendCommand_cb _hidl_cb) { std::string command = cmd.c_str(); int32_t result = 0; std::string reply; if (command == "ping") { reply = "pong"; } else if (command == "reset") { std::lock_guard<std::mutex> lock(mLock); mStatus = "reset"; reply = "ok"; } else { result = -1; reply = "unknown command"; } _hidl_cb(result, reply); return Void(); }

_hidl_cb调用完之后,HIDL框架会把result和reply序列化回Binder reply,客户端那边就能拿到。_hidl_cb必须被调用且只能调用一次,否则客户端会一直阻塞等reply,直到超时。这是新手最容易犯的错误之一——某个分支忘了调_hidl_cb,客户端就卡死了。

getStatus类似,只是没有输入参数:

Return<void> Vcom::getStatus(getStatus_cb _hidl_cb) { std::lock_guard<std::mutex> lock(mLock); _hidl_cb(mStatus); return Void(); }

3.4 主动上报事件的实现

native进程主动通知system_server,靠的就是之前存下来的mCallback:

void Vcom::notifyEvent(const std::string& event, int32_t code) { sp<IVcomCallback> cb; { std::lock_guard<std::mutex> lock(mLock); cb = mCallback; } if (cb != nullptr) { cb->onEvent(event, code); } }

注意这里先加锁取出cb,再在锁外调用。为什么?因为onEvent是一次IPC,可能耗时,如果在锁内调用,会阻塞其他需要mLock的线程(比如sendCommand),造成不必要的串行化。这是一个很典型的“缩小锁粒度”的优化,值得养成习惯。

notifyEvent可以由服务端内部的任何逻辑触发,比如一个定时器、一个socket监听线程、或者某个硬件事件。它跟HIDL的调用方向是反的,这也是“双向通信”里“反向”那一半。

3.5 服务注册与service.cpp

服务端进程的入口是service.cpp,核心工作是把Vcom实例注册到hwservicemanager:

int main() { // 配置Binder线程池,至少1个 configureRpcThreadpool(1, true); sp<Vcom> vcom = new Vcom(); if (vcom == nullptr) { return 1; } // 注册服务,实例名跟.hal里的package对应 status_t status = vcom->registerAsService("default"); if (status != OK) { return 1; } // 加入线程池,开始处理请求 joinRpcThreadpool(); return 0; }

几个要点:

  • configureRpcThreadpool的线程数:如果服务端有耗时的同步方法,线程数要相应增加,否则并发请求会排队。一般1到4个够用,具体看负载。
  • registerAsService的参数:这里传"default",客户端拿服务时也用"default"。如果一个HAL有多个实例(比如多个同类型硬件),就用不同的名字区分。
  • joinRpcThreadpool:这行会阻塞,让进程一直活着处理请求。如果它返回了,说明线程池退出了,进程也就该结束了。

3.6 Android.bp的配置

default/目录下的Android.bp大致长这样:

cc_binary { name: "android.hardware.vcom@1.0-service", defaults: ["hidl_defaults"], vendor: true, relative_install_path: "hw", srcs: [ "service.cpp", "Vcom.cpp", ], shared_libs: [ "libhidlbase", "libhidltransport", "libutils", "liblog", "android.hardware.vcom@1.0", ], init_rc: ["android.hardware.vcom@1.0-service.rc"], }

vendor: true表示这是个vendor分区进程,跟system_server分属不同分区,这也是为什么需要SELinux放行。relative_install_path: "hw"让它装到/vendor/bin/hw/下,这是HAL服务的惯例位置。

对应的.rc文件负责开机启动:

service vcom-hal /vendor/bin/hw/android.hardware.vcom@1.0-service class hal user system group system

class hal让它跟随hal类一起启动,user/group决定进程身份,这个身份要跟SELinux策略里的域对应上。

4. 常见问题与排查技巧实录

4.1 服务注册失败:客户端拿不到服务

这是最高频的问题。现象是客户端getService()返回nullptr。排查顺序建议这样:

排查点检查方法常见原因
服务进程是否启动ps -A | grep vcomrc文件没配、进程崩溃
服务是否注册成功dumpsys -l | grep vcom或lshalregisterAsService返回非OK
SELinux是否放行dmesg | grep avc缺add策略
实例名是否一致对比注册和获取的名字一个写"default"一个写别的

lshal这个命令特别好用,它能列出当前所有已注册的HAL服务,包括接口名、实例名、PID。如果lshal里看不到你的服务,那问题一定在服务端;如果看得到但客户端拿不到,那问题在客户端的SELinux或名字匹配上。

4.2 SELinux权限被拒

HAL服务几乎必然要配SELinux,因为vendor进程和system_server分属不同域。典型的avc denial长这样:

avc: denied { find } for service=android.hardware.vcom@1.0::IVcom default scontext=u:r:system_server:s0 tcontext=u:object_r:hal_vcom_default:s0

解决方法是写策略文件,给服务打上hal_vcom_default标签,然后允许system_server域find它。策略文件一般放在device/<厂商>/<设备>/sepolicy/下。注意:不要图省事用permissive模式,那只是掩盖问题,量产版本必须enforcing。

踩坑记录:有一次服务注册成功、lshal也能看到,但客户端就是拿不到。查了半天avc,发现是hwservicemanager本身没有权限把服务信息返回给客户端域。也就是说,除了服务端和客户端之间的策略,还要放行hwservicemanager跟双方的交互。这个很容易漏。

4.3 回调方向不通

正向调用(system_server调native)通了,但反向回调(native调system_server)没反应。可能的原因:

  • 回调对象没注册成功:init里callback == nullptr判断没过,或者客户端传了个空。
  • 死亡通知误触发:客户端进程重启过,旧的mCallback已经失效,但服务端没清理,继续调用返回错误。
  • SELinux方向策略缺失:反向调用是native域调system_server域,需要另一套call权限,跟正向的find是两码事。

排查反向调用,可以在notifyEvent里打印cb->onEvent的返回值。如果返回FAILED_TRANSACTION之类,基本就是代理失效或权限问题。

4.4 同步调用卡死

客户端调用sendCommand一直不返回。九成是服务端某个分支忘了调_hidl_cb。HIDL的同步调用在客户端侧是阻塞等reply的,服务端不reply,客户端就等到天荒地老(或者Binder超时)。

排查方法:在服务端每个分支入口和_hidl_cb调用处都打日志,看哪个分支进去了没出来。养成习惯:写_hidl_cb的时候,确保所有return路径都覆盖到,包括异常分支。

4.5 版本升级的坑

HIDL有严格的版本管理。如果你改了.hal文件但没升版本号,编译可能过,但运行时会出现接口不匹配。正确做法是:任何接口变更都要新建一个版本目录,比如从1.0复制出1.1,在1.1里改,1.0保持不动。这样老客户端还能用老版本,新客户端用新版本。

这个机制一开始觉得麻烦,但它保证了向后兼容,在长期维护的项目里价值很大。我见过有人直接改1.0,结果OTA升级后老设备上的客户端全挂了。

4.6 调试手段汇总

最后整理几个实用的调试命令:

# 列出所有HAL服务 lshal # 查看某个服务的详细信息 lshal --interface android.hardware.vcom@1.0::IVcom # 查看SELinux拒绝 dmesg | grep avc cat /sys/fs/selinux/avc/cache_stats # 查看服务进程日志 logcat -s android.hardware.vcom@1.0-service # 确认进程和线程 ps -A | grep vcom

lshal配合logcat基本能定位八成的问题。剩下两成,多半是SELinux和时序问题,需要耐心看avc和加日志。

服务端这一侧搭好之后,客户端接入其实就顺理成章了,无非是getService拿到代理、调init注册回调、然后正常调用。但system_server侧的Java/native桥接有它自己的门道,比如怎么在Java层拿到native的sp对象、怎么把Java回调转成HIDL回调,这些放到下一篇细说。就服务端本身而言,把接口设计清楚、把锁和死亡通知处理好、把SELinux配全,基本就稳了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/12 7:45:43

CrystalReports10_5安装包:老项目报表环境配置与避坑指南

简介&#xff1a;CrystalReports10_5安装包面向需要在Windows环境下运行或集成水晶报表的开发者与企业IT人员&#xff0c;解决报表运行环境搭建与32/64位兼容问题。水晶报表由SAP开发&#xff0c;广泛用于企业级数据分析与报表制作&#xff0c;本包提供运行时组件&#xff0c;使…

作者头像 李华
网站建设 2026/10/12 7:44:20

开放代码评审框架:从标准到工具,构建可落地的团队评审体系

1. 从“代码评审”这件事说起&#xff1a;为什么我们需要一个开放的评审框架代码评审这件事&#xff0c;做过几年开发的人都不陌生。不管你是刚入行的新人&#xff0c;还是带了几年团队的老手&#xff0c;只要参与过真实的项目协作&#xff0c;就一定绕不开“让别人看你的代码”…

作者头像 李华
网站建设 2026/10/12 7:42:59

Linux基础不神秘:从内核原理到常用命令与运维排障全攻略

1. 打开Linux大门&#xff1a;这套基础真的不难这些年带过不少新人&#xff0c;也帮企业做过内部培训&#xff0c;发现大多数人对Linux的第一反应就是“黑乎乎的窗口、记不完的命令”。实际上&#xff0c;Linux不是洪水猛兽&#xff0c;它就在你身边——你刷的安卓手机底层就是…

作者头像 李华
网站建设 2026/10/12 7:42:56

区间加法与GCD查询:线段树+树状数组+差分数组的巧妙解法

1. 先搞清楚题目到底在要求什么线段树区间增加、区间 GCD 查询&#xff0c;这个组合我第一次遇到的时候&#xff0c;第一反应是“这不就是裸线段树吗”&#xff0c;等真正上手才发现&#xff0c;GCD 这个操作和加法根本不兼容。你没法在懒标记下推的时候用一个公式把父节点的区…

作者头像 李华
网站建设 2026/10/12 7:41:38

实时人脸磨皮滤镜实现:导向滤波+肤色掩膜+局部方差自适应

简介&#xff1a;本资源是一个基于C#实现的磨皮滤镜图像处理DEMO&#xff0c;面向图像算法初学者、.NET开发者及人像美化类应用实践者&#xff0c;解决人像皮肤平滑与细节保留这一典型图像处理需求。压缩包共55个文件&#xff0c;含8个核心DLL库、8个C#源码文件&#xff08;涵盖…

作者头像 李华
网站建设 2026/10/12 7:40:38

纯离线人脸识别与头像对比:ONNX Runtime+InsightFace实战

简介&#xff1a;这是一份面向AI初学者与C#开发者的人脸识别离线实践项目&#xff0c;提供从图像预处理、人脸检测、特征提取到比对匹配的完整技术链源码实现&#xff0c;无需联网即可在本地完成头像对比与身份识别任务。资源共687个文件&#xff0c;以621个DLL动态库&#xff…

作者头像 李华