上一篇说了,VHAL是车机系统里直读信号的重要方式。但领克车机中实现的VHAL工具和接口不是标准AAOS的CarPropertyManager,AAOS能获取的信号很少,所以每个车机都会有定制工具。

VHAL真正对接CAN总线的是ECarX的私有服务层CarSignalManager,车机的超级定制版。公开文档?不存在的!接入只能靠猜。

不过好在,ECarX看不得我们开发人员没有文档的苦,悄悄的放了一条路出来。TestSignalApp没有任何加密,转储逆向App的dex就可以,/vendor/bin/vehicle-hal-tool也能列出所有信号名称。

因此通过反射CarSignalManager的接口,就能模拟车机系统接入读取。反射还有一个好处,编译期零依赖,只需要Class.forName + Proxy。至于需要反射的Jar,都在系统目录下,有root权限就可以轻松访问。Root权限怎么获取就不用我说了吧(狗头)

一、反射接入

代码如下:

val binder = Class.forName("android.os.ServiceManager")
    .getMethod("getService", String::class.java)
    .invoke(null, "ecarxcar_service") as? IBinder

val iECarXCar = Class.forName("ecarx.car.IECarXCar\$Stub")
    .getMethod("asInterface", IBinder::class.java)
    .invoke(null, binder)

val eCarXCar = Class.forName("ecarx.car.ECarXCar")
    .getMethod("createCar", Context::class.java, clsIECarXCar)
    .invoke(null, context, iECarXCar)

val manager = eCarXCar.javaClass
    .getMethod("getCarManager", String::class.java, clsIECarXCar)
    .invoke(eCarXCar, "car_signal", iECarXCar)

拿到SiganlManager后反射扫一遍所有无参getter,建立sourceKey → Method缓存映射表。ECarX的getter命名规律是get + PascalCase,比如getEngSpdDispd,去掉get转小写就是engspddispd,正好和信号定义里的sourceKey对上,信号定义可以通过adb访问/vendor/bin/vehicle-hal-tool获取。

for (m in mgr.javaClass.methods) {
    if (m.parameterCount != 0 || !m.name.startsWith("get")) continue
    val key = m.name.removePrefix("get").lowercase()
    m.isAccessible = true
    getters.putIfAbsent(key, m)
}

这样后面新增VHAL信号就省事了——只要在信号定义里新增一行属性,读取侧一个字都不用改(当然别忘了数据默认单位转换的问题)。

二、轮询?回调?

CarSignalManager有两条数据通道,默认走回调推送:

  • 回调推送(callbackFlow): 注册CarSignalEventCallback,信号变化时框架主动推,不受频率限制,理论频率无上限。
  • 反射轮询(signalValuesFlow): while(true){delay} + getter invoke,频率由vhalPollingHz控制,是事件回调不可用时的降级路径。

回调路径

回调推送有个大坑——Proxy.newProxyInstance第一个参数必须用被代理接口的classLoader,不能用context的。开发的时候坐在车上调试了一下午,一直遇到IllegalArgumentException报错,看得满脸懵逼:

Proxy.newProxyInstance(clsCallback.classLoader, arrayOf(clsCallback), handler)

另外:回调给的不是信号ID,而是MgrID,你需要用一张propertyIdToSourceKey反查表才能对上号,再查SignalRegistry拿定义。反差表Id是getter的ID+1。

数据看门狗

还有个细节:每个已订阅信号有5秒看门狗。如果某个信号5秒没收到回调(比如车机这个信号就不发),就单独把该信号降级到getter轮询,轮询频率通过信号源配置的频率轮询,其他信号保持回调。一旦回调返回了新数据,自动升级到回调模式。避免某个信号回调侧卡死不发,导致全部信号降级轮询,给车机带来查询压力。

三、公式校准

VHAL有两条读取路径(CarSignalManager反射和/vendor/bin/vehicle-hal-tool shell轮询),公式必须收敛在一个地方,不然回调一个值、shell一个值,天知道你在用哪个数据源,又应该用哪个公式。就算是AI也会被全量的if-else弄崩溃。

典型公式

  • 转速: raw / 2 单位: RPM (x2 原因不知道,755.5RPM意义好像也不大?)
  • 油门: raw / 256 单位: 百分比 (8-bit,不是16-bit, 反正不是标准IEEE浮点数)
  • 胎压: raw × 0.01375 单位: bar (谁家压力比值是要乘这个奇怪的魔数的)
  • 轮胎温度: raw - 50 单位: °C (大概是为了负数才 +50给出来?)

方向盘公式符号问题

方向盘转角是个经典坑:VHAL把int16当成uint16返回,左转是负值但读出来是个大正数。修正就是int16-as-uint16重解释:

val signed = raw.toInt().toShort().toFloat() // 0x8000=-32768
signed * formula.scale + formula.offset

实测校准后是* 0.056 - 1.232 (别问,问就是经验公式校准,真实公式我也不知道,只能线性拟合)。

有效值范围校验

VHAL还会发一些哨兵值/无效值。比如平均油耗信号实测出现过raw 4259/4260,换算成425.9/426.0 L/100km——谁家油耗能到425?你也不是在开飞机。明显是无效数据。

所以我们给每个信号都配置上validRange,换算完越界就丢弃无效数据,不让它进插值器,下游快照保持上一帧数据的有效值,显示数据的地方就不会狂闪了。

一个小猜测:油耗数据中的无效数据值是为了同步其他信号的发送频率故意加的占位值。系统内部处理了,只有我们在这里采坑。

四、刹车信号

刹车信号从VHAL读不到,UDS诊断也读不到。领克的CAN网关把BrkPedlrRatPerc(MgrID 31602)和BrkPedlPsdBrkPedlPsd(MgrID 31598)过滤了,拿到的永远是0x6400这个占位符,踩不踩都一样。看起来只要不进高级别诊断Level,这个数据就没有。领克:这信号你就别想了。

所以刹车信号直接放弃了,UDS也只有刹车踏板是否踩下的bool值。刹车开度信号就别想了。

五、轮询通道

VhalSignalReader走的是车机自带的/vendor/bin/vehicle-hal-tool -S,通过一个长期存活的长效sh进程批量读取。每个命令后面带一个时间戳哨兵###VHAL_EOF_<nanoTime>###,读到包含哨兵的行才算命令响应结束,可靠地区分命令-响应边界。shell出现Broken Pipe后自动销毁重启,下次调用重建。

六、AdaptAPI

还有一部分信号走的是ECarX AdaptAPI的ICarFunction,不是CarSignalManager的getter,例如驾驶模式,仪表车速,真实车速等:

以驾驶模式为例:

val rawValue = iCarFunction.getFunctionValue(570491136)

raw值570491137~570491200对应14种模式(ECO/COMFORT/SPORT/EV/HYBRID/POWER/SNOW/MUD/ROCK/SAND/OFF-ROAD/TRACK/ADAPTIVE/CUSTOM),比标准AAOS的4种多得多。255表示数据不可用,兜底到COMFORT。TRACK模式映射成内部的SPORT_PLUS处理。

还有一个坑:SecurityException之后要完全停止轮询不重试——车机不给权限的时候再试也是白试,浪费性能不说,日志也白打了。

七、写在最后

VHAL/AdaptAPI的坑基本就这些:刹车永远读不到,某些数据源有无效值。

最后还是得致敬领克大人开 (偷)(懒) ,让我们能自己定制车机和获取车机的文档。


《LynkCoTrack 开发系列》导航