☰
SPI扩展CAN实战:多路CAN通道设计与驱动开发全攻略
2026/9/28 2:08:15 网站建设 项目流程

这个方案我是在一个车载网关项目里真正用起来的。当时需要4路CAN同时跑,MCU手里只有2个硬件CAN外设,换芯片几乎等于重做一版硬件,时间上完全不现实。最后用的就是SPI扩展CAN:主控MCU通过SPI总线外挂多个独立CAN控制器,每个控制器再接一路CAN收发器,硬生生把通道数从2扩到了6。这一篇就把这套方案的选型逻辑、硬件连接、驱动实现和调试踩坑完整写出来,给需要做多路CAN通信但又在为CAN外设数量发愁的朋友做个参考。

这套打法不是万金油,但它非常适合“在现有MCU基础上低成本增加CAN通道”这类需求。全文不讲虚的,直接按“先算账、再选型、后实现、最后排坑”的顺序来,设备端和驱动端的细节都会照顾到。无论你是做车载、工业控制还是仪器仪表,只要MCU带SPI,这篇文章的思路可以直接抄。

1. 什么时候该用SPI扩CAN:先算清楚这笔账

1.1 多路CAN需求从哪来

多路CAN的需求在工业里非常常见。车载网关是最典型的:一路CAN接BMS管电池状态,一路CAN接仪表和车机,一路CAN接诊断口,如果做商用车还得单独分一路给充电桩协议,起步就是4路。工业设备也没好到哪去,一台控制器同时控制多台变频器、伺服驱动器,或者分区域隔离不同的子系统,每块区域独立一路CAN,面板一拉就是5路往上。

问题在于,大多数通用MCU原生CAN外设通常只有1到2路。STM32F103、F407这些常用料,CAN1和CAN2已经是不少人的极限。遇到需要3路以上的场合,第一反应是换带更多CAN的MCU,但冷静算一下成本:重新画板、重新做EMC、迁移Bootloader和底层库,时间和风险都不可控。相比之下,SPI扩展CAN仅仅是在现有板上加SPI从设备芯片,主控完全不用换。

1.2 三种方案的真实对比

先摆结论,再上数据。常见的多路CAN实现路径有三条:换MCU用原生多路CAN外设、SPI外扩CAN控制器芯片、外挂多路CAN网关模块。我做过对比,各有适用边界。

对比维度换MCU原生多路CANSPI扩展CAN控制器外挂多路CAN网关
硬件改动重新画板,成本高原板加芯片,改动小原板几乎不改
软件工作量底层全部重做只写SPI和CAN驱动走AT指令或配置协议
单路实时性最好,硬件外设独立受SPI调度影响一般,取决于网关性能
通道数量弹性固定,由芯片决定2到8路容易扩展灵活但采购成本高
成本芯片贵、开发费高单路增加几元成本一个网关几百上千
适合场景新项目,追求极致性能存量板增强、中小批量临时测试、快速验证

我的经验是:2路以内优先用芯片原生CAN外设,3到8路且单路速率在250kbps附近时,SPI扩展CAN性价比最高。超过8路或CAN FD重负载,就建议用网关做域隔离,别把所有通道都压在一个MCU上。

1.3 用数据说明SPI扩展方案在带宽上的底气

很多人一听“好几个CAN共用一条SPI”就觉得带宽不够,这个担心其实过了。拿最常用的MCP2515举例,SPI时钟按9MHz算,波特率500kbps,展开算一遍完全不虚。

CAN标准帧最坏情况下一位流加上位填充,一帧约130bit,500kbps下折算成一帧约260us,也就是一路CAN每小时大概能跑3800帧。每帧最多8字节数据,连续满载时有效数据量约30多KB/s。而SPI侧发送一帧8字节数据,要写ID、DLC、数据字节,加上命令头和地址,大概20字节SPI传输。9MHz时钟下这20字节只要不到18us,和CAN总线上一帧260us相比,占用SPI带宽不过7%。

也就是说,用MCP2515这一颗芯片,纯按带宽算,SPI脚下连跑4路250kbps满载都还有富余。真正决定性能上限的不是SPI总线的物理带宽,而是MCU在收到中断后能不能及时把数据搬走,以及驱动代码是否足够短。这一点在后文踩坑部分会详细展开。所以选型阶段不要一上来就担心SPI带宽,优先考虑接收缓冲区够不够、中断处理够不够快。

2. 芯片与硬件设计:选型、引脚、晶振和地

2.1 主流SPI转CAN芯片横向对比

SPI转CAN控制器这颗料,市面上可选的不算多,但每颗定位差异挺大。我做选型时通常会列一张表把主要参数全部摆出来。

芯片型号接口支持协议外部晶振集成收发器SPI时钟典型场景
MCP2515SPICAN 2.0B需要否最高10MHz经典方案,资料最多
MCP2517FD / MCP2518FDSPICAN FD需要否最高20MHz需要CAN FD的新项目
MCP25625SPICAN 2.0B需要是最高10MHz板级空间紧张
SJA1000并行接口CAN 2.0B需要否不支持SPI老设计,新项目不推荐

MCP2515是绕不开的基准款,市面上大量CAN扩展模块用的都是它,Linux内核里也有原生驱动支持(mcp251x),树莓派扩展CAN基本都是靠它。MCP25625等于把MCP2515和TJA1050级别的收发器装进一颗芯片里,好处是省一颗收发器和配套电路,缺点是散热和抗干扰不如分离方案。MCP2517FD和MCP2518FD则面向CAN FD需求,CAN FD的数据段速率能上到几Mbps,但对驱动和PCB要求更高,而且很多老收发器不支持CAN FD,整体成本会上一个台阶。

SJA1000虽然经典,但它是并行接口,想挂在SPI总线上得用GPIO去模拟并行时序,非常浪费IO和CPU,新设计完全没有必要碰。如果项目确实需要CAN FD且量不大,我更倾向直接评估带FDCAN外设的MCU,比外挂MCP2517FD更省心。

2.2 典型硬件连接:以MCP2515+TJA1050为例

下面这套连接方式我用了很多次,也是最稳的组合:MCP2515做CAN控制器,TJA1050做收发器,MCU负责SPI主机通信。接线可以按这张表来。

MCP2515引脚接到哪里说明
SCKMCU SPI SCK时钟,MCP2515支持SPI Mode 0,0
SIMCU SPI MOSI数据输入
SOMCU SPI MISO数据输出
CSMCU任意GPIO片选,软件控制
INTMCU外部中断引脚低有效,接收/错误事件通知
RSTMCU任意GPIO低有效复位
TXCAN收发器TXD控制器发送
RXCAN收发器RXD控制器接收

需要注意几个细节。第一个是电平匹配,MCP2515有3.3V和5V两种后缀型号,选型时务必看清楚。MCU是3.3V时用3.3V版本MCP2515,别拿5V型号直连,否则得加电平转换电路。第二个是INT引脚,它是低有效开漏输出,必须接上拉电阻到电源,MCU侧配下降沿触发的外部中断。第三个是CS片选,用软件GPIO控制比硬件NSS更灵活,多路扩展时每颗芯片一个独立CS,SCK、MOSI、MISO可以共享。

TJA1050那侧相对简单,但终端电阻一定不要乱加。标准CAN总线要求在物理链路最远两端各接一个120Ω终端电阻,中间节点不加。如果你的板子就是链路的一端,那就靠近TJA1050的CANH和CANL之间跨一个120Ω电阻。很多人在调试时发现总线错误率很高,最后查出来就是每个节点板子上都焊了120Ω,等效阻抗被拉到了40Ω,直接导致差分信号畸变。

2.3 晶振与波特率的关系:16MHz还是8MHz

MCP2515需要外部提供时钟,最常见的是8MHz或16MHz晶振。这个选择直接影响波特率寄存器配置的便捷程度和误差,不能随手抓一个就用。

波特率配置的核心是时间份额TQ,MCP2515的时间份额计算方式是:

TQ = 2 × (BRP + 1) / Fosc

其中BRP是波特率预分频值。位时间由SyncSeg、PropSeg、PhaseSeg1、PhaseSeg2四段组成,总位时间 = 总TQ数 × TQ,波特率就是总位时间的倒数。

以16MHz晶振跑500kbps为例,位时间2us。若BRP=0,则TQ = 2 / 16MHz = 0.125us,总TQ数 = 16,分段余量非常大,采样点可以轻松配到75%。但同样500kbps如果换8MHz晶振,BRP=0时TQ = 0.25us,总TQ数只有8,分段自由度就小很多。要是跑1Mbps,8MHz晶振下总TQ数只剩4,基本没法用。所以我的习惯是:以250kbps和500kbps为主的项目,8MHz晶振够用;只要涉及1Mbps或更高,优先选16MHz晶振。

网上常看到一组8MHz晶振跑500kbps的寄存器值:CNF1=0x00、CNF2=0x90、CNF3=0x02。这套值在很多例程里能用,但我建议量产前还是用Microchip官方的波特率计算工具重新算一遍,因为不同采样点和总线长度对应的最优参数不一样。工程上最怕的就是PCB已经贴片了才发现某两个节点间采样点不匹配导致偶发错误帧。

2.4 多节点共地、地偏移与隔离方案

CAN通信虽然靠差分信号,但收发器依然需要参考地。多个节点都用自己的电源时,节点之间的GND会存在电位差,这就是常说的地偏移。如果地偏移超过收发器共模输入范围,波形就会畸变,CAN控制器进入错误状态,表现为总线频繁出现错误帧甚至bus-off。

稳妥的做法是先测量再定方案。我在现场排查时按三步走:第一步,用万用表测各个节点GND之间的电压差,超过1V就要警惕;第二步,示波器接在CAN_H和CAN_L与本地GND之间,观察显性位和隐性位的共模电平,正常TJA1050的隐性电平应该在2.5V附近,显性差约2V;第三步,持续通信时看总线的错误计数寄存器是否不断增长。

如果确认地偏移大,两条路可选:要么把所有节点统一拉一根粗地线,要么上隔离收发器。工业场景里我更推荐后者,用ISO1042、ADM3053这类带隔离的CAN收发器,或者给MCP2515那侧加数字隔离器。有一个坑很多人会踩:只隔离了CAN收发器,MCU和MCP2515之间依然是SPI共地,地环路照样存在。彻底隔离需要连SPI侧的数字隔离器一起上,同时给隔离侧单独供电。成本会上去,但总线上挂十几个节点的时候,这会让你少掉一大半调试时间。

3. 驱动从零实现:寄存器、CubeMX和多路抽象

3.1 MCP2515初始化:寄存器与基本时序

MCP2515初始化这件事,本质上就是操作寄存器和SPI指令。整个驱动可以拆成几块:SPI读写底层、芯片复位、进入配置模式、设置波特率、设置过滤器、退出配置模式、中断使能。

底层SPI读写用HAL库的话,最核心的是读字节和写字节。MCP2515的SPI协议很简单,发指令字节+地址+数据。写字节指令0x02,读字节指令0x03,读接收缓冲区指令0x90到0x93,装载发送缓冲区指令0x40到0x43,请求发送指令0x80到0x87,位修改指令0x05。

初始化部分的核心代码如下,注意每一步操作必须按顺序来:

uint8_t mcp2515_read_reg(mcp2515_t *dev, uint8_t reg) { uint8_t tx[3] = {0x03, reg, 0x00}; uint8_t rx[3] = {0, 0, 0}; HAL_GPIO_WritePin(dev->cs_port, dev->cs_pin, GPIO_PIN_RESET); HAL_SPI_TransmitReceive(dev->hspi, tx, rx, 3, 10); HAL_GPIO_WritePin(dev->cs_port, dev->cs_pin, GPIO_PIN_SET); return rx[2]; } void mcp2515_write_reg(mcp2515_t *dev, uint8_t reg, uint8_t val) { uint8_t tx[3] = {0x02, reg, val}; uint8_t rx[3] = {0, 0, 0}; HAL_GPIO_WritePin(dev->cs_port, dev->cs_pin, GPIO_PIN_RESET); HAL_SPI_TransmitReceive(dev->hspi, tx, rx, 3, 10); HAL_GPIO_WritePin(dev->cs_port, dev->cs_pin, GPIO_PIN_SET); } void mcp2515_init(mcp2515_t *dev) { // 先软复位 HAL_GPIO_WritePin(dev->cs_port, dev->cs_pin, GPIO_PIN_RESET); uint8_t rst_cmd = 0xC0; HAL_SPI_Transmit(dev->hspi, &rst_cmd, 1, 10); HAL_GPIO_WritePin(dev->cs_port, dev->cs_pin, GPIO_PIN_SET); HAL_Delay(10); // 进入配置模式,仅使用请求CANCTRL寄存器 mcp2515_write_reg(dev, 0x0F, 0x80); // CANCTRL = REQOP_CONFIG // 配置500kbps,假设16MHz晶振 mcp2515_write_reg(dev, 0x2A, 0x01); // CNF1, 具体值按波特率工具计算 mcp2515_write_reg(dev, 0x29, 0x90); mcp2515_write_reg(dev, 0x28, 0x02); // 关闭验收滤波 mcp2515_write_reg(dev, 0x60, 0x60); // RXB0CTRL = RXM1|RXM0 接收全部帧 // 退出配置模式,进入正常模式 mcp2515_write_reg(dev, 0x0F, 0x00); // 清中断并打开接收中断 mcp2515_write_reg(dev, 0x2C, 0x00); // CANINTF 清零 mcp2515_write_reg(dev, 0x2B, 0x03); // CANINTE = RX0IE | RX1IE }

上面代码里的CNF1、CNF2、CNF3具体值,我是示意性写的,不同晶振不同波特率需要按工具计算。生产环境里我会把这三个寄存器值做成宏或者配置表,方便在初始化时按实际需求切换波特率。还有一点,芯片刚上电时如果晶振还没起振,SPI读回的数据全是0xFF,初始化前建议先读CANSTAT状态寄存器,确认芯片响应正常再往下走。

3.2 发送和接收的完整流程

发送流程上,先用装载发送缓冲区指令把ID、DLC和数据写进某个发送缓冲,再发请求发送命令。以标准帧为例,发送函数可以这样写。

uint8_t mcp2515_send(mcp2515_t *dev, uint32_t id, uint8_t *data, uint8_t len) { uint8_t buf[14]; // 0x40 装载TXB0 buf[0] = 0x40; buf[1] = (id >> 3) & 0xFF; // SIDH,标准ID高位 buf[2] = (id & 0x07) << 5; // SIDL,标准ID低位 buf[3] = 0; // EID8,标准帧不用 buf[4] = 0; // EID0 buf[5] = 0x80 | (len & 0x0F); // DLC,标准帧,IDE=0 for (uint8_t i = 0; i < len; i++) { buf[6 + i] = data[i]; } HAL_GPIO_WritePin(dev->cs_port, dev->cs_pin, GPIO_PIN_RESET); HAL_SPI_Transmit(dev->hspi, buf, 6 + len, 20); HAL_GPIO_WritePin(dev->cs_port, dev->cs_pin, GPIO_PIN_SET); // 请求发送TXB0 uint8_t rts = 0x81; HAL_GPIO_WritePin(dev->cs_port, dev->cs_pin, GPIO_PIN_RESET); HAL_SPI_Transmit(dev->hspi, &rts, 1, 10); HAL_GPIO_WritePin(dev->cs_port, dev->cs_pin, GPIO_PIN_SET); return 0; }

发送之后不能立刻认为帧已经上总线了,还应该定期检查TXB0CTRL的TXREQ位是否被硬件清零。TXREQ清零代表帧已经完成发送。如果一直为1,可能是总线在忙或者发生了错误。这个检查要带超时,否则一旦总线bus-off,代码很容易卡死在等待里。

接收有两种做法,轮询和中断。轮询简单,但CPU浪费大;中断是主流。MCP2515的INT引脚在RXB0或RXB1收到帧时会自动拉低,MCU的EXTI中断被触发,然后在中断服务函数里读取CANINTF寄存器确认中断源,再读接收缓冲区。注意,接收中断标志必须手动清除,通常用位修改指令把CANINTF的RX0IF和RX1IF清零。顺序上建议先把数据搬走再清标志,避免清早了丢帧。

3.3 CubeMX配置SPI+DMA的选型和坑

STM32F103做SPI主机时,CubeMX配置半天其实就几个关键点。SPI模式选Full-Duplex Master,CPOL=0、CPHA=0,也就是Mode 0。片选不要用硬件NSS,用普通GPIO。速度方面,F103的APB1最高36MHz,SPI2的分频系数选4得到9MHz,比MCP2515手册标称的10MHz稍低,但更稳妥,尤其3.3V供电的MCP2515在高时钟下的时序余量没那么好。

关于DMA,我的看法是:MCP2515这种单包最多20字节的小数据交互,开DMA收益不大,反而会引入CS和DMA完成中断的配合问题。如果你确实要用DMA,最大的坑是CS引脚拉低后,DMA还没发起传输,或者传输结束后DMA中断回调里没有及时拉高CS,下一次传输就会错位。建议只在每路CAN都有大数据块发送需求时再上DMA,普通轮询和中断收发已经足够快。

EXTI中断配置上,INT引脚选择GPIO_EXTI,下降沿触发,NVIC优先级可以设比系统滴答低、比普通外设高。接收中断服务函数里绝不做协议解析,只做SPI读取和FIFO写入。协议解析放在应用层任务,这是保证多路CAN同时工作时不丢帧的基础。

3.4 多路CAN的软件抽象与调度

多路扩展时,最忌讳的是把每一路的收发逻辑散落在中断里到处写。我会定义一张通道表,把每个CAN通道视为一个带SPI片选和中断引脚的独立设备,代码里用一个结构体数组管理,这样增减通道就是改表项。

typedef struct { SPI_HandleTypeDef *hspi; GPIO_TypeDef *cs_port; uint16_t cs_pin; GPIO_TypeDef *int_port; uint16_t int_pin; uint8_t net_id; uint32_t baudrate; osMessageQueueId_t tx_queue; void (*rx_callback)(uint8_t net_id, uint32_t can_id, uint8_t *data, uint8_t len); } can_channel_t; static can_channel_t can_channels[CAN_CHANNEL_MAX]; uint8_t can_channel_send(uint8_t ch, uint32_t id, uint8_t *data, uint8_t len) { // 获取SPI总线互斥锁,防止多任务同时操作SPI外设 if (xSemaphoreTake(spi_mutex, pdMS_TO_TICKS(10)) != pdTRUE) { return 1; } // 操作mcp2515发送 // 释放互斥锁 }

所有通道共用同一条SPI总线会带来一个调度问题:同时有多路帧到达时,MCU中断会被连续触发,如果每路中断里都在读SPI,后面的通道只能排队。我的处理思路是,中断里只做一件事:把对应通道的数据读进一个环形缓冲区,然后把RCV事件投递给应用任务。应用任务按优先级决定先处理哪一路,SPI操作加上互斥信号量,发送加超时保护。这样即使4路同时满载,系统也不会阻塞死。

4. 我实测中踩过的四个坑

4.1 INT中断丢失与接收缓冲溢出

第一次把4路CAN跑起来时,现象很稳定:单独测任何一路都正常,同时跑4路就丢帧,而且是固定丢某一路。排查链路是这样的:先用CAN卡在总线上抓,确认总线侧帧都发出来了,问题出在MCU没收到或者收到没处理。再用示波器看INT引脚的波形,发现中断确实有下降沿,而且每次接收事件都会拉低。但代码里读CANINTF寄存器时,有些标志已经消失。最后定位到原因:MCP2515每组接收缓冲区只有两个,如果MCU中断响应不及时,新到的帧会覆盖旧帧。

解决这个坑有几个手段。第一,接收中断服务函数里动作要极短,只做SPI读数据,不做解析不下发队列;第二,中断标志千万别读完数据后才清,而是读完立即清,减少覆盖窗口;第三,RXB0和RXB1可以配置成轮询交替模式,让两个缓冲区都参与普通帧接收,等效缓冲深度加了一倍。最关键的是,检查一下是否有更高优先级的中断长时间关掉了这个EXTI,比如Flash写操作、I2C等待之类,这些操作会把CAN中断卡掉几十微秒,足以让两个接收缓冲全部被覆盖。

4.2 SPI读时序导致的假丢帧

这个坑特别隐蔽,现象是:偶尔初始化会失败,读回CANSTAT全是0xFF,但重新上电又正常。单独看波形,SPI时钟、 MOSI、CS都是对的。后来用示波器把CS和SCK放大了看,发现CS拉低之后很快就来了第一个SCK上升沿,MCP2515内部从CS有效到SO引脚数据准备好需要一定的建立时间,时钟太快,第一个字节就会读到0xFF。

MCP2515的数据手册上对CS下降沿到第一个SCK上升沿是有最小时间要求的,高速SPI下这个时间容易被忽略。解决很简单,两个方案任选:把SPI时钟从9MHz降到4.5MHz,给芯片多留点准备时间;或者每次CS拉低后,先发一个dummy字节或者直接加个几百纳秒的延时再开始真正的读。我现在写驱动时习惯性在SPI读操作前加一个短延时,虽然只多了几百纳秒,但彻底避开了这个坑。

4.3 CAN波特率配错的完整排查链路

有次客户反馈两套设备对接时频繁掉线。我先远程让他们看终端的错误计数器,发现TEC一直在增长,说明MCU确实在尝试发送但一直发不出去,紧接着就是bus-off死循环。Can卡挂上去之后,错误帧多到刷屏。

这种问题第一怀疑对象永远是波特率和地偏移,不是软件逻辑。排查顺序我总结成一套固定流程:先量CAN_H和CAN_L之间的阻力,确认不是终端电阻叠加问题;再检查两个设备的实际波特率,最好用CAN卡抓一下设备的发送帧,看帧间隔是否符合预期波特率;接着用示波器看显性位和隐性位电平,确认收发器工作正常。最后回到MCP2515的CNF1、CNF2、CNF3寄存器,用官方工具重算对比。

那次最后查到的是客户单位里有人把晶振从16MHz换成了8MHz,但代码里波特率寄存器没改。8MHz晶振按16MHz的参数配置,500kbps出来的真实波特率直接偏到250kbps,两边根本握不上手。从此以后,我在PCB上画MCP2515的晶振位置时都会特意标清楚频率,BOM再忙也不能在晶振上省事。

4.4 多路同时发送时的CPU占用和阻塞问题

4路CAN重负载压力测试时还遇到过一个典型的应用层问题:某一路CAN的发送任务偶尔延迟特别大,甚至任务卡住。最后发现是发送函数里有个死等逻辑,它在等待TXREQ清零时没有超时,一旦总线上正好有错误帧导致发送不出去,任务就一直在那转。

修复方案是给所有发送操作加超时,超时后返回错误码并清除请求位。另外,多路共用SPI时,所有任务在访问SPI前必须抢互斥信号量,否则两路同时在写MCP2515寄存器,数据会错乱。加完互斥之后再做一次4路同时满载测试,SPI总线9MHz,4路250kbps,收发全部开启,CPU占用在50%到70%之间,没有死等和丢帧。这个数据说明,对于常规工业CAN场景,SPI扩展方案完全扛得住。真到了4路每路都按500kbps满载的地步,建议直接用带原生FDCAN的MCU,别在这个方案上继续硬顶着。

5. 一些个人建议:边界与升级路径

用SPI扩展CAN这套方案在3到8路通道区间内,是目前综合成本、开发和维护最平衡的选择。它有边界,比如CAN FD重负载、单路1Mbps满跑、以及需要硬实时响应的场景,都该重新评估换方案。不要因为驱动好写就用它硬抗所有需求。

选型上,我个人的优先级排序是:普通2.0B场景首选MCP2515,资料多、驱动稳、成本最低;板级空间不够选MCP25625;明确未来要上CAN FD就一步到位选MCP2518FD,省得下次再改版。晶振统一用16MHz,给自己留出高波特率的余量。

硬件上给每条CAN链路加TVS管和共模电感,成本不高但能减少现场大部分莫名其妙的损坏。量产前最好做一次多节点、长线缆、不同供电系统的压力测试,把地偏移问题扼杀在设计阶段。软件上,不要追求用DMA处理所有SPI传输,先保证CS和中断时序的确定性,再去优化传输效率。这套方案设计得干净,维护起来是很省心的。以我个人的经验,只要把中断、锁和超时这三件事做好,SPI扩展CAN完全可以作为长期稳定运行的量产方案来用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询