简介:本资源是一套基于STM32F103与MCP2515协同实现CAN总线通信的完整嵌入式开发工程,面向嵌入式初学者、CAN协议实践者及工业通信项目开发者,解决STM32通过SPI驱动MCP2515进行稳定收发的核心技术难点。压缩包共193个文件,含30个C源文件(实现SPI初始化、MCP2515寄存器配置、CAN帧发送/接收、中断处理等核心逻辑)、33个头文件(定义寄存器映射、CAN结构体及API接口)、31个编译中间文件(.o/.d/.crf)及Keil MDK工程文件(.uvprojx/.axf/.hex等),完整保留可直接编译运行的调试环境,包体大小为4.95MB。已有1765人学习下载,工程结构清晰,包含标准外设库驱动(如stm32f10x_rcc.c、stm32f10x_spi.c)与MCP2515专用驱动层,附带波特率配置、验收滤波器设置、错误状态监控等关键实现,可快速移植至同类CAN应用场景。 自己做嵌入式这几年,经常遇到一种情况:项目需要挂CAN总线,但手上这颗STM32没有CAN外设,或者一路CAN根本不够用。这个时候外挂一颗MCP2515就是最省心的方案。最近我刚好完成了一个基于STM32和MCP2515的CAN收发数据程序,全程用C/C++实现,从寄存器配置到发送接收再到扩展帧滤波都调通了。这篇文章把整个实现过程复盘一遍,重点讲MCP2515的初始化、收发函数怎么写、波特率怎么配、扩展帧滤波怎么设,以及实际调试中容易踩到的一堆坑。计划接触CAN通信的嵌入式开发者,尤其是那些想用STM32加外置CAN控制器做数据收发的朋友,可以直接拿这份代码作为参考。
1. 整体设计与方案选型
1.1 为什么用MCP2515而不是STM32内置CAN
很多人第一反应是STM32本身自带CAN控制器,为什么还要外挂MCP2515?这种想法本身没错,但实际项目里会遇到几个情况。
第一,选型时为了成本或供货选了不带CAN外设的型号,比如STM32F103系列中的某些小封装型号,或者从F0/F1换到更低端的资源时,CAN直接被砍掉了。第二,一个节点要同时控制多个CAN网络,比如整车上的动力CAN和车身CAN是分开的,单路CAN控制器就不够用。第三,有些用户模块已经内置了MCP2515,比如各种CAN扩展板、USB-CAN适配器,MCU只需要通过SPI操作它,逻辑反而简单。
MCP2515本身是一个独立的CAN控制器,支持标准帧、扩展帧,最大速率1Mbps,集成了发送缓冲、接收缓冲、验收滤波、错误检测等完整CAN协议功能。MCU只需要通过SPI接口跟它通信,把要发送的数据写到它内部的缓冲区,剩下的CAN协议处理全部由MCP2515完成。对STM32来说,相当于多扩展出了一路CAN接口。
我用STM32的原因也纯粹是手边开发板多,SPI外设也方便,其实只要带SPI的单片机都能这么干。整体软件架构上,底层是SPI驱动,中间是MCP2515寄存器读写,上层是CAN报文收发接口。如果要做产品化,还可以用C++再包一层,把每个CAN节点当成一个类来管理。
1.2 硬件连接与SPI接线
MCP2515通过SPI接收指令,所以硬件连接其实很固定。以STM32F103为例,我用的是SPI1,引脚分配如下:
- SPI1_SCK:PA5,接MCP2515的SCK
- SPI1_MOSI:PA7,接MCP2515的SI
- SPI1_MISO:PA6,接MCP2515的SO
- SPI1_CS:PA4,接MCP2515的CS
- INT:PB0,接MCP2515的INT(理论上可以省,但建议保留)
MCP2515还需要外接晶振,一般都是8MHz,这很关键,后面波特率配置直接受晶振频率影响。供电方面,MCP2515可以3.3V也可以5V,但MCU和MCP2515之间要注意电平匹配。STM32F103如果工作在3.3V,MCP2515也选3.3V供电,两边电平就一致。如果MCP2515用5V供电,SPI引脚再接回3.3V的STM32,就需要电平转换,否则存在烧引脚的风险。
还有一个容易被忽略的点:CAN总线两端要各接一个120Ω终端电阻。对调试来说,如果只是两块板对接,至少要在其中一块板子的CANH和CANL之间接上这个电阻,否则CAN信号反射严重,经常出现时好时坏的现象。我一开始偷懒没接,结果收发成功率只有一半,后来接上就好了,这个坑必须写出来。
1.3 软件框架选择:标准库、HAL库还是C++
MCP2515驱动本身不依赖STM32库,只要你SPI收发函数能正常跑就行。我这次用了STM32CubeMX生成的HAL库,原因很简单:生成工程快,SPI初始化、GPIO配置都省事。如果你是标准库党,原理完全一样,只要把HAL_SPI_Transmit和HAL_SPI_TransmitReceive替换成标准库里的SPI_I2S_SendData和SPI_I2S_ReceiveData就行。
关于C/C++:MCP2515的驱动代码我所有底层函数都用C写,没有做C++类和虚函数,主要是为了保持代码体积小、可移植性好。但上层业务逻辑用了C++风格,比如把CAN报文抽象成一个结构体,用类似类的静态方法组织接口,这样在工程里既能直接调用,也不会被编译器找麻烦。如果你的工具链支持C++,可以把MCP2515封装成一个类,把初始化、发送、接收都变成成员函数,管理多个CAN节点会方便很多。
2. MCP2515核心机制与关键寄存器解析
2.1 内部结构、缓冲器与报文存储
MCP2515内部结构可以理解成一个独立的CAN协议芯片加上SPI从站接口。MCU通过SPI往它内部寄存器写数据,它自动把数据打包成CAN帧发到总线上;收到总线上的报文后,它会把数据存进接收缓冲器,再通过中断引脚或状态寄存器通知MCU来取。
它内部有3个发送缓冲器,分别叫TXB0、TXB1、TXB2,每个缓冲器都有独立的控制寄存器、ID寄存器、数据长度寄存器和8字节数据空间。好处是可以提前把多帧报文填进去,再分别请求发送,适合做批量发送。接收侧有2个接收缓冲器,RXB0和RXB1。RXB0优先级高,RXB1可以配置成当RXB0满时自动接收溢出的报文,两个缓冲器共同配合可以减少丢帧。
理解缓冲器结构很重要。发送的流程不是直接把数据发给MCP2515就完事,而是先写进TXBn的8字节区域,再置位发送请求位,MCP2515才会把整帧数据发出去。接收也一样,收到报文后数据不会直接出现在MCU面前,而是存进RXBn,MCU要主动去读。
MCP2515还有一个错误状态寄存器,可以读取发送错误计数和接收错误计数,对排查总线故障非常有用。实际调试时我经常先读这个寄存器,如果错误计数一直在涨,基本就是硬件连接或波特率配置有问题,而不是代码逻辑问题。
2.2 SPI指令集与基础读写流程
MCP2515的SPI指令集并不复杂,常用的就几个:
- 0x02:写寄存器,后面跟寄存器地址和要写入的数据
- 0x03:读寄存器,后面跟寄存器地址,再发一个哑字节读出数据
- 0x05:读状态寄存器,用于快速查询发送请求位、接收缓冲满标志等
- 0x81:请求发送TXB0缓冲器
- 0xC0:复位芯片
- 0xB0:对寄存器做位修改,传入地址、掩码、新值
所以底层的核心就是三个函数:读寄存器、写寄存器、位修改。以HAL库为例,读寄存器可以这样写:
uint8_t mcp2515_read_reg(uint8_t addr) { uint8_t tx[2] = {0x03, addr}; uint8_t rx[2] = {0x00, 0x00}; MCP2515_CS_Low(); HAL_SPI_TransmitReceive(&hspi1, tx, rx, 2, 10); MCP2515_CS_High(); return rx[1]; }这里的关键在于SPI的特性:发送读指令和地址之后,主机需要再发送一个哑字节,才能把MCP2515返回的数据读出来。HAL_SPI_TransmitReceive在第二字节发送哑字节的同时,会把MISO线上的数据存到rx[1],所以最后返回的是rx[1]。
写寄存器简单一些:
void mcp2515_write_reg(uint8_t addr, uint8_t val) { uint8_t tx[2] = {0x02, addr}; MCP2515_CS_Low(); HAL_SPI_Transmit(&hspi1, tx, 2, 10); HAL_SPI_Transmit(&hspi1, &val, 1, 10); MCP2515_CS_High(); }在写任何寄存器之前,MCP2515的CS引脚必须拉低,一个完整的SPI事务结束后再拉高。CS时序如果不对,数据会随机错位,我遇到过几次,都是因为CS在操作中途被其他中断打断,导致多了一个时钟周期。
位修改函数也很有用,尤其适合修改CANINTF、CANCTRL这种状态位,可以做到只改某个bit而不影响其他bit:
void mcp2515_modify_bits(uint8_t addr, uint8_t mask, uint8_t val) { uint8_t tx[4] = {0xB0, addr, mask, val}; MCP2515_CS_Low(); HAL_SPI_Transmit(&hspi1, tx, 4, 10); MCP2515_CS_High(); }2.3 波特率配置与计算过程
MCP2515的波特率不是像STM32内部CAN那样直接用寄存器算预分频,而是依靠三个寄存器来配置:CNF1、CNF2、CNF3。这三个寄存器组合决定了一个CAN位的长度、采样点位置以及波特率。
关于计算原理,MCP2515内部有一个位时序发生器,每个位时间被分成多个时间份额TQ。TQ的大小由晶振频率和BRP预分频决定,公式可以简单理解为:
TQ = 2 * (BRP + 1) / Fosc
比如MCP2515外接8MHz晶振,BRP设为0,那么TQ就是2us? 这里要注意,不是2us,是0.25us。2 / 8MHz = 0.25us,也就是250ns。如果目标波特率是500kbps,每个CAN位时间是2us,那么一个位周期需要8个TQ。接下来的任务就是把8个TQ分配到同步段、传播段、相位缓冲段1和相位缓冲段2上。
实际项目中我一般不会手动去推导每一个寄存器值,因为MCP2515的数据手册里给了很多常用配置表。我把自己实测过的几组配置贴出来,晶振为8MHz时:
| 目标波特率 | CNF1 | CNF2 | CNF3 | 实测误差 |
|---|---|---|---|---|
| 500kbps | 0x00 | 0x90 | 0x82 | 正常 |
| 250kbps | 0x01 | 0x90 | 0x82 | 正常 |
| 125kbps | 0x03 | 0x90 | 0x82 | 正常 |
| 100kbps | 0x04 | 0x90 | 0x82 | 正常 |
这段配置适合大部分常规应用。你拿到代码后,如果板子上的晶振是16MHz,就需要换一组值。千万不要拿8MHz的配置直接去跑16MHz的板子,否则波特率会差一倍,总线怎么都通不了。
初始化流程中,一定要先把MCP2515切换到配置模式,然后才能修改CNF1、CNF2、CNF3。在正常模式下写这三个寄存器是无效的,甚至有可能会触发配置错误。切换模式的代码在后面会一起给出。
2.4 滤波与掩码机制
MCP2515的滤波机制很多人搞不懂,我尽量用大白话解释。
MCP2515有2个掩码寄存器,RXM0和RXM1,分别作用于RXB0和RXB1。掩码决定你要检查报文的哪些位。掩码某一位为1,表示这一位必须匹配;为0,表示这一位不关心。此外还有6个滤波器,RXF0到RXF5,RXB0可以关联RXF0和RXF1,RXB1可以关联RXF2到RXF5。滤波器写出你想接收的ID值。
举个例子,我想接收一个CAN ID为0x123的报文,掩码设置为0xFFFFFFFF(所有位都要匹配),滤波器设置为0x00000123,那么只有ID为0x123的帧才能进接收缓冲器。如果我想同时接收0x123和0x124,可以把掩码设为0xFFFFFFFE,后一位不关心,滤波器设为0x00000122,这样0x122和0x123都会被接收。注意这里的ID和掩码都是29位还是11位,取决于你接收的是扩展帧还是标准帧。
滤波配置其实是在配置模式做的事情之一。实际项目中如果只做收发测试,完全可以不启用滤波,让所有报文都进缓冲器。需要过滤大量报文时再认真配置掩码,不然一个不精确的过滤器会吞掉你真正想收的帧,而且非常难查出来。
3. C/C++收发程序设计与实现
3.1 SPI初始化与引脚配置
我用STM32CubeMX生成工程,SPI1配置为全双工主机模式,CPOL设为Low,CPHA设为1Edge,也就是SPI模式0。MCP2515对SPI模式有严格要求,必须是模式0,如果配置成模式3,芯片能读到数据但经常会出现错位。这个我踩过坑,第一条就要确认。
SPI时钟频率我设的是4MHz,保守一点。MCP2515最大支持10MHz SPI时钟,但STM32的SPI分频后不一定刚好是整数,而且线路稍长时高频容易出错。对500kbps的CAN通信来说,SPI 4MHz完全够用。
CS引脚和INT引脚单独配置成普通GPIO,CS配置为推挽输出,INT配置为输入。如果要用中断方式接收,INT引脚要选择外部中断EXTI模式。我先用轮询方式把逻辑跑通,再
本文还有配套的精品资源,点击获取