4. CAN报文发送与接收:邮箱机制、发送流程、接收中断与FIFO管理

CAN通信这块,说白了就是两个动作:发和收。但怎么发得稳、收得全,这里面的门道可不少。我刚开始做座舱项目时,就因为在报文接收上栽了个跟头,导致车机黑屏——嗯,从那以后我对CAN的收发机制就再也不敢马虎了。

4.1 邮箱机制——CAN通信的“快递柜”

CAN控制器内部有一组硬件缓冲区,我们叫它“邮箱”。每个邮箱可以独立配置成发送邮箱或接收邮箱。你可以把它想象成一组快递柜:发送就是把报文塞进柜子,硬件自己会把它送出去;接收就是硬件把收到的报文放进柜子,你去取。

邮箱类型 方向 典型数量 用途
发送邮箱 MCU → CAN总线 3~8个 存放待发送报文
接收邮箱 CAN总线 → MCU 2~16个 存放已接收报文
FIFO邮箱 双向 深度可配置 批量接收/发送

每个邮箱都有自己的标识符(ID)和掩码寄存器。硬件收到报文后,会自动比对ID,匹配上了就存进对应的接收邮箱。这个过程不需要CPU干预——这才是CAN控制器的精髓。

关键点:邮箱数量是硬件固定的,但你可以通过软件灵活配置。比如把3个邮箱配成发送,5个配成接收,剩下的做FIFO。具体怎么分,看你的项目需求。

4.2 发送流程——轮询 vs 中断

发送报文有两种方式:轮询和中断。我个人的习惯是:低速报文用轮询,高速或周期性报文用中断。为什么?往下看。

4.2.1 轮询发送

轮询发送的流程很简单:

  1. 检查邮箱状态寄存器,确认邮箱是否为空(空闲)。
  2. 将报文数据写入邮箱的数据寄存器。
  3. 设置标识符、数据长度码(DLC)等控制位。
  4. 置位发送请求位(TXRQ),启动发送。
  5. 循环等待发送完成标志位(TXOK)置位。
  6. 清除标志位,释放邮箱。
// 轮询发送示例(伪代码)
void CAN_Send_Polling(uint32_t id, uint8_t *data, uint8_t len) {
    // 等待邮箱空闲
    while (CAN->TSR & CAN_TSR_TME0 == 0);
    
    // 填充邮箱
    CAN->sTxMailBox->TIR = (id << 21);
    CAN->sTxMailBox->TDTR = len;
    for (int i = 0; i < len; i++) {
        CAN->sTxMailBox->TDLR = data[i] << (i * 8);
    }
    
    // 请求发送
    CAN->sTxMailBox->TIR |= CAN_TI0R_TXRQ;
    
    // 等待发送完成
    while ((CAN->TSR & CAN_TSR_RQCP0) == 0);
    
    // 清除标志
    CAN->TSR |= CAN_TSR_RQCP0;
}

我的经验:轮询发送适合发送频率低(比如每秒几次)或者对实时性要求不高的报文。我曾经在一个项目中用轮询发送空调控制报文,每100ms发一次,CPU占用率几乎可以忽略。

4.2.2 中断发送

中断发送的流程:

  1. 配置邮箱,写入数据,置位发送请求。
  2. 使能发送完成中断。
  3. CPU去做其他事情。
  4. 硬件发送完成后,触发中断。
  5. 在中断服务函数中清除标志位,准备下一个报文。
// 中断发送——中断服务函数
void CAN_TX_IRQHandler(void) {
    if (CAN->TSR & CAN_TSR_RQCP0) {
        // 清除发送完成标志
        CAN->TSR |= CAN_TSR_RQCP0;
        
        // 通知主循环:邮箱已释放
        g_can_tx_done = 1;
        
        // 如果有待发送报文,继续发送
        if (tx_queue_not_empty()) {
            CAN_Send_From_Queue();
        }
    }
}

注意:中断服务函数里不要做复杂操作!我见过有人直接在中断里调用printf打印日志,结果系统直接卡死。中断里只做标志位操作和简单数据搬运,复杂逻辑放到主循环或任务里处理。

4.3 接收中断处理——别让报文“丢了”

接收中断是CAN通信中最常用的方式。为什么?因为报文什么时候来,你根本不知道。轮询接收会浪费大量CPU时间,而中断接收能做到“来了就处理”。

接收中断的处理流程:

  1. 硬件收到报文,匹配接收邮箱或FIFO。
  2. 触发接收中断(RXNE或FMP)。
  3. 在中断服务函数中读取报文。
  4. 将报文存入软件缓冲区(环形队列)。
  5. 清除中断标志位。
  6. 通知应用层处理。
// 接收中断服务函数
void CAN_RX_IRQHandler(void) {
    CanRxMsg rx_msg;
    
    // 读取报文
    rx_msg.id = CAN->sFIFOMailBox->RIR >> 21;
    rx_msg.dlc = CAN->sFIFOMailBox->RDTR & 0x0F;
    for (int i = 0; i < rx_msg.dlc; i++) {
        rx_msg.data[i] = (CAN->sFIFOMailBox->RDLR >> (i * 8)) & 0xFF;
    }
    
    // 释放FIFO(出栈)
    CAN->RF0R |= CAN_RF0R_RFOM0;
    
    // 存入环形队列
    ring_buffer_push(&rx_queue, &rx_msg);
}

避坑指南:我曾经在一个项目中,接收中断里直接调用了一个耗时较长的处理函数。结果总线负载一高,中断嵌套导致栈溢出,系统直接复位。后来我把接收中断改成“只存不处理”,在主循环中统一处理,问题就解决了。

4.4 FIFO管理——批量接收的利器

FIFO(先进先出)是CAN控制器提供的一种硬件队列。它和邮箱最大的区别是:邮箱是一对一的,FIFO是一对多的。一个FIFO可以连续存储多个报文,你只需要按顺序读取就行。

特性 邮箱模式 FIFO模式
存储深度 1个报文/邮箱 可配置(通常3~64个)
匹配方式 每个邮箱独立ID过滤 共享ID过滤
读取方式 直接读取指定邮箱 按顺序读取(出栈)
适用场景 特定ID报文 批量接收、高负载

FIFO的管理有几个关键点:

  • 深度配置:根据总线负载和CPU处理能力来定。负载高、CPU慢,就配深一点;反之可以浅一些。
  • 溢出处理:FIFO满了怎么办?硬件会丢弃新报文或覆盖旧报文。我建议开启溢出中断,在溢出时记录日志,方便排查问题。
  • 读取时机:可以在中断中逐条读取,也可以在定时中断中批量读取。我个人更推荐后者——减少中断频率,降低系统抖动。

小技巧:FIFO的“水位线”功能很实用。你可以设置一个阈值,比如FIFO里存了5个报文时触发中断。这样既能批量处理,又不会让FIFO溢出。我在座舱项目中就用这个方式处理多媒体控制报文,效果很好。

4.5 知识结构总览

下面这张图把CAN报文收发的主要脉络串起来了。你可以对照着看,心里有个全局图景。

CAN报文发送与接收知识结构图 CAN报文发送与接收知识结构 CAN控制器 发送模块 邮箱机制(3~8个发送邮箱) 轮询发送(低速/简单场景) 中断发送(高速/实时场景) 接收模块 接收邮箱(2~16个独立邮箱) FIFO管理(深度可配置) 接收中断处理(只存不处理) 核心原则: 中断只做数据搬运,主循环做业务处理

4.6 实战建议

说了这么多,最后给几个实在的建议:

  • 发送优先级:CAN总线本身有仲裁机制,但多个发送邮箱之间也有优先级。通常邮箱号越小优先级越高。把关键报文放在小号邮箱里。
  • 接收超时:如果某个报文超过预期时间没收到,要能检测到并做容错处理。我在项目中就遇到过CAN线接触不良导致报文丢失的情况,加了超时检测后问题很快定位。
  • 调试手段:初期调试时,可以把所有收发报文都打印出来。但量产版本一定要关掉打印,否则CAN通信会被打印拖垮。

最后提醒:CAN控制器的寄存器操作一定要小心。我曾经因为写错了某个位,导致整个CAN模块无法正常工作。调试了整整两天才发现——嗯,从那以后我每次配置寄存器都会对照手册逐位确认。