4. 搭建STM32 CANopenNode开发环境

好,咱们进入第四章。这一章我打算把 STM32 的 HAL 库 CAN 驱动好好封装一下。说白了,就是给 CANopenNode 准备一套趁手的底层接口。你想想看,如果底层驱动写得稀烂,上层协议栈再牛也跑不稳。

我个人习惯是把 CAN 相关的操作全部封装到一个 can_driver.c 和 can_driver.h 里。这样后期移植或者换芯片,只需要改这两个文件,上层代码基本不动。

4.1 HAL库CAN初始化:别漏了时间片

初始化这块,核心就是配置 CAN 的时序参数。STM32 的 CAN 外设用的是 位时间(Bit Time) 的概念,分成 Sync_Seg、Prop_Seg、Phase_Seg1、Phase_Seg2 四段。我刚开始接触的时候,总觉得这东西很玄乎,后来发现其实就是算一个公式:

CAN 波特率 = APB1 时钟 / (BRP + 1) / (tBS1 + tBS2 + 1)

举个例子,如果 APB1 是 42MHz,我想跑 500kbps,那可以这么配:

hcan.Instance = CAN1;
hcan.Init.Prescaler = 6;        // BRP = 6 → 分频后 7MHz
hcan.Init.SyncJumpWidth = CAN_SJW_1TQ;
hcan.Init.TimeSeg1 = CAN_BS1_8TQ;   // tBS1 = 8
hcan.Init.TimeSeg2 = CAN_BS2_6TQ;   // tBS2 = 6
// 7MHz / (8 + 6 + 1) = 7MHz / 15 ≈ 466kHz → 接近 500k

我曾经踩过的坑: 采样点位置。CAN 总线要求采样点通常在 75%~85% 之间。上面这个配置采样点在 (1 + 8) / (1 + 8 + 6) = 9/15 = 60%,太靠前了。后来我改成 TimeSeg1=10, TimeSeg2=4,采样点 (1+10)/(1+10+4)=11/15≈73%,总线就稳了。

初始化函数里,我一般还会顺手把过滤器设为旁路模式,等后面再单独配置:

hcan.Init.Mode = CAN_MODE_NORMAL;
hcan.Init.AutoBusOff = DISABLE;
hcan.Init.AutoWakeUp = DISABLE;
hcan.Init.AutoRetransmission = ENABLE;  // 我习惯开启自动重发
hcan.Init.ReceiveFifoLocked = DISABLE;
hcan.Init.TransmitFifoPriority = DISABLE;

4.2 中断配置:别让CPU空转

CAN 通信如果靠轮询,CPU 基本就废了。所以必须用中断。我一般开三个中断:

  • 接收中断(FIFO0/FIFO1 消息挂起) —— 来报文了立刻通知我
  • 发送完成中断(TX Mailbox 空) —— 发完了我好释放缓冲区
  • 错误中断(总线错误、状态变化) —— 出问题了我得知道

在 HAL 库里,使能中断的代码很简单:

HAL_CAN_ActivateNotification(&hcan, CAN_IT_RX_FIFO0_MSG_PENDING);
HAL_CAN_ActivateNotification(&hcan, CAN_IT_TX_MAILBOX_EMPTY);
HAL_CAN_ActivateNotification(&hcan, CAN_IT_ERROR);

然后记得在 stm32f4xx_it.c 里把中断服务函数加上:

void CAN1_RX0_IRQHandler(void) {
    HAL_CAN_IRQHandler(&hcan);
}
void CAN1_TX_IRQHandler(void) {
    HAL_CAN_IRQHandler(&hcan);
}
void CAN1_SCE_IRQHandler(void) {
    HAL_CAN_IRQHandler(&hcan);
}

一个小技巧: 我习惯在中断优先级上把 CAN 接收中断设得比发送中断高一点。因为接收不及时会导致 FIFO 溢出丢帧,发送晚一点问题不大。

4.3 发送/接收函数封装:别裸调HAL

直接调 HAL_CAN_AddTxMessage 和 HAL_CAN_GetRxMessage 当然可以,但不够灵活。我封装了两个核心函数:

发送函数

uint8_t CAN_SendMessage(uint32_t id, uint8_t *data, uint8_t len, uint8_t is_ext) {
    CAN_TxHeaderTypeDef tx_header;
    uint32_t tx_mailbox;

    tx_header.StdId = (is_ext) ? 0 : id;
    tx_header.ExtId = (is_ext) ? id : 0;
    tx_header.IDE = (is_ext) ? CAN_ID_EXT : CAN_ID_STD;
    tx_header.RTR = CAN_RTR_DATA;
    tx_header.DLC = len;

    if (HAL_CAN_AddTxMessage(&hcan, &tx_header, data, &tx_mailbox) != HAL_OK) {
        return 0;  // 发送失败
    }
    return 1;
}

这里我加了一个 is_ext 参数,因为 CANopen 标准帧和扩展帧混用的情况很常见。CANopen 的 NMT、SDO 用标准帧,但有些厂家自定义对象字典会用扩展帧。

接收函数(回调方式)

接收我不用轮询,直接在 HAL 的回调里处理:

void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan) {
    CAN_RxHeaderTypeDef rx_header;
    uint8_t rx_data[8];

    HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &rx_header, rx_data);

    // 这里直接丢给 CANopenNode 的接收处理函数
    canopen_node_receive(rx_header.StdId, rx_data, rx_header.DLC);
}

注意: 回调函数里不要做耗时操作。我见过有人直接在回调里做 CRC 校验、写 EEPROM,结果导致中断嵌套过深,系统直接挂掉。回调里只做数据搬运,处理逻辑放到任务循环里。

4.4 过滤器配置:别让CPU被垃圾报文淹没

CAN 总线是广播式的,所有节点都能收到所有报文。如果不加过滤,CPU 会被无关报文频繁打断。STM32 的 CAN 控制器有 28 个过滤器组,每个组可以配置为 列表模式 或 掩码模式。

我个人习惯用 掩码模式,因为 CANopen 的 COB-ID 是有规律的,可以用掩码一次性匹配一组 ID:

CAN_FilterTypeDef filter;
filter.FilterBank = 0;
filter.FilterMode = CAN_FILTERMODE_IDMASK;
filter.FilterScale = CAN_FILTERSCALE_32BIT;
filter.FilterIdHigh = 0x0000;       // 标准帧 ID 高 16 位
filter.FilterIdLow = 0x0000;        // 标准帧 ID 低 16 位
filter.FilterMaskIdHigh = 0x0000;   // 掩码全 0 → 接收所有
filter.FilterMaskIdLow = 0x0000;
filter.FilterFIFOAssignment = CAN_RX_FIFO0;
filter.FilterActivation = ENABLE;
HAL_CAN_ConfigFilter(&hcan, &filter);

上面这个配置是接收所有报文,适合调试阶段。实际项目中,我会根据 CANopen 节点 ID 来设置掩码:

功能 COB-ID 范围 掩码策略
NMT 管理 0x000 精确匹配
PDO 通信 0x180 ~ 0x1FF 掩码高 5 位
SDO 通信 0x580 ~ 0x5FF 掩码高 5 位
心跳报文 0x700 ~ 0x77F 掩码高 5 位

我曾经犯过的错: 过滤器配置完忘了调用 HAL_CAN_Start(&hcan),结果中断死活不触发。查了半天才发现 CAN 外设根本没启动。嗯,这种低级错误犯一次就够了。

4.5 整体封装结构图

下面这张图是我自己项目里用的驱动分层结构,你可以参考一下:

CAN 驱动封装结构图 CANopenNode 协议栈 can_driver.c / can_driver.h CAN_SendMessage() | CAN_ReceiveCallback() | CAN_ConfigFilter() STM32 HAL 库 CAN 驱动 HAL_CAN_Init() | HAL_CAN_AddTxMessage() | HAL_CAN_GetRxMessage() STM32 CAN 外设 (bxCAN) 封装要点 • 隐藏 HAL 细节 • 统一接口命名 • 中断回调转发 • 过滤器预配置 • 错误状态上报 数据流向 应用 → 封装 封装 → HAL HAL → 硬件

你看,这样分层之后,CANopenNode 协议栈完全不知道底层是 STM32 还是别的芯片。它只管调 CAN_SendMessage(),剩下的封装层搞定。这就是我强调的 解耦。

4.6 初始化完整流程

最后,我把整个初始化流程串起来,你照着这个顺序写基本不会错:

  1. 使能时钟: __HAL_RCC_CAN1_CLK_ENABLE() 以及 GPIO 时钟
  2. 配置 GPIO: CAN_RX 设为输入浮空,CAN_TX 设为复用推挽输出
  3. 配置 NVIC: 设置 CAN 中断优先级并使能
  4. 调用 HAL_CAN_Init(): 传入配置好的时序参数
  5. 配置过滤器: 根据项目需求设置掩码或列表
  6. 使能中断通知: HAL_CAN_ActivateNotification()
  7. 启动 CAN: HAL_CAN_Start(&hcan) —— 这一步最容易忘
  8. 注册回调: 在 HAL_CAN_RxFifo0MsgPendingCallback 里连接协议栈

我的习惯: 我会在初始化最后加一个自检——发一个空帧给自己,如果能收到,说明 CAN 收发通路是通的。这个自检函数我放在 can_driver_self_test() 里,调试时特别有用。