4、诊断服务实现(DSL):DSL子模块划分、服务调度机制、P2与P2*定时器管理

好,咱们今天聊聊诊断栈里最核心的干活部分——DSL(Diagnostic Session Layer)。说白了,它就是负责把诊断请求拆开、分发、再组装回去的那一层。我做了这么多年,发现很多新手工程师容易把DSL和DSP(诊断服务处理层)搞混。其实很简单:DSL管的是“怎么传”,DSP管的是“传什么”。

4.1 DSL子模块划分

我个人习惯把DSL拆成三个子模块。为什么是三个?你想想看,一个诊断请求从CAN总线上进来,总得有人接、有人查、有人回吧?

  • 请求接收模块(DSL_Req):负责从PDU层拿数据,做最基础的格式检查。比如长度对不对、格式对不对。我在项目中遇到过,有个同事没做长度检查,结果一个超长报文直接把接收缓冲区撑爆了,整个ECU都挂了。
  • 服务调度模块(DSL_Sched):这是大脑。它决定当前请求该由哪个DSP服务去处理。比如0x10是诊断会话控制,0x22是读取数据,调度器会根据SID(服务标识符)做路由。
  • 响应发送模块(DSL_Resp):处理完的结果,由它打包发回去。包括正响应和负响应。嗯,这里要注意,负响应的优先级其实比正响应高,因为ECU得先告诉客户端“我出错了”。

核心原则:DSL的三个子模块之间,只能通过内部接口通信,不能直接调用DSP的服务函数。这是分层架构的基本要求,也是我踩过坑之后才深刻理解的。

4.2 服务调度机制

服务调度,说白了就是“谁来干活”的问题。DSL_Sched收到一个请求后,会做以下几件事:

  1. SID匹配:查表,看这个SID有没有注册过。没注册?直接回NRC 0x11(服务不支持)。
  2. 会话检查:当前会话模式是否允许这个服务?比如0x31(例程控制)在默认会话里是不允许的。我建议把会话权限表做成静态数组,查起来快,也容易维护。
  3. 安全访问检查:有些服务需要解锁才能用。比如0x2E(写入数据),没解锁就直接拒绝。
  4. 调用DSP处理函数:通过函数指针数组来调用。这样新增一个服务,只需要在数组里加一项,不用改调度逻辑。
/* 服务调度表示例 */
typedef struct {
    uint8_t SID;
    uint8_t (*handler)(uint8_t* data, uint16_t len);
} ServiceEntry_t;

static ServiceEntry_t serviceTable[] = {
    {0x10, DiagSessCtrl_Handler},
    {0x22, ReadDataByIdentifier_Handler},
    {0x2E, WriteDataByIdentifier_Handler},
    /* 新增服务只需在这里加 */
};

uint8_t DS L_ScheduleService(uint8_t sid, uint8_t* data, uint16_t len) {
    for (int i = 0; i < sizeof(serviceTable)/sizeof(ServiceEntry_t); i++) {
        if (serviceTable[i].SID == sid) {
            return serviceTable[i].handler(data, len);
        }
    }
    return NRC_SERVICE_NOT_SUPPORTED;
}

小技巧:函数指针数组的查找效率是O(n),如果服务很多(比如超过50个),可以考虑用哈希表或者二分查找。不过一般诊断服务也就二三十个,线性查找足够了。

4.3 P2与P2*定时器管理

P2和P2*,这两个定时器是诊断通信里最容易出问题的地方。我曾经在一个项目里,就因为P2定时器配置错了,导致ECU在产线上总是超时,差点被退货。

P2定时器:服务器处理请求的最大时间。客户端发出请求后,服务器必须在P2时间内给出响应。如果处理不完,就得先发一个NRC 0x78(请求正确接收,正在处理),然后进入P2*模式。

P2*定时器:扩展响应时间。当服务器发了0x78之后,客户端会等待P2*的时间。如果P2*也超时了,客户端就会认为通信失败。

参数 典型值 说明
P2_Server_Max 50 ms 正常响应时间,超过则发0x78
P2*_Server_Max 5000 ms 扩展响应时间,超过则超时
P2_Client_Max 50 ms + 抖动 客户端等待时间,一般比服务器略长

定时器的实现,我建议用硬件定时器+软件计数的方式。为什么?因为诊断服务可能同时有多个会话在跑,每个会话都需要独立的P2计时。硬件定时器数量有限,软件计数可以无限扩展。

/* P2定时器管理示例 */
typedef struct {
    uint16_t p2Counter;    /* P2倒计时 */
    uint16_t p2StarCounter; /* P2*倒计时 */
    uint8_t  sessionState; /* 当前会话状态 */
} DiagSession_t;

void DS L_TimerTick(DiagSession_t* session) {
    if (session->p2Counter > 0) {
        session->p2Counter--;
        if (session->p2Counter == 0) {
            /* P2超时,进入P2*模式 */
            session->p2StarCounter = P2STAR_MAX;
            SendResponse(0x78);  /* 发送等待响应 */
        }
    }
    if (session->p2StarCounter > 0) {
        session->p2StarCounter--;
        if (session->p2StarCounter == 0) {
            /* P2*超时,终止当前服务 */
            TerminateCurrentService(session);
        }
    }
}

避坑指南:我曾经在P2定时器里直接调用了发送函数,结果造成了递归调用——发送函数又触发了定时器中断,系统直接死机。记住:定时器中断里只做计数,不要做任何I/O操作。把状态变化留给主循环去处理。

还有一个容易忽略的点:P2和P2*的时间单位。有些芯片的定时器是1ms一个tick,有些是10ms。我建议统一用1ms作为基本单位,这样配置起来更灵活。如果硬件定时器精度不够,可以用软件累加的方式补偿。

嗯,DSL这块其实还有很多细节,比如多会话管理、请求序列化、响应缓存等等。但核心就是这三个部分:子模块划分、服务调度、定时器管理。把这三点搞清楚了,DSL的骨架就搭起来了。剩下的,就是往里面填肉了。