1. 项目背景:为什么高通要重构sensor架构
1.1 老架构留下的那笔烂账
做高通平台传感器开发的工程师,对老一套Sensor Subsystem(也就是大家嘴里常说的SSP)应该都不陌生。早期平台在AP侧跑一个Sensor HAL,往上对接Android框架,往下通过I2C/SPI直接挂在AP的某个控制器上。这种方案最大的问题在于:传感器一旦频繁唤醒AP,功耗就很难看,而且每个Sensor的适配代码都散落在各个OEM的vendor目录里,高通想统一维护都无从下手。
后来高通引入了SLPI,也就是Sensor Low Power Island,把传感器数据采集、算法处理、事件仲裁从AP侧搬到了低功耗DSP核上,host这边只保留了一个很薄的HAL。思路本身是好的,但问题在于软件架构一直没有彻底收敛。老的QXDM里翻出来的日志五花八门,sns_dcm、sns_dd、sns_em、sns_rh,不同版本模块名字都对不上。尤其到了Android 9以后,Google提高了对sensor suspend/resume、batch、latency的验收标准,老架构里的状态机开始显得僵硬,改一个地方崩三个地方,这是很多一线工程师共同的痛。
1.2 SEE架构要解决的核心矛盾
SEE的全称是Sensor Execution Environment,简单说,高通把原来分散在SLPI上的传感器框架做了一次彻底重构,目标是把“传感器运行环境”做成一个统一、长时驻留、可动态加载、用标准消息交互的软件平台。这套新架构在New Gen平台上已经全面铺开,和老的QXDM日志体系、老的sensor1库不兼容,所以初次接触的人会极其痛苦。
SEE要解决的核心矛盾其实是三个:
- 一致性。不管是加速度计、陀螺仪、光感、接近,还是SAR、UWB、指纹、姿态算法,大家统一走同一套框架,不再为每个sensor单独造轮子。
- 功耗。SLPI本身是一个低功耗岛,SEE把整个传感器数据链路固定在这个岛内,host可以长时间处于suspend状态,只有在真正需要上报的时候才通过QMI/Wakeup机制唤醒AP。
- 可升级性。SEE引入模块化加载机制,sensor固件模块可以被单独增删、替换,不需要因为加一个传感器就重编整个DSP镜像。
如果你是从老SSP迁移过来的,最直观的感受就是:以前你在sns_dd_xxx.c里写一堆寄存器操作,现在你开始跟sns_client、sns_sensor_uid、QMI消息打交道,代码风格完全不一样了。
1.3 谁需要吃透这套架构
我觉得有三类人必须把SEE彻底搞明白。
第一类是手机或者IoT平台上的传感器驱动工程师,你想让一个新sensor在板子上跑起来,如果不懂SEE的注册、调度、上报链路,连第一个数据你都别想看到。第二类是算法移植工程师,很多第三方算法,比如计步、抬手亮屏、室内定位,都是作为SEE里的一个sensor算法模块来跑的,你的算法包要能适配SEE的sensor框架,而不是像以前那样随便开个线程轮询。第三类是调试功耗和稳定性的系统工程师,很多奇怪的问题,比如休眠之后数据不来、sensornode反复重启、AP偶发被异常唤醒,根源就在SEE框架内部的QMI消息或者Sensor调度器的状态流转上,没有架构视野很难定位。
这篇文章我就从架构、模块、实操、踩坑四个维度,把SEE这套东西完整梳理一遍。下面涉及的代码和日志格式都是我在实际调试中常用的,大家可以对照自己手里的工程来理解。
2. SEE整体架构与技术选型
2.1 从硬件与系统域的视角看SEE落点
先摆一张全局图在脑子里。整个SoC从功能上分成几个域:APSS(application processor subsystem,也就是跑Linux/Hypervisor的主核域)、ADSP(audio DSP,跑音频和部分低功耗业务)、SLPI(专门跑传感器的低功耗DSP域),以及CDSP、MDSP等等。
SEE这套传感器框架最核心的承载者就是SLPI。SLPI上面跑的其实也是一个小的RTOS环境(高通把这一整套固件运行时称为Qurt或类似RTOS的轻量内核),SEE就是以这个环境为底座形成的传感器中间件层。AP侧运行Linux,通过高通自有协议栈和SLPI通信,通信物理通道通常是共享内存+中断,逻辑通道是QMI(Qualcomm Messaging Interface)。
通信链路大致是:Linux Sensor HAL —— sensor1库 —— QMI —— SLPI侧的QMI服务 —— SEE核心服务 —— 具体sensor模块 —— 物理sensor芯片。这里不要把QMI想象成一个复杂的网络协议,它本质是一套基于IDL定义的消息协议,有request、response、indication三种消息类型,编码格式类似TLV。SEE内部很多模块之间也直接复用QMI这种消息约定,所以你在SLPI日志里看到“QMI”这个词的频率会非常高。
2.2 为什么选SLPI而不是AP侧做SensorHub
可能有人会问,为什么不直接用SoC上的某个Cortex-M核跑SensorHub,比如很多MCU方案那样?高通的做法是用SLPI这个自研DSP核,而不是一颗独立的MCU。这么做有几个原因。
一是和音频共用一套调试和加载体系,产线校准、固件签名、镜像升级路径都是现成的。二是DSP的功耗比主核低很多,同时这类核没有Linux那样的调度开销,对实时中断的响应更稳定。三是高通可以统一在固件里内置自研算法,比如传感器校准、融合、SAR检测,OEM不用每次都在AP侧的APK或者库里去实现。
当然这个选择也带来代价。DSP的调试手段比Linux少很多,你不能像在AP侧一样gdb打断点,只能靠日志和内存dump,再加上很多SLPI固件是不开源的,开发时拿到的是预编译的库,遇到问题往往只能看函数名字符串来猜。这也是很多人觉得SEE难啃的原因:边界模糊、抽象太厚、日志又晦涩。
2.3 SEE模块化设计的大体脉络
SEE内部模块可以粗略分为三层。
底层是运行环境与抽象层,包括内存管理、定时器、消息队列、物理总线抽象(I2C/SPI/UART)、电源管理、QMI收发。中间层是核心服务层,包括传感器注册中心(registry)、请求处理(request handler)、事件分发(event distributor)、调度器(scheduler)、保活与重启机制。上面是业务模块层,也就是一个个具体的sensor算法包和物理sensor驱动,比如sns_accel、sns_gyro、sns_proximity、sns_geomag、sns_rotation_vector等。
这些模块在SLPI固件里以“sensor library”的形式存在,编译后可以链接进固件,也可以按需求裁剪。AP侧的程序不直接和某个sensor library通信,而是统一通过sns_client的接口来发起一个sensor请求,SEE框架根据请求里携带的sensor UID,路由到对应的sensor模块,再把结果通过事件流回传给client。这个设计思路很像Linux里的v4l2框架或者input子系统:上层不关心具体硬件,只认一个抽象的设备节点。
3. 关键模块拆解与实现细节
3.1 sns_client:应用侧和传感器框架间的第一道门
如果你拿到一个高通新平台的sensor代码包,你会发现SDK里必定有一个叫sns_client的组件。这个名字在SLPI侧和AP侧都有,但含义略有不同。AP侧的sns_client库为上层Sensor HAL提供了一组C API,用于发起校准、订阅数据、设置采样率和batch参数。SLPI侧则是一个核心服务,维护着所有连接的client会话、请求状态和消息路由。
实际开发里接触最多的几个函数是sns_client_send_req、sns_client_register、sns_client_get_event。我很早的时候踩过一个坑:在上层频繁调用send_req发送请求,但没有妥善处理response,导致client会话数量增长,SLPI侧内存吃紧。后来还用了一个很朴素的排查方法:在HAL里打日志看每次send_req返回的err code,配合SLPI日志里的sns_client_session信息,才定位到是请求响应超时导致的会话堆积。
API层面有个概念叫“request ID”,每次请求都带一个唯一编号,响应里会把这个编号原样带回来。调试的时候有点类似给每一个消息做“追踪号”,日志里看到req_id对不上,基本可以断定是数据竞争或者乱序了。
3.2 QMI协议栈:AP与SLPI之间的消息总线
QMI协议栈是SEE架构里的“神经系统”。它分两层概念:一层是传输层,负责将消息打包成QMI frame,经过共享内存送到对端;另一层是服务层,具体定义消息ID、TLV结构和错误码。
在代码里你会看到sns_qmi这个模块。它维护了一组服务端接口和一个消息分发表。比如ApToSensor、SensorToAp两类消息,在SDK里用一组宏定义好的枚举值来表示。SLPI侧收到消息后,会先根据service ID找到对应的处理函数,再解析TLV里的参数,最终转成sns_client请求。
调试QMI最常见的工具是QXDM里的QMI抓包,以及在高通平台日志中开启的qmuxd日志。很多工程师看到QMI log里一堆十六进制数组头皮发麻。我的经验是不要试图通读整个报文,只看三类字段:消息类型(0x00 request/0x01 response/0x02 indication)、事务号、主要TLV的tag。比如报错的时候看error TLV,里面高字节通常对应QMI错误码,低字节是服务自己定义的错误码,这个组合能帮你快速缩小范围。
3.3 sns_rh、sns_smgr和协议调度的分工
SLPI固件内部除了sns_client,还有两个模块你几乎避不开:sns_rh(request handler)和sns_smgr(sensor manager)。说实话这两个模块的边界不同平台版本有差异,但大的分工是:rh负责解析和管理请求,smgr负责传感器任务的调度、batch策略、上报频率控制以及电源状态管理。
从数据流的角度看,一个外部请求到达SLPI后大致经过这样的路径:QMI服务解析报文 -> 转成sns_client请求 -> sns_rh登记并校验请求参数 -> 根据sensor UID找到对应sensor模块 -> sns_smgr把该传感器加入调度列表 -> 传感器按配置采样 -> 数据经过算法处理后由sns_client主动推送到AP侧。如果AP侧设置了batch模式,SLPI会先将数据缓存在低功耗岛内部,到达batch窗口后再统一上报。这个机制对功耗友好,但也引入了“数据延迟”的问题,很多产线测试说数据卡顿,可能不是bug而是batch配置太大。
还有一个很重要的东西是sns_registry,也就是传感器注册中心。每个sensor模块在固件启动时向registry登记自己的UID、版本、能力参数。AP侧可以通过QMI查询registry来发现当前SLPI上到底有哪些sensor可用。我调试时经常遇到的情况是,代码里明明加了某个sensor,但Framework层看不到,最后发现是registry table生成脚本没更新,UID没注册进去。
3.4 Sensor HAL层:从see到Android系统之间的桥梁
Android系统侧的标准Sensor HAL接口是生成的,高通的实现其实是在这个接口下面挂了一个sensor1协议库,这个库和SLPI之间的QMI服务进行通信。整体结构大概是:Android sensor service -> Sensor HAL (libsensor) -> sensor1 library -> QMI -> SLPI。
Sensor HAL这层的核心工作,是把Android的sensor_t描述结构映射成SEE里的sensor UID。比如Android里一个“accelerometer”的type是TYPE_ACCELEROMETER,vendor字段是“qti”,HAL代码就会在初始化时遍历registry,找到UID是sns_accel的模块,然后匹配handle。这里容易出现handle错乱,特别是同一颗sensor芯片同时提供给多个Android sensor使用的时候,比如旋转向量和加速度计、陀螺仪都来自同一个组合芯片,HAL里的handle映射必须一一对应。
另外,Sensor HAL处理上报事件时,需要按照Android sensor事件规范填充timestamp、accuracy、data数组。SEE里事件的时间戳通常来自SLPI侧的高精度计时器,HAL拿到后一般会做一次时间同步补偿(有些平台是通过和AP侧的系统时间做线性回归),保证event的时间戳和Android系统内部的其他事件在同一个时间轴上。这就是为什么FlightMode或者休眠唤醒后时间戳偶尔会跳变,多数是时间同步补偿没做好,而不是传感器本身数据有问题。
4. 实操:在SEE上新增并调试一个传感器
4.1 第一步:给传感器分配UID并完成注册
在SEE架构里,一切sensor的身份都由sensor_uid来定义。这个UID不是简单的字符串,通常是一个64位或更长的数据结构,类型包含sensor的用途、实现方式、实例编号等信息。新增一个sensor的时候,第一步就是为它在公共头文件里定义UID宏。
以代码片段为例,通常在sns_sensor_uid.h里会有类似这样的定义:
#define SNS_ACCEL_UID \ { 0x0b, 0x03, SNS_SENSOR_STRING_TYPE, SNS_STYPE_ACCEL, \ SNS_SENSOR_IMP_TYPE_SEE, 0x00, 0x00, 0x00, 0x00 }这不是完整的真实内容,但结构就是那几段:字符串标识、sensor type、实现类型、实例号。UID的意义在于,当sensor请求到达SEE时,框架会以UID作为key在注册表里查找对应的sensor模块。如果你定义了一个new sensor,却没有加入注册表,就算模块代码写好了,框架也永远找不到它。
注册流程一般是在sns_registry相关的配置文件中,把UID填充到一个静态表,随后生成registry blob。很多新人改完代码发现传感器枚举不到,都会反复检查模块代码,其实先看一眼生成的registry文件里有没有把UID编进去,往往能省半天时间。
4.2 第二步:实现sensor模块的核心回调
每个sensor模块本质上就是一组接口约定的实现。SEE的模块会被编译成一个库,SLPI启动后由框架动态加载。模块需要实现四个最基本的能力:init、deinit、process_req、handle_event。
我在实现一个光感sensor时,一般是这么组织的:
static sns_rc sns_lightsensor_init(sns_sensor *sensor) { // 分配传感器实例数据,初始化I2C句柄,读取校准参数 } static sns_rc sns_lightsensor_process_req(sns_sensor *sensor, sns_request *req) { // 根据req里的uid和config,设置采样率/增益/上报模式 } static sns_rc sns_lightsensor_handle_event(sns_sensor *sensor, sns_sensor_event *event) { // 触发一次采样,通过sns_client_handle_event把数据推给上层 }process_req是重灾区。很多新人的模块在接收到request之后,直接开了一段阻塞式的I2C读取操作,这在没做特殊处理时会把SLPI上的调度线程卡住。正确做法是,把I2C读取放到sensor自己的上下文或者异步回调里去,读取完成后再生成事件。高通内部一些sensor模块都采用软中断加状态机的写法,目的就是避免长时间占用DSP执行流。
事件上送这一环也容易踩坑。SLPI的事件消息里有sensor_uid、timestamp、payload长度、payload内容。payload内容必须和HAL层约好的格式一致,否则上层解析出来全是乱码。很多OEM加自定义sensor时,会直接在payload里塞自定义结构体,但忘了和HAL端对齐大小端和字节对齐,数据歪了还查不到。
4.3 第三步:让HAL识别并暴露到Android层
Sensor模块跑起来后,Android要能看到它,需要HAL做两件事:一是把模块UID添加进HAL的sensor list,二是为它分配一个唯一的handle。常见的做法是在sensors.conf或者HAL配置文件中定义一个虚拟sensor,指定它的type、name、vendor和对应的UID。
配置好之后,可以用adb shell dumpsys sensorservice来确认系统是否已经把传感器枚举出来。如果看不到,优先确认三个地方:SLPI日志中是否有sensor模块初始化成功的记录;registry里是否包含这个UID;HAL里的handle和UID映射是否正确。这三个点基本覆盖了90%的“上层看不到sensor”问题。
最让我意外的一次是sensor列表出现了,但数据始终为0。后来定位到是SLPI模块把采样率参数里的samples_per_batch处理反了,上层设置100Hz,实际没有触发采样周期。后来我把模块日志里每次触发采样的时间戳打出来,才看出采样频率差了一个数量级。
4.4 调试SEE的常规手段与小技巧
SEE调试比普通Linux驱动麻烦,但可用的手段也不少。
第一是SLPI侧的日志。通过在高通日志系统中打开SLPI日志透传,能将SLPI上的打印转发到AP侧logcat或者kernel log里。打开方式各平台略有差异,但常见是设置diag端口透传和开启sns_debug相关的宏。调试时在sensor模块里加SNS_PRINTF宏,输出关键状态,这是最直接的方式。
第二是QMI抓包。主要用来观察AP侧和SLPI之间的消息交互。重点看有没有频繁重传、超时、错误码。我做完一次sensor的suspend/resume测试,就会抓一段QMI log看看是否有异常indication。
第三是sensorservice的EventLog。执行adb shell dumpsys sensorservice,能列出每个client的请求参数、batch时长、上报频率。如果你发现实际上报频率和设定值差很多,多半可以反推是SLPI侧的调度配置问题。
第四是硬件侧的电平抓取。很多I2C通信不正常的问题,用逻辑分析仪抓一下SCL/SDA就能确认。比如传感器模块挂死的时候,总线可能会一直处于高或低电平,用示波器看比看日志更快。
5. 常见故障与排查实录
5.1 故障速查表
| 现象 | 可能原因 | 排查建议 |
|---|---|---|
| 系统态看不到sensor枚举 | registry表未更新/UID错误 | 先查SLPI日志和registry blob |
| sensor枚举存在但数据一直为0 | 采样率配置错误/I2C通信异常 | 抓I2C波形,打点看采样触发是否发生 |
| 数据正常但时间戳跳变 | AP与SLPI时间同步补偿未生效 | 检查HAL侧的timestamp校正逻辑 |
| 上报频率远低于设定值 | batch窗口设置过大/调度器未唤醒 | 调整samples_per_batch,检查power state |
| 休眠唤醒后数据恢复慢 | SLPI低功耗状态未正确退出 | 查看SLPI电源状态日志,检查唤醒源 |
| QMI消息大量超时重发 | sensor模块阻塞或固件崩溃重启 | 抓QMI报文,统计timeout分布 |
| HAL初始化耗时过长 | QMI服务启动慢/注册表查询慢 | 查看系统启动时间线,定位慢点 |
| sensor进程反复崩溃 | UID映射混乱或payload格式错误 | 抓crash log,确认handle与UID对应关系 |
这个表我建议直接存一份,遇到问题先按表排查。特别是时间戳和上报频率的问题,很多人一开始去查传感器芯片寄存器,最后发现是框架配置的问题,方向完全错了。
5.2 一个典型case:QMI超时导致sensor反复重启
有次在客户现场遇到一个必现问题:设备开机一段时间后,加速度计数据突然没输出,过几秒又自动恢复,反复循环。从logcat看HAL一直在发sensor请求,但没有事件回来。刚开始怀疑是sensor芯片坏了,替换后依旧复现。
我后来在SLPI日志里看到sensor模块有“request timeout”的错误,再去抓QMI log,发现AP侧发的部分请求没有response,事务号一直持续增长。整个链条分析下来,问题出在SLPI侧某个后台传感器算法模块(和姿态算法相关)在运行一段时间后申请了一块较大内存,导致同一个SLPI核上的其他传感器任务调度延迟,accel模块的事件处理超时,于是SLPI watchdog触发了sensor模块重启。
这个问题最后通过降低算法模块的内存占用、调整SLPI任务优先级解决。复盘起来,如果只看AP侧日志,永远定位不到这个根因。这就是为什么我强烈建议做传感器稳定性的同学,一定要建立“AP、QMI、SLPI”三段式日志同步抓取的习惯,缺一段都很难下结论。
5.3 另一个典型case:HAL和SLPI的UID映射错乱
定制了一款内置SAR传感器的设备后,Android层出现了两个都叫“sar”的sensor,一个能用,一个不能用。查下来发现是HAL层在枚举时,按照字符串匹配UID,把sns_sar_generic和sns_sar_new两个模块的UID混在了一起。因为两套模块名称都带“sar”,配置脚本里写错了正则匹配。
这种问题最隐蔽的地方在于,它不报错,只是把错误的sensor list暴露给上层,导致调用到错误UID的client永远拿不到数据。排查时我建议在HAL初始化时打印每个sensor的uid和handle,和SLPI注册表的uid列表做一次全量比对,不要轻易信任配置文件。
6. 踩坑之后的一些体会
自己从老SSP迁移到SEE架构后,最大感受是:这套架构其实谈不上多么“新”,它更像是一次彻底的接口标准化。传感器驱动以前是面向寄存器的,现在是面向消息和服务;以前要关心芯片怎么配置,现在更要关心UID怎么定义、请求怎么路由、数据怎么调度。本质上是把嵌入式驱动开发这件事,向“分布式中间件开发”的方向推进了一大步。
对刚接触SEE的工程师,我建议先放下具体芯片datasheet,把下面三件事搞透:一是能看着代码画出AP到SLPI的整条消息链路;二是能看懂SLPI日志里每一个和sns_client、sns_smgr相关的关键日志节点;三是遇到问题先确定卡在哪一段传输/处理环节,再动手查硬件。
调试工具方面,务必把QXDM/QMI抓包和SLPI日志透传玩熟。很多看似神秘的传感器现象,其实都是消息交互中的正常情况,只是你没看到消息。
最后分享一个小技巧:在sensor模块的process_req入口加一个时间戳打印,在HAL层的数据回调里也加一个时间戳打印,两边一对比,就能快速量化整条链路的延迟分布。这招帮我定位过好几次I2C速率问题和调度延迟问题,成本极低,收益极高。