CAN 收发实战
从启动外设到收发一帧报文的完整链路:填充发送邮箱、轮询与中断两种接收方式、错误回调处理,以及两块开发板互连组网的可运行示例。
从启动外设到收发一帧报文的完整链路:填充发送邮箱、轮询与中断两种接收方式、错误回调处理,以及两块开发板互连组网的可运行示例。
HAL_CAN_Init 与过滤器配置之后调用。它会等待硬件同步(检测到 11 个连续隐性位);若总线异常会返回超时错误。只有 Start 之后邮箱才能真正把报文发上总线。StdId(标准 11 位 ID)/ExtId(扩展 29 位 ID)、IDE(标准还是扩展)、RTR(数据帧还是远程帧)、DLC(数据长度 0~8)、TransmitGlobalTime(是否附时间戳)。数据本身单独放在一个 uint8_t[8] 数组里。HAL_CAN_GetTxMailboxesFreeLevel 查询空闲数。FilterMatchIndex(命中了第几组过滤器)和 Timestamp。读取报文时把它和数据缓冲区一起传给 HAL_CAN_GetRxMessage,函数会填好 ID、DLC 和数据。HAL_CAN_GetRxFifoFillLevel 查询 FIFO 是否有报文,有则 HAL_CAN_GetRxMessage 取出。实现简单,但 CPU 空转浪费、可能漏帧(FIFO 深度只有 3,来不及取就溢出),只适合报文稀疏或对实时性要求不高的场景。HAL_CAN_ActivateNotification 使能“FIFO0 报文挂起”中断,报文一到硬件立即触发 HAL_CAN_RxFifo0MsgPendingCallback 回调,在回调里取出报文。CPU 平时可做别的事,实时性高,是生产代码的首选。需先在 NVIC 里使能对应中断向量。CAN_IT_RX_FIFO0_MSG_PENDING(FIFO0 收到报文)、CAN_IT_TX_MAILBOX_EMPTY(发送完成)、CAN_IT_ERROR(总线错误)。可用按位或同时使能多个。对应还有 DeactivateNotification 关闭。HAL_CAN_GetError(&hcan) 读取错误码定位原因。总线上没有接收方(无人 ACK)时会持续报 ACK 错误,是单板独立测试最常见的现象。下图串起从初始化到收发的完整调用链,标注了轮询与中断两条接收路径:
发送分三步:填充帧头(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); }
把第 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:
/* 节点 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/其他号会导致引脚不通、总线无信号。
CAN 协议要求每帧发出后,总线上至少有一个其他节点在 ACK 槽拉低作为应答。如果总线上只有你一块板子(且未开回环模式),发出的帧无人 ACK,硬件会不断重发并触发 HAL_CAN_ERROR_ACK。单板自测请用回环模式 CAN_MODE_LOOPBACK,或接上第二个节点/CAN 分析仪。
收发标准流程:MX_CAN1_Init → HAL_CAN_ConfigFilter → HAL_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 会持续报错,用回环模式或接第二节点解决。