4. CAN报文发送与接收:发送缓冲区管理与发送优先级、接收缓冲区管理与FIFO机制、中断使能与中断服务函数编写

好,咱们今天聊点实在的。CAN通信说白了,核心就两件事:把数据发出去,把数据收回来。但就是这两件事,里面门道不少。我刚开始调CAN的时候,总觉得不就是写个寄存器嘛,结果板子一跑起来,报文丢了、优先级乱了、中断进不去……各种问题。后来才明白,发送和接收的缓冲区管理,才是CAN通信的“心脏”。

4.1 发送缓冲区管理与发送优先级

先说说发送。CMS32的CAN模块,发送缓冲区不是只有一个“坑位”。它通常有3个独立的发送缓冲区,我习惯叫它们TX0、TX1、TX2。每个缓冲区都能独立存放一帧完整的报文。

关键点:这三个缓冲区不是简单的“轮流用”,它们有优先级之分。TX0优先级最高,TX1次之,TX2最低。硬件在发送时,会先看TX0有没有待发报文,有就发;没有再看TX1,最后看TX2。

你可能会问:“那我是不是把所有报文都塞进TX0就行了?”千万别这么干。我见过有人这么搞,结果高优先级报文把低优先级报文活活“饿死”了。正确的做法是:根据报文的重要性分配缓冲区。

缓冲区 优先级 推荐用途
TX0 最高 紧急控制指令、故障报文
TX1 中等 周期性状态数据、参数更新
TX2 最低 诊断请求、非关键日志

发送流程其实不复杂,但有几个坑要注意。我一般这么写发送函数:

// 发送一帧CAN报文
uint8_t CAN_SendMsg(CAN_TypeDef* CANx, CanTxMsg* TxMsg) {
    uint8_t mailbox = 0;
    uint32_t tsr = CANx->TSR;
    
    // 1. 检查哪个发送邮箱空闲
    if (tsr & (1 << 26)) {          // TX0空闲
        mailbox = 0;
    } else if (tsr & (1 << 27)) {   // TX1空闲
        mailbox = 1;
    } else if (tsr & (1 << 28)) {   // TX2空闲
        mailbox = 2;
    } else {
        return CAN_BUSY;              // 全满,返回忙
    }
    
    // 2. 写入标识符
    if (TxMsg->IDE == CAN_ID_STD) {
        CANx->sTxMailBox[mailbox].TIR = (TxMsg->StdId << 21);
    } else {
        CANx->sTxMailBox[mailbox].TIR = (TxMsg->ExtId << 3) | (1 << 2);
    }
    
    // 3. 写入数据长度和帧类型
    CANx->sTxMailBox[mailbox].TDTR = TxMsg->DLC;
    
    // 4. 写入数据
    CANx->sTxMailBox[mailbox].TDLR = (TxMsg->Data[3] << 24) |
                                      (TxMsg->Data[2] << 16) |
                                      (TxMsg->Data[1] << 8)  |
                                       TxMsg->Data[0];
    CANx->sTxMailBox[mailbox].TDHR = (TxMsg->Data[7] << 24) |
                                      (TxMsg->Data[6] << 16) |
                                      (TxMsg->Data[5] << 8)  |
                                       TxMsg->Data[4];
    
    // 5. 请求发送
    CANx->sTxMailBox[mailbox].TIR |= (1 << 0);
    
    return CAN_OK;
}

我的经验:发送前一定要检查邮箱是否真的空闲。我曾经踩过一个坑:连续快速发送时,TSR寄存器还没更新,我就往同一个邮箱里写数据,结果把上一帧报文覆盖了。加个超时重试机制会稳妥很多。

4.2 接收缓冲区管理与FIFO机制

接收端的设计思路和发送端不太一样。CMS32的CAN模块有2个接收FIFO:FIFO0和FIFO1。每个FIFO可以存3帧报文。为什么用FIFO?说白了,就是为了应对“突发流量”。

你想想看,总线上可能同时有好几个节点发数据。如果CPU正在处理其他中断,来不及读接收寄存器,报文就丢了。FIFO就像一个缓冲池,先帮你存着,等CPU有空了再来取。

FIFO工作机制:

  • FIFO0和FIFO1各自独立,深度为3
  • 每个FIFO有对应的中断标志:FMP(报文等待)、FOV(溢出)、FULL(满)
  • 读取顺序:先入先出,硬件自动维护指针

接收流程我习惯这样写:

// 从FIFO读取一帧报文
uint8_t CAN_ReceiveMsg(CAN_TypeDef* CANx, uint8_t fifo_num, CanRxMsg* RxMsg) {
    CanRxFIFO* fifo;
    
    // 选择FIFO
    if (fifo_num == 0) {
        fifo = &CANx->sFIFOMailBox[0];
    } else {
        fifo = &CANx->sFIFOMailBox[1];
    }
    
    // 1. 读取标识符
    if (fifo->RIR & (1 << 2)) {  // 扩展帧
        RxMsg->IDE = CAN_ID_EXT;
        RxMsg->ExtId = (fifo->RIR >> 3) & 0x1FFFFFFF;
    } else {
        RxMsg->IDE = CAN_ID_STD;
        RxMsg->StdId = (fifo->RIR >> 21) & 0x7FF;
    }
    
    // 2. 读取数据长度
    RxMsg->DLC = fifo->RDTR & 0x0F;
    
    // 3. 读取数据
    RxMsg->Data[0] = fifo->RDLR & 0xFF;
    RxMsg->Data[1] = (fifo->RDLR >> 8) & 0xFF;
    RxMsg->Data[2] = (fifo->RDLR >> 16) & 0xFF;
    RxMsg->Data[3] = (fifo->RDLR >> 24) & 0xFF;
    RxMsg->Data[4] = fifo->RDHR & 0xFF;
    RxMsg->Data[5] = (fifo->RDHR >> 8) & 0xFF;
    RxMsg->Data[6] = (fifo->RDHR >> 16) & 0xFF;
    RxMsg->Data[7] = (fifo->RDHR >> 24) & 0xFF;
    
    // 4. 释放FIFO(硬件自动更新指针)
    CANx->RF0R |= (1 << 5);  // 释放FIFO0
    // CANx->RF1R |= (1 << 5);  // 释放FIFO1
    
    return CAN_OK;
}

重要提醒:读完报文后,一定要释放FIFO!我见过有人忘了这步,结果FIFO一直显示“满”,后续报文全部丢失。释放操作就是置位RF0R或RF1R寄存器的RFOM位。

4.3 中断使能与中断服务函数编写

轮询方式接收报文,CPU利用率太低了。实际项目中,我几乎都是用中断。CMS32的CAN中断源挺多的,但常用的就这几个:

中断源 标志位 触发条件
发送完成 TXOK 报文成功发送到总线
接收FIFO0 FMP0 FIFO0中有待读报文
接收FIFO1 FMP1 FIFO1中有待读报文
错误警告 EWG 错误计数达到警告阈值
总线关闭 BOF 节点进入总线关闭状态

中断使能的配置,我一般放在CAN初始化函数里:

// 使能CAN中断
void CAN_EnableInterrupt(CAN_TypeDef* CANx) {
    // 使能发送完成中断
    CANx->IER |= (1 << 0);   // TMEIE
    
    // 使能FIFO0接收中断
    CANx->IER |= (1 << 1);   // FMPIE0
    
    // 使能FIFO1接收中断
    CANx->IER |= (1 << 4);   // FMPIE1
    
    // 使能错误中断
    CANx->IER |= (1 << 11);  // EWGIE
    CANx->IER |= (1 << 14);  // BOFIE
    
    // 在NVIC中使能CAN中断通道
    NVIC_EnableIRQ(CAN_IRQn);
}

中断服务函数怎么写?我个人的习惯是:ISR里只做最轻量级的事,比如把数据从FIFO搬到环形缓冲区,然后置一个标志位。真正的数据处理,放在主循环里做。

// CAN中断服务函数
void CAN_IRQHandler(void) {
    uint32_t status;
    
    // 读取中断状态
    status = CANx->ESR;
    
    // 1. 处理接收中断(FIFO0)
    if (CANx->RF0R & (1 << 3)) {  // FMP0位
        // 读取报文,存入环形缓冲区
        CAN_ReceiveMsg(CANx, 0, &rx_msg);
        RingBuf_Write(&rx_ring_buf, &rx_msg);
        
        // 置位接收标志
        rx_flag = 1;
    }
    
    // 2. 处理发送完成中断
    if (CANx->TSR & (1 << 0)) {  // TXOK0
        // 清除标志
        CANx->TSR |= (1 << 0);
        
        // 可以在这里触发下一帧发送
        tx_done_flag = 1;
    }
    
    // 3. 处理错误中断
    if (status & (1 << 0)) {  // EWGF
        // 错误警告处理
        error_count++;
    }
    
    if (status & (1 << 2)) {  // BOFF
        // 总线关闭处理
        bus_off_flag = 1;
        // 这里需要重新初始化CAN
    }
}

避坑指南:我曾经在ISR里直接调用printf打印调试信息,结果程序卡死了。为什么?printf本身可能用了中断,在ISR里调用就形成了中断嵌套,堆栈溢出。记住:ISR里别做耗时操作,别调用不可重入函数。

最后,我画了一张图,把发送和接收的整个数据流串起来,方便你理解:

CAN报文发送与接收数据流 CAN报文发送与接收数据流 发送缓冲区管理 TX0 (最高) TX1 (中等) TX2 (最低) 总线仲裁(ID优先级) CAN总线 接收缓冲区管理(FIFO) 硬件滤波(ID筛选) FIFO0 (深度3) FIFO1 (深度3) 中断服务函数(ISR) 主循环数据处理 发送优先级规则 TX0 > TX1 > TX2 硬件自动仲裁,无需软件干预 FIFO接收规则 先入先出,深度3 溢出时丢弃最旧报文

嗯,这张图基本把发送和接收的路径画清楚了。发送端从三个优先级不同的缓冲区出发,经过总线仲裁,最终送上CAN总线。接收端则先经过硬件滤波,符合条件的报文进入FIFO,然后触发中断,ISR把数据搬到环形缓冲区,主循环再慢慢处理。

记住一句话:发送看优先级,接收看FIFO,中断里别干重活。把这三点吃透了,CAN通信这块你就拿下了大半。