第四章 诊断协议栈软件架构设计:分层解耦与模块化设计思想

做诊断协议栈这些年,我最大的体会就是:架构设计决定了你能走多远。刚开始做项目时,我也图省事,把UDS、OBD、DoIP全揉在一个文件里。结果呢?每次改一个bug,至少牵出三个新问题。后来我痛定思痛,重新设计了分层架构。今天就把这些经验分享给你。

4.1 为什么必须分层?

说白了,分层就是为了解耦。你想想看,诊断协议栈要处理的事情其实很杂:

  • 底层要跟CAN、LIN、以太网打交道
  • 中间要解析UDS、OBD这些协议
  • 上层还要对接应用层的DTC、数据采集

如果全混在一起,那代码就是一团乱麻。我在项目中遇到过最夸张的情况:一个工程师为了修CAN接收超时的bug,结果把DTC上报逻辑也给改了。嗯,这就是不分层的代价。

核心原则:每一层只关心自己的事,不要越界。

4.2 经典的三层架构

我个人习惯把诊断协议栈分成三层:

层级 职责 典型模块
传输层 收发原始数据,处理总线时序 CAN驱动、DoIP Socket、LIN驱动
协议层 解析/封装诊断报文,处理会话、安全 UDS解析器、OBD解析器、会话管理器
应用层 对接ECU功能,处理DTC、数据、例程 DTC管理器、数据采集器、例程控制器

为什么这么分?我举个例子你就明白了。

假设你要从CAN总线切换到以太网。如果分层做得好,你只需要换掉传输层的驱动,协议层和应用层完全不用动。我曾经帮客户做过一次这样的迁移,只花了3天就搞定了。要是没分层,估计得折腾一个月。

4.3 模块化设计的三个关键点

分层是骨架,模块化是血肉。这里我重点讲三个实战经验。

4.3.1 接口要稳定,实现要灵活

每个模块对外暴露的接口,一旦定下来就不要轻易改。我见过太多项目,因为接口频繁变动,导致上下游代码反复重写。

举个例子,传输层给协议层提供的接口,我一般这样设计:

/* 传输层接口 - 稳定不变 */
typedef struct {
    uint8_t* data;
    uint16_t length;
    uint32_t timestamp;
} DiagFrame_t;

/* 发送接口 */
DiagStatus_t DiagTransmit(DiagFrame_t* frame);

/* 接收回调 */
typedef void (*DiagReceiveCallback)(DiagFrame_t* frame);
DiagStatus_t DiagRegisterReceiveCallback(DiagReceiveCallback cb);

你看,接口里只定义了数据、长度和时间戳。至于底层是用CAN还是以太网,协议层根本不需要知道。这就是解耦。

我的习惯:接口定义好后,先写一个mock实现跑通上层逻辑,再回头优化底层。这样能尽早发现接口设计的问题。

4.3.2 状态机要独立,别到处散落

诊断协议里到处都是状态机:会话状态、安全状态、传输状态...我建议你把每个状态机封装成一个独立的模块。

我曾经接手过一个项目,会话状态判断散落在十几个文件里。每次改状态逻辑,都得全局搜索一遍,生怕漏掉。后来我统一改成了这样:

/* 会话状态机 - 独立模块 */
typedef enum {
    SESSION_DEFAULT,
    SESSION_PROGRAMMING,
    SESSION_EXTENDED
} SessionState_t;

typedef struct {
    SessionState_t currentState;
    uint32_t timeoutMs;
    void (*onStateChange)(SessionState_t newState);
} SessionManager_t;

SessionState_t Session_GetState(void);
DiagStatus_t Session_ChangeState(SessionState_t newState);
void Session_Tick(uint32_t elapsedMs);

所有与会话相关的逻辑,都集中在这个模块里。其他地方想获取会话状态?调用 Session_GetState() 就行。想切换会话?调用 Session_ChangeState()。简单、清晰、好维护。

4.3.3 配置要可裁剪,别写死

不同项目对诊断功能的需求差别很大。有的要支持OBD,有的要支持UDS,有的两个都要。我建议你把功能配置做成可裁剪的。

具体做法是用编译开关:

/* diag_config.h */
#define DIAG_ENABLE_UDS         1
#define DIAG_ENABLE_OBD         1
#define DIAG_ENABLE_DOIP        0
#define DIAG_MAX_SESSIONS       3
#define DIAG_DEFAULT_TIMEOUT_MS 5000

/* 协议层根据配置决定编译哪些代码 */
#if DIAG_ENABLE_UDS
#include "uds_parser.h"
#endif

#if DIAG_ENABLE_OBD
#include "obd_parser.h"
#endif

这样做的好处是:同一个代码库,通过修改配置文件就能适配不同项目。我手上有三个量产项目,用的就是同一套代码,只是配置文件不同。维护成本大大降低。

注意:配置项不要太多,否则会变成配置地狱。我一般控制在20个以内,超过这个数就要考虑重新设计架构了。

4.4 实战中的避坑指南

讲几个我踩过的坑,希望能帮你少走弯路。

坑一:层与层之间直接调用

我曾经让协议层直接调用CAN驱动的发送函数。后来要加DoIP支持,发现所有发送逻辑都得改。正确的做法是:层与层之间通过接口回调通信,不要直接调用。

坑二:全局变量满天飞

早期项目里,我用了十几个全局变量来传递诊断数据。结果调试时根本不知道哪个模块改了哪个变量。后来我改用模块内部静态变量 + 接口访问的方式,问题迎刃而解。

坑三:忽视错误处理

很多工程师只关注正常流程,忽略了异常处理。比如CAN总线断开了怎么办?会话超时了怎么办?我建议每个接口都返回错误码,上层根据错误码做相应处理。

我的经验:写代码时,先写错误处理逻辑,再写正常逻辑。这样能保证异常情况不会被遗漏。

4.5 总结

分层解耦和模块化设计,说白了就是高内聚、低耦合。每个模块只关心自己的事,模块之间通过稳定的接口通信。这样做的好处是:

  • 代码容易维护,改一处不会影响其他地方
  • 功能可裁剪,适配不同项目
  • 团队协作方便,每个人负责一个模块

最后送你一句话:架构设计不是一蹴而就的,而是在实践中不断迭代优化的。别怕一开始设计得不够完美,关键是保持重构的勇气和能力。

下一章,我会讲如何把UDS协议栈移植到你的MCU上,包括内存管理、中断处理、时序控制等实战技巧。到时候见。