Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

6.1 诊断的世界:UDS

会说话的机器

1965年,底特律。一辆V8引擎的轿车因间歇性动力丧失被拖进维修车间。维修技师打开引擎盖,面对的不是故障码,而是一个完全沉默的金属疙瘩。换分电器盖——不行。调化油器混合比——不行。拆到第三天,把进气系统全部解下来,才在歧管密封垫片上找到一条只有热胀时才显现的发丝裂纹。

诊断耗时:两周。唯一的方法是拆、看、试、猜。

五十年后。一辆车在高速上检测到失火。ECU在几个监控周期内确认了故障,存储了DTC P0302,附带完整的freeze frame数据。诊断仪接入OBD口,发出一条请求。车回答:“2号缸失火。发生时的条件:车速87km/h,转速2450rpm,冷却液温度92°C,长期燃油修正+3.1%。”

诊断耗时:三秒钟。

这不是工具的事。是哲学的事。1965年的车是一个会做的系统——它执行功能,但从不解释自身。今天的车是一个会说也会做的系统——它内置了自我描述的能力。UDS(Unified Diagnostic Services,ISO 14229)就是这种语言的语法。


诊断的姿态:不是“拆“,是“问“

所有复杂系统都有两种存在方式。

第一种:只能做,不能说。它执行功能,但从不解释状态。当它出问题时,你只能从外部观察、猜测、逐层拆解。你像一个考古学家面对古代器物——根据外部痕迹推断内部构造。

第二种:不仅能做,还能说。它内置了自我监测的回路。每一个异常条件,它主动记录。每一个内部状态,它愿意公开。当它出问题时,它不需要被解剖——它自己告诉你哪里不对、什么时候开始不对、附带什么上下文数据。

UDS为第二种系统定义了语言。

在这个语言里,诊断仪问ECU“你的软件版本是什么“,ECU回答版本字节。诊断仪问“你把当前检测到的所有故障码报出来“,ECU逐一返回每个DTC及其状态。诊断仪对制动ECU说“激活你的ABS泵两秒,我要测液压回路“,制动ECU泵转了两秒后回复“已执行,液压正常。“

这背后有一整条协议栈在支撑——UDS应用层定义“问什么“和“怎么问“,ISO 15765-2传输层负责把长回复拆成CAN帧并在多帧之间协调收发,CAN数据链路层和差分物理层负责比特的实际传输。那套细节在《UDS诊断书》里已经穷尽了。

在这里你只需要看见为什么。为什么人类创造了诊断协议?因为系统越复杂,它就越不能靠“拆开看看“来维修。当一辆车有100个ECU,你不可能把100个ECU都拆出来单独测试每一个——工时是天价,而且拆过之后的ECU要重新标定、重新配置、重新安全校验。解决这个问题的唯一方法,是让每个ECU自己学会说话。


诊断协议栈的核心:DCM 如何分发请求

UDS 请求到达 ECU 后,第一个处理它的是 DCM(Diagnostic Communication Manager)。DCM 的角色类似于网络的 HTTP 服务器——它接收请求、解析服务 ID、分发到对应的处理函数。

一个极简的 DCM 分发器实现:

/* DCM_ServiceDispatcher.c — 简化的诊断通信管理器 */

#define UDS_SID_DIAGNOSTIC_SESSION_CONTROL  0x10
#define UDS_SID_ECU_RESET                    0x11
#define UDS_SID_READ_DATA_BY_IDENTIFIER      0x22
#define UDS_SID_READ_DTC_INFORMATION         0x19
#define UDS_SID_SECURITY_ACCESS              0x27
#define UDS_SID_CLEAR_DIAGNOSTIC_INFORMATION  0x14
#define UDS_SID_ROUTINE_CONTROL              0x31
#define UDS_SID_TESTER_PRESENT               0x3E

typedef enum {
    DCM_SESSION_DEFAULT  = 0x01,
    DCM_SESSION_PROGRAMMING = 0x02,
    DCM_SESSION_EXTENDED = 0x03,
} Dcm_SessionType;

static Dcm_SessionType g_current_session = DCM_SESSION_DEFAULT;
static uint8_t g_security_level = 0;  /* 0=锁定, 1=解锁 */

/* UDS负响应码 */
#define NRC_SERVICE_NOT_SUPPORTED     0x11
#define NRC_CONDITIONS_NOT_CORRECT    0x22
#define NRC_SECURITY_ACCESS_DENIED    0x33

void Dcm_ProcessRequest(const uint8_t *req, uint16_t req_len,
                         uint8_t *resp, uint16_t *resp_len)
{
    uint8_t sid = req[0];

    /* 首先检查诊断仪是否"活着"——不插会话超时 */
    Dcm_ResetSessionTimer();

    switch (sid) {
    case UDS_SID_DIAGNOSTIC_SESSION_CONTROL:
        Dcm_HandleSessionControl(req, req_len, resp, resp_len);
        break;

    case UDS_SID_READ_DATA_BY_IDENTIFIER:
        Dcm_HandleReadDataById(req, req_len, resp, resp_len);
        break;

    case UDS_SID_READ_DTC_INFORMATION:
        Dcm_HandleReadDTC(req, req_len, resp, resp_len);
        break;

    case UDS_SID_SECURITY_ACCESS:
        Dcm_HandleSecurityAccess(req, req_len, resp, resp_len);
        break;

    case UDS_SID_CLEAR_DIAGNOSTIC_INFORMATION:
        Dcm_HandleClearDTC(req, req_len, resp, resp_len);
        break;

    case UDS_SID_ROUTINE_CONTROL:
        Dcm_HandleRoutineControl(req, req_len, resp, resp_len);
        break;

    case UDS_SID_TESTER_PRESENT:
        Dcm_HandleTesterPresent(req, req_len, resp, resp_len);
        break;

    case UDS_SID_ECU_RESET:
        Dcm_HandleEcuReset(req, req_len, resp, resp_len);
        break;

    default:
        /* 不支持的服务——返回负响应 */
        resp[0] = 0x7F;  /* 负响应SID */
        resp[1] = sid;
        resp[2] = NRC_SERVICE_NOT_SUPPORTED;
        *resp_len = 3;
        break;
    }
}

void Dcm_HandleSessionControl(const uint8_t *req, uint16_t req_len,
                               uint8_t *resp, uint16_t *resp_len)
{
    uint8_t new_session = req[1];

    switch (new_session) {
    case DCM_SESSION_DEFAULT:
        g_current_session = DCM_SESSION_DEFAULT;
        g_security_level = 0;  /* 回到默认会话 → 自动锁定安全访问 */
        resp[0] = 0x50;  /* 正响应SID = 请求SID + 0x40 */
        resp[1] = new_session;
        *resp_len = 2;
        break;

    case DCM_SESSION_EXTENDED:
        g_current_session = new_session;
        resp[0] = 0x50;
        resp[1] = new_session;
        *resp_len = 2;
        break;

    // 在生产代码中,此处应检查 g_security_level >= 1 后才允许进入编程会话
    case DCM_SESSION_PROGRAMMING:
        g_current_session = new_session;
        resp[0] = 0x50;
        resp[1] = new_session;
        *resp_len = 2;
        break;

    default:
        resp[0] = 0x7F;
        resp[1] = UDS_SID_DIAGNOSTIC_SESSION_CONTROL;
        resp[2] = NRC_CONDITIONS_NOT_CORRECT;
        *resp_len = 3;
        break;
    }
}

/* 会话超时定时器——如果5秒内没有收到TesterPresent,自动退回默认会话 */
void Dcm_ResetSessionTimer(void)
{
    g_session_timer = DCM_SESSION_TIMEOUT_MS;
}

void Dcm_SessionTimer_Tick(void)
{
    if (g_session_timer > 0) {
        g_session_timer--;
        if (g_session_timer == 0) {
            /* 会话超时——退回到默认 */
            g_current_session = DCM_SESSION_DEFAULT;
            g_security_level = 0;
        }
    }
}

DCM 不复杂。它就是一张查找表——把服务 ID 映射到处理函数。整个 UDS 协议的复杂性不在 DCM 本身,而在各个服务处理函数内部——以及这些处理函数调用的下层模块(如 DEM 对 DTC 的管理)。


诊断的三个层次:看、动、改

诊断不是单一操作。它有三个层次。

读取。 诊断仪可以读ECU的各种信息——软件版本、硬件编号、故障码、传感器实时值、通信计数器。这是“让系统报告自己“。不需要任何权限。任何诊断仪都可以问任何一个ECU“你是谁“。对应的 UDS 服务是 0x22(ReadDataByIdentifier)和 0x19(ReadDTCInformation)。

控制。 诊断仪可以激活ECU的执行器——让ABS泵短暂运转、让空调风扇启动、让某个喷油器喷射。这是“让系统按你的要求动作“。对应的 UDS 服务是 0x31(RoutineControl)——诊断仪说“执行例程 0x0201(激活ABS泵100ms)“,ECU执行后返回结果。

写入。 诊断仪可以向ECU写入标定数据——更新发动机map、修改传感器阈值、刷写新的固件。这是“改变系统的行为“。对应的服务是 0x2E(WriteDataByIdentifier)、0x34/0x36/0x37(RequestDownload/TransferData/RequestTransferExit)。

前两个层次的哲学是:系统主动暴露自己,帮助维修者更快定位故障。 第三个层次是另一回事:你赋予了维修者修改系统的权力。这需要一个锁。


安全访问:谁给了你写的权力?

ECU出厂时,它的HSM内部存储了一个密钥。这个密钥永远不离开HSM——主CPU可以请求HSM用这个密钥做加密运算,但主CPU自己读不出密钥的值。

当诊断仪需要执行写操作时,ECU不会直接同意。它要求诊断仪证明自己知道这个密钥:

Tester (诊断仪)                        ECU
    |                                      |
    |  Request Seed (0x27 0x01)            |
    |  ─────────────────────────────────>    |
    |                                      |  生成随机数 Seed (4字节)
    |  Seed: 0x5A8F_3C21                   |
    |  <─────────────────────────────────    |
    |                                      |
    |  Key = AES(Seed, SecretKey)           |
    |      截取前4字节                     |
    |                                      |
    |  Send Key (0x27 0x02, key)           |
    |  ─────────────────────────────────>    |
    |                                      |  HSM重新计算 Key'
    |                                      |  Key == Key' ?
    |                                      |  匹配 → 解锁安全级别1
    |  Positive Response                    |
    |  <─────────────────────────────────    |

质询—响应(Challenge-Response)的核心设计原则:密钥永不在总线上传输。 每次的seed是随机数,每次的key也不同。攻击者录制了全部通信,也无法提取密钥,也无法用录制的key重放——因为下一次的seed不同。

如果连续发送错误的key——比如三次——ECU进入“安全访问延迟“状态,每次错误后等待时间指数增长(1秒、2秒、4秒……),拒绝进一步的解锁尝试。这是抗暴力破解的措施。

解锁成功后,诊断仪进入编程会话扩展会话,获得写权限。执行完毕后退出到默认会话——写权限关闭。如果诊断仪在规定时间内没有任何通信(通常5秒),ECU自动退回到默认会话。这是“人走关门“的策略。

会话是门。安全访问是钥匙。 诊断仪想进门,先用钥匙证明身份,然后在门关闭之前把事做完。


诊断会话状态机

诊断会话不是简单的“默认/编程“二选一。它是一个完整的状态机:

                          ┌──────────────┐
         上电/复位        │              │
    ┌──────────────────→ │  默认会话     │←──────────────┐
    │                     │  (0x01)      │               │
    │                     │              │               │
    │                     └──────┬───────┘               │
    │                            │                       │
    │                   0x10 0x03                        │ 0x10 0x01
    │                            │                       │ (返回默认)
    │                            ↓                       │
    │                     ┌──────────────┐               │
    │                     │  扩展会话     │───────────────┤
    │                     │  (0x03)      │               │
    │                     │              │               │
    │                     └──────┬───────┘               │
    │                            │                       │
    │                   0x10 0x02                        │ 0x10 0x01
    │                            │                       │
    │                            ↓                       │
    │                     ┌──────────────┐               │
    │                     │  编程会话     │───────────────┘
    │                     │  (0x02)      │
    │                     │              │
    │                     └──────────────┘
    │                            │
    └────────────────────────────┘
              ECU复位 (0x11 0x01)
  • 默认会话(0x01):只读权限。读取 DID、读取 DTC、TesterPresent。没有写权限。
  • 扩展会话(0x03):读写权限。可以写入标定数据、执行例程控制。需要安全访问解锁才能写。
  • 编程会话(0x02):最高权限。允许固件刷写(RequestDownload)。必须在扩展会话中通过安全访问后才能进入。

在许多OEM的诊断策略中,你不能直接从默认会话跳到编程会话。典型的做法是:默认会话→扩展会话→安全访问解锁→编程会话。这不是ISO 14229-1的强制要求,而是OEM通过会话安全策略实现的额外保护——确保攻击者不能在跳过安全访问的情况下获得刷写权限。


DTC的生命:它不是一个布尔值

初学者以为DTC就是“有故障/没故障“的标志位。大错。

一个DTC有一整套生命周期。它不是“检测到了所以亮灯“,而是一个由 DEM(Diagnostic Event Manager)管理的有穷状态机。

让我们看 DEM 如何追踪一个 DTC 的状态:

/* DEM_DTCStatus.c — 诊断事件管理器:DTC状态字节管理 */

/* DTC状态字节中的位定义 (UDS bit numbering) */
#define DTC_BIT_TEST_FAILED            0x01  /* bit 0: 本次测试失败 */
#define DTC_BIT_TEST_FAILED_THIS_OP    0x02  /* bit 1: 本操作周期失败 */
#define DTC_BIT_PENDING_DTC            0x04  /* bit 2: 待确认DTC */
#define DTC_BIT_CONFIRMED_DTC          0x08  /* bit 3: 已确认DTC */
#define DTC_BIT_TEST_NOT_COMPLETE_SINCE_CLEAR  0x10 /* bit 4 */
#define DTC_BIT_TEST_FAILED_SINCE_CLEAR        0x20 /* bit 5 */
#define DTC_BIT_TEST_NOT_COMPLETE_THIS_OP      0x40 /* bit 6 */
#define DTC_BIT_WARNING_INDICATOR_REQUESTED    0x80 /* bit 7 */

typedef struct {
    uint32_t dtc_code;          /* 如 0xC00202 = P0202 */
    uint8_t  status;            /* DTC状态字节 */
    uint8_t  fault_counter;     /* 故障确认计数器 */
    uint8_t  aging_counter;     /* 老化计数器 */
    uint8_t  healing_counter;   /* 自愈计数器 */
    uint32_t occurrence_count;  /* 累计发生次数 */
    uint32_t freeze_frame_data[4]; /* freeze frame快照 */
} Dem_DTCRecord_t;

static Dem_DTCRecord_t g_dtc_table[MAX_DTC_COUNT];

/* SWC 监测函数调用此API报告故障事件 */
void Dem_SetEventStatus(uint32_t dtc_code, uint8_t event_status)
{
    Dem_DTCRecord_t *dtc = Dem_FindOrCreateDTC(dtc_code);
    if (!dtc) return;

    /* 本次测试结果 */
    if (event_status == DEM_EVENT_FAILED) {
        dtc->status |= DTC_BIT_TEST_FAILED;
        dtc->status |= DTC_BIT_TEST_FAILED_THIS_OP;
        dtc->status |= DTC_BIT_TEST_FAILED_SINCE_CLEAR;
        dtc->fault_counter++;

        /* 确认逻辑:连续N个操作周期检测到故障 → 确认DTC */
        if (dtc->fault_counter >= DEM_CONFIRMATION_THRESHOLD) {
            dtc->status |= DTC_BIT_CONFIRMED_DTC;
            dtc->status &= ~DTC_BIT_PENDING_DTC;
            dtc->aging_counter = 0;

            /* 写入非易失存储——DTC被永久记录 */
            NvM_WriteBlock(NVM_DTC_BLOCK_ID, g_dtc_table);

            /* 如果需要点亮MIL灯 */
            if (Dem_IsEmissionRelated(dtc_code)) {
                dtc->status |= DTC_BIT_WARNING_INDICATOR_REQUESTED;
            }

            /* 保存freeze frame——首次确认时的环境快照 */
            if (dtc->occurrence_count == 0) {
                Dem_CaptureFreezeFrame(dtc);
            }
            dtc->occurrence_count++;
        } else if (dtc->fault_counter >= DEM_PENDING_THRESHOLD) {
            /* 尚未确认——标记为pending */
            dtc->status |= DTC_BIT_PENDING_DTC;
        }
    } else if (event_status == DEM_EVENT_PASSED) {
        dtc->status &= ~DTC_BIT_TEST_FAILED;
        dtc->status &= ~DTC_BIT_PENDING_DTC;

        /* 老化逻辑:连续N个周期通过 → 清除确认状态 */
        if (dtc->status & DTC_BIT_CONFIRMED_DTC) {
            dtc->aging_counter++;
            if (dtc->aging_counter >= DEM_AGING_THRESHOLD) {
                dtc->status &= ~DTC_BIT_CONFIRMED_DTC;
            }
        }

        /* 自愈 */
        if (dtc->fault_counter > 0) {
            dtc->healing_counter++;
            if (dtc->healing_counter >= DEM_HEALING_THRESHOLD) {
                dtc->fault_counter = 0;
                dtc->healing_counter = 0;
                dtc->status &= ~DTC_BIT_TEST_FAILED_THIS_OP;
            }
        }
    }
}

这个 DTC 状态机的完整时间线是这样的:

操作周期 1: 检测到故障 → TestFailed=1, TestFailedThisOp=1, faultCounter=1
操作周期 2: 检测到故障 → faultCounter=2, PendingDTC=1
操作周期 3: 检测到故障 → faultCounter=3, ConfirmedDTC=1, PendingDTC=0
           → 写入NVM, 捕获freeze frame, MIL灯可能亮
操作周期 4: 检测到通过 → TestFailed=0, agingCounter=1
操作周期 5: 检测到通过 → agingCounter=2
...
操作周期 42: 检测到通过 → agingCounter=40 (达到阈值)
           → ConfirmedDTC=0 (DTC从"已确认"变为"老化清除")
           → 但occurrenceCount不为0——维修技师仍可见"这个DTC历史发生过"

一个故障出现后,它需要连续 3 个监控周期被确认才成为“真实故障“——这是为了过滤偶发的电压毛刺、接触不良、瞬态 EMI。 一个故障消失后,它需要连续 40 个监控周期才被自动清除——这是为了防止间歇性故障被认为“已经好了“。3 个周期确认、40 个周期清除——这些数字不是随意定的,是 OEM 在多年售后数据的基础上权衡“不漏报“和“不误报“的结果。


穿透:从一个 DTC 状态位到 Flash 中的电子

一个 DTC 状态字节的第 3 位(ConfirmedDTC)从 0 翻到 1——在物理上,这是 Flash 中的一个 bit 在几十微秒内完成了编程操作。

让我们穿透这一瞬间。

最上层:dtc->status |= DTC_BIT_CONFIRMED_DTC——C 语言代码中一次按位或。

往下一层:编译器生成了 LDRB R0,[R1]; ORR R0,#0x08; STRB R0,[R1]——一个字节从 SRAM 加载,OR 上 0x08,写回。

往下一层:NvM_WriteBlock() 把整个 DTC 表写入 Flash。Flash 控制器收到命令——“扇区 12,地址 0x0801E000,写入 64 字节”。

往下一层:Flash 控制器启动内部状态机。电荷泵把 VDD(3.3V)升压到编程电压(约 10V)。NOR Flash cell 的沟道热电子在高压下被注入浮栅。浮栅中积累的负电荷改变了 cell 晶体管的阈值电压——从“擦除态“(读取为 1)变为“编程态“(读取为 0)。

往下一层:扇区中的几个 cell 在第 3 位对应的物理位置完成了编程。这个 0 就是 ConfirmedDTC 在非易失存储中的物理存在——不是软件记录的逻辑值,而是一组被囚禁在二氧化硅层中、影响着晶体管开关行为的电子。

DTC 之所以能在断电后仍然存在,不是因为有软件帮你保存——而是因为这些电子被物理地困在浮栅中。 浮栅是二氧化硅层包围的多晶硅岛——电子无处可逃。除非你在足够高的温度下让它自然泄漏(数据保持 20 年),或者你施加一个高压擦除脉冲把它们通过 Fowler-Nordheim 隧穿拉走(扇区擦除)。

从仪表盘上的一盏黄色 MIL 灯,到 NOR Flash 浮栅中被困住的电子。诊断连接了这辆车从人机界面到硅晶片物理结构的每一层。这就是“穿透“——你不是只看表面的故障现象,你沿着因果关系链一路追溯到物理根源。


工程师留给未来的信

一个好的诊断系统设计者,不是在写代码。他是在写一封信。

收信人是十年后面对这辆车束手无策的维修技师。是下一代接手维护这段代码的工程师。也是未来某天已经忘记了自己当初设计思路的自己。

每定义一个 DID——“这个数据标识符 0xF190 读取冷却液温度”——你是在告诉未来的人:“当系统在这里表现异常,想一想温度传感器。“每配置一个 DTC 的确认阈值——“连续 3 个监控周期失败才确认为真实故障”——你是在平衡“不漏报“和“不误报“,是为未来的人做了一次取舍决定。每编写一个 RoutineControl 的测试例程——“激活 ABS 泵 2 秒然后读取压力传感器”——你是在为未来的人准备了一条自动化的排查路径。

1965 年的维修技师只能拆开引擎——因为那台车在设计时就从未考虑过“让机器描述自己“。那条歧管裂纹是一个工程缺陷,但沉默的金属无法自述。

诊断,是工程师递出的接力棒。

你写的每一个DTC、每一行故障处理代码,都是一根递向未来的接力棒——接棒的可能是10年后某个4S店里的维修技师。他从未见过你,但你留下的诊断逻辑会在他接通OBD口的那一瞬间开口说话。你不在场,但你的工程判断在场。

但诊断能发现问题。不能阻止有人故意制造问题。

诊断仪可以读故障码,也可以写标定数据。如果攻击者能通过安全访问——或绕过安全访问——他就能改写 ECU 的行为。2015 年,两位安全研究员在高速公路上远程控制了一辆 Jeep Cherokee。他们没有走诊断协议。他们走了信息娱乐系统的互联网入口,进入了车内 CAN 网络——一个没有认证机制的广播网络。他们直接伪造了“刹车释放““油门全开”“方向盘锁死“的 CAN 帧。

诊断是你留给未来维修者的信。安全是你不让攻击者拆开这封信。


本篇小结

今天我们做了一件事:理解诊断不是“读取故障码“——而是工程师跨越时间写给未来的一封信,穿透从人机界面到硅晶片物理结构的每一层抽象。

关键结论:

  1. DTC不是一个布尔值——它是一个有生命周期的状态机:从“当前操作周期检测到故障“到“连续N个周期确认“到“写入非易失存储“到被清除——每个状态转换都有明确的工程取舍(灵敏度 vs 误报率)。
  2. 诊断是工程师的遗产:你写的每一个DID定义、每一个确认阈值、每一个RoutineControl测试例程——都是在为10年后面对这辆车的维修技师铺设一条自动化的排查路径。你不在场,但你的工程判断在场。
  3. 从仪表盘MIL灯到NOR Flash浮栅中被困的电子——诊断连接了这条物理因果链的每一层dtc->status |= DTC_BIT_CONFIRMED_DTC最终变成浮栅中被囚禁的电荷。断电不丢——不是因为软件,是因为物理。

下一节,诊断能发现问题,但不能阻止有人故意制造问题。硬件安全——HSM。信任不在代码里,在硅里。

【下集预告】

Jeep 事件之后,汽车信息安全从一个“未来可能发生的问题“变成了“立法强制解决的问题“。UNECE R155 法规和 ISO/SAE 21434 标准相继出台。

信息安全的基础不是“把代码写好“——再好的代码也有 bug。信息安全的基础是物理隔离和密码学——具体来说,是一块嵌在 MCU 硅晶片内部的独立安全处理器。它的密钥永不可读出。它在主 CPU 启动之前就已完成验签。

这个安全处理器叫 HSM。它的信任不来自任何一行代码——代码本身是被验证的对象。信任来自硅——具体来说,来自一块一次性编程的熔丝阵列。编程时,一个高压脉冲物理地烧断一根金属连线。这根连线永远不可能再接回去。这就是安全的物理根基——不是数学保证,是热力学保证。

下一节:硬件安全——HSM。信任不在代码里,在硅里。