行走在Android底层开发这条路上的朋友,大概率都绕不过一个坎:GPS定位。尤其是做车载、工控、物联网设备的,更是天天和它打交道。最近刚好帮一个项目组把U-blox模块从Linux环境整到Android 9平台上,整个过程的“酸爽”程度远超预期,也把GPS HAL层那套设计思想整个翻了个底朝天。这篇就结合这次实践,把Android GPS HAL层的架构和U-blox模块驱动移植的全过程、踩过的坑、还有那些文档里不会明说的细节,一次讲明白。
这个内容适合正在做Android系统定制、车载方案、或者准备把外部GPS模块接入到现有安卓设备的工程师参考。如果你只是写应用层代码,通过LocationManager拿坐标,那这篇可能相对底层,但理解了HAL层之后,你对“定位”这件事的认知会完全不同——很多应用层诡异的上报问题,根源其实都在底层HAL的物理层实现上。
1. 内容整体设计与思路拆解
1.1 为什么Android要专门搞一个GPS HAL层
在讲移植之前,得先把HAL层这件事的来龙去脉说清楚,否则后面看代码会一头雾水。Android的系统架构是分层设计的,应用层通过LocationManager拿定位结果,这个Manager会走到系统服务层的LocationManagerService,再往下一层,就是真正的硬件抽象层——HAL。
HAL层的存在,本质上是Google为了“锁死”硬件的差异性。GPS芯片厂商太多了,博通、高通、MTK、U-blox、中科微、华大北斗……每个厂商的寄存器、驱动、协议栈都不一样。如果让系统服务直接面对这些千奇百怪的硬件,整个框架代码就没办法维护了。所以Google定义了一套统一的HAL接口,写死了GPS模块应该提供哪些能力、怎么把位置数据上报给上层。硬件厂商只需要按照这套接口实现自己的HAL库,上层代码完全不用关心你用的是哪家芯片。
换句话说,HAL就是“把不同GPS硬件翻译成Android系统能听懂的统一语言”的那一层翻译官。
1.2 U-blox模块接入的方案选型分析
U-blox是瑞士的定位模块厂商,在车载、工业、测绘领域名声很响。我们这次用的是NEO-M8N,这是一颗成熟的多星座模块,支持GPS、GLONASS、北斗、Galileo/QZSS,内部集成LNA和SAW滤波器,抗干扰能力在同级别里数一数二。
选定U-blox之后,接入Android有两条路线可以走:
第一条路,直接用内核的GPS驱动框架。这种方式需要写一个内核态驱动,把模块通过某个总线(一般是UART或I2C)接入系统,然后在驱动里负责往module发命令、读NMEA数据,再把数据通过一个字符设备节点暴露给用户空间。HAL层通过打开这个设备节点来拿到数据。
第二条路,HAL层直接操作串口。也就是HAL库内部通过open("/dev/ttyHSL1")之类的接口来打开UART节点,自己管理波特率、流控、数据解析。模块接入不需要专门写内核驱动,只要内核里对应串口驱动能工作就行。
我们最终选了第二条路。原因是U-blox模块的输出是标准的NMEA 0183协议,本身是一个纯异步的“数据吐出来就完事儿”的设备,不需要内核驱动的复杂管理逻辑。而且内核串口驱动是现成的,稳定可靠,HAL层直接读串口反而是最干净、最可控的方案。第一条路看着正规,但实际上把很多本可以在用户空间灵活处理的事情,都压到了内核态,出了问题调试起来非常痛苦。
1.3 HAL层设计目标的隐藏考点
Android GPS HAL的设计有一个很容易被忽略的核心目标:上层系统对底层硬件的“可控性”。定位开关是不是开了、是不是在获取位置、AGPS辅助数据要不要下发、NMEA原数据要不要透传,这些能力全部被抽象成了GpsInterface结构体里的函数指针。HAL层的实现者,本质上就是在把这些函数指针一一落实到位。
还有一个不太明显但极其重要的点:HAL层必须处理“冷启动”和“热启动”的差异。上层框架在发起定位请求时会传入期望的定位模式——GPS_POSITION_MODE_STANDALONE、MS_BASED还是MS_ASSISTED。这三种模式决定了模块是否需要网络辅助数据。移植的时候如果忽略了这个模式转换逻辑,往往会出现“单独GPS定位没问题,但一发AGPS就死机”这种怪现象。
2. 核心细节解析与实操要点
2.1 GPS HAL层关键结构体拆解
正式进入代码之前,先带大家把HAL层的关键数据结构熟悉一遍。这些定义在hardware/libhardware/include/hardware/gps.h这个头文件里,是整个GPS HAL的“宪法”。
第一个是GpsInterface。这个结构体就是上层和HAL沟通的总接口,里面的函数指针包括:init(初始化)、start(开始定位)、stop(停止定位)、inject_time(注入时间)、inject_location(注入位置)、delete_aiding_data(删除辅助数据)、set_position_mode(设定定位模式)、get_extension(获取扩展接口)等。
第二个是GpsCallbacks。这个结构体是HAL向上层回调消息用的,包括location_cb(位置上报)、status_cb(状态变化)、sv_status_cb(卫星状态)、nmea_cb(NMEA原始数据)、set_capabilities_cb(能力上报)、acquire_wakelock_cb / release_wakelock_cb(电源管理)、request_utc_time_cb(请求UTC时间)等。这里要注意,HAL层拿到位置数据后,不是自己决定“该不该上报”,而是必须严格按照上层设置的回调时机来,这里面有一个“黑盒窗口期”的概念,等下在代码里再说。
第三个是GpsLocation。这是最核心的数据结构,字段包括size、flags、latitude、longitude、altitude、speed、bearing、accuracy、timestamp。移植中最容易忽略的坑就是那个flags字段——你上报的数据里哪些字段是有效的,全靠flags里的位标记。很多第三方库从HAL层直接拿NMEA解析,然后自己填充这个结构体,但忘了对flags做正确的按位或(|=)操作,上层就会把所有传感器数据当成无效值,表现为“一直在定位中但永远不出结果”。
2.2 串口通信和NMEA协议解码的硬核细节
U-blox模块通过UART输出NMEA语句,常见的有GGA(定位数据)、RMC(推荐最小定位信息)、GSA(精度因子与卫星状态)、GSV(可见卫星信息)等。这些语句都是ASCII码,以$开头,以\r\n结尾,每条语句内部以逗号分隔字段,最后带一个异或校验。
一个简洁高效的做法是,在HAL层开一个专门的读取线程,阻塞式地读串口,把读到的每一字节按状态机的方式拼接成一条完整的NMEA语句,然后按$开头、行尾\n结束的判断逻辑来“切包”。切出来的包再做校验和验证,通过之后分发给对应的解析函数。
这里非常关键的实操细节是:读串口时,不要反复调用一次只读一个字节的read,因为系统调用开销很大。我们实测下来,在115200波特率下,如果一字节一读,CPU占用率能飙到15%以上,整个系统的流畅度会受影响。正确做法是一次尽量多读,读到一个缓冲区里,然后在缓冲区里做状态机切割。另外,NMEA数据是分帧的,串口驱动底层有FIFO,如果一次性读出来的数据里包含几条不完整的帧,必须先存到“半包缓冲区”里等下一批数据到来再拼接,不能直接把不完整的包丢掉。
NMEA解析上还有一个容易栽跟头的地方:字段位置偏移。比如GGA语句,经度位于第5个字段(index=4),纬度位于第3个字段(index=2),而RMC语句里纬度是第4个字段。而且经纬度格式是“ddmm.mmmm”——度分格式,需要转换成“度.小数点度”的浮点数。作者见过不少移植代码,因为解析时index数组搞错了,或者忘记了度分转换,结果定位到的经纬度和真实位置之间隔了一个太平洋。
2.3 定位模式转换与AGPS辅助数据注入
上层框架会通过set_position_mode接口设定定位模式,U-blox模块要根据这个设定决定使用哪种星历辅助方式。纯standalone模式下,模块直接冷启动,靠卫星广播的星历信息自行定位,冷启动时间通常在26秒到60秒之间;而MS_ASSISTED模式下,可以依赖注入的AGPS辅助数据,把定位时间压缩到10秒以内甚至3秒。
HAL层拿到辅助数据(主要就是XTRA数据包和时历数据)之后,需要把它转成U-blox模块能认的格式,然后通过串口写入。这里就涉及到U-blox私有协议——UBX帧格式。UBX帧的帧头是0xB5 0x62,后跟类ID、消息ID、长度、载荷、校验字段。我们需要构造AID-ALPSRV(0x0B 0x31)等消息来注入XTRA数据。
这个过程的调试难度相当大,因为UBX私有协议不像NMEA那么直观可见。我的经验是,在调试阶段打开U-blox模块的串口echo,或者用逻辑分析仪直接抓UART波形,对比注入的数据和模块返回的ACK/NACK,确认模块到底有没有接受这批数据。很多时候XTRA注入失败不是数据不对,而是时序问题——模块还在初始化阶段,串口还没进命令模式,写入的数据全被当成垃圾丢掉了。这个问题的规避办法,就是挂起写入线程,等模块的UBX-NAV-STATUS返回“ready”之后再注入。
3. 实操过程与核心环节实现
3.1 环境准备和系统版本选型
我们这次整机平台用的是某款车规级开发板,系统刷的是安卓9。选安卓9的一个重要原因,是这个版本的GPS HAL接口相对成熟且文档齐全,既有传统的GpsInterface,也引入了支持“融合定位”的扩展机制;而且很多车机方案商在这个版本的适配案例最多,踩坑资料也相对丰富。
编译环境建议直接用AOSP官方推荐的Ubuntu 18.04 LTS,openjdk-8版本要严格对齐。如果试图在Ubuntu 20.04上硬编安卓9,会撞上一堆老旧的Python脚本兼容性问题。
准备动作还有点空间:把串口设备节点固定下来。U-blox模块通过UART3接口接入,内核默认生成的设备节点可能是/dev/ttyS2或者/dev/ttyHSL1,建议通过udev规则或者直接修改内核dts,把节点稳定成/dev/ttyGPS,避免系统重启后节点漂移。这一步虽然小,但特别关键——HAL层代码里写死了打开路径,如果节点漂移了,定位功能会神秘失效。
3.2 硬件连接与串口参数核对
U-blox NEO-M8N的硬件连接不算复杂:VCC接3.3V电源,GND接地,TX/RX交叉连到主控的UART RX/TX,还有PPS(TimePulse)引脚接到一个GPIO,用于输出秒脉冲信号,这在车载方案里对接CAN时间同步很有用。
串口参数上,U-blox模块默认波特率是9600。这个波特率用来跑NMEA输出没有问题,但如果要往模块里注入XTRA等大块数据,9600的速率就太磨人了。我们的做法是:上电后先以9600波特率连接,通过UBX协议把波特率切到115200,然后重新打开串口,以115200的速率继续交互。切换波特率有一个细节:模块在收到切换命令之后,会立刻切换自身串口速率,所以必须在发送切换命令后,间隔一小段延时(至少100ms),再关闭旧串口、重新打开新串口,否则会出现波特率错位导致的乱码。
3.3 HAL层框架搭建与核心代码实现
在hardware/目录下新建一个gps/目录,里面放我们的HAL实现。主要工作有四项。
第一,实现hw_module_t和hw_device_t的接口,这是Android HAL的通用模板,负责模块加载和打开设备。核心就是实现open函数,在里面做全局状态初始化。
第二,实现GpsInterface的全部函数指针。这里最繁琐的是init函数,要完成串口打开、启动读取线程、初始化回调、上报能力集。open函数原型大致长这样:
static const HwModuleMethods gps_module_methods = { .open = gps_open }; static int gps_open(const hw_module_t* module, const char* name, hw_device_t** device) { gps_device_t* dev = (gps_device_t*)calloc(1, sizeof(gps_device_t)); dev->common.tag = HARDWARE_DEVICE_TAG; dev->common.version = 1; dev->common.module = (hw_module_t*)module; dev->common.close = gps_close; dev->get_gps_interface = gps_get_gps_interface; *device = (hw_device_t*)dev; return 0; }第三,写串口读取线程,不断read串口数据,拼接NMEA语句,解析后填充GpsLocation,再通过回调上报。
串口读取的核心循环示例:
static void* gps_reader_thread(void* arg) { char buf[512]; int nread; m_serial_fd = open("/dev/ttyGPS", O_RDWR | O_NOCTTY); if (m_serial_fd < 0) { ALOGE("open /dev/ttyGPS failed: %s", strerror(errno)); return NULL; } /* 配置串口为115200, 8N1, 原始模式 */ struct termios tc; tcgetattr(m_serial_fd, &tc); cfmakeraw(&tc); cfsetispeed(&tc, B115200); cfsetospeed(&tc, B115200); tc.c_cflag |= CLOCAL | CREAD; tcsetattr(m_serial_fd, TCSANOW, &tc); tcflush(m_serial_fd, TCIOFLUSH); while (!m_thread_exit) { nread = read(m_serial_fd, buf, sizeof(buf)); if (nread > 0) { nmea_parser_append(buf, nread); } else if (nread < 0) { ALOGE("read serial error: %s", strerror(errno)); usleep(100 * 1000); } } return NULL; }第四,NMEA解析器。按行切包、校验、分发到具体语句处理函数,在GGA和RMC里提取经纬度、时间、速度、航向、精度因子等字段填充GpsLocation结构。
一个最关键的细节:上报的GpsLocation中,timestamp字段必须用NMEA语句里的UTC时间(GGA/RMC语句里的时间字段),结合当前日期合成,而不是直接用gettimeofday()的系统时间。原因在于,上层定位引擎在计算位置时要对卫星时间和本地时间做比对,如果拿了系统本地时间,在时区偏移或者系统时间未同步的场景下,时间基准会完全错乱。实测中,这个问题会导致整个定位进程内部的时间线倒退,进而出现“定位结果坐标正确但时间标签是1970年”这种可怕的现象。
3.4 Android.bp文件与编译集成
安卓9开始,HAL层构建系统已经从Android.mk迁移到Android.bp。我们需要的构建文件大致长这样:
cc_library_shared { name: "android.hardware.gps.ublox", vendor: true, relative_install_path: "hw", srcs: [ "gps_hal.c", "nmea_parser.c", "ubx_parser.c", ], shared_libs: [ "liblog", "libcutils", "libhardware", "libc", ], cflags: [ "-Wall", "-Werror", "-Wno-unused-variable", ], }这里有一个需要注意的地方:vendor: true这个属性决定了库文件被编译进vendor分区还是system分区。在安卓9的系统架构里,system分区和vendor分区的HAL实现是硬隔离的,如果vendor里要用到某个库,而你这个库被编进了system,运行时就会报“library not found”。很多新手第一次编Android.bp都会栽在这个问题上。
编译完成后会生成android.hardware.gps.ublox.so,CPU架构对应的路径在out/target/product/xxx/vendor/lib/hw/下。这是个32位的库,如果是64位系统,还会有一个单独的vendor/lib64/hw目录。不同位数的库不能混用,一旦装错,HAL加载失败,log里会直接显示“cannot locate symbol”。
3.5 GPS配置文件的正确写法
Android的GPS HAL有两份关键配置文件,一份是gps.conf,一份是gpsdebug.conf(可选)。
gps.conf放在/system/etc/目录下,里面的核心字段有:
NTP_SERVER=cn.pool.ntp.org——NTP时间服务器地址,AGPS定位时非常重要的时间来源,建议配置多个备选地址,用空格分隔。XTRA_SERVER_1=http://xtra1.gpsonextra.net/xtra.bin——XTRA辅助数据下载地址,这个服务器地址要保证设备能稳定访问。SUPL_HOST=supl.google.com和SUPL_PORT=7276——SUPL服务配置,MS_BASED辅助定位时用得到。DEFAULT_AGPS_ENABLE=TRUE——默认开启AGPS。
gpsdebug.conf则用来控制调试信息输出,里面有个很实用的配置项NMEA_LOG_LEVEL,设为2后,HAL层接收和发送的NMEA语句都会打到logcat,这在现场排查“模块到底有没有输出数据”时是救命稻草。
但要注意,gps.conf里配置的SUPL_HOST如果不可达,会严重影响MS_BASED模式下的定位性能。如果要解决的问题是纯本地场景,建议直接把DEFAULT_AGPS_ENABLE设成FALSE,让模块走纯粹的自主定位,避免SUPL连接超时拖垮整个定位线程。
4. 常见问题与排查技巧实录
4.1 HAL模块无法加载或无法打开
这个问题的典型表现是:系统起来后,设置里“位置信息”开关点击无反应,logcat里出现failed to open gps module字样。
排查顺序一般是:
- 确认so文件路径正确,通常在/vendor/lib/hw/或/vendor/lib64/hw/下,32位和64位架构要分开放。
- 确认so的符号表完整,用
readelf -d检查是不是缺少依赖库。 - 确认system分区和vendor分区的SELinux上下文正确。安卓9开启了强制SELinux,HAL层so必须标注为
hwservicemanager可访问的类型。如果在logcat里看到avc denied的记录,八成就是SELinux的问题,需要调整file_contexts和sepolicy。
头两样都正常的话,再检查/system/etc/gps.conf是否存在,缺失这个文件会导致HAL初始化直接abort。
4.2 定位始终无结果,但模块有输出
模块上电能看到其电源LED闪烁,串口也捕获到了NMEA数据,但系统一直显示“正在定位”。这种问题十有八九出在NMEA解析或者GpsLocation的flags处理上。
逐条排查:
- 直接抓串口log看原始NMEA,确认模块到底有没有输出GGA语句。很多车载环境因为天线供电问题,模块能输出数据但看不到卫星(能看到数据里的卫星数量一直是0)。
- 解析日志里有没有打印“checksum error”——如果大量校验失败,八成是波特率不匹配,或者读串口时丢字节。
- 最关键的一招:人为在解析器里加一个临时的原始NMEA透传,把收到的句子原封不动打出来。拿原始语句和解析结果做对比,立刻能看到经纬度字段是不是因为度分转换出错而偏到一个诡异位置。
4.3 搜星慢、定位漂移问题
搜星慢分两种情况。一种是模块天线的灵敏度问题。U-blox模块对天线要求比较高,有源的陶瓷天线供电电压、驻波比不达标的话,会大大降低C/N0值。作者见过不少项目在天线上省钱,结果卫星信号强度差得离谱,在开阔地也只能看到一两颗星。还有一点是U-blox的GPS陶瓷片设计注意事项:天线的匹配电路要严格参考规格书的Layout建议,馈线要尽量短,远离高速数字信号线和高频时钟源,否则干扰会直接“吞噬”掉GPS信号。
另一种情况是AGPS数据失效。辅助数据一般有有效期,Linux项目里,如果一直部署在同一片区域但使用外国的NTP服务器,会导致星历数据时延异常,进而影响卫星搜索阶段的工作效率。解决办法是把gps.conf里的NTP服务地址改成国内源,并且打开XTRA自动下载,保证每次开机时星历数据都是新的。
定位漂移问题则要重点检查HAL层上报速度。如果GpsLocation里的accuracy字段不可信,上层导航算法会把一个可信度很低的坐标当成高精度坐标来用,造成路径诡异跳变。NEO-M8N同时可以输出定位质量指示,在GGA语句的第6个字段是定位质量:0代表无定位,1代表单点定位,2代表差分定位。如果质量从1跳到0再跳回1,那说明信号环境不好,不等上层发疯,HAL层就应该先抑制无效定位结果的上报。
4.4 AGPS定位一直失败,查看日志全是timeout
这个问题在移植U-blox的时候很典型。U-blox的AGPS辅助数据格式和很多手机芯片不一样,不能直接把通用格式的XTRA数据喂给它,需要进行一次内部适配。排查思路是:抓协议log确认上层下发inject_time和inject_xtra的顺序对不对,U-blox对这两类数据有明确的先后依赖,时间不对会导致模块等待辅助数据超时。
另一个非常隐蔽的问题是:HAL库与U-blox模块的UBX固件版本兼容性。U-blox每隔一段时间会更新模块固件,新固件可能调整了UBX消息的协议细节,如果HAL里写死的消息结构和固件版本不匹配,会出现“命令发出去了,模块也不报错,但就是没有效果”的假象。解决办法是,在初始化流程里主动查询模块固件版本(MON-VER消息),打印到日志中,并记录到版本兼容表里。
4.5 老代码在android 9机型上root后的怪异表现
还有一个和系统版本相关的点:很多玩机用户会在安卓9设备上开放root权限来调试,但root环境下SELinux默认permissive,反而让HAL层的很多权限问题被掩盖了。如果HAL代码依赖root权限去open某些受保护的节点,那么在正常的系统启动流程里打开就会失败。移植时一定不要以root状态下的行为作为基准,要从SELinux enforcing的第一性原理出发,逐步放权。
5. 调试工具链与性能优化技巧
5.1 串口击杀工具:逻辑分析仪和虚拟串口
在HAL层开发中,最爽的调试配合是:把模块的TX引脚同时接到两个地方——主控的UART RX引脚和一个USB转TTL调试口。这样做的效果是,在不干扰主控数据流的前提下,能实时在电脑上抓取模块吐出来的原始NMEA数据和UBX帧,直接比对HAL层解析是否正确。
实际操作中,还有一种很省事的方式:先用一个U-blox官方提供的Windows工具(u-center软件)在电脑上把模块的各种输出配置好,再用一个串口转发器使HAL层命令与u-center的实时画面形成对照。尤其是调XTRA注入时序的时候,u-center的Packets视图能直接显示模块收到的UBX消息内容,一眼就能看出HAL注入的数据是否格式正确。
5.2 CPU和内存占用优化经验
整个HAL层常驻一个读线程,同时也可能在注入辅助数据时临时开一个写线程。读线程的调度优化主要靠减少锁竞争:串口数据进到缓冲区后,立即把解析任务交给一个无锁队列,由另一个解析线程消费。这样做的好处是,读线程永远不会因为解析耗时而被阻塞,模块UART FIFO不会溢出丢数据。
还有一点容易被忽略:解析线程的优先级需要适当调低,避免和高精度传感器、显示线程抢占CPU。GPS数据每秒最多5~10帧,每帧解析耗时也就几微秒,完全不需要RT优先级。调得太高反而会引入其他硬件驱动的调度延迟,造成系统卡顿。
5.3 定位精度提升的工程实践
如果项目对定位精度有较高要求,可以在HAL里加一个扩展功能:从U-blox模块读取RTCM差分修正数据。车载和测绘场景里,通过基站或网络获取RTCM数据,再注入模块,模块就能输出厘米级精度的定位结果。U-blox的UBX协议里,差分数据走的是一套独立通道,和NMEA输出互不干扰。
这个扩展的实现思路是:HAL层增加一个RTCM输入接口,上层通过网络从CORS站源获取RTCM数据,通过HAL写入模块。写入速度要控制在模块能接受的范围内,通常每秒钟不能超过模块的差分处理能力,否则模块缓冲溢出会直接丢弃数据。
从那以后,作者在给U-blox类模块做安卓适配时,都会在设计阶段就把差分接口预留出来,哪怕首版用不到,也方便后续项目直接启用。
6. 移植完成后的全流程验证清单
移植工作做完不代表结束,真正的痛苦是验证阶段。根据作者的惨痛经历,以下验证清单每一项都值得认真跑一遍:
6.1 基础功能验证
- 冷启动首次定位时间:室外开阔环境下,standalone模式应小于40秒。如果远超这个数值,说明天线或模块配置有问题。
- 热启动定位时间:关机重启后马上再定位,应小于5秒。
- AGPS辅助定位:下发XTRA数据后,冷启动应缩短到10秒以内。
- 定位精度:静态环境下,CEP 50%误差一般应小于2.5米。
6.2 系统集成验证
- 飞行模式下能不能定位:飞行模式关闭了蜂窝网络,但GPS定位应该正常工作,这说明HAL不依赖网络辅助数据。
- 息屏和休眠状态下能不能保持定位:很多项目在平板息屏后,CPU进入低功耗模式,串口驱动被挂起,GPS数据就不来了。这个问题必须在HAL层通过wakelock和串口唤醒机制来解决。
- 长时间运行的稳定性:连续跑12小时以上,看内存有没有增长、文件描述符有没有泄漏、定位结果有没有掉线。
这些测试项如果全部通过了,移植工作才算真正完成。至少在某一种情况下出问题,都要回头审视HAL的实现细节——因为GPS HAL层是直接面对物理硬件的,任何一点不严谨,都会在真实环境里被无情放大。
根据自己的实际经验,最后再分享一个小细节:在串口读取循环里,每次read之后做一个极短的精简判断,如果连续读到一堆无效字符(比如全FF),立刻执行tcflush清空串口FIFO。这个操作看起来暴力,但实际在调试那些“模块偶尔死机、输出乱码”的场景里救了无数次命。无论是U-blox还是其他模块,串口永远是嵌入式工程师最忠实也最狡诈的朋友——搞定了串口,GPS HAL的移植就成功了一半。