1. 项目缘起:为什么要在Pico上折腾SPI?
如果你玩过树莓派Pico,大概率会先从点亮个LED、读个按键开始,然后很快就会发现GPIO不够用了,或者想接个屏幕、读个传感器,但I2C的速度又跟不上。这时候,SPI就该登场了。我手头这个项目,核心就是基于Raspberry Pi Pico,把SPI(Serial Peripheral Interface)这个通信协议给彻底摸透、用活。你可能在各种教程里见过SPI,但很多都是点到为止,讲个初始化、发个“Hello World”就结束了。实际用起来,从选引脚、配模式、到处理时序冲突和提升稳定性,坑多得是。
SPI对于Pico这类微控制器来说,就像它的高速数据通道。I2C虽然省引脚,但它是半双工,速度也有限(标准模式100kHz,快速模式400kHz)。而SPI是全双工,主频轻松上到几十兆赫兹,驱动TFT屏幕、读写SD卡、连接高速ADC/DAC,这些活离了SPI还真不好办。Pico的硬件SPI外设功能挺强,但官方文档和基础例程往往只展示了冰山一角。这次,我们不满足于“点灯”,目标是实现一个稳定、高效、能应对复杂场景的SPI通信框架,并把这些实战中积累的经验和踩过的坑,系统地分享出来。
2. 核心概念扫盲:SPI协议到底在“约法三章”什么?
在动手写代码前,我们必须把SPI的“游戏规则”搞清楚。很多人觉得SPI比I2C简单,因为它没有地址概念,但实际上,它的灵活性背后藏着不少需要明确约定的细节。
2.1 SPI的“四线制”与“三线制”
最经典的SPI需要4根线:
- SCK (Serial Clock):时钟线,由主机产生,从机根据这个时钟来同步收发数据。这是SPI的“指挥棒”。
- MOSI (Master Out Slave In):主机输出,从机输入。数据从主机流向从机。
- MISO (Master In Slave Out):主机输入,从机输出。数据从从机流向主机。
- SS/CS (Slave Select / Chip Select):片选线,低电平有效。主机通过拉低这根线来“选中”要通信的从机。这是SPI多设备连接的关键。
注意:很多资料,包括Pico的SDK,会用
SPI_RX和SPI_TX来指代MISO和MOSI,这是从主机视角命名的。对于主机,TX就是MOSI(主机发送),RX就是MISO(主机接收)。理解这一点对配置代码很重要。
有时候,比如只向从机写数据而不需要读回(例如驱动只有输入的数字电位器),或者使用半双工模式的设备,可以省去MISO线,变成“三线制”。但全双工通信才是SPI的完全体。
2.2 核心参数:模式(CPOL, CPHA)与速率
这是SPI最容易搞混的地方,直接关系到通信能否成功。
- CPOL (Clock Polarity,时钟极性):决定了SCK空闲时的电平。
- CPOL=0:SCK空闲时为低电平。
- CPOL=1:SCK空闲时为高电平。
- CPHA (Clock Phase,时钟相位):决定了数据在时钟的哪个边沿被采样(捕获)。
- CPHA=0:数据在SCK的第一个边沿(如果CPOL=0,就是上升沿;如果CPOL=1,就是下降沿)被采样,在相反的边沿变化。
- CPHA=1:数据在SCK的第二个边沿被采样,在第一个边沿变化。
这两个参数组合起来,就构成了常见的SPI模式0, 1, 2, 3:
- 模式0 (Mode 0):CPOL=0, CPHA=0。空闲低电平,在上升沿采样数据。这是最常用的模式。
- 模式1 (Mode 1):CPOL=0, CPHA=1。空闲低电平,在下降沿采样数据。
- 模式2 (Mode 2):CPOL=1, CPHA=0。空闲高电平,在下降沿采样数据。
- 模式3 (Mode 3):CPOL=1, CPHA=1。空闲高电平,在上升沿采样数据。
怎么记?我个人的经验是:永远以从机设备的数据手册为准!在连接一个新的SPI设备(如Flash芯片、屏幕驱动IC)时,第一件事就是去手册里找“SPI mode”或“CPOL/CPHA”的说明。主机(Pico)的模式必须与从机严格匹配,否则读回来的全是乱码。
- 速率 (Baud Rate):SPI的通信时钟频率。Pico的硬件SPI可以设置很高的速率(理论可达系统时钟的一半),但实际速率受限于:
- 从机设备支持的最高速率(看手册!)。
- 导线长度和布线质量,线太长或干扰大,高速率下数据会出错。
- Pico的GPIO输出能力。通常,在面包板或杜邦线连接时,我保守起见会从1MHz或2MHz开始测试,稳定后再逐步提高。
2.3 数据格式:MSB先行还是LSB先行?
SPI协议本身没有规定先传输字节的最高位(MSB)还是最低位(LSB)。这又是一个需要主从双方约定的参数。绝大多数设备默认是MSB先行,但有些设备(比如某些型号的RFID读卡器芯片)可能是LSB先行。同样,需要在从机设备的数据手册中确认。Pico的SPI外设可以灵活配置数据传输顺序。
3. Pico的硬件SPI外设与引脚分配实战
树莓派Pico有两个硬件SPI控制器:spi0和spi1。它们功能相同,可以独立工作。硬件SPI的优势是效率高,不占用CPU时间,在后台自动完成数据的移位和收发,CPU可以腾出手来做其他事情。
3.1 硬件SPI的固定引脚映射
这是第一个容易踩坑的点。Pico的硬件SPI引脚不是任意GPIO都可以的,它有固定的“功能复用”映射表。以spi0为例:
| SPI功能 | GPIO引脚 (spi0) | GPIO引脚 (spi1) | 备注 |
|---|---|---|---|
| SCK | 2, 6, 18 | 10, 14 | 必须从这几组里选一个 |
| TX (MOSI) | 3, 7, 19 | 11, 15 | 必须从这几组里选一个 |
| RX (MISO) | 0, 4, 16 | 8, 12 | 必须从这几组里选一个 |
| CSn | 1, 5, 17 | 9, 13 | 硬件片选,可选 |
重要提示:上表中的CSn引脚是硬件SPI控制器自带的片选信号线。使用它时,控制器会在数据传输前后自动控制该引脚的电平,非常方便。但是,更多时候我们使用“软件片选”,即任意一个GPIO口来手动控制,因为一个SPI总线可能挂多个设备,每个设备都需要独立的片选线,而硬件CSn通常只有一根。
3.2 初始化配置代码详解
我们使用Pico官方推荐的C/C++ SDK进行开发。下面是一个初始化spi0的典型代码片段,我会逐行加上注释:
#include "hardware/spi.h" #include "hardware/gpio.h" #define SPI_PORT spi0 #define PIN_SPI_SCK 2 #define PIN_SPI_MOSI 3 #define PIN_SPI_MISO 4 #define PIN_SPI_CS 5 // 这里我们使用软件片选,所以用一个普通GPIO void spi_init_custom() { // 1. 初始化SPI端口,指定速率 // 这里设置SPI波特率为1MHz (1 * 1000 * 1000) // 实际时钟 = 系统时钟 / (CPHA + CPOL?) 不对,是系统时钟 / (波特率分频系数)。 // spi_init函数内部会计算合适的分频系数。 spi_init(SPI_PORT, 1000 * 1000); // 2. 设置SPI格式 // 参数依次为:SPI端口、数据位宽(8)、SPI模式(CPOL, CPHA)、MSB先行 // 这里设置为模式0,MSB先行,8位数据。这是最常见配置。 spi_set_format(SPI_PORT, 8, SPI_CPOL_0, SPI_CPHA_0, SPI_MSB_FIRST); // 3. 初始化GPIO功能复用 // 将特定的GPIO引脚设置为SPI功能,而不是普通的输入输出。 gpio_set_function(PIN_SPI_SCK, GPIO_FUNC_SPI); gpio_set_function(PIN_SPI_MOSI, GPIO_FUNC_SPI); gpio_set_function(PIN_SPI_MISO, GPIO_FUNC_SPI); // 4. 初始化片选引脚(软件控制) gpio_init(PIN_SPI_CS); gpio_set_dir(PIN_SPI_CS, GPIO_OUT); gpio_put(PIN_SPI_CS, 1); // 初始化为高电平(不选中) // (可选)5. 提升GPIO驱动能力,如果连接线较长或负载较重 // gpio_set_drive_strength(PIN_SPI_MOSI, GPIO_DRIVE_STRENGTH_8MA); // gpio_set_slew_rate(PIN_SPI_MOSI, GPIO_SLEW_RATE_FAST); }为什么这么配置?
spi_init不仅设置速率,还会启用SPI外设的时钟。速率值是一个“期望值”,SDK会选择一个最接近的、可行的分频系数。spi_set_format是配置的核心,必须与从机设备匹配。数据位宽除了8,也可以是4到16之间的值(取决于外设支持)。- GPIO功能复用是关键步骤。如果你忘记调用
gpio_set_function,即使引脚号对了,SPI信号也无法输出到物理引脚上。 - 软件片选初始化为高电平,是因为SPI片选通常是低电平有效。先拉高,确保设备处于未选中状态。
4. 软件片选 vs. 硬件片选:灵活性与效率的权衡
4.1 软件片选:绝对的掌控力
就像上面代码展示的,我们用一个普通GPIO(如GPIO5)作为片选。通信流程完全手动控制:
void write_to_spi_device(uint8_t *data, size_t len) { gpio_put(PIN_SPI_CS, 0); // 拉低片选,选中设备 sleep_us(1); // 微小延时,等待从机准备就绪(视设备而定) spi_write_blocking(SPI_PORT, data, len); // 写入数据 sleep_us(1); // 微小延时,保证数据稳定 gpio_put(PIN_SPI_CS, 1); // 拉高片选,结束通信 }优点:
- 数量无限:你可以用任意多个GPIO控制任意多个从机。
- 时序可控:可以在片选拉低后、时钟产生前,插入必要的延时(
sleep_us),这对某些“慢热”的从机设备是必须的。 - 操作直观:代码逻辑清晰,便于调试。
缺点:
- CPU开销:每次通信前后都需要CPU干预来操作GPIO。
- 时序精度:如果使用
sleep_us,其精度受系统滴答中断影响,在极高频率或苛刻时序下可能不够精确。
4.2 硬件片选:解放CPU
如果使用SPI控制器自带的CSn引脚(如spi0的GPIO1/5/17),代码会简化:
// 假设使用GPIO17作为硬件CS #define PIN_SPI_HW_CS 17 gpio_set_function(PIN_SPI_HW_CS, GPIO_FUNC_SPI); // 在传输数据时,只需要调用: spi_write_blocking(SPI_PORT, data, len); // CSn引脚会自动在传输开始时拉低,传输结束后拉高。优点:
- 自动高效:完全由硬件控制,无需CPU介入,时序精准。
- 代码简洁。
缺点:
- 数量有限:每个SPI控制器通常只有1-2个专用的硬件CSn引脚。
- 灵活性差:无法在CS有效期间插入自定义延时,也无法在连续传输多段数据时保持CS有效(除非使用DMA构造特殊的数据帧)。
4.3 我的选择建议
- 单一高速设备:如果总线只挂一个设备,且该设备时序要求严格,优先考虑硬件片选。
- 多设备系统:99%的情况使用软件片选。它带来的灵活性和可扩展性远大于那一点点CPU开销。在连接SD卡、TFT屏、多个传感器时,软件片选是更实际的选择。
- 混合使用:我曾在一个项目里,对实时性要求最高的ADC芯片用了硬件片选(spi0的CS0),而对另外两个配置后就不常读写的传感器用了软件片选,物尽其用。
5. 阻塞、非阻塞与DMA:三种传输方式的性能抉择
Pico的SDK提供了三种SPI数据传输方式,应对不同场景。
5.1 阻塞式传输:简单可靠
我们上面用的spi_write_blocking和spi_read_blocking就是阻塞式的。函数会一直等待,直到整个数据传输完成才返回。
uint8_t tx_buf[4] = {0x01, 0x02, 0x03, 0x04}; uint8_t rx_buf[4] = {0}; // 阻塞式写入 spi_write_blocking(SPI_PORT, tx_buf, 4); // 执行到这里时,4个字节已经发送完毕。 // 阻塞式读取(同时需要发送时钟,通常发送0xFF或0x00) spi_read_blocking(SPI_PORT, 0xFF, rx_buf, 4);适用场景:初始化配置、单次读写、对实时性要求不高的任务。代码简单,不易出错。
5.2 非阻塞式传输:提高CPU利用率
使用spi_write16_read16_blocking这类函数?不,那是阻塞的。真正的非阻塞需要用到spi_get_hw(SPI_PORT)直接操作底层寄存器,或者使用spi_write16_nonblocking等函数配合状态查询。但SDK对非阻塞的支持封装较少,更常用的高级方式是DMA。
5.3 DMA传输:终极性能方案
DMA(Direct Memory Access)是微控制器里的“专职快递员”。它可以在不打扰CPU的情况下,在内存和外设(如SPI)之间搬运数据。对于大量、连续的SPI数据传输(例如刷新整块TFT屏幕、读写SD卡扇区),DMA能极大解放CPU。
Pico的SDK提供了DMA与SPI配合的API,但配置稍复杂。核心思路是:
- 配置DMA通道,告诉它源地址(内存中的数组)、目标地址(SPI发送数据寄存器)、传输数量。
- 配置SPI,使其在发送完一个数据后自动向DMA请求下一个数据。
- 启动DMA传输,然后CPU就可以去干别的事了。
- 通过中断或轮询检查DMA传输是否完成。
一个简化的DMA发送示例框架:
#include "hardware/dma.h" int dma_chan; dma_channel_config c; // 准备要发送的数据 uint8_t screen_buffer[1024]; // ... 填充 screen_buffer ... // 获取一个DMA通道 dma_chan = dma_claim_unused_channel(true); // 配置DMA通道 c = dma_channel_get_default_config(dma_chan); channel_config_set_transfer_data_size(&c, DMA_SIZE_8); // 每次传输8位(一个字节) channel_config_set_read_increment(&c, true); // 源地址(内存)递增 channel_config_set_write_increment(&c, false); // 目标地址(SPI数据寄存器)固定 channel_config_set_dreq(&c, DREQ_SPI0_TX); // 数据请求来自SPI0的发送端 // 配置DMA:从内存(buffer)搬运到外设(&spi_get_hw(SPI_PORT)->dr) dma_channel_configure(dma_chan, &c, &spi_get_hw(SPI_PORT)->dr, // 目标地址:SPI数据寄存器 screen_buffer, // 源地址 sizeof(screen_buffer), // 传输数量 true); // 立即开始 // 此时,CPU空闲。可以在这里执行其他任务,比如准备下一帧数据。 // 等待DMA传输完成 dma_channel_wait_for_finish_blocking(dma_chan); // 传输完成,清理DMA通道(可选,或配置为循环模式)DMA使用的经验之谈:
- 内存对齐:DMA访问的内存地址最好4字节对齐(
__attribute__((aligned(4)))),效率最高。 - 缓冲区管理:使用双缓冲区(Ping-Pong Buffer)是高级技巧。当DMA在发送缓冲区A时,CPU填充缓冲区B,然后无缝切换,可以实现无卡顿的连续数据流,这在视频播放或高速数据采集时非常有用。
- 调试难度:DMA问题较难调试。一旦配置错误,可能 silently fail(静默失败)。务必从简单例子开始,确保基础传输正确,再增加复杂度。
6. 实战避坑指南:那些手册上不会写的细节
6.1 上拉电阻的必要性
SPI总线需要上拉电阻吗?I2C是必须的,但SPI通常不需要。因为SPI的主机始终驱动着MOSI和SCK,从机在选中时驱动着MISO,都是推挽输出,有明确的高低电平。但是,在以下情况,加上拉电阻(通常4.7kΩ-10kΩ)是明智的:
- 总线有多主机可能(虽然SPI标准是单主机,但某些特殊设计会有)。
- 从机设备在未被选中时,其MISO引脚处于高阻态(Hi-Z)。如果这根线悬空,容易受到噪声干扰。加上拉可以将其稳定在高电平。
- 片选线(CS)如果可能因为程序跑飞而处于未定义状态,加上拉可以确保设备默认不被选中,防止总线冲突。
我的习惯是,在PCB设计时,会给SCK、MOSI、MISO和所有软件片选线都预留上拉电阻的位置(0Ω电阻或焊盘)。在面包板实验时,如果通信不稳定,首先尝试在SCK和MOSI上加10kΩ上拉到3.3V。
6.2 时序匹配:等待从机就绪
很多SPI设备,尤其是那些包含慢速存储单元(如EEPROM)或复杂操作(如ADC转换)的,在片选拉低后,需要一段准备时间(tCSS)才能响应命令。直接发送命令会导致失败。解决方案:在gpio_put(CS, 0)后,加一个sleep_us(1)或busy_wait_us(1)。这个延时时间需要查阅从机数据手册的“AC Characteristics”章节。对于大多数现代芯片,1微秒足矣,但对于一些老式芯片,可能需要几微秒甚至几十微秒。
6.3 全双工与半双工数据帧处理
SPI是全双工,主机在发送的同时也在接收。spi_write_blocking函数会忽略接收到的数据。spi_read_blocking需要发送一个虚拟字节(如0xFF)来产生时钟。很多时候,我们需要发送命令并接收响应,这是一个“先写后读”的过程。注意:在写和读之间,片选必须保持有效(低电平)。错误的做法是写完后拉高CS,再拉低CS去读。正确的做法是:
gpio_put(PIN_CS, 0); sleep_us(1); // 发送命令字节 spi_write_blocking(SPI_PORT, &cmd, 1); // 可能需要的延时,等待从机处理 sleep_us(10); // 读取响应数据,发送0xFF产生时钟 spi_read_blocking(SPI_PORT, 0xFF, rx_buf, resp_len); gpio_put(PIN_CS, 1);6.4 电平转换与长线驱动
Pico的GPIO是3.3V电平。如果你要连接一个5V的SPI从机设备(注意:很多现代SPI设备也是3.3V的),绝对不能直接连接,否则可能损坏Pico。需要使用电平转换芯片(如TXB0104等双向转换器)。同样,如果SPI总线要走很长的线(>10cm),需要考虑信号完整性问题,可能需要在输出端串联一个小电阻(22-100Ω)来抑制振铃,或者使用差分SPI(有些高速ADC支持)或降低通信速率。
6.5 多从机共享总线:避免冲突
当多个设备共享MOSI、MISO、SCK时,必须确保任何时候只有一个设备的MISO输出是有效的(即被选中)。其他未被选中的设备,其MISO引脚必须处于高阻态。这是从机芯片设计时就保证的。你需要担心的只是软件上不要同时选中两个设备。更复杂的情况是,有些设备的MISO引脚在没有被选中时,可能不是高阻,而是输出低电平或高电平,这就会与当前被选中的设备冲突。这种情况在选型时就要避免。
7. 综合案例:驱动一块SPI TFT屏幕
让我们用一个实际例子把上面的知识点串起来:驱动一块常见的1.8英寸SPI TFT屏幕(驱动IC通常是ST7735或ST7789)。
7.1 硬件连接
- Pico 3.3V -> LCD VCC
- Pico GND -> LCD GND
- Pico GP2 (SPI0 SCK) -> LCD SCK
- Pico GP3 (SPI0 MOSI) -> LCD SDA (或MOSI)
- Pico GP4 (SPI0 MISO) -> 本例中LCD可能没有MISO,不接或接GND。
- Pico GP5 (任意GPIO) -> LCD CS (片选)
- Pico GP6 (任意GPIO) -> LCD DC (数据/命令选择,非常重要!)
- Pico GP7 (任意GPIO) -> LCD RST (复位)
7.2 软件驱动核心这类屏幕的通信协议是“9位SPI”:在发送每个字节之前,需要先通过DC引脚告诉从机,接下来发送的是命令(DC=0)还是数据(DC=1)。
void lcd_write_cmd(uint8_t cmd) { gpio_put(PIN_DC, 0); // DC拉低,表示命令 gpio_put(PIN_CS, 0); // 选中屏幕 spi_write_blocking(SPI_PORT, &cmd, 1); gpio_put(PIN_CS, 1); } void lcd_write_data(uint8_t data) { gpio_put(PIN_DC, 1); // DC拉高,表示数据 gpio_put(PIN_CS, 0); spi_write_blocking(SPI_PORT, &data, 1); gpio_put(PIN_CS, 1); } void lcd_write_data_bulk(uint8_t *data, uint32_t len) { gpio_put(PIN_DC, 1); gpio_put(PIN_CS, 0); spi_write_blocking(SPI_PORT, data, len); // 使用阻塞传输发送大量数据(如图像数据) gpio_put(PIN_CS, 1); }初始化序列:屏幕在上电后需要一系列特定的命令和数据进行初始化(设置分辨率、颜色格式、扫描方向等)。这些序列通常由屏幕厂商提供,或者可以从开源驱动库(如Adafruit_ST7735)中找到。你需要将这些命令和数据用上面的函数依次发送。
7.3 性能优化刷新整屏图像(160x128像素,16位色深)需要传输 160 * 128 * 2 = 40960 字节。用阻塞式spi_write_blocking一次传输,CPU会被完全占用几十毫秒。优化方案:
- 提升SPI速率:在屏幕允许的范围内(查手册),将SPI时钟提高到20MHz甚至更高。
- 使用DMA:将整个帧缓冲区通过DMA发送,这是最有效的方法。CPU只在启动DMA和切换缓冲区时介入。
- 局部刷新:如果只更新屏幕的一部分,只发送那部分区域的数据。
通过这个案例,你可以看到SPI的配置、软件片选、时序控制、批量数据传输等知识点是如何结合在一起的。掌握了这些,你就能驾驭Pico上绝大多数SPI设备了。