接口写好了,代码也编过了,service也能在shell里注册成功了。我以为接下来的工作只是简单联调,结果在让system_server和native daemon互通消息这件事上,前前后后折腾了好几个通宵。回头复盘,最大的问题不是“不会写HIDL”,而是“自以为会写HIDL”。这篇踩坑篇记录的就是我从一个能编译的虚拟HAL,到真正打通system_server与native进程双向通信的路上,所有值得写下来的坑和完整排查思路。
我做的这个项目场景很典型:需要在内置系统中让system_server(Java侧)和独立native daemon进程互相传消息。两个进程资源互相独立,既不能用静态变量,又不想引入额外的socket方案,最后选型落在HIDL上——用一个不依赖真实硬件的虚拟HAL作为通信中转层。整个方案的骨架可以参考上一篇,这篇只讲实战过程里几乎每个坑我都踩了一遍的部分。
如果你是正在做framework定制、系统通信改造、HAL虚拟化的工程师,这篇文章大概率能帮你少走很多弯路。下面按我实际踩坑的时间线来写。
1. 接口设计阶段的三个雷:契约、类型与回调边界
1.1 接口契约一旦发布就改不动
HIDL接口本质上是版本化契约,这个特点跟传统Binder的aidl还不太一样。传统aidl在系统内部可以相对随意地改动同步升级,但HIDL接口一旦以某个版本(比如android.hardware.xxx@1.0)发布到系统的VINTF清单里,这个“1.0”就被固定了。你要改东西,正确的做法是新建@1.1、@1.2版本去扩展,而不是直接修改@1.0里已有的方法签名。
我在这个坑上吃过亏。最早设计接口时图省事,只放了两个方法:registerCallback和sendMessage。后面发现需要区分消息来源和携带一个确认标记,直接改了sendMessage的签名,把void改成Status,把参数从单个结构体改成两个参数。改完确实同步改了服务端、客户端、回调实现,编也编过了,但运行到system_server早期调用时经常报接口版本不匹配的错,还出现过老的客户端代码走到新接口的方法空指针。
后来学乖了:设计虚拟HAL的.hal接口前,先花两小时把消息模型、回调方向、异常处理、参数类型全列出来。哪怕第一版实现只支持最核心的路径,接口形状也必须一次画完整。运行期不好改,所以宁可设计阶段放慢点。
1.2 类型不能穿透HIDL边界
这个坑很隐蔽,我第一次实现的时候完全没意识到。HIDL接口上定义的method参数,必须在.hal文件所在package下定义类型,它不会理解也不接受你在另一个C++头文件里定义的枚举、结构体或者Java对象。跨进程传输的所有数据,最终都会被HIDL编译器翻译成序列化布局,这套布局只认.hal文件里那些声明。
举个例子,我最初想要回调事件数据时传一个枚举值MSG_TYPE_A、MSG_TYPE_B。这个枚举在服务端的某个头文件里定义得很舒服,客户端也能include,但它不满足HIDL的边界条件。正确的做法是在types.hal里定义:
package vendor.demo.vcomm@1.0; enum MessageType : int32_t { MSG_TYPE_HEARTBEAT = 0, MSG_TYPE_PUSH_DATA, MSG_TYPE_ACK, }; struct VcommMessage { MessageType type; uint32_t seqId; string payload; };然后主接口和回调接口都收割这个package里定义的类型。之后无论system_server用Java侧调用,还是native daemon用C++侧调用,两边拿到的结构体shape都严格一致,不会出现“我这边加了个字段你那边解不出来”的情况。
1.3 回调接口的边界设计
双向通信必然有一端持有另一端的回调对象。HIDL里这个对象也是接口类型,我踩坑的地方在于回调接口的组织形式。一开始我把回调直接作为主接口里的一个方法参数传进去,比如sendMessageToClient(IVcommCallback cb),样子难看不说,后面想给多个客户端分别派发回调时根本区分不出来。
正确做法是把回调接口定义成同一个package下的独立接口,独立文件对应独立HIDL类型,比如:
package vendor.demo.vcomm@1.0; interface IVcommCallback { oneway onMessage(const VcommMessage& msg); }; interface IVcomm { registerCallback(IVcommCallback callback); sendMessage(uint32_t clientId, const VcommMessage& msg); };服务端实现里保存sp<IVcommCallback>,客户端注册时把这个回调对象传过去。这里还有两个细节要特别提醒:
第一,回调方法尽量声明成oneway。原因后面第3章会详细讲,简要说是hwbinder线程池模型下同步回调很容易把服务端线程占满导致死锁。
第二,回调接口是客户端进程里实现的,服务端不要做任何“回调方一定还在”的假设。客户端进程挂掉之后,服务端如果还拿着旧回调调用,轻则返回false,重则直接crash。链路两端的死亡通知都是必须的,这个放到第5章展开,但接口设计时就要在脑子里留出这个位置。
2. 编译与生成链路上的坑:hidl-gen、依赖与加载模式
2.1 hidl-gen版本漂移
生成HIDL接口代码的工具是hidl-gen。这个工具最大的坑在于它是跟源码树版本强绑定的。我在做的过程中一开始图方便,直接用系统里装好的现成hidl-gen生成代码,结果编译报了一堆模板相关的错误,核心报错是找不到android::hardware::hidl_enum_range这类符号。后来才意识到,本机的hidl-gen版本和源码树里HIDL runtime版本不一致,生成出来的代码接口对不上。
处理方式很老实:在源码树根目录编译hidl-gen,然后强制使用编译出来的版本:
make hidl-gen export PATH=$ANDROID_BUILD_TOP/out/host/linux-x86/bin:$PATH hidl-gen -L androidbp -r vendor.demo:vendor/demo/interfaces \ -randroid.hardware:hardware/interfaces \ vendor.demo.vcomm@1.0-L androidbp场景用于生成Android.bp,-L c++场景用于直接生成代码,实际工程中通常先生成Android.bp再让构建系统去编代码。建议每次升级源码树版本后都重新编译一次hidl-gen,别用旧的缓存工具。
2.2 Android.bp依赖清单
写完.hal文件、配置好Android.bp后,服务端编译时最常遇到的问题是“明明接口都定义了,shared_libs少了某几个,编译直接失败”。有些时候错误并不直接提示你缺了哪个,而是报一些奇怪的“undefined symbol”或者找不到头文件。
我实际工程里服务端依赖比较稳定的清单大致是:
| 依赖项 | 用途说明 |
|---|---|
| vendor.demo.vcomm@1.0 | 当前package的服务端/客户端类型库 |
| libhidlbase | HIDL基础库,提供IBase、hidl_string等底层支持 |
| libhidltransport | hwbinder传输层,提供defaultServiceManager等 |
| libutils | C++工具类,sp、RefBase依赖 |
| liblog | ALOGD等日志输出 |
| libbinder | 传统binder依赖,hwbinder场景下有时也间接需要 |
如果用的是比较新的Android版本,部分库被合并精简,建议以源码树的hidl-gen生成结果为准。最稳妥的办法是先让hidl-gen生成一次完整的Android.bp,再照着它给的模块依赖去套服务端二进制模块。但有个细节:hidl-gen生成的依赖只覆盖接口模块自身,服务端本体的cc_binary/cc_library仍然要自行补齐上述列表。我在第一次写服务端时就是在这一步来回试错,最后照着生成模块抄依赖,问题立刻消失。
2.3 服务进程的加载模式
HIDL有两种运行模式:绑定式(BINDERIZED)和直通式(PASSTHROUGH)。编译产物形态直接决定走哪条路,这一步非常容易踩坑,因为两套机制对“服务进程在哪里”的理解完全不同。
绑定式服务是一个独立的daemon进程,可执行文件放在vendor分区的bin目录(比如/vendor/bin/vcommd),启动后注册到hwservicemanager,客户端跨进程通过hwbinder调用。这是我们虚拟HAL所需的形态。
直通式服务则不同,它编译成.impl.so,并没有一个独立进程。调用进程需要时通过dlopen把.impl.so加载进自己进程,然后直接调用进程内的实现函数。这种模式适合高频访问、不想跨进程的硬件接口(早期sensor等场景常用),但对“system_server与native进程通信”这种需求来说是反模式。
我最初按直通式的思想把服务做成了impl库,结果system_server那边getService始终拿不到。限制在于system_server作为一个系统进程,不会也没办法随便加载vendor分区里的impl库到自己的进程空间。后来把实现改成独立的vcommd进程,问题才消失。
判断当前实现是哪种形态也很简单:查编译产物。如果产物是二进制可执行文件,多半是绑定式;如果产物是.so文件,且实现了HIDL_FETCH_vendor_demo_vcomm函数,就是直通式。
3. 直通式与绑定式之间:跨进程通信的正确打开方式
3.1 直通式服务“消失”的诡异现象
我在第2章末尾提到最初用了直通式impl库。更诡异的是,在daemon进程本身或某个native shell里自己调用getService,服务是能拿到的,也能正常触发方法。但换到system_server侧就死活拿不到。这个“自己拿得到,别人拿不到”的现象误导了我很久。
后来从架构上想清楚了:直通式服务加载到哪个进程,取决于谁调用getService。在shell进程里它被加载进shell进程,在system_server的system process里它被加载进system_server进程。问题是system_server进程空间装载的vendor实现库如果依赖了vendor分区里的其他共享库,或者有厂商签名校验,大概率直接加载失败;就算加载成功,这也不是一个独立的服务进程,完全违背了隔离设计意图。
所以这类“虚拟HAL做跨进程消息中转”的需求,不用纠结,直接上绑定式。只有对性能极其敏感、每次调用量极大、且进程隔离要求不高的场景才值得考虑直通式。虚拟HAL的通信流量对于hwbinder来说完全不是瓶颈,选择绑定式是结构正确性优先。
3.2 正确注册与获取:hwservicemanager两端姿势
绑定式服务注册其实很简单,我放一段实际工程里的伪代码片段供参考:
#include <vendor/demo/vcomm/1.0/IVcomm.h> #include <vendor/demo/vcomm/1.0/IVcommCallback.h> #include <hidl/LegacySupport.h> #include <utils/StrongPointer.h> using vendor::demo::vcomm::V1_0::IVcomm; int main(int argc, char** argv) { ALOGI("vcommd starting"); android::sp<IVcomm> service = new VcommImpl(); android::status_t status = service->registerAsService("default"); if (status != android::OK) { ALOGE("Failed to register vcomm service, status=%d", status); return -1; } ALOGI("vcomm service registered"); android::hardware::configureRpcThreadpool(4, true /* callerWillJoin */); android::hardware::joinRpcThreadpool(); return 0; }客户端侧,C++进程直接:
sp<IVcomm> service = IVcomm::getService("default"); if (service == nullptr) { /* 处理取服务失败 */ }system_server侧Java进程,可以通过JNI或Framework层封装,本质上仍需要调用HIDL的getService/waitForService入口,返回IVcomm接口对象。
这里有一个小知识点,获取服务时“default”是实例名而不是包名。同一个HAL package可以注册多个实例,比如“default”“left”“right”,每个实例在hwservicemanager里都是独立的。我们虚拟HAL只用一个default实例,但这套机制在后续扩展多客户端场景时很有用。
3.3 hwbinder线程池:双向通信死锁的元凶之一
双向通信和我熟悉的单向AIDL调用有个很不明显的差异:服务端调用回调方法时,回调方法最终也会在线程池里执行,而这个线程池和主服务方法共用。
如果服务端在某个主方法里同步等待回调返回,会占用一个hwbinder线程;回调到来时又需要一个新线程处理回调;回调处理完成后若再同步调回服务端,又需要第三个线程。这种“服务端方法—回调—再入服务端方法”的链路在默认小线程池下非常容易把线程耗尽,然后表现成每次调用都卡住或超时。
我的处理策略是:
- 回调方法一律声明为oneway;
- 服务端不阻塞等待回调返回结果;
- 将连接状态、消息序号、ACK等状态变量放在普通线程内自行维护,不占用binder线程同步等待;
- 根据并发客户端数量调整configureRpcThreadpool参数,比如4个或8个线程。
踩过一次之后,我会建议你在设计阶段就把“要不要同步等待对方确认”这个问题想清楚。纯异步有丢消息的可能,同步有死锁的风险,通常我会做一层本地缓存:服务端先把消息推到发送队列,本地线程负责实际回调发送,同时维护序号和超时重发。这样既不要binder线程长时间等待,又能保证可靠性。
4. SELinux策略排查:从avc日志到规则落地的完整链路
4.1 第一现场:注册服务时被拒的avc
这是整个过程中卡得最久、最折磨人的环节。现象是:vcommd启动日志正常,也没有crash,但lshal列表里始终看不到这个服务,system_server那边也一直报找不到服务。
偶然打开dmesg,发现了这样的avc拒绝日志:
audit: avc: denied { add } for pid=1234 comm="vcommd" name="vendor.demo.vcomm@1.0::IVcomm/default" scontext=u:r:hal_vcomm_default:s0 tcontext=u:object_r:default_android_hwservice:s0 tclass=hwservicemanager逐段解释:{ add }表示进程试图向hwservicemanager注册服务;scontext是vcommd进程的SELinux domain,tcontext是目标服务对象的安全上下文,tclass=hwservicemanager说明这是HIDL服务注册相关操作。
问题很清楚:虽然vcommd进程domain是hal_vcomm_default,但我没把它映射到hwservicemanager上下文对应的服务类型上。缺少了一条“允许hal_vcomm_default在hal_vcomm_hwservice类型的hwservice上执行add操作”的规则。
4.2 system_server侧获取服务的权限缺口
服务注册问题解决后,紧接着又冒出新的拒绝日志。这次发生在system_server调用阶段:
audit: avc: denied { find } for pid=5678 comm="system_server" name="vendor.demo.vcomm@1.0::IVcomm/default" scontext=u:r:system_server:s0 tcontext=u:object_r:hal_vcomm_hwservice:s0 tclass=hwservicemanager{ find }表示system_server找不到/发现不了这个HIDL服务。权限模型里,即使服务已经注册,客户端也要具备对hwservice类型的“find”权限,才能通过hwservicemanager拿到服务Binder引用。
再往下还会有调用阶段的拒绝:
audit: avc: denied { call } for pid=5678 comm="system_server" scontext=u:r:system_server:s0 tcontext=u:r:hal_vcomm_default:s0 tclass=binder这段就很好理解了:system_server进程的domain没有获得对hal_vcomm_default domain进行binder call的权限。注意HIDL底层数据传输走hwbinder,但SELinux上下文里仍然会体现binder相关的class。
4.3 te规则与上下文:从dmesg到可落地的例子
针对上面的日志,我整理出了一套比较清晰的te规则。先定义服务和domain:
# 定义 vcomm HAL 的 domain 和 hwservice 类型 type hal_vcomm, domain; type hal_vcomm_default, domain; type hal_vcomm_hwservice, hwservice_type; # 让 vcommd 进程启动后进入 hal_vcomm_default domain init_daemon_domain(hal_vcomm_default); # 允许在 hwservicemanager 上注册服务 allow hal_vcomm_default hal_vcomm_hwservice:hwservicemanager add; # 允许 system_server 查找并调用该服务 allow system_server hal_vcomm_hwservice:hwservicemanager find; allow system_server hal_vcomm_default:binder call; allow system_server hal_vcomm_default:hwbinder { transfer call };实际操作中不需要手工编写全部细节,Android的sepolicy平台策略提供了大量宏,比如add_hwservice、binder_call等等。重点是理解日志里scontext、tcontext、tclass、permission四个要素,然后找到对应宏去匹配。直接贴avc denied难看的自定义allow规则很容易,但后期维护比较脏。能用宏就用宏。
还有一个容易漏掉的点:vcommd二进制本身的文件上下文要配置好,否则init进程启动它时domain不生效,依然会以别的domain运行,导致注册被拒或后续规则挂不上。file_contexts里要加一行:
/vendor/bin/vcommd u:object_r:hal_vcomm_exec:s0然后te里配合init_daemon_domain(hal_vcomm_default),让init启动时把它切到hal_vcomm_default。没有这一步,前面规则写得再全也白搭。
4.4 排错操作顺序:先放宽再收紧
SELinux排错我总结了一套好用的操作顺序,强烈建议按顺序做:
第一步,确认服务本身是否正常。先setenforce 0切换到permissive模式,让所有权限检查只记录不拒绝,然后看vcommd能不能注册、system_server能不能调用。如果permissive下依然失败,说明问题不在SELinux,先回去查HIDL实现本身。
第二步,确认permissive下全部功能跑通后,setenforce 1恢复强制模式,然后重新触发一次失败路径。抓日志用:
adb shell dmesg | grep avc adb shell dmesg | grep hal_vcomm如果日志没抓到,有可能是dontaudit规则把它静默了。调试时可以在编译sepolicy时关掉dontaudit,或者临时在全局dontaudit里注掉相关规则,但别把这个改动带到正式版本,很容易成为安全隐患。
第三步,根据avc日志逐条补规则。每补一条验证一次,直到日志清净、功能恢复。最后再把permissive切回enforcing做一次完整回归,确保没有漏网之鱼。
我个人的体会是,SELinux的坑不是难在写规则,而是难在你不知道它到底拒绝在哪里。沉下心按“现象→日志→定位→补规则→回归”的顺序走,通常不需要猜。
5. 运行时稳定性:时序、死亡通知与进程守护
5.1 getService和waitForService的时序问题
虚拟HAL服务是独立daemon,而system_server开机启动时有一大堆系统服务要初始化。如果某个系统服务在vcommd注册完之前就去getService,大概率拿到的是空对象或者直接抛交易错误。
我最早用的笨办法是在系统服务启动流程里反复重试:
private IVcomm getVcommWithRetry(int maxRetries) throws RemoteException { for (int i = 0; i < maxRetries; i++) { try { IVcomm service = IVcomm.getService(true); if (service != null) { return service; } } catch (RemoteException e) { Log.w(TAG, "getVcomm failed", e); } SystemClock.sleep(500); } return null; }后面发现HIDL本身就提供了waitForService,C++侧用起来更直接:
sp<IVcomm> service = IVcomm::waitForService("default");不过waitForService在某些实现里是阻塞直到服务出现的,开机早期如果不加超时会拖慢整个system_server启动。稳妥做法是自己包一层带超时重试逻辑的获取函数,把超时控制在5秒左右。开机阶段服务通常都起得快,多等一两秒完全可以接受。
5.2 死亡通知:两边都要linkToDeath
这个坑在调试阶段被我用“反正都会跑”糊弄过去了,直到手动kill掉vcommd进程再测试system_server调用时,才发现保持一个失效的Binder引用会导致连续的交易失败。
正确做法是客户端注册死亡接收器,重连时重新获取服务:
private final DeathRecipient mDeathRecipient = new DeathRecipient() { @Override public void serviceDied(long cookie) { Log.w(TAG, "vcommd died, trying to reconnect"); mHandler.post(this::reconnectVcomm); } }; private void connectVcomm() { try { IVcomm service = getVcommWithRetry(10); if (service != null) { service.linkToDeath(mDeathRecipient, 0); } } catch (RemoteException e) { Log.e(TAG, "connectVcomm failed", e); } }注意linkToDeath注册过之后,死亡接收器对象必须长期被持有,否则会被回收。更隐蔽的是,服务端侧也要对持有的客户端回调对象做死亡监听——客户端进程(比如system_server正在重启)挂掉后,服务端如果再往旧回调发消息,往往就是crash或者返回错误。我第二次调这链路时就在服务端加了对IVcommCallback的linkToDeath:
Return<bool> linked = callback->linkToDeath(new VcommCallbackDeathRecipient(), 0); if (!linked) { ALOGW("Failed to link death to callback"); }双向通信链路任何一端的进程重启都必须让另一端感知到,否则消息就会发到“幽灵引用”上。这个不是可选项,是基础项。
5.3 回调消息的可靠性与线程池占用
最后一类坑偏工程化:消息可靠性。HIDL的oneway回调天然是异步的,服务端把消息发出去后不会等待接收方处理完毕。消息丢失通常是两个原因:客户端线程池处理不过来,或者低内存状态下Binder收发优先级问题。这种问题最难排查,因为日志里完全找不到错误。
我的方案是给回调消息加递增序号,客户端检测到序号断层后主动发起重连并请求补偿;服务端在发送队列里保留一段最近消息缓存。同时服务端把configureRpcThreadpool调大防止线程池打满。这方案不复杂但很有效,因为虚拟HAL的通信流量本身不大,做全量缓存成本很低。
还有一个实用调试技巧:抓hwbinder线程池占用情况。卡死时通过kill -3打出Java层和Native层线程栈,找到所有hwbinder线程处于什么状态,基本一眼就能看出到底是谁在阻塞等谁。
最后再分享一点实际运维过程中的体会
如果让我把这一路踩坑浓缩成几句话,我会说:先确认模式,再动SELinux;先跑通permissive,再收紧策略;先处理死亡通知,再优化消息性能。HIDL虚拟HAL这种方案本身不复杂,复杂的是它和系统权限模型、进程模型耦合在一起,任何一环出了偏差,外在表现都是“服务拿不到”“调用超时”这类让人抓狂的问题。
另有一个小技巧,特别管用:无论什么时候都不只依赖logcat,养成看dmesg | grep avc的习惯。HIDL服务注册、调用、回调链条中很多诡异的“时好时坏”,本质都是SELinux在静默拦截,而静默拦截的日志只落在kernel侧。
项目的后续方向,我打算在虚拟HAL之上再封装一层Framework服务,把HIDL细节隐藏掉,向上提供更友好的Java接口;同时把可靠性队列做成可配置项。这套“虚拟HAL作为系统内部通信总线”的思路在实际项目里还是比较通用的,希望这篇踩坑篇能帮你绕开大部分我走过的弯路。