做嵌入式这些年,但凡听到“板子上的CAN口不够用”,我第一反应基本都是同一个方向:用SPI去外扩CAN控制器。前两天还有个朋友问我,手头MCU只剩一个SPI和几个空闲GPIO,却要接两路CAN,问能不能搞。我说太能了,这不只是能搞,搞完还能留下余量,后面想加到四路、六路,也只是换板子加片选的事。今天就把这套方案从头拆一遍,从硬件选型到CubeMX配置,再到怎么把SPI上的寄存器操作封装成好用的驱动,最后把我在调试中踩过的坑一起摆出来。如果你正在搞STM32F103这类只有单路CAN的MCU,或者想在一块主控下挂多路CAN节点,这篇应该能帮你少走不少弯路。
1. 为什么我推荐用SPI扩展CAN:选题逻辑和方案对比
1.1 什么场景下会用到多路CAN
多路CAN不是没事找事。工控设备里常见的一种场景是运动控制器:一个主控既要跟伺服驱动器走CANopen,又要跟IO扩展模块走私有CAN协议,两边波特率还不一样,一路CAN根本忙不过来。车载测试设备也是重灾区,一个测试盒子往往同时挂在整车的动力CAN、车身CAN和诊断CAN上,物理上就是三路独立总线,不可能共用一条。
还有一种更隐蔽的需求是“协议隔离”。比如设备本身是CANopen主站,但还需要从外部采集一些非标准报文。如果把所有节点扔到一条总线上,不同协议之间互相干扰,排查起来特别痛苦。用多路CAN把不同通信域隔开,各走各的,调试时一抓一个准。
这类需求落到MCU选型上就很尴尬。主流MCU普遍只带1到2路CAN控制器,带3路以上CAN的芯片要么贵,要么采购周期长,要么你手头已经在用的平台根本没那么高配的型号。这时候SPI扩CAN就成了最现实的选择。
1.2 几种扩CAN方案的横向对比
先说结论:没有最优方案,只有最合适的方案。我用了几年以后把常见做法拉了一张对比表。
| 方案 | 实现成本 | 通道扩展能力 | 实时性 | 灵活性 | 适合场景 |
|---|---|---|---|---|---|
| 用MCU内置多路CAN | 硬件简单,芯片选型受限 | 固定,一般最多2路 | 最好 | 差 | 新项目选型阶段就可以规划 |
| SPI外扩独立CAN控制器 | 需要增加芯片和外围电路 | 一条SPI总线挂多个,扩展很灵活 | 受SPI速率影响,但够用 | 好 | 现有板卡改版、多协议隔离、快速出样 |
| 外接USB转CAN模块 | 成本低,接线简单 | 受USB口数量限制,通常1-2路 | 依赖USB协议栈,延迟高 | 一般 | 上位机调试、PC端采集,不适合MCU嵌入式 |
| 用FPGA实现CAN控制器 | 灵活但开发量巨大 | 很强 | 极好 | 极强 | 定制化量产、特殊波特率、极多通道 |
我大部分项目都落在第二行。原因有三个:STM32F103的SPI接口是现成的,CAN控制器芯片如MCP2515成本不高,而且驱动逻辑可以完全由自己做主。第三行看着省事,但让MCU挂USB转CAN模块属于给自己挖坑,USB Host栈跑到单片机里,协议栈开销和稳定性都是一言难尽。
1.3 SPI方案的优势在哪里,又有什么限制
SPI扩CAN最大的好处是“数量自由”。一条SPI总线上可以挂多个MCP2515,每个用一根片选线CS区分。MCU的GPIO一般管够,扩四路CAN一共也就占用SPI的SCK、MOSI、MISO三根线,加四路片选。如果后面发现四路还不够,板子上预留几个电阻位,把新芯片的CS并到新的GPIO上,驱动的改动量很小。
代价也有,最明显的是带宽。SPI外扩CAN的总吞吐量受SPI时钟和协议开销限制。MCP2515的SPI接口最高能跑到10MHz左右,一次报文操作大约要读写好几个帧的寄存器数据。假设波特率是500kbps,一条CAN总线每秒最多约几千帧报文,SPI通信通常在报文层面完全够用。但如果你跑CAN FD,数据段速率高达5Mbps,想靠MCP2515这种老芯片去顶,那确实不现实。这种场景要么换MCP2517FD/2518FD这类支持CAN FD的SPI控制器,要么干脆上更高性能的多CAN芯片。
还有一个限制是延迟。SPI读写本身是主从同步通信,MCU需要主动去轮询或者靠中断信号触发读取,相比硬件CAN控制器通过FIFO和DMA自动收包,CPU占用率会高一些。所以SPI方案适合“多路但并不极端高速”的场景,如果每个通道都是满负载的CAN FD高波特率通信,建议重新考虑架构。
2. 系统架构和底层原理,搞懂SPI和CAN怎么握手
2.1 整体硬件组网方式
先画一遍硬件结构。主控MCU的SPI接口分别连接CAN控制器MCP2515的SCK、SDI、SDO引脚,再用一个GPIO连它的CS片选。MCP2515后面再接一颗CAN收发器,比如MCP2562、TJA1050或者SN65HVD230,收发器再把CAN_H和CAN_L引到接线端子。
多路扩展就是同一组SPI信号线并联挂多个MCP2515,每一个芯片的CS独立接到MCU的不同GPIO。因为SPI从机的SCK、SDI、SDO在没有被片选选中时可以看成高阻状态,所以并联不会互相干扰。收发的数据隔离靠CS完成,这是SPI协议天然支持多从机的原因。
MCP2515还需要一个外部晶振或者时钟源,常见的是20MHz晶振。有些同学图省事想用MCU的MCO引脚直接输出时钟给它,也能工作,但要注意MCU时钟配置不能随意改,改完总线波特率会跟着跑偏。独立晶振反而是最稳的。
中断线值得一提。MCP2515有个INT引脚,当收到报文或者发生错误时会拉低,可以接到MCU的EXTI外部中断引脚,这样就不需要MCU不停轮询。多路CAN时,每片MCP2515可以各自配一个中断引脚,也可以把多个INT引脚直接并到一起,因为中断是电平触发,MCU检测到低电平后读每一路的中断状态寄存器就知道是谁触发的。
2.2 SPI时序、片选策略与DMA配合
MCP2515的SPI接口支持标准SPI模式,我常用的是模式0,0,也就是CPOL=0、CPHA=0。数据在SCK上升沿采样,MSB先发送。这个时序在STM32F103的SPI外设里很好配置,CubeMX里选好模式,波特率分频根据实际时钟算一下就行。
片选使用上一直有硬件片选和软件片选两种做法。硬件NSS由SPI外设自动控制,看起来省心,但在多从机和DMA场景下容易踩坑。NSS信号在不同芯片上触发时机不完全一致,当你用DMA连续搬运数据时,硬件NSS的拉高拉低时机做到精确控制很麻烦。软件片选就是用普通GPIO手动控制CS,传输前拉低,传输结束后拉高,跟SPI外设完全解耦。
| 对比项 | 硬件NSS | 软件GPIO片选 |
|---|---|---|
| 配置复杂度 | 低,复用SPI外设引脚 | 略高,需要手动操作GPIO |
| 多从机支持 | 麻烦,通常只适合单从机 | 非常容易,每个从机一根线 |
| DMA配合 | 容易出半包、竞争问题 | 可控性强,按字节流控制 |
| 调试友好度 | 波形上看不到片选切换细节 | 逻辑分析仪上一目了然 |
| 我的建议 | 简单单从机场景 | 多路CAN、DMA传输一律用软件片选 |
DMA方面,读取CAN控制器内部寄存器这种短数据操作,用轮询就好,没必要上DMA。但对于批量读取接收缓冲区、连续写入发送缓冲区这类超过几十字节的传输,DMA能明显降低CPU占用。STM32F103的SPI配合DMA收发比较常见,CubeMX里把SPI的发送和接收DMA请求都打开,代码里分别准备两个数组,用HAL_SPI_TransmitReceive_DMA触发全双工传输。
2.3 CAN控制器和收发器的分工
很多初学者会把CAN控制器和CAN收发器搞混。CAN控制器负责协议层面的事,比如组帧、解析、CRC校验、仲裁、错误状态管理。CAN收发器负责物理层面的事,把控制器输出的逻辑电平转换成CAN_H和CAN_CAN_L上的差分电平信号。
MCP2515内置了三个发送缓冲区和两个接收缓冲区,还有自己的报文过滤和屏蔽逻辑。这意味着MCU不需要每帧报文都参与处理,符合条件的报文会自动进入接收缓冲区,并通过INT引脚通知MCU来取。发送时MCU把完整报文写到发送缓冲区,然后触发请求发送命令,MCP2515会自动进行位填充、CRC计算、总线仲裁。
CAN收发器选择的坑主要在电压域和速率上。如果是3.3V系统,选SN65HVD230这种3.3V收发器,配合3.3V的MCP2515刚好。如果是5V系统,TJA1050这类经典收发器很皮实。千万别让5V收发器直接接3.3V控制器,电平不兼容会导致通信异常甚至烧引脚。
2.4 终端电阻与地偏移的基本功
CAN总线两端各需要一颗120欧姆终端电阻。注意是“两端各一颗”,不是每个节点一颗。很多人图省事在每个节点都放一个可跳线的120欧姆电阻,结果多条CAN设备并联在一起时,终端电阻并联后等效值远低于60欧姆,信号反射严重,通信距离一大就丢帧。
地偏移是最容易被忽视的问题。CAN总线虽然靠差分信号抗干扰,但每个节点的CAN收发器共模输入范围是有限的。当两个节点之间地电位差过大,差分信号整体被抬高或拉低,收发器就无法正确采样。我见过不少现场问题,总线波特率没问题,终端电阻没问题,但就是偶发错误帧,最后发现是节点间地线接触不良,地偏移超过几伏。
测量地偏移并不复杂。设备都供电但不通信的情况下,用万用表交流档和直流档分别测两个节点地线之间的电压,正常应接近0V,如果直流偏移超过1V或者有较大的交流分量,就需要检查地线连接和隔离方案。条件允许的话,在CAN收发器侧用DC-DC隔离电源加数字隔离器,把节点地彻底隔开,几乎能解决绝大多数地环路问题。
3. 基于CubeMX和STM32F103的完整实现过程
3.1 CubeMX里的SPI和DMA配置
这里以STM32F103C8T6为例,配合一个MCP2515模块。先打开CubeMX,选择芯片型号,在Pinout视图里把SPI1的SCK、MOSI、MISO引脚映射出来。PA5是SCK,PA7是MOSI,PA6是MISO,片选我用PA4,中断用PA3,都设为GPIO_Output和GPIO_EXTI。
SPI1的参数配置如下:Mode选Full-Duplex Master,Hardware NSS Disable,Clock Speed设成系统时钟APB2的二分频或四分频,保证实际SCK在5MHz以内比较稳妥。CPOL和CPHA都选Low和1 Edge,也就是模式0,0。Data Size选8bit,First Bit选MSB First。
DMA配置在DMA Settings选项卡里,把SPI1_TX和SPI1_RX分别添加,方向分别是MemoryToPeripheral和PeripheralToMemory,Mode选Normal。这是比较关键的一步,如果选成Circular模式,DMA会在传输结束后反复传输,导致SPI总线被反复触发。
生成工程前,记得在GPIO设置里把PA3的模式设为External Interrupt Mode with Falling edge trigger detection,PA4设为Output Push Pull,初始电平设High,因为MCP2515的CS是低电平有效。初始化代码建议保留在CubeMX自动生成的MX_GPIO_Init和MX_SPI1_Init中,后续不要手动删改,避免重新生成工程时冲突。
3.2 驱动封装:用SPI寄存器操作MCP2515
MCP2515的寄存器操作完全靠SPI命令完成。常用命令有:RESET(0xC0)、READ(0x03)、WRITE(0x02)、RTS(0x80+缓冲区编号)、READ_STATUS(0xA0)。底层驱动说穿了就是封装好CS拉低、SPI收发一个字节、CS拉高这三件事。
// 底层字节收发:单个字节轮询方式,适合寄存器读写 uint8_t mcp_spi_transfer(uint8_t byte) { uint8_t rx = 0; HAL_SPI_TransmitReceive(&hspi1, &byte, &rx, 1, 100); return rx; } // 读寄存器 uint8_t mcp_read_reg(uint8_t reg) { uint8_t val = 0; MCP_CS_LOW(); mcp_spi_transfer(0x03); // READ 命令 mcp_spi_transfer(reg); // 寄存器地址 val = mcp_spi_transfer(0x00); // 读回数据 MCP_CS_HIGH(); return val; } // 写寄存器 void mcp_write_reg(uint8_t reg, uint8_t val) { MCP_CS_LOW(); mcp_spi_transfer(0x02); // WRITE 命令 mcp_spi_transfer(reg); mcp_spi_transfer(val); MCP_CS_HIGH(); }上面这段是最初级的版本,适合刚起步时验证SPI通路。实际项目里我会在这之上包一层带片选参数和多实例支持的函数,比如把“当前操作的是第几路CAN”作为参数传入。C语言里用结构体保存每个MCP2515的片选GPIO和中断状态,代码结构清晰很多。
初始化流程一般是:上电延时几毫秒,发RESET命令,然后配置CANCTRL寄存器进入配置模式,再写波特率相关的CNF1、CNF2、CNF3寄存器,配置验收过滤寄存器,最后把CANCTRL切回Normal模式。每一步都可以通过读取状态寄存器来确认配置是否生效。
3.3 发送、接收和中断处理
MCP2515发送一帧报文,本质上是把报文的ID、DLC、数据字节以及帧格式信息写入发送缓冲区,然后发RTS命令请求发送。下面这段代码演示了标准数据帧的发送流程:
void mcp_send_std_frame(uint8_t buf_idx, uint16_t id, uint8_t *data, uint8_t len) { uint8_t buf_reg = TXB0CTRL + (buf_idx * 0x10); MCP_CS_LOW(); mcp_spi_transfer(0x40 + (buf_idx * 0x10)); // LOAD TXB0/B1/B2 mcp_spi_transfer((uint8_t)((id >> 3) & 0xFF)); // TXB0SIDH mcp_spi_transfer((uint8_t)((id & 0x07) << 5)); // TXB0SIDL mcp_spi_transfer(0x00); // TXB0EID8 mcp_spi_transfer(0x00); // TXB0EID0 mcp_spi_transfer(0x00); // DLC: 标准帧 + 数据长度 for (int i = 0; i < len; i++) { mcp_spi_transfer(data[i]); // 数据段 } MCP_CS_HIGH(); // 请求发送指定缓冲区 MCP_CS_LOW(); mcp_spi_transfer(0x80 + (1 << buf_idx)); // RTS 命令 MCP_CS_HIGH(); }接收那边我推荐用中断加标志位的方式。INT引脚触发下降沿外部中断后,MCU调用中断服务函数,读取MCP2515的接收状态寄存器,判断哪个接收缓冲区有帧,然后按地址读出ID和数据,置一个“有CAN数据待处理”的软件标志。主循环里看到标志再去解析,避免在中断里做太多耗时操作。
中断服务函数里还有一个容易忽略的动作:读取接收缓冲区之后要发送一个清除接收标志的命令或者读取对应寄存器,否则中断会一直被触发,MCU卡在中断里出不来。第一次调的时候要是发现程序动不动死循环,先怀疑这个。
3.4 从一路CAN扩展到多路CAN
上面所有底层函数都基于MCP2515本身,和“是第几路”没有关系。扩展到多路,只需要在代码里加入一个索引参数,操作不同的片选引脚。
typedef struct { GPIO_TypeDef *cs_port; uint16_t cs_pin; GPIO_TypeDef *int_port; uint16_t int_pin; } mcp_instance_t; // 示例:定义两路CAN mcp_instance_t can_instances[2] = { {GPIOA, GPIO_PIN_4, GPIOA, GPIO_PIN_3}, {GPIOB, GPIO_PIN_0, GPIOB, GPIO_PIN_1}, }; void mcp_select_instance(uint8_t idx) { // 先把所有片选拉高 for (int i = 0; i < 2; i++) { HAL_GPIO_WritePin(can_instances[i].cs_port, can_instances[i].cs_pin, GPIO_PIN_SET); } // 再选中目标 HAL_GPIO_WritePin(can_instances[idx].cs_port, can_instances[idx].cs_pin, GPIO_PIN_RESET); }所有寄存器操作函数前面先调用mcp_select_instance,就能做到一套驱动函数管多路CAN。中断那边同理,可以在每个INT引脚的EXTI回调里设置对应的通道标志,主循环按优先级处理。
这里有个细节:SPI总线上只允许一个从机被选中,所以在写多路驱动时,务必保证所有SPI事务都按照“选中某一路、发完整命令、取消选中”的原子方式执行。如果代码里出现一个函数还没执行完,就去切换片选,总线协议会被打乱。我自己写过一版中断和主循环共用一个SPI的代码,结果两边同时抢总线,后来加了互斥标志才稳定。
4. 调试过程中遇到的典型问题和排查方法
4.1 SPI通信不生效,多半不是协议问题
很多人遇到SPI读写MCP2515没有任何反应,第一反应是SPI协议配置错了。实际调试下来,我发现“读寄存器一直返回0xFF或者0x00”这类问题,根因经常在初始化或者接线层面。先用逻辑分析仪拉一下CS、SCK、MOSI、MISO四根线,看看SPI命令有没有完整发出。只要波形正常,问题基本就锁定在MCP2515没工作。
MCP2515没工作的常见原因包括:供电引脚接错,晶振没起振。用示波器量一下MCP2515的CLKOUT引脚,如果能正常输出时钟,说明芯片内部工作正常。CLKOUT没有输出或者频率不对,大概率晶振问题,检查晶振负载电容是否匹配,以及焊盘有没有虚焊。
还有一种情况是软件片选时序不对。MCP2515要求CS低电平期间完成完整的命令帧,命令结束后CS拉高。有些人用HAL库时,HAL_SPI_TransmitReceive返回之前CS已经拉高,导致命令被截断。解决办法是先拉低CS,完成所有字节传输,确认SPI状态机空闲后再拉高CS。
4.2 CAN节点进bus-off,怎么恢复才安全
bus-off是CAN控制器在发送错误计数器TEC超过255后进入的状态。进入bus-off后,控制器会主动断开总线,这时候这个节点既不发送也不接收,整条总线如果只剩这个节点,线上直接处于空闲状态,其他设备可能还会报错。
想强制恢复bus-off,可以软件给MCP2515发RESET命令,或者通过配置CANCTRL寄存器让芯片重新初始化。但要注意,如果导致bus-off的根因还在,比如终端电阻接触不良、波特率不匹配,恢复之后很快就会再次进入bus-off。恢复之前先排查物理层,不是无脑复位。
还有一种常见的误杀操作:多个节点同时上电,此时总线上还没有稳定的同步,有的控制器会误判错误并进入bus-off。解决办法是软件初始化CAN控制器后延时几十毫秒再开始发送报文,给总线上其他节点足够的同步时间。
4.3 地偏移测试怎么做才可靠
前面提到地偏移问题,这里给一个可复现的测试步骤。先把所有节点正常供电,但不启动CAN通信。用万用表直流电压档测量节点A的地和节点B的地之间的电压,记为Vdc。再用交流毫伏档测量同一个位置,记为Vac。
如果Vdc绝对值大于1V,说明两个节点之间地线压差已经很大,传输距离稍长就会出现收发器共模超限。如果Vac有明显幅值,说明地环路中串入了高频干扰,最常见的是接入了电机驱动、开关电源这类设备。
最直接的解决办法是用隔离CAN收发器,比如ISO1050或者带隔离电源的CAN收发模块。隔离之后两边地完全断开,地偏移问题从物理上消失。现场急修时也可以临时用共地线把各个节点地连起来,但这不是长久之计,共地线路径长一点又会引入新的干扰。
4.4 仲裁导致丢帧的误判
CAN总线仲裁机制是按照报文ID的优先级的。多个节点同时发送时,ID小(显性位优先级高)的报文会赢得仲裁,ID大的节点会自动转为接收状态,下一轮再重发。这个机制本身不会丢帧,但如果你在上位机或者MCU端没有做发送完成确认,很容易误以为帧丢了。
我第一次做多路CAN时,用SPI扩展的两路CAN同时发数据,一路ID是0x100,另一路ID是0x200,逻辑上应该都发出去。调试时发现ID小的那一路总是先出现,ID大的经常“丢”。后来读MCP2515的发送错误计数器和状态寄存器才发现,ID大的那路一直在等待重发,并不是丢了,只是被仲裁延后了。
排查仲裁问题的最佳工具是CAN分析仪。抓一段总线波形,把报文ID和时间戳展开,能看到仲裁和重发的全过程。如果只是简单统计收到的帧数,很容易把仲裁延时当成故障。
4.5 更多排查技巧整理
| 现象 | 优先排查方向 | 可能原因 |
|---|---|---|
| 所有CAN节点都不通 | 总线上是否有终端电阻 | 两端120欧姆未接或断线 |
| 单帧偶发错误 | 地线、屏蔽层 | 地偏移、干扰耦合 |
| 某个节点发送失败 | 该节点的供电和晶振 | 晶振频率偏差、供电纹波大 |
| 程序卡死在中断 | 中断标志未清除 | 读接收缓冲区后未清标志 |
| SPI读写部分寄存器有问题 | 片选时序 | CS拉高过早或过晚 |
| 在多路CAN中某一路不稳定 | 该路CS引脚复用冲突 | 引脚被其他外设占用 |
5. 最后再分享几个我后来觉得特别有用的经验
第一条是关于寄存器的初始化备份。MCP2515的寄存器配置项比较多,移植时会反复改波特率和过滤配置。建议把每个通道的CNF1、CNF2、CNF3和验收过滤寄存器做一份配置结构体,程序启动时统一写入。这样换板子现场调参数时,只需要改一个配置数组,不需要翻代码里散落的魔数。
第二条是DMA和半双工SPI的配合方式。如果你用的是STM32的SPI半双工模式,接收和发送共用一根数据线,DMA的触发逻辑会和全双工不一样,编程复杂度会上升。我这几年做过的SPI扩展CAN方案里,绝大多数都建议用全双工模式,哪怕只用到其中一路收发。半双工节省一个引脚,但对驱动开发要求更高,不建议没有特别理由时去省这个引脚。
最后一条是关于MCP2517FD这类CAN FD控制器的提醒。CAN FD的数据段波特率远高于标称波特率,对SPI读写速度和DMA时序要求更高,不能直接套用MCP2515的老驱动。我自己的习惯是:只要项目里明确要求CAN FD,选型时直接从支持CAN FD的芯片开始,不回头考虑老芯片;如果只是普通CAN,MCP2515经过多年大规模验证,依然是很稳的选择。
回到开头那个问题,一片只有单路CAN的STM32F103,通过SPI挂两片MCP2515,实际跑起来收发都很稳定。硬件上多花几块钱,软件上多写一点驱动,却换来了后续扩展的可能性。这套方案适合快速出样,也适合中低速率的多路CAN隔离场景。如果你正在纠结板子CAN口不够用,不妨按这个思路搭一版试试,调通之后你会觉得,原来多路CAN也没那么复杂。