ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Android车载开发实战:从CAN总线到UDS诊断的核心链路

Android车载开发实战:从CAN总线到UDS诊断的核心链路 做Android开发很多年第一次接触车载项目时我以为只要把车机屏幕上的App写漂亮就行直到车厂丢给我一个DBC文件和一份诊断规范我才意识到真正的门槛不在UI而在Android底层和那根CAN总线之间。CAN、SocketCAN、CAN FD、DBC、ISO-TP、UDS这六个词基本概括了Android车载开发的全部核心链路。这篇文章就以我实际做过的项目为主线把每一个环节从原理到实践完整拆一遍重点讲那些文档里不会写、真正做项目才会踩到的坑。1. 为什么Android车载开发绕不开CAN这道门槛1.1 从App层到总线层中间隔着的三座山普通Android开发者接触的是Binder、Activity、View体系但车机上的功能最终都要落到对车辆的控制和状态读取上。你不可能用Java反射直接去读车轮转速底层必须有人把CAN总线上那些带校验、带字节序的原始报文翻译成App能理解的方向盘角度“车速”“挡位”这样的业务值。这条链路由三层组成最底下是物理CAN控制器和收发器中间是内核提供的SocketCAN网络协议栈最上层才是DBC信号解析、ISO-TP传输层以及UDS诊断应用协议。任何一个环节不熟项目推进都会卡壳。1.2 一批人做这块最容易犯的错误我见过很多从纯Android转过来的同事上来就搜Android CAN开发库找到一个JNI封装包以为就能用了结果连CAN接口都起不来或者压根没有root权限访问SocketCAN。反过来从嵌入式转过来的兄弟对位时序、采样点门儿清但一碰到Android的SELinux、Binder权限模型就一脸懵。这篇笔记我尽量把两头打通。你如果是Android出身重点看SocketCAN和DBC解析这两章你如果从MCU背景切过来重点看Android权限和系统集成那部分。总之整条链路缺一环都不行但也不需要你成为某个领域的专家把链路跑通更重要。2. CAN报文与SocketCAN先把传输层吃透2.1 CAN帧结构不是八股文越早理解越少踩坑CAN总线传输的报文可以简单看成两部分ID和数据。经典CAN 2.0A标准帧的ID是11位2.0B扩展帧是29位数据场最多8字节。大多数乘用车网络用的是扩展帧因为节点数量多11位ID分配不过来而一些简单的车身控制网络可能还在用标准帧你开发前一定要从总线规范里确认清楚同一个网络上标准帧和扩展帧混跑时扩展帧的优先级判定逻辑和标准帧不同调试时特别容易迷惑。can_frame结构体是SocketCAN的核心我贴一下内核里的定义struct can_frame { canid_t can_id; /* 32 bit CAN_ID EFF/RTR/ERR flags */ union { __u8 len; __u8 can_dlc; }; __u8 __pad; __u8 __res0; __u8 __res1; __u8 data[8] __attribute__((aligned(8))); };注意can_id不是纯ID高4位被用来标记扩展帧标志、远程帧标志和错误帧标志。很多人用库收发报文时发现ID对不上多半就是这里没搞对。我们平时收发数据用的是数据帧远程帧基本遇不到错误帧则要由内核帮你统计不需要自己构造。2.2 仲裁机制为什么CAN通信里ID越小越老大CAN总线的仲裁规则用一句话说就是显性电平0会覆盖隐性电平1谁先发0谁获胜。因此标准帧和扩展帧同时抢总线时ID数值更小的帧优先发送如果ID一样数据帧的优先级高于远程帧。做项目时会发现车厂给的DBC里那些报文周期和ID分配是精心设计过的周期短的信号往往ID靠前保证紧急数据能抢占总线。这不是随便排的ID分配本身就是设计的一部分。你在自己搭测试网络时不要把周期1ms的报文放在一个很大的ID上否则高负载的时候它会一直等低优先级导致周期抖动。2.3 位时序BS1和BS2到底怎么配很多人在配置CAN控制器时会看到BS1、BS2、SJW这些参数尤其是STM32里还要自己算波特率。采样点的计算公式是采样点 (1 TSEG1) / (1 TSEG1 TSEG2)其中TSEG1和TSEG2分别是BS1和BS2的「实际时间片数」。比如BS1配置为13BS2配置为2那么采样点就是(113)/(11321)看具体硬件寄存器模型是否包含同步段。实测下来采样点建议落在75%80%之间不要低于70%。采样点太靠后遇到总线线缆较长、上升沿变缓时容易采到不稳定的电平太靠前又可能把别节点的位错误采进来导致错误帧频发。SJW同步跳转宽度这个参数我调试时很少动它默认1个时间片就行。除非你发现两个节点的时钟偏差比较大、同步跟不上才适当加大。2.4 SocketCAN上手的几个命令和API在Android里操作CAN最正统的方式就是SocketCAN它被集成在内核网络协议栈中。设备上电后通常先要用ip命令把接口配置好# 500kbps 波特率经典CAN ip link set can0 type can bitrate 500000 ip link set can0 up然后C/C这边打开一个原始套接字就能收发帧了#include linux/can.h #include linux/can/raw.h #include sys/socket.h #include net/if.h int s socket(PF_CAN, SOCK_RAW, CAN_RAW); struct ifreq ifr; strcpy(ifr.ifr_name, can0); ioctl(s, SIOCGIFINDEX, ifr); struct sockaddr_can addr; addr.can_family AF_CAN; addr.can_ifindex ifr.ifr_ifindex; bind(s, (struct sockaddr *)addr, sizeof(addr)); struct can_frame frame; frame.can_id 0x123; frame.can_dlc 8; frame.data[0] 0x01; // 填充 data[1]...data[7] write(s, frame, sizeof(frame));Java层没有直接可用的SocketCAN API项目里普遍的方案是写一个JNI桥接到这套C/C代码。后面会单独讲Android侧的权限坑这里记住一点普通第三方App默认是没有权限去操作网络接口的。3. CAN FD常见但需要单独对待的形态差异3.1 CAN FD和经典CAN的核心差别不只是更快CAN FD从2015年前后开始普及大家最直观的印象是速率快、数据长。经典CAN数据场最多8字节CAN FD最多能到64字节经典CAN整个帧都跑同一个波特率CAN FD把一帧分成两个阶段——仲裁段保持和经典CAN一致的波特率数据段可以切到高速比如2Mbps甚至5Mbps。切换靠帧头里的BRS位表示支持CAN FD的节点会自动完成切换。我在实际刷写项目里最深刻的体会是64字节数据场对提升刷写速度帮助非常大。UDS的0x36传输数据请求每次多塞几十字节整个Bootloader刷写时间能缩短一半以上。3.2 什么时候用CAN FD什么时候别用不是说所有ECU都支持CAN FD。老车型或者低端节点往往是经典CAN而网关、域控制器、新出的传感器可能已经是CAN FD。CAN FD节点向下兼容经典CAN但经典CAN节点无法理解CAN FD帧。如果你的网络里混着两类节点要把会发CAN FD报文的节点单独划到支持它的网段否则经典CAN节点会把这个帧当成错误帧导致总线错误风暴。还有一个容易踩的坑CAN FD对终端电阻和线束质量更敏感。数据段从500k跳到2M信号反射、振铃这些在经典CAN下不明显的问题在CAN FD下会直接表现为采样错误。调试时先拿示波器看眼图别一上来就怀疑代码。3.3 CAN FD对上层协议和DBC解析的影响DBC文件里报文的DLC只有8个字节到了CAN FD就成了最多64字节对应的信号定义起始位编号范围从0~63扩展到0~511解析逻辑本身不变但要注意很多老解析库不支持超长报文需要检查底层是否按实际DLC读取。ISO-TP在CAN FD上也变了一个形态单帧可以携带62字节数据而经典CAN只有7字节分段数量大幅减少。做刷写和诊断工具时我建议优先走CAN FD体验完全不一样。4. DBC文件把十六进制报文翻译成人话4.1 DBC文件到底描述了哪些信息DBC不是协议更像一张“报文翻译表”它告诉你某个CAN ID上的数据字节怎么拆分成信号每个信号的字节序、精度、偏移量、取值范围、单位分别是什么。一个完整的DBC包括四个部分节点、报文、信号和值表。比如车厂给你一个转向角信号的DBC片段BO_ 1013 EPS_SteeringData: 8 EPS SG_ SteeringAngle : 7|160 (0.1,-780) [0|780] deg EPS这一行信息量非常大报文ID是1013十进制报文名EPS_SteeringData长度8字节信号名SteeringAngle起始位为7长度16字节序0表示Motorola格式大端符号表示无符号因子0.1偏移-780物理范围0~780单位deg。我最初拿到DBC时盯着这个语法看了很久后来总结出经验DBC里每一条SG_定义都可以翻译成一句人话——“从报文数据流的第7位开始取16位作为一个无符号整数再乘以0.1最后减掉780才是真正的方向盘角度”。4.2 字节序是DBC解析最容易翻车的地方字节序分为Intel小端1和Motorola大端0两种。Intel格式下起始位表示的是信号LSB所在的位置数据顺着位序号递增方向排列Motorola格式下起始位表示的是MSB所在的位置数据在同一字节内从高位向低位走走到bit0后跳到下一个字节的bit7继续。我手写过一个提取函数直接按这个逻辑做def extract_signal(data, start_bit, length, byte_order): raw 0 if byte_order 1: # Intel 小端从LSB顺序取 for i in range(length): bit start_bit i byte data[bit // 8] raw | ((byte (bit % 8)) 1) i else: # Motorola 大端从MSB开始字节内递减 bit start_bit for i in range(length): byte data[bit // 8] bit_index bit % 8 raw | ((byte bit_index) 1) (length - 1 - i) if bit_index 0: bit 15 else: bit - 1 return raw这个函数里Motorola分支的bit 15是关键第一次写的时候我漏了这步所有Motorola多字节信号全部解析反了。调试时最直接的表现是单个字节内的信号正常一跨字节就数值大错特错。4.3 符号扩展与物理量换算DBC里信号定义末尾的或-决定了要不要做符号扩展。比如温度信号可能是-40125℃如果DBC里标的是-那你取出的原始值当超过最高位为1时要按有符号数做扩展不能直接当无符号整数用。物理值的换算也容易出错。公式是物理值 原始值 * factor offset但有些新手看到DBC字段只有factor没有offset就以为全部倍率换算结果算出的车速差了几百。实际很多信号offset不是0尤其是角度、温度这类带负数范围的信号。另外如果解析出的物理值超出DBC里[min max]范围哪怕只有一次也要高度警惕。这通常是字节序配错或者信号起始位解析错不是数据噪声。4.4 复用信号DBC里最绕的一个机制现在不少控制器为了省报文会在同一个ID下做多路复用。比如车身控制器把「车门状态」和「车窗状态」放在同一个报文ID里由一个复用字节决定当前解析信号组。DBC里会这么写BO_ 500 MuxData: 8 XXX SG_ MuxSelector M : 0|81 (1,0) [0|255] XXX SG_ DoorStatus m0 : 8|121 (1,0) [0|4095] XXX SG_ WindowStatus m1 : 8|121 (1,0) [0|4095] XXX这里M表示多路复用器m0、m1则分别对应MuxSelector值为0和1时的信号布局。解析时必须先提取MuxSelector的值再决定解析哪组信号不能无脑遍历所有SG_。我踩过一个坑某个ECU的Mux信号不在byte0而是在byte2但DBC里给的起始位是16。一开始没注意拿MuxSelector的值按byte0去解析结果所有复用信号全部落在同一组查了很久。复用信号解析前先打印MuxSelector的值和预期对比是最快的排查手段。5. ISO-TP单帧装不下的诊断数据怎么传5.1 为什么需要ISO-TPCAN一帧最多8字节经典但UDS诊断动辄几十上百字节比如读故障码和刷写时传输的数据早就超了。ISO 15765-2也常直接叫ISO-TP就是用来解决这个问题的传输层协议它把上层大数据包拆分成多个CAN帧发送接收端再重组。它同时定义了四种帧类型单帧、首帧、连续帧和流控帧。单帧处理不超过7字节的经典CAN数据超过时发方先发首帧告诉对端总长度收到流控帧后再按序号发连续帧。5.2 四种帧类型的关键字段单帧SF的类型字是0x0低4位表示数据长度经典CAN最多7字节首帧FF是0x1低4位是高4位长度紧接着一个字节是低8位长度合起来12位表示总长度连续帧CF是0x2低4位是序号流控帧FC是0x3它携带两个重要参数块大小BS和STmin。STmin表示连续帧之间的最小间隔范围不同含义也不同0x00到0x7F直接表示1到127ms0xF1到0xF9则表示100到900微秒。做刷写时如果对方ECU不支持大包高速连续接收你STmin开得太小它缓冲区会溢出然后你收到一堆7F 0x78或者连续帧序号错乱。5.3 一个多帧传输过程的完整时序以请求0x22 F1 90为例如果响应数据有60字节它会这样走ECU发送首帧首帧里声明总长度60字节。诊断仪收到首帧后返回流控帧告知可以发块大小比如0表示可以连续发不限制块大小STmin比如10ms。ECU按顺序发连续帧序号从1递增每个连续帧携带7字节数据经典CAN或62字节数据CAN FD。诊断仪按序号重组出完整60字节响应。调试时最有效的方法是并一个CAN分析仪抓ID范围内的所有报文一帧一帧看PCI字节。比如0x10开头是单帧0x1开头是首帧0x2开头是连续帧0x3开头是流控帧。大多数时候看到对端一直回流控帧或者不回问题基本出在STmin和BS配置上。5.4 内核里的CAN ISO-TP模块怎么用Linux内核从4.7开始集成了CAN ISO-TP协议Android设备只要内核打开了CONFIG_CAN_ISOTP就能像发UDP包一样收发ISO-TP数据int s socket(PF_CAN, SOCK_DGRAM, CAN_ISO_TP); struct sockaddr_can addr {0}; addr.can_family AF_CAN; addr.can_ifindex ifr.ifr_ifindex; // 设置发送/接收CAN ID struct can_isotp_options opts {0}; opts.flags CAN_ISOTP_TX_PADDING; opts.txpad 0xCC; // 填充字节 setsockopt(s, SOL_CAN_ISOTP, CAN_ISOTP_OPTS, opts, sizeof(opts)); bind(s, (struct sockaddr *)addr, sizeof(addr)); uint8_t uds_request[] {0x22, 0xF1, 0x90}; sendto(s, uds_request, sizeof(uds_request), 0, NULL, 0); uint8_t resp[4096]; int n recvfrom(s, resp, sizeof(resp), 0, NULL, NULL);这么一发一收上层拿到的已经是完整响应不需要自己处理分包和重组。但有一点要注意sendto传多帧数据时内核会自动完成ISO-TP分段但如果你的上层包太大且对端STmin约束严格则需要把报文拆成多次发送或者调用时注意阻塞超时。很多项目图省事直接用这个模块省了不少事。6. UDS诊断协议从读故障码到刷写的完整链路6.1 UDS本质上是请求-响应式服务框架UDSISO 14229是应用层诊断协议它的核心模型非常简单诊断仪发一个请求ECU回一个响应。请求最开头是服务IDSID后面跟着子功能或参数。常见的服务有0x10会话控制、0x27安全访问、0x19读故障码、0x22按ID读数据、0x2E按ID写数据、0x31例程控制、0x34请求下载、0x36传输数据、0x37请求退出传输、0x11复位ECU。做Android车载诊断App时业务层搞明白哪个功能对应哪个SID远比纠结底层CAN帧重要。6.2 从默认会话到编程会话路上还有安全解锁UDS里有一个极其重要的概念会话模式。ECU上电后通常处于默认会话0x01这个会话下很多诊断服务不可用必须先切到扩展会话0x10 02或编程会话0x10 03才能做更多操作。刷写Bootloader时进的编程会话平时读故障码进扩展会话就够。但进了会话还不够涉及写ECU、刷写时要先做安全访问。0x27服务的过程是先发0x27 01请求种子ECU返回一个seed你用厂家的加密算法把这个seed算成key再发0x27 02把key传给ECU验证通过后ECU才允许你执行敏感服务。我提醒一句seed-key算法每个厂家都不一样有的用AES有的是自定义查表。不要凭经验猜一定得看厂家提供的诊断规范算法通常不在公开文档里。6.3 NRC为什么总是收到7F响应UDS的正常响应是SID0x40比如0x22请求成功回0x62如果失败ECU回0x7F SID NRC。NRC是负响应码我整理一个高频表格NRC含义排查方向0x10一般拒绝请求格式对不对前提条件满不满足0x11服务不支持当前ECU根本不支持这个SID0x12子功能不支持子功能号范围错误0x13报文长度错误请求数据长度不对多字节漏发0x22条件不满足可能需要先切换会话或解锁0x24请求顺序错误比如刷写时跳过擦除直接发下载0x31请求超出范围参数越界地址或长度不对0x33安全访问被拒绝seed-key验证失败或没有先解锁0x78请求收到响应待定ECU处理中稍等后继续发不要当成错误0x78这个NRC在刷写时极其常见。很多ECU擦除Flash要几十秒它会回0x78让你等千万不要立刻报失败要在App层做好超时轮询。6.4 0x19读故障码和0x31例程控制的实战要点0x19服务有一个子功能列表01读当前DTC、02读冻结帧、04读快照等。返回的DTC是三字节加一个状态掩码比如0x1000010x80表示当前存在且测试失败。状态掩码的每一位都有意义做售后诊断App时至少要把bit0testFailed和bit6previouslyTestFailed显示清楚不然客户一看一堆故障码不明白严重程度。0x31例程控制常用于触发ECU内部动作比如0x31 01 0203 01启动一个自学习例程。请求结构是服务ID 子功能 例程ID 可选参数。有的例程还分启动、停止、查询三种子功能切到扩展会话且完成安全解锁后才能执行。6.5 一版通用的刷写流程虽然每家ECU刷写细节千差万别但骨架基本一致我把一个通用流程列出来断开总线上的应用报文避免干扰。发送10 03进入编程会话部分方案先走10 02扩展会话再跳转。发送27 01/02完成安全解锁。发送31 01 FF00 执行擦除例程等待0x78后的最终肯定响应。发送34请求下载携带起始地址和总长度ECU回一个块长度上限。循环发送36传输数据每个块长度不能超过ECU给的上限也要控制ISO-TP的STmin。全部数据发完发送37请求退出传输。发送11 01复位ECU让新程序跑起来。刷写过程中任何一个步骤收到负响应都要结合NRC排查。我遇到过反复在34卡住的情况最后发现是地址虽然按字节对齐了但长度包含了校验和段厂家规范里要求长度必须去掉校验和。这种细节只能靠逐帧抓包和文档比对比出来。7. Android侧的架构取舍与工程实战7.1 Android到底能不能直接操作SocketCAN答案是能但权限不是随便给的。SocketCAN是网络接口普通第三方App既没有权限执行ip link set can0 up也没有权限socket(PF_CAN, ...)。车机项目里常见的做法有三种把App做成系统应用使用platform签名并放在/system/priv-app下分配系统权限直接在Native层操作SocketCAN。在系统服务里封装一个CAN守护进程对外暴露Binder/AIDL接口App只调用接口。如果CAN不是由Android直接控制而是由独立MCU处理Android通过串口/以太网和MCU通信那Android端根本不碰SocketCAN。从我实际经验看第二种方案最稳妥。它把权限收口在系统层App崩溃不会影响总线安全也方便审计谁在发报文。7.2 SELinux和开机初始化配置Android 8.0之后SELinux策略管得很严即使进程是root没有对应te规则也访问不了网络接口。要在系统里新增一个can原生服务需要写对应的can_service.te允许它对self执行socket创建和网络接口操作再在init.rc里用特定的seclabel拉起service can_service /system/bin/can_service class main user system group system seclabel u:r:can_service:s0 oneshot然后开机阶段把CAN接口拉起来这个过程我放在on boot里执行on boot ip link set can0 type can bitrate 500000 dbitrate 2000000 fd on ip link set can0 up注意如果内核不支持CAN FDdbitrate这个参数会直接报错所以先确认CONFIG_CAN_FD有没有开。7.3 排查问题必备的调试链路在Android上跑CAN相关的东西我强烈建议先用通用工具把底层链路验证一遍再写自己的代码。交叉编译can-utils到车机上然后# 抓取总线所有帧 candump can0 # 按ID过滤 candump can0,0x7e0:0x7ff # 发送一帧测试报文 cansend can0 0x123#DEADBEEF如果要做离线分析把candump输出转成pcap用Wireshark打开可以看到清晰的ISO-TP分帧和UDS会话调试效率提升不止一个量级。很多刷写问题在Wireshark里扫一眼时序就明白了。7.4 工程架构上的几点个人建议如果项目里CAN信号量很大比如几百个信号上屏DBC解析放在Native层做别用Java一层层解析帧率一高CPU占用非常难看。我见过一个项目在App层用纯Java刷DBC每秒几十帧上去手机发烫最后老老实实把解析下放到C。如果信号量不大比如只读几个车速、转速、挡位App层封装一个简单解析器完全够用。要先评估量级再定架构不要一上来就堆Native代码后期维护成本会很高。关于维护我建议把DBC解析代码和数据分开。DBC文件用工具生成C结构体或Python类别在代码里硬编码位偏移否则车厂发一版新DBC你就得改一遍代码。UDS诊断这块能做出一套通用的请求封装层是最好的。会话管理、安全解锁、0x22/0x2E读写、0x19读DTC这些是几乎所有车型通用的封装好后换项目只需要换DBC和诊断规范里的参数。最后再分享一个小习惯每次实际操作CAN之前一定先用分析仪或者candump确认总线波特率。很多时候你以为的500k实际是250k配置不对直接导致总线上错误帧刷屏连其他ECU的通信都被干扰。动手改配置前先花一分钟抓一下物理层比什么都重要。
返回列表