4. CAN通信栈模块概览

CAN通信的核心,说白了就是报文怎么发出去、怎么收进来。很多初学者把CAN驱动当成一个黑盒子,调通了就完事。但真正做产品,你得搞清楚里面的收发机制——不然遇到丢帧、优先级反转、FIFO溢出,你连从哪下手都不知道。

我个人习惯把CAN收发拆成三个维度来看:发送侧的邮箱调度、接收侧的FIFO处理、以及过滤机制。今天咱们就一个一个掰开聊。

4.1 发送邮箱管理:谁先走,谁等着?

CAN控制器内部通常有多个发送邮箱(也叫发送缓冲器)。以STM32的bxCAN为例,它有3个发送邮箱。你可能会问:才3个,够用吗?

嗯,这里要注意——邮箱数量是硬件决定的,但怎么用,是软件决定的。

4.1.1 邮箱的三种状态

每个发送邮箱在任意时刻处于以下三种状态之一:

状态 含义 典型场景
空 邮箱空闲,可写入新报文 系统初始化后,所有邮箱均为空
挂起 报文已写入,等待总线仲裁 高优先级报文正在等待低优先级报文释放总线
发送中 报文正在总线上传输 该邮箱对应的报文赢得仲裁,正在逐位发送

我刚开始做CAN驱动时,犯过一个低级错误:往一个非空邮箱里写数据,结果把正在发送的报文覆盖了。后来养成习惯,每次写邮箱前先检查状态寄存器。

4.1.2 发送优先级策略

当多个邮箱同时有报文等待发送时,控制器怎么决定谁先走?

有两种模式:

  • 固定优先级:邮箱编号越小,优先级越高。比如邮箱0 > 邮箱1 > 邮箱2。简单粗暴,但容易导致低编号邮箱被“饿死”。
  • 轮询模式:按邮箱编号循环发送,每个邮箱轮流获得发送机会。公平,但实时性差。

我在项目中遇到过一个问题:某个节点发送紧急报警报文,但因为它被分配到了邮箱2,前面两个邮箱一直在发周期性的状态报文,报警报文迟迟发不出去。后来我把报警报文固定到邮箱0,问题解决。

实战建议:把实时性要求最高的报文(如故障码、紧急指令)固定到编号最小的邮箱。周期性报文用轮询模式或软件调度。

4.1.3 软件发送流程

一个标准的发送流程,我一般这样写:

// 伪代码:CAN报文发送
uint8_t CAN_SendMessage(uint32_t id, uint8_t* data, uint8_t len) {
    // 1. 找一个空邮箱
    uint8_t mailbox = CAN_FindEmptyMailbox();
    if (mailbox == NO_MAILBOX) {
        return BUSY;  // 所有邮箱都满了
    }
    
    // 2. 写入ID、数据、长度
    CAN_TxMailbox[mailbox].ID = id;
    CAN_TxMailbox[mailbox].DLC = len;
    for (int i = 0; i < len; i++) {
        CAN_TxMailbox[mailbox].Data[i] = data[i];
    }
    
    // 3. 请求发送
    CAN_TxMailbox[mailbox].TXRQ = 1;
    
    return OK;
}

注意:CAN_FindEmptyMailbox() 这个函数,我建议不要只查邮箱状态,还要考虑优先级。比如先查邮箱0,再查邮箱1,最后查邮箱2——这样能保证高优先级邮箱优先被使用。

4.2 接收FIFO处理:别让数据淹了

接收侧和发送侧不一样。发送是主动的,接收是被动的。你永远不知道总线上下一个报文什么时候来,来多少。

所以CAN控制器用FIFO(先进先出队列)来缓冲接收到的报文。以bxCAN为例,它有2个接收FIFO,每个FIFO深度为3个报文。

4.2.1 FIFO的工作原理

FIFO的工作流程很简单:

  1. 总线上的报文通过硬件过滤后,被存入FIFO。
  2. CPU从FIFO头部读取最早到达的报文。
  3. 读取完成后,释放该报文占用的FIFO空间。

但有个坑:如果CPU读取速度跟不上报文到达速度,FIFO会溢出。一旦溢出,新来的报文会被丢弃。

我曾经踩过的坑:在一个高速CAN网络(1Mbps)上,总线负载率接近80%。我的接收中断里做了很多数据处理,导致中断服务函数执行时间过长。结果FIFO溢出,丢帧率高达5%。后来我把数据处理挪到任务级,中断里只做FIFO读取和缓存,问题解决。

4.2.2 FIFO溢出处理策略

遇到FIFO溢出,你有三种选择:

策略 做法 适用场景
丢弃新报文 FIFO满时,新报文直接丢弃 周期性报文,丢一帧影响不大
覆盖旧报文 FIFO满时,新报文覆盖最旧的报文 状态类报文,最新数据最重要
软件缓冲 在RAM中开辟更大的环形缓冲区 关键报文,一帧都不能丢

我个人习惯用第三种——软件缓冲。硬件FIFO只有3个深度,太浅了。我在RAM里开一个256字节的环形缓冲区,中断里只做快速拷贝,主循环里慢慢处理。

4.2.3 接收中断 vs 轮询

接收报文有两种方式:

  • 中断方式:FIFO非空时触发中断,CPU立即读取。实时性好,但中断频率高时CPU负载大。
  • 轮询方式:主循环定期检查FIFO状态。实现简单,但实时性差。

我的经验是:总线负载率低于30%时,轮询就够了。高于30%,必须用中断。高于70%,中断里只能做最轻量的操作——读取FIFO、存入缓冲区、清中断标志,然后立刻退出。

4.3 硬件过滤与软件过滤:谁来决定报文该收?

CAN总线上所有节点都能收到所有报文。但每个节点只关心跟自己相关的报文。过滤机制就是干这个的。

4.3.1 硬件过滤:又快又省心

硬件过滤是CAN控制器内部的一个功能模块。它根据预设的ID列表或ID范围,决定哪些报文能进入FIFO,哪些直接丢弃。

以bxCAN为例,它有28个过滤器组。每个过滤器组可以配置为:

  • 列表模式:精确匹配一个或两个ID。比如只收ID=0x123的报文。
  • 掩码模式:匹配一个ID范围。比如收ID=0x100~0x1FF的所有报文。

配置示例:

// 配置过滤器0:掩码模式,只收ID=0x123的报文
CAN_FilterInitTypeDef filter;
filter.FilterNumber = 0;
filter.FilterMode = CAN_FilterMode_IdMask;
filter.FilterScale = CAN_FilterScale_32bit;
filter.FilterIdHigh = 0x123 << 5;   // 标准ID左移5位
filter.FilterIdLow = 0;
filter.FilterMaskIdHigh = 0x7FF << 5;  // 掩码:只比较11位ID
filter.FilterMaskIdLow = 0;
filter.FilterFIFOAssignment = CAN_FIFO0;
filter.FilterActivation = ENABLE;
CAN_FilterInit(&filter);

硬件过滤的好处是:不占用CPU时间。报文在进入FIFO之前就被过滤掉了,CPU根本不知道有这回事。

4.3.2 软件过滤:灵活但费CPU

软件过滤,说白了就是先把所有报文都收进来,然后在软件里判断要不要处理。

什么时候用软件过滤?

  • 硬件过滤器数量不够用(比如28个过滤器不够你配的)
  • 过滤条件太复杂(比如要根据报文数据段内容过滤)
  • 需要动态修改过滤规则

软件过滤的典型实现:

void CAN_ReceiveHandler(void) {
    CanRxMsg rxMsg;
    CAN_Receive(CAN_FIFO0, &rxMsg);
    
    // 软件过滤:只处理ID在0x200~0x2FF范围内的报文
    if ((rxMsg.ID >= 0x200) && (rxMsg.ID <= 0x2FF)) {
        ProcessMessage(&rxMsg);
    }
    // 其他ID的报文直接丢弃
}

但注意:软件过滤意味着所有报文都进了FIFO。如果总线上报文很多,FIFO很容易溢出。我建议能用硬件过滤的,尽量用硬件过滤。

4.3.3 硬件+软件混合过滤

实际项目中,我常用混合策略:

  1. 用硬件过滤收下所有“可能有用”的报文(比如一个较宽的ID范围)
  2. 用软件过滤做精确筛选

这样既减少了FIFO压力,又保留了灵活性。

核心原则:硬件过滤做粗筛,软件过滤做精筛。硬件过滤解决“要不要收”的问题,软件过滤解决“要不要处理”的问题。

4.4 本章知识体系

下面这张图总结了CAN报文收发机制的核心逻辑:

CAN报文收发机制知识体系 CAN报文收发机制知识体系 发送邮箱管理 接收FIFO处理 过滤机制 邮箱状态:空 / 挂起 / 发送中 优先级策略:固定 / 轮询 软件发送流程:找空邮箱→写入→请求 FIFO深度:3个报文(bxCAN) 溢出策略:丢弃 / 覆盖 / 软件缓冲 读取方式:中断 vs 轮询 硬件过滤:列表模式 / 掩码模式 软件过滤:灵活但费CPU 混合策略:硬件粗筛 + 软件精筛 核心原则 发送:优先级决定谁先走 | 接收:FIFO深度决定能存多少 | 过滤:硬件决定收不收,软件决定用不用

这张图把本章的三个核心模块串起来了。你想想看:发送邮箱管的是“怎么发出去”,接收FIFO管的是“怎么收进来”,过滤机制管的是“收哪些”。三者配合好了,CAN通信才算真正跑得稳。