树莓派 Pico PIO 模拟 UART:任意 GPIO 实现串口通信全解析
PIO 实现 UART 通信
1.实验准备
在下面实验中,我们需要将风雅一号板-Grove接口扩展板插入到风雅一号板-通用兼容扩展板上,同时使用 HY2.0-4P 接线连接风雅一号板-Grove接口扩展板的 UART1 接口和 GraftSense-基于CH340K的USB转TTL模块:


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

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

插入后实物图如下:
引脚连接情况如下所示:

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

我们可以在 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 程序部分,我们进行了如下工作:

- 从
TX FIFO中取出一个字节:程序首先等待直到TX FIFO中有数据可用。这是通过pull指令实现的,它会检查FIFO是否为空,如果不为空,则将一个字节的数据从FIFO中取出 - 初始化位计数器 x:接下来,程序将位计数器
x设置为 7,这意味着程序将发送 8 个数据位 - 发送起始位:程序将引脚置低电平 7 个周期,再加上一个额外的周期来发送起始位,这是通过
.side(0)指令实现的 - 进入
bitloop循环:程序定义了一个名为bitloop的标签,开始发送数据位:- 发送数据位:在
bitloop循环中,程序将数据位从输出移位寄存器(OSR)移动到引脚。这是通过out(pins, 1)指令实现的,这个指令会执行一个周期,程序还会在此之后延迟 6 个周期 - 递减位计数器 x:在每个数据位发送完成后,程序会将位计数器
x递减 1 - 判断是否发送完所有数据位:如果位计数器
x不为 0,程序将跳回到bitloop标签处,继续发送下一个数据位。否则,程序将跳出循环
- 发送数据位:在
- 发送停止位:当所有数据位发送完成后,程序将发送一个停止位。这是通过
nop()指令实现的,它会执行一个时钟周期。然后,程序再次延迟 6 个周期,再加上pull指令一共延时 8 个周期,发送一个停止位
这里,我们将 PIO 状态机的时钟周期设置为 8 * UART_BAUD,保证 UART 时序正确。在主循环中,我们使用 pio_uart_print() 函数和 hardware_uart_print() 函数调用 PIO 和硬件 UART 外设发送同一个字符串并对比二者运行速度。
烧录代码,打开远程终端,可以看到输出两个函数的运行速度:

这里,可以看到通过 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 函数的时间:
- 首先将字符串 s 的指针传递给硬件 UART C 语言驱动。
- 驱动将数据复制到 UART 的硬件 FIFO 缓冲区。
- 立即返回,无需等待数据全部发送完成。
这里,@timed_function 装饰器记录的仅是Python,C 接口调用 + 数据复制到 FIFO 的时间,而非物理层传输时间。
如果想要测量硬件 UART 的真实发送时间,可以将下面注释的等待发送串口完毕的代码去除:

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

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


相比于使用硬件 UART 外设进行串口通信,使用 PIO 实现串口通信具有以下优势:
- 任意引脚支持:
PIO可以使用任意 GPIO 引脚作为串口发送引脚,而硬件 UART 通常绑定到特定引脚;并且,单个 PIO 模块可同时实现多个串口发送通道(如 4 个状态机实现 4 路 UART TX),而硬件 UART 数量有限(如 RP2040 仅 2 个硬件 UART)。 - 灵活扩展:可通过
PIO实现半双工、全双工、单线串口等非标准通信方式,也实现复杂的帧格式(如添加自定义前导码、CRC 校验等),同时树莓派 Pico 任意引脚都支持 PIO,在我们自制电路板时,可以大大减少 PCB 布局的复杂性。
3.PIO 实现 UART 接收功能
这里,我们使用 PIO 实现 UART 串口接收功能,首先,我们回忆一下串口通信协议,最简单的情况下(无校验位),它包括 1 个起始位、8 个数据位和 1 个停止位,空闲状态时保持高电平:

当通信启动时,总线会从空闲高电平跳转为低电平,这一低电平就是起始位;随后依次传输 8 个数据位(时序图中从 LSB 到 MSB);数据位传输完成后,总线会恢复为高电平,这一高电平就是停止位,标志着一帧数据接收完成。
我们可以在 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 程序部分,我们进行了如下工作:

- 等待起始位:通过
wait(0, pin, 0)指令持续监测引脚状态,直到检测到引脚从空闲高电平跳转为低电平(即 UART 起始位),才进入后续流程; - 初始化与延时对齐:用
set(x, 7)设置数据计数器 x(对应 8 位数据位的计数),同时通过[10]延时 10 个周期,确保后续采样时机对齐到第一个数据位的中间位置,提升采样准确性; - 循环读取数据位:通过
label("bitloop")定义循环标签,使用in_(pins, 1)将当前引脚电平读取到 ISR 移位寄存器中;再通过jmp(x_dec, "bitloop")让计数器 x 递减并跳回循环(直到 x 减为 0),同时通过[6]延时 6 个周期,等待下一位数据的采样时机,完成 8 位数据位的读取; - 停止位校验与数据处理:数据位读取完成后,检查引脚是否为高电平(即 UART 停止位):
- 若停止位为高电平,执行
push(block)指令,将 ISR 中存储的 8 位数据推送至 RX FIFO,供后续程序读取; - 若停止位异常(非高电平),则通过
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)
其工作原理为:
- 不断调用带计时功能的底层函数
pio_uart_read_byte从 PIO 状态机的 RX FIFO 中读取单个字节数据,将其转换为对应字符后存入字符列表,同时累计字符长度; - 在循环过程中,函数会实时判断两个终止条件:
- 一是当前接收的字符长度是否达到预设的
max_length(默认由全局常量UART_MAX_STR_LEN指定,防止程序无限阻塞); - 二是是否读取到预设的
terminator终止符(默认是换行符\n,为串口通信常用的字符串结束标志)。
- 一是当前接收的字符长度是否达到预设的
- 只要满足其中一个条件就立即终止循环;
- 最后,函数将存储字符的列表拼接为完整的字符串并返回,从而实现从 PIO UART 中直接读取完整字符串的功能,无需开发者手动处理字节拼接和终止判断的细节。
这里,我们首先将 GraftSense-基于CH340K的USB转TTL模块 通过数据线连接电脑,打开 xcom 串口助手,注意点击发送新行:

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

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

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