树莓派 Pico PIO 模拟 SPI 全解析:原理、时序与代码实战
PIO 实现 SPI 通信
这里,我们需要使用 KingST 逻辑分析仪连接风雅一号板-通用兼容扩展板上的长排针:

树莓派 Pico 使用引脚情况如下表所示:

实物整体连接效果如下:

1.使用 PIO 实现 SPI 通信协议
这里,我们以 CPOL 时钟极性和 CPHA 时钟相位均为 0 的 SPI 通信为例讲解如何使用 PIO 实现 SPI 通信协议,首先我们先回忆一下 SPI 通信的基本要点,当 CPOL = 0 和 CPHA = 0 时,时钟信号在没有数据传输时保持低电平,而数据在时钟信号的上升沿被采样(即数据被发送到接收设备时,时钟信号从低电平跳变到高电平):

通信流程可以简化为:
- 当不传输数据时,
SCLK(时钟线)保持低电平。 - 在每个时钟周期,数据在
MOSI(主输出从输入)或MISO(主输入从输出)线上传输,数据信号在下降沿变化,上升沿读取。 - 数据的传输是根据时钟同步的,每传输一个数据位,时钟就变化一次。
这里,我们参考官方例程对其进行修改代码如下:
# Python env : MicroPython v1.23.0
# -*- coding: utf-8 -*-
# @Time : 2024/11/6 下午4:26
# @Author : 李清水
# @File : pio_spi.py
# @Description : PIO类实验,在PIO程序中实现SPI协议通信
# 参考代码:https://github.com/raspberrypi/pico-micropython-examples/blob/master/pio/pio_spi.py
# ======================================== 导入相关模块 ========================================
# 导入硬件相关模块
from machine import Pin
# 导入RP2040相关的模块
from rp2 import PIO, StateMachine, asm_pio
# ======================================== 全局变量 ============================================
# ======================================== 功能函数 ============================================
# 使用@asm_pio装饰器定义一个 PIO 程序
# OSR移位寄存器的方向为左移,使能自动推出和自动加载,移位计数阈值均为8
# 用于侧集操作的两个引脚初始化为低电平和高电平,用于输出的引脚初始化为低电平
@asm_pio(out_shiftdir=0, autopull=True, pull_thresh=8,
autopush=True, push_thresh=8,
sideset_init=(PIO.OUT_LOW, PIO.OUT_HIGH), out_init=PIO.OUT_LOW)
def spi_cpha0() -> None:
"""
PIO 实现的 SPI 协议(CPHA=0\CPOL=0)
该 PIO 程序实现了 SPI 协议的 CPHA=0\CPOL=0 模式,支持 8 位数据传输。
通过侧集引脚控制 SCK 和 MOSI,同时读取 MISO 数据。
Args:
None
Returns:
None
"""
# 设置寄存器 x 为 6,这里 x 寄存器用来控制每个字节的位数倒计数
# 设置为 6 表示需要发送 7 次时钟循环,x的值从6递减到0
set(x, 6)
# 用于定义循环开始位置
wrap_target()
# 当OSR输出移位计数器达到其阈值时(即8个数据位),则从TX FIFO队列中提取一个字节数据到输出移位寄存器
# 如果TX FIFO为空,则等待数据填充
# 设置侧边位(side-set)的时钟线 SCK 为高电平,[1] 表示等待一个周期
pull(ifempty) .side(0x2) [1]
# 定义标签 bitloop,用于表示一个字节数据的位循环(发送和接收每一位)
label("bitloop")
# 从输出移位寄存器 OUT 中取出 1 位并通过 pins 输出,即数据输出到 MOSI 引脚
# 设置 SCK 低电平,表明数据在时钟的下降沿输出
out(pins, 1) .side(0x0) [1]
# 从 MISO 引脚读取1位数据并存入输入ISR移位寄存器
# 将 SCK 设置为高电平,在时钟的上升沿采样数据
in_(pins, 1) .side(0x1)
# 判断x是否减至零,如果 x 未减到零,跳转到 bitloop 标签继续传输下一个位
# 之后对 x 寄存器进行递减操作,同时保持时钟为高电平
jmp(x_dec, "bitloop") .side(0x1)
# 再次输出 1 位数据到 MOSI 引脚,将 SCK 设置为低电平,确保数据发送完成
# 总共进行8次数据收发,即一个字节数据传输完成
out(pins, 1) .side(0x0)
# 重置 x 寄存器为 6,为下一字节传输做准备
set(x, 6) .side(0x0)
# 从 MISO 引脚读取1位数据并存入输入ISR移位寄存器
# 将 SCK 设置为高电平,在时钟的上升沿采样数据
in_(pins, 1) .side(0x1)
# 如果 OSR 不为空(尚未达到其阈值),则跳转到 bitloop,继续传输下一个字节
jmp(not_osre, "bitloop") .side(0x1)
# 无操作同时设置 SCK 为低电平,形成CS结束的延迟,表示一字节数据传输结束
nop() .side(0x0) [1]
# 用于定义循环结束位置
wrap()
# ======================================== 自定义类 ============================================
# 使用RP2040的PIO(可编程I/O)系统实现的自定义SPI驱动类
class PIOSPI:
"""
PIOSPI 类,用于通过 PIO 实现 SPI 通信。
该类封装了基于 RP2040 PIO 的 SPI 通信功能,支持阻塞式读写操作。
通过 PIO 状态机实现 SPI 协议,适用于需要高效、低延迟的 SPI 通信场景。
Attributes:
_sm (StateMachine): PIO 状态机实例,用于实现 SPI 协议。
_cs (Pin): CS 引脚实例,用于控制 SPI 设备的片选信号。
Methods:
__init__(self, sm_id, pin_mosi, pin_sck, pin_miso=None, pin_cs=None, cpha=False, cpol=False, freq=1000000):
初始化 PIOSPI 类实例。
write(self, wdata: list[int]) -> None:
阻塞式写入数据到 SPI 设备。
read(self, n: int) -> list[int]:
阻塞式从 SPI 设备读取数据。
write_read(self, wdata: list[int]) -> list[int]:
阻塞式写入并读取 SPI 设备数据。
"""
def __init__(self, sm_id: int, pin_mosi: int, pin_sck: int, pin_miso: int = None, pin_cs: int = None, cpha: bool = False, cpol: bool = False, freq: int = 1000000):
"""
初始化 PIOSPI 类实例。
Args:
sm_id (int): 状态机编号。
pin_mosi (int): MOSI 引脚编号。
pin_sck (int): SCK 引脚编号。
pin_miso (int): MISO 引脚编号,可选。
pin_cs (int): CS 引脚编号,可选。
cpha (bool): 时钟相位,默认为 False。
cpol (bool): 时钟极性,默认为 False。
freq (int): 时钟频率,默认为 1000000 Hz。
Raises:
AssertionError: 如果 cpol 或 cpha 不为 False。
"""
# 确保仅使用 CPHA=0 和 CPOL=0(不支持反相时钟信号或其他相位)
assert(not(cpol or cpha))
# 判断pin_mosi编号和pin_sck编号是否相邻,并且pin_mosi编号小于pin_sck编号
if not (pin_mosi == pin_sck - 1 or pin_mosi == pin_sck + 1):
raise AssertionError('pin_mosi must be adjacent to pin_sck')
# 创建了一个状态机对象 _sm,并将其激活,状态机程序为 spi_cpha0,频率为 4*freq
self._sm = StateMachine(sm_id, spi_cpha0, freq=4*freq, sideset_base=Pin(pin_sck), out_base=Pin(pin_mosi), in_base=Pin(pin_miso))
# 初始化 CS 引脚(如果提供)
self._cs = Pin(pin_cs, Pin.OUT) if pin_cs is not None else None
if self._cs:
# 初始状态为高电平(未选中)
self._cs.value(1)
# 激活状态机
self._sm.active(1)
def write(self, wdata: list[int]) -> None:
"""
阻塞式写入数据到 SPI 设备。
Args:
wdata (list[int]): 要写入的数据列表,每个元素为 8 位数据。
Returns:
None
"""
# 拉低 CS 引脚
if self._cs:
self._cs.value(0)
# 将每个字节左移 24 位后放入状态机的输出 FIFO
for b in wdata:
self._sm.put(b << 24)
# 拉高 CS 引脚
if self._cs:
self._cs.value(1)
def read(self, n: int) -> list[int]:
"""
阻塞式从 SPI 设备读取数据。
Args:
n (int): 需要读取的数据字节数。
Returns:
list[int]: 读取的数据列表,每个元素为 8 位数据。
"""
# 拉低 CS 引脚
if self._cs:
self._cs.value(0)
data = []
# 清空RX FIFO
self._sm.restart()
for i in range(n):
# 取数据的前16位
data.append(self._sm.get() & 0xff)
# 拉高 CS 引脚
if self._cs:
self._cs.value(1)
return data
def write_read(self, wdata: list[int]) -> list[int]:
"""
阻塞式写入并读取 SPI 设备数据。
Args:
wdata (list[int]): 要写入的数据列表,每个元素为 8 位数据。
Returns:
list[int]: 读取的数据列表,每个元素为 8 位数据。
"""
# 拉低 CS 引脚
if self._cs:
self._cs.value(0)
rdata = []
# 清空RX FIFO
self._sm.restart()
for b in wdata:
# 将每个字节左移 24 位后放入状态机的输出 FIFO
self._sm.put(b << 24)
# 取数据的前16位
rdata.append(self._sm.get() & 0xff)
# 拉高 CS 引脚
if self._cs:
self._cs.value(1)
return rdata
# ======================================== 初始化配置 ==========================================
# ======================================== 主程序 ===========================================
该代码中首先使用装饰器 @asm_pio 来定义程序的基本配置,包括设定 OSR 移位方向为左移,使能自动推出和自动加载,其移位计数阈值为 8,表示每个数据字节包含 8 个数据位,PIO 会等待 8 个时钟周期后才移位一次;初始化用于 SPI 时钟和其他操作的引脚,同时 MOSI 和 SCK 引脚都初始化为低电平。
接着,在 PIO 汇编程序中,我们首先使用 set(x, 6) 设置寄存器 x 的初始值为 6,这表示每次需要进行 7 次时钟循环,每次发送 1 位数据,这里的 x 寄存器控制每次循环的次数。
然后我们定义 SPI 协议数据传输过程,首先通过 wrap_target() 和 wrap() 包裹了整个数据传输过程,表示每次传输一个字节后可以继续传输下一个字节:
- 拉取数据:
pull(ifempty)表示从TX FIFO队列中拉取数据,,如果 FIFO 为空,则会等待直到数据填充,并且通过side(0x2)使 SCK 信号被设置为高电平,表示开始发送数据 - 发送和接收数据的位循环:数据的传输使用
bitloop标签来实现,循环发送和接收每一位:- 输出数据位:使用
out(pins, 1)从输出移位寄存器(OSR)输出 1 位数据到MOSI引脚,并通过side(0x0)将时钟线SCK设置为低电平,表示在时钟的下降沿进行数据发送 - 读取数据位:使用
in_(pins, 1)从 MISO 引脚读取 1 位数据到输入移位寄存器(ISR),并通过side(0x1)将SCK设置为高电平,表示在时钟的上升沿采样数据 - 跳转判断:通过
jmp(x_dec, "bitloop")指令将x寄存器递减 1 并继续循环,直到所有数据位都传输完成
- 输出数据位:使用
- 完成一个字节传输:当传输一个字节的 8 个位时,设置
x为 6 并开始新的字节传输,通过out(pins, 1)输出最后一位数据并设置SCK为低电平,表示完成数据的发送 - 检查是否继续:
jmp(not_osre, "bitloop")表示如果输出移位寄存器(OSR)未达到其阈值 8,且FIFO队列仍有数据,则继续循环进行数据传输 - 无操作等待延迟:
nop().side(0x0)用于在传输完成后等待一定时间,形成CS信号的延迟,标志着一个字节的传输结束
整体时序图如下所示:

简单来说,我们主要通过如下步骤通过 PIO 实现 SPI 通信:
- 在每个时钟周期,数据的各个位通过移位寄存器从输出移位寄存器(
OSR)移到MOSI引脚,在时钟的下降沿被发送,而数据在时钟的上升沿被采样。 - 通过
x寄存器控制每个字节的数据位传输次数,每次传输完一个字节后,程序会准备好下一字节的数据。 - 使用
side()指令控制 SPI 时钟(SCK)引脚的电平变化,保证数据发送和采样的时序符合 CPHA=0, CPOL=0 的要求。
接着,我们在自定义的 PIOSPI 类中封装了使用 PIO 状态机实现 SPI 通信的功能,并提供了如下方法:

- 初始化
init()方法:初始化 PIO 状态机,指定sm_id(状态机编号)、pin_mosi(数据输出引脚)、pin_miso(数据输入引脚)、pin_sck(时钟引脚)等参数,并设置频率为4*freq来适应 PIO 指令的时钟周期(每个数据位级操作占用一个时钟周期),接着激活状态机准备传输数据。 - 写入
write()方法:使用self._sm.put()方法将每个字节左移 24 位(对齐到最高位)后传入 TX FIFO,该方法为阻塞式方法,需要等待所有数据发送完成。

- 读取
read()方法:使用self._sm.get()逐字节读取 RX FIFO 中的数据,通过& 0xff截取最低 8 位,确保数据在 1 字节范围内,同样为阻塞式方法。

- 读写
write_read()方法:同时发送和接收数据,使用put发送每个字节,同时调用get接收对应的响应数据。

这里,需要注意的是,由于 PIO 汇编程序中涉及到侧集操作相关设置,要求pin_mosi编号和pin_sck编号相邻,并且pin_mosi编号小于pin_sck编号。
2.SPI 数据收发回环测试
以下代码可以在我们提供的资料包中 elegance-devkit v1\Demo\36 PIO_SPI 文件夹找到。
在主程序中,我们通过自定义的 PIOSPI 类向外部设备发送数据,主程序每秒发送一组字节数据,并且设置 MOSI 引脚和 MISO 引脚相同从而进行数据收发回环测试,示例代码如下所示:
# Python env : MicroPython v1.23.0
# -*- coding: utf-8 -*-
# @Time : 2024/11/6 下午6:41
# @Author : 李清水
# @File : main.py
# @Description : 使用PIO模拟的SPI协议进行数据收发回环测试
# ======================================== 导入相关模块 ========================================
# 导入时间相关模块
import time
# 导入PIO模拟SPI协议相关模块
from pio_spi import PIOSPI
# ======================================== 全局变量 ============================================
# 待发送的字节列表
tx_list = [0, 1, 2, 3, 4, 5, 6, 7]
# ======================================== 功能函数 ============================================
# ======================================== 自定义类 ============================================
# ======================================== 初始化配置 ==========================================
# 延时等待设备初始化
time.sleep(3)
# 打印调试信息
print('FreakStudio : Using PIO to implement the SPI protocol')
# 初始化SPI类,设置波特率、极性、相位、时钟引脚、数据引脚
# 设置pin_mosi和pin_miso为GP10,pin_sck为GP11,pin_cs为GP12,CPHA为False,CPOL为False,波特率为1000000
# 设置MISO和MOSI引脚相同,进行收发回环测试
spi = PIOSPI(sm_id=0, pin_mosi=10, pin_sck=11, pin_miso=10, pin_cs = 12,cpha=False, cpol=False, freq=1000000)
# ======================================== 主程序 ===========================================
# 使用PIO模拟SPI协议收发数据
while True:
# 发送并读取数据
data = spi.write_read(tx_list)
# 打印调试信息
print('FreakStudio : SPI data received : {}'.format(data))
# 等待1秒
time.sleep(1)
烧录代码前,我们需要在 KingST 逻辑分析软件中添加一个 SPI 协议解析器,进行如下设置:

接着烧录代码,打开终端,并打开逻辑分析仪上位机,点击启动单次采样:

输出如下所示:

可以看到,我们成功使用 PIO 模拟 SPI 协议发送了数据:


时钟频率和程序预设的通信频率一致:

CS 片选端时序与设置的基本相同,拉低发送开始、拉高时表示发送完成,数据收发周期为 1s 左右:


打开终端,使用 mpremote 工具连接树莓派 Pico,可以看到自定义的 PIOSPI 类可以完成数据接收:


这里,我们需要使用 `KingST` 逻辑分析仪连接**`风雅一号板-通用兼容扩展板`**上的长排针:
浙公网安备 33010602011771号