
简介这是一份面向PIC系列微控制器的SPI总线通信C语言源码适合嵌入式初学者以及需要快速开发外设驱动的工程师参考。压缩包中只包含一个c源文件整体大小约947字节代码量小便于逐行阅读和直接集成到项目。源码实现了SPI接口的初始化、四种工作模式选择、时钟频率设置、从设备片选控制以及数据读写等核心功能函数模块划分清楚配合芯片数据手册可以快速理解寄存器配置和通信时序。SPI总线普遍用于连接Flash存储器、温湿度传感器、OLED显示屏等外部器件通过这份代码可以掌握主从设备之间的数据交换流程并能根据实际外设调整时钟极性和相位参数。当前已有127人学习作为PIC平台下的SPI编程入门示例具有不错的参考价值。1. 从 SPI.rar 到 PICKit 3.5PIC 上的 SPI 为什么总让人从头写一遍我见过不少工程师拿到一个 SPI.rar 压缩包里面是某个 PIC 项目的 spi.c解压出来直接往自己板子上套结果波形永远不对。PIC 的 SPI 和 STM32 不同没有现成的寄存器库可以抄MSSP 模块的 CKP、CKE 和 SMP 一旦配错从设备要么不响应要么读出全 0xFF。这个包通常只是线索真正要解决的是PIC 的 SPI 主模式如何初始化、发送接收为什么是同一个 SSPBUF、软件模拟 SPI 又要放在什么场景。无论你是刚转过来做 PIC还是在维护带 OLED、Flash、RF 模块的老产品只要调过一次 SPI 的波形下面这些坑基本都会遇到。这篇文章会从 MSSP 寄存器映射讲到逻辑分析仪抓时序中间给出可直接写入 MPLAB X IDE 的 XC8 代码。2. PIC 的 MSSP 模块SPI 主从模式、CKP 和 CKE 的寄存器搭配2.1 主从模式并不只是拉高片选SSPCON 和 SSPSTAT 的分工PIC16 系列的 MSSP 模块同时承担 SPI 和 I2C 两种协议这决定了配置时不能只看某一个寄存器。以 PIC16F877A 为例SPI 功能通过SSPCON的SSPM3:SSPM0位选择SSPM0b0000是 SPI 主模式时钟为Fosc/40b0001是Fosc/160b0010是Fosc/640b0011的时钟源来自 Timer2 输出。如果只是把SSPEN置 1 但没选SSPM值MSSP 不会进入 SPI 模式。SSPSTAT里的CKE位决定数据在 SCK 的哪个边沿跳变SMP位决定主模式采样时间一般固定为 1。很多移植失败的代码只改了CKP忘了同时配CKE结果主设备在上升沿发数据从设备也在同一个沿采样数据自然对不上。2.2 四种 SPI 模式和 CKP、CKE 的对应关系MSSP 数据手册里常用的了一种映射将 SPI Mode 0 到 3 映射到CKP和CKESPI 模式CKPCKESCK 空闲电平从设备采样沿Mode 001低上升沿Mode 100低下降沿Mode 211高下降沿Mode 310高上升沿这里的CKE是反相于通常意义上的 CPHA。如果你原来在 STM32 的 HAL 库中配置SPI_PHASE_1EDGE对应 Mode 0那么换到 PIC 上要把CKE置 1而不是置 0。W25Q64 这类 Flash 一般支持 Mode 0 和 Mode 3而 SSD1306 OLED 驱动更常见使用 Mode 0也就是CKP0, CKE1。2.3 配置主模式前必须处理的数字引脚和复用SPI 引脚在 PIC16F877A 上是SCKRC3、SDIRC4、SDORC5。RC4 是 T1OSO 的复用引脚RC5 是 CCP1 的复用引脚但这些复用不影响 SPI。真正需要先处理的是 RA 口上的模拟通道因为很多 PIC 芯片默认将 PORTA 的部分引脚作为模拟输入而 RA7 或 RE0 如果被复用对比较器也可能会干扰时钟输出。稳妥做法是先写ADCON1 0x06把 PORTA 和 PORTE 全部设为数字 I/O再配置引脚方向。把TRISC5清零后SDO 才会有输出TRISC4必须置 1否则外部从设备的数据拉不进来。SSPEN要在SSPM位设置之后置位寄存器初始化顺序错了模块会跑到未知状态。3. 用 XC8 写 PIC 的 SPI 驱动初始化、发送和接收的最小可跑代码3.1 SPI 主模式初始化从寄存器值到一行 XC8 代码下面这段代码可以在 MPLAB X IDE XC8 编译器上直接使用目标芯片为 PIC16F877A假设系统时钟为 8MHz 的晶体。#include xc.h void SPI_Master_Init(void) { TRISC3 0; // SCK 输出 TRISC4 1; // SDI 输入 TRISC5 0; // SDO 输出 ADCON1 0x06; // 关闭 A/D 模拟引脚全部作数字 SSPSTATbits.SMP 1; // 主模式输入数据在采样阶段读取 SSPSTATbits.CKE 1; // SPI Mode 0: CKP0, CKE1 SSPCONbits.CKP 0; // SCK 空闲为低 SSPCONbits.SSPM 0b0000; // SPI 主模式时钟 Fosc/4 SSPCONbits.SSPEN 1; // 使能 MSSP 模块 PIR1bits.SSPIF 0; // 清中断标志 }ADCON1 0x06这行容易被忽略但它在某些 PIC 的 bootloader 代码里能避免 RA 口模拟输入导致的上拉电流建议保留。SSPCONbits.SSPM 0b0000在 20MHz 系统时钟下会得到 5Mbps 的 SPI 时钟这已经比较快。如果从设备是 NRF24L01建议改用0b0001也就是Fosc/16否则芯片端容易丢数据。3.2 SPI_Transfer一个字节能发送也能接收主模式下写入SSPBUF会启动一次数据传输传输结束后SSPIF被置位同时接收到的数据进入SSPBUF。这里要注意你写入的字节会被移位寄存器发送出去等 SCK 走完 8 个边沿后SSPBUF 里已经是被从设备返回的新字节而不是你写进去的值。unsigned char SPI_Transfer(unsigned char data) { SSPBUF data; // 启动发送 while (!PIR1bits.SSPIF); // 等待完成 PIR1bits.SSPIF 0; // 清标志 return SSPBUF; // 取回从设备数据 }while (!PIR1bits.SSPIF)的写法比while (!SSPSTATbits.BF)更可靠因为 BF 在读取 SSPBUF 时会被硬件自动清除。如果代码里先读SSPBUF再清SSPIF标志位可能被错误清除导致下一次传输还没结束就返回。所以我的习惯是完事后先清标志再读数据。3.3 软件片选和多从设备挂载的时序边界PIC 的 MSSP 模块没有提供硬件片选输出主模式下的\SS引脚通常不是必须的。挂多个从设备时所有从设备的SCK、MOSI、MISO并联CS分别接到 PIC 的普通 GPIO。操作代码动作时序注意拉低选中CS1 0;必须在写入 SSPBUF 前完成最好有 10us 以上延迟写命令SPI_Transfer(0x90);一个字节完成后 SSPIF 置位释放片选CS1 1;必须等最后一个字节的 SSPIF 置位后再拉高有些开发者把CS放在SPI_Transfer函数内部拉低这在单从设备时问题不大但多从设备如果穿插读取另一个从设备就很容易造成片选打架。我一般会让片选操作独立成宏保证 SPI 总线的字节流严格配对。4. 什么时候用软件模拟 SPI从 GPIO 位操作到 Proteus 调 OLED4.1 软件模拟 SPI 的适用场景与引脚自由度的取舍硬件 MSSP 模块不是每个引脚都能映射到 SPIPIC16 系列往往只能在固定引脚上复用。如果板子走线已经定死或者三个 SPI 引脚被 I2C、USART 占满就需要用普通 GPIO 模拟 SPI。另一个场景是 SPI 时钟太快导致从设备跟不上比如某些 OLED 模块在长线上跑 2MHz 就会花屏这时把软件模拟的最高速度限制在 500kHz 以下反而更稳。软件模拟 SPI 的代价是 CPU 占满一个字节的每一位中间不能被打断。如果开了高优先级中断进入中断后会打乱 SCLK 的边沿时序这时候要么关中断要么老老实实用硬件 MSSP。4.2 一个可运行的软件 SPI Master 代码下面代码用 PIC16F877A 的普通引脚模拟模式 0MSB 在前#define SPI_SCK RC3 #define SPI_MOSI RC5 #define SPI_MISO RC4 #define SPI_CS RA0 void SW_SPI_Init(void) { TRISC3 0; TRISC5 0; TRISC4 1; TRISA0 0; SPI_SCK 0; SPI_CS 1; } void SW_SPI_Start(void) { SPI_CS 0; __asm __nop __endasm; // 至少一个 NOP等从设备 CS 稳定 } void SW_SPI_Stop(void) { SPI_CS 1; } unsigned char SW_SPI_Transfer(unsigned char byte) { unsigned char i; unsigned char rx 0; for (i 0; i 8; i) { SPI_SCK 0; SPI_MOSI (byte 0x80) ? 1 : 0; byte 1; __asm __nop __endasm; SPI_SCK 1; __asm __nop __endasm; rx 1; if (SPI_MISO) rx | 1; } SPI_SCK 0; return rx; }这个函数在SPI_SCK 0时设置MOSI然后拉高SCK正好符合模式 0 的数据在上升沿采样要求。__asm __nop __endasm的次数决定了最大速率每个 NOP 在 8MHz 主频下大约是 0.5us我一般放一个 NOP实际速率在 1MHz 以下。如果要更慢就在翻转前加几个空循环。4.3 用软件 SPI 驱动 OLED并把 Proteus 模拟跑通SSD1306 这类 OLED 只需要写命令和写数据两个函数本质上都是SW_SPI_Transfer加片选控制。初始化时先发送命令序列其中开显示前的延迟用__delay_ms控制。#define OLED_CMD 0x00 #define OLED_DATA 0x40 void OLED_Write_Cmd(unsigned char cmd) { SW_SPI_Start(); SW_SPI_Transfer(OLED_CMD); // 第一个字节是命令头 SW_SPI_Transfer(cmd); SW_SPI_Stop(); }在 Proteus 里模拟这个代码时最常遇到的问题是看不到任何波形。Proteus 运行时频率很高逻辑分析探针反应不过来。我一般会在仿真 speed 设置里把主时钟降成 1MHz或者直接在SW_SPI_Transfer的SPI_SCK 1后面加__delay_us(1)。如果加了__delay_us后还是全白屏就检查 Proteus 中 OLED 模块的片选是否接到了 RA0而不是接在 RC 口上。5. 用 PICKit 3.5 配合逻辑分析仪验证 SPI一个读取 Flash ID 的收尾技巧5.1 抓波形前先把 PICKit 3.5 的供电和地处理好用 PICKit 3.5 调试 PIC 时如果目标板外部供电最好把 PICKit 的地线和目标板共地否则逻辑分析仪看到的 SCLK 会有毛刺。把逻辑分析仪的三个通道分别接在 RC3、RC5、RA0设置下降沿触发至少抓 8 个 SCK 周期。触发点选片选下拉最方便因为整个字节流会紧跟在片选之后。5.2 用 W25Q64 的 JEDEC ID 验证主模式是否正确W25Q64 支持0x9F指令发送后连续读 3 个字节返回厂商 ID 和设备 ID。把 MISO 接到逻辑分析仪之后跑下面这段测试void Read_Jedec_ID(void) { CS 0; SPI_Transfer(0x9F); id0 SPI_Transfer(0x00); // 厂商 ID应为 0xEF id1 SPI_Transfer(0x00); // 内存类型W25Q64 为 0x40 id2 SPI_Transfer(0x00); // 容量W25Q64 为 0x17 CS 1; }如果id0读出来不是0xEF先把 SCK 引脚上的示波器探头移开再检查 SPI 模式。PIC16F877A 的 MSSP 默认发送高位在前W25Q64 也支持高位在前所以问题通常出在CKE位。也可以把所有SPI_Transfer换成SW_SPI_Transfer在同一个引脚上跑软件模拟对比两次读回来的数据。如果软件模拟能读对而硬件 MSSP 读不对那就要检查SSPCONbits.SSPM的时钟分频值是否超过了从设备支持的最大速率。本文还有配套的精品资源点击获取