很多人问我,SPI扩展CAN到底靠不靠谱,尤其是当MCU原生CAN接口不够用的时候。我这两年做车载网关和工业控制板,经常要在单片机上堆3路甚至5路CAN,最顺手的方案就是“主控MCU的SPI接口 + 外挂CAN控制器”,其中最经典的组合是STM32F103 + MCP2515 + CAN收发器。这个方案听起来绕了一层,但实际做起来稳定、灵活、可裁剪,非常适合那些原厂不带多路CAN、或者换主控成本太高的场合。这篇文章我就从硬件选型、电路设计、CubeMX配置、软件驱动到实际踩坑,把完整的SPI扩展CAN实现思路拆开讲清楚,希望能帮正卡在“MCU的CAN口不够用”这个问题上的朋友打开一条路。
1. 多路CAN需求与方案选型
1.1 为什么需要多路CAN
先说说需求从哪来。新能源汽车的整车控制器一般要同时管理动力CAN、车身CAN、充电CAN、诊断CAN,动不动就是3路以上;工业设备里,PLC要接现场设备总线,又要接上位机,很多时候也需要2到3路CAN。就算是一些农业机械、工程车辆、医疗设备,也经常出现“主控只有一个CAN口,但实际系统需要两路甚至更多”的局面。
最直接的办法是换一颗带多路CAN的MCU,但现在芯片供应链波动很大,很多硬件工程师一听到换主控就头疼——启动代码、外设驱动、bootloader全要返工,硬件板卡也可能要大改。相比之下,外挂SPI转CAN控制器只需要改很少的硬件,主控MCU还是原来那颗,软件上增加几个外设驱动即可,成本和风险都可控,这是这个方案能被大量量产项目采用的根本原因。
1.2 SPI转CAN控制器的选型
市面上常见的SPI转CAN控制器,最经典的就是Microchip的MCP2515,支持CAN 2.0B标准帧和扩展帧,最高速率1Mbps,内部有3个发送缓冲器和2个接收缓冲器,工作电压3.3V,外部需要一颗8MHz晶振,然后还要外接CAN收发器。这颗芯片资料多、用的人多、成本低,是目前最稳妥的选择。
如果你的系统需要CAN FD,可以考虑MCP2518FD或者MCP2517FD,它们支持CAN FD协议,数据段最高速率能到8Mbps甚至更高,但SPI时钟和驱动逻辑也复杂一些,寄存器结构和MCP2515不兼容。选择时主要看这几点:支持的帧格式和速率、SPI时钟上限、FIFO深度、封装尺寸、供货稳定性。
有些朋友可能会问,STM32F103本身自带CAN外设,能不能直接外挂个CAN收发器?当然可以,但这里讨论的是主控没有CAN外设、或者CAN外设数量不够的情况。SPI转CAN控制器的本质是把CAN协议栈放在独立芯片里,主机只负责收发数据,协议处理、错误检测、仲裁这些事都由MCP2515完成,主控压力很小。
1.3 为什么不用内置多路CAN的MCU
我们列个对比,看看内置多路CAN的MCU和SPI扩展方案各有什么优劣:
| 维度 | 内置多路CAN的MCU | SPI扩展CAN方案 |
|---|---|---|
| 主控成本 | 高,多CAN MCU通常定位高 | 可以使用低端主控,但加上外围芯片不一定更便宜 |
| 路数扩展 | 受MCU选型限制 | 灵活,4路、8路都能加 |
| 实时性 | 高,硬件自动收发 | 受SPI通信时间影响 |
| 软件工作量 | 相对简单 | 需要写驱动、调度资源,工作量更大 |
| 硬件复杂度 | 低 | 增加了控制器芯片、晶振、收发器 |
| 芯片供货风险 | 单芯片 | 可将主控和CAN功能拆开,备货更灵活 |
如果每一路CAN都跑满载、而且对接收延迟极其敏感,那我建议还是选内置多路CAN的MCU。但大多数应用,比如网关转发、BMS数据采集、工业设备通信,对延迟要求并没有苛刻到微秒级,SPI扩展方案完全够用,而且可维护性好、后续换主控也不至于推翻重来。
2. 硬件电路设计:搭建SPI转CAN的“骨架”
2.1 MCU与SPI转CAN控制器的连接
我以STM32F103C8T6和MCP2515为例。MCP2515的接口包括:SCK、SI(MOSI)、SO(MISO)、CS、INT、RESET、TXCAN、RXCAN、OSC1/OSC2。
实际接法很简单:
- SCK接STM32的SPI1_SCK,比如PA5
- SI接SPI1_MOSI,比如PA7
- SO接SPI1_MISO,比如PA6
- CS接任意GPIO,比如PA4,多路时每路各占一个GPIO
- INT接外部中断引脚,比如PA0、PA1、PA2,多路时各占一个
- RESET也建议用GPIO控制,比如PB0,方便软件复位
- TXCAN/RXCAN接CAN收发器的TXD/RXD
- OSC1/OSC2接8MHz晶振,加上两个15pF电容
多路扩展时,所有MCP2515共享SCK、SI、SO这三根线,CS和INT各自独立。共享总线有个好处,MCU的SPI外设只需要一个,成本低,但也要注意MISO是三态输出,某个芯片CS拉高后其MISO会释放,不影响其他设备通信。
如果MCU没有USB差分信号引脚,那跟这个方案没有直接关系,CAN是差分总线没错,但MCP2515已经把CAN协议转成SPI了,主控完全不必直接接触CAN的物理层差分信号。
2.2 硬件片选与软件片选的选择
STM32的SPI外设有NSS引脚,可以做硬件片选,但我强烈建议使用普通GPIO软件控制CS。原因是MCP2515要求整个SPI帧期间CS脚保持低电平,而硬件NSS在某些情况下可能提前拉高,或者产生不期望的时序,排查起来很痛苦。用GPIO控制CS,想什么时候拉低就什么时候拉低,想拉多低就拉多低,逻辑完全由自己掌握。
多片MCP2515扩展时,每个芯片单独一个CS。切换不同芯片时,注意先拉高上一片CS,再拉低下一片CS,中间至少留几百纳秒间隔。有些芯片在CS上升沿处理数据,如果间隔太短,容易导致上一片还没完成内部锁存,下一片已经开始访问。
另外,如果使用DMA传输,CS必须在整个DMA传输期间保持低电平,不能提前拉高。真正的做法是等DMA完成中断到来后,再拉高CS,而且还要确认SPI的BSY标志已经清除,否则可能出现最后一次移位还没完成,CS就被拉高的情况,导致数据丢失。
2.3 CAN收发器、隔离与终端电阻
MCP2515内部是CAN控制器,输出的是TXCAN和RXCAN逻辑电平,还需要一颗CAN收发器转换成总线差分电平。收发器选择很多,TJA1050是5V供电,输出RXD高电平也是5V,直接接MCP2515并不完全推荐;更好的选择是3.3V供电的收发器,比如TJA1051T/3、SN65HVD230,或者带隔离的ISO1050、ADM3053。
我实际项目中常用SN65HVD230,和MCP2515能直接搭,3.3V电平,不需要电平转换。如果板子必须用5V供电的TJA1050,遇到RXD输出电平偏高的问题,最简单的处理是用电阻分压到3.3V,或者用方向正的电平转换芯片。我在量产板上吃过亏,当时直接拿TJA1050的RXD接MCP2515的RXCAN,短距离测试没问题,但布线长了之后偶尔报错,后来换成3.3V收发器再没出现过。
终端电阻不能忘。CAN总线规范要求两端各接一个120欧姆电阻,作用是匹配阻抗、反射信号、保证差分电平正确。做板子的时候,我会在CAN收发器旁边预留120欧姆的焊盘位,很多场合节点间距离短、节点数量少,可以先用跳线或者电阻装上,现场调试再决定要不要摘掉。注意,终端电阻只加在总线两端,不是每个节点都加,如果每个板子都带120欧,整个总线等效电阻过低,收发器会过载。
隔离设计也很重要。工业环境里不同设备地电位差可能很大,总线两端如果地不平衡,CANH和CANL之间会出现电位漂移,严重时直接导致通信失败。测试CAN地偏移最简单的三个步骤:一是用万用表量CANH到CANL的静态电压和差分电压,二是分别量CANH、CANL对本地地线的电压,观察是否在收发器共模范围内,三是加入隔离收发器后再测一次。如果地电位差较大,就老老实实上隔离方案。
2.4 SPI时钟速度与DMA设计要点
MCP2515在3.3V供电下,SPI时钟最高建议控制在5MHz以内,5V供电可以到10MHz。STM32F103外设时钟为36MHz或72MHz,SPI1挂APB2能达到18MHz。我一般使用8分频,得到9MHz,但为了稳妥,常用16分频得到4.5MHz。这个速度下,单帧20字节的报文传输时间约35微秒,多路CAN完全跑得过来。
为什么推荐DMA而不用中断方式收发?因为多路CAN场景下,主循环经常有协议解析、任务调度,如果每收一帧都进中断读写几十个字节,CPU占用率很高,还会因为中断优先级竞争导致CAN接收FIFO溢出。DMA可以把数据从SPI直接搬到内存,传输完成后才触发一次中断,主控去做后续业务处理,效率高很多。
DMA设计有一个细节,接收数据时,通常先发送读命令和地址,再启动DMA接收。CS保持低电平,分两步时序没问题,只要两次SPI访问之间CS不拉高即可。更好的办法是一次性使用HAL_SPI_TransmitReceive_DMA,构造发送缓冲区[读命令, 地址, 0x00, 0x00, ...],同时接收相同长度的数据,有效数据从第三个字节开始取。这种方法时序更紧凑,DMA只需要一次,但要注意有效数据的偏移。
3. 软件驱动实现:从CubeMX到多路调度
3.1 CubeMX配置SPI与DMA
用STM32CubeMX生成工程,配置SPI1为Full-Duplex Master,参数如下:
- Master/Slave: Master
- Frame Format: Motorola
- Data Size: 8 Bits
- First Bit: MSB First
- Prescaler: 16(得到4.5MHz,具体看时钟树)
- CPOL: Low
- CPHA: 1 Edge
MCP2515的数据手册写明支持模式0,0,也就是CPOL=0、CPHA=1Edge。有少数驱动用了模式1,1也能通信,那是因为时序余量足够,但不推荐照抄,老老实实按照手册来。
DMA配置两步:
- 添加SPI1_RX DMA请求,Direction: PeripheralToMemory,Mode: Normal
- 添加SPI1_TX DMA请求,Direction: MemoryToPeripheral,Mode: Normal
- Data Width都选Byte
外部中断配置好INT引脚,比如PA0、PA1、PA2,设为下降沿触发。CS和RESET引脚配置为GPIO Output,初始电平置高。
CubeMX生成的代码只负责初始化,具体的MCP2515操作需要自己写驱动函数,下面我把关键部分展开。
3.2 MCP2515寄存器操作基础
MCP2515对外提供SPI命令接口,核心命令如下:
| 命令 | 命令码 | 作用 |
|---|---|---|
| RESET | 0xC0 | 复位芯片 |
| READ | 0x03 | 从指定地址读取字节 |
| WRITE | 0x02 | 向指定地址写入字节 |
| RTS | 0x80~0x84 | 请求发送缓冲器发送 |
| READ STATUS | 0xA0 | 读取中断状态和发送状态 |
| RX STATUS | 0xB0 | 读取接收过滤器信息 |
读写寄存器最简单的实现:
void mcp2515_write_reg(uint8_t cs_id, uint8_t addr, uint8_t val) { uint8_t send[3] = {0x02, addr, val}; cs_low(cs_id); HAL_SPI_Transmit(&hspi1, send, 3, 10); cs_high(cs_id); } uint8_t mcp2515_read_reg(uint8_t cs_id, uint8_t addr) { uint8_t send[2] = {0x03, addr}; uint8_t recv = 0xFF; cs_low(cs_id); HAL_SPI_Transmit(&hspi1, send, 2, 10); HAL_SPI_Receive(&hspi1, &recv, 1, 10); cs_high(cs_id); return recv; }软件复位后,要等芯片稳定再初始化。MCP2515需要进入到配置模式才能修改波特率寄存器。初始化顺序是:复位芯片,读取CANSTAT确认进入配置模式,设置CANCTRL选择正常模式、设置CNF1/CNF2/CNF3设定波特率,配置收发缓冲器和中断使能,设置验收滤波器和掩码,最后退出配置模式。
波特率由主时钟8MHz和三个配置寄存器决定。以500kbps为例,设BRP=0,则TQ=2*(BRP+1)/8MHz=250ns,一个位时间需要8个TQ,可以将CNF1的BRP设为0,CNF2的PHSEG1设为2,CNF3的PHSEG2设为2,这样位时间为1+1+2+2+1? 实际MCP2515的位时间结构是SyncSeg(1TQ)+PropSeg+TQ+PhaseSeg1+PhaseSeg2,配置前要仔细对照手册。我通常直接使用官方或成熟驱动里的参数表,避免手算出错。
3.3 收发流程与中断处理
MCP2515每路有自己的INT引脚,低有效。只要芯片有接收数据、发送完成、错误等事件,INT就会拉低,触发MCU外部中断。
接收流程:进入外部中断后,先判断是哪一路触发,然后向对应芯片发送读取接收缓冲器命令,例如读取RXB0使用0x92,接着依次读取ID、控制字节、数据长度和数据字段。读完以后要把接收中断标志清掉,方法是写CANINTF寄存器,将RX0IF对应位置0,注意读该寄存器后不能直接写回全部值,否则会误清其他中断标志。
发送流程更简单。应用层要发送报文时,先找到空闲的发送缓冲器,把ID、DLC、数据写入对应寄存器,然后置位TXREQ发送请求。MCP2515有三个发送缓冲器,可以同时缓存3帧,适合做小队列。使用RTS命令可以直接触发指定缓冲器发送,比如RTS TXB0的命令码是0x81。
如果开启了发送完成中断,发送完成后MCP2515会拉低INT引脚,MCU在中断里读取发送中断标志,更新软件队列。在低负载场景,也可以不做发送完成中断,只做接收中断,发送流程直接阻塞等待发送完成,但这种方法在多路CAN高负载时容易卡住整个SPI总线,不推荐。
3.4 多路CAN的资源调度与缓冲管理
多路CAN共享一条SPI总线,核心问题是并发访问。比如第1路CAN正在通过SPI读接收数据,第2路CAN的发送请求来了,不能直接抢占SPI,否则两边的寄存器访问都会出错。
我常用的做法是给SPI驱动加一个互斥锁,用全局变量就行:
static volatile uint8_t spi_lock = 0; int spi_acquire(void) { if (spi_lock) return -1; spi_lock = 1; return 0; } void spi_release(void) { spi_lock = 0; }所有对MCP2515的SPI访问,都包在acquire和release之间。在实时性要求高的场景,可以关中断保护临界区,但关中断时间不能太长,否则影响MCU其他定时任务。实测上,由于每次SPI事务只有几十微秒,用全局锁在多路CAN场景下完全够用。
软件上要维护每路CAN的接收环形队列和发送队列。接收中断里只做SPI读数据和写环形队列,协议解析在主循环完成。发送队列由应用层填入,后台任务定期检查待发状态,这样可以避免在中断里处理复杂的业务逻辑。我做过4路CAN网关,主控是72MHz,4路全部设置为500kbps,SPI通信占用率不到15%,主循环还有大量时间做转存和诊断。
4. 实践中的坑与排查方法
4.1 SPI通信不生效怎么办
这是最多人问的问题,现象是MCP2515寄存器读出来全是0xFF,或者写入后读不到数据。我整理一个排查顺序:
第一步,检查芯片供电和复位。MCP2515是3.3V供电,如果接了5V,芯片直接烧毁或工作异常。RESET引脚不能用软件复位时,至少要接一个10k上拉和一个0.1uF电容到地,保证上电后能正常释放复位。
第二步,检查晶振是否起振。MCP2515没有时钟输出脚,用示波器测量OSC1和OSC2引脚可以看到8MHz正弦波,没有示波器可以量OSC2引脚对地电压,通常约1.5V左右。如果晶振没起来,寄存器读写必然失败。
第三步,检查SPI时序。用逻辑分析仪抓CS、SCK、MOSI、MISO波形,确认CPOL和CPHA。常见问题是SPI模式配错,MCP2515只认模式0,0。另外,读寄存器时MISO数据是从第三个字节位置才开始有效,如果直接用第二个字节的数据去判断,容易被误导。
第四步,检查CS时序。我之前遇到过SPI读写函数里先调用HAL_SPI_Transmit,紧接着调用HAL_SPI_Receive,两者之间CS被意外拉高,导致MCP2515认为这是两个独立事务。正确做法是CS在整个读时序中保持低电平。
第五步,检查IO复用冲突。STM32F103的PA4默认是SPI1_NSS,但如果你把CS放在PA4,且GPIO配置为AF开漏,可能会被SPI外设抢占控制权。GPIO模式必须设置为Output Push Pull,不要设置为AF。
4.2 CAN总线bus-off的恢复
Bus-off是CAN节点发送错误计数器超过256后进入的离线状态。进入bus-off后,节点不参与总线通信,直到检测到128次11个连续隐性位。实际项目中,如果某一路CAN总线短路或者接错线,MCP2515反复发送失败,最终进入bus-off状态。
MCP2515可以通过读取EFLG寄存器查看错误状态。如果判断到bus-off,最简单的处理是给芯片发复位命令,然后重新初始化。但不要一检测到错误就疯狂复位,那会让问题更复杂。我会设计一个状态机:正常状态,如果连续发送失败N次,进入bus-off恢复状态,等待总线空闲,再重新初始化并恢复发送;如果恢复后仍然失败,则判断是硬件故障,只上报不重复重试。
还要注意,在某些应用中,单路CAN进入bus-off不能影响到其他路。SPI互斥锁要保证复位其中一片芯片时,不影响其他芯片正在进行的SPI访问。
4.3 仲裁、滤波与终端电阻那些事
CAN仲裁机制决定多个节点同时发送时,ID小的节点赢得总线。如果你发现某个节点总是发不出去,先看ID是否比总线上的其他节点ID大,然后用CAN分析仪抓总线空闲率。如果总线被ID很小的节点占满,那高ID节点的报文自然会延迟。
MCP2515的验收滤波器也是一个容易踩坑的地方。默认情况下,如果掩码为全0,则所有报文都通过;如果设了掩码和滤波器,而配置不正确,就可能收不到任何报文。调试时先把滤波器全部置为不使能,确保能收到数据,再逐步配置过滤。
终端电阻问题前面讲过,这里再说一个现象:总线两端都该有120欧,但如果你用万用表量CANH和CANL之间的电阻,在总线上电后量到的不是60欧,常见原因是有节点没有正常接入收发器,或者收发器没有供电。终端电阻缺失时,传输距离短、速率低可能还能通,速率一旦跑到500kbps以上,错误帧明显增多。
4.4 多路CAN联调经验清单
最后把我在多次联调中积累的经验整理成清单,照着做能省很多时间:
- 上电后先读每路MCP2515的CANSTAT寄存器的OPMOD字段,确认是否进入配置模式。
- 先单路调试,用USB-CAN分析仪对接,确认收发正常;不要一上来就把多路同时打开,出了问题很难定位。
- 在每路收发器输出端预留跳线或测试点,方便断开某一节点排查短路。
- 多路CAN共地要牢靠,可以用粗铜箔或覆铜整块连接,避免地回路产生压差。
- 尽量把MCP2515的复位控制单独引出,软件做统一复位时更方便。
- 逻辑分析仪建议采样率至少10MHz,能同时抓CS、SCK、MISO和INT,排查时序问题最见效。
- 发送缓冲器要监测TXREQ位,防止应用层往同一个缓冲器重复写,导致覆盖正在发送的数据。
- 隔离场景下,每路隔离电源的地不能共用,否则隔离失去意义。
- 多路CAN的接收中断建议设置为可抢占的阈值,避免一路CAN报文多时阻塞其他路中断,导致接收FIFO溢出。
我自己做第一个3路CAN网关时,在SPI时钟和CS时序上折腾了两天,最后用逻辑分析仪抓波形才发现是CS提前拉高导致的。从那以后,所有SPI外设的驱动我都会先写一个简单的寄存器回读测试函数,确认最底层通信稳定后再开发业务逻辑。
这个SPI扩展CAN方案,说到底就是一句话:用MCU的SPI接口模拟出N个CAN口,把每个MCP2515当成一个独立从设备,用CS和INT区分身份。只要理解了片选、SPI时序和DMA配合这三点,扩展几路CAN本质上只是多画几路电路、多写几个驱动实例的问题。后续如果系统升级到CAN FD,可以参考同样的设计思路,把MCP2515换成MCP2518FD,主控侧的SPI通信处理逻辑大方向一致,只是寄存器操作细节需要重新适配。