第2章:协议栈架构设计:分层架构、核心模块划分、接口定义规范

好,咱们接着聊。上一章我们把CAN FD协议栈的选型思路理清了,这一章,我带你看看协议栈内部到底长什么样。说白了,就是怎么把一堆代码组织得井井有条,让后续的移植和调优不那么痛苦。

我个人习惯,做任何嵌入式软件,第一件事就是画架构图。不是那种花里胡哨的PPT图,而是能指导你写代码的模块划分图。CAN FD协议栈也不例外。你想想看,一个协议栈要处理报文收发、帧封装解析、错误处理、时间同步……如果全揉在一个文件里,那后期维护起来,啧啧,我见过太多这样的“屎山”代码了。

2.1 分层架构:为什么是三层?

我建议把CAN FD协议栈分成三层:应用层数据链路层驱动层。为什么是三层?不是两层,也不是四层?

嗯,这里要注意。分两层,应用和驱动直接耦合,换芯片就得重写应用,太蠢。分四层,比如再加个“网络层”或“传输层”,对于CAN FD这种短帧、实时性要求高的总线来说,反而增加了不必要的延迟和内存开销。三层刚刚好,这是我在多个项目里验证过的。

层级 职责 典型文件
应用层 提供API给用户,处理高层协议(如J1939、UDS) can_app.c / can_app.h
数据链路层 帧封装/解析、ID过滤、DLC处理、错误状态机 can_dl.c / can_dl.h
驱动层 直接操作CAN控制器寄存器,收发原始报文 can_drv.c / can_drv.h

我曾经在一个项目中,接手别人的代码,发现他把CAN控制器的初始化、报文发送、中断处理全写在一个叫 can_bsp.c 的文件里,足足3000行。我花了整整一周才理清楚哪里是驱动,哪里是协议处理。从那以后,我坚持分层,哪怕项目再小。

2.2 核心模块划分:每个层里有什么?

光分层还不够,每一层内部还得拆成模块。我习惯按功能来切,每个模块只干一件事。

2.2.1 驱动层模块

驱动层是离硬件最近的。说白了,就是操作寄存器。我把它拆成三个模块:

  • 初始化模块:配置波特率、采样点、滤波器、中断使能。注意,CAN FD的波特率配置比CAN 2.0复杂,仲裁段和数据段可以不同速率。
  • 收发模块:提供 drv_send_frame()drv_read_frame()。这里要处理硬件FIFO,防止溢出。
  • 中断处理模块:处理发送完成、接收、错误、总线关闭等中断。我建议把中断服务函数写得尽量短,只做标志位设置,具体处理交给数据链路层。

核心经验:驱动层不要做任何协议解析!它只负责把原始报文(ID + DLC + Data + Flags)扔给上层。我曾经见过有人在驱动层里判断报文ID,结果换了个芯片,ID过滤逻辑全得重写,教训深刻。

2.2.2 数据链路层模块

这一层是协议栈的核心。我把它拆成四个模块:

  • 帧封装/解析模块:把应用层的数据打包成CAN FD帧,或者把收到的帧解析成应用层能理解的结构体。这里要处理DLC到数据长度的映射,CAN FD的DLC可以表示0-64字节。
  • ID过滤模块:实现软件过滤。虽然硬件也有过滤器,但有时候不够用。比如你想同时接收标准帧和扩展帧,或者根据特定ID范围过滤。
  • 错误状态机模块:监控发送/接收错误计数器,判断节点状态(主动错误、被动错误、总线关闭)。这个模块我建议单独拎出来,因为错误处理逻辑往往很复杂。
  • 时间戳模块:给每个收到的帧打上时间戳。这对于后续的延迟分析和同步很重要。

一个小技巧:数据链路层和驱动层之间,我习惯用环形缓冲区(Ring Buffer)来解耦。驱动层把收到的帧塞进缓冲区,数据链路层从缓冲区取。这样即使中断来得再快,也不会丢帧。我曾经在一个项目中,因为没用缓冲区,高速CAN FD通信时丢帧率高达5%,加上环形缓冲区后,直接降到0。

2.2.3 应用层模块

应用层是给用户用的。我把它拆成两个模块:

  • API接口模块:提供 can_send_message()can_receive_message()can_set_filter() 等函数。用户不需要知道底层怎么操作,调用这些函数就行。
  • 高层协议适配模块:如果你要跑J1939、UDS或者CANopen,这里做协议适配。比如J1939的PGN解析、UDS的诊断服务处理。

2.3 接口定义规范:怎么让各层愉快地聊天?

分层和模块都划好了,接下来最关键的一步:定义接口。接口定义得好,各层之间就像齿轮一样咬合紧密;定义得不好,那就是互相扯皮。

我总结了几条接口定义规范,供你参考:

  1. 接口函数命名统一:我习惯用 层名_模块名_动作() 的格式。比如 dl_filter_set_id(),一看就知道是数据链路层过滤模块的设置ID函数。
  2. 参数尽量用结构体:不要传一堆零散的参数。比如发送帧,我定义了一个 can_frame_t 结构体,包含ID、DLC、Data、Flags、Timestamp。这样接口清晰,也方便扩展。
  3. 返回值统一:所有接口函数返回 int32_t,0表示成功,负数表示错误码。错误码定义在 can_err.h 里,比如 -1 表示发送超时,-2 表示缓冲区满。
  4. 避免全局变量:各层之间通过函数参数传递数据,不要用全局变量。全局变量是万恶之源,我吃过太多亏了。

避坑指南:我曾经在一个项目中,为了图方便,在驱动层定义了一个全局的 can_rx_buffer[],数据链路层直接访问。后来要支持多通道(两个CAN FD控制器),这个全局变量就成了噩梦。改了一个月才把所有依赖清理干净。所以,接口一定要用函数封装,不要暴露内部数据结构。

下面是一个接口定义的示例,你可以看看:

// 驱动层接口
int32_t drv_init(can_config_t *config);
int32_t drv_send_frame(can_frame_t *frame);
int32_t drv_read_frame(can_frame_t *frame);
int32_t drv_get_status(can_status_t *status);

// 数据链路层接口
int32_t dl_init(void);
int32_t dl_send(can_frame_t *frame);
int32_t dl_receive(can_frame_t *frame);
int32_t dl_set_filter(uint32_t id, uint32_t mask);
int32_t dl_get_error_count(uint8_t *tx_err, uint8_t *rx_err);

// 应用层接口
int32_t can_send_message(uint32_t id, uint8_t *data, uint8_t len);
int32_t can_receive_message(uint32_t *id, uint8_t *data, uint8_t *len);
int32_t can_set_acceptance_filter(uint32_t id, uint32_t mask);

你看,每个接口都只做一件事,参数清晰,返回值统一。这样移植的时候,只需要改驱动层的 drv_init()drv_send_frame() 等函数,上层代码基本不用动。

好,这一章的内容就到这里。总结一下:分层架构让你代码结构清晰,核心模块划分让你功能内聚,接口定义规范让你各层解耦。这三板斧下去,你的CAN FD协议栈就有了一个稳固的骨架。下一章,我们聊聊怎么把这个骨架移植到具体的芯片上,那才是真正考验功力的时候。