CAN总线

CAN总线(Controller Area Network,控制器局域网)是嵌入式领域最常用的“工业级通信总线”——简单说,就是给设备之间(比如汽车里的发动机、仪表盘、车灯,工业设备里的传感器、控制器)搭建的“高速、可靠、抗干扰的对话通道”。

一、为什么需要CAN总线?

在CAN总线出现前,设备间通信是“点对点”的——比如一个系统里有5个设备要互相通信,就得拉5×4=20根线,还得处理每个设备的通信协议,不仅布线复杂、成本高,还容易出故障(一根线断了,两个设备就没法通信)。

CAN总线解决了这个问题,核心优势是:

  1. 总线结构:所有设备都挂在“两根线”(CAN_H、CAN_L)上,像大家共用一条“高速公路”,不用点对点拉线;
  2. 多主通信:没有“老大”(比如不是只有一个设备能发数据),任何设备都能主动发数据,适合分布式系统;
  3. 抗干扰强:CAN_H和CAN_L是差分信号
  4. 容错性好
  5. 远距离+高速

二、CAN总线的核心

1. 物理层:

CAN总线的物理层核心是“两根差分线”:

  • CAN_H:高电平线(正常通信时电压约3.5V);
  • CAN_L:低电平线(正常通信时电压约1.5V);
  • 差分电压:CAN_H - CAN_L = 2V(表示“显性电平”,对应逻辑0),CAN_H - CAN_L = 0V(表示“隐性电平”,对应逻辑1);
  • 终端电阻:在总线的两端必须接一个120Ω的电阻(相当于“匹配负载”),防止信号反射导致通信出错(新手容易忘接,导致总线不稳定)。

2. 数据帧:CAN总线的“数据包”(相当于“快递包裹”)

设备之间通信,不能乱发数据,必须把数据打包成“标准格式的数据包”,这就是CAN数据帧。新手重点记“标准帧”(最常用),结构如下(用比喻拆解):

字段 作用(比喻:快递包裹的组成) 通俗解释
起始位 包裹的“开头标记” 1个显性电平(0),告诉所有设备:“我要发数据了,大家注意接收”
ID(11位) 包裹的“地址+优先级”(核心!) 11位二进制数(0~2047),既代表“数据要发给谁”,也代表“数据的优先级”——ID越小,优先级越高
RTR位 包裹的“类型标记”(数据帧/远程帧) 0=数据帧(带实际数据,比如“转速1000转”);1=远程帧(请求数据,比如“请告诉我你的转速”)
DLC位(4位) 包裹的“快递单”:说明里面有多少数据 0~8,表示数据段有多少个字节(比如DLC=2,就是2个字节数据)
数据段(0-8B) 包裹里的“实际货物” 要传输的真实数据(比如发动机转速是0x03E8,就是1000转;温度是0x28,就是40℃)
CRC段 包裹的“校验码” 对前面的字段做校验,接收方用来判断数据有没有传错(比如路上干扰导致字节变了)
ACK段 包裹的“签收确认” 接收方收到正确数据后,发一个“确认信号”,告诉发送方“我收到了,没问题”
结束位 包裹的“结尾标记” 7个隐性电平(1),告诉所有设备:“这次数据发完了”

关键提醒:CAN总线的ID是“核心中的核心”——不仅是地址,还是优先级。比如汽车里“刹车信号”的ID是0x001(优先级最高),“空调温度”的ID是0x100(优先级低),当两者同时发数据时,刹车信号会“插队”先传,这就是后面要讲的“总线仲裁”。

3. 总线仲裁:解决“多设备同时发数据”的冲突(相当于“交通规则”)

CAN总线是“多主通信”——所有设备都能主动发数据,那如果两个设备同时发数据,会不会像路上两辆车撞在一起?

不会!因为CAN有“总线仲裁”机制,核心逻辑是:按ID优先级“抢答”,优先级高的先发,优先级低的自动避让,全程无冲突

用比喻解释仲裁过程:

  • 总线平时是“隐性电平”(相当于道路空着);
  • 设备A(ID=0x001,优先级高)和设备B(ID=0x002,优先级低)同时想发数据,都开始往总线上发“起始位”(显性电平);
  • 接下来发ID:设备A发第1位是0,设备B发第1位也是0(一致,继续);… 直到发ID的最后一位,设备A发0,设备B发1;
  • 此时总线的电平是“显性电平”(因为A发的0是显性,B发的1是隐性,显性电平会“覆盖”隐性电平);
  • 设备B发现:“我发的是1(隐性),但总线上实际是0(显性),说明有优先级比我高的设备在发数据”——于是设备B立刻停止发送,退到一边,等A发完再试;
  • 设备A没发现冲突,继续把剩下的数据发完;
  • A发完后,总线回到隐性电平,设备B再重新尝试发送自己的数据。

这个过程全程很快(微秒级),不会导致数据丢失,也不用像其他总线那样“等待”,这就是CAN总线适合“实时系统”(比如汽车刹车、工业控制)的关键——优先级高的紧急数据能立刻传输,不耽误。

第一步:硬件准备(CubeMX配置的核心)

STM32H7的FDCAN外设需要连接CAN总线,CubeMX里的配置,主要是这几点:

  1. 引脚配置:指定STM32的哪两个引脚作为CAN_H和CAN_L——这是硬件连接的基础,必须和实际接线一致;
  2. 波特率配置:设置CAN总线的通信速率(比如500Kbps、1Mbps)——所有挂在总线上的设备,波特率必须一致,否则没法通信(比如A设备发数据是1Mbps,B设备收数据是250Kbps,B会把数据当成乱码);
    • 波特率怎么来?STM32的FDCAN外设由“时钟源”(比如APB时钟)分频得到,CubeMX会自动计算“预分频器、时间段1、时间段2”这三个参数,不用你手动算。
  3. 模式配置:设置FDCAN是“正常模式”(能发能收)、“只收模式”(只接收不发送)还是“环回模式”(自己发的数据自己收,用来调试);
  4. 过滤器配置:CAN总线所有设备都会收到数据,但我们只关心自己需要的数据(比如只接收ID=0x123的数据)——过滤器就是“筛选器”,只让符合条件的数据进入接收缓冲区,其他数据直接丢弃(减少CPU负担);

第二步:初始化FDCAN

这部分是CubeMX生成+可能手动修改的代码,核心作用是“告诉STM32的FDCAN外设:怎么通信、怎么筛选数据、怎么处理接收”,步骤拆解:

// 示例:初始化函数核心逻辑(HAL库生成)
static void MX_FDCAN1_Init(void)
{
  hfdcan1.Instance = FDCAN1;  // 选择FDCAN1外设(STM32H7可能有多个FDCAN)
  hfdcan1.Init.ClockDivider = FDCAN_CLOCK_DIV1;  // 时钟分频(1分频,用原始时钟)
  hfdcan1.Init.FrameFormat = FDCAN_FRAME_CLASSIC;  // 帧格式:传统CAN帧(11位ID)
  hfdcan1.Init.Mode = FDCAN_MODE_NORMAL;  // 正常模式(能发能收)
  hfdcan1.Init.NominalPrescaler = 16;  // 波特率预分频器(和时钟一起决定波特率)
  hfdcan1.Init.NominalSyncJumpWidth = FDCAN_BT1_BITTIME_1;  // 同步跳转宽度(时钟同步用)
  hfdcan1.Init.NominalTimeSeg1 = FDCAN_BT1_BITTIME_13;  // 时间段1(波特率计算参数)
  hfdcan1.Init.NominalTimeSeg2 = FDCAN_BT2_BITTIME_2;  // 时间段2(波特率计算参数)
  // 其他配置(比如自动重发、唤醒模式等)
  
  if (HAL_FDCAN_Init(&hfdcan1) != HAL_OK)  // 初始化FDCAN外设
  {
    Error_Handler();  // 初始化失败就报错(比如硬件故障、配置错误)
  }

  // 配置过滤器:只接收ID=0x123的标准帧
  FDCAN_FilterTypeDef sFilterConfig = {0};
  sFilterConfig.FilterActivation = ENABLE;  // 启用过滤器
  sFilterConfig.FilterBank = 0;  // 用第0个过滤器(STM32有多个过滤器)
  sFilterConfig.FilterMode = FDCAN_FILTER_IDMASK;  // 过滤模式:ID掩码模式(按ID+掩码筛选)
  sFilterConfig.FilterScale = FDCAN_FILTER_SCALE_32BIT;  // 过滤器宽度:32位
  sFilterConfig.FilterId1 = 0x123 << 21;  // 要筛选的ID(11位ID左移21位,符合FDCAN寄存器格式)
  sFilterConfig.FilterMask1 = 0x1FFFFFFF;  // 掩码(全1表示严格匹配ID=0x123)
  sFilterConfig.FilterFIFOAssignment = FDCAN_FILTER_FIFO0;  // 符合条件的数据放进FIFO0缓冲区

  if (HAL_FDCAN_ConfigFilter(&hfdcan1, &sFilterConfig) != HAL_OK)  // 应用过滤器配置
  {
    Error_Handler();
  }

  // 启动FDCAN接收:开启FIFO0的接收中断(数据来了触发中断)
  if (HAL_FDCAN_Start(&hfdcan1) != HAL_OK)
  {
    Error_Handler();
  }
  if (HAL_FDCAN_ActivateNotification(&hfdcan1, FDCAN_IT_RX_FIFO0_NEW_MESSAGE, 0) != HAL_OK)
  {
    Error_Handler();
  }
}

逐行解释核心逻辑:

  1. hfdcan1是FDCAN的“句柄”:相当于FDCAN1外设的“身份证”,后续所有操作(发数据、收数据、配置)都要通过这个句柄告诉HAL库“操作哪个FDCAN”;
  2. 波特率配置参数:不用手动算,CubeMX会根据你设置的波特率(比如500Kbps)自动生成NominalPrescaler等参数,只要知道“这些参数决定了通信速率,所有设备必须一致”;
  3. 过滤器配置:为什么要配置?如果不配置,STM32会接收总线上所有数据,CPU要处理大量无用数据,导致负担过重。配置后只接收需要的数据,效率更高;
  4. HAL_FDCAN_ActivateNotification():启用“接收中断”——当FIFO0缓冲区有新数据时,会触发中断,CPU会暂停当前任务(比如FreeRTOS任务),去执行中断服务函数(这是嵌入式里“及时接收数据”的常用方式)。

第三步:发送CAN数据

设备要发数据时,需要把数据打包成CAN数据帧,通过FDCAN外设发送到总线上,HAL库提供了HAL_FDCAN_AddMessageToTxFifoQ()函数,核心逻辑:

// 示例:手动编写的CAN发送函数
uint8_t fdcan_send_data(uint32_t id, uint8_t *data, uint8_t len)
{
  FDCAN_TxHeaderTypeDef tx_header;  // 发送帧的“头部配置”(ID、DLC等)
  uint32_t tx_mailbox;  // 发送邮箱(STM32FDCAN有多个发送邮箱,用来缓存要发的数据)

  // 配置发送帧头部
  tx_header.Identifier = id;  // 要发送的ID(比如0x123)
  tx_header.IdType = FDCAN_STANDARD_ID;  // 标准帧(11位ID)
  tx_header.TxFrameType = FDCAN_DATA_FRAME;  // 数据帧(带实际数据)
  tx_header.DataLength = len;  // 数据长度(0~8字节)
  tx_header.TransmitGlobalTime = DISABLE;  // 不包含发送时间戳

  // 把数据帧加入发送邮箱,发送到总线上
  if (HAL_FDCAN_AddMessageToTxFifoQ(&hfdcan1, &tx_header, data, &tx_mailbox) == HAL_OK)
  {
    return 0;  // 发送成功
  }
  else
  {
    return 1;  // 发送失败(比如发送邮箱满了、总线忙)
  }
}

关键提醒:

  1. 发送数据时,必须指定Identifier(ID)和DataLength(数据长度),否则接收方没法解析;
  2. tx_mailbox是“发送邮箱”:STM32FDCAN有3个发送邮箱,当总线忙时,要发送的数据会存在邮箱里,等总线空闲了再发——如果3个邮箱都满了,HAL_FDCAN_AddMessageToTxFifoQ()会返回失败,所以要判断返回值,避免数据丢失;
  3. 发送成功不代表接收方收到了:CAN总线的“ACK段”只是“接收方收到正确数据后确认”,但发送方不知道是哪个接收方确认的——如果需要“一对一确认”,需要在应用层自己加协议(比如接收方收到数据后,发一个“确认帧”)。

第四步:接收CAN数据

接收数据有两种方式:“查询方式”(CPU一直问“有没有数据?”,浪费CPU)和“中断方式”(数据来了主动通知CPU,高效),核心流程:

1. 中断触发条件

当FDCAN接收到符合过滤器条件的数据,并且放进了FIFO0缓冲区,就会触发FDCAN1_IRQn中断(FDCAN1的中断号),CPU会跳转到中断服务函数FDCAN1_IRQHandler()

2. 中断服务函数
// 示例
void FDCAN1_IRQHandler(void)
{
  /* USER CODE BEGIN FDCAN1_IRQn 0 */

  /* USER CODE END FDCAN1_IRQn 0 */
  HAL_FDCAN_IRQHandler(&hfdcan1);  // HAL库的中断处理入口(自动判断中断类型)
  /* USER CODE BEGIN FDCAN1_IRQn 1 */

  /* USER CODE END FDCAN1_IRQn 1 */
}

这行代码的作用:HAL_FDCAN_IRQHandler(&hfdcan1)会自动读取FDCAN的中断状态寄存器,判断是“接收中断”“发送完成中断”还是“错误中断”,然后调用对应的回调函数——这是HAL库的“封装优势”,不用我们手动操作寄存器判断中断类型。

3. 接收回调函数

HAL库会在中断处理完成后,调用回调函数HAL_FDCAN_RxFifo0Callback(),我们在这个函数里处理接收的数据(比如把数据放进FreeRTOS队列,交给任务处理):

// 示例:FDCAN接收回调函数(数据来了会自动调用)
void HAL_FDCAN_RxFifo0Callback(FDCAN_HandleTypeDef *hfdcan, uint32_t RxFifo0ITs)
{
  if ((RxFifo0ITs & FDCAN_IT_RX_FIFO0_NEW_MESSAGE) != RESET)  // 确认是“新数据接收”中断
  {
    FDCAN_RxHeaderTypeDef rx_header;  // 接收帧的头部(存储接收数据的ID、DLC等)
    uint8_t rx_data[8] = {0};  // 接收数据的缓冲区(最多8字节)

    // 从FIFO0缓冲区读取数据和头部信息
    if (HAL_FDCAN_GetRxMessage(hfdcan, FDCAN_RX_FIFO0, &rx_header, rx_data) == HAL_OK)
    {
      // 关键:中断里不能做耗时操作(比如解析数据、打印日志),要快进快出
      // 所以把数据放进FreeRTOS队列,交给任务处理
      xQueueSendFromISR(can_data_queue, rx_data, NULL);  // 中断安全的队列发送函数
    }
  }
}

关键逻辑:

  1. 为什么用回调函数?因为HAL库把“判断中断类型、读取数据”的复杂操作封装了,我们只需要在回调函数里写“数据收到后该做什么”,不用修改中断服务函数(符合“分层设计”,方便维护);
  2. 为什么中断里要把数据放进队列?中断服务函数要“快进快出”——如果在中断里解析数据(比如判断数据是转速还是温度)、打印日志(USART打印很慢),会导致中断响应时间变长,其他中断(比如USART、TIM)没法及时响应,甚至导致系统崩溃;
  3. 为什么用xQueueSendFromISR()而不是xQueueSend()xQueueSend()是普通的队列发送函数,只能在FreeRTOS任务中使用;xQueueSendFromISR()是“中断安全”的队列发送函数,专门用于中断服务函数/回调函数中,能避免中断和任务同时操作队列导致的数据混乱(容易用错,导致系统不稳定)。

第五步:FreeRTOS任务处理CAN数据

核心逻辑:中断把数据放进队列后,FreeRTOS的“CAN数据处理任务”从队列中取出数据,进行解析、处理(比如控制LED、通过USART打印、下发指令给其他外设),示例代码:

//FreeRTOS CAN数据处理任务
void can_process_task(void *argument)
{
  uint8_t received_data[8] = {0};  // 存储从队列取出的数据

  for (;;)  // 任务是“无限循环”(FreeRTOS任务不能返回,否则会崩溃)
  {
    // 从队列中读取数据:如果队列有数据,就取出;如果没有,任务进入阻塞态(不占用CPU)
    if (xQueueReceive(can_data_queue, received_data, portMAX_DELAY) == pdPASS)
    {
     
      uint8_t temp_int = received_data[0];
      uint8_t temp_dec = received_data[1];
      float temperature = temp_int + temp_dec / 100.0f;

      // 处理数据
      char log_buf[32];
      sprintf(log_buf, "Received temperature: %.2f℃\r\n", temperature);
      HAL_UART_Transmit(&huart1, (uint8_t *)log_buf, strlen(log_buf), 100);

      // 或者控制LED:温度超过50℃,LED亮;否则灭
      if (temperature > 50.0f)
      {
        HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET);
      }
      else
      {
        HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET);
      }
    }
  }
}

关键逻辑解释:

  1. 任务是“无限循环”:FreeRTOS的任务是“最小执行单元”,一旦创建就会一直运行,所以用for(;;)循环——如果任务返回,会导致FreeRTOS调度器出错;
  2. xQueueReceive()的阻塞态:portMAX_DELAY表示“一直等,直到队列有数据”,此时任务会进入“阻塞态”,CPU会去执行其他任务(比如LED任务),不会浪费CPU资源(新手容易写成while(1)查询队列,导致CPU占用100%);
  3. 数据解析的灵活性:CAN数据帧的数据段是“原始字节”,具体代表什么意思(比如是温度、转速、指令),需要你和总线上的其他设备约定好“应用层协议”——比如约定“ID=0x123的数据,第0-1字节是温度,第2-3字节是转速”,这就是“自定义协议”,新手要注意:CAN总线只负责“传输数据”,不负责“解释数据”,解释数据需要应用层协议。

六、CAN总线开发中容易犯的错误

  1. 波特率不一致:总线上的所有设备(比如STM32、传感器、控制器)波特率必须一样(比如都是500Kbps),否则会出现“发得出但收不到”“收到乱码”——容易只改STM32的波特率,忘了改其他设备;
  2. 终端电阻没接或接多了:总线两端必须各接一个120Ω电阻,接少了(比如只接一端)会导致信号反射,通信不稳定;接多了(比如中间设备也接)会导致总线电平异常,没法通信;
  3. 中断里做耗时操作:比如在HAL_FDCAN_RxFifo0Callback()里用HAL_UART_Transmit()打印数据(USART打印很慢,耗时毫秒级),会导致中断响应超时,其他中断丢失——正确做法是用队列把数据传给任务处理;
  4. 队列操作函数用错:中断里用xQueueSend()而不是xQueueSendFromISR(),会导致FreeRTOS内核崩溃(因为xQueueSend()不是中断安全的);
  5. 过滤器配置错误:比如ID左移位数不对(FDCAN的ID存储格式要求11位ID左移21位)、掩码设置错误(比如想匹配ID=0x123,掩码设为0x00000000,会接收所有数据)——可以先禁用过滤器(接收所有数据),调试通了再配置过滤器。
Logo

昇腾计算产业是基于昇腾系列(HUAWEI Ascend)处理器和基础软件构建的全栈 AI计算基础设施、行业应用及服务,https://devpress.csdn.net/organization/setting/general/146749包括昇腾系列处理器、系列硬件、CANN、AI计算框架、应用使能、开发工具链、管理运维工具、行业应用及服务等全产业链

更多推荐