树莓派 Pico PIO 模拟 UART:任意 GPIO 实现串口通信全解析

PIO 实现 UART 通信

1.实验准备

在下面实验中,我们需要将风雅一号板-Grove接口扩展板插入到风雅一号板-通用兼容扩展板上,同时使用 HY2.0-4P 接线连接风雅一号板-Grove接口扩展板UART1 接口和 GraftSense-基于CH340K的USB转TTL模块

微信图片_20260609195610_28_4.jpg

1280X1280.JPEG

接着使用 USB-AMiniUSB 数据线连接GraftSense-基于CH340K的USB转TTL模块上的 USB 接口和电脑,实物连接图如下所示:

1280X1280 (1).JPEG

除此之外,我们首先需要将 MiniUSB 数据线连接到风雅一号板-通用兼容扩展板上的串口,这里,GP0GP1 是树莓派 Pico 的硬件串口 UART0 的默认引脚:

1280X1280 (1).PNG

插入后实物图如下:

ccf1d6e9-a7b2-4e23-9910-5c9e786f952c.jfif

引脚连接情况如下所示:

ScreenShot_2026-06-09_194953_543.png

2.PIO 实现 UART 发送功能

这里,我们使用 PIO 实现 UART 串口发送功能,首先,我们回忆一下串口通信协议,最简单的情况下(无校验位),它包括 1 个起始位、8 个数据位和一个停止位,空闲状态时保存高电平:

7628e255-8223-4e6e-b99a-2d9d103785f1.png

我们可以在 PIO 程序中使用 set 指令置高电平或低电平来发送起始位和停止位,使用 out 指令将 TX FIFO 中数据发送的 OSR 移位寄存器中从而发送数据位。在 MicroPython 程序中,通过 StateMachine.put() 方法将待发送数据推入 TX FIFO 中来实现串口发送功能。

以下代码可以在我们提供的资料包中 elegance-devkit v1\Demo\35 PIO_UART_TX 文件夹找到。

完整代码如下:

# Python env   : MicroPython v1.23.0
# -*- coding: utf-8 -*-        
# @Time    : 2024/7/28 下午1:55   
# @Author  : 李清水            
# @File    : main.py       
# @Description : PIO类实验,在PIO程序中实现UART串口通信

# ======================================== 导入相关模块 ========================================

# 导入时间相关的模块
import time
# 导入RP2040相关的模块
from rp2 import PIO, StateMachine, asm_pio
# 导入硬件相关的模块
from machine import Pin, UART

# ======================================== 全局变量 ============================================

# 定义了 UART 的波特率为115200
UART_BAUD = 115200
# 定义了 PIO 使用的起始引脚编号为 4
PIN_BASE = 4
# 串口发送计数
UART_TX_COUNT = 0

# ======================================== 功能函数 ============================================

# 计时装饰器,用于计算函数运行时间
def timed_function(f: callable, *args: tuple, **kwargs: dict) -> callable:
    """
​    计时装饰器,用于计算并打印函数/方法运行时间。

​    Args:
​        f (callable): 需要传入的函数/方法
​        args (tuple): 函数/方法 f 传入的任意数量的位置参数
​        kwargs (dict): 函数/方法 f 传入的任意数量的关键字参数

​    Returns:
​        callable: 返回计时后的函数
​    """
​    ​myname = str(f).split(' ')[1]

    def new_func(*args: tuple, **kwargs: dict) -> any:
        t: int = time.ticks_us()
        result = f(*args, **kwargs)
        delta: int = time.ticks_diff(time.ticks_us(), t)
        print('Function {} Time = {:6.3f}ms'.format(myname, delta / 1000))
        return result

    return new_func

# 使用@asm_pio装饰器定义一个 PIO 程序
# 侧集引脚初始化为高电平,OUT引脚初始化为高电平,输出数据方向为向右
@asm_pio(sideset_init=PIO.OUT_HIGH, out_init=PIO.OUT_HIGH, out_shiftdir=PIO.SHIFT_RIGHT)
def uart_tx() -> None:
    """
​    PIO实现UART发送逻辑。

​    Returns:
​        None
​    """
​    ​# 从 TX FIFO 中取出一个字节,等待直到有数据可用
    pull()
    # 初始化位计数器x的值为7,同时将引脚置低电平 7 + 1 个周期,相当于发送一个起始位
    set(x, 7)  .side(0)       [7]
    # 定义了一个标签 "bitloop",用于后续跳转
    label("bitloop")
    # 将数据位从OSR移至pins,OUT指令执行需要一个周期,延时6个周期,总共花费 6+1 = 7个周期
    out(pins, 1)              [6]
    # 将x寄存器递减1,若x不为0,则跳转到bitloop标签处,继续数据发送循环
    # jmp指令执行花费一个周期,总共花费 7+1 = 8个周期,一次bitloop循环发送一个数据位
    jmp(x_dec, "bitloop")
    # 无操作侧集操作置高电平并延时6个周期,总共花费 6+1 = 7个周期
    # pull指令执行花费一个周期,总共花费 7+1 = 8个周期,发送一个停止位
    nop()      .side(1)       [6]

# pio_uart_print函数,用于通过PIO实现UART发送字符串
@timed_function
def pio_uart_print(sm: StateMachine, s: str) -> None:
    """
​    通过PIO实现UART发送字符串。

​    Args:
​        sm (StateMachine): 使用的PIO状态机实例。
​        s (str): 要发送的字符串。

​    Returns:
​        None
​    """
​    ​# 遍历字符串中的每个字符
    for c in s:
        # 将一个字推送到状态机的 TX FIFO
        sm.put(ord(c))

# 硬件UART外设发送数据
@timed_function
def hardware_uart_print(uart_obj: UART, s: str) -> None:
    """
​    硬件UART外设发送数据。

​    Args:
​        uart_obj (UART): 使用的UART硬件串口外设实例。
​        s (str): 要发送的字符串。

​    Returns:
​        None
​    """
​    ​uart_obj.write(s)
    # 阻塞直到发送完成
    while not uart.txdone():
        pass

# ======================================== 自定义类 ============================================

# ======================================== 初始化配置 ==========================================

# 创建一个状态机0,写入PIO程序uart_tx,时钟周期为8 * UART_BAUD,使用引脚4作为侧集引脚和输出引脚
sm = StateMachine(0, uart_tx, freq=8 * UART_BAUD, sideset_base=Pin(PIN_BASE), out_base=Pin(PIN_BASE))
# 启动状态机
sm.active(1)
# 创建一个串口实例
uart = UART(0, UART_BAUD)
# 串口外设初始化
uart.init(baudrate  = UART_BAUD,
          bits      = 8,
          parity    = None,
          stop      = 1,
          tx        = 0,
          rx        = 1,
          timeout   = 100)

# ========================================  主程序  ===========================================

while True:
    # 延时1秒
    time.sleep(1)
    # PIO实现UART发送字符串
    pio_uart_print(sm, "UART TX DATA\r\n")
    # 硬件UART外设发送字符串
    hardware_uart_print(uart, "UART TX DATA\r\n")

PIO 程序部分,我们进行了如下工作:

ddb8360e-c03b-488c-9466-9cf1e5537e69.png

  1. TX FIFO​中取出一个字节:​程序首先等待直到 TX FIFO 中有数据可用。这是通过 pull 指令实现的,它会检查 FIFO 是否为空,如果不为空,则将一个字节的数据从 FIFO 中取出
  2. ​初始化位计数器 x:​接下来,程序将位计数器 x 设置为 7,这意味着程序将发送 8 个数据位
  3. ​发送起始位:​程序将引脚置低电平 7 个周期,再加上一个额外的周期来发送起始位,这是通过 .side(0) 指令实现的
  4. 进入 bitloop​循环:​程序定义了一个名为 bitloop 的标签,开始发送数据位:
    1. ​发送数据位:​bitloop 循环中,程序将数据位从输出移位寄存器(OSR)移动到引脚。这是通过 out(pins, 1) 指令实现的,这个指令会执行一个周期,程序还会在此之后延迟 6 个周期
    2. ​递减位计数器 x:​在每个数据位发送完成后,程序会将位计数器 x 递减 1
    3. ​判断是否发送完所有数据位:​如果位计数器 x 不为 0,程序将跳回到 bitloop 标签处,继续发送下一个数据位。否则,程序将跳出循环
  5. ​发送停止位:​当所有数据位发送完成后,程序将发送一个停止位。这是通过 nop() 指令实现的,它会执行一个时钟周期。然后,程序再次延迟 6 个周期,再加上 pull 指令一共延时 8 个周期,发送一个停止位

这里,我们将 PIO 状态机的时钟周期设置为 8 * UART_BAUD,保证 UART 时序正确。在主循环中,我们使用 pio_uart_print() 函数和 hardware_uart_print() 函数调用 PIO 和硬件 UART 外设发送同一个字符串并对比二者运行速度。

烧录代码,打开远程终端,可以看到输出两个函数的运行速度:

b9c12d7b-27ab-4cc3-ab35-d4ca5df6f120.png

这里,可以看到通过 PIO 实现 UART 发送 14 个字节数据花费时间为 0.924ms = 924us,在 115200 波特率下,发送一个字节数据所用时间为 8/115200 s = 69.4 us,我们发送 14 个字节花费时间应该是 69.4 * 14 ≈ 896 us,近似于 PIO 实现 UART 发送 14 个字节数据花费时间(考虑到还有解释器开销时间,如类型检查、函数调用等)。

我们看到,通过硬件 UART 外设发送 14 个字节数据花费时间为 0.127 ms,注意这里并非实际的 UART 发送时间,而是 MicroPython 函数执行和调用底层 C 函数的时间:

  1. 首先将字符串 s 的指针传递给硬件 UART C 语言驱动。
  2. 驱动将数据复制到 UART 的硬件 FIFO 缓冲区。
  3. 立即返回,无需等待数据全部发送完成。

这里,@timed_function 装饰器记录的仅是​Python,C 接口调用 + 数据复制到 FIFO 的时间​,而非物理层传输时间。

如果想要测量硬件 UART 的真实发送时间,可以将下面注释的等待发送串口完毕的代码去除:

48a323bc-58f6-4976-8101-7aff78667545.png

再次运行,可以看到终端输出的串口发送完毕时间为 1.2ms 左右:

93d99a11-3d89-431c-80d1-04d1c9729713.png

GraftSense-基于CH340K的USB转TTL模块 通过数据线连接电脑,打开 xcom 串口助手,我们看到 PIO 实现 UART 发送可以正常执行:

e297a09c-5586-498a-992f-35b9670a5747.png

9.gif

相比于使用硬件 UART 外设进行串口通信,使用 PIO 实现串口通信具有以下优势:

  1. ​任意引脚支持:​PIO 可以使用任意 GPIO 引脚作为串口发送引脚,而硬件 UART 通常绑定到特定引脚;并且,单个 PIO 模块可同时实现多个串口发送通道(如 4 个状态机实现 4 路 UART TX),而硬件 UART 数量有限(如 RP2040 仅 2 个硬件 UART)。
  2. ​灵活扩展:​可通过 PIO 实现半双工、全双工、单线串口等非标准通信方式,也实现复杂的帧格式(如添加自定义前导码、CRC 校验等),同时树莓派 Pico 任意引脚都支持 PIO,在我们自制电路板时,可以大大减少 PCB 布局的复杂性。

3.PIO 实现 UART 接收功能

这里,我们使用 PIO 实现 UART 串口接收功能,首先,我们回忆一下串口通信协议,最简单的情况下(无校验位),它包括 1 个起始位、8 个数据位和 1 个停止位,空闲状态时保持高电平:

7628e255-8223-4e6e-b99a-2d9d103785f1.png

当通信启动时,总线会从空闲高电平跳转为低电平,这一低电平就是起始位;随后依次传输 8 个数据位(时序图中从 LSBMSB);数据位传输完成后,总线会恢复为高电平,这一高电平就是停止位,标志着一帧数据接收完成。

我们可以在 PIO 程序中:

  • 通过 wait 指令检测起始位(等待引脚变为低电平);
  • 使用 in_ 指令将引脚电平读取到 ISR 移位寄存器中,从而获取数据位;
  • 通过 jmp 指令检查停止位是否为高电平(确保通信有效),若停止位正常,再通过 push 指令将 ISR 中的数据推入 RX FIFO

在 MicroPython 程序中,通过 StateMachine.get () 方法从 RX FIFO 中读取已接收的数据,即可完成串口接收功能。

以下代码可以在我们提供的资料包中 elegance-devkit v1\Demo\76 PIO_UART_RX 文件夹找到。

完整代码如下:

# Python env   : MicroPython v1.23.0
# -*- coding: utf-8 -*-        
# @Time    : 2025/12/23 下午3:56   
# @Author  : 李清水            
# @File    : main.py       
# @Description : PIO类实验,实现UART串口接收

# ======================================== 导入相关模块 ========================================

import time
from rp2 import PIO, StateMachine, asm_pio
from machine import Pin, UART

# ======================================== 全局变量 ============================================

# 定义了 UART 的波特率为115200
UART_BAUD = 115200
# 定义了 PIO UART接收使用的引脚编号为5(GP5)
PIO_RX_PIN_NUM = 5
# 定义了串口接收字符串的终止符
UART_TERMINATOR = '\r'
# 定义了串口接收字符串的最大长度(防止缓冲区溢出)
UART_MAX_STR_LEN = 128

# ======================================== 功能函数 ============================================

# 计时装饰器,用于计算函数运行时间
def timed_function(f: callable, *args: tuple, **kwargs: dict) -> callable:
    """
​    计时装饰器,用于计算并打印函数/方法运行时间。

​    Args:
​        f (callable): 需要传入的函数/方法
​        args (tuple): 函数/方法 f 传入的任意数量的位置参数
​        kwargs (dict): 函数/方法 f 传入的任意数量的关键字参数

​    Returns:
​        callable: 返回计时后的函数
​    """
​    ​myname = str(f).split(' ')[1]

    def new_func(*args: tuple, **kwargs: dict) -> any:
        t: int = time.ticks_us()
        result = f(*args, **kwargs)
        delta: int = time.ticks_diff(time.ticks_us(), t)
        print('Function {} Time = {:6.3f}ms'.format(myname, delta / 1000))
        return result

    return new_func

# 使用@asm_pio装饰器定义一个 PIO 程序
# 输入数据方向为向右移位
@asm_pio(in_shiftdir=PIO.SHIFT_RIGHT)
def uart_rx() -> None:
    """
​    PIO实现UART接收逻辑(8位数据位,1位起始位,1位停止位)。

​    Returns:
​        None
​    """
​    ​label("start")
    # 等待起始位(低电平)
    wait(0, pin, 0)
    # 设置数据计数器x为7(共8位数据),延时到第一个数据位中间(10个周期)
    set(x, 7)                 [10]
    # 循环读取8位数据
    label("bitloop")
    in_(pins, 1)
    jmp(x_dec, "bitloop")     [6]
    # 检查停止位(高电平为正常)
    jmp(pin, "good_stop")
    # 停止位错误:触发中断,等待引脚空闲,放弃数据
    irq(block, 4)
    wait(1, pin, 0)
    jmp("start")
    # 停止位正常:推送数据到FIFO(block表示阻塞直到FIFO有空间)
    label("good_stop")
    push(block)

def uart_break_handler(sm: StateMachine) -> None:
    """
​    PIO检测到串口帧错误/停止位错误时的中断处理函数。

​    Args:
​        sm (StateMachine): 触发中断的PIO状态机实例

​    Returns:
​        None
​    """
​    ​print("Recv Break/Frame Error at: {}ms".format(time.ticks_ms()))

@timed_function
def pio_uart_read_byte(sm: StateMachine) -> int:
    """
​    从PIO状态机读取一个UART接收的字节(带计时装饰器)。

​    Args:
​        sm (StateMachine): 使用的PIO状态机实例

​    Returns:
​        int: 接收的8位字节数据(0-255)
​    """
​    ​# 从PIO FIFO读取32位数据,右移24位提取有效8位(因右移位存储在高8位)
    received_data = sm.get()
    received_byte = received_data >> 24
    return received_byte

def pio_uart_read_string(sm: StateMachine, max_length: int = UART_MAX_STR_LEN, terminator: str = UART_TERMINATOR) -> str:
    """
​    从PIO状态机读取UART接收的字符串(直到终止符或最大长度)。

​    Args:
​        sm (StateMachine): 使用的PIO状态机实例
​        max_length (int, optional): 最大读取长度,防止无限阻塞。默认值为UART_MAX_STR_LEN
​        terminator (str, optional): 字符串终止符(如换行符)。默认值为UART_TERMINATOR

​    Returns:
​        str: 接收的字符串
​    """
​    ​received_chars = []
    current_length = 0
    # 循环读取字节直到达到最大长度或遇到终止符
    while current_length < max_length:
        byte = pio_uart_read_byte(sm)
        char = chr(byte)
        received_chars.append(char)
        current_length += 1
        # 遇到终止符则停止读取
        if char == terminator:
            break
    # 拼接字符为字符串并返回
    return ''.join(received_chars)

# ======================================== 自定义类 ============================================

# ======================================== 初始化配置 ==========================================

# 初始化PIO接收引脚(上拉输入,防止浮空)
pio_rx_pin = Pin(PIO_RX_PIN_NUM, Pin.IN, Pin.PULL_UP)

# 创建PIO状态机0,加载uart_rx程序,时钟频率为8*UART_BAUD(时序匹配的倍频)
# in_base: 输入引脚的起始编号,jmp_pin: 跳转判断使用的引脚(与输入引脚相同)
sm = StateMachine(
    0,
    uart_rx,
    freq=8 * UART_BAUD,
    in_base=pio_rx_pin,
    jmp_pin=pio_rx_pin
)

# 绑定中断处理函数(接收错误时触发)
sm.irq(uart_break_handler)
# 激活状态机(开始接收数据)
sm.active(1)

# ========================================  主程序  ===========================================

while True:
    # 读取字符串(直到换行符)并打印
    received_str = pio_uart_read_string(sm)
    print("Received String: {}".format(received_str))

PIO 程序部分,我们进行了如下工作:

06b6bb66-1506-4a96-9c10-08b03119b5fc.png

  1. 等待起始位​:通过 wait(0, pin, 0) 指令持续监测引脚状态,直到检测到引脚从空闲高电平跳转为低电平(即 UART 起始位),才进入后续流程;
  2. 初始化与延时对齐​:用 set(x, 7) 设置数据计数器 x(对应 8 位数据位的计数),同时通过 [10] 延时 10 个周期,确保后续采样时机对齐到第一个数据位的中间位置,提升采样准确性;
  3. 循环读取数据位​:通过 label("bitloop") 定义循环标签,使用 in_(pins, 1) 将当前引脚电平读取到 ISR 移位寄存器中;再通过 jmp(x_dec, "bitloop") 让计数器 x 递减并跳回循环(直到 x 减为 0),同时通过 [6] 延时 6 个周期,等待下一位数据的采样时机,完成 8 位数据位的读取;
  4. 停止位校验与数据处理​:数据位读取完成后,检查引脚是否为高电平(即 UART 停止位):
    1. 若停止位为高电平,执行 push(block) 指令,将 ISR 中存储的 8 位数据推送至 RX FIFO,供后续程序读取;
    2. 若停止位异常(非高电平),则通过 irq(block, 4) 触发停止位错误中断,随后用 wait(1, pin, 0) 等待引脚恢复高电平,再通过 jmp("start") 跳回起始位置,重新开始接收流程。

这里,我们还定义了两个函数:

def uart_break_handler(sm: StateMachine) -> None:
    """
​    PIO检测到串口帧错误/停止位错误时的中断处理函数。

​    Args:
​        sm (StateMachine): 触发中断的PIO状态机实例

​    Returns:
​        None
​    """
​    ​print("Recv Break/Frame Error at: {}ms".format(time.ticks_ms()))

@timed_function
def pio_uart_read_byte(sm: StateMachine) -> int:
    """
​    从PIO状态机读取一个UART接收的字节(带计时装饰器)。

​    Args:
​        sm (StateMachine): 使用的PIO状态机实例

​    Returns:
​        int: 接收的8位字节数据(0-255)
​    """
​    ​# 从PIO FIFO读取32位数据,右移24位提取有效8位(因右移位存储在高8位)
    received_data = sm.get()
    received_byte = received_data >> 24
    return received_byte

其中:

  • 串口错误中断处理函数 uart_break_handler:这个函数是 PIO 状态机的中断回调函数,专门处理串口接收时出现的帧错误(停止位错误),当 PIO 汇编程序检测到停止位不是高电平时(串口帧错误 /break 信号),会触发中断;
  • 字节读取函数 pio_uart_read_byte:这个函数是 PIO UART 接收的核心数据读取接口,从 PIO 状态机的 RX FIFO 中读取并解析出一个 8 位的 UART 接收字节。

在实际的串口通信场景中,我们很少只需要接收单个字节,更多是接收​字符串​(比如用户发送的一行指令、一段文本)。如果每次都手动循环调用 pio_uart_read_byte 读取字节,再手动拼接成字符串,会非常繁琐。为了方便使用,我们还封装了一个 pio_uart_read_string 函数:

def pio_uart_read_string(sm: StateMachine, max_length: int = UART_MAX_STR_LEN, terminator: str = UART_TERMINATOR) -> str:
    """
​    从PIO状态机读取UART接收的字符串(直到终止符或最大长度)。

​    Args:
​        sm (StateMachine): 使用的PIO状态机实例
​        max_length (int, optional): 最大读取长度,防止无限阻塞。默认值为UART_MAX_STR_LEN
​        terminator (str, optional): 字符串终止符(如换行符)。默认值为UART_TERMINATOR

​    Returns:
​        str: 接收的字符串
​    """
​    ​received_chars = []
    current_length = 0
    # 循环读取字节直到达到最大长度或遇到终止符
    while current_length < max_length:
        byte = pio_uart_read_byte(sm)
        char = chr(byte)
        received_chars.append(char)
        current_length += 1
        # 遇到终止符则停止读取
        if char == terminator:
            break
    # 拼接字符为字符串并返回
    return ''.join(received_chars)

其工作原理为:

  1. 不断调用带计时功能的底层函数 pio_uart_read_byte 从 PIO 状态机的 RX FIFO 中读取单个字节数据,将其转换为对应字符后存入字符列表,同时累计字符长度;
  2. 在循环过程中,函数会实时判断两个终止条件:
    1. 一是当前接收的字符长度是否达到预设的 max_length(默认由全局常量 UART_MAX_STR_LEN 指定,防止程序无限阻塞);
    2. 二是是否读取到预设的 terminator 终止符(默认是换行符 \n,为串口通信常用的字符串结束标志)。
  3. 只要满足其中一个条件就立即终止循环;
  4. 最后,函数将存储字符的列表拼接为完整的字符串并返回,从而实现从 PIO UART 中直接读取完整字符串的功能,无需开发者手动处理字节拼接和终止判断的细节。

这里,我们首先将 GraftSense-基于CH340K的USB转TTL模块 通过数据线连接电脑,打开 xcom 串口助手,注意点击发送新行:

8e544d22-cd80-4827-bdce-2d8a05a51af0.png

接着烧录代码,打开远程终端,在 xcom 串口助手中发送数据,终端输出如下:

微信图片_20260609200659_30_4.jpg

我们可以看到,当前可以正常使用 PIO 完成串口接收功能。

posted @ 2026-09-01 16:52  FreakStudio  阅读(12)  评论(0)    收藏  举报