1. 写在前面:为什么 CAN 和 UDS 是车载开发绕不开的两座山
车载测试、车载网络、嵌入式诊断,这两年几乎是个人都在聊,但真正动过手的人都知道,车上的东西和普通嵌入式开发完全是两个物种。我最早接触车规项目时,最深的感受是:你写一版 PC 端串口通信程序,可能半天就通了,但在车上,哪怕只是把 CAN 总线上的一帧报文收发稳定下来,都可能磨掉你一整天。原因很简单,CAN 和 UDS 底下藏着一整套关于可靠性、实时性、安全性的行业规则,这些东西不在芯片手册里,也不在协议规范的目录里,而是在无数个实测现场里。
这篇文章想讲透两件事:底层 CAN 通信怎么做到稳定、可控;上层 UDS 诊断协议怎么把“读故障码、刷写软件、标定参数”这些动作变成标准化的请求和响应过程。同时把两层之间如何衔接、诊断会话如何切换、安全访问如何绕过、刷写流程如何不掉电跑完,全部按实际开发顺序拆开。
适合谁来读?如果你是车载测试工程师想搞明白问题到底出在物理层还是协议栈;如果你是嵌入式软件工程师,正准备把诊断功能搬进自己的 MCU 工程;或者你只是对 CAN 和 UDS 感兴趣、想从零搭一套可用的开发环境,这篇文章都能给你一条相对完整的路径。先声明一下,这不是某本协议规范的复述,更多是基于项目实践踩出来的经验汇总。
2. CAN 底层最容易翻车的三件事:位定时、仲裁与容错机制
CAN 总线的协议栈本身并不复杂,但为什么很多人调不通?因为 CAN 能不能跑起来,很大程度不取决于你代码写得多漂亮,而取决于你对物理层和链路层的理解。这一节把最常翻车的三个点拆开来聊。
2.1 位定时不是你想象的那种“波特率”
很多人把 CAN 通信配置等同于“把波特率设成 500K”,然后调用初始化函数就完事。实际上,CAN 波特率的背后是采样点,而采样点又由同步段、传播段、相位缓冲段1、相位缓冲段2以及波特率分频系数共同决定。
以最常见的 500Kbps、16MHz 晶振、STM32F103 平台为例,Fpclk = 36MHz 时,要得到 500K 的位速率,BRP 分频可以取 4,那么一个位时间占用的时钟周期数就是 36MHz / 4 / 500K = 18 个 TQ。此时如果你把 TSEG1 配成 13、TSEG2 配成 4、同步段固定为 1,采样点就在 (1 + 13) / 18,也就是 77.8% 附近。这个值对高速 CAN 来说是合理的,但如果你用的是低速容错 CAN,比如 125K,采样点最好往 80% 以上靠,否则长距离传输时信号沿的振铃很容易把你的采样点“拉”到错误电平上去。
判断位定时配没配对的土办法有两个:一是看 DLC 较大的扩展帧能否稳定收发,二是用示波器抓 CAN_H 和 CAN_L 之间的差分波形,看位宽度是否整齐、有没有毛刺。实测中,位定时导致的故障往往表现为“短报文没问题、长报文随机错误”,因为长报文包含更多位边沿,任何一个边沿的采样误差都可能引发填充位错误。
2.2 仲裁机制:低 ID 优先,但不是“越小越快”
CAN 总线仲裁真的是一个被无数人误解的设计。它并不会让低 ID 的报文“先发出去”,而是让多个节点在同一时刻抢总线时,ID 数值最小的那一个赢得仲裁。整个过程依赖的是显性电平覆盖隐性电平,也就是说,在仲裁场中每一位 ID 都要参与电平比较,谁先在某个位上写 1 而别人写 0,谁就退出竞争,转为接收模式。
这里有一个常见误区:不少人为了让某条报文优先发送,直接把 ID 改成 0x000,导致所有节点都在跟它抢总线,反而把它所在的节点拖进持续发送状态。更合理的做法是结合 DBC 中的报文周期和 ID 分配规则,把实时性要求最高的报文放在低 ID 段,比如 0x100 到 0x150,把周期型但非关键的放在 0x300 以后,网络管理报文通常固定在 0x7xx 段。
2.3 容错与重同步:为什么波形看起来是好的,总线上却全是错误帧
另一个隐蔽的坑是 CAN 控制器内部的错误计数机制。CAN 的容错能力来自协议层的错误检测和自动重发,但自动重发不等于无代价。发送错误计数器和接收错误计数器是根据总线上每一次错误事件动态调整的,如果某个节点因为时钟精度差或者位定时配置不合理反复出错,它的错误计数器会一路涨到 127 以上,进入被动错误状态,此时它只能接收,不能主动发送,这就会造成“节点偶发性失联”的诡异现象。
更隐蔽的是重同步机制。CAN 的硬同步只发生在帧起始的下降沿,而后续位边沿则通过重同步来修正。如果你的相位缓冲段留得太小,频繁的干扰脉冲会让同步跳转宽度不够用,错误帧就来了。这类问题只看逻辑分析仪上的报文基本看不出来,必须配合 CAN 控制器的错误状态寄存器实测,或者直接抓总线波形数错误帧。
3. 报文收发链路的代码实现层级:从寄存器到驱动再到应用接口
CAN 底层的操作,在实际工程里一般分三层:寄存器操作、驱动封装、应用接口。很多开发者在第一层和第三层之间反复横跳,导致代码既不好维护,也很难定位问题。
3.1 最底层的发送与接收流程
以经典 CAN 控制器为例,发送流程通常是:把报文写入发送邮箱,请求发送,等待发送完成中断或标志位。这里最容易踩的坑是“发送缓冲区满”。如果网络上有节点迟迟没有 ACK,比如终端电阻没接好,发送邮箱会一直占住,程序如果死等,就直接卡死主循环。正确做法是设置超时,比如 50ms 内没有发送完成就清掉邮箱并报错,而不是无限等待。
接收流程同理。验收滤波器是另一个高发问题区——很多芯片默认的滤波器模式会过滤掉所有报文,新手往往会在这里抓狂半天。建议初始化时先把滤波器设成接收全部,先把通路跑通,再逐步按 ID 筛选。
3.2 驱动层应该暴露哪些接口
实测下来,一个真正用得顺手的 CAN 驱动,至少应该提供这些接口:
CAN_Init(BaudRate, SamplePoint):初始化控制器和引脚,最好把位定时参数也传进来CAN_Send(MsgId, Data, Len, MsgType):超时可控的发送接口CAN_RegisterRxCallback(callback):注册接收回调,中断或轮询里触发CAN_GetErrorStatus():读取错误计数器和总线状态CAN_EnterSleepMode()/CAN_WakeUp():低功耗相关
很多工程师只写前两个,第三个没有,接收靠主循环轮询,这在报文量小的时候没问题,但一旦诊断刷写或者多帧传输跑起来,轮询会带来严重的 jitter,直接影响服务质量。
下面给一个简化的发送接口伪码,注意超时和错误处理部分:
uint8_t CAN_Send(uint32_t id, uint8_t *data, uint8_t len, uint8_t is_ext) { uint32_t timeout = 0; if (CAN_IsTxBufferFull()) { while (CAN_IsTxBufferFull()) { if (++timeout > 5000) return CAN_ERR_TX_TIMEOUT; // 约50ms } } CAN_WriteTxMailbox(id, data, len, is_ext); CAN_RequestSend(); return CAN_OK; }3.3 中断服务函数里到底该干什么
很多人写 CAN 接收中断时喜欢在中断里做协议解析,比如直接判断 ID 然后处理 UDS 请求,这是个很大的隐患。CAN 中断频率并不低,尤其总线负载高时,中断里做重逻辑会拉爆 CPU。规范做法是中断里只做数据搬移,把报文压入 FIFO 或环形缓冲区,然后在主循环或者 RTOS 任务里做解析。
实测中,FIFO 的深度至少要覆盖总线上最大的突发报文量。以 500K 波特率为例,一毫秒内最多可以挤进来接近 50 帧标准帧,如果你的接收缓冲区只有 16 深,丢帧几乎是必然的。建议至少设 64,如果需要做诊断刷写,接收缓冲区最好 128。
4. UDS 诊断协议栈的骨架:诊断寻址、会话切换与服务分发
CAN 底层跑通以后,UDS 协议栈就是在这条“路”上跑的应用程序。UDS 的全称是 Unified Diagnostic Services,标准编号 ISO 14229,它定义了一套统一的服务请求和响应格式,但具体怎么传输,还取决于底层网络,比如 CAN 上的传输层是 ISO-TP(ISO 15765-2)。
4.1 物理寻址 vs 功能寻址
UDS 诊断在 CAN 上分两种寻址模式。物理寻址是点对点的,请求方和响应方都有唯一地址,比如诊断仪用 0x7E0 向 ECU 的 0x7E8 发请求;功能寻址是一对多的,比如诊断仪用 0x7DF 发请求,总线上所有 ECU 都会接收并各自响应。
功能寻址看起来方便,但实际开发中坑非常多。比如你发一个功能寻址的 ECU 复位服务,结果十几个 ECU 同时回响应,总线直接被塞满,而且有些 ECU 可能不回响应,很难判断谁执行了谁没执行。所以功能寻址一般只用于广播类请求,比如进入扩展会话模式、某些特殊的例行测试,其他场景尽量用物理寻址。
4.2 会话模式:三类会话和默认行为
UDS 定义了多种诊断会话,最常见的三种是默认会话(Default Session,0x01)、编程会话(Programming Session,0x02)、扩展诊断会话(Extended Diagnostic Session,0x03)。
默认会话是 ECU 上电后自动进入的会话,功能受限,通常只能做读取故障码、读取数据标识符这类基础操作。进入扩展会话后,才能执行写入、例程控制这类高风险服务。编程会话则专门用于刷写,在编程会话下,ECU 通常会停掉应用层报文、降低通信负载,为固件下载让路。
会话切换服务是 0x10,请求格式如下:
请求: 02 10 03 00 00 00 00 00 响应: 06 50 03 00 32 01 F4 00这里的02是 PCI 字节,表示后续数据长度为 2;10是服务 ID;03是子功能,表示扩展会话;响应里的50 03是肯定响应,00 32 01 F4是 P2 和 P2* 时间参数,含义是服务器能在 50ms 内响应的最快时间以及增强响应时间。
会话都有超时机制。如果 ECU 在设定时间内没有收到任何诊断请求,会自动跳回默认会话。这个时间在 10 服务响应参数里就有,通常 P2 是 50ms,P2* 是 5000ms。很多测试工程师遇到“明明已经进入扩展会话了,过一会儿又不行了”的问题,十有八九是上位机没有在超时窗口内持续发送请求。
4.3 服务分发:一帧请求如何路由到对应处理函数
UDS 服务分发是一个典型的表驱动设计。收到一帧诊断请求后,先解析第一个字节拿到 SID(Service ID),然后通过一张函数指针表找到对应的服务处理函数。
这是我个人很推荐的一种分发框架:
typedef struct { uint8_t sid; void (*handler)(uint8_t *data, uint16_t len); } UdsServiceEntry; const UdsServiceEntry serviceTable[] = { {0x10, DioCtrl_HandleSessionControl}, {0x11, DioCtrl_HandleEcuReset}, {0x14, DioCtrl_HandleReadDtc}, {0x19, DioCtrl_HandleReadDtcInfo}, {0x22, DioCtrl_HandleReadDataByIdentifier}, {0x27, DioCtrl_HandleSecurityAccess}, {0x2E, DioCtrl_HandleWriteDataByIdentifier}, {0x31, DioCtrl_HandleRoutineControl}, {0x34, DioCtrl_HandleRequestDownload}, {0x36, DioCtrl_HandleTransferData}, {0x37, DioCtrl_HandleRequestTransferExit}, {0x3E, DioCtrl_HandleTesterPresent}, {0x85, DioCtrl_HandleControlDtcSetting}, };每个处理函数接收原始请求数据,解析后执行对应逻辑,最后把响应数据填入发送缓冲区,由传输层发送。服务处理函数内部要层叠做权限检查:当前会话模式是否支持这个服务?是否需要安全解锁?子功能参数是否合法?这三大检查缺一不可,否则就会出现“用户在默认会话也能执行刷写”这种严重问题。
5. 从会话切换到安全访问再到读写服务的报文实战
光说不练是假把式,这一节从 10 服务开始,把几个关键服务逐个做报文级拆解,每一帧都给出请求和响应的具体数据,这样你可以直接对照抓包验证。
5.1 10 服务:会话切换的细节
请求进入扩展会话:
发送: 02 10 03 AA AA AA AA AA 2 -> PCI,表示后面有 2 个数据字节 10 -> 服务 SID 03 -> 子功能:扩展诊断会话正常情况下,ECU 回肯定响应:
接收: 06 50 03 00 32 01 F4 AA 50 03 -> 肯定响应 (10+0x40=0x50,03回显) 00 32 -> P2=50ms 01 F4 -> P2*=500ms如果收到 0x7F 开头,那就是否定响应,比如:
接收: 03 7F 10 22 22 -> NRC,会话模式不支持这里简单列一下常见的 NRC 含义,后面还会系统性总结:
0x11:服务不支持(SID 不在服务表里)0x12:子功能不支持(比如 0x10 的 04 子功能)0x13:报文长度或格式错误0x22:条件不满足(比如默认会话下执行刷写服务)0x31:请求超出范围(DID 不存在、数据越界)0x33:安全访问被拒绝(未解锁或密钥错误)0x35:密钥错误0x78:请求已收到,正在处理(多用于耗时服务)
5.2 27 服务:安全访问的完整过程
安全访问服务是 UDS 里安全门槛最高的一环,它基于挑战-响应机制。诊断仪先发请求种子,ECU 返回一串随机数种子,诊断仪用约定的算法把种子计算成密钥,再通过第二帧请求把密钥发给 ECU,ECU 校验通过后进入解锁状态。
第一帧:请求种子
发送: 02 27 01 AA AA AA AA AA 27 -> 安全访问服务 01 -> 子功能:请求种子(级别1)ECU 返回种子:
接收: 06 67 01 11 22 33 44 AA 67 01 -> 肯定响应,请求种子成功 11 22 33 44 -> 4 字节种子第二帧:发送密钥
发送: 06 27 02 44 33 22 11 AA 27 -> 安全访问服务 02 -> 子功能:发送密钥(级别1) 44 33 22 11 -> 密钥,本地计算得到如果密钥对了,ECU 回:
接收: 02 67 02 AA AA AA AA AA如果错了,回:
接收: 03 7F 27 35 35 -> 错误密钥,ECU 可能还会启动延时锁定机制实测中要注意几个问题。首先是种子算法的复杂度。很多项目用的是简单异或、位翻转、或查表算法,安全性很差,能够被轻易逆向。稍微正规点的项目会把种子放进 AES 或者 CRC32 再叠加校准码。第二个是尝试次数限制,一般错误密钥 3 次会被锁定一段时间,测试时如果连续输错,ECU 可能 10 分钟内都不给你出新种子。
我遇到过最坑的一次是,ECU 和诊断仪都实现了 27 服务,但算法里有一个隐含的字节序问题——种子是“小端”存储的,算法却按大端计算,导致所有解锁都失败。后来抓了两边 log 逐字节比对才发现。这类问题靠代码 review 看不出来,必须准备一个能解析 ISO-TP 报文的工具,把种子和密钥的每个字节打出来人工核对。
5.3 22/2E 服务:DID 读写机制
22 服务是读数据,2E 服务是写数据,DID(Data Identifier)是它们操作的对象。DID 可以是 2 字节的,比如 0xF190 表示车辆识别码,也可以是厂商自定义的,比如 0x1234 表示某个标定参数。
读一个 DID:
发送: 03 22 F1 90 AA AA AA AA 22 -> ReadDataByIdentifier F1 90 -> DID响应:
接收: 07 62 F1 90 31 32 33 34 62 -> 肯定响应 (0x22+0x40) F1 90 -> 请求的 DID 31 32 33 34 -> 数据,ASCII 字符串 "1234"写一个 DID:
发送: 07 2E F1 90 41 42 43 44 2E -> WriteDataByIdentifier F1 90 -> DID 41 42 43 44 -> 要写入的数据 "ABCD"响应:
接收: 04 6E F1 90 AA AA AA AA写 DID 服务在默认会话下通常是被禁止的,必须进入扩展会话且安全解锁后才行。很多开发者在这块偷懒,只在会话层做了限制,没做安全状态检查,结果诊断仪在没解锁的情况下也能修改标定值,这是很严重的代码缺陷。
6. UDS 刷写流程的完整链路:从 34 服务到 36 服务再到 37 服务
刷写是 UDS 诊断里最复杂、风险最高的一项功能,它涉及的是 ECU 的 Flash 存储区域。一旦中途掉电或者传输错误,ECU 可能变砖,只能靠 bootloader 恢复。所以刷写流程设计得非常保守。
6.1 刷写前的会话切换与前置安全条件
完整刷写流程一般长这样:
- 10 02 进入编程会话
- 27 01/02 安全解锁、通常为 Bootloader 级别的安全等级
- 85 02 关闭 DTC 记录(防止刷写过程中产生误报故障)
- 28 03 停掉非诊断通信报文
- 34 34 请求下载,协商地址和长度
- 36 xx 循环传输数据块
- 37 请求传输退出,触发 CRC 校验
- 11 01 复位 ECU,启动新程序
每一步错一步都可能中断刷写。尤其是第 4 步,很多 ECU 如果忽略停通信报文,刷写过程中应用报文还在发,和诊断流量抢带宽,拖慢整个刷写流程不说,严重时还会触发看门狗复位。
6.2 34 服务:请求下载的参数如何计算
34 服务(RequestDownload)请求格式如下:
发送: 08 34 00 44 00 00 10 00 34 -> RequestDownload 00 -> 数据格式:不压缩不加密 44 -> 地址和长度格式:地址 4 字节,长度 4 字节 00 00 10 00 -> 下载起始地址 0x00001000 00 00 00 10 -> 数据总长度 16 字节响应返回的是 ECU 愿意接受的传输块大小:
接收: 07 74 00 44 00 40 00 00 74 -> 肯定响应 00 -> 数据格式回显 44 -> 地址长度格式回显 00 40 -> 最大传输块大小为 0x40 = 64 字节这里要注意,最大传输块大小是包括 36 服务头部在内的长度吗?其实是数据部分长度。也就是说,后面每个 36 服务的块大小不能超过 64 字节数据。实际做上位机时,最好把单个块大小设成比 64 小一点,留出 PCI 头的余量。
6.3 36 服务:多帧传输的细节与技术门槛
36 服务(TransferData)是刷写里真正“搬砖”的环节。每个块有一个块序号,从 01 开始递增,单个块格式如下:
发送: 44 36 01 11 22 33 44 55 44 -> PCI 字节,表示后续共 68 字节(这里简化了) 36 -> TransferData 01 -> 块序号 11 22 33 44 -> 前 4 字节数据响应是:
接收: 05 76 01 00 00 00 00 00 76 -> 肯定响应 01 -> 当前块序号回显36 服务的核心难点是稳定性。实测中,传输速度、块大小、ISO-TP 的拆分策略、CAN 中断响应时间、ECU 内部 Flash 擦写耗时,这些因素叠加在一起会形成非常复杂的时序关系。比如 ECU 在擦除一个扇区时,内部 Flash 控制器可能忙上几十毫秒,这段窗口内如果诊断请求到了,ECU 可能来不及及时回响应,上位机如果等待时间不够就直接报超时,实际数据已经写进去了,重发就会导致数据重复、地址错位。
所以标准的实现方式有两种:一种是上位机等待 ECU 返回肯定响应再发下一块(流控方式),另一种是 ECU 在擦写忙时回复 0x78 NRC,表示“请求已收到正在处理”,上位机收到 0x78 后不重发请求,而是继续等待最终响应。实测中,0x78 机制是刷写稳定性的关键,很多自研刷写工具卡在“发一帧等一帧”的简单逻辑上,一遇到擦写慢的 ECU 就失败。
6.4 37 服务与刷写后的完整性校验
传输完所有数据后,发 37 服务请求结束传输:
发送: 01 37 AA AA AA AA AA AA 接收: 03 77 22 AA AA AA AA AA这个响应里的22是校验和检查的结果。如果 37 服务返回 0x77,但后续 ECU 校验固件 CRC 不过,ECU 会在复位的自检阶段报错。更严谨的做法是在 34 服务前先发一条诊断指令,让 ECU 计算整个镜像的 CRC 或者签名值,然后在 37 服务后单独核对。
我强烈建议你在刷写协议栈里写一个独立的“完整性自检”逻辑,不要写完数据就默认成功。至少要在复位前读一次固件版本号或者执行例程控制里的 CRC 检查,确认写入的数据和源文件一致之后,再做 11 01 ECU 复位。
7. NRC 错误码体系与诊断开发中躲不开的坑
NRC(Negative Response Code)是 UDS 里最容易让人摸不着头脑的地方,因为它不是一个单纯错误码,而是一个“带上下文的否定响应”。同一帧请求,在默认会话和扩展会话下,返回的 NRC 可能完全不同。
7.1 NRC 到底是哪里来的
当一个 ECU 收到诊断请求后,会经过一条完整的检查流水线。每层检查失败都会返回对应的 NRC:
- 先检查服务 ID 是否支持,不支持回
0x11 - 再检查子功能是否支持,不支持回
0x12 - 接着检查是否支持该服务当前会话模式,不支持回
0x7F - 再检查安全访问状态,未解锁回
0x33 - 最后检查请求参数是否超出范围,超范围回
0x31 - 长度和格式错误会回
0x13
这个顺序很关键,它决定了你排查问题的方式。比如你在扩展会话调用写 DID,结果回了0x33,那大概率不是参数问题,而是安全状态没满足。如果你上来就查 DID 范围,纯属白费功夫。
7.2 一张表整理高频 NRC
| NRC | 名称 | 含义 | 排查建议 |
|---|---|---|---|
| 0x10 | General Reject | 服务被普遍拒绝 | 检查是否处于不可诊断状态 |
| 0x11 | Service Not Supported | SID 不在服务表 | 查服务表配置 |
| 0x12 | Sub-Function Not Supported | 子功能不受支持 | 查子功能范围 |
| 0x13 | Incorrect Message Length | 报文长度不对 | 核对 PCI 长度 |
| 0x22 | Conditions Not Correct | 条件不满足 | 查会话模式/前置条件 |
| 0x24 | Request Sequence Error | 请求顺序错误 | 比如没做 27 直接做 2E |
| 0x31 | Request Out of Range | 参数超出范围 | 查 DID/数据范围 |
| 0x33 | Security Access Denied | 安全访问被拒绝 | 先查是否解锁 |
| 0x34 | Authentication Failed | 认证失败 | 查密钥算法 |
| 0x35 | Invalid Key | 密钥无效 | 查密钥计算结果 |
| 0x72 | General Programming Failure | 编程失败 | 查 Flash 写入错误 |
| 0x78 | Request Correctly Received,Response Pending | 已收到待处理 | 等待进一步响应 |
| 0x7F | Service Not Supported In Active Session | 当前会话不支持 | 切换会话模式 |
7.3 排查 NRC 的三个实战套路
第一个套路,抓时间戳。UDS 响应的时序信息永远比内容更有价值。比如 0x78 出现的频率、每个 0x78 和最终响应之间的间隔、ECU 对连续请求的响应间隔,这些数据能直接反映 ECU 在处理什么耗时操作。建议所有诊断日志都带毫秒级时间戳。
第二个套路,做边界测试。一个服务功能要测稳,不能只测正常报文,要把长度超限、字节差一、数据越界、子功能非法、会话未切换、安全未解锁这些情况全测一遍,确认每种情况下的 NRC 都符合预期。这样才能确保 ECU 在真实环境下收到畸形请求时,不会打出错误响应甚至卡死。
第三个套路,抓 ECU 内部日志。CAN 报文只能告诉你“ECU 回了个 0x31”,但说不清是哪个字段越界。这一步要在诊断协议栈里加调试打印,把服务分发之前的原始数据、DID 解析结果、加密种子和密钥都打出来。实际项目中,这样一个 debug 开关能省掉一整个下午的分析时间。
8. 测试工具链与实测过程中的问题定位手段
最后聊一聊测试环境。很多人以为 UDS 开发只需要一根 USBCAN 和上位机就够,真正上车调的时候才发现,手里没有趁手的工具,连问题在哪层都说不清楚。
8.1 三层工具链是标配
我的建议是至少备齐三件套:
| 工具类别 | 推荐选择 | 用途 |
|---|---|---|
| 总线分析仪 | CANoe / PCAN / USBCAN | 抓取 CAN 报文,解析 ISO-TP 和 UDS |
| 诊断上位机 | CANdelaStudio + 自研脚本 | 按诊断规范构造报文、回放测试序列 |
| 示波器 | 至少 100MHz 带宽 | 看物理层波形,定位位定时和终端电阻问题 |
抓包工具一定要能按 ISO-TP 自动重组多帧报文,否则 36 服务刷写时,一帧 64 字节的内部数据会被拆成十几帧 CAN 报文,手动分析会把人逼疯。
8.2 实测中最经典的三个问题
第一个,终端电阻问题。总线上没有 120 欧终端电阻,短距离通信偶尔能通,长线或者高负载下错误帧就会急剧增加。判断方法很简单:示波器抓 CAN_L 和 CAN_H,看差分信号幅值是否在 2V 左右,如果不稳定或者反射很重,优先查终端电阻。
第二个,ISO-TP 接收缓冲溢出。这种情况通常表现为:短诊断服务一切正常,一到 36 服务刷写时,ECU 回了一两个 0x78 后直接不回响应。大概率是 ECU 的 ISO-TP 接收缓冲不够,刷写的大块数据把它冲垮了。解决方式是增加接收缓冲深度,或者把上位机的单块数据长度调小。
第三个,波特率不一致。CAN 总线上的节点各自配的波特率不同,但节点间的报文还能偶尔通过,这事儿听起来很不合常理,但真的遇到过。原因是一些控制器的同步段容差比较大,波特率差个 0.5% 以内可能还能勉强通信,但长期运行会有偶发错误帧。排查方式是看错误计数器是否在持续增长,或者把所有节点的波特率寄存器配置全部列出来对齐。
8.3 用 CAN 总线波形判断通信质量
很多人问我怎么从波形上判断 CAN 通信好坏,这里给一个简单实用的判断逻辑。先用示波器抓取 CAN_H 和 CAN_L 的电平变化,观察单一位的宽度是否稳定。在 500K 波特率下,一个显性位的宽度应该是 2 微秒,如果位宽度有大范围跳动,说明位定时有问题,或者总线干扰严重。
然后再看波形边沿的单调性。好的差分波形应该有干净的上升沿和下降沿,如果边沿有过冲、振铃、台阶,说明总线阻抗不匹配,或者节点的收发器驱动能力不足。这种问题在实验室短线上往往不明显,在你的测试台架上拉一条长线后就会爆发。
最后看波形幅值。CAN_H 对地的静态电平约 2.5V,CAN_L 对地也约 2.5V,显性状态时 CAN_H 升到约 3.5V,CAN_L 降到约 1.5V。如果幅值明显偏低,比如只有 1V 的差分,那很可能是总线负载过重或者收发器已经损伤,长期运行出错的概率很高。
9. 最后再分享一点个人体会
做车载 CAN 和 UDS 开发这几年,如果只说一条最值的经验,我会选这条:不要把 CAN 和 UDS 分开学,也不要只看规范不写代码。CAN 的问题是物理世界的问题,UDS 的问题是应用逻辑的问题,它们之间由 ISO-TP 这座桥连接,而这座桥恰恰是诊断开发中最容易被忽略、又最容易出问题的部分。真正动手把 CAN 收发调通、把 10 27 2E 34 36 37 这几个服务跑起来之后,你会发现所谓的车载诊断开发,核心就是一套标准化的“请求-响应”机制,外加一堆对异常流程的防御处理。把这些骨架搭稳了,后面无论你要做 Bootloader 刷写、DoIP、LIN 还是车载以太网诊断,都只是换一层传输管道而已。