SPI协议
1. 本节介绍
📝本节您将学习如何通过开发板使用SPI协议
🏆学习目标
1️⃣ 了解SPI协议的原理和注意事项
2️⃣ 通过SPI设备 W25Q64存储器模块
,了解SPI协议的具体通信流程
2. SPI协议介绍
SPI(Serial Peripheral Interface)是一种同步串行通信协议
,用于在微控制器和外部设备之间进行数据传输。
它由一个主设备(通常是微控制器MCU)
和 一个或多个从设备
组成,即一主多从模式
。主设备
是通信的发起方和控制方,而从设备
则是被动接收和响应主设备的命令和数据。主设备通过时钟信号来同步数据传输,同时使用多个双向数据线来实现数据的传输和接收。
SPI协议是一种全双工通信方式,意味着主设备和从设备可以同时发送和接收数据。它还使用一种选择信号(通常称为片选或使能信号)
,用于选择与主设备进行通信的特定从设备。
SPI通常用于短距离、高速、全双工的通信
,它在许多嵌入式系统和电子设备中被广泛应用,如存储器芯片、传感器、显示器驱动器、无线模块等。
SPI的硬件接口
SPI主要使用4根线,时钟线(SCLK)
,主输出从输入线(MOSI)
,主输入从输出线(MISO)
和 片选线(CS)
。
通信线 | 说明 |
---|---|
SCLK | 时钟线,也叫做SCK。由主机产生时钟信号。 |
MOSI | 主设备输出从设备输入线,也叫做SDO。意为主机向从机发送数据。 |
MISO | 主设备输入从设备输出线,也叫做SDI。意为主机接收从机的数据。 |
CS | 片选线,也叫做NSS。从机使能信号,由主机控制。当我们的主机控制某个从机时,需要将从机对应的片选引脚电平拉低或者是拉高。 |
主设备是通过片选线选择要与之通信的从设备。每个从设备都有一个片选线,当片选线为低电平时,表示该从设备被选中。(也有一些设备以高电平有效,需要根据其数据手册确定)。主设备通过控制时钟线的电平来同步数据传输。时钟线的上升沿和下降沿用于控制数据的传输和采样。SPI的主从接线方式需要对应,主从机设定后身份固定。
SPI的通信模式
SPI协议定义了多种传输模式,也称为SPI模式或时序模式,用于控制数据在时钟信号下的传输顺序和数据采样方式。
SPI的传输模式主要由两个参数决定:时钟极性 (CKPL)
和 相位(CKPH)
。
时钟极性 (CKPL):时钟极性定义了时钟信号在空闲状态时的电平。
时钟相位 (CKPH):相位定义了数据采样和更新发生在时钟信号的哪个边沿上。
各信号说明
CKPL = 0:时钟信号在空闲状态时为低电平。
CKPL = 1:时钟信号在空闲状态时为高电平。
CKPH = 0:数据采样发生在时钟的第一个边沿,数据更新发生在第二个边沿。
CKPH = 1:数据采样发生在时钟的第二个边沿,数据更新发生在下一个边沿。
以下是常见的SPI模式:
模式0(CKPL=0,CKPH=0):
- 时钟极性(Clock Polarity)为0,表示时钟空闲状态为低电平。
- 时钟相位(Clock Phase)为0,表示数据在时钟信号的第一个边沿(时钟上升沿)进行采样和稳定。
模式1(CKPL=0,CKPH=1):
- 时钟极性为0,时钟空闲状态为低电平。
- 时钟相位为1,数据在时钟信号的第二个边沿(时钟下降沿)进行采样和稳定。
模式2(CKPL=1,CKPH=0):
- 时钟极性为1,时钟空闲状态为高电平。
- 时钟相位为0,数据在时钟信号的第一个边沿(时钟下降沿)进行采样和稳定。
模式3(CKPL=1,CKPH=1):
- 时钟极性为1,时钟空闲状态为高电平。
- 时钟相位为1,数据在时钟信号的第二个边沿(时钟上升沿)进行采样和稳定。
选择SPI模式的决策通常取决于从设备的规格要求和通信协议。不同的设备可能采用不同的模式,所以在与特定从设备通信之前,必须了解从设备所需的SPI模式。如果没有明确指定SPI模式,通常可以根据从设备的规格手册或通信协议选择最常见的模式0或模式3进行尝试。
此外,还需要注意SPI模式时钟的频率限制,以确保主设备和从设备之间的时钟频率匹配。
3. RP2350的SPI
SPI与IIC类似,都分有软件SPI和硬件SPI,软件SPI部分不再讲解,本章节着重讲解硬件SPI。
RP2350只有两个硬件SPI,SPI对应的引脚如下图总结的橙色信号:
TX表示发送,即MOSI;RX表示接收,即MISO
![]() |
---|
RP2350的硬件SPI主要特性:
- 可配置为控制器或外设(主机和从机)
- 8位 发送和接收缓冲区
- 可编程的时钟速率
- 支持中断和DMA
- 4-16位可编程的数据帧大小
4.SPI在Mpy的使用方式
硬件和软件 SPI
是通过 machine.SPI
和 machine.SoftSPI
实现的 。
硬件spi
使用系统的底层硬件外设支持来执行读/写,通常高效且快速,但对可以使用的引脚有限制。
软件spi
是通过软件代码 位组合
实现的,可以在任何引脚上使用,但效率不高。
这些 类
具有相同的可用方法,主要区别在于它们的构造方式。
使用machine.SPI
该类是用于 硬件SPI
的初始化。
在 MicroPython
中,使用 machine.SPI
类可以方便地操作RP2350的 硬件SPI
功能。使用时需要导入该类。
from machine import SPI
其构造函数如下:
machine.SPI(id, ...)
它会在给定的总线id上构造一个 SPI 对象。id 的值取决于特定端口及其硬件。填入0、1 等通常用于选择硬件 SPI0、SPI1 等。
在没有附加参数的情况下,SPI 对象被创建但不初始化。如果给出了额外的参数
,则总线被初始化。参见方法总结中的 init
初始化参数 或者 下方参数说明。
额外参数说明
baudrate
是 SCK
时钟速率。
polarity
可以是 0
或 1
,指的是空闲时钟线所在的电平是低电平还是高电平。
phase
可以是 0
或 1
分别指的是在第一个还是第二个时钟沿采样数据。
bits
是每次传输的位宽度。
firstbit
指定数据传输时首先发送的位是最高有效位(MSB)还是最低有效位(LSB)。可以 SPI.MSB
或 SPI.LSB
.
sck, mosi, miso
是用于总线信号的引脚 (machine.Pin
) 对象。对于大多数硬件 SPI
(由id 构造函数的参数选择),引脚是固定的,不能更改。
在硬件 SPI 的情况下,实际时钟频率可能低于设置的波特率。这取决于硬件。实际速率可以通过打印 SPI 对象来确定。
示例:
from machine import Pin, SPI
spi = SPI(1, 10_000_000) # Default assignment: sck=Pin(10), mosi=Pin(11), miso=Pin(8)
spi = SPI(1, 10_000_000, sck=Pin(14), mosi=Pin(15), miso=Pin(12))
spi = SPI(0, baudrate=80_000_000, polarity=0, phase=0, bits=8, sck=Pin(6), mosi=Pin(7), miso=Pin(4))
2
3
4
5
使用machine.SoftSPI
该类是用于 软件SPI
的初始化。
在 MicroPython
中,使用 machine.SoftSPI
类可以方便地操作RP2350的 软件SPI
功能。使用时需要导入该类。
from machine import SoftSPI
其构造函数如下:
machine.SoftSPI(baudrate=500000, *, polarity=0, phase=0, bits=8, firstbit=MSB, sck=None, mosi=None, miso=None)
参数说明
构造一个新的 软件SPI
对象。必须给出额外的参数,通常至少是sck
、mosi
和 miso
,这些用于初始化总线。有关参数的说明,请参阅硬件SPI的额外参数说明。
示例:
from machine import Pin, SoftSPI
spi = SoftSPI(baudrate=100_000, polarity=1, phase=0, sck=Pin(0), mosi=Pin(2), miso=Pin(4))
2
3
方法总结
重新初始化SPI总线
SPI.init(baudrate=1000000, *, polarity=0, phase=0, bits=8, firstbit=SPI.MSB, sck=None, mosi=None, miso=None)
使用给定的参数初始化 SPI
总线:
baudrate
是 SCK
时钟速率。
polarity
可以是 0
或 1
,指的是空闲时钟线所在的电平是低电平还是高电平。
phase
可以是 0
或 1
分别指的是在第一个还是第二个时钟沿采样数据。
bits
是每次传输的位宽度。
firstbit
指定数据传输时首先发送的位是最高有效位(MSB)还是最低有效位(LSB)。可以 SPI.MSB
或 SPI.LSB
.
sck, mosi, miso
是用于总线信号的引脚 (machine.Pin
) 对象。对于大多数硬件 SPI
(由id 构造函数的参数选择),引脚是固定的,不能更改。
在硬件 SPI 的情况下,实际时钟频率可能低于设置的波特率。这取决于硬件。实际速率可以通过打印 SPI 对象来确定。
关闭SPI总线
SPI.deinit()
关闭 SPI 总线。
读取数据
SPI.read(nbytes, write=0)
读取由 nbytes
指定的字节数,在读取的同时,可以连续写入由 write
指定的单个字节。
返回一个 bytes
对象,其中包含所读取的数据。
读取数据到缓冲区
SPI.readinto(buf, write=0)
读入由 buf
指定的缓冲区,在读取时也可以同时连续写入由 write
给出的单个字节。
返回None。
发送数据
SPI.write(buf)
写入buf
中包含的字节。
返回None。
同时发送与接收数据
SPI.write_readinto(write_buf, read_buf)
在读入read_buf时,从write_buf写入字节到总线。缓冲区可以相同也可以不同,但两个缓冲区必须具有相同的长度。
返回None。
5. SPI实验介绍
以W25Q64-FLASH存储模块
作为实验案例。通过硬件SPI的方式与其进行通信,实现数据的读写。
W25Q64
是一种常见的串行闪存器件,它采用SPI接口协议,具有高速读写和擦除功能,可用于存储和读取数据。
W25Q64
芯片容量为64 Mbit(8 MB)
,其中名称后的数字代表不同的容量选项。不同的型号和容量选项可以满足不同应用的需求,比如W25Q16、W25Q64、W25Q128等。
它通常被用于嵌入式设备、存储设备、路由器等高性能电子设备中。
W25Q64
闪存芯片的内存分配是按照扇区(Sector)和块(Block)进行的,每个扇区的大小为4KB,每个块包含16个扇区,即一个块的大小为64KB。
实验硬件连接
W25Q64存储芯片,其引脚的说明,见下表。
CLK | 从外部获取时间,为输入输出功能提供时钟 |
---|---|
DI | 标准SPI使用单向的DI,来串行的写入指令,地址,或者数据到FLASH中,在时钟的上升沿。 |
DO | 标准SPI使用单向的DO,来从处于下降边沿时钟的设备,读取数据或者状态。 |
WP | 防止状态寄存器被写入 |
HOLD | 当它有效时允许设备暂停,低电平:DO引脚高阻态,DI CLK引脚的信号被忽略。高电平:设备重新开始,当多个设备共享相同的SPI信 号的时候该功能可能会被用到 |
CS | CS高电平的时候其他引脚成高阻态,处于低电平的时候,可以读写数据 |
它与开发板的连接如下:
开发板(主机) | W25Q128(从机) | 说明 |
---|---|---|
13 | CS(NSS) | 片选线 |
14 | CLK | 时钟线 |
12 | DO(IO1)(MISO)(RX) | 主机输入从机输出线 |
15 | DI(IO0)(MOSI)(TX) | 主机输出从机输入线 |
GND | GND | 电源线 |
VCC | 3V3 | 电源线 |
W25Q64的开发
读取设备ID案例
根据W25Qx的数据手册可知,读取ID的指令有很多个,ABH/90H/92H/94H/9FH等。而90H的命令,在数据手册中给出了其读取的时序图。
读取步骤:
- 将CS端拉低为低电平;
- 发送指令
90H(1001_0000)
; - 发送地址
000000H(0000_0000_0000_0000_0000_0000)
; - 读取制造商ID,根据数据手册可以知道制造商ID为
EFh
; - 读取设备ID,根据数据手册可以知道设备ID为
16h
; - 恢复CS端为高电平;
实现代码:
# 仅展示关键代码
def read_id():
# 拉低片选线,选中W25Q64
cs.value(0)
# 发送读取ID指令
spi.write(bytearray([0x90, 0x00, 0x00, 0x00]))
# 读取设备ID(两个字节)
id = spi.read(2)
# 拉高片选线,取消选中W25Q64
cs.value(1)
return id
2
3
4
5
6
7
8
9
10
11
12
13
14
15
效果:
读取数据案例
读取数据的时序图如下:
- 拉低CS端为低电平;
- 发送指令03h(0000_0011);
- 发送24位读取数据地址;
- 接收读取到的数据;
- 恢复CS端为高电平;
# 仅展示关键代码
"""
address=读取地址
length=读取数据的长度
buffer=读出数据的存储地址
"""
def read_flash(address, length, buffer):
# 拉低CS引脚以选择设备
cs.value(0)
# 发送读取命令和地址
spi.write(bytearray([0x03, (address >> 16) & 0xFF, (address >> 8) & 0xFF, address & 0xFF]))
# 读取数据
spi.readinto(buffer, length)
# 拉高CS引脚以取消选择设备
cs.value(1)
# 调用函数读取数据
read_address = 0
read_length = 20
data_buffer = bytearray(read_length)
read_flash(read_address, read_length, data_buffer)
print(data_buffer)# 打印读取的数据
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
效果:
写使能案例
在进行写入操作之前,需要使用到写使能(Write Enable)命令。
在默认情况下,闪存芯片处于保护状态,禁止对其进行写入操作,主要是为了防止误操作对数据的损坏。
写使能命令可以解除这种保护状态,将闪存芯片设置为可以进行写入操作。
通过发送写使能命令,闪存芯片将进入一个特定的状态,使得后续的写入命令可以被接受和执行。在写入数据或者擦除数据之前,需要发送写使能命令来确保闪存芯片处于可写状态。然后,才能发送写入命令将数据写入指定的存储位置。
使用写使能命令可以有效地保护数据的完整性和安全性,防止误操作对数据进行写入或者修改。同时,也能够确保数据的一致性,避免写入过程中出现错误或者干扰。因此,在使用W25Q64进行写入操作时,需要先发送写使能命令,以确保闪存芯片处于可写状态,再进行数据的写入操作。
W25Q64的数据手册中,关于写使能的时序如下:
操作步骤:
- 将CS端拉低为低电平;
- 发送指令 06H(0000_0110);
- 恢复CS端为高电平;
具体实现代码如下:
# 写使能
def write_enable():
cs.value(0) # 拉低CS引脚以选择设备
spi.write(bytearray([0x06])) # 发送写使能命令
cs.value(1) # 拉高CS引脚以取消选择设备
2
3
4
5
读取器件状态案例
在W25Q64的数据手册中,有3个状态寄存器,可以判断当前W25Q64是否正在传输、写入、读取数据等,我们每一次要对W25Q64进行操作时,需要先判断W25Q64是否在忙。如果在忙的状态,我们去操作W25Q64,很可能会导致数据丢失,并且操作失败。而判断是否忙,是通过状态寄存器1的S0位(BUSY)进行判断,状态寄存器1的地址为0X05。
读取状态寄存器的时序图如下:
- 拉低CS端为低电平;
- 发送指令05h(0000_0101);
- 接收状态寄存器值;
- 恢复CS端为高电平;
具体实现代码如下:
# 等待设备空闲
def wait_for_idle():
while True:
cs.value(0)
spi.write(bytearray([0x05]))
status = spi.read(1)
cs.value(1)
if (status[0] & 0x01) == 0:
break
2
3
4
5
6
7
8
9
数据擦除案例
W25Q64闪存芯片的内存分配是按照扇区(Sector)和块(Block)进行的,每个扇区的大小为4KB,每个块包含16个扇区,即一个块的大小为64KB。
W25Q64闪存芯片的扇区擦除是指将某个特定扇区中的数据全部擦除的操作。擦除操作会将扇区中的所有数据都置为1(即0xFF),恢复到初始状态。下面是W25Q64扇区擦除的一般流程:
- 写使能(Write Enable):首先,要确保闪存芯片处于可写状态。发送写使能命令,将闪存芯片设置为可写模式,解除写保护。
- 扇区擦除设置(Sector Erase Setup):向W25Q64发送扇区擦除设置命令,并指定要擦除的扇区地址。W25Q64支持多种扇区擦除命令,可以根据需要选择擦除一个或多个扇区。
- 扇区擦除确认(Sector Erase Confirm):等待扇区擦除确认。W25Q64芯片进行擦除操作需要一定的时间,具体时间可参考该芯片的规格书。在擦除操作进行期间,通常会读取状态寄存器忙位的方法来确定擦除是否完成。过早地读取擦除操作中的数据可能会导致不正确的结果。
- 扇区擦除完成:当扇区擦除成功后,状态寄存器将指示擦除操作完成。此时,该扇区中的数据已经全部被擦除为1。
扇区擦除操作是一种高级操作,需要小心谨慎地使用。在实际应用中,通常会结合编程逻辑和相应的控制器来管理闪存芯片的擦除和写入操作,以确保数据的安全性和完整性。
在使用扇区擦除操作时,有几个注意事项需要特别关注:
- 擦除范围:要确保擦除的范围是正确的,仅擦除目标扇区,避免误擦除其他扇区中的数据。在执行擦除操作之前,请务必仔细检查要擦除的扇区地址,并确保没有错误。
- 数据备份:由于扇区擦除操作将数据全部擦除为1(0xFF),在执行擦除之前,应该确保重要数据已经备份。擦除后,数据将无法恢复,因此在执行重要数据的扇区擦除操作之前,请务必做好数据备份的工作。
扇区擦除的时序图如下:
- 拉低CS端为低电平;
- 发送指令20h(0010_0000);
- 发送24位的扇区首地址;
- 恢复CS端为高电平;
具体实现代码如下:
# 擦除扇区
def sector_erase(address):
# 发送写使能命令
write_enable()
# 发送扇区擦除命令
cs.value(0)
spi.write(bytearray([0x20, (address >> 16) & 0xFF, (address >> 8) & 0xFF, address & 0xFF]))
cs.value(1)
# 读取器件状态 等待擦除完成
wait_for_idle()
2
3
4
5
6
7
8
9
10
11
12
数据写入案例
写入数据步骤如右图。在FLASH存储器中,每次写入数据都要确保其中的数据为0xFF,是因为FLASH存储器的写入操作是一种擦除-写入操作。擦除操作是将存储单元中的数据全部置为1,也就是0xFF。然后,只有将要写入的数据位为1的位置才能进行写入操作,将其改变为0。这个过程是不可逆的,所以在写入数据之前,需要先确保要写入的位置为0xFF,然后再写入数据。
这种擦除-写入的操作是由于FLASH存储器的特殊结构决定的。FLASH存储器中的存储单元是通过电子门的状态进行控制的,每个门可以存储一个二进制位。擦除操作需要将门的状态恢复为初始状态,即全部为1。然后通过改变门的状态,将需要存储的数据位改变为0。所以在写入数据之前,需要确保存储单元的状态为1,以便进行正确的写入操作。
另外,FLASH存储器的擦除操作是以块为单位进行的,而不是单个存储单元。所以如果要写入数据的位置上已经有数据存在,需要进行擦除操作,将整个块的数据都置为1,然后再写入新的数据。这也是为什么在FLASH写入数据之前需要确保其中的数据为0xFF的原因。
写入数据的前置步骤:擦除数据->写使能->判断忙 我们都完成了,只剩下将数据写入到对应地址中保存即可。
具体写入数据代码如下:
# 写入数据
def write_flash(address, data):
# 发送写使能命令
write_enable()
# 发送页编程命令和地址
cs.value(0)
spi.write(bytearray([PAGE_PROGRAM_CMD, (address >> 16) & 0xFF, (address >> 8) & 0xFF, address & 0xFF]) + data)
cs.value(1)
# 等待写入完成
wait_for_idle()
2
3
4
5
6
7
8
9
10
11
12
6. 代码验证
逐一点开上一小章节的讲义看完,再来看看下面的完整代码:
from machine import Pin, SPI
# 定义SPI引脚
cs = Pin(13, Pin.OUT) # 片选线
# 初始化SPI
spi = SPI(1, baudrate=1000000, polarity=0, phase=0, sck=Pin(14), mosi=Pin(15), miso=Pin(12))
# 读取设备ID的指令
READ_ID_CMD = 0x90
# 读取命令
READ_CMD = 0x03
# 写使能命令
WRITE_ENABLE_CMD = 0x06
# 页编程命令
PAGE_PROGRAM_CMD = 0x02
# 扇区擦除命令
SECTOR_ERASE_CMD = 0x20
# 读取状态命令
READ_STATUS_REG_CMD = 0x05
# W25Q64的页大小为256字节
PAGE_SIZE = 256
def read_id():
# 拉低片选线,选中W25Q128
cs.value(0)
# 发送读取ID指令
spi.write(bytearray([READ_ID_CMD, 0x00, 0x00, 0x00]))
# 读取设备ID
id = spi.read(2)
# 拉高片选线,取消选中W25Q128
cs.value(1)
return id
# 读取数据
def read_flash(address, length, buffer):
# 拉低CS引脚以选择设备
cs.value(0)
# 发送读取命令和地址
spi.write(bytearray([READ_CMD, (address >> 16) & 0xFF, (address >> 8) & 0xFF, address & 0xFF]))
# 读取数据
spi.readinto(buffer, length)
# 拉高CS引脚以取消选择设备
cs.value(1)
# 写使能
def write_enable():
cs.value(0) # 拉低CS引脚以选择设备
spi.write(bytearray([WRITE_ENABLE_CMD])) # 发送写使能命令
cs.value(1) # 拉高CS引脚以取消选择设备
# 等待设备空闲
def wait_for_idle():
while True:
cs.value(0)
spi.write(bytearray([READ_STATUS_REG_CMD]))
status = spi.read(1)
cs.value(1)
if (status[0] & 0x01) == 0:
break
# 擦除扇区
def sector_erase(address):
# 发送写使能命令
write_enable()
# 发送扇区擦除命令
cs.value(0)
spi.write(bytearray([SECTOR_ERASE_CMD, (address >> 16) & 0xFF, (address >> 8) & 0xFF, address & 0xFF]))
cs.value(1)
# 等待擦除完成
wait_for_idle()
# 写入数据
def write_flash(address, data):
# 发送写使能命令
write_enable()
# 发送页编程命令和地址
cs.value(0)
spi.write(bytearray([PAGE_PROGRAM_CMD, (address >> 16) & 0xFF, (address >> 8) & 0xFF, address & 0xFF]) + data)
cs.value(1)
# 等待写入完成
wait_for_idle()
# 主程序
def main():
# 读取并打印设备ID
device_id = read_id()
print(device_id.hex())
# 调用函数读取数据
read_address = 0
read_length = 20
data_buffer = bytearray(read_length)
read_flash(read_address, read_length, data_buffer)
print(data_buffer)# 打印读取的数据
# 扇区擦除
sector_erase(0)
# 调用函数写入数据
write_address = 0
write_data = bytearray(b"12345, LCKFB!")
write_flash(write_address, write_data)
# 调用函数读取数据
read_address = 0
read_length = 20
data_buffer = bytearray(read_length)
read_flash(read_address, read_length, data_buffer)
print(data_buffer)# 打印读取的数据
# 运行主程序
main()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
效果:
为什么CS引脚要单独设置?
由于大多数的SPI协议中,整个时序里在发送接收时片选是要一直拉低的,而SPI外设的CS片选线在每次发送和接收完一帧后会拉高,所以CS片选线需要用MCU的IO口独立控制,故这里使用GPIO的方式(软件方式)去控制CS引脚的输出。