UDS诊断协议栈C语言实现:从分层到刷写实战
2026/8/31 17:44:37 网站建设 项目流程

简介:本资源是一份面向汽车电子开发工程师与嵌入式系统学习者的UDS协议C语言实现轻量级参考代码,聚焦于ISO 14229标准下诊断服务的核心逻辑落地,解决ECU端UDS服务响应、会话管理、错误码处理及CAN帧封装等关键开发问题。压缩包为9KB的RAR格式,共含2个文件:1个头文件(.h)定义UDS服务枚举、数据结构与接口函数原型;1个源文件(.c)实现诊断会话切换、0x10/0x22/0x2E等常用服务响应逻辑、ISO-TP分包重组基础框架及标准错误码返回机制。代码结构清晰、注释规范,便于嵌入现有CAN驱动工程快速验证UDS交互流程。目前已有1193人学习下载,适合初涉车载诊断协议的开发者理解服务映射关系、调试请求/响应时序,并作为自研UDS通信库的起点模块或教学演示范例。 先说明一个事:很多做嵌入式的朋友第一次接触 UDS(Unified Diagnostic Services,统一诊断服务)时,第一反应是去搜“UDS 协议”,然后被 ISO 14229 那一摞几百页的文档吓退。其实不必。UDS 本质上是定义在汽车电控单元(ECU)和诊断仪之间的一套“问答规则”,只要搞懂了分层结构、几个高频服务、刷写时序和错误码,你就能在 C 语言工程里把它落地。这篇东西我会按我自己做诊断协议栈时的思路来写,从分层、服务实现、刷写链路、时间参数与 NRC 排查,再到工程化状态机的设计,尽量把踩过的坑和最终沉淀下来的写法一起讲清楚。

先交代一下背景:我在几个量产项目的 BootLoader 和应用层诊断模块里写过 UDS,用纯 C 语言,跑过 AUTOSAR 环境也跑过裸机环境。下面这些内容不是 ISO 文档的翻译,是我在实际项目里验证过的东西。

1. 先说透 UDS 的分层结构:代码写得乱不乱的根源在这

很多人在写 UDS 代码之前,没想清楚一个问题:UDS 到底跑在哪一层?答案说起来简单,但工程上极容易混淆。

UDS 在 OSI 模型里对应的是应用层(第 7 层),它自己不管数据怎么分包、怎么重传、怎么确认。真正负责把一长串诊断数据从物理层搬到应用层的,是下面的传输层和网络层。在车载 CAN 总线场景下,这一层通常由 ISO 15765-2(也就是常说的 CAN TP)承担。换句话说:你调用的uds_rx_indication(data, len)回调里拿到的 data,已经是 CAN TP 层帮你拼好的完整诊断报文,而 UDS 代码只需要关心这份数据里的服务 ID、子功能、参数,然后给出响应。这个边界一旦清晰,代码结构就不会乱。

我在项目里会把诊断模块拆成三层来写:

  • 接口层:负责从 CAN TP 拿数据、送数据,这一层通常叫uds_transport.c
  • 核心层:真正的协议解析和响应逻辑,只处理字节流,不关心 CAN ID 和波特率,比如uds_service.c
  • 服务实现层:每个服务一个文件或一组函数,比如uds_sid_19.cuds_sid_27.c

刚开始写诊断协议栈的人最容易犯的错误,是把 CAN ID 过滤、多帧缓存、服务分发全塞在一个中断回调里。结果就是调试时根本分不清问题是出在传输层还是应用层。

从 C 语言实现的角度看,一个最小可用的数据结构大概是这样的:

typedef struct { uint8_t sid; /* 服务ID */ uint8_t sub_func; /* 子功能 */ uint8_t data[UDS_MAX_DATA_LEN]; uint16_t data_len; } uds_message_t; typedef struct { uint8_t request[UDS_MAX_DATA_LEN]; uint16_t request_len; uint8_t response[UDS_MAX_DATA_LEN]; uint16_t response_len; uint8_t nrc; /* 否定响应码 */ uint8_t session; /* 当前诊断会话 */ uint8_t security_level; /* 安全等级 */ } uds_context_t;

这个上下文结构体会贯穿整个诊断流程。注意,response缓冲区和request缓冲区一定要分开,因为有些服务(比如 22 服务读数据、19 服务读 DTC 信息)是允许在请求还没处理完时就构造响应的,如果共用一个缓冲区,构造响应时很容易把还没解析完的请求数据覆盖掉。

那 CAN TP 层怎么区分 UDS 的请求和响应?靠 CAN ID。诊断物理寻址的请求 CAN ID 和响应 CAN ID 是分开的,这是 ISO 15765-2 的寻址规则。比如 0x7E0 是功能寻址请求 ID,0x7E8 是物理响应 ID。UDS 层不需要关心这些,但你在做通信矩阵的时候必须定义清楚。很多项目联调时发现“发送没问题但对端收不到响应”,查到最后是 CAN ID 配置反了。

关于分层,还有一个容易忽略的点:**功能寻址(functional addressing)**与 **物理寻址(physical addressing)**的区别。

  • 物理寻址:请求发给唯一一个 ECU,通常用 2 字节地址,比如 0x7E0 请求 / 0x7E8 响应。响应是必须的。
  • 功能寻址:请求发给总线上多个 ECU,比如 0x7DF。功能寻址下,除了 0x19 服务(读 DTC 信息)等少数服务外,ECU 通常不响应,因为多个 ECU 同时回报文会冲突。

我在做诊断仪模拟器的时候,经常看到有人把功能寻址的请求也强制回复否定响应,这是不对的。正确的做法是:在uds_rx_indication入口判断一下本次请求的 CAN ID 是不是功能寻址 ID,如果是且服务不支持功能寻址,直接静默丢弃,不回帧。

2. 六大高频诊断服务的 C 语言实现要点

UDS 的服务号很多,从 0x10 到 0x87,但一个诊断应用(比如 BootLoader 刷写)真正高频用到的其实就那么几个。我把它们分成两组:基础控制类和 DTC 类。

2.1 0x10 服务:会话切换必须带状态机

0x10 服务是诊断功能的“总开关”,子功能包括:

  • 0x01:默认会话(Default Session)
  • 0x02:编程会话(Programming Session)
  • 0x03:扩展诊断会话(Extended Diagnostic Session)

C 语言实现里最容易错的地方,是切换会话时没有清理上一会话的上下文。比如从扩展会话切到默认会话时,安全解锁状态、DTC 设置状态如果不清掉,后面会出大问题。这是一个真实事故现场:某项目在做 EOL(下线检测)时,产线设备发送 0x10 03 进入扩展会话,然后直接做 22 服务读版本号,读不到。查了三天,最后发现是 BootLoader 里的代码在会话切换时没有把上一次诊断仪留下的“服务暂停标志”清掉,导致诊断仪发过来的请求被当成非法状态拒绝。

我现在的实现是这样:

static void uds_session_change(uds_context_t *ctx, uint8_t sub_func) { /* 先复位所有会话相关状态 */ ctx->security_level = 0; ctx->dtc_setting_status = 0; switch (sub_func) { case 0x01: ctx->session = SESSION_DEFAULT; /* 会话超时定时器重新计时,P2/P2* 使用默认值 */ uds_timer_start(ctx->session_timer, S3_TIMER_MS); break; case 0x02: ctx->session = SESSION_PROGRAMMING; break; case 0x03: ctx->session = SESSION_EXTENDED; break; default: uds_send_nrc(ctx, NRC_SUB_FUNCTION_NOT_SUPPORTED); return; } uds_send_positive_response(ctx, 0x10, sub_func); }

注意uds_send_positive_response的参数是 (ctx, sid, sub_func),而不是直接回 “0x10 0x03 0x00 0x32 0x01 0xF4”。因为不同子功能返回的“会话参数记录”长度不一样,让各服务自己构造响应,核心分发函数只负责封装,这个设计后期扩展会省很多事。

2.2 0x22 / 0x2E 服务:读数据按 DID 查表,写数据务必校验长度

0x22 服务(ReadDataByIdentifier)和 0x2E 服务(WriteDataByIdentifier)是按 DID(Data Identifier,数据标识符)来读写数据的。DID 是 2 字节,比如 0xF190 是 VIN 码,0xF18C 是 ECU 硬件版本号。每个 DID 对应的数据长度、存储位置、访问权限都不一样。

C 语言实现不建议用 switch-case 堆几百种 DID,而是用一张表:

typedef struct { uint16_t did; uint8_t data_len; uint8_t access_type; /* 读/写/读写 */ uint8_t min_session; /* 允许访问的最低会话等级 */ uint8_t security_level; /* 是否需要安全解锁 */ uint8_t *data_ptr; } uds_did_table_t; static const uds_did_table_t did_table[] = { { 0xF190, 17, ACCESS_READ_WRITE, SESSION_DEFAULT, SECURITY_NONE, (uint8_t*)vin_data }, { 0xF18C, 4, ACCESS_READ, SESSION_DEFAULT, SECURITY_NONE, (uint8_t*)hw_version }, { 0xF191, 4, ACCESS_WRITE, SESSION_EXTENDED, SECURITY_LOCK, (uint8_t*)sw_version }, };

这个表的好处是,新增一个 DID 只改表,不改逻辑。每次读取时遍历表,匹配到 DID 后先检查访问权限(当前会话等级够不够、安全等级够不够),再检查请求的数据长度是否等于表中配置的长度。注意:0x22 服务请求里带的数据长度必须是 2 字节 DID,不能带别的,有些实现把 DID 长度写错,导致 0x22 请求被当成 0x2E 的短格式,解析错位。

0x2E 服务写数据时,最容易忽略的是“写之前先确认数据长度”。比如写 VIN 码,DID 0xF190 长度是 17,请求帧格式是2E F1 90 17 个字节,如果测试仪发来 16 个字节,必须回 0x13(IncorrectLengthOrFormat)而不是 0x22(ConditionsNotCorrect)。这个细节在一致性测试(conformance test)里是必测项。

2.3 0x19 服务:读 DTC 信息,子功能 01/02/04/06

19 服务是 UDS 里最复杂的服务之一,子功能很多。项目里最常用的几个:

  • 0x01:按 DTC 状态掩码读已存储 DTC。
  • 0x02:按 DTC 状态掩码读当前 DTC。
  • 0x04:读快照记录(FreezeFrame)。
  • 0x06:读扩展数据(比如发生次数、老化计数、时间戳)。

先说 19 01 的响应格式:请求 19 01 掩码响应 59 01 DTC数量 DTC1 状态1 DTC2 状态2 ...。DTC 是 3 字节,状态是 1 字节。注意这里的“DTC 数量”是 1 字节,最多 0xFF,但一般一个 ECU 的 DTC 不可能这么多。

C 语言实现时,DTC 存储通常用一个结构体数组:

typedef struct { uint32_t dtc_code; /* 3 字节有效,比如 0xC00101 */ uint8_t status; /* 状态位 */ uint8_t occur_count; /* 发生次数 */ uint8_t aging_count; /* 老化计数 */ uint8_t snapshot_len; uint8_t snapshot_data[UDS_MAX_FRAME_DATA]; } dtc_entry_t; static dtc_entry_t dtc_table[DTC_MAX_COUNT];

状态位每一位的含义要背下来:

  • bit0: testFailed(当前测试失败)
  • bit1: testFailedThisOperationCycle(本次运行循环测试失败)
  • bit2: pendingDTC(待确认)
  • bit3: confirmedDTC(已确认)
  • bit4: testNotCompletedSinceLastClear(上次清除后未完成测试)
  • bit5: testFailedSinceLastClear(上次清除后测试失败)
  • bit6: testNotCompletedThisOperationCycle
  • bit7: warningIndicatorRequested

这个字节在 19 服务里是核心,因为 19 01 的第三个参数“状态掩码”就是用来筛选 DTC 的。比如要读“已确认的故障”,掩码就是 0x08。筛选逻辑是(dtc.status & mask) != 0,不是== mask这里有个坑:测试仪发来掩码 0x08,你的 DTC 状态字节是 0x0A(confirmed + testFailedSinceLastClear),按&逻辑应该返回,但有的实现写成了==导致漏报。

2.4 0x14 服务:清除 DTC,注意“运行循环”条件

0x14 服务(ClearDiagnosticInformation)用来清除 DTC,请求格式是14 掩码高字节 掩码低字节,通常掩码是 0xFFFFFF。这个服务跟 0x19 是搭配使用的。热搜里有个问题“当前故障 DTC 能否被 14 服务清除”,我的回答是:能清,但清零只是把状态位和发生次数清掉,如果故障条件仍然存在,运行循环很快会再次报出。也就是说,14 服务清的是“记录”,不是“根因”。

实现 0x14 时,一定要先清除快照数据和扩展数据,不能只清 DTC 码。有些发动机控制器里,故障码清掉了但快照数据还在,产线上复测时 DTC 读不到,但冻结帧还能读出来,会被质量问题部门误判为“清不掉故障”。

2.5 0x27 服务:安全访问,密钥的种子与密钥

27 服务(SecurityAccess)是刷写流程里最关键的环节。流程是两个步骤:

  1. 请求种子:诊断仪发27 01(请求种子),ECU 回67 01 种子数据
  2. 发送密钥:诊断仪对种子做特定算法计算,发27 02 密钥数据,ECU 校验,成功回67 02,失败回 NRC 0x35(invalidKey)。

“密钥的作用”其实就是访问控制。没有通过安全校验,很多写操作(0x2E、0x34 等)会被拒绝。种子长度通常是 4 字节或 8 字节,密钥算法各厂商不同,有 AES、有 CRC、有自定义查表。

C 语言实现要注意几个点:

  • 种子和密钥是配套的,同一个种子不能重复用于两次解锁。ECU 端要记录上一次种子,收到密钥后立刻把种子状态置为“已使用”,防止重放攻击。
  • 安全等级是分级的。比如等级 1(0x01/0x02)是普通解锁,等级 3(0x03/0x04)是 BootLoader 专用。每个等级对应不同的算法。
  • 失败次数限制:连续失败 3 次后,必须等待一段时间(通常是 10 秒)才能再次请求种子。这个叫“延时尝试”。实现时用一个定时器,没到时间发 27 01 就回 NRC 0x36(exceedNumberOfAttempts)。

最容易被忽略的是:27 服务的种子返回里,字节顺序和表示法。有些 ECU 是大端(种子字节数组不动),有些是小端(先发低位字节),跟测试仪联调时如果发现密钥算法明明对但一直回 0x35,先排查字节序。

2.6 0x85 服务 / 0x87 服务:DTC 设置控制与链路建立

0x85 服务(ControlDTCSetting)用来开启/关闭 DTC 记录。刷写前要先关 DTC 记录(发85 02),刷完再打开(发85 01)。关闭 DTC 设置不是清 DTC,只是暂停记录新故障,这个在 19/14 服务配合里很重要。

0x87 服务(LinkControl)通常用于进入 BootLoader 的“链路建立”阶段。有些 BootLoader 刷写流程会在擦除 Flash 之前用 87 01 建立链路,确认 bootloader 和刷写工具之间的传输链路稳定。实现上只是一个标志位,但要注意:不是所有 ECU 都支持这个服务,不支持时直接回 NRC 0x11(serviceNotSupported)。

3. 34/36/37 刷写链路:BootLoader 场景下的完整时序与易错点

UDS 刷写(也叫软件下载、重编程)是 BootLoader 最核心的功能。完整刷写流程通常分三个阶段:预编程、编程会话、传输数据。

3.1 预编程阶段:先关“故障记录”和“通信”

预编程的典型序列:

  1. 10 03:进入扩展诊断会话。
  2. 85 02:关闭 DTC 记录。
  3. 28 03 03:关闭应用报文的通信(comControl)。
  4. 27 01/02:安全解锁。
  5. 10 02:切换到编程会话(有的 ECU 是先解锁再切会话,顺序以具体规范为准)。

这个阶段常见的失败点是:测试仪发送10 02后,ECU 没有停止发送应用报文,导致 CAN 总线冲突。所以 28 服务(CommunicationControl)必须在切换编程会话前执行。实现上,28 服务会设置一个通信静默标志,后续诊断响应只保留物理寻址的报文,功能寻址和大部分应用报文都不再发送。

3.2 传输阶段:34 请求下载 → 36 传输数据 → 37 请求退出传输

刷写传输数据的核心序列是:

  1. 34 00 44 地址(4字节) 长度(4字节):请求下载。00 表示按地址下载,44 表示地址和长度都是 4 字节。
  2. ECU 回74 00 块长度(1字节) 块长度值,这里的“块长度”是 ECU 愿意接受的最大单块数据长度,测试仪后续每帧 36 的数据不能超过这个值。
  3. 测试仪循环发36 块序号 数据...,块序号从 1 开始递增。
  4. 数据发完,发37,ECU 回77,表示传输结束。

C 语言实现 34 服务时,要把地址和长度存到上下文里。最常见的一个 bug 是:地址和长度没有区分“按地址下载”和“按内存地址下载”两种格式。格式字节 0x00 对应的是地址和长度一起给,格式字节 0x01 对应的是只给长度不给地址(例如在已经指定了固定下载地址的场景)。解析时要把格式字节抠出来,按不同格式偏移解析后续字节。

36 服务实现时,有一个数据校验点:当前块序号必须等于上次块序号加 1。如果测试仪重发了上一块数据(比如超时重传),ECU 应该回 NRC 0x73(wrongBlockSequenceCounter)还是忽略?实际项目中,我建议对“等于上次序号”的 36 请求做幂等处理——直接回肯定响应但不重复写入,因为 CAN 总线偶发丢帧是正常现象,测试仪重发上一帧也是合理行为。但有的大厂测试工具会认为这是错误,所以最终取决于你对接的规范要求。

刷写里最容易出问题的,不是 34/36/37 本身,而是块序号溢出。块序号是 1 字节,最大 0xFF,如果写入的文件超过 255 个块,序号又会绕回 1。正确做法是:每次收到 36 后,更新一个“当前块序号”变量,判断逻辑用“等于上一块 +1 或上一块等于 255 且当前块等于 0”,而不是简单比较<

3.3 刷写数据的写入位置与 Flash 驱动隔离

34 服务里给的地址,通常是 Flash 逻辑地址,比如 0x08010000。C 语言实现里,真正调用 Flash 写入函数的时机有两个选择:

  • 方案 A:收到 36 的每一帧数据,立即写入对应 Flash 地址。
  • 方案 B:先缓存到 RAM,全部收完再统一写入。

方案 A 实现简单,但擦写耗时可能超过 CAN TP 层的 P2* 超时,需要在写 Flash 期间周期性调用uds_on_pending()发送 NRC 0x78(responsePending)。方案 B 需要一大块 RAM 做缓存,简单项目可能没有足够空间。

我推荐方案 A,但要注意:写 Flash 函数必须在单独的 Flash 驱动模块里,不要和 UDS 协议代码耦合。刷写失败时,BootLoader 要能判断是通信问题还是 Flash 驱动问题,这样故障现场才可分析。

3.4 0x78 responsePending 的处理方法

诊断仪发来的请求如果 ECU 处理时间超过 P2 超时(通常是 50ms),就必须先回一个 NRC 0x78(responsePending),告诉诊断仪“我正在处理,你不许超时”,然后在 P2* 超时(通常是 5000ms)内给出真正的响应。

这个机制在 36 服务刷写时特别关键。Flash 擦除可能耗时几百毫秒甚至几秒,你不可能让诊断仪一直等。正确做法是:收到 36 请求后,先回 0x78,然后启动一个后台任务执行实际擦写,擦写完成后再发送真正的肯定响应。

我见过一个比较粗暴的实现:在中断里直接调用 Flash 擦除函数,结果是中断被阻塞几十毫秒,CAN 接收缓冲区溢出,刷写失败。正确做法是:UDS 层只负责入队和回 0x78,Flash 擦写在主循环或独立任务里执行。

4. 时间参数与 NRC 错误码:诊断联调中最容易卡壳的两个区域

4.1 P2 / P2* / S3:三个时间参数决定诊断体验

UDS 协议里定义了三个关键时间参数:

  • P2:服务器处理请求的最大时间,标准默认 50ms。超过 P2 必须先回 0x78。
  • P2*:服务器处理“长请求”的最大时间,标准默认 5000ms。回 0x78 后到最终响应的间隔不能超过 P2*。
  • S3:会话超时时间,默认 5000ms。如果诊断仪在 S3 时间内没有发任何请求,ECU 自动从非默认会话回到默认会话。

C 语言实现里,P2 和 P2* 通常用一个 1ms 或 10ms tick 的软件定时器。收到请求时启动定时器,在响应发送函数里判断定时器是否超时。如果超时,先发 0x78,再继续处理。

实际工程里比较常用的配置是:P2 = 50ms,P2* = 2000ms,S3 = 5000ms。但如果某个 22 服务读取的数据在 EEPROM 里,EEPROM 读取本身可能就要几十毫秒,这时候如果 P2 只按 50ms 算,很容易误触发 0x78。我的建议是:P2 的计时起点不是“收到请求”,而是“确认这个请求不能在一个固定短周期内处理完”。所以代码里通常不放“P2 定时器”,而是放一个“短处理定时器”+“长处理定时器”的双阈值。

4.2 否定响应码 NRC:0x7E 与 0x10/0x22/0x24 的排查链路

NRC(Negative Response Code)是 UDS 联调时最常看的东西。NRC 的格式是7F 服务ID NRC码,比如7F 27 35表示 27 服务回“密钥错误”。

常见服务对应的 NRC 归属:

NRC含义触发场景
0x10generalReject请求格式无法解析、服务忙
0x11serviceNotSupported服务 ID 不支持
0x12subFunctionNotSupported子功能不支持
0x13incorrectMessageLengthOrInvalidFormat报文长度或格式错误
0x22conditionsNotCorrect条件不满足(比如未解锁就执行 34)
0x24requestSequenceError请求顺序错误(比如 36 前没发 34)
0x31requestOutOfRange参数超出范围(比如 34 的地址不对)
0x33securityAccessDenied需要安全访问但没解锁,或等级不够
0x35invalidKey密钥错误
0x36exceedNumberOfAttempts超过尝试次数
0x37requiredTimeDelayNotExpired延时时间未到
0x7EsubFunctionNotSupportedInActiveSession当前会话下不支持该子功能
0x7FserviceNotSupportedInActiveSession当前会话下不支持该服务

热搜里提到的“uds 否定应答码 7E”就是 0x7E:当前会话下子功能不支持。最常见的场景:在默认会话下发85 02(关闭 DTC 记录),但 85 服务只允许在扩展会话或编程会话下使用,于是 ECU 回7F 85 7E。排查思路很简单:

  1. 先确认当前会话是不是默认会话(发 10 01 再读会话,或者发 22 F1 00 之类读会话数据的 DID)。
  2. 再确认目标服务在你所处的会话等级里是否被允许。
  3. 如果会话没问题,检查代码里uds_session_supported()函数对会话等级的判断条件。

0x10 这个 NRC 特别容易误用。有些新手在解析报错、长度不对、服务忙时都回 0x10,实际上应该细分:长度不对回 0x13,请求顺序错回 0x24,条件不满足回 0x22。诊断仪一般会根据 NRC 码做不同的 UI 提示,用错码会误导排障。

4.3 排查实例:刷写时 36 服务一直回 0x24

我调过一个 BootLoader,现象是:测试仪发 34 成功,然后连续发 36,ECU 却一直回7F 36 24(requestSequenceError)。排查过程是这样的:

  1. 先检查 34 是否真正成功。用 CAN 报文分析仪看 34 请求的响应,确实是74 00 02 00 80
  2. 检查 36 的第一个数据帧。发现块序号是 0x01,符合预期,但 ECU 还是回 0x24。
  3. 检查代码里记录“传输进行中”的状态变量。最后发现,34 服务里把状态置为DOWNLOAD_STARTED,但随后因为 34 响应里的“块长度”解析错误,代码走到一个错误分支把状态复位成DOWNLOAD_IDLE

问题根源不在 36,而在 34 的响应构造。34 响应里的块长度表示“ECU 可接收的最大单帧数据长度(不含 36 服务ID和块序号)”,但那张表的单位是字节。如果测试仪配置的块长度大于 ECU 能提供的值,ECU 按规范会拒绝 34,直接回 31。但我们这个案例更隐蔽:代码里把块长度字段按整数存了,但 34 处理完没正确更新上下文状态。

从那之后,我在 34 和 36 之间加了一个很简单的状态断言:

static bool uds_is_download_active(uds_context_t *ctx) { return (ctx->download_state == DOWNLOAD_STARTED); }

在 36 入口处直接判断:

if (!uds_is_download_active(ctx)) { uds_send_nrc(ctx, NRC_REQUEST_SEQUENCE_ERROR); return; }

这个状态机是刷写功能不出错的基础。

5. 工程化落地:诊断状态机、配置表与我的避坑清单

5.1 会话、安全等级、通信状态:三个正交的状态机

UDS 的工程实现,说白了是三个状态机的协同:

  • 会话状态机:DEFAULT / PROGRAMMING / EXTENDED,由 0x10 服务驱动,S3 超时自动回到 DEFAULT。
  • 安全状态机:LOCKED / UNLOCKED 以及等级,由 0x27 服务驱动。
  • 传输状态机:IDLE / DOWNLOAD_STARTED / TRANSFERRING / DOWNLOAD_COMPLETE,由 0x34 / 0x36 / 0x37 服务驱动。

这三个状态机必须独立维护,不能用一个枚举塞在一起。我之前在项目里见过把SESSION_EXTENDED + LOCKED + DOWNLOAD_STARTED合并成一个状态码的做法,结果就是每次加新功能都要改一堆 switch-case,代码很快就没法维护了。

独立状态机的好处是:

  • 可以单独测试每个状态机。
  • 会话切换时,安全状态复位、DTC 设置复位只影响自己的模块,不影响传输状态。
  • 诊断仪在编程会话中途发 10 01 切回默认会话,传输状态必须能正常复位。

5.2 配置表驱动:DID 表、DTC 表、服务表统一管理

上一节提到了 DID 表,实际上服务本身的注册也可以做成表。这样新加一个服务,只需要在表里加一行。

typedef struct { uint8_t sid; uint8_t allowed_session; /* 允许的会话掩码 */ uint8_t security_req; /* 是否需要安全访问 */ uint8_t (*handler)(uds_context_t *ctx, uint8_t *data, uint16_t len); } uds_service_entry_t; static const uds_service_entry_t service_table[] = { { 0x10, SESSION_ALL, SECURITY_NONE, uds_service_10 }, { 0x22, SESSION_ALL, SECURITY_NONE, uds_service_22 }, { 0x27, SESSION_EXT | SESSION_PROG, SECURITY_NONE, uds_service_27 }, { 0x2E, SESSION_EXT | SESSION_PROG, SECURITY_LOCK, uds_service_2E }, { 0x34, SESSION_PROG, SECURITY_LOCK, uds_service_34 }, { 0x36, SESSION_PROG, SECURITY_LOCK, uds_service_36 }, { 0x37, SESSION_PROG, SECURITY_LOCK, uds_service_37 }, { 0x85, SESSION_EXT | SESSION_PROG, SECURITY_LOCK, uds_service_85 }, { 0x19, SESSION_ALL, SECURITY_NONE, uds_service_19 }, { 0x14, SESSION_EXT | SESSION_PROG, SECURITY_LOCK, uds_service_14 }, };

分发时先查表,找到后检查会话和权限,再调用 handler。这个结构清晰,而且一致性测试时很容易对照规范逐条验证。

5.3 实际开发中踩过的坑清单

最后列几个我在 UDS 开发中真正踩过、且网上资料很少提到的坑:

第一个坑:诊断请求的进入时机。

CAN 接收中断里只要收到完整报文就调用uds_rx_indication是没问题的,但如果在主循环里轮询接收队列,要注意“不同服务的处理时间差异”。有的服务处理很快(比如 0x87),有的服务处理很慢(比如 0x19 读快照),如果你在主循环里一次处理完一个请求再回响应,后面的请求会被拖住。建议是:主循环里每次只处理一个队列头的请求,处理完就退出循环,避免长服务饿死短服务

第二个坑:软件刷写的 flash 驱动时序。

36 服务调用 Flash 写入函数时,如果 Flash 驱动本身要占用 CPU 时间较长,要确保 CAN 控制器接收缓冲区足够大且 CAN 中断优先级足够高。我遇到过一次:Flash 擦写时占用了中断 20ms,恰好下一帧 36 在这个窗口里到达,CAN 控制器接收 FIFO 溢出,丢了一帧,bootloader 和测试仪之间的块序号就对不上了。后来把 Flash 擦写放到了低优先级任务里,每次擦写不超过 5ms,问题就消失了。

第三个坑:字节序和编译器的字节对齐。

UDS 报文是纯字节流,你在定义结构体时如果想用#pragma pack(push, 1)__attribute__((packed))来贴合协议,可以。但一定要知道:协议字段很多是超过 1 字节的,比如 34 服务里的地址和长度是 4 字节,按大端排列。如果代码里把uint32_t addr直接跟data[0]做强制类型转换,在大小端不匹配时就会出问题。推荐的做法是写一组工具函数:

static uint32_t uds_read_u32(const uint8_t *buf) { return ((uint32_t)buf[0] << 24) | ((uint32_t)buf[1] << 16) | ((uint32_t)buf[2] << 8) | ((uint32_t)buf[3]); } static void uds_write_u32(uint8_t *buf, uint32_t val) { buf[0] = (uint8_t)(val >> 24); buf[1] = (uint8_t)(val >> 16); buf[2] = (uint8_t)(val >> 8); buf[3] = (uint8_t)(val); }

整个 UDS 层全部用这种字节操作来读写协议字段,绝不直接做结构体指针强转。这是 C 语言移植性最好的写法,也是我压箱底的习惯。

第四个坑:诊断仪掉线时怎么恢复。

如果诊断仪在刷写过程中突然掉线,S3 超时会触发 ECU 回到默认会话,但传输状态机可能还停在 TRANSFERRING。这时候如果新的诊断仪用 10 02 重新进入编程会话,要先检查传输状态是不是 IDLE。如果不是,先复位传输状态,再执行 34。这个检查逻辑放在 10 服务处理的分支里。

5.4 一个可复用的服务分发框架示例

最后给一个精简的服务分发框架,这个结构我在多个项目里复用,改动很小:

void uds_dispatch(uds_context_t *ctx, uint8_t *data, uint16_t len) { uint8_t sid; uint8_t nrc; const uds_service_entry_t *entry; uint8_t i; if (ctx == NULL || data == NULL || len == 0) { return; } sid = data[0]; for (i = 0; i < SERVICE_TABLE_SIZE; i++) { if (service_table[i].sid == sid) { entry = &service_table[i]; break; } } if (i >= SERVICE_TABLE_SIZE) { uds_send_nrc(ctx, SID, NRC_SERVICE_NOT_SUPPORTED); return; } /* 会话检查 */ if ((entry->allowed_session & (1 << ctx->session)) == 0) { uds_send_nrc(ctx, sid, NRC_SERVICE_NOT_SUPPORTED_IN_ACTIVE_SESSION); return; } /* 安全检查 */ if ((entry->security_req & SECURITY_LOCK) && (ctx->security_level == 0)) { uds_send_nrc(ctx, sid, NRC_SECURITY_ACCESS_DENIED); return; } /* 调用具体服务处理函数 */ nrc = entry->handler(ctx, &data[1], len - 1); if (nrc != 0) { uds_send_nrc(ctx, sid, nrc); } }

这个框架的粒度可能不是最优的,比如 0x27 服务自身还需要在内部区分请求种子和发送密钥,0x19 服务内部还要区分子功能,但整体上已经能满足大多数项目的代码组织需求。

最后分享一个经验:写 UDS 代码最好的方式是先写一个最小可用的 0x10 + 0x22 + 0x27 服务跑通,然后再慢慢扩展。不要一开始就铺开所有服务,不然联调时出了问题你根本不知道是哪个环节错了。我最初做 BootLoader 时,就是先实现 10 服务切换到编程会话、27 服务解锁、然后用 34/36/37 刷一个简单的 LED 固件。刷通了,再往里加 19/14 这些 DTC 服务。这个顺序慢,但稳,而且后面调试其它模块时你会感谢当初这个决定的。

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

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

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

立即咨询