简介:这是一份基于MCP2515 CAN控制器的51单片机工程源码,面向嵌入式硬件学习者与单片机开发者,用于实现CAN总线通信测试与中继转发功能。工程采用Keil C环境编写,硬件平台为AT89S51/52外接MCP2515,功能为通过CAN总线接收八个字节数据后原样转发,相当于一个简易CAN中继器;代码被清晰拆分为2515初始化、CAN查询发送、CAN中断接收等模块,可帮助读者理解MCP2515寄存器配置、报文收发流程和中断机制。压缩包共24个文件,体积仅59KB,包括C源文件、功能头文件、Keil工程文件、编译生成的HEX烧录文件、列表映射文件与备份文件,工程目录完整,既方便阅读源码,也可直接编译烧录到开发板验证。目前已有372人学习下载。对于刚接触CAN总线或51单片机外扩CAN模块的开发者,这套源码是一份可参考的完整示例框架,既能查看主函数及硬件初始化过程,也能对比查询发送与中断接收两种方式,有效缩短MCP2515的调试周期。
1. 基于MCP2515 CAN控制器的51单片机程序源码到底解决什么问题
很多51项目做到中途才发现,自己选型时没有CAN模块。换带CAN控制器的型号,开发工具链和板级改动都很大,不如在现有51上外挂一颗SPI接口的MCP2515。这个标题下的源码,本质上是把“51单片机如何通过SPI驱动MCP2515、收发标准CAN帧、配置滤波和波特率”这一套样板代码打包在一起。它解决的具体问题,是让老平台快速接入CAN总线,用在车载仪表、工业传感器、小型数据采集终端这些场景。适合刚开始接触CAN的嵌入式工程师,也适合需要把51产品接入已有CAN网络的人。拿到源码先别急着改业务逻辑,先把SPI读写跑通,后面才谈得上报文收发。
2. MCP2515硬件连接与SPI时序:51单片机怎么把数据送进CAN控制器
2.1 MCP2515引脚与51单片机的最小接法
MCP2515是一颗独立CAN控制器,单片机不直接操作TXCAN和RXCAN引脚,而是通过SPI接口读写它内部寄存器。这种方案的优点是CAN物理层和链路层由控制器处理,CRC、填充位、仲裁都不占用51的CPU时间。缺点是SPI驱动和寄存器初始化必须自己写,时序错了整条链路都不通。
最小系统里,MCP2515与51单片机的引脚接法是这样:
| MCP2515引脚 | 51单片机引脚 | 作用 |
|---|---|---|
| CS | P1.4 或任意IO | SPI片选,低有效 |
| SCK | P1.5 | SPI时钟 |
| SI | P1.6 | 主出从入(MOSI) |
| SO | P1.7 | 主入从出(MISO) |
| INT | P3.2(INT0) | 中断输出,低有效,需要上拉 |
| RESET | P3.3 或 RC 复位 | 硬件复位,低有效 |
| OSC1 / OSC2 | 8MHz晶振 | 晶振引脚 |
| TXCAN | CAN收发器(如TJA1050) | 发送差分信号 |
| RXCAN | CAN收发器(如TJA1050) | 接收差分信号 |
INT引脚是漏极开路结构,正常运行时内部不会拉高,必须在外部加一个4.7k到10k的上拉电阻到VCC。否则51的INT0检测不到下降沿,中断模式没法用。晶振也特别重要,MCP2515对晶振频率有要求,常见的是8MHz和16MHz,程序里的波特率寄存器必须和晶振频率对应,不能只看波特率数值。
2.2 SPI读写时序与初始化命令示例
51没有硬件SPI时,我一般直接用普通IO口模拟。MCP2515支持SPI模式0(CPOL=0且CPHA=0),也就是SCLK空闲为低,数据在上升沿采样。下面是一段完整的模拟SPI读写函数,也包含两个最常用的寄存器操作函数。
sbit MCP_CS = P1^4; sbit MCP_SCK = P1^5; sbit MCP_SI = P1^6; sbit MCP_SO = P1^7; unsigned char MCP_SPI_RW(unsigned char dat) { unsigned char i, recv = 0; for (i = 0; i < 8; i++) { MCP_SCK = 0; if (dat & 0x80) MCP_SI = 1; else MCP_SI = 0; dat <<= 1; MCP_SCK = 1; _nop_(); recv <<= 1; if (MCP_SO) recv |= 0x01; } MCP_SCK = 0; return recv; } void MCP_WriteReg(unsigned char addr, unsigned char val) { MCP_CS = 0; MCP_SPI_RW(0x02); // 写寄存器指令 MCP_SPI_RW(addr); // 寄存器地址 MCP_SPI_RW(val); // 要写入的数据 MCP_CS = 1; } unsigned char MCP_ReadReg(unsigned char addr) { unsigned char val; MCP_CS = 0; MCP_SPI_RW(0x03); // 读寄存器指令 MCP_SPI_RW(addr); // 寄存器地址 val = MCP_SPI_RW(0xFF); // 发送空字节,同时读取数据 MCP_CS = 1; return val; }这里要解释几个关键点。MCP_SPI_RW在每个时钟周期完成一个bit的发送和接收,发送时取dat的最高位放到SI,接收时把SO的电平放进recv的最低位,8个周期后完成一次字节交换。MSP2515要求MSB先行,所以数据左移发送。MCP_WriteReg的CS必须在整个事务过程中保持低,写完第三个字节后再拉高,不能在一个字节之间翻转CS。MCP_ReadReg发完地址后需要有一个假的写字节来产生时钟,返回值才是目标寄存器的内容。如果读出来的数据全是0xFF,先检查CS极性,再检查SCK空闲电平,这两个是最常见的SPI通信失败原因。
2.3 晶振与复位时序对初始化的影响
MCP2515上电后要先复位,再等待内部振荡器稳定。外部8MHz晶振的启动时间通常在1到2ms,软件里至少延时5ms再开始第一次读寄存器。如果RESET脚由单片机控制,可以拉低2个时钟周期再拉高。另一个容易忽略的点是,MCP2515复位后默认进入配置模式,这是好事,因为波特率和滤波器本来就只能在配置模式下修改。程序里如果一上来就向CKCTRL等运行模式寄存器写值,可能没有任何效果,必须先确认当前的模式。初始化建议按这个顺序:复位,延时,写CANCTRL进入配置模式,写CNF1/CNF2/CNF3和滤波器,最后写CANCTRL退出配置模式并确认CANSTAT已经变化。
3. CAN报文封装与解析:从标准帧到扩展帧的寄存器布局
3.1 收发缓冲区里的报文存储结构
MCP2515的发送缓冲区有TXB0、TXB1、TXB2三个,接收缓冲区有RXB0、RXB1两个。每个缓冲区对应一组连续寄存器,包括SIDH、SIDL、EID8、EID0、DLC、D0到D7。标准帧只用11位ID,扩展帧用29位ID,两者在缓冲区里的排布方式不一样。
标准帧ID为11位,低3位放在SIDL的bit7到bit5,高8位放在SIDH。扩展帧则要把29位拆成三组放,读取时需要做移位拼接。实际项目中大部分简单CAN协议都用标准帧,只有在接CANopen设备或某些J1939设备时才需要处理扩展帧。我建议源码里把标准帧和扩展帧的解析分成两个函数,不要混在一个函数里到处判断,否则后面改ID格式容易出错。
3.2 写发送缓冲区并请求发送
发送一个标准帧,要先把ID和数据写入TXB0对应寄存器,再置发送请求位。下面的函数选中TXB0,封装了标准帧发送过程。
void CAN_SendStd(unsigned int id, unsigned char len, unsigned char *data) { unsigned char i; MCP_WriteReg(0x31, 0x00); // TXB0CTRL,先清发送请求位 MCP_WriteReg(0x33, (unsigned char)(id >> 3)); // TXB0SIDH MCP_WriteReg(0x34, (unsigned char)((id & 0x07) << 5)); // TXB0SIDL MCP_WriteReg(0x35, len & 0x0F); // TXB0DLC for (i = 0; i < len; i++) MCP_WriteReg(0x36 + i, data[i]); // TXB0D0开始 MCP_WriteReg(0x31, 0x08); // 置TXREQ=1 }这段代码的关键是ID拆分。TXB0SIDH地址0x33,存放11位ID的高8位,所以id >> 3。TXB0SIDL地址0x34,低3位ID需要左移5位,放到bit7到bit5位置。比如ID=0x100,高8位是0x20,低3位是0,写出来就是SIDH=0x20,SIDL=0x00。DLC寄存器只需要低4位有效,len最多8,所以直接按位与0x0F。数据从地址0x36开始连续排列,用循环写入。
发送请求位TXREQ是TXB0CTRL的bit3,置1后MCP2515会尝试把报文发到总线。发送成功后硬件自动把TXREQ清0,同时置位CANINTF里的TX0IF。读发送状态建议优先看TXIF而不是TXREQ,因为仲裁失败时TXREQ可能一直保持1,看起来像是在发送中,实际报文并没有发出去。
3.3 读取接收缓冲区并处理ID
接收侧代码要注意缓冲区被读走后,接收标志必须手动清除。下面是一个读取RXB0标准帧的函数。
void CAN_ReadRx0(unsigned int *id, unsigned char *len, unsigned char *data) { unsigned char sidl; unsigned char i; sidl = MCP_ReadReg(0x62); // RXB0SIDL *id = ((unsigned int)MCP_ReadReg(0x61) << 3) | (sidl >> 5); // 组合11位ID *len = MCP_ReadReg(0x65) & 0x0F; // RXB0DLC for (i = 0; i < *len; i++) data[i] = MCP_ReadReg(0x66 + i); // RXB0D0开始 }这里RXB0SIDH地址是0x61,左移3位得到ID高8位在最终ID里的位置;RXB0SIDL低3位ID位于bit7到bit5,右移5位取回来。读完之后不能马上认为数据已经属于用户,还需要把CANINTF中的RX0IF写1清零,否则下一个报文进来时接收缓冲区可能不再更新。MCP2515的接收缓冲区有深度只有一条,如果中断处理太慢,新报文会把旧的覆盖掉,这在总线负载高的场景下需要靠双缓冲和快速读走解决。
4. 过滤与屏蔽寄存器配置:让51单片机只收想要的CAN报文
4.1 RXF0与RXN0的工作机制
CAN总线是广播式通信,所有挂在总线上的节点都会接收到总线上传递的每一个报文。节点如果不做硬件过滤,51就得在中断或轮询里一条一条判断ID,局促的RAM和CPU都被浪费掉。MCP2515的过滤机制由接收滤波寄存器RXF和屏蔽寄存器RXN组成。RXF决定“接收哪种ID”,RXN决定“接收帧中哪些位必须与滤波寄存器一致”。
MCP2515有6个滤波器RXF0到RXF5,和2个屏蔽器RXN0与RXN1。默认关系是RXF0、RXF1对应RXN0,RXF2到RXF5对应RXN1。掩码位为0表示这一位不参与比较,为1表示这一位必须与滤波器匹配。把掩码全部写0就能接收所有报文,这在调试时很有用。真正产品中一般建议只开放RXF0和RXN0,过滤逻辑最清晰,匹配性能也最稳定。
4.2 计算掩码并写入过滤器
假设某个应用只希望接收ID在0x100到0x103之间的标准帧,也就是ID低2位允许变化,高9位必须匹配。11位ID中变化的位置是bit1和bit0,所以过滤器基础ID写0x100,掩码需要匹配bit10到bit2,忽略bit1和bit0。
按照MCP2515的寄存器位域,RXF0SIDH和RXN0SIDH处理ID的高8位,RXF0SIDL和RXN0SIDL处理ID的低3位。代码如下:
void CAN_FilterConfig(void) { MCP_WriteReg(0x0F, 0x80); // CANCTRL,进入配置模式 MCP_WriteReg(0x00, 0x20); // RXF0SIDH,ID[10:3]=0x20 MCP_WriteReg(0x01, 0x00); // RXF0SIDL,ID[2:0]=0 MCP_WriteReg(0x24, 0xFF); // RXN0SIDH,高8位必须匹配 MCP_WriteReg(0x25, 0x80); // RXN0SIDL,只匹配ID2,忽略ID1和ID0 MCP_WriteReg(0x0F, 0x00); // 退出配置模式,进入正常模式 }这里的掩码0x80表示ID2必须匹配,ID1和ID0任意,所以能够接收0x100、0x101、0x102、0x103。如果掩码写成0xE0,位2、1、0都匹配,那就只能接收0x100,范围大大缩小。需要强调的是,配置滤波器前必须先进入配置模式。MCP2515只有在配置模式下才允许写RXF和RXN寄存器,如果直接在正常模式下写,寄存器值不会被更新,但程序不会报错,这是最隐蔽的坑。
| 配置对象 | 寄存器地址 | 写入值 | 作用 |
|---|---|---|---|
| RXF0SIDH | 0x00 | 0x20 | 滤波器ID高位 |
| RXF0SIDL | 0x01 | 0x00 | 滤波器ID低3位 |
| RXN0SIDH | 0x24 | 0xFF | 高位屏蔽,全比较 |
| RXN0SIDL | 0x25 | 0x80 | 只比较ID[2] |
4.3 过滤匹配失败的常见表现
如果过滤器设置有问题,最直接的表现就是总线上明明有报文,但51的接收标志始终不置位。排查顺序是这样:先把RXN0SIDH和RXN0SIDL都写成0,也就是不过滤任何位,看是否能收到报文;能收到再慢慢收紧掩码,每次只改一个区域。如果此时仍然收不到,问题就不在滤波,而在接收中断使能或者缓冲区读走逻辑上。还有一种情况是滤波器已经匹配,但报文进了RXB1,而代码只查RXB0,此时可以用RXB0CTRL里的BUKT位把RXB1重定向到RXB0,或者中断里同时处理RXB1。
5. 51单片机低资源下的处理策略:轮询与中断如何挑
5.1 INT引脚和外部中断0的配合
MCP2515的INT引脚是漏极开路,外部必须接上拉电阻。任何接收、发送完成或错误事件发生时,INT会被拉低,51的外部中断0检测下降沿后进入ISR。相比轮询方式,中断模式对51的实时性更友好。但代价是ISR里必须快速处理完数据并清除标志,否则INT会一直保持低电平,退出ISR后被反复触发,CPU根本干不了别的活。
外部中断0配置为下降沿触发,MCP2515内部的中断使能位也要同步打开。我一般只使能接收中断,发送完成用轮询标志位,这样ISR做的事少,逻辑更清晰。如果同时使能发送中断,需要注意在发送函数里可能多次修改TXIF,稍不留神会丢掉一次完成状态,调试难度反而更大。
5.2 一个可以直接用的中断处理函数示例
下面的初始化代码和ISR组合适用于使用INT0接收报文的场景。
void MCP_Init(void) { MCP_WriteReg(0x0F, 0x80); // 进入配置模式 // 这里可以加入CNF1/CNF2/CNF3和滤波器配置 MCP_WriteReg(0x0F, 0x00); // 正常模式 MCP_WriteReg(0x2C, 0x03); // CANINTE,只使能RX0IF和RX1IF IT0 = 1; // 51外部中断0下降沿触发 EX0 = 1; // 使能外部中断0 EA = 1; // 打开总中断 } void ext0_isr(void) interrupt 0 { unsigned char intf; unsigned int rx_id; unsigned char rx_len; unsigned char rx_data[8]; intf = MCP_ReadReg(0x2D); // 读CANINTF if (intf & 0x01) // RX0IF CAN_ReadRx0(&rx_id, &rx_len, rx_data); if (intf & 0x02) // RX1IF CAN_ReadRx1(&rx_id, &rx_len, rx_data); MCP_WriteReg(0x2D, intf); // 写1清零已处理标志 }CANINTF地址是0x2D,清除方式是写1清零,这一点和很多51片内中断正好相反。ISR里读取CANINTF后可以一次性处理多个标志,最后把读到的intf原样写回,已经置位的位被清掉;需要注意不要在清除前覆盖其他未处理的标志位。数组rx_data声明在ISR里会多占8字节的栈空间,51默认栈很小,如果编译时报栈溢出,可以把rx_data改成全局数组。
5.3 波特率寄存器与SJW同步跳跃宽度的选择
波特率配置在CNF1、CNF2、CNF3三个寄存器里。MCP2515的位时间由同步段、传播段、相位缓冲段1和相位缓冲段2组成,每个段以TQ为单位。下面给出8MHz晶振下常用波特率的分段参数,BRP和SJW都是寄存器原始值。
| 波特率 | BRP | SJW | PRSEG | PHSEG1 | PHSEG2 | 位时间TQ数 |
|---|---|---|---|---|---|---|
| 500kbps | 0 | 1 | 3 | 2 | 2 | 8 |
| 250kbps | 0 | 1 | 7 | 4 | 4 | 16 |
| 125kbps | 1 | 1 | 5 | 5 | 5 | 16 |
以500kbps为例,8MHz晶振下BRP=0,每一个TQ为250ns,位时间共8个TQ,正好是2微秒,换算出来就是500kbps。SJW表示同步跳跃宽度,节点在总线电平跳过阈值点时,可以在这个范围内调整采样位置。SJW太小,抗干扰能力弱;SJW太大,采样点可能被拉过头,造成采样错误。短距离车内部署一般SJW=1就够,长线缆或重载总线可以设到2。另外还要检查两个节点的波特率参数是否完全一致,光波特率相同但采样点不同,在线上信号劣化时会出现偶发错误帧。
轮询和中断的选择没有绝对标准。51主频低,如果要同时处理很大的显示或控制任务,中断几乎是唯一选择。但中断处理又要保证在下一帧报文到来之前完成,通常要求在数百微秒内读完数据。如果任务特别重大,可以采用轮询加时间片的方式,每5ms查一次INT引脚,适合总线负载不高、丢一个帧也无所谓的应用。
6. 用Proteus仿真快速验证MCP2515程序收发的一个技巧
6.1 搭建仿真环境的最小配置
Proteus里可以直接放置MCP2515和51单片机,不需要真的有CAN收发器模型,因为MCP2515自带回环模式。连接时保持CS、SCK、SI、SO、INT和晶振与代码一致。晶振频率我建议直接设成8MHz,这样代码不用改。仿真运行前先确认MCP2515的电源脚和地脚已经接好,有些版本的Proteus模型漏了VSS,读写寄存器会一直失败。
6.2 使用回环模式自测
MCP2515的CANCTRL寄存器可以配置REQOP为回环模式,发送的报文不会走TXCAN,而是直接进入自己的接收缓冲区。先把程序初始化到回环模式,再调用一次发送函数,然后等接收中断标志。如果标志能置位,说明SPI读写、寄存器配置、发送缓冲区和接收缓冲区整条链路都没有问题。
// 进入回环模式:REQOP=011,写入CANCTRL MCP_WriteReg(0x0F, 0x60); // 发送一帧测试数据 CAN_SendStd(0x100, 4, test_data); // 等待RX0IF或读取CANINTF判断是否收到回环模式的好处是不需要外部第二个节点,也能观察MCP2515自己是否产生了接收中断。等回环通了,再把CANCTRL改成0x00正常模式,接入真实的CAN收发器验证网络通信。仿真中如果发现发送后一直没有接收标志,大多数问题在波特率寄存器配置,而不是SPI时序。
6.3 一个验证技巧:发送完成后正确判断TXIF
很多人在发送函数里用TXREQ位来判断发送完成,但TXREQ在总线繁忙时会保持置位,仲裁失败时也可能不立刻清为0,导致误判。我建议用发送中断标志TXIF,并配合超时循环。下面的函数把发送状态封装成带超时的接口,调试时可以打印返回结果检查发送是否真正完成。
bit CAN_SendTimeout(unsigned int id, unsigned char len, unsigned char *data) { unsigned int t; MCP_WriteReg(0x2D, 0x04); // 清TX0IF CAN_SendStd(id, len, data); for (t = 0; t < 0xFFFF; t++) { if (MCP_ReadReg(0x2D) & 0x04) // TX0IF置位 return 1; } return 0; }这个技巧特别适合调试总线仲裁和应用层发送节奏。Proteus仿真中,如果回环模式下发送后TX0IF没有在若干循环里置位,优先检查是否忘记清除上一次的TX0IF,或者MCP2515是否已经退出配置模式。回环模式验证通过后再测真实总线,通常只用检查收发器接线和终端电阻就能跑通。
本文还有配套的精品资源,点击获取