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的中断类型挺多的,但常用的就这几个:

  • 接收中断:收到新消息时触发。这是最常用的。
  • 发送完成中断:消息成功发送后触发。可以用来释放发送缓冲区。
  • 错误中断:总线出错时触发。调试时很有用。

配置中断的步骤一般是:

  1. 在CAN控制器的中断使能寄存器里,打开对应中断位。
  2. 在MCU的NVIC(嵌套向量中断控制器)里,使能CAN的中断通道,并设置优先级。
  3. 编写中断服务函数(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的复用功能。

配置步骤:

  1. 找到芯片手册里,CAN_TX和CAN_RX对应的GPIO端口和引脚号。
  2. 使能对应GPIO端口的时钟。
  3. 配置引脚为复用功能模式(Alternate Function)。
  4. 选择正确的复用功能编号(AF编号),让引脚连接到CAN外设。
  5. 配置引脚速度(一般设为高速)和上下拉(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报文发送和接收的实现,到时候这些初始化代码就会派上用场了。