Chapter 07 · CAN 总线 / 汽车电子

CAN 收发实战

从启动外设到收发一帧报文的完整链路:填充发送邮箱、轮询与中断两种接收方式、错误回调处理,以及两块开发板互连组网的可运行示例。

课程进度44%

关键概念

HAL_CAN_Start()
让 bxCAN 从初始化模式退出、进入正常模式并连接总线的启动函数。必须在 HAL_CAN_Init 与过滤器配置之后调用。它会等待硬件同步(检测到 11 个连续隐性位);若总线异常会返回超时错误。只有 Start 之后邮箱才能真正把报文发上总线。
CAN_TxHeaderTypeDef (发送帧头)
描述一帧待发报文的元数据结构:StdId(标准 11 位 ID)/ExtId(扩展 29 位 ID)、IDE(标准还是扩展)、RTR(数据帧还是远程帧)、DLC(数据长度 0~8)、TransmitGlobalTime(是否附时间戳)。数据本身单独放在一个 uint8_t[8] 数组里。
HAL_CAN_AddTxMessage()
把帧头、数据和目标邮箱句柄传入,请求硬件发送。它并不阻塞等待发送完成,而是把报文放进空闲发送邮箱后立即返回,并通过出参回填使用了哪个邮箱号。若 3 个邮箱都满会返回错误,需先用 HAL_CAN_GetTxMailboxesFreeLevel 查询空闲数。
CAN_RxHeaderTypeDef (接收帧头)
接收报文时由硬件回填的元数据结构,字段与发送帧头对应,另外多出 FilterMatchIndex(命中了第几组过滤器)和 Timestamp。读取报文时把它和数据缓冲区一起传给 HAL_CAN_GetRxMessage,函数会填好 ID、DLC 和数据。
轮询接收 (Polling)
主循环里不断调用 HAL_CAN_GetRxFifoFillLevel 查询 FIFO 是否有报文,有则 HAL_CAN_GetRxMessage 取出。实现简单,但 CPU 空转浪费、可能漏帧(FIFO 深度只有 3,来不及取就溢出),只适合报文稀疏或对实时性要求不高的场景。
中断接收 (Interrupt)
通过 HAL_CAN_ActivateNotification 使能“FIFO0 报文挂起”中断,报文一到硬件立即触发 HAL_CAN_RxFifo0MsgPendingCallback 回调,在回调里取出报文。CPU 平时可做别的事,实时性高,是生产代码的首选。需先在 NVIC 里使能对应中断向量。
HAL_CAN_ActivateNotification()
使能指定的 CAN 中断源,参数是标志位掩码,如 CAN_IT_RX_FIFO0_MSG_PENDING(FIFO0 收到报文)、CAN_IT_TX_MAILBOX_EMPTY(发送完成)、CAN_IT_ERROR(总线错误)。可用按位或同时使能多个。对应还有 DeactivateNotification 关闭。
总线错误回调 HAL_CAN_ErrorCallback()
当发生位错误、填充错误、CRC 错误、ACK 错误、格式错误或进入错误被动/离线状态时触发。回调里通过 HAL_CAN_GetError(&hcan) 读取错误码定位原因。总线上没有接收方(无人 ACK)时会持续报 ACK 错误,是单板独立测试最常见的现象。

收发数据流总览

下图串起从初始化到收发的完整调用链,标注了轮询与中断两条接收路径:

bxCAN 收发完整调用链 ═══════════════════════════════════════════════════════════════ 初始化阶段 ┌────────────────────────────────────────────────────┐ │ MX_CAN1_Init() ─▶ ConfigFilter() ─▶ HAL_CAN_Start() │ │ (可选) ActivateNotification(RX_FIFO0_MSG_PENDING) │ └────────────────────────────────────────────────────┘ │ ┌─────────────────┴──────────────────┐ ▼ ▼ 发送路径 接收路径 ══════════ ══════════ 填 TxHeader(ID/DLC) ┌── 轮询 ──────────────┐ │ │ GetRxFifoFillLevel>0? │ HAL_CAN_AddTxMessage() │ ─▶ GetRxMessage() │ │ 放入空闲邮箱 └───────────────────────┘ ▼ ┌── 中断 ──────────────┐ 硬件仲裁 → 发上总线 │ 报文到达 → ISR 触发 │ │ │ RxFifo0MsgPending │ 发送完成中断(可选) │ Callback 内取报文 │ └───────────────────────┘ │ 出错 → HAL_CAN_ErrorCallback()

发送一帧报文

发送分三步:填充帧头(ID、类型、长度)、准备数据、调用 HAL_CAN_AddTxMessage。下面封装成一个便捷函数,先检查是否有空闲邮箱:

/* can_tx.c — 发送一帧标准数据帧 */
#include "stm32f4xx_hal.h"

extern CAN_HandleTypeDef hcan1;

/* 返回 0 成功,-1 失败 */
int CAN_SendStd(uint16_t id, uint8_t *data, uint8_t len) {
  CAN_TxHeaderTypeDef tx = {0};
  uint32_t mailbox;              /* 出参:实际使用的邮箱号 */

  tx.StdId = id;                 /* 标准 11 位 ID */
  tx.IDE   = CAN_ID_STD;         /* 标准帧 */
  tx.RTR   = CAN_RTR_DATA;        /* 数据帧(非远程帧)*/
  tx.DLC   = len;                /* 数据长度 0~8 */
  tx.TransmitGlobalTime = DISABLE;

  /* 3 个邮箱是否全满?满则直接失败 */
  if (HAL_CAN_GetTxMailboxesFreeLevel(&hcan1) == 0) {
    return -1;
  }
  if (HAL_CAN_AddTxMessage(&hcan1, &tx, data, &mailbox) != HAL_OK) {
    return -1;
  }
  return 0;
}

/* 使用示例:每 100ms 发送一帧 */
void Tx_Loop(void) {
  uint8_t payload[8] = { 0x11, 0x22, 0x33, 0x44,
                       0x55, 0x66, 0x77, 0x88 };
  CAN_SendStd(0x123, payload, 8);
  HAL_Delay(100);
}

方式一:轮询接收

在主循环里查询 FIFO0 有没有报文,有就取出处理。简单但会占用 CPU:

/* 轮询接收 —— 放在 while(1) 主循环里 */
void Rx_Poll(void) {
  CAN_RxHeaderTypeDef rx;
  uint8_t buf[8];

  /* FIFO0 里有报文吗? */
  if (HAL_CAN_GetRxFifoFillLevel(&hcan1, CAN_RX_FIFO0) > 0) {
    if (HAL_CAN_GetRxMessage(&hcan1, CAN_RX_FIFO0, &rx, buf) == HAL_OK) {
      /* rx.StdId 是发来的 ID,rx.DLC 是长度,buf 是数据 */
      printf("RX ID=0x%03lX DLC=%lu: ", rx.StdId, rx.DLC);
      for (int i = 0; i < rx.DLC; i++)
        printf("%02X ", buf[i]);
      printf("\n");
    }
  }
}

方式二:中断接收(推荐)

使能 FIFO0 挂起中断,报文一到就在回调里立刻处理,CPU 平时空闲:

/* ① 初始化后使能中断(在 HAL_CAN_Start 之后调用一次)*/
void CAN_EnableRxIT(void) {
  HAL_CAN_ActivateNotification(&hcan1,
      CAN_IT_RX_FIFO0_MSG_PENDING |   /* FIFO0 收到报文 */
      CAN_IT_ERROR |                  /* 总线错误 */
      CAN_IT_BUSOFF);                 /* 离线 */
}

/* ② 在 stm32f4xx_it.c 里把 CAN 中断转给 HAL 处理 */
void CAN1_RX0_IRQHandler(void) {
  HAL_CAN_IRQHandler(&hcan1);
}

/* ③ HAL 在收到报文时自动回调此函数——在这里取报文 */
volatile uint8_t g_rx_flag = 0;
CAN_RxHeaderTypeDef g_rx;
uint8_t g_buf[8];

void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan) {
  if (HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &g_rx, g_buf) == HAL_OK) {
    g_rx_flag = 1;      /* 置标志,主循环处理,回调里别做重活 */
  }
}

/* ④ 别忘了在 NVIC 里使能中断向量(MspInit 或初始化里)*/
/* HAL_NVIC_SetPriority(CAN1_RX0_IRQn, 5, 0);            */
/* HAL_NVIC_EnableIRQ(CAN1_RX0_IRQn);                    */
中断回调里只做最小工作

回调运行在中断上下文,应尽量短:把报文取出、置个标志或压入队列就返回,具体解析放到主循环或任务里做。若在回调里 printf、跑复杂协议解析,会长时间占用中断,导致后续报文来不及取而溢出丢帧。第 9 章会讲用 FreeRTOS 队列把报文安全地从 ISR 投递到任务。

总线错误回调

发生错误时 HAL 会调用错误回调,读取错误码可精准定位问题:

void HAL_CAN_ErrorCallback(CAN_HandleTypeDef *hcan) {
  uint32_t err = HAL_CAN_GetError(hcan);

  if (err & HAL_CAN_ERROR_ACK)    printf("ACK 错误:总线上无人应答\n");
  if (err & HAL_CAN_ERROR_BOF)    printf("Bus-Off:错误过多离线\n");
  if (err & HAL_CAN_ERROR_STF)    printf("填充错误 (Stuff)\n");
  if (err & HAL_CAN_ERROR_CRC)    printf("CRC 错误\n");
  if (err & HAL_CAN_ERROR_BD)     printf("位错误 (Bit dominant)\n");

  /* 清除错误标志,恢复中断通知 */
  HAL_CAN_ResetError(hcan);
}

过滤器实战:只收特定 ID

把第 6 章的掩码过滤器收窄,只接收 ID 为 0x200 的报文——掩码全 1 表示每一位都要精确匹配:

/* 只接收标准帧 ID = 0x200 */
void CAN_Filter_Only0x200(void) {
  CAN_FilterTypeDef f = {0};
  f.FilterBank  = 0;
  f.FilterMode  = CAN_FILTERMODE_IDMASK;
  f.FilterScale = CAN_FILTERSCALE_32BIT;
  /* 32 位模式:ID 放在高 16 位的 [15:5],需左移 5 */
  f.FilterIdHigh     = (0x200 << 5);
  f.FilterIdLow      = 0x0000;
  f.FilterMaskIdHigh = (0x7FF << 5);   /* 掩码全 1:精确匹配 11 位 ID */
  f.FilterMaskIdLow  = 0x0000;
  f.FilterFIFOAssignment = CAN_RX_FIFO0;
  f.FilterActivation = ENABLE;
  HAL_CAN_ConfigFilter(&hcan1, &f);
}

多节点组网收发示例

两块 STM32 板通过 CAN 收发器连到同一总线(两端各接 120Ω 终端电阻)。节点 A 周期发 0x100,节点 B 收到后回 0x101

两节点 CAN 组网 ┌──────────┐ TTL ┌────────┐ CAN_H ═══════════ CAN_H ┌────────┐ TTL ┌──────────┐ │ STM32 A │───────▶│TJA1050 │──────┬─────────────┬──────│TJA1050 │◀──────│ STM32 B │ │ bxCAN │◀───────│ 收发器 │ CAN_L│ │CAN_L │ 收发器 │──────▶│ bxCAN │ └──────────┘ └────────┘ 120Ω 120Ω └────────┘ └──────────┘ ▔▔▔ ▔▔▔ 终端电阻 终端电阻 A: 每 200ms 发 ID=0x100 ──────────▶ B: 过滤器只收 0x100 B: 收到后回 ID=0x101 ◀────────── A: 过滤器只收 0x101
/* 节点 A 主循环 */
void NodeA_Loop(void) {
  uint8_t cmd[1] = { 0xA5 };
  CAN_SendStd(0x100, cmd, 1);   /* 发命令 */
  HAL_Delay(200);
  /* B 的回复由中断回调置 g_rx_flag,此处检查 */
  if (g_rx_flag) {
    g_rx_flag = 0;
    if (g_rx.StdId == 0x101)
      printf("收到 B 回复:0x%02X\n", g_buf[0]);
  }
}

/* 节点 B 的接收回调:收到 0x100 立即回 0x101 */
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan) {
  CAN_RxHeaderTypeDef rx; uint8_t buf[8];
  if (HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &rx, buf) == HAL_OK) {
    if (rx.StdId == 0x100) {
      uint8_t ack[1] = { 0x5A };
      CAN_SendStd(0x101, ack, 1);   /* 回复 */
    }
  }
}
三个高频坑

① 过滤器没配:bxCAN 默认过滤器全部关闭,不配任何过滤器则一帧都收不到(不是“收全部”)。至少要配一组“接收所有”的掩码过滤器。
② 时钟没开:忘了 __HAL_RCC_CAN1_CLK_ENABLE(),寄存器写入无效,Init 直接失败。
③ 引脚复用错:STM32F4 上 CAN1 的复用号是 GPIO_AF9_CAN1,写成 AF8/其他号会导致引脚不通、总线无信号。

单板测试为什么一直报 ACK 错误?

CAN 协议要求每帧发出后,总线上至少有一个其他节点在 ACK 槽拉低作为应答。如果总线上只有你一块板子(且未开回环模式),发出的帧无人 ACK,硬件会不断重发并触发 HAL_CAN_ERROR_ACK。单板自测请用回环模式 CAN_MODE_LOOPBACK,或接上第二个节点/CAN 分析仪。

本章小结

收发标准流程:MX_CAN1_InitHAL_CAN_ConfigFilterHAL_CAN_Start →(可选)HAL_CAN_ActivateNotification。发送三步:填 CAN_TxHeaderTypeDef(StdId/IDE/RTR/DLC)、备数据、HAL_CAN_AddTxMessage(非阻塞,满邮箱返回错误,先查 GetTxMailboxesFreeLevel)。接收两种方式:轮询用 GetRxFifoFillLevel+GetRxMessage,简单但费 CPU 易丢帧;中断用 ActivateNotification(CAN_IT_RX_FIFO0_MSG_PENDING),在 HAL_CAN_RxFifo0MsgPendingCallback 里取报文,回调只做最小工作。错误统一在 HAL_CAN_ErrorCallback 里用 HAL_CAN_GetError 定位。过滤器收窄时掩码置全 1 精确匹配。三大坑:过滤器不配则收不到、时钟不开 Init 失败、CAN1 引脚复用必须 AF9。单板测试无人 ACK 会持续报错,用回环模式或接第二节点解决。

⚒️ 配套开发者工具箱 · JSON / 正则 / 时间戳 / Cron 等 17 款免费在线工具