树莓派SPI驱动TFT屏幕实战:从硬件连接到Python图形显示
1. 项目缘起:为什么选择SPI驱动TFT屏幕?
最近在折腾一个需要本地显示状态的小项目,手头正好有一块闲置的1.5寸的TFT彩屏。这种小尺寸屏幕在物联网设备、便携式仪表或者智能家居的中控面板上很常见。直接使用HDMI输出对于树莓派来说当然最简单,但一来功耗高,二来对于只需要显示几行文本或简单图形的应用来说,属于“杀鸡用牛刀”。而GPIO直接驱动又太复杂,需要自己处理时序和显存。这时候,SPI接口就成了一个绝佳的选择。
SPI,全称Serial Peripheral Interface,是一种高速、全双工、同步的通信总线。用它来驱动TFT屏幕,核心优势在于“省引脚”。一块典型的SPI TFT屏幕,除了电源和背光,通常只需要4根线(MOSI, SCLK, DC, CS)就能完成数据和命令的传输,如果再加上一根RST(复位)线,也才5根。这极大地节省了树莓派宝贵的GPIO资源,让其他引脚可以用于连接传感器、执行器或通信模块。对于像我手头这块基于GC9305或类似驱动芯片的屏幕,SPI几乎是标准配置。
这个项目的目标很明确:让树莓派通过其自带的SPI硬件接口,点亮并控制这块TFT屏幕,最终能显示我们想要的任何内容,无论是静态图片、动态图形还是文本信息。整个过程会涉及到硬件连接、系统配置、驱动安装和软件编程几个核心环节。下面,我就把从零开始踩坑、调试到最终成功显示的完整过程梳理一遍,其中几个关键配置点如果搞错,屏幕可能永远是一片漆黑。
2. 硬件连接:理清线序与电源是成功的第一步
拿到一块TFT屏幕,第一步不是急着写代码,而是正确连接。这一步错了,后面所有软件调试都是徒劳。我用的是一块常见的1.5寸SPI TFT,驱动芯片可能是ST7735、ILI9341或GC9305中的一种,它们的SPI接口定义大同小异。
2.1 引脚定义与对应关系
我们需要先明确屏幕和树莓派两边的引脚定义。屏幕一般会有一排引脚,通常标记为:VCC、GND、SCL(或SCLK)、SDA(或MOSI)、RES(或RST)、DC(或RS)、CS、BL(背光)。
- VCC/GND:电源正极和地。绝大多数3.3V屏幕可以直接接树莓派的3.3V引脚,但务必确认你的屏幕电压!有些老屏幕或5V屏幕需要接5V,接错可能烧毁屏幕或树莓派。
- SCLK (Serial Clock):SPI时钟线,由主设备(树莓派)产生,用于同步数据。
- MOSI (Master Out Slave In):主设备输出,从设备输入。树莓派通过这根线向屏幕发送命令和数据。
- DC (Data/Command): 也称为RS或A0。这是SPI驱动TFT屏幕非常关键的一根线。它告诉屏幕当前在MOSI线上传输的是“命令”还是“数据”。例如,设置屏幕扫描方向是一个命令,而填充颜色的像素值则是数据。通常高电平代表数据,低电平代表命令。
- CS (Chip Select):片选线,低电平有效。当树莓派有多个SPI设备时,用这根线来选择当前要和哪个设备通信。如果只有一块屏幕,可以将其永久接地(低电平),但为了规范,建议还是连接到GPIO并控制。
- RST (Reset):复位线,低电平有效。用于硬件复位屏幕驱动芯片。可以接GPIO控制,也可以直接接3.3V(即不进行硬件复位),但软件初始化时需要进行软复位。
树莓派这边,我们需要找到硬件SPI0的引脚(在40Pin GPIO排针上):
- MOSI: GPIO 10 (物理引脚19)
- MISO: GPIO 9 (物理引脚21) -驱动屏幕通常不需要,但建议保留
- SCLK: GPIO 11 (物理引脚23)
- CE0 (Chip Enable 0): GPIO 8 (物理引脚24) - 可作为屏幕的CS
- CE1 (Chip Enable 1): GPIO 7 (物理引脚26) - 如果有第二个SPI设备可用
此外,我们还需要两个普通的GPIO口来连接DC和RST线,例如:
- DC: GPIO 25 (物理引脚22)
- RST: GPIO 24 (物理引脚18)
背光(BL)可以接一个GPIO进行开关控制,或者直接接3.3V让其常亮。
2.2 我的连接方案与避坑点
根据以上分析,我的实际连接如下表所示:
| 树莓派 GPIO (BCM编号) | 树莓派物理引脚 | TFT屏幕引脚 | 说明 |
|---|---|---|---|
| 3.3V | 引脚1 | VCC | 关键!确认屏幕电压 |
| GND | 引脚6 | GND | 共地 |
| GPIO 11 (SCLK) | 引脚23 | SCL/SCLK | SPI时钟 |
| GPIO 10 (MOSI) | 引脚19 | SDA/MOSI | SPI数据输出 |
| GPIO 8 (CE0) | 引脚24 | CS | SPI片选0 |
| GPIO 25 | 引脚22 | DC/RS | 数据/命令选择 |
| GPIO 24 | 引脚18 | RST | 复位(低电平有效) |
| 3.3V | 引脚1 | BL | 背光常亮 |
注意1:电源确认:这是我踩的第一个坑。我最初想当然地接了3.3V,但屏幕毫无反应。后来用万用表量了一下屏幕板子的稳压芯片输出,发现其逻辑电压是3.3V,但VCC输入脚实际需要5V。接上5V后屏幕背光亮起。所以,务必查阅屏幕资料或测量确认电压。注意2:电平匹配:树莓派GPIO是3.3V电平。如果你的屏幕是5V逻辑(虽然少见),则需要电平转换模块,否则可能通信不稳定甚至损坏树莓派GPIO。注意3:接线牢固:使用杜邦线连接时,确保接触良好。接触不良是导致“时好时坏”问题的常见元凶。对于长期项目,建议使用排针焊接或专用扩展板。
3. 系统配置:启用SPI与安装必要软件库
硬件连接无误后,我们需要在树莓派系统层面启用SPI接口,并安装图形库来简化编程。
3.1 启用树莓派的SPI接口
树莓派的SPI接口默认是关闭的。启用方法如下:
- 打开终端,运行配置工具:
sudo raspi-config - 使用方向键选择
Interface Options->SPI。 - 当询问 “Would you like the SPI interface to be enabled?” 时,选择
Yes。 - 确认后,工具会提示需要重启,选择
Yes立即重启。
重启后,可以通过以下命令验证SPI是否已启用:
lsmod | grep spi如果看到spi_bcm2835等相关模块,说明SPI驱动已加载。也可以检查/dev/下是否有SPI设备节点:
ls /dev/spi*正常情况下应该能看到/dev/spidev0.0和/dev/spidev0.1(分别对应CE0和CE1)。
3.2 安装Python图形库:Pillow与Adafruit_CircuitPython_RGB_Display
为了便于在屏幕上绘图、显示文字和图片,我们使用Python编程。需要安装两个核心库:
Pillow (PIL Fork):强大的图像处理库,用于创建、加载和处理图像。
sudo apt update sudo apt install python3-pil -yAdafruit_CircuitPython_RGB_Display:Adafruit出品的、针对各种RGB显示屏(包括SPI TFT)的Python驱动库。它封装了底层SPI通信和屏幕初始化命令,让我们可以专注于应用逻辑。
sudo apt install python3-pip -y sudo pip3 install adafruit-circuitpython-rgb-display这个库会自动安装一系列依赖,如
adafruit-blinka(用于GPIO和SPI控制)、RPi.GPIO的替代品等。
实操心得:在安装
adafruit-circuitpython-rgb-display时,可能会遇到编译依赖问题。如果报错关于python3-dev或libjpeg等,需要先安装这些系统依赖:sudo apt install python3-dev libjpeg-dev zlib1g-dev libopenjp2-7 libtiff5 -y然后再重新执行
pip3 install命令。
4. 驱动适配与初始化:让屏幕“活”过来
这是最核心也最容易出错的一步。adafruit-circuitpython-rgb-display库支持很多型号的屏幕,我们需要找到与自己屏幕匹配的驱动类并正确初始化。
4.1 确定屏幕驱动芯片型号
通常,屏幕卖家会提供资料,或者屏幕上最大的那个黑色芯片上会印有型号。如果都没有,可以尝试常见的驱动型号,如ST7735、ST7789、ILI9341、ILI9486、HX8357等。我的屏幕资料显示是GC9305,但经过测试,它与ST7735的初始化序列兼容。这是一个常见情况,很多国产屏会兼容主流驱动芯片的命令集。
4.2 编写初始化代码
下面是一个针对ST7735驱动、分辨率为128x160的1.5寸屏幕的完整Python示例。请根据你的屏幕分辨率(常见还有128x128, 240x240, 320x240等)和引脚连接修改参数。
# 导入必要的库 import digitalio import board from PIL import Image, ImageDraw, ImageFont from adafruit_rgb_display import st7735 # 如果驱动是ST7789,则导入st7789 # 配置SPI spi = board.SPI() # 使用默认SPI0 # 定义控制引脚 (根据你的实际连接修改!) cs_pin = digitalio.DigitalInOut(board.CE0) # 片选,接GPIO8 (CE0) dc_pin = digitalio.DigitalInOut(board.D25) # 数据/命令,接GPIO25 reset_pin = digitalio.DigitalInOut(board.D24) # 复位,接GPIO24 # 创建显示屏对象 # 关键参数解析: # spi: SPI总线对象 # cs: 片选引脚对象 # dc: 数据/命令引脚对象 # rst: 复位引脚对象 # width=160, height=128: 屏幕分辨率(宽x高)。注意:有些屏幕是竖屏模式,可能需要交换width和height。 # rotation=90: 屏幕旋转角度(0, 90, 180, 270)。如果你的屏幕显示方向不对,调整这个参数。 # baudrate=24000000: SPI通信速率。可以尝试提高(如40000000)或降低(如8000000)以适配屏幕和线长。 # x_offset=1, y_offset=2: 有些屏幕的显示区域在内存中有偏移,如果显示内容偏了,可以微调这两个值。 disp = st7735.ST7735R(spi, cs=cs_pin, dc=dc_pin, rst=reset_pin, width=160, height=128, rotation=90, baudrate=24000000, x_offset=1, y_offset=2) # 初始化屏幕 disp.reset() # 硬件复位(如果接了RST线) disp.begin() # 发送初始化序列 # 创建一个空白图像,模式为RGB,大小与屏幕一致 # 注意:这里创建的是内存中的图像对象,用于绘制。 image = Image.new("RGB", (disp.width, disp.height)) # 获取一个绘图对象,以便在图像上画图 draw = ImageDraw.Draw(image) # 用黑色清屏 draw.rectangle((0, 0, disp.width, disp.height), outline=0, fill=(0, 0, 0)) disp.image(image) # 将图像数据通过SPI发送到屏幕显示 print("屏幕初始化完成!")4.3 常见初始化问题排查
如果运行上述代码后屏幕仍是空白,请按以下步骤排查:
- 检查电源和背光:屏幕的背光亮了吗?如果没亮,检查BL引脚是否接电。
- 检查SPI是否启用:再次运行
ls /dev/spi*确认设备存在。 - 检查引脚编号:确认代码中的
board.D25等与你实际的物理连接(BCM编号)一致。board.D25对应的是BCM GPIO 25。 - 调整SPI波特率:过高的波特率可能导致长导线通信失败。将
baudrate从24000000降低到12000000或8000000试试。 - 尝试不同的驱动类:如果你的屏幕不是ST7735,将
st7735.ST7735R替换为st7789.ST7789或ili9341.ILI9341等。 - 调整旋转和偏移:
rotation参数严重影响显示方向。x_offset和y_offset用于微调显示起始位置。 - 查看控制台输出:代码是否有报错?权限问题(需要sudo)?库未安装?
- 逻辑分析仪或示波器:这是终极手段。用逻辑分析仪抓取SCLK、MOSI、DC、CS的波形,看是否有数据发出。可以对照屏幕驱动芯片的数据手册,看初始化命令序列是否正确。
踩坑实录:我的屏幕在初始化后,显示区域只有左上角一小块,大部分区域是花屏。经过排查,是
width和height参数设置反了,并且需要设置rotation=270才能全屏正确显示。同时,我发现baudrate设为40000000时在短线连接下稳定,但换成长线后就出现雪花点,降至20000000后问题解决。SPI速率和线长、屏幕质量强相关,需要实际调试确定。
5. 图形与文字显示实战:从清屏到动态刷新
屏幕成功初始化后,我们就可以在上面“作画”了。所有操作都围绕在内存中创建一个Image对象,对其进行绘制,最后调用disp.image()将其一次性发送到屏幕。
5.1 基本图形绘制
PIL.ImageDraw模块提供了丰富的绘图功能。
# 假设disp和image对象已按上一节创建好 draw = ImageDraw.Draw(image) # 1. 清屏为白色 draw.rectangle((0, 0, disp.width, disp.height), fill=(255, 255, 255)) disp.image(image) # 2. 画一个红色的矩形框 draw.rectangle((10, 10, 50, 50), outline=(255, 0, 0), width=2) # 3. 画一条蓝色的对角线 draw.line((0, 0, disp.width, disp.height), fill=(0, 0, 255), width=3) # 4. 画一个绿色的实心圆 circle_center = (80, 64) # 屏幕中心附近 circle_radius = 20 draw.ellipse((circle_center[0]-circle_radius, circle_center[1]-circle_radius, circle_center[0]+circle_radius, circle_center[1]+circle_radius), fill=(0, 255, 0)) # 将绘制好的图像显示到屏幕 disp.image(image)5.2 显示文字
显示文字需要加载字体文件。树莓派系统自带一些字体,也可以使用自己的TTF字体文件。
# 尝试加载系统字体,如果失败则指定一个路径 try: font = ImageFont.load_default() # 默认小字体 # 或者使用指定大小的字体 # font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 16) except IOError: # 如果默认字体加载失败,使用一个备用的内置字体(可能像素化) font = ImageFont.load_default() # 定义要显示的文本和位置 text = "Hello, TFT!" text_width, text_height = draw.textsize(text, font=font) # 获取文本占据的像素大小 text_x = (disp.width - text_width) // 2 # 水平居中 text_y = (disp.height - text_height) // 2 # 垂直居中 # 设置文字颜色(这里用黑色) text_color = (0, 0, 0) # 在绘制文字前,你可能想先画一个背景色矩形(例如白色)来覆盖原有内容,避免文字重叠 draw.rectangle((text_x-2, text_y-2, text_x+text_width+2, text_y+text_height+2), fill=(255,255,255)) # 绘制文字 draw.text((text_x, text_y), text, font=font, fill=text_color) # 显示到屏幕 disp.image(image)注意事项:
ImageFont.load_default()加载的字体非常小,可能只适合显示少量字符。对于中文或大字体,必须使用ImageFont.truetype()并指定一个完整的TTF字体文件路径。确保该字体文件存在于树莓派上。
5.3 显示图片
我们可以将JPG、PNG等格式的图片缩放至屏幕分辨率后显示。
# 打开一张图片 original_image = Image.open("your_image.jpg") # 替换为你的图片路径 # 将图片缩放至屏幕大小。 # Image.Resampling.LANCZOS 是一种高质量的重采样滤波器。 scaled_image = original_image.resize((disp.width, disp.height), Image.Resampling.LANCZOS) # 如果图片模式不是RGB,需要转换(例如RGBA的PNG) if scaled_image.mode != 'RGB': scaled_image = scaled_image.convert('RGB') # 显示图片 disp.image(scaled_image)5.4 实现动态刷新与动画
动态效果的核心是循环:在每次循环中,根据新的数据或状态,在内存中绘制新的图像帧,然后刷新到屏幕。
import time # 一个简单的弹跳球动画示例 ball_pos = [disp.width // 2, disp.height // 2] ball_radius = 5 ball_velocity = [2, 3] # [x方向速度, y方向速度] try: while True: # 1. 清空上一帧(用黑色填充整个图像) draw.rectangle((0, 0, disp.width, disp.height), fill=(0, 0, 0)) # 2. 更新小球位置 ball_pos[0] += ball_velocity[0] ball_pos[1] += ball_velocity[1] # 3. 边界碰撞检测 if ball_pos[0] - ball_radius < 0 or ball_pos[0] + ball_radius >= disp.width: ball_velocity[0] = -ball_velocity[0] if ball_pos[1] - ball_radius < 0 or ball_pos[1] + ball_radius >= disp.height: ball_velocity[1] = -ball_velocity[1] # 4. 绘制当前帧的小球(白色) draw.ellipse((ball_pos[0]-ball_radius, ball_pos[1]-ball_radius, ball_pos[0]+ball_radius, ball_pos[1]+ball_radius), fill=(255, 255, 255)) # 5. 将图像发送到屏幕 disp.image(image) # 6. 控制帧率,短暂延时 time.sleep(0.02) # 大约50 FPS except KeyboardInterrupt: print("动画停止")这个例子展示了动态刷新的基本模式。对于更复杂的UI,你可以定义不同的界面状态(如主页面、设置页),在循环中根据状态绘制不同的内容。
6. 性能优化与高级话题
当显示复杂图形或需要较高刷新率时,性能就成为需要考虑的问题。
6.1 局部刷新与双缓冲
上面的例子每次都是全屏刷新(draw.rectangle清全屏),这对于简单动画可以接受,但效率不高。更优的方法是只重绘发生变化的部分区域。
真正的“双缓冲”在adafruit-circuitpython-rgb-display中不是必须的,因为disp.image()调用内部已经处理了数据发送。但我们可以通过优化绘图逻辑来模拟:
- 脏矩形更新:记录哪些区域需要更新,只清除和重绘这些区域。
- 离屏渲染:始终在一个内存中的
Image对象上绘制完整帧,然后一次性disp.image()发送。这避免了屏幕撕裂(虽然SPI屏通常不易察觉),也是我们一直在用的方法。关键在于减少disp.image()调用之间的冗余绘图计算。
6.2 SPI速率与帧率瓶颈
SPI总线速率是帧率的主要瓶颈。计算公式简化如下:传输一帧数据所需时间 ≈ (屏幕宽度 * 屏幕高度 * 每个像素字节数 * 8) / SPI波特率
对于160x128的16位色(2字节)屏幕:数据量 = 160 * 128 * 2 = 40960 字节比特数 = 40960 * 8 = 327680 比特在24Mbps (24,000,000 bps) 的SPI速率下:理论最短时间 = 327680 / 24,000,000 ≈ 0.0137 秒理论最高帧率 ≈ 73 FPS
但这只是纯数据传输的理想时间,未包含命令开销、Python代码执行时间、SPI协议开销等。实际帧率会低很多,可能在20-40 FPS左右。提高baudrate参数可以提升帧率,但受硬件和接线质量限制。
6.3 使用硬件加速?
树莓派的GPU(VideoCore)通常用于HDMI输出渲染,很难直接用于驱动SPI TFT。因此,驱动SPI屏的图形性能主要取决于:
- CPU处理PIL绘图的速度:复杂的
ImageDraw操作(如抗锯齿图形、大量文字)会消耗大量时间。 - SPI数据传输速度:如上所述,是硬性瓶颈。
- Python解释器开销:对于极高帧率需求,可以考虑使用C语言库(如
wiringPi+fbdev)或MicroPython,但开发复杂度会显著增加。
对于大多数状态显示、仪表盘、简单动画等应用,Python + PIL + Adafruit库的组合在性能上已经完全足够,且开发效率最高。
7. 项目集成与实用案例
将SPI TFT屏幕集成到实际项目中,通常不仅仅是显示动画,而是作为信息输出的窗口。
7.1 系统状态监视器
一个经典应用是制作一个树莓派系统状态监视屏,实时显示CPU温度、负载、内存使用率、IP地址等信息。
import psutil import socket def get_ip_address(): # 获取本机IP地址(非回环地址) try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) ip = s.getsockname()[0] s.close() return ip except Exception: return "N/A" try: while True: # 获取系统信息 cpu_percent = psutil.cpu_percent(interval=None) cpu_temp = psutil.sensors_temperatures().get('cpu_thermal', [None])[0] temp = cpu_temp.current if cpu_temp else 'N/A' memory = psutil.virtual_memory() ip_addr = get_ip_address() # 创建新图像并绘制 draw.rectangle((0, 0, disp.width, disp.height), fill=(0, 0, 50)) # 深蓝背景 y_offset = 10 line_height = 15 font_small = ImageFont.load_default() info_lines = [ f"IP: {ip_addr}", f"CPU: {cpu_percent:5.1f}%", f"Temp: {temp:5.1f}C", f"Mem: {memory.percent:5.1f}%", f"Used: {memory.used//1024//1024:3d}M", f"Free: {memory.free//1024//1024:3d}M", ] for line in info_lines: draw.text((5, y_offset), line, font=font_small, fill=(255, 255, 255)) y_offset += line_height # 画一个简单的CPU使用率条形图 bar_width = 100 bar_height = 10 bar_x = 5 bar_y = disp.height - bar_height - 5 # 背景条 draw.rectangle((bar_x, bar_y, bar_x+bar_width, bar_y+bar_height), outline=(200,200,200), fill=(50,50,50)) # 前景条(根据CPU使用率) fill_width = int((cpu_percent / 100.0) * bar_width) draw.rectangle((bar_x, bar_y, bar_x+fill_width, bar_y+bar_height), fill=(0, 255, 0)) disp.image(image) time.sleep(2) # 每2秒更新一次 except KeyboardInterrupt: pass这个脚本需要安装psutil库:sudo pip3 install psutil。它创建了一个简洁的状态面板,并带有动态更新的CPU使用率条。
7.2 结合传感器数据显示
你可以很容易地将屏幕与树莓派上的其他传感器(如DHT11温湿度传感器、BMP280气压计)结合,创建一个环境监测站。
# 假设已安装Adafruit_DHT库 import Adafruit_DHT DHT_SENSOR = Adafruit_DHT.DHT11 DHT_PIN = 4 # GPIO4, 物理引脚7 try: while True: humidity, temperature = Adafruit_DHT.read(DHT_SENSOR, DHT_PIN) draw.rectangle((0, 0, disp.width, disp.height), fill=(0, 0, 0)) if humidity is not None and temperature is not None: draw.text((10, 20), f"Temp: {temperature:.1f}C", font=font, fill=(255,255,0)) draw.text((10, 50), f"Hum: {humidity:.1f}%", font=font, fill=(0,255,255)) else: draw.text((10, 35), "Sensor Error!", font=font, fill=(255,0,0)) disp.image(image) time.sleep(3) except KeyboardInterrupt: pass通过这种方式,SPI TFT屏幕成为了一个低成本、低功耗的本地人机交互界面,非常适合各种嵌入式项目和物联网终端。
