3. 硬件抽象层设计:CAN控制器寄存器映射、时钟配置与波特率计算、收发器驱动、中断处理机制
硬件抽象层,说白了就是给上层软件一个统一的接口。不管底下是哪个厂家的MCU,CAN模块长什么样,到了HAL这一层,都得给我长一个样。我最早做OBD协议栈移植的时候,就是被这层折腾得够呛——换了个芯片,底层寄存器全得重写,那叫一个痛苦。
后来我学乖了。先搭好HAL的骨架,再往里填具体芯片的实现。今天咱们就把这四块硬骨头啃下来:寄存器映射、时钟与波特率、收发器驱动、中断处理。
核心思路:HAL层只暴露操作函数,不暴露寄存器地址。上层代码永远不要直接读写CAN寄存器。
3.1 CAN控制器寄存器映射
每个MCU的CAN控制器,寄存器布局都不一样。但功能模块是相似的:控制寄存器、状态寄存器、中断寄存器、消息RAM、时序寄存器等等。
我习惯的做法是,先定义一个结构体,把寄存器地址映射好。比如这样:
/* CAN控制器寄存器映射结构体 */
typedef struct {
volatile uint32_t CTRL; /* 控制寄存器 */
volatile uint32_t STATUS; /* 状态寄存器 */
volatile uint32_t INT_EN; /* 中断使能 */
volatile uint32_t INT_FLAG; /* 中断标志 */
volatile uint32_t BIT_TIM; /* 位时序寄存器 */
volatile uint32_t RX_MSG; /* 接收消息 */
volatile uint32_t TX_MSG; /* 发送消息 */
/* ... 其他寄存器 */
} CAN_RegMap_t;
/* 基地址定义 */
#define CAN0_BASE_ADDR 0x40006400UL
#define CAN1_BASE_ADDR 0x40006800UL
/* 访问宏 */
#define CAN0 ((CAN_RegMap_t *)CAN0_BASE_ADDR)
#define CAN1 ((CAN_RegMap_t *)CAN1_BASE_ADDR)
嗯,这里要注意:volatile关键字不能丢。编译器优化起来六亲不认,不加volatile,寄存器读出来可能一直是同一个值。
我在项目中遇到过一个问题:调试时发现CAN一直收不到数据,查了两天,最后发现是寄存器映射地址算错了,偏移量差了4个字节。从那以后,我每次写完映射都会打印出地址,拿数据手册一行一行对。
个人习惯:每个寄存器都写一个独立的读/写函数,哪怕只是简单的一行赋值。这样将来加调试打印、加断言检查,都方便。
3.2 时钟配置与波特率计算
波特率这东西,算不对就全完蛋。CAN总线上所有节点必须用同一个波特率,差一点都不行。
CAN的位时间由四段组成:同步段、传播段、相位缓冲段1、相位缓冲段2。再加上采样点位置,这些参数决定了最终的波特率。
公式其实不复杂:
波特率 = CAN时钟频率 / (BRP + 1) / (SyncSeg + PropSeg + PhaseSeg1 + PhaseSeg2)
其中:
BRP = 波特率预分频器
SyncSeg = 固定为1个时间量子
PropSeg = 传播段(通常1-8个时间量子)
PhaseSeg1 = 相位缓冲段1(通常1-8个时间量子)
PhaseSeg2 = 相位缓冲段2(通常1-8个时间量子)
采样点 = (SyncSeg + PropSeg + PhaseSeg1) / 总时间量子
举个例子,假设CAN时钟是40MHz,想要500kbps的波特率:
/* 目标:500kbps,采样点87.5% */
/* 总时间量子 = 40MHz / 500kbps = 80个时间量子 */
/* 如果BRP=1,则每个时间量子 = 2个时钟周期 */
/* 分配:SyncSeg=1, PropSeg=7, PhaseSeg1=6, PhaseSeg2=6 */
/* 总时间量子 = 1+7+6+6 = 20 */
/* 实际波特率 = 40MHz / (1+1) / 20 = 1MHz ??? 不对 */
/* 重新算:BRP=3,每个时间量子=4个时钟周期 */
/* 总时间量子 = 40MHz / 4 / 500kbps = 20 */
/* 分配:SyncSeg=1, PropSeg=5, PhaseSeg1=6, PhaseSeg2=8 */
/* 采样点 = (1+5+6)/20 = 60% 偏低 */
/* 再调:SyncSeg=1, PropSeg=3, PhaseSeg1=8, PhaseSeg2=8 */
/* 采样点 = (1+3+8)/20 = 60% 还是低 */
/* 最终:SyncSeg=1, PropSeg=2, PhaseSeg1=9, PhaseSeg2=8 */
/* 采样点 = (1+2+9)/20 = 60% ... */
你想想看,采样点60%对于高速CAN来说太低了。标准推荐采样点在80%-87.5%之间。所以得调整BRP或者总时间量子数。
我建议直接用工具算。手算太容易出错。我自己写了个小脚本,输入时钟频率和目标波特率,自动输出所有可行的配置组合。
我曾经踩过的坑:同一块板子上,CAN控制器和收发器的时钟源不同,导致实际波特率偏差超过2%。CAN总线对时钟容差要求很严,尤其是CAN FD,数据段波特率更高,容差更小。一定要用示波器实测位时间。
3.3 收发器(Transceiver)驱动
收发器是CAN控制器和物理总线之间的桥梁。它负责把控制器的差分信号转换成总线电平,反过来也一样。
常见的收发器有TJA1040、TJA1050、SN65HVD230这些。驱动其实不复杂,主要做三件事:
- 初始化:配置收发器的工作模式(正常、待机、休眠)
- 控制:使能/禁用电平转换、控制终端电阻
- 状态检测:读取错误标志、总线状态
收发器驱动代码示例:
/* 收发器驱动接口 */
typedef struct {
void (*init)(void);
void (*set_mode)(TransceiverMode_t mode);
TransceiverStatus_t (*get_status)(void);
void (*enable_termination)(bool enable);
} Transceiver_Driver_t;
/* 以TJA1040为例的实现 */
static void tja1040_init(void) {
/* 配置STB和EN引脚为输出 */
GPIO_SetMode(PORT_STB, PIN_STB, GPIO_MODE_OUTPUT);
GPIO_SetMode(PORT_EN, PIN_EN, GPIO_MODE_OUTPUT);
/* 初始化为正常模式 */
GPIO_WritePin(PORT_STB, PIN_STB, 0); /* STB=0 */
GPIO_WritePin(PORT_EN, PIN_EN, 1); /* EN=1 */
}
static void tja1040_set_mode(TransceiverMode_t mode) {
switch(mode) {
case TRANSCEIVER_NORMAL:
/* STB=0, EN=1 */
GPIO_WritePin(PORT_STB, PIN_STB, 0);
GPIO_WritePin(PORT_EN, PIN_EN, 1);
break;
case TRANSCEIVER_STANDBY:
/* STB=1, EN=0 */
GPIO_WritePin(PORT_STB, PIN_STB, 1);
GPIO_WritePin(PORT_EN, PIN_EN, 0);
break;
case TRANSCEIVER_SLEEP:
/* STB=1, EN=1 */
GPIO_WritePin(PORT_STB, PIN_STB, 1);
GPIO_WritePin(PORT_EN, PIN_EN, 1);
break;
}
}
/* 驱动实例 */
Transceiver_Driver_t tja1040_driver = {
.init = tja1040_init,
.set_mode = tja1040_set_mode,
.get_status = tja1040_get_status,
.enable_termination = tja1040_enable_term
};
我在项目中遇到过一个问题:收发器进入待机模式后,总线上的唤醒信号没响应。查了半天,发现是唤醒引脚没配置成中断输入。收发器的唤醒功能,通常需要MCU配合外部中断来实现。
避坑指南:收发器的终端电阻(120Ω)一定要在PCB布局时靠近收发器引脚放置。我曾经为了省事,把电阻放在板子另一端,结果信号反射严重,总线通信时好时坏。
3.4 中断处理机制
CAN通信离不开中断。发送完成、接收就绪、错误发生、总线关闭……这些事件都得靠中断来响应。
中断处理的设计原则:快进快出,不要在ISR里做复杂处理。
我一般这样设计中断处理流程:
- ISR只做一件事:读取中断标志,把数据搬到环形缓冲区,清标志,退出。
- 主循环或任务:从缓冲区取数据,做协议解析、应用层处理。
- 错误处理:错误中断里只记录错误码和计数器,具体恢复策略交给上层。
中断处理代码骨架:
/* CAN中断服务函数 */
void CAN0_IRQHandler(void) {
uint32_t flags = CAN0->INT_FLAG;
/* 接收中断 */
if(flags & CAN_INT_RX) {
/* 读取消息到临时缓冲区 */
CanMessage_t msg;
msg.id = CAN0->RX_ID;
msg.dlc = CAN0->RX_DLC;
for(int i = 0; i < msg.dlc; i++) {
msg.data[i] = CAN0->RX_DATA[i];
}
/* 压入环形缓冲区 */
if(rx_buffer_push(&msg) == BUFFER_FULL) {
/* 缓冲区溢出,记录错误 */
can_stats.rx_overflow++;
}
/* 清中断标志 */
CAN0->INT_FLAG = CAN_INT_RX;
}
/* 发送完成中断 */
if(flags & CAN_INT_TX_DONE) {
/* 标记发送完成 */
can_tx_busy = false;
CAN0->INT_FLAG = CAN_INT_TX_DONE;
}
/* 错误中断 */
if(flags & CAN_INT_ERROR) {
/* 读取错误计数器 */
can_stats.tx_err_cnt = CAN0->TX_ERR_CNT;
can_stats.rx_err_cnt = CAN0->RX_ERR_CNT;
/* 清中断标志 */
CAN0->INT_FLAG = CAN_INT_ERROR;
/* 设置错误标志,由主循环处理 */
can_error_pending = true;
}
/* 总线关闭中断 */
if(flags & CAN_INT_BUS_OFF) {
can_bus_off = true;
CAN0->INT_FLAG = CAN_INT_BUS_OFF;
}
}
注意:中断优先级一定要配好。CAN中断通常设成较高优先级,但不能高过系统滴答定时器。否则在高负载下,系统时间会跑偏。我见过一个案例,CAN中断优先级设成最高,结果系统调度全乱套了。
还有一个容易被忽略的点:中断嵌套。如果CAN FD的数据段速率很高(比如5Mbps),中断可能非常频繁。这时候要考虑是否允许中断嵌套,或者干脆把接收改成轮询+中断混合模式。
我个人习惯是:接收用中断,发送用轮询。因为发送是主动的,可以控制节奏;接收是被动的,必须靠中断来保证不丢帧。
3.5 本章知识结构
下面这张图把硬件抽象层的四个模块串起来了。你可以看到,HAL层夹在芯片寄存器和上层协议栈之间,起到了隔离和统一的作用。
这张图你看懂了吗?上层协议栈通过HAL层调用底层硬件,HAL层把四个模块封装成标准接口。换芯片?重写HAL层就行,上层代码基本不动。
总结一下:硬件抽象层设计的关键是隔离变化。寄存器映射要结构体化,波特率计算要工具化,收发器驱动要接口化,中断处理要缓冲化。把这四点做好,你的CAN驱动就能在各种芯片间轻松移植。