树莓派 Pico PIO 模拟 SPI 全解析:原理、时序与代码实战

PIO 实现 SPI 通信

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

ScreenShot_2026-06-09_200953_688.png

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

ScreenShot_2026-06-09_201016_903.png

实物整体连接效果如下:

1280X1280 (2).JPEG

1.使用 PIO 实现 SPI 通信协议

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

1280X1280.PNG

通信流程可以简化为:

  1. 当不传输数据时,SCLK(时钟线)保持低电平。
  2. 在每个时钟周期,数据在 MOSI(主输出从输入)或 MISO(主输入从输出)线上传输,数据信号在下降沿变化,上升沿读取。
  3. 数据的传输是根据时钟同步的,每传输一个数据位,时钟就变化一次。

这里,我们参考官方例程对其进行修改代码如下:

# 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 时钟和其他操作的引脚,同时 MOSISCK 引脚都初始化为低电平。

接着,在 PIO 汇编程序中,我们首先使用 set(x, 6) 设置寄存器 x 的初始值为 6,这表示每次需要进行 7 次时钟循环,每次发送 1 位数据,这里的 x 寄存器控制每次循环的次数。

然后我们定义 SPI 协议数据传输过程,首先通过 wrap_target()wrap() 包裹了整个数据传输过程,表示每次传输一个字节后可以继续传输下一个字节:

  1. 拉取数据​:pull(ifempty) 表示从 TX FIFO 队列中拉取数据,,如果 FIFO 为空,则会等待直到数据填充,并且通过 side(0x2) 使 SCK 信号被设置为高电平,表示开始发送数据
  2. 发送和接收数据的位循环​:数据的传输使用 bitloop 标签来实现,循环发送和接收每一位:
    1. 输出数据位​:使用 out(pins, 1) 从输出移位寄存器(OSR)输出 1 位数据到 MOSI 引脚,并通过 side(0x0) 将时钟线 SCK 设置为低电平,表示在时钟的下降沿进行数据发送
    2. 读取数据位​:使用 in_(pins, 1) 从 MISO 引脚读取 1 位数据到输入移位寄存器(ISR),并通过 side(0x1)SCK 设置为高电平,表示在时钟的上升沿采样数据
    3. 跳转判断​:通过 jmp(x_dec, "bitloop") 指令将 x 寄存器递减 1 并继续循环,直到所有数据位都传输完成
  3. 完成一个字节传输​:当传输一个字节的 8 个位时,设置 x 为 6 并开始新的字节传输,通过 out(pins, 1) 输出最后一位数据并设置 SCK 为低电平,表示完成数据的发送
  4. 检查是否继续​:jmp(not_osre, "bitloop") 表示如果输出移位寄存器(OSR)未达到其阈值 8,且 FIFO 队列仍有数据,则继续循环进行数据传输
  5. 无操作等待延迟​:nop().side(0x0) 用于在传输完成后等待一定时间,形成 CS 信号的延迟,标志着一个字节的传输结束

整体时序图如下所示:

075761bb-7f12-43de-b9b7-9b0814af6e0f.png

简单来说,我们主要通过如下步骤通过 PIO 实现 SPI 通信:

  1. 在每个时钟周期,数据的各个位通过移位寄存器从输出移位寄存器(OSR)移到 MOSI 引脚,在时钟的下降沿被发送,而数据在时钟的上升沿被采样。
  2. 通过 x 寄存器控制每个字节的数据位传输次数,每次传输完一个字节后,程序会准备好下一字节的数据。
  3. 使用 side() 指令控制 SPI 时钟(SCK)引脚的电平变化,保证数据发送和采样的时序符合 CPHA=0, CPOL=0 的要求。

接着,我们在自定义的 PIOSPI 类中封装了使用 PIO 状态机实现 SPI 通信的功能,并提供了如下方法:

c6fd375c-691c-4cd7-824a-fb3f66028b23.png

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

86d87ea8-0db4-4ad5-bbd4-5b7bddf4a46e.png

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

c9f3adf6-9ae6-4fe0-9f3d-2c61f0961bc0.png

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

2f18938f-905b-49f7-bb6c-0c945b2f1bf9.png

这里,需要注意的是,由于 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 协议解析器,进行如下设置:

38b43cfd-7124-45ea-aadf-a43bd6e4366f.png

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

3509a237-4e55-449a-9eb5-24f055dee40d.png

输出如下所示:

a3619ccc-a6aa-467a-858e-0b4f79b158a4.png

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

7045646d-8cf2-436c-a20c-22541cb638e8.png

66df05b5-3f98-4be9-9a89-1148f6d5c7c8 (1).png

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

2d465c74-aded-4d07-8495-6eff88873680.png

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

0ee776b5-3c8f-4d73-b8cf-fb4fb0df7bf8.png

64dbd432-cece-4a83-8633-387944e56b2b.png

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

7cb2d436-8c88-44ca-a167-4d432e936f98.png

posted @ 2026-09-01 17:23  FreakStudio  阅读(20)  评论(0)    收藏  举报