第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 接口定义规范:怎么让各层愉快地聊天?
分层和模块都划好了,接下来最关键的一步:定义接口。接口定义得好,各层之间就像齿轮一样咬合紧密;定义得不好,那就是互相扯皮。
我总结了几条接口定义规范,供你参考:
- 接口函数命名统一:我习惯用
层名_模块名_动作()的格式。比如dl_filter_set_id(),一看就知道是数据链路层过滤模块的设置ID函数。 - 参数尽量用结构体:不要传一堆零散的参数。比如发送帧,我定义了一个
can_frame_t结构体,包含ID、DLC、Data、Flags、Timestamp。这样接口清晰,也方便扩展。 - 返回值统一:所有接口函数返回
int32_t,0表示成功,负数表示错误码。错误码定义在can_err.h里,比如-1表示发送超时,-2表示缓冲区满。 - 避免全局变量:各层之间通过函数参数传递数据,不要用全局变量。全局变量是万恶之源,我吃过太多亏了。
避坑指南:我曾经在一个项目中,为了图方便,在驱动层定义了一个全局的 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协议栈就有了一个稳固的骨架。下一章,我们聊聊怎么把这个骨架移植到具体的芯片上,那才是真正考验功力的时候。