☰
UDS(ISO14229)源码解析:CAN TP、会话安全与踩坑实践
2026/10/28 9:06:47 网站建设 项目流程

简介:一份基于ISO 14229的UDS诊断协议C语言实现源码,面向汽车电子开发、嵌入式诊断工程师及协议学习者。资源聚焦ECU通信中的服务定义、传输层分包重组、会话管理、安全访问与错误处理等关键模块,便于从代码层面理解UDS协议栈的分层设计与工作机制。压缩包共15个文件,以C源文件(4个)和头文件(3个)为主体,另含构建脚本、Makefile、说明文档及测试代码,适合快速编译、运行与二次开发。资源包仅20KB,结构精简,已有3924人学习下载。开发者可借助其中ISO-TP传输层实现、诊断服务分发逻辑以及测试用例,快速搭建诊断原型,并根据实际需求对会话权限、安全解锁和编程流程进行定制扩展。

1. 把 UDS(ISO14229)源码包拆开之前:先搞懂它到底解决什么问题

做车载诊断或者嵌入式 BMS、VCU 相关开发的人,对“UDS”这个词应该都不陌生。它全称是 Unified Diagnostic Services,统一诊断服务,定义在 ISO 14229 标准里,是当前乘用车和商用车诊断功能的事实标准。无论你是在做 CAN 总线报文解析、写 Bootloader 刷写逻辑,还是搞产线 EOL 诊断,基本都绕不开这套协议。整份 UDS(ISO14229)协议源码.zip 下载资源,核心就是把协议栈从“概念”落成“代码”——从服务定义到会话管理、安全等级跳转、DTC 处理,再到多帧发送接收的状态机,源码里都有对应实现。适合正在做嵌入式诊断开发、想自己搭一套诊断协议栈的工程师,也适合刚入行、需要完整的可运行代码来对照标准学习的同学。

协议本身不算难,但坑全在细节里:会话切换失败、安全解锁失败、多帧传输丢帧、P2 超时设置不对,这些经典问题足以让一个调试新人磨上两三天。源码的价值就在于把服务和状态机的骨架搭好,你拿到手可以直接跑,然后在这个骨架上去改自己的诊断需求。

2. 为什么首选 ISO 14229:协议分层与源码设计逻辑

2.1 从 OSI 模型看 UDS:它到底站在哪一层

很多初学者拿到 UDS 源码,第一反应是找收发函数,然后对着 CAN 报文一头雾水。正确顺序是先搞明白 UDS 在通信协议栈中的位置——它属于应用层协议。物理层、数据链路层走的是 CAN(ISO 11898),网络层走的是 ISO 15765-2(也就是常说的 TP 层,Transport Protocol),负责把超过单帧长度的诊断报文做拆分和重组。UDS 则定义在 TP 层之上,只管“这条诊断请求是做什么的”以及“怎么回”。

在咱们这份源码里,通常会有这么几个层次:CanIf管物理收发、CanTp管多帧组包拆包、Uds_Tp管应用层和传输层的接口、Uds_Srv才是真正处理诊断服务逻辑的地方。拿到源码先不要急着看 SID 那一堆 switch-case,先把 TP 层的接收状态机跑通,很多联调问题都出在这一层。

2.2 源码整体框架:从接口到调度的分层拆解

源码常见的文件划分是这样的:

  • uds.h / uds.c:主调度入口,负责把收到的诊断请求按 SID 分发到具体服务处理函数
  • uds_sid_*.c:各服务实现,比如 0x10 会话切换、0x27 安全解锁、0x22 按 ID 读数据、0x2E 按 ID 写数据
  • uds_tp.c:CAN TP 收发状态机,ISO 15765-2 的单帧、首帧、连续帧、流控帧处理
  • uds_nrc.c:负响应码管理,统一走 0x7F 响应
// 诊断请求分发核心示意 void Uds_HandleRequest(uint8_t* data, uint16_t len) { uint8_t sid = data[0]; // 第一字节是服务ID switch (sid) { case 0x10: UdsSid10_Handler(data, len); break; // 会话切换 case 0x22: UdsSid22_Handler(data, len); break; // 读数据 case 0x2E: UdsSid2E_Handler(data, len); break; // 写数据 case 0x27: UdsSid27_Handler(data, len); break; // 安全解锁 case 0x31: UdsSid31_Handler(data, len); break; // 例程控制 case 0x19: UdsSid19_Handler(data, len); break; // 读DTC信息 default: Uds_SendNrc(0x11); break; // 0x11 = 服务不支持 } }

每一行都是一个诊断场景里最常见的分支。值得注意的参数是 SID 的取值——0x10到0x31这些高四位是0010到0011的区间,标准里是按功能组划分的,不要自己去改这些值,否则诊断仪就不认了。默认走Uds_SendNrc(0x11)是兜底策略,任何没实现的服务都统一回“不支持”,这个习惯是业内通用的安全做法。

2.3 源码好不好的关键:看它怎么处理 NRC

NRC(Negative Response Code,负响应码)是 UDS 调试时最先要盯住的东西。比如你发 0x22 读一个不存在的 DID,收到 0x7F 22 31,这个 31 就是 NRC,表示“请求超出范围”。这看起来很简单,但真实的开发里,NRC 的优先级是有严格规定的——比如服务不支持时,要回 0x11;但如果在错误的会话下请求,哪怕服务本身支持,也要先回 0x7F 服务ID+0x7E(当前会话下服务不支持),而不是 0x11。这个优先级顺序写错,轻则被测试用例打回来,重则导致诊断仪和 ECU 之间出现无法理解的交互。

源码里如果有一个独立的 NRC 检查链——先查会话、再查安全等级、再查服务是否支持、最后查参数是否合法——那这份代码就是知道深浅的写法。拿到手之后,第一件事应该去看这条检查链的顺序跟 ISO 14229 附录里的规定对不对得上。

3. 核心服务实现精读:0x10、0x22、0x2E、0x27 的代码级拆解

3.1 0x10 会话切换:从默认会话到编程会话的状态机

诊断会话是 UDS 里最容易理解也最容易写错的机制。ECU 上电默认进的是默认会话,只有切到编程会话才能做 Bootloader 刷写,只有切到扩展会话才能写参数。会话切换就是发02 10 02——长度 2 字节、服务 0x10、子功能 0x02(扩展会话)。正确响应是06 50 02 00 32 01 F4,其中后四个字节是 P2 和 P2* 的计时值。

void UdsSid10_Handler(uint8_t* data, uint16_t len) { uint8_t sessionType = data[1]; if (!Uds_CheckSession(sessionType)) { Uds_SendNrc(0x7F); // 当前会话下不支持 return; } // 切换会话,同时要重置安全等级状态 g_activeSession = sessionType; g_securityLevel = 0; // 会话一切换,安全等级立刻回零 // 响应:sid + 子功能 + P2(ms) + P2*(ms) uint8_t resp[6] = {0x50, sessionType, 0x00, 0x32, 0x01, 0xF4}; Uds_SendResponse(resp, 6); }

注意g_securityLevel = 0这一行。会话切换后安全等级必须清零,这是 ISO 14229 的硬性要求,也是很多初学者容易忽略的。你在默认会话下解锁成功了,一切到扩展会话,如果安全等级不清零,那就有安全漏洞——比如产线上刷写程序的时候,操作员只需要切个会话就能绕过安全校验直接写 Flash。

这里的 P2 值也有讲究。0x00 0x32换成十进制是 50ms,0x01 0xF4是 500ms。P2 表示 ECU 收到请求后到发出响应之间的最大允许时间,P2* 表示在 P2 超时后、需要额外时间时才使用的扩展超时值。这两个值的设置要根据你实际 ECU 的处理能力来,有的源码直接写死,这种代码拿过来接自己产品的时候一定要改成可配置的。

3.2 0x22/0x2E 读写数据:DID 映射表的设计思路

0x22 读数据和 0x2E 写数据是最常用的两个服务,它们的核心都建立在 DID(Data Identifier,数据标识符)映射表上。DID 是一个 16 位的 ID,比如 0xF190 读 VIN 码、0xF18C 读硬件版本号。

// DID 映射表定义 typedef struct { uint16_t did; uint8_t length; uint8_t accessType; // 0=只读 1=可写 uint8_t* (*readHandler)(void); void (*writeHandler)(uint8_t* data, uint8_t len); } DidMapType; static const DidMapType g_didTable[] = { {0xF190, 17, 0, ReadVin, NULL}, {0xF18C, 4, 0, ReadHwVersion, NULL}, {0x1234, 8, 1, NULL, WriteCalParam}, }; void UdsSid22_Handler(uint8_t* data, uint16_t len) { uint16_t did = (data[1] << 8) | data[2]; // DID 是两字节,大端对齐 for (int i = 0; i < sizeof(g_didTable)/sizeof(DidMapType); i++) { if (g_didTable[i].did == did) { uint8_t* value = g_didTable[i].readHandler(); uint8_t resp[4 + g_didTable[i].length]; resp[0] = 0x62; // 正响应 = SID + 0x40 resp[1] = data[1]; resp[2] = data[2]; memcpy(&resp[3], value, g_didTable[i].length); Uds_SendResponse(resp, 3 + g_didTable[i].length); return; } } Uds_SendNrc(0x31); // 请求超出范围 }

这里有几个值得注意的设计决策。第一,DID 使用大端序,data[1] << 8 | data[2],这个顺序不能反;第二,accessType字段控制读写权限,0x22 只允许读只读和可写的 DID,0x2E 只允许写可写的 DID;第三,readHandler用函数指针而不是直接读内存,这样做的好处是 DID 对应的数据可以来自 RAM、Flash、甚至传感器实时值,由 handler 决定。

读多字节数据时,VIN 这种 ASCII 字符串要特别注意——17 位长度是不是包含了结束符、字符编码是 ASCII 还是 UTF-8,很多产品在读出来之后在诊断仪上显示乱码,问题就出在这。

3.3 0x27 安全解锁:种子-密钥机制的具体实现

安全解锁是 UDS 里最带“玄学”色彩的服务。逻辑本身很简单:ECU 发一个种子,诊断仪用密钥算法算出密钥返回,ECU 校验通过就解锁。难点在于——解锁后是有时效的,而且多次失败会锁定一段时间。

void UdsSid27_Handler(uint8_t* data, uint16_t len) { uint8_t subFunc = data[1]; // 0x01=请求种子, 0x02=发送密钥 if (subFunc == 0x01) { if (g_securityLevel >= 0x05) // 已经解锁到等级5了 { Uds_SendNrc(0x24); // 子功能不支持 return; } g_seed = Uds_GenerateSeed(4); // 生成4字节随机种子 uint8_t resp[7] = {0x67, 0x01, seed_3, seed_2, seed_1, seed_0}; Uds_SendResponse(resp, 7); } else if (subFunc == 0x02) { uint32_t key = (data[2]<<24) | (data[3]<<16) | (data[4]<<8) | data[5]; if (Uds_CalcKey(g_seed) == key) // 内部算法校验 { g_securityLevel = 0x05; // 解锁到等级5 Uds_SendPositive(0x67, 0x02); } else { g_seedTryCount++; if (g_seedTryCount >= 3) Uds_SendNrc(0x36); // 超过尝试次数限制 else Uds_SendNrc(0x35); // 密钥错误 } } }

实际的密钥算法不会写在这个演示代码里,产品上会用一个自定义的算法——常见的有 CRC16 查表、移位异或、或者 AES 的某个固定 key 派生。源码里给你的是这个框架,算法本身你自己填。要注意的是0x36这个响应——超过尝试次数后,ECU 会进入延迟状态,通常要等若干秒才能再次请求种子。源码里这个延时是空跑循环还是定时器实现,直接决定诊断仪能不能在锁定期间正常通信。

另一个关键参数是解锁后失效时间的处理。项目里我一般会另加一个定时器,解锁 10 秒后自动回落到默认安全等级,防止刷写完成后忘记锁定导致安全风险。

4. CAN TP 多帧传输:把 ISO 15765-2 的收发状态机讲透

4.1 单帧、首帧、连续帧的帧类型判定

UDS 报文大多不长,用单帧(SF)就能装下。但 Bootloader 刷写时,一条写数据的请求可能要几百字节,这个时候就要用到多帧传输了。CAN TP 层用 CAN ID 的仲裁位来区分帧类型——实际上不是仲裁位,而是报文第一字节的高四位FrameType字段。

  • 0x0:单帧(SF),最多 7 字节数据
  • 0x1:首帧(FF),前 2 字节是 12 位长度信息
  • 0x2:连续帧(CF),只有 1 字节的序号
  • 0x3:流控帧(FC),通知发送方可以发多少、间隔多少

判断逻辑在源码的接收中断里:

uint8_t frameType = (data[0] >> 4) & 0x0F; switch (frameType) { case 0x0: // 单帧:直接交给上层解析 Uds_HandleRequest(&data[1], data[0] & 0x0F); break; case 0x1: // 首帧:初始化接收缓冲区,等后续连续帧 g_rxTotalLen = ((data[0] & 0x0F) << 8) | data[1]; g_rxCurIndex = 0; memset(g_rxBuffer, 0, sizeof(g_rxBuffer)); Uds_SendFlowControl(g_rxTotalLen); // 发流控帧 break; case 0x2: // 连续帧:按序号填入缓冲区 // 序号是循环的 0~15,需要处理回绕 break; }

流控帧的发送策略是第一个要调的参数。有两种常见的做法:第一种是收到首帧后立刻回流控,让发送方连续发完;第二种是先准备好接收缓冲区再回流控。第一种简单,缺点是如果缓冲区不够大,连续帧会丢。源码里一般实现的是第一种,因为诊断场景下缓冲区通常是固定分配的。

4.2 多帧接收的状态机:丢帧检测与超时复位

多帧接收是最容易翻车的地方。ISO 15765-2 要求连续帧的序号从 1 开始递增,到 15 后回绕到 0。实操里,ECU 发连续帧的速度极快(通常间隔 10ms 以内),如果接收方在中断里拷贝数据不及时,就可能漏帧。

uint8_t expectedSeq = 1; // 期望的下一帧序号 // 收到连续帧时检查 uint8_t actualSeq = data[0] & 0x0F; if (actualSeq == expectedSeq) { memcpy(&g_rxBuffer[g_rxCurIndex], &data[1], 7); g_rxCurIndex += 7; expectedSeq = (expectedSeq + 1) % 16; // 序号回绕 } else { // 序号对不上,说明丢帧了,整个接收过程作废 g_rxState = TP_IDLE; Uds_SendNrc(0x10); // 一般不会回 NRC,直接静默丢弃 }

丢帧之后的处理策略很关键。有些源码会直接丢弃并等待下一次诊断请求,有些会回复一个 NRC。实际项目中建议走静默丢弃——因为如果发一个 NRC,对应的多帧状态机可能已经乱了,再回 NRC 会导致诊断仪等待超时。这个选择在测试时用 CANoe 的干扰功能注入丢帧即可验证状态机是否能正确复位。

4.3 流控帧的 BlockSize 与 STmin 参数设置

多帧传输的发送节奏由流控帧控制。BlockSize表示连续发多少帧后需要再等一个流控帧;STmin表示帧与帧之间的最小间隔,单位是微秒。这两个参数设置不当,会导致接收方缓冲区溢出或者总线上传输时间过长。

// 发送流控帧 void Uds_SendFlowControl(uint16_t totalLen) { uint8_t fc[3]; fc[0] = 0x30; // 帧类型=流控帧 fc[1] = 0x00; // BlockSize=0,表示不限制连续帧数量 fc[2] = 0x14; // STmin=20ms,帧间隔20ms Can_Send(fc, 3); }

这里BlockSize=0表示允许发送方连续发送直到全部发完,STmin是 0x14 也就是 20ms——算下来 1KB 的数据大约要 2 秒,确实慢。常见的优化方式是 STmin 设为 0x00(即不限制间隔),但这要求接收端的缓冲区足够大。一个实际调试经验:如果接收方用 DMA + 环形缓冲区,STmin 可以压到 1ms 以下;如果是中断里逐字节拷贝,保守一点设 10~20ms。源码给的默认值多为 10ms 或 20ms,接自己的板子时按实际负载能力调整即可。

5. 常见问题排查:UDS 协议踩坑实录与解决办法

5.1 踩坑一:0x7F 22 31,读 DID 永远返回“请求超出范围”

现象:用诊断仪发送 0x22 读 DID,收到的永远是7F 22 31(requestOutOfRange)。但确认 DID 已经在代码的映射表里。

原因:排查后发现 DID 的值大小端搞反了。源码里定义的是0xF190,但诊断仪发过来的字节流是先发 0xF1 再发 0x90,代码里写成了(data[1]<<8) | data[2],看起来是对的,但其实 —— data[1] 才是 DID 高位。问题出在映射表里定义的顺序和实际接收顺序不一致,有的源码里把 DID 按小端存了。

解决:检查 DID 映射表确认统一用大端序。建议在UdsSid22_Handler入口加一条打印日志,把data[1]和data[2]的原始值打出来对比表里的定义,一眼就能看出大小端问题。从那以后,我每新增一个 DID,都强制先打印收发报文再联调。

5.2 踩坑二:0x27 安全解锁,种子返回正常但密钥总校验失败

现象:请求种子返回正常,计算密钥发送后,ECU 一直在回7F 27 35(invalidKey)。

原因:种子是 4 字节,但看代码里种子的生成和密钥的解析用了不同的字节序。种子生成时用的是大端顺序组包,密钥解析时用的却是小端。常见的错误是把Uds_GenerateSeed(4)产生的随机数直接 memcpy 到响应缓冲区,而Uds_CalcKey内部是按数字方式读取的,这俩对不上。

解决:统一所有种子和密钥的传输格式。我一般在代码里封装两个函数,一个Uds_PackU32,一个Uds_UnpackU32,所有跨 ECU 的 4 字节数据一律走这两个函数。同时注意解锁后安全等级过期失效问题——如果在调试工具里的“解锁-读数据”流程之间停留太久,等级会自动回零,表现也像密钥错误。

5.3 踩坑三:多帧写数据经常卡住,诊断仪报“超时”

现象:用 0x2E 写一个超过 7 字节的 DID(比如写一组标定参数),诊断仪偶尔报超时,但有时又能成功。抓总线发现 ECU 只是收到了前几个连续帧就再也不回了。

原因:接收缓冲区只分配了一次,第二次请求进来时缓冲区头部状态没有复位。典型问题在于首帧处理时直接把g_rxCurIndex=0就开收,但上次接收失败后残留的expectedSeq还是中断时的值,导致序号对不上,后续帧全部被丢弃。

解决:多帧接收状态机在每次回到 IDLE 态时,必须把expectedSeq、g_rxCurIndex、g_rxTotalLen全部清零。源码里如果只在首帧里重置这几个变量,那这个状态机是不完备的。建议把重置动作收敛到一个Uds_TpRxReset()函数里,在三种情况下调用——收到首帧时、接收超时时、收到单帧时。这是排查这类问题的通用手段。

5.4 踩坑四:发送流控帧后,连续帧间隔仍然很密,缓冲区溢出

现象:STmin 设置了 20ms,但对方 ECU 完全无视,一股脑把连续帧全发完,导致 ECU 丢数据、诊断流程失败。

原因:STmin 的单位换算错了。ISO 15765-2 里 STmin 的编码不是直接毫秒数——0x14这个值对应的解释是“20ms”,但对方可能把它当成其它含义。更常见的问题是对方发送方根本不看 STmin,只按自己的节奏发。

解决:不要依赖对方的流控帧遵守程度。自己的接收端必须做到——无论对方 STmin 多小,都要能在中断/轮询里及时取走数据。排查时可以人为降频发送,如果帧间隔调到 5ms 还是丢,就说明中断处理耗时有问题,需要看是不是在中断里打印了日志或者做了耗时操作。这是我调试过的最玄学的问题之一,最后定位到大意是一个printf出现在中断里。

6. 从源码到工程落地:我给协议栈加诊断日志的常用技巧

拿到一份 UDS 协议源码,能在开发板上把 0x10、0x22 跑通只是第一步。真正让它能在项目里用起来,我通常会先做三件事:加诊断日志、加发送超时重发机制、加自动化测试用例的对接接口。这三个动作能让后续所有联调工作省下一大半时间。

诊断日志的核心思路是:把每一条收到的请求和发出的响应,用统一格式打印出来。格式建议包含时间戳、方向、CAN ID、数据长度、原始字节。

typedef struct { uint32_t timestamp; uint8_t isRx; // 1=接收 0=发送 uint32_t canId; uint8_t data[8]; uint8_t len; } DiagTraceEntry; void Diag_Trace(uint8_t isRx, uint32_t canId, uint8_t* data, uint8_t len) { printf("[%10d] %s ID=0x%03X L=%d", NowMs(), isRx ? "RX" : "TX", canId, len); for (int i = 0; i < len; i++) { printf(" %02X", data[i]); // 逐字节十六进制输出 } printf("\n"); }

这个日志函数看起来简单,但实际价值很大。当你收到7F 22 31时,是 DID 没定义还是服务没实现,看收发记录里的原始请求就能定位。加日志的方式注意不要直接往中断里塞printf——多帧传输时中断频率很高,打印 I/O 会拉长中断处理时间,导致丢帧。我通常在Uds_HandleRequest入口和Uds_SendResponse出口各打一条,这里的频率低得多,足够覆盖 99% 的排查场景。

发送超时重发机制是另一个工程实用点。UDS 标准规定 ECU 必须在 P2 时间内响应,否则诊断仪侧会超时。但实际开发中,ECU 偶尔因为 EEPROM 写入之类的高耗时操作没法及时响应。我在工程里加了“P2 超时后延后响应”的策略:进入 P2 超时检查后,先发一个 NRC 0x78(responsePending),然后继续干活,完成后再发真正的正响应。这个机制能救回很多看似“ECU 卡死”的现象。

UDS 自动化测试的对接接口,我觉得是最值得投入的。源码里如果能看到类似Uds_RequestInjection()这样的测试钩子函数,那说明原作者考虑过自动化的事。没有的话也不难补——把Uds_HandleRequest收的字节数组替换成测试向量生成的数据,再用断言检查响应内容,就能在本地自动化回归测试每个 SID 的异常分支。

做整车厂项目时,经常遇到供应商的 ECU 发送的响应不符合标准的情况——有些供应商会在0x22的肯定响应里漏掉一个字节,有些会把 DID 大小端弄反。诊断日志这时候就是最好的谈判工具,把收发记录打出来,是谁的问题一目了然。从那以后,我每次拿到新的诊断源码,第一件事不是跑 Demo,而是先把日志框架挂上,再谈别的。这个方法让我少走了不少弯路,希望也能帮到你——调试 UDS 协议栈,日志和状态机复位比什么都重要。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询