3. 硬件抽象层(HAL)设计:MCU与CAN控制器的接口抽象、定时器抽象、中断抽象

好,咱们进入第三章。这一章,说白了就是解决一个核心问题:怎么让CANopen协议栈跟具体的MCU解耦

你想想看,今天你用STM32,明天可能换GD32,后天又来个NXP。如果协议栈代码里到处都是寄存器操作,那移植起来得多痛苦?我早年做第一个项目时就吃过这个亏——代码写死了,换芯片等于重写。从那以后,我学乖了,硬件抽象层(HAL)必须做

核心思想:把MCU相关的操作封装成标准接口,上层协议栈只调用这些接口,不关心底层硬件。

3.1 MCU与CAN控制器的接口抽象

CAN控制器,有的MCU内置(比如STM32的bxCAN),有的外挂(比如SJA1000、MCP2515)。不管哪种,我们需要抽象出几个关键操作。

3.1.1 初始化接口

初始化CAN控制器,设置波特率、工作模式等。我习惯这样定义:

/* CAN硬件初始化 */
typedef struct {
    uint32_t baudrate;      /* 波特率,如 125000, 250000, 500000 */
    uint8_t  mode;          /* 0: 正常模式, 1: 只听模式, 2: 环回模式 */
    uint8_t  sjw;           /* 同步跳转宽度 */
    uint8_t  tseg1;         /* 时间段1 */
    uint8_t  tseg2;         /* 时间段2 */
} CAN_InitConfig_t;

int32_t HAL_CAN_Init(CAN_Handle_t *hcan, CAN_InitConfig_t *config);

这里有个坑——波特率计算。不同MCU的时钟树不一样,APB时钟可能不同。我在项目中遇到过,STM32F1和STM32F4的CAN时钟源就不同,一个挂在APB1(36MHz),一个挂在APB1(42MHz)。所以,初始化函数内部必须做时钟适配

3.1.2 发送与接收接口

CANopen协议栈需要发送和接收CAN帧。抽象接口要支持标准帧(11位ID)和扩展帧(29位ID)。

/* CAN帧结构 */
typedef struct {
    uint32_t id;            /* CAN ID */
    uint8_t  id_type;       /* 0: 标准帧, 1: 扩展帧 */
    uint8_t  frame_type;    /* 0: 数据帧, 1: 远程帧 */
    uint8_t  dlc;           /* 数据长度 (0-8) */
    uint8_t  data[8];       /* 数据 */
} CAN_Frame_t;

/* 发送一帧,返回0成功,负值失败 */
int32_t HAL_CAN_Transmit(CAN_Handle_t *hcan, CAN_Frame_t *frame, uint32_t timeout);

/* 接收一帧,阻塞等待,返回0成功 */
int32_t HAL_CAN_Receive(CAN_Handle_t *hcan, CAN_Frame_t *frame, uint32_t timeout);

嗯,这里要注意:发送函数最好支持超时机制。我曾经遇到过总线繁忙导致发送卡死的情况,加了超时后,至少能恢复。

3.1.3 过滤器配置

CAN控制器通常有硬件过滤器,可以只接收特定ID的帧。这对CANopen很重要,因为协议栈只关心COB-ID范围内的帧。

/* 设置过滤器,只接收指定ID范围的帧 */
int32_t HAL_CAN_ConfigFilter(CAN_Handle_t *hcan, uint32_t id_low, uint32_t id_high);

我个人习惯把过滤器配置成掩码模式,这样更灵活。比如只接收11位ID中高7位匹配的帧,低4位忽略。

3.2 定时器抽象

CANopen协议栈离不开定时器。比如心跳报文(Heartbeat)需要周期性发送,同步报文(SYNC)需要定时触发,还有节点守护(Node Guarding)也需要超时检测。

我一般抽象出三个核心定时器接口:

/* 定时器句柄 */
typedef void (*TimerCallback_t)(void *arg);

/* 启动一个定时器,period_ms为周期(毫秒),callback为回调函数 */
int32_t HAL_Timer_Start(uint32_t timer_id, uint32_t period_ms, TimerCallback_t callback, void *arg);

/* 停止定时器 */
int32_t HAL_Timer_Stop(uint32_t timer_id);

/* 获取当前系统滴答(毫秒),用于超时判断 */
uint32_t HAL_GetTick(void);

小技巧:HAL_GetTick() 通常用SysTick实现,每1ms中断一次。但要注意,如果中断里处理太多任务,会导致计时不准。我建议只做计数,不做处理

为什么需要多个定时器ID?因为CANopen协议栈内部可能需要多个定时器:一个给心跳,一个给SYNC,一个给节点守护。如果MCU硬件定时器不够用,可以用软件定时器链表来模拟。我在一个项目中,MCU只有4个硬件定时器,但协议栈需要6个,最后用软件方式解决了。

3.3 中断抽象

中断是CAN通信的灵魂。CAN控制器收到一帧数据,或者发送完成,都会触发中断。协议栈需要在这些中断里快速响应。

3.3.1 中断回调注册

我习惯用回调函数的方式,把中断处理逻辑交给上层:

/* 中断回调类型 */
typedef void (*CAN_IRQCallback_t)(CAN_Handle_t *hcan, uint32_t event);

/* 事件类型 */
#define CAN_EVENT_RX         0x01   /* 接收完成 */
#define CAN_EVENT_TX         0x02   /* 发送完成 */
#define CAN_EVENT_ERROR      0x04   /* 错误发生 */
#define CAN_EVENT_WAKEUP     0x08   /* 唤醒 */

/* 注册中断回调 */
int32_t HAL_CAN_RegisterIRQCallback(CAN_Handle_t *hcan, CAN_IRQCallback_t callback);

在中断服务函数(ISR)里,我们只做最轻量级的工作:

void CAN_ISR_Handler(CAN_Handle_t *hcan) {
    uint32_t events = 0;
    
    /* 检查中断标志位 */
    if (/* 接收中断 */) {
        events |= CAN_EVENT_RX;
        /* 读取接收到的帧到缓冲区 */
        HAL_CAN_ReadRxFifo(hcan, &rx_frame);
    }
    if (/* 发送完成 */) {
        events |= CAN_EVENT_TX;
    }
    
    /* 调用回调,通知上层 */
    if (hcan->irq_callback) {
        hcan->irq_callback(hcan, events);
    }
}

警告:千万不要在中断里做复杂处理!比如协议栈解析、PDO映射等。中断里只做标志位设置或数据拷贝,真正的处理放到主循环或任务里。我曾经见过有人把整个CANopen状态机放在中断里,结果系统响应延迟得一塌糊涂。

3.3.2 临界区保护

中断和主循环可能同时访问共享数据(比如接收缓冲区),这时候需要临界区保护。我抽象了两个宏:

/* 进入临界区(关中断) */
#define HAL_ENTER_CRITICAL()    __disable_irq()

/* 退出临界区(开中断) */
#define HAL_EXIT_CRITICAL()     __enable_irq()

但要注意,临界区时间要尽量短。关中断太久会影响实时性。我一般只在操作链表或队列时用,几微秒就搞定。

3.4 整体架构图

下面这张图展示了HAL层在整个系统中的位置:

CANopen协议栈硬件抽象层(HAL)架构 应用层(用户代码) CANopen协议栈(平台无关) 硬件抽象层(HAL) CAN控制器接口 Init/Transmit/Receive 定时器抽象 Start/Stop/GetTick 中断抽象 回调/临界区 MCU硬件(STM32/GD32/...) HAL层隔离了协议栈与硬件,换MCU只需重写HAL接口 接口 抽象

3.5 移植时的注意事项

最后,我总结几个移植时的常见坑:

  • 字节序问题:CAN帧数据是Big-Endian还是Little-Endian?不同MCU可能不同。我建议在HAL层统一转换,协议栈内部固定使用Little-Endian。
  • 时间精度:定时器的精度直接影响心跳和SYNC的抖动。如果MCU的定时器精度不够(比如只有10ms),可以考虑用硬件定时器+PWM捕获来提升。
  • 中断优先级:CAN中断优先级要设置合理。我一般设为中等优先级,比SysTick低,比普通外设高。太低了会丢帧,太高了会影响系统调度。
  • 缓冲区大小:接收缓冲区要足够大。CANopen的SDO传输可能连续发多帧,缓冲区太小会溢出。我一般设16帧深度。

我的经验:刚开始做HAL时,别追求完美。先实现最基础的Init、Transmit、Receive、GetTick四个接口,让协议栈跑起来。后面再慢慢优化。我曾经花了两周设计一个“完美”的HAL,结果发现协议栈根本用不上那么多功能。

好了,这一章就到这里。HAL层是移植的关键,做好了,后面换MCU就像换衣服一样简单。记住:接口要稳定,实现要灵活