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层在整个系统中的位置:
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就像换衣服一样简单。记住:接口要稳定,实现要灵活。