简介:针对汽车电子诊断开发场景的UDS协议栈完整源码包,面向需要在ECU通信、故障诊断与数据交换中集成UDS功能的嵌入式工程师。压缩包内含UDS服务层、ISO 15765-2传输层(TP层)以及CAN/LIN网络适配代码,并配有诊断Demo、使用说明与测试工程,可直接用于学习协议实现或向自研项目移植。资源共75个文件,以C/C++源码(18个.c、17个.h)为主,辅以14个调试命令脚本、配置文件、映射文件及帮助文档等,整体仅521KB,结构紧凑、便于快速定位关键模块。已有5704人学习下载,新人可借助Demo和文档理解诊断会话、DTC读取、ECU内存读写等具体服务流程;有经验的开发者则能参考其网络层分包、重传与错误检测机制,结合自身的CAN ID、波特率配置完成协议栈适配。移植时注意核对底层驱动与硬件平台匹配,并建议在实际总线上做兼容性验证,总体是一份兼顾理论学习与工程落地的UDS开发参考。
1. UDSDemo:一份能跑通的UDS协议栈源码,省掉你三个月造轮子时间
做车载诊断开发的同行应该都有过这种经历:项目一启动,导师扔过来一份ISO 14229-1标准文档,让你“把UDS协议栈实现一下”。标准倒是写得清清楚楚,但真到自己写代码的时候,才发现UDS远比想象中复杂——会话切换、安全访问、DTC读取、27服务解锁、0x22按数据标识符读数据,每一条都要处理NRC否定响应码。更头疼的是,写完不一定能跑通,调试还得对着CAN报文一条条抓。
这份UDSDemo资源包里带的就是一份可以直接读懂的UDS协议栈源码,不是教材里的伪代码,是可以编译、可以烧录、可以对着诊断仪实测的工程。它把14229-1里最常用的诊断服务都实现了,而且代码结构是分层的:传输层、协议层、应用层各管各的,你既能直接拿来用,也能当模板改成自己的协议实现。这几天我重新把它翻出来过了一遍,把里面的分层思路、集成步骤和几个容易踩的坑都整理在下面。
2. 先拆协议栈:UDS的骨架是三层,不是一坨代码
2.1 从ISO 14229-1到代码:为什么需要中间层
直接在应用层里把CAN报文塞给驱动、然后裸写诊断服务的做法,短期能跑,中期就乱。原因在于诊断服务本身是跨层的:0x31例程控制要管请求的合法性(会话是否允许、子功能是否支持)、要构造响应报文、还要和后端的执行逻辑解耦。如果这些全写在一个文件里,改一个DTC采集逻辑就得动诊断主流程,风险不可控。
UDSDemo的分层方式是照着ISO 14229-1的分层模型来的,但在实现上做了更适合嵌入式环境的简化。最底下是CAN驱动层,负责收发CAN帧,处理DLC、CAN ID和字节序;中间是传输层,处理ISO-TP的组包和拆包——因为UDS报文经常超过单帧CAN的8字节限制,需要连续帧发送;最上面是应用层,按服务ID(SID)分发请求,做权限检查、参数合法性校验,最后生成正响应或NRC否定响应码。
其中最有参考价值的是应用层的分发机制。它用一张服务表来登记每个SID对应的处理函数和处理条件,而不是写一串switch-case。要扩展一个新服务时,只需要在表里加一条映射,不用动主循环逻辑。表结构是典型的“配置与运行分离”,代码里把每个服务的会话权限、安全等级、子功能支持范围都作为表的字段,新增一个服务时只需要在表里加一条映射,不用动主循环逻辑。
2.2 核心数据结构:服务表、请求缓冲区和会话状态机
UDSDemo里最值得先读的三个文件是uds_config.h、uds_core.c和uds_transport.c。读代码有顺序,不要一上来就追某个服务的实现,那样会被零散的状态变量绕晕。
先看uds_config.h里的服务表结构体。它定义了一个uds_service_t类型,字段包括SID、最小请求长度、允许的会话范围、是否需要安全访问标志,以及处理函数指针。这个设计解决了一个典型的问题:14229-1里每个服务在不同会话下的行为不一样(比如0x27安全访问只在非锁定会话里才允许),如果把这些规则硬编码到各个服务函数里,整理起来非常麻烦。用一张表统一描述规则,新增服务时改表就行。
然后是uds_session状态机。会话状态是UDS里最容易漏的状态:默认会话、编程会话、扩展会话,各自对应的服务权限不同,并且还有一个会话超时时间(S3Server Timer)。UDSDemo在uds_core.c里用一个定时器回调来刷新会话状态,一旦超时自动切回默认会话。这个逻辑一定要看懂,因为很多诊断仪连不上、刷写中断的现场问题,根因都是会话超时被踹回默认会话。
请求缓冲区用的是环形队列,配合ISO-TP的多帧接收。收到首帧后,传输层计算总长度,如果超过当前缓冲区剩余空间就回NRC 0x33(busy repeat request),否则继续收连续帧。这里有个细节:buffer的大小和ISO-TP的最大报文长度必须匹配,UDSDemo默认配置是4095字节(ISO-TP单包上限),实际项目里如果诊断仪一次性下发2000字节的例程数据,这个缓冲区的压力要提前算清楚。
2.3 选型不纠结:这份源码适合哪类项目
读完上面几个核心模块,可以判断这份源码是否适合自己。它的定位很明确:面向中小型ECU的量产诊断需求,覆盖的是标准里最高频的服务子集——0x10会话切换、0x27安全解锁、0x22读数据、0x2E写数据、0x31例程控制、0x19读DTC、0x14清DTC以及0x3E待机握手。像0x2F输入输出控制、0x34请求下载这类刷写相关服务也有实现,但深度不如商业协议栈(比如对传输层的流控处理比较简化)。
如果你做的是OEM刷写工具链、需要完整的0x34/0x36/0x37刷写序列、要支持多路并发诊断通道,那这份源码需要你自己扩展;如果你做的是一块VCU、BMS或域控制器的诊断从机(SLave),需要稳定处理诊断仪发来的常规诊断命令,那直接拿这份源码改改配置就能用。它最大的价值不在功能全——这类开源或半开源的协议栈从来都不是功能最全的——而在代码结构清晰,读得懂改得动,适合做二次开发的基座。
3. 把UDSDemo跑起来:集成、编译和第一条诊断请求
3.1 移植的第一步:裁剪配置而不是动协议逻辑
拿到源码后不要急着编译。先打开uds_config.h,把与自己MCU平台相关的配置项过一遍。这份UDSDemo的协议栈部分和硬件平台是解耦的,MCU相关的只有一小部分:CAN收发函数指针、定时器获取函数和Flash读写回调。
配置结构大致如下:
/* uds_config.h */ #define UDS_BUFFER_SIZE 4095 /* ISO-TP 最大报文缓冲 */ #define UDS_MAX_SERVICE_NUM 16 /* 服务表项上限 */ #define UDS_DEFAULT_TA_TYPE 0x01 /* 物理寻址(1) / 功能寻址(2) */ #define UDS_S3_TIMEOUT_MS 5000 /* 默认会话超时时间 */ typedef struct { uint8_t sid; uint8_t session_allow_mask; /* BIT0: 默认 BIT1: 编程 BIT2: 扩展 */ uint8_t security_required; /* 0 或 1 */ uint16_t min_len; uint8_t (*handler)(uint8_t* req, uint16_t len, uint8_t* resp); } uds_service_t;逻辑说明:session_allow_mask是位掩码,比如只允许默认会话和扩展会话,就设为0x05。handler是协议栈内部的服务处理函数,你注册新服务时,函数签名必须按这个格式来。所有服务的返回值都是NRC(否定响应码),0x00表示成功正响应,其他值表示否定响应。
需要注意:UDS_BUFFER_SIZE、S3超时、TA类型这几个参数属于“改错了必出问题”的敏感项。缓冲区改小的后果是收不下多帧报文,超时改短会频繁踢出扩展会话(刷写必断),TA类型写错则会诊断仪扫不到ECU。
3.2 接入CAN驱动:回调注册和收发适配
UDSDemo用函数指针来做硬件解耦,这也是嵌入式项目里最常见的做法。你不需要改协议栈内部的任何调用逻辑,只需要把CAN驱动的收发函数注册进来。代码里预留了注册入口:
/* uds_can_interface.c */ void uds_can_init(uds_can_rx_t rx_cb, uds_can_tx_t tx_func) { /* 注册接收回调和发送函数 */ } static void can_rx_isr(uint32_t can_id, uint8_t* data, uint8_t dlc) { /* CAN接收中断里直接喂给协议栈 */ uds_iso_tp_rx_indicate(can_id, data, dlc); }逻辑说明:rx_cb是接收回调,CAN中断里每收到一帧诊断报文就调用一次。tx_func是发送函数,协议栈要发正响应或NRC时,通过这个函数把数据填到CAN驱动发送队列。诊断报文一般在独立的CAN ID段(通常是0x7E0/0x7E8这套,实际以OEM规范为准),所以接收中断里最好做一个ID过滤,不要所有CAN帧都丢给协议栈。
参数说明:CAN ID是标准帧的11位地址,发送和接收的ID是分开的——请求和响应走不同的ID,这是UDS的硬性要求,千万别共用一个ID。
3.3 编译跑通的典型问题:堆栈、字节序和Keil工程路径
这个环节是新手翻车集中的区域,先把三个最常见的问题列出来。第一,ISO-TP组包时大量用memcpy,协议栈的运行栈需求大约在2KB以上,在工程里把任务栈配大一点(比如4KB-8KB),否则跑0x22读数据时直接进hardfault。第二,CAN报文里多字节参数是大端序(Motorola格式),不少MCU是ARM小端核,转换要到位,否则你读回来的VIN码是反的。第三,代码里如果有中文注释,工程文件的编码格式尽量统一成UTF-8 without BOM,尤其在使用老旧IDE版本时,乱码注释有时会导致编译中断。
3.4 快速验证:用CAN分析工具发第一条0x10请求
代码编译烧录进板子后,验证方式不用等诊断仪就位,直接用CAN分析工具(常见的是PCAN或同类设备)手动发帧即可。UDS首次交互建议从扩展会话开始,因为扩展会话权限最高,能跑通0x22、0x31这些服务,方便快速验证问题。
比如发一条切换到扩展会话的请求:CAN ID填0x7E0(请求ID),数据段填 02 10 03。其中02是PCI(协议控制信息),表示这是单帧且数据长度2字节;0x10是SID;0x03是扩展会话子功能。正常响应在0x7E8上,数据段形如 06 50 03 00 32 01 F4——其中0x50是SID+0x40的正响应标识,后面跟的是P2_server_max(0x0032)和P2*_server_max(0x01F4)两个时间参数。P2默认50ms,P2*默认500ms,这两个值就是后面避坑章节要讨论的重点。
4. 核心服务实战:安全访问、DTC读写和例程控制的源码路径
4.1 0x27安全访问:种子与密钥的时序陷阱
诊断安全级别是UDS里最容易出“玄学问题”的地方。现象是诊断仪发送0x27请求后ECU不回正响应,或者回了一个NRC 0x36/0x37。问题通常在时序和状态管理上。
看UDSDemo里0x27服务的实现,注意它的状态机设计:0x27服务分了两个子功能——0x01请求种子、0x02发送密钥。协议栈在收到0x01请求后生成种子,然后必须等待0x02在同一会话里到来,中途不能切换会话。源码里用一个security_level状态变量记录当前解锁的安全级别,如果密钥校验失败会立刻锁定(evaluateKeyAttemptCounter),超过预设的尝试次数(通常3次)后,后续请求直接NRC 0x37。这是标准推荐的防暴力破解机制,代码里已经实现。
调试0x27失败的经验是:先用固定密钥测试(比如把算法简化成直接比对固定数组),跑通了再换成加密算法。很多项目失败在把密钥算法集成得过于仓促,掩盖了时序本身的问题。
/* uds_service_27.c - 安全访问简化流程 */ static uint8_t sec_level; /* 当前解锁的等级 */ static uint8_t attempt_cnt; /* 失败尝试次数 */ static void uds_27_request_seed(uint8_t level) { /* 生成种子:常见做法是取定时器低16位 + 伪随机数混合 */ seed = (uint16_t)(timer_get() & 0xFFFF) ^ alt_seed_mix; sec_level = 0; /* 解锁状态复位 */ attempt_cnt = 0; /* 回正响应:种子+等级 */ } static void uds_27_send_key(uint8_t* key, uint8_t len) { if (attempt_cnt >= 3) { uds_send_nrc(0x37); /* 超过尝试次数,直接拒 */ return; } /* 常见做法: key = seed 经过算法变换,这里用固定逻辑示意 */ if (key[0] == (uint8_t)(seed >> 8) && key[1] == (uint8_t)seed) { sec_level = 1; /* 置位解锁标记 */ uds_send_pos_resp_0x27(); } else { attempt_cnt++; uds_send_nrc(0x36); /* 密钥错误 */ } }逻辑说明:attempt_cnt的设计很关键,它就是前文说的防暴力破解机制。0x37表示超过了尝试次数,0x36表示密钥校验失败。注意“超过次数”和“密钥失败”是两个不同的NRC,排查问题的时候先分清是哪一个——如果一直是0x37,说明之前的失败尝试没被复位;如果一直是0x36,说明算法算出的密钥不一致。
4.2 0x19读DTC和0x14清DTC:状态位的管理必须集中
读故障码是诊断仪用得最频繁的功能,但DTC管理的坑很少在读取逻辑上,而在故障状态位的维护上。ISO 14229-1里,DTC的状态由一个字节描述(bit0测试失败、bit1当前故障、bit2过去故障等),这些位不是读出来的,是在运行时由应用层故障处理逻辑置位的。
UDSDemo里维护了一个dtc_status表,表中记录了每个DTC的当前状态位。0x19服务读取时只是把这个表导出来,0x14清DTC时把整个表清零。实际项目里的血泪经验是:故障检测逻辑和DTC状态管理一定要分开。如果你在MCU的ADC采集中断里直接修改DTC状态位,和诊断主循环产生竞争条件,轻则读到的DTC状态错乱,重则清DTC清不干净——明明清了,下电再上电故障码又回来了,这是因为DTC存到了Flash,而清DTC时根本没有擦Flash。
UDSDemo里的处理方式是:应用层只要更新DTC的测试结果(test_failed位),协议栈内部根据测试结果去推算其他状态位。耦合度低,不容易累加出逻辑错误。
4.3 0x31例程控制:耗时任务的异步处理范式
例程控制服务(0x31)和前面几个服务的明显区别在于:它经常要做耗时操作(比如擦除Flash、写校准数据),不能在CAN中断的上下文里同步执行,否则会卡死整个通信。
UDSDemo对0x31的处理采用异步范式。收到0x31请求后,先校验例程编号(routineIdentifier)和子功能(startRoutine/stopRoutine/requestRoutineResults),然后立刻回一个“例程已启动”的正响应(包含0x01暂停的结果),实际执行放到主循环的后台任务里。执行结束后再通过周期性的结果请求(requestRoutineResults)返回执行结果。
/* uds_service_31.c - 例程控制的异步框架 */ static uint8_t routine_running = 0; static uint16_t routine_result = 0; static uint8_t uds_31_start(uint16_t routine_id) { /* 只做登记,立即返回 */ routine_running = 1; /* 标记开始 */ app_start_routine(routine_id); /* 触发后台任务 */ return 0x00; /* NRC=0 表示正响应 */ } /* 主循环周期调用 */ void uds_poll_routine_result(void) { if (routine_running) { if (app_get_routine_status(&routine_result) == ROUTINE_DONE) { routine_running = 0; /* 存储结果,供 requestRoutineResults 读取 */ } } }逻辑说明:这种设计避免了一个经典故障——诊断仪发送0x31请求后,ECU因为忙于擦Flash而不再响应任何CAN消息,导致诊断仪报“ECU无响应”。实际上标准允许的做法是长响应(pended response),但工程上很多人图省事直接在中断里同步执行,这就是为什么某些项目刷写时总断连的根因。
5. 避坑专题:UDS协议栈调试中反复踩到的五个真问题
5.1 CAN ID过滤没写,诊断仪发来功能寻址也照单全收
现象:ECU对功能寻址(2xx)和物理寻址(7xx)都回了响应,诊断仪显示多个ECU同时答复,总线乱套。
原因:协议栈的接收层没有区分TA类型(target address type),把广播报文当单播处理了。
解决:在CAN接收回调里判断报文ID。物理寻址的诊断请求只应由目标ECU响应,功能寻址的请求(比如0x3E待机握手)则按需响应。UDSDemo的配置里有一个UDS_DEFAULT_TA_TYPE字段,但那只影响协议栈内部的行为,CAN接收中断的ID过滤必须自己做。我一般会维护一张诊断CAN ID过滤表,不匹配的帧直接丢弃。
5.2 P2和P2*时间参数没配对,诊断仪总报超时
现象:0x10切扩展会话后,紧接着的0x22请求一发出就报“超时”,但单独发0x10是正常的。
原因:0x10的正响应里携带了P2_server_max和P2*_server_max,诊断仪会按这个时间等待ECU响应。如果ECU端实际处理时间超过了P2,应该发NRC 0x78(响应待定)并进入P2状态。UDSDemo默认P2为50ms、P2为5000ms,但实际项目里,如果ECU的NVM读取或DTC采集超过50ms,就会触发这个超时。
解决:在0x10正响应里如实上报自己的真实时序参数。不能随意填一个大值,因为诊断仪工具链往往按这个参数设置自己的超时阈值——你填了50ms但实际响应要80ms,必然超时。正确做法是:P2填自己的最大响应时间(留余量),P2*填最长例程时间(比如擦Flash的典型时间×2),同时确保耗时操作都走了0x78+延时的路径。
5.3 安全访问的失败计数没有按会话清零
现象:连续输错3次密钥后,重新请求种子,仍然返回NRC 0x37,必须下电重启才能恢复。
原因:attempt_cnt在会话切换或重新请求种子时没有复位。虽然ISO 14229-1对计数器的生命周期没有绝对明确,但工程共识是:新的安全访问序列开始(重新请求种子)时,计数器应当清零,否则诊断仪一次输错3次,你的ECU就得断电才能解锁,这在产线上后果就是整车下线检测不通过。
解决:在uds_27_request_seed()函数里把attempt_cnt复位为0。这是UDSDemo最早版本的一个小坑,后面版本已经修正,但如果你拿到的是旧版本代码,这个问题非常隐蔽。排查思路:连着发3次错误密钥,再发一次请求种子看NRC是否恢复——不能恢复就检查计数器的复位路径。
5.4 多帧接收缓冲区用环形队列,但没做长度预检
现象:诊断仪下发一个0x31例程(数据段含长参数),ECU收到首帧后缓冲区溢出,回NRC 0x33,但诊断仪端显示“请求被拒绝”。
原因:ISO-TP首帧里携带总长度,UDSDemo的接收逻辑会先判断总长度是否超出缓冲区剩余空间。但如果你在移植时把udsa_buffer_size改成2048,而诊断仪按标准最大长度4095下发请求,就会在首帧阶段被拒。
解决:先看协议栈buffer大小的配置是否和ISO-TP支持的最大长度匹配。如果内存受限、只能用2048字节,必须在诊断规范里约束诊断仪的最大请求长度(通常在OEM的诊断规范附录里有定义)。不要指望协议栈侧能弹性扩容——嵌入式里没有动态内存来做这个事,至少在这种轻量级协议栈里不现实。
5.5 时序问题:请求刚收到就发了NRC 0x10
现象:诊断仪发0x22请求,ECU立刻回NRC 0x10(generalReject),但从逻辑上看服务表和会话权限都没有问题。
原因:这是典型的“请求还没处理完就被下次会话状态检查抢先了”。在一些移植版本里,uds_core.c的入口先检查当前会话是否允许该SID,但如果在会话切换的临界状态(比如刚收到0x10切会话的请求,正响应还没来得及发),此时收到的任何新请求都可能在旧的会话状态下被拒绝。
解决:协议栈的主循环处理应该保证“一条请求处理完成后再处理下一条”。检查你的CAN驱动是否是中断收帧、主循环处理——如果是,问题不大;如果你是直接在CAN中断回调里同步调用了协议栈入口,就要加上处理中的状态锁,防止重入。这也是为什么UDSDemo主循环采用周期轮询的原因:case处理天然串行,逻辑清晰。
6. 把源码改成自己的协议栈:加一个私有服务的完整路径
最后分享一个实用技巧:在UDSDemo框架下扩展一个私有诊断服务需要改哪几个文件、要走通哪些步骤。这个流程你以后会经常用到,因为实际项目的诊断规范总会比标准多几条私有服务——比如整车下电诊断、传感器标定写入。
假设你要加一个0xC0服务(自定义:读取MCU内部温度)。第一步,在uds_config.h的服务表里注册一个新条目:
/* uds_config.h - 服务表追加条目 */ { 0xC0, 0x07, 0, 2, uds_service_c0_read_temp },参数说明:0xC0是SID;0x07表示允许所有会话(BIT0/1/2全置位);0表示不需要安全访问;2表示请求最小长度为2字节(SID+1个数据字节)。
第二步,在uds_core.c里实现处理函数:
uint8_t uds_service_c0_read_temp(uint8_t* req, uint16_t len, uint8_t* resp) { uint8_t temp_raw; if (len < 2) { return 0x13; /* incorrect length */ } temp_raw = app_read_mcu_temp(); resp[0] = 0xC0 | 0x40; /* 正响应SID */ resp[1] = temp_raw; return 0x00; /* 0 表示成功 */ }逻辑说明:处理函数的返回值就是NRC。0表示正响应,协议栈框架会把resp缓冲区的内容按UDS格式发出;非0值表示否定响应,框架会自动构造NRC帧。注意resp[0]填的是SID|0x40,这是UDS正响应的硬性规定,漏了这一步,诊断仪会直接把响应丢弃。
第三步,在uds核心分发入口中确认处理函数已经加入函数映射表。UDSDemo用的是表驱动分发,所以正常只需要改配置文件和实现文件两处。有些旧版本代码里可能保留了一个switch-case的兜底分发,要一并检查,否则会走default分支返回NRC 0x11(serviceNotSupported)。
第四步,用CAN工具验证。发送 02 C0 01(单帧2字节),预期返回 03 C0 01 1F(假设温度原始值为0x1F)。
这个流程走完后,你的诊断协议栈就真正变成了“自己的代码”。从那以后我每次扩展服务,都会强制走一遍这个流程:先改服务表、再写handler、最后用CAN工具发原始帧验证正响应和NRC分支——尤其是NRC分支,两个错误请求、两个正确请求,六个用例跑完才敢对接诊断仪。这套习惯帮我挡掉了好几次会上被诊断仪打回来的尴尬。希望这个流程和前面那些踩坑记录能帮到你,让你在做UDS开发时少走几段弯路。
本文还有配套的精品资源,点击获取