
这个方案我是在一个车载网关项目里真正用起来的。当时需要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标准帧最坏情况下一位流加上位填充一帧约130bit500kbps下折算成一帧约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 典型硬件连接以MCP2515TJA1050为例下面这套连接方式我用了很多次也是最稳的组合MCP2515做CAN控制器TJA1050做收发器MCU负责SPI主机通信。接线可以按这张表来。MCP2515引脚接到哪里说明SCKMCU SPI SCK时钟MCP2515支持SPI Mode 0,0SIMCU 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更灵活多路扩展时每颗芯片一个独立CSSCK、MOSI、MISO可以共享。TJA1050那侧相对简单但终端电阻一定不要乱加。标准CAN总线要求在物理链路最远两端各接一个120Ω终端电阻中间节点不加。如果你的板子就是链路的一端那就靠近TJA1050的CANH和CANL之间跨一个120Ω电阻。很多人在调试时发现总线错误率很高最后查出来就是每个节点板子上都焊了120Ω等效阻抗被拉到了40Ω直接导致差分信号畸变。2.3 晶振与波特率的关系16MHz还是8MHzMCP2515需要外部提供时钟最常见的是8MHz或16MHz晶振。这个选择直接影响波特率寄存器配置的便捷程度和误差不能随手抓一个就用。波特率配置的核心是时间份额TQMCP2515的时间份额计算方式是TQ 2 × (BRP 1) / Fosc其中BRP是波特率预分频值。位时间由SyncSeg、PropSeg、PhaseSeg1、PhaseSeg2四段组成总位时间 总TQ数 × TQ波特率就是总位时间的倒数。以16MHz晶振跑500kbps为例位时间2us。若BRP0则TQ 2 / 16MHz 0.125us总TQ数 16分段余量非常大采样点可以轻松配到75%。但同样500kbps如果换8MHz晶振BRP0时TQ 0.25us总TQ数只有8分段自由度就小很多。要是跑1Mbps8MHz晶振下总TQ数只剩4基本没法用。所以我的习惯是以250kbps和500kbps为主的项目8MHz晶振够用只要涉及1Mbps或更高优先选16MHz晶振。网上常看到一组8MHz晶振跑500kbps的寄存器值CNF10x00、CNF20x90、CNF30x02。这套值在很多例程里能用但我建议量产前还是用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标准帧IDE0 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配置SPIDMA的选型和坑STM32F103做SPI主机时CubeMX配置半天其实就几个关键点。SPI模式选Full-Duplex MasterCPOL0、CPHA0也就是Mode 0。片选不要用硬件NSS用普通GPIO。速度方面F103的APB1最高36MHzSPI2的分频系数选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总线9MHz4路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完全可以作为长期稳定运行的量产方案来用。