2. 硬件抽象层设计:MCU CAN外设寄存器映射、CAN控制器初始化流程(模式、波特率、中断)、收发引脚GPIO配置
好,咱们进入第二章。这一章是真正动手写代码前的关键一步——硬件抽象层设计。说白了,就是把芯片手册上那些枯燥的寄存器地址,变成我们代码里能直接操作的变量和结构体。这一步做扎实了,后面移植CAN协议栈就像搭积木一样顺畅。
我个人习惯,在开始任何MCU的CAN开发前,先花半小时把参考手册的CAN章节通读一遍。别急着写代码,先搞清楚这个芯片的CAN外设是怎么挂载在总线上的,它的寄存器基地址是多少,每个控制位在哪个比特位置。磨刀不误砍柴工嘛。
2.1 MCU CAN外设寄存器映射
寄存器映射,说白了就是给硬件寄存器起个“外号”。芯片厂商的数据手册里,每个寄存器都有一个物理地址,比如0x40006400。但我们写代码时总不能老记着这串数字吧?所以我们要用C语言的结构体和宏定义,把这些地址“包装”成好记的名字。
我见过不少新手直接拿绝对地址去操作寄存器,代码里到处都是*(volatile uint32_t *)0x40006400 = 0x01;。这样写不仅可读性差,移植到别的芯片时还得满世界找这些地址。我的做法是,先定义一个CAN外设的基地址,然后用结构体把相关寄存器按偏移量排列好。
核心思路:用结构体映射寄存器组,用宏定义映射基地址。
举个例子,假设某款MCU的CAN外设寄存器布局如下:
| 偏移地址 | 寄存器名称 | 功能描述 |
|---|---|---|
| 0x00 | CAN_MCR | 主控制寄存器 |
| 0x04 | CAN_MSR | 主状态寄存器 |
| 0x08 | CAN_TSR | 发送状态寄存器 |
| 0x0C | CAN_RF0R | 接收FIFO 0寄存器 |
| 0x10 | CAN_RF1R | 接收FIFO 1寄存器 |
| ... | ... | ... |
那么,我会这样定义:
/* CAN寄存器结构体映射 */
typedef struct {
volatile uint32_t MCR; /* 主控制寄存器,偏移0x00 */
volatile uint32_t MSR; /* 主状态寄存器,偏移0x04 */
volatile uint32_t TSR; /* 发送状态寄存器,偏移0x08 */
volatile uint32_t RF0R; /* 接收FIFO 0寄存器,偏移0x0C */
volatile uint32_t RF1R; /* 接收FIFO 1寄存器,偏移0x10 */
/* ... 其他寄存器按顺序列出 ... */
} CAN_TypeDef;
/* 基地址宏定义 */
#define CAN1_BASE (0x40006400UL)
#define CAN2_BASE (0x40006800UL)
/* 方便操作的外设指针 */
#define CAN1 ((CAN_TypeDef *)CAN1_BASE)
#define CAN2 ((CAN_TypeDef *)CAN2_BASE)
这样定义之后,操作寄存器就变成了CAN1->MCR = 0x01;,是不是清爽多了?而且移植到其他芯片时,只需要修改这个结构体定义和基地址宏,上层代码几乎不用动。
小技巧:定义结构体时,每个成员前面加上volatile关键字。因为寄存器值可能被硬件修改,不加volatile的话,编译器优化时可能会把多次读取合并成一次,导致读到错误的值。我曾经在这个坑里摔过一次,排查了整整一天才发现是少了volatile。
2.2 CAN控制器初始化流程
寄存器映射搞定了,接下来就是初始化CAN控制器。这一步很关键,初始化不对,后面收发数据全是白搭。我总结了一套“三步走”的流程,你照着做基本不会出错。
2.2.1 模式配置
CAN控制器有多种工作模式,最常见的是正常模式、环回模式和静默模式。
- 正常模式:标准通信模式,CAN控制器通过CAN总线收发数据。这是最终产品要用的模式。
- 环回模式:控制器发送的数据会立即被自己接收,不经过总线。调试时特别好用,可以验证发送和接收路径是否正常。
- 静默模式:只能接收,不能发送。常用于总线监听。
我个人习惯,在开发初期先用环回模式。为什么呢?因为不需要接外部CAN收发器,也不需要两根CAN线连接,就能验证代码逻辑。等环回模式跑通了,再切到正常模式去联调。
模式配置通常通过主控制寄存器(MCR)的某个位域来实现。比如某芯片的MCR寄存器的bit[1:0]用于模式选择:
/* 模式选择宏定义 */
#define CAN_MODE_NORMAL 0x00
#define CAN_MODE_LOOPBACK 0x01
#define CAN_MODE_SILENT 0x02
/* 设置模式函数 */
void CAN_SetMode(CAN_TypeDef* CANx, uint8_t mode)
{
/* 先请求进入初始化模式(通常需要先进入初始化模式才能修改模式配置) */
CANx->MCR |= (1 << 0); /* 假设bit0是初始化请求位 */
/* 等待硬件确认进入初始化模式 */
while(!(CANx->MSR & (1 << 0)));
/* 清除模式位,再设置新模式 */
CANx->MCR &= ~(0x03 << 1);
CANx->MCR |= (mode << 1);
/* 退出初始化模式 */
CANx->MCR &= ~(1 << 0);
/* 等待硬件退出初始化模式 */
while(CANx->MSR & (1 << 0));
}
注意:修改模式配置时,大部分芯片要求先进入初始化模式(通常叫Init Mode或Freeze Mode)。如果不走这个流程直接写寄存器,配置可能不会生效。我刚开始做CAN驱动时,就因为这个原因折腾了两天,后来仔细看手册才发现这个“隐藏条件”。
2.2.2 波特率配置
波特率是CAN通信的命根子。总线上所有节点的波特率必须一致,否则谁也收不到谁的数据。CAN的波特率由三个参数决定:时间段1(TSEG1)、时间段2(TSEG2)和同步跳转宽度(SJW)。
计算公式很简单:
波特率 = 时钟频率 / ((1 + TSEG1 + TSEG2) × 波特率预分频值)
举个例子,假设CAN外设的时钟频率是36MHz,我想要一个500kbps的波特率。那么:
/* 波特率配置参数 */
#define CAN_CLOCK_FREQ 36000000 /* 36MHz */
#define TARGET_BAUDRATE 500000 /* 500kbps */
/* 计算预分频值 */
/* 假设TSEG1=13, TSEG2=2, 那么总时间量子 = 1+13+2 = 16 */
/* 预分频值 = 36MHz / (500k * 16) = 4.5,取整为5 */
/* 实际波特率 = 36MHz / (5 * 16) = 450kbps,误差较大 */
/* 调整TSEG1=12, TSEG2=3, 总时间量子 = 1+12+3 = 16 */
/* 预分频值 = 36MHz / (500k * 16) = 4.5,还是不行 */
/* 再调整TSEG1=11, TSEG2=4, 总时间量子 = 1+11+4 = 16 */
/* 预分频值 = 36MHz / (500k * 16) = 4.5,依然不行 */
/* 看来16个时间量子不行,试试15个 */
/* TSEG1=10, TSEG2=4, 总时间量子 = 1+10+4 = 15 */
/* 预分频值 = 36MHz / (500k * 15) = 4.8,取整为5 */
/* 实际波特率 = 36MHz / (5 * 15) = 480kbps,误差4% */
/* 试试14个时间量子 */
/* TSEG1=9, TSEG2=4, 总时间量子 = 1+9+4 = 14 */
/* 预分频值 = 36MHz / (500k * 14) = 5.14,取整为5 */
/* 实际波特率 = 36MHz / (5 * 14) = 514kbps,误差2.8% */
嗯,这里要注意,波特率误差最好控制在1%以内。超过2%的话,在恶劣环境下通信就容易出错了。我一般会写个小脚本,遍历所有可能的TSEG1、TSEG2和预分频值组合,找到误差最小的那组参数。
配置代码大致如下:
/* 波特率配置函数 */
void CAN_SetBaudrate(CAN_TypeDef* CANx, uint32_t baudrate)
{
uint32_t brp, tseg1, tseg2;
uint32_t best_brp = 0, best_tseg1 = 0, best_tseg2 = 0;
float best_error = 100.0f;
float error;
uint32_t actual_baud;
/* 遍历可能的参数组合,找到最优解 */
for(brp = 1; brp < 64; brp++)
{
for(tseg1 = 1; tseg1 < 16; tseg1++)
{
for(tseg2 = 1; tseg2 < 8; tseg2++)
{
actual_baud = CAN_CLOCK_FREQ / (brp * (1 + tseg1 + tseg2));
error = (float)(actual_baud > baudrate ?
(actual_baud - baudrate) : (baudrate - actual_baud)) / baudrate;
if(error < best_error)
{
best_error = error;
best_brp = brp;
best_tseg1 = tseg1;
best_tseg2 = tseg2;
}
}
}
}
/* 将最优参数写入寄存器 */
/* 假设波特率寄存器是BTR,bit[9:0]是BRP,bit[19:16]是TSEG1,bit[22:20]是TSEG2 */
CANx->BTR = ((best_brp - 1) & 0x3FF) |
(((best_tseg1 - 1) & 0x0F) << 16) |
((best_tseg2 - 1) & 0x07) << 20;
}
经验之谈:实际项目中,我一般把TSEG1设得比TSEG2大一些,比如TSEG1=12、TSEG2=3。这样采样点位置在87.5%左右,对总线延迟的容忍度更好。采样点太靠前或太靠后,都容易在长距离通信时出错。
2.2.3 中断配置
CAN通信离不开中断。你总不能一直轮询有没有新消息吧?那CPU啥也别干了。所以我们要配置好中断,让硬件在有消息到达或发送完成时通知我们。
CAN的中断类型挺多的,但常用的就这几个:
- 接收中断:收到新消息时触发。这是最常用的。
- 发送完成中断:消息成功发送后触发。可以用来释放发送缓冲区。
- 错误中断:总线出错时触发。调试时很有用。
配置中断的步骤一般是:
- 在CAN控制器的中断使能寄存器里,打开对应中断位。
- 在MCU的NVIC(嵌套向量中断控制器)里,使能CAN的中断通道,并设置优先级。
- 编写中断服务函数(ISR)。
代码示例:
/* 中断配置函数 */
void CAN_EnableInterrupt(CAN_TypeDef* CANx)
{
/* 1. 使能CAN控制器内部的中断 */
/* 假设中断使能寄存器是IER,bit0是接收中断使能,bit1是发送完成中断使能 */
CANx->IER |= (1 << 0) | (1 << 1); /* 使能接收中断和发送完成中断 */
/* 2. 使能NVIC中的CAN中断通道 */
/* 假设CAN1的中断号是19 */
NVIC_EnableIRQ(CAN1_IRQn);
NVIC_SetPriority(CAN1_IRQn, 2); /* 设置优先级,数值越小优先级越高 */
}
/* 中断服务函数 */
void CAN1_IRQHandler(void)
{
/* 检查是否是接收中断 */
if(CAN1->MSR & (1 << 0)) /* 假设bit0是接收中断标志 */
{
/* 读取接收到的数据 */
/* ... 处理接收数据 ... */
/* 清除中断标志 */
CAN1->MSR &= ~(1 << 0);
}
/* 检查是否是发送完成中断 */
if(CAN1->MSR & (1 << 1)) /* 假设bit1是发送完成中断标志 */
{
/* 标记发送完成,可以发送下一帧数据 */
/* ... 处理发送完成事件 ... */
/* 清除中断标志 */
CAN1->MSR &= ~(1 << 1);
}
}
重要提醒:中断服务函数里不要做太多事情!尤其是不能调用printf、malloc这类耗时或不可重入的函数。我见过有人直接在中断里打印日志,结果系统卡死。正确的做法是:中断里只做标记或把数据放到队列里,真正的处理逻辑放到主循环或任务里去做。
2.3 收发引脚GPIO配置
CAN控制器本身不直接连到总线,它需要通过CAN收发器(比如TJA1050、SN65HVD230)来驱动差分信号。而MCU的CAN控制器和收发器之间,通常通过两个引脚连接:CAN_TX(发送)和CAN_RX(接收)。
这两个引脚其实是普通的GPIO引脚,但被复用为CAN功能。所以我们需要配置GPIO的复用功能。
配置步骤:
- 找到芯片手册里,CAN_TX和CAN_RX对应的GPIO端口和引脚号。
- 使能对应GPIO端口的时钟。
- 配置引脚为复用功能模式(Alternate Function)。
- 选择正确的复用功能编号(AF编号),让引脚连接到CAN外设。
- 配置引脚速度(一般设为高速)和上下拉(CAN_TX通常设为推挽输出,CAN_RX设为浮空输入或上拉输入)。
代码示例:
/* GPIO配置函数 */
void CAN_GPIO_Config(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
/* 1. 使能GPIO时钟 */
/* 假设CAN_TX和CAN_RX都在GPIOB上 */
__HAL_RCC_GPIOB_CLK_ENABLE();
/* 2. 配置CAN_TX引脚(假设是PB9) */
GPIO_InitStruct.Pin = GPIO_PIN_9;
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; /* 复用推挽输出 */
GPIO_InitStruct.Pull = GPIO_NOPULL; /* 无上下拉 */
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; /* 高速 */
GPIO_InitStruct.Alternate = GPIO_AF9_CAN; /* 复用功能选择,假设AF9是CAN */
HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
/* 3. 配置CAN_RX引脚(假设是PB8) */
GPIO_InitStruct.Pin = GPIO_PIN_8;
GPIO_InitStruct.Mode = GPIO_MODE_AF_INPUT; /* 复用输入模式 */
GPIO_InitStruct.Pull = GPIO_PULLUP; /* 上拉输入,防止浮空 */
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
GPIO_InitStruct.Alternate = GPIO_AF9_CAN;
HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
}
避坑指南:我曾经在一个项目里,CAN通信时好时坏,查了半天发现是CAN_RX引脚没有配置上拉。在总线空闲时,CAN_RX引脚电平不确定,导致控制器误判为收到数据。加上内部上拉后,问题就解决了。所以,CAN_RX引脚最好配置成上拉输入。
2.4 完整的初始化流程
好了,上面各个部分都讲完了,现在把它们串起来,形成一个完整的初始化函数:
/* CAN控制器完整初始化 */
void CAN_Init(void)
{
/* 第一步:配置GPIO引脚 */
CAN_GPIO_Config();
/* 第二步:使能CAN外设时钟 */
__HAL_RCC_CAN1_CLK_ENABLE();
/* 第三步:复位CAN控制器(可选,但推荐) */
/* 有些芯片有软件复位功能,可以清除所有寄存器到默认值 */
CAN1->MCR |= (1 << 15); /* 假设bit15是复位位 */
while(CAN1->MCR & (1 << 15));
/* 第四步:配置模式(先设为环回模式,调试用) */
CAN_SetMode(CAN1, CAN_MODE_LOOPBACK);
/* 第五步:配置波特率(500kbps) */
CAN_SetBaudrate(CAN1, 500000);
/* 第六步:配置中断 */
CAN_EnableInterrupt(CAN1);
/* 第七步:启动CAN控制器 */
/* 有些芯片需要显式启动,比如设置MCR的bit0为0 */
CAN1->MCR &= ~(1 << 0); /* 退出初始化模式,进入正常工作 */
while(CAN1->MSR & (1 << 0)); /* 等待确认 */
printf("CAN初始化完成!\r\n");
}
这个初始化函数,你可以在main函数一开始就调用。调用之后,CAN控制器就处于待命状态了,随时可以收发数据。
嗯,这一章的内容就到这里。硬件抽象层设计是CAN协议栈移植的基石,寄存器映射、初始化流程、GPIO配置,每一步都要仔细。下一章我们会讲CAN报文发送和接收的实现,到时候这些初始化代码就会派上用场了。