接口写好了代码也编过了service也能在shell里注册成功了。我以为接下来的工作只是简单联调结果在让system_server和native daemon互通消息这件事上前前后后折腾了好几个通宵。回头复盘最大的问题不是“不会写HIDL”而是“自以为会写HIDL”。这篇踩坑篇记录的就是我从一个能编译的虚拟HAL到真正打通system_server与native进程双向通信的路上所有值得写下来的坑和完整排查思路。我做的这个项目场景很典型需要在内置系统中让system_serverJava侧和独立native daemon进程互相传消息。两个进程资源互相独立既不能用静态变量又不想引入额外的socket方案最后选型落在HIDL上——用一个不依赖真实硬件的虚拟HAL作为通信中转层。整个方案的骨架可以参考上一篇这篇只讲实战过程里几乎每个坑我都踩了一遍的部分。如果你是正在做framework定制、系统通信改造、HAL虚拟化的工程师这篇文章大概率能帮你少走很多弯路。下面按我实际踩坑的时间线来写。1. 接口设计阶段的三个雷契约、类型与回调边界1.1 接口契约一旦发布就改不动HIDL接口本质上是版本化契约这个特点跟传统Binder的aidl还不太一样。传统aidl在系统内部可以相对随意地改动同步升级但HIDL接口一旦以某个版本比如android.hardware.xxx1.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.vcomm1.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.vcomm1.0; interface IVcommCallback { oneway onMessage(const VcommMessage msg); }; interface IVcomm { registerCallback(IVcommCallback callback); sendMessage(uint32_t clientId, const VcommMessage msg); };服务端实现里保存spIVcommCallback客户端注册时把这个回调对象传过去。这里还有两个细节要特别提醒第一回调方法尽量声明成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.vcomm1.0-L androidbp场景用于生成Android.bp-L c场景用于直接生成代码实际工程中通常先生成Android.bp再让构建系统去编代码。建议每次升级源码树版本后都重新编译一次hidl-gen别用旧的缓存工具。2.2 Android.bp依赖清单写完.hal文件、配置好Android.bp后服务端编译时最常遇到的问题是“明明接口都定义了shared_libs少了某几个编译直接失败”。有些时候错误并不直接提示你缺了哪个而是报一些奇怪的“undefined symbol”或者找不到头文件。我实际工程里服务端依赖比较稳定的清单大致是依赖项用途说明vendor.demo.vcomm1.0当前package的服务端/客户端类型库libhidlbaseHIDL基础库提供IBase、hidl_string等底层支持libhidltransporthwbinder传输层提供defaultServiceManager等libutilsC工具类sp、RefBase依赖liblogALOGD等日志输出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::spIVcomm 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进程直接spIVcomm 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 pid1234 commvcommd namevendor.demo.vcomm1.0::IVcomm/default scontextu:r:hal_vcomm_default:s0 tcontextu:object_r:default_android_hwservice:s0 tclasshwservicemanager逐段解释{ add }表示进程试图向hwservicemanager注册服务scontext是vcommd进程的SELinux domaintcontext是目标服务对象的安全上下文tclasshwservicemanager说明这是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 pid5678 commsystem_server namevendor.demo.vcomm1.0::IVcomm/default scontextu:r:system_server:s0 tcontextu:object_r:hal_vcomm_hwservice:s0 tclasshwservicemanager{ find }表示system_server找不到/发现不了这个HIDL服务。权限模型里即使服务已经注册客户端也要具备对hwservice类型的“find”权限才能通过hwservicemanager拿到服务Binder引用。再往下还会有调用阶段的拒绝audit: avc: denied { call } for pid5678 commsystem_server scontextu:r:system_server:s0 tcontextu:r:hal_vcomm_default:s0 tclassbinder这段就很好理解了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本身就提供了waitForServiceC侧用起来更直接spIVcomm 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的linkToDeathReturnbool 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作为系统内部通信总线”的思路在实际项目里还是比较通用的希望这篇踩坑篇能帮你绕开大部分我走过的弯路。
阅读完成 · 觉得有帮助?