4、诊断服务实现(上):0x10诊断会话控制服务实现、0x27安全访问服务实现、0x22读取数据标识符服务实现

各位同学,今天我们正式开始啃诊断栈的硬骨头——服务实现。这部分内容,说白了就是ECU和诊断仪之间的“对话协议”。你想想看,车上的ECU那么多,诊断仪怎么知道该跟谁说话?怎么确认对方身份?怎么读取数据?这三个服务就是干这个的。

我个人习惯把诊断服务分成两类:一类是“控制类”,比如会话切换、安全解锁;另一类是“数据类”,比如读数据、写数据。今天讲的三个服务,正好覆盖了这两类。咱们一个一个来拆解。

诊断服务实现(上) 0x10 会话控制 默认/扩展/编程会话 会话状态机管理 0x27 安全访问 种子-密钥机制 安全等级管理 0x22 读取数据 DID数据标识符 多字节数据打包 S3定时器管理 会话超时处理 密钥算法实现 防暴力破解机制 DID映射表 数据长度校验

4.1 0x10诊断会话控制服务

先说说0x10服务。这个服务是诊断通信的“大门”。ECU上电后,默认处于默认会话(Default Session)。在这个会话里,你只能做点基本操作,比如读个VIN码。想干点“大事”?不行,得先切换会话。

UDS标准定义了三种会话:默认会话(0x01)、扩展会话(0x02)、编程会话(0x03)。扩展会话用于配置参数,编程会话用于刷写固件。我见过不少新手,上来就想刷写,结果发现ECU根本不响应——因为还在默认会话里呢。

核心要点:会话切换是单向的。默认会话可以切到扩展或编程,但扩展和编程之间不能直接切换,必须先回到默认会话。

实现上,我们需要维护一个会话状态机。看代码:

typedef enum {
    SESSION_DEFAULT = 0x01,
    SESSION_EXTENDED = 0x02,
    SESSION_PROGRAMMING = 0x03
} SessionType;

static SessionType g_currentSession = SESSION_DEFAULT;
static uint32_t g_sessionTimer = 0;

// 会话切换处理
UdsResponse_t SessionControl_Handler(uint8_t targetSession) {
    // 检查目标会话是否合法
    if (targetSession != SESSION_DEFAULT &&
        targetSession != SESSION_EXTENDED &&
        targetSession != SESSION_PROGRAMMING) {
        return UDS_NRC_INVALID_FORMAT;  // 0x13
    }

    // 检查切换路径是否合法
    if (g_currentSession == SESSION_EXTENDED &&
        targetSession == SESSION_PROGRAMMING) {
        return UDS_NRC_CONDITIONS_NOT_CORRECT;  // 0x22
    }

    // 执行切换
    g_currentSession = targetSession;
    g_sessionTimer = 0;  // 重置S3定时器

    // 返回肯定响应
    return UDS_POSITIVE_RESPONSE;
}

这里有个坑——S3定时器。每个会话都有超时时间,默认会话一般是5秒,扩展和编程会话可能更长。超时后ECU会自动切回默认会话。我曾经在一个项目里,S3定时器忘了复位,结果ECU总是莫名其妙地跳回默认会话,排查了两天才找到原因。

我的经验:S3定时器最好放在1ms或10ms的定时中断里处理。别用RTOS的延时函数,精度不够,容易出问题。

4.2 0x27安全访问服务

0x27服务,说白了就是“解锁”。你想写数据、刷固件?先证明你有权限。UDS用的是种子-密钥机制:ECU发一个随机种子,诊断仪用算法算出密钥,ECU验证通过后解锁。

实现分两步:

  1. 请求种子(0x27 0x01):ECU生成随机种子,返回给诊断仪
  2. 发送密钥(0x27 0x02):诊断仪计算密钥,ECU验证

看代码实现:

static uint32_t g_seed = 0;
static uint8_t g_securityLevel = 0;
static uint8_t g_attemptCount = 0;

UdsResponse_t SecurityAccess_SeedHandler(uint8_t level) {
    // 检查安全等级是否支持
    if (level != 1 && level != 3) {  // 假设只支持等级1和3
        return UDS_NRC_INVALID_FORMAT;
    }

    // 防暴力破解:连续失败3次后锁定
    if (g_attemptCount >= 3) {
        return UDS_NRC_EXCEEDED_NUMBER_OF_ATTEMPTS;  // 0x36
    }

    // 生成随机种子
    g_seed = rand() % 0xFFFFFFFF;
    g_securityLevel = level;

    // 返回种子
    return UDS_POSITIVE_RESPONSE;
}

UdsResponse_t SecurityAccess_KeyHandler(uint32_t key) {
    uint32_t expectedKey;

    // 计算期望密钥
    expectedKey = CalculateKey(g_seed, g_securityLevel);

    if (key == expectedKey) {
        g_attemptCount = 0;  // 成功,重置计数
        return UDS_POSITIVE_RESPONSE;
    } else {
        g_attemptCount++;
        return UDS_NRC_INVALID_KEY;  // 0x35
    }
}
注意:种子必须是真随机数!别用固定值或者简单的递增序列。我曾经见过一个项目,种子是固定的0x12345678,结果被破解后整个ECU的安全防护形同虚设。

密钥算法怎么选?我个人建议用AES-128或者自定义的异或+移位算法。AES安全性高,但计算量大;自定义算法轻量,但容易被逆向。看你的项目需求——量产车用AES,工装设备用自定义就够了。

4.3 0x22读取数据标识符服务

0x22服务,就是读数据。ECU内部有很多数据,比如VIN码、软件版本、故障码数量等,每个数据都有一个DID(数据标识符),2个字节。

实现上,我们需要维护一个DID映射表:

typedef struct {
    uint16_t did;
    uint8_t* dataPtr;
    uint8_t dataLength;
    uint8_t (*readCallback)(uint8_t* buffer, uint16_t* length);
} DidEntry_t;

static DidEntry_t g_didTable[] = {
    {0xF190, NULL, 17, ReadVIN},       // VIN码,17字节
    {0xF195, NULL, 4,  ReadSWVersion}, // 软件版本,4字节
    {0xF197, NULL, 2,  ReadDTCCount},  // 故障码数量,2字节
};

UdsResponse_t ReadDataByIdentifier_Handler(uint16_t did) {
    // 查找DID
    for (int i = 0; i < sizeof(g_didTable)/sizeof(DidEntry_t); i++) {
        if (g_didTable[i].did == did) {
            uint8_t buffer[256];
            uint16_t length = 0;

            // 调用回调函数读取数据
            if (g_didTable[i].readCallback(buffer, &length) == 0) {
                // 返回数据
                return UDS_POSITIVE_RESPONSE;
            } else {
                return UDS_NRC_GENERAL_REJECT;  // 0x10
            }
        }
    }

    return UDS_NRC_REQUEST_OUT_OF_RANGE;  // 0x31
}
我的习惯:DID表用回调函数的方式实现,而不是直接存数据指针。为什么?因为有些数据是动态生成的,比如当前车速、发动机转速,每次读的时候都要重新获取。回调函数更灵活。

这里有个细节——数据长度。UDS规定,响应报文里要先返回DID,再返回数据。比如读VIN码,响应是:0x62 0xF1 0x90 + 17字节VIN。长度不对,诊断仪会报错。我踩过这个坑,有一次VIN码长度写成了16字节,结果诊断仪一直显示“数据格式错误”。

4.4 三个服务的协同工作

这三个服务不是孤立的。你想想看:

  • 想读某些敏感数据(比如密钥),必须先进入扩展会话(0x10),再解锁安全访问(0x27),最后才能用0x22读取
  • 会话切换后,安全解锁状态会丢失,需要重新解锁
  • S3定时器超时后,会话和安全状态都会重置

我建议在实现时,把这三个服务的状态机统一管理。比如:

typedef struct {
    SessionType session;
    uint8_t securityLevel;
    uint8_t isUnlocked;
    uint32_t s3Timer;
} DiagState_t;

static DiagState_t g_diagState = {
    .session = SESSION_DEFAULT,
    .securityLevel = 0,
    .isUnlocked = 0,
    .s3Timer = 0
};

这样,每个服务在处理请求时,都可以检查当前状态是否允许操作。代码清晰,也不容易出bug。

总结一下:0x10是门禁,0x27是保险柜密码,0x22是文件柜。门不开,密码输不了;密码不对,文件拿不到。三个服务环环相扣,构成了诊断通信的基础框架。

好了,今天的内容就到这里。代码示例我都放在附件里了,建议大家动手跑一跑。下节课我们继续讲剩下的服务——0x2E写入、0x31例程控制、0x34请求下载。到时候见。


公众号:蓝海资料掘金营,微信deep3321