简介:面向PIC微控制器开发者的串行外设接口通信程序源码包,专注解决微控制器与各类外设之间的高速同步数据交换问题,适用于传感器采集、显示屏驱动、存储芯片读写等嵌入式场景。资源共1个文件,为C语言源码文件,RAR压缩包仅947B,代码体量非常精简,适合入门者通读和基础驱动的快速移植。目前已有127人学习,可供需要理解底层协议实现或进行二次开发的单片机工程师参考。源码覆盖串行外设接口初始化、四种工作模式切换、时钟频率配置、从设备选择以及读写数据等核心函数,并对时钟线、主出从入线、主入从出线和片选线四条信号线的时序关系作出清晰处理。通过阅读和修改这些代码,读者可掌握主设备发起传输、从设备响应命令的完整流程,也能够直接适配常见串行外设接口存储器件显示模块等外设,从而显著缩短项目开发周期。
1. 一个只有 SPI.c 的压缩包,为什么还要看 PIC 的 MSSP
拿到 SPI.rar 时里面只有一个 SPI.c,很多人会直接把它拖进工程,改好引脚就等着接 W25Q64、OLED 或 NRF24L01,结果读回来的数据不是 0xFF 就是乱跳。SPI 虽然只有 SCK/MOSI/MISO/CS 四根线,但在 PIC 上它藏在一个叫 MSSP 的模块里,初始化顺序、CKP/CKE 组合、主模式时钟分频甚至 SS 引脚的方向都会影响通信结果。这个资源适合正在用 PIC16F88x 或类似型号做传感器采集、Flash 存储、显示屏驱动的工程师,也适合原来用 STM32、现在不得不迁移到 PIC 的人。要真正用好这份代码,先得把 MSSP 相关寄存器读明白,否则 SPI.c 就只是一段“看起来能跑”的模板。
2. PIC 的 MSSP 模块与 SPI 模式配置:先看 CKP/CKE 再写初始化
2.1 SPI 四种模式在 PIC 上的寄存器映射
标准 SPI 协议由 CPOL 和 CPHA 两个位定位出 Mode 0 到 Mode 3。PIC 的 MSSP 没有直接用这两个名字,而是用 SSPCON1 的 CKP 表示时钟极性,用 SSPSTAT 的 CKE 表示数据在哪个边沿变化。很多人移植代码时习惯把 STM32 的 Mode 0/1/2/3 数值原封不动拿过来,这在 PIC 上不成立,因为 CKE 和 STM32 的 CPHA 位定义并不是一一对应。
| 标准模式 | CKP(SCK空闲电平) | CKE(数据变化位置) | 常见外设场景 |
|---|---|---|---|
| Mode 0 | 0,空闲为低 | 1,空闲到有效边沿变化 | W25Q 系列 Flash、SSD1306 屏 |
| Mode 1 | 0,空闲为低 | 0,有效到空闲边沿变化 | 部分 ADC/DAC 芯片 |
| Mode 2 | 1,空闲为高 | 1,空闲到有效边沿变化 | 部分传感器模块 |
| Mode 3 | 1,空闲为高 | 0,有效到空闲边沿变化 | 部分 SD 卡、存储器 |
注意,CKE=1 对应标准 CPHA=0,CKE=0 对应标准 CPHA=1。也就是说你在 STM32 上常用哪套模式,到了 PIC 这边要先对着时序图核对,不能只看数字。像 W25Q64 的手册会写支持 Mode 0 和 Mode 3,但读 JEDEC ID 这种最底层的操作我一般固定用 Mode 0,减少变量。SPI.c 里的SPI_SetMode如果写得太简单,到了 PIC 上很容易漏掉 CKE 这个位。
2.2 SPI_Init 的寄存器顺序:先关 SSPEN,再改 SSPSTAT
初始化 MSSP 的关键不是把寄存器值算出来,而是顺序要对。如果模块还开着就去改 CKP/CKE,SCK 上可能产生一个多余的脉冲,从机会把这个毛刺当成命令起始位,后面整包数据都错位。所以SPI_Init里第一步必须关闭 SSPEN。
#define SPI_CLK_FOSC4 0b0010 #define SPI_CLK_FOSC16 0b0011 #define SPI_CLK_FOSC64 0b0100 void SPI_Init(unsigned char mode, unsigned char prescaler) { SSPEN = 0; /* 1. 关闭MSSP,避免配置中途产生SCK */ SSPCON1 = 0x00; /* 2. 清空SSPCON1,输出保持空闲 */ SSPSTAT = 0x00; /* 3. 清空SSPSTAT,SMP=0、CKE=0 */ switch (mode) /* 4. 按标准SPI模式设置CKP/CKE */ { case 0: SSPCON1bits.CKP = 0; SSPSTATbits.CKE = 1; break; case 1: SSPCON1bits.CKP = 0; SSPSTATbits.CKE = 0; break; case 2: SSPCON1bits.CKP = 1; SSPSTATbits.CKE = 1; break; case 3: SSPCON1bits.CKP = 1; SSPSTATbits.CKE = 0; break; } SSPCON1bits.SSPM = prescaler; /* 5. 选择主模式时钟分频 */ SSPEN = 1; /* 6. 使能MSSP,开始产生SCK */ }这个函数把初始化收敛成一个入口:mode只接受 0~3,对应前面表格里的四种组合;prescaler是 SSPM 字段的值,不是实际分频倍数。经典 PIC16 的 SPI 主模式没有独立的波特率寄存器,直接由 SSPM 选择/4、/16、/64,例如SPI_Init(0, SPI_CLK_FOSC16)在 8MHz 晶振下得到 500kHz SCK。如果你用的 PIC 型号带 SPI 波特率发生器,比如部分 PIC18F 系列,那就改成SSPADD参与计算,常见公式是Fosc / (4 * (SSPADD + 1)),效果等价于这里的分频选择。
SMP 位我在这里设成 0,也就是主机在数据输出中间采样。遇到 MISO 数据建立时间比较长的从设备,再把 SMP 改成 1,采样点会移到数据末尾,通信余量会大一些。这个位很容易被 SPI.c 模板漏掉,默认值也不全一致,建议在外设调试不通时主动试一次。
2.3 常见误区:只改“分频值”不看实际 SCK
很多人把初始化里的0b0011改成0b0010,以为 SCK 翻倍就完事了,忘了 PIC 内部振荡器本身的偏差可能到 ±1%~2%。用内部 OSC 时,OSCCON即使设成 8MHz,实际频率在不同温度下也会有波动,低速 SPI 无所谓,到了 2MHz 以上就可能出现偶发错位。外部晶振则要去烧录配置位里确认FOSC选的是 HS 而不是 LP,LP 模式下晶振可能被当作低频钟表晶振,实际频率只有几十 kHz,SPI 通信照样能跑,只是慢得让人误以为程序卡住。
提示:用 Pickit3 烧录时,在配置位界面里先看一遍 FOSC 和 LVP。很多 PIC 板子默认开启 LVP,会占用 PORTB 相关引脚,如果那片引脚正好被当作 CS 或 MISO,插拔烧录器后通信就变异常。
调试时最常见的手段是拿示波器直接测 SCK 引脚,数一数有没有 8 个脉冲、频率是否接近期望值。PIC16F88x 这类器件引脚复用很多,MSSP 的 SCK/SDI/SDO 可能不是默认位置,如果代码配置没问题但示波器上完全看不到 SCK,优先查 APFCON 或对应型号的复用寄存器。
3. 读写函数与片选策略:SPI.c 里的核心不是读也不是写,是字节交换
3.1 SSPBUF 同时完成发送和接收
SPI 是全双工,主机每发一个字节,从机通过 MISO 同时移出一个字节。所以真正底层的函数只有一个:写SSPBUF启动一次传输,等SSPIF置位后读回SSPBUF。所谓SPI_Read其实就是发一个空字节,用产生的 8 个 SCK 时钟把从机数据移出来。
unsigned char SPI_TransferByte(unsigned char dat) { SSPBUF = dat; /* 写入SSPBUF即启动一次传输 */ while (!PIR1bits.SSPIF) /* 等待移位结束 */ ; PIR1bits.SSPIF = 0; /* 必须软件清除中断标志 */ return SSPBUF; /* 返回MISO收到的数据 */ }逻辑说明:dat是主机准备发送的字节,如果只是读从机数据,通常填 0x00 或 0xFF。SSPIF是 MSSP 的中断标志位,位置随型号可能不同,有的在PIR1,有的在PIR2,移植 SPI.c 时第一件事就是改这个标志位。等待循环里不要加超时处理,调试时用示波器看 SCK 是否在循环里正常输出;如果在等待前忘了清除SSPIF,循环会直接跳过,返回的上一次数据会造成误判。
3.2 硬件片选还是软件片选:PIC 的 SS 引脚经常是噪声源
在 PIC 的 MSSP 中,SS 引脚在从模式下是片选输入,在主模式下可能被用于总线冲突检测。我建议单主机多从机场景全部使用软件片选,也就是把任意空闲 GPIO 当作 CS 来拉低和拉高。原因很简单:有些 PIC 型号在 SS 引脚被外部拉低时会自动切到从模式,SCK 和 MOSI 方向反转,表现为主机“发不出去”,而且这种故障不固定,可能只是某个引脚碰到了排针。
| 片选方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 硬件 SS | 省一个 GPIO,能检测总线冲突 | 引脚固定,模式切换有隐含风险 | 多主总线 |
| 软件片选 | 任意 I/O,时序完全可控,方便支持多设备 | 每包数据要自己控制电平 | 单主多从,最常用 |
这和 Linux SPI 子系统把片选声明成cs-gpios是同一套思路:SPI 控制器管时钟和数据,片选是通信行为,由通用 GPIO 完成。写在 SPI.c 里的SPI_SelectSlave如果只是把某个引脚置低,必须和具体的SPI_TransferByte调用绑定在一起,不能只拉低 CS 不拉高,否则下一个外设会被错误选中。
#define FLASH_CS LATCbits.LATC0 void Flash_Select(void) { FLASH_CS = 0; } void Flash_Release(void) { FLASH_CS = 1; __delay_us(1); /* 保证从机片选释放至少持续1us */ }注意,Flash_Select和Flash_Release之间是一个完整事务,中间不能被打断。很多人习惯在每次SPI_TransferByte前都拉低一次 CS,如果从机对 CS 上升沿敏感,会把一次读操作拆成了好几段无效事务。片选时序的正确做法是:先准备好 D/C 或命令地址,再拉低 CS,连续传输所有字节,最后拉高 CS。
3.3 用 W25Q64 读 ID 把整套驱动跑通
W25Q64 是验证 SPI 驱动最方便的芯片,因为它有固定返回的 JEDEC ID,不需要擦除写入就能确认通信链路。下面是常用命令:
| 命令 | 字节码 | 说明 |
|---|---|---|
| Read JEDEC ID | 0x9F | 连续读出 3 字节 ID |
| Read Data | 0x03 | 3 字节地址后连续读 |
| Write Enable | 0x06 | 写操作前必须发送 |
| Page Program | 0x02 | 一次最多写 256 字节 |
unsigned long W25Q_ReadJEDEC(void) { unsigned char b0, b1, b2; Flash_Select(); SPI_TransferByte(0x9F); /* JEDEC ID 命令 */ b0 = SPI_TransferByte(0xFF); b1 = SPI_TransferByte(0xFF); b2 = SPI_TransferByte(0xFF); Flash_Release(); return ((unsigned long)b0 << 16) | ((unsigned long)b1 << 8) | b2; }逻辑说明:0x9F 后面跟的三个空字节用来产生读时钟,同时 MISO 上会依次移出 Manufacturer ID、Memory Type、Capacity。W25Q64 的返回值是 0xEF4017。如果读到 0xFFFFFF,先看 CS 是否在整个事务期间保持低电平;如果读到 0x000000,查 MISO 是否接对、PIC 的 TRIS 方向是否把 SDI 设置成输入;如果前两个字节对但第三个不对,重点怀疑 SPI 模式不是 Mode 0。这里顺带说明,PIC 的 MSSP 不会自动配置 TRIS 方向,只开模块不设引脚方向是 SPI.c 最常见的使用错误。
4. OLED、NRF24L01 与 Proteus 模拟:外设的“非 SPI”引脚决定了能不能亮
4.1 SSD1306 的 D/C 引脚:命令和数据是两套总线行为
SSD1306 这类 SPI OLED 不是纯 SPI 设备,它多了一个 D/C 引脚,用来区分当前字节是命令还是显示数据。SPI.c 只需要负责把字节发出去,但 D/C 和 CS 的配合是由上层驱动决定的。很多白屏问题就是 D/C 电平在 CS 下降沿还没稳定,从机采样时读到了错误的命令/数据标志。
#define OLED_CS LATCbits.LATC1 #define OLED_DC LATCbits.LATC2 void OLED_WriteCmd(unsigned char cmd) { OLED_DC = 0; /* 命令通道 */ OLED_CS = 0; SPI_TransferByte(cmd); OLED_CS = 1; } void OLED_WriteData(unsigned char dat) { OLED_DC = 1; /* 数据通道 */ OLED_CS = 0; SPI_TransferByte(dat); OLED_CS = 1; }逻辑说明:OLED_DC = 0表示写命令,OLED_DC = 1表示写数据,电平必须在 CS 拉低之前就位。SSD1306 的显存是 1024 字节(128×64 逐位映射),连续写显存时 D/C 保持高,可以一次发送很多字节;但初始化命令序列里,每条命令之后要按手册延时,比如关显示、设置对比度、开显示之间都不能连续猛发。
4.2 NRF24L01 的 CE 和 CSN:SPI 管配置,CE 管发射
NRF24L01 的 SPI 接口叫 CSN 而不是 CS,功能上依然是片选。另一个控制脚 CE 则完全不属于 SPI,它负责把配置好的数据包真正发出去。很多人在 NRF24L01 上反复读寄存器都正常,但发送不出去,就是因为 CE 时序没有配合好。
#define RF_CE LATCbits.LATC3 #define RF_CSN LATCbits.LATC4 void RF_Transmit(unsigned char *buf, unsigned char len) { RF_CE = 0; /* 先让模块回到空闲状态 */ RF_CSN = 0; /* 打开SPI片选 */ SPI_TransferByte(0xA0); /* 写TX_PAYLOAD命令 */ while (len--) { SPI_TransferByte(*buf++); } RF_CSN = 1; /* 关闭片选,SPI事务结束 */ RF_CE = 1; /* 拉高CE,触发发送 */ __delay_us(10); RF_CE = 0; }逻辑说明:CSN 只控制 SPI 总线访问,CE 控制射频状态机。如果 CE 一直拉高,模块可能处于接收模式,写入 TX FIFO 后并不会立刻发送,而是被状态机打断。这里把SPI_TransferByte封在 NRF 驱动外层,SPI.c 本身不需要知道 NRF 的任何协议,这是比较干净的分层方式。
4.3 在 Proteus 里怎么查 SPI 时序
Proteus 里模拟 SPI OLED 或 W25Q64 时,不要只看最终能不能显示数据,要把 SCK、MOSI、CS、D/C 四路信号拉出来看。步骤我一般这样做:
- 在 Proteus 中放置 PIC16F887 和 SSD1306 或 W25Q64 模型,按实际电路连好 SCK、MOSI、MISO、CS、D/C。
- 把 SPI.c 加入源文件,编译生成 HEX 后加载到 PIC 模型。
- 打开菜单里的 Logic Analyser,添加 SCLK、SDO、CS 到观测窗口。
- 全速运行,查看 CS 拉低期间 SCK 是否有 8 的整数倍个脉冲,MOSI 上第一个字节是否等于命令码。
Proteus 对信号时序的模拟比真实硬件更理想化,经常出现“模拟正常,实机不正常”的情况。反过来也一样,模拟器里因为模型简化,可能忽略真实电路的负载电容和引脚上拉需求。看到模拟通过后再到实机上把 SCK 频率降一档,往往是解决不稳定最直接的办法。
5. 把 SPI.c 改造成带自检的驱动:回环测试和示波器判据
5.1 回环测试:MOSI 和 MISO 短接
调试阶段最值得做的事是回环测试:把 MOSI 和 MISO 用一根跳线短接,然后让 PIC 发送一个已知字节,读回来必须完全相同。这个测试能一次性验证 SCK 是否产生、MISO 是否配置成输入、SSPBUF 读写逻辑是否正确。
unsigned char SPI_LoopbackTest(void) { unsigned char tx = 0xA5; unsigned char rx; SPI_Init(0, SPI_CLK_FOSC64); /* 模式0,时钟降到Fosc/64 */ rx = SPI_TransferByte(tx); return (rx == tx); }如果返回 1,说明主机的 MOSI 输出、MISO 输入和 MSSP 模块都正常。返回 0 时不要急着改 SPI.c,先用万用表量 MOSI 和 MISO 是否真的短接。PIC 的 TRIS 方向寄存器不会因为开启 MSSP 而自动配置,SDI 引脚需要手动设为输入,SDO 设为输出,这是最常见的失败原因。
5.2 示波器判据:先看空闲电平,再看第一个跳变沿
把示波器 CH1 接 SCK,CH2 接 MOSI,发送 0x55。0x55 的二进制是 01010101,每一位都反转,可以很清楚地看到数据变化和 SCK 边沿的相对位置。对于 Mode 0,SCK 空闲为低,MOSI 在 SCK 上升沿附近变化,主机在下降沿采样;如果看到 MOSI 在下降沿变化,说明 CKE 设置反了。对于 Mode 2/3,先看 SCK 空闲是不是高电平,再确认数据和边沿的先后关系。
如果一次SPI_TransferByte后 SCK 只有七个脉冲,多半是 SSPEN 在传输过程中被意外关闭,或者中断函数里动了 SSPCON1。使用 PIC16F88x 这类型号时还要检查 APFCON 或重映射寄存器,SCK 可能被映射到非默认引脚,示波器探针夹错位置,代码怎么调都看不到波形。回环测试时在 MOSI 和 MISO 之间串一个 10kΩ 电阻,既防误接短路,也能模拟实际线路的驱动衰减,读到 0xA5 说明从寄存器配置到引脚方向都对了。
本文还有配套的精品资源,点击获取