前三篇分别把项目背景、软件架构和渲染方案聊完了,说实话一直停在纸面上,心里多少有点没底。这篇终于轮到硬件落地——板子和屏幕到手,从接线上电到屏幕亮起,再到跑通第一个能显示真实系统状态的 Demo。整个过程比想象中顺,但也没有完全顺:接线差点接反、颜色通道反了一路、SPI 速率一拉高就花屏。这些坑都不大,每个单独拎出来都能搜到答案,可当你第一次把 SBC、SPI 屏和自写渲染代码串在一起时,它们会一起冒出来,让你怀疑到底是代码错了还是硬件坏了。这篇就把从零到 Demo 的全过程完整记录下来,给同样在做桌面信息面板、状态屏这类项目的朋友一个可以照着走的参考。1. 硬件配置的等待耐心:这套组合是怎么选出来的先说结论:这个项目选择的是“普通 ARM 小板 + 3.5 寸 SPI 接口彩色 IPS 屏”的方案。没有用带 HDMI 的大屏,也没用那种自带主控的一体化显示模块。原因无非三点:便宜、灵活、好替换。SPI 屏走的是通用接口,几乎所有开发板都带 SPI 外设,坏了再买一块也就几十块钱,不会心疼。而 HDMI 屏虽然显示效果更好、刷新率更高,但对这块板子来说有点杀鸡用牛刀——Status Deck 要显示的是时间、系统负载、网络状态这类低频变化信息,并不需要 60 帧的流畅度,反而需要的是低功耗、低接线复杂度、高可靠性。选屏幕的时候,我重点看了三种规格:1.3 寸 OLED、2.8 寸 LCD、3.5 寸 LCD。1.3 寸 OLED 是很省电,分辨率也够,但信息密度实在太小,放一个大号时钟就占满了,做不了什么布局。2.8 寸是一个折中项,可后来对比价格发现,3.5 寸和 2.8 寸的差价几乎可以忽略,而 3.5 寸的像素是 320x480,比 2.8 寸的 240x320 多了一倍,信息量不是一个量级。所以我最后选了 3.5 寸 IPS 屏,驱动 IC 是常见的 ILI9488 兼容方案,16bit RGB565 色深,SPI 接口驱动,触摸屏排线也有,但第一版先不管它。板子的选择其实更随意。手头有一块国产 ARM 小板,带 SPI、I2C、UART,跑的是轻量 Linux,完全满足需求。这里我想多说一句:做这种个人硬件项目,真的不必追求“旗舰配置”,关键是接口齐、文档全、社区有人踩过坑。因为 Status Deck 的性能瓶颈根本不在 CPU,而在 SPI 链路本身,这个后面会详细算一笔账。下单之后是漫长的等待。硬件从电商平台发货,经历了中转、清关,前后差不多两周。这段时间我没闲着,反正 UI 层可以脱离硬件先做:我在电脑上开了一个 320x480 的窗口,把 Pillow 的渲染逻辑全部写好,用假数据模拟显示效果,反复调字体大小、间距、颜色。等到硬件真正到手那天,软件渲染层已经基本定型,差的只剩下“怎么把 PIL 画出来的 Image 对象变成屏幕上的像素”。这一步,就是整篇的核心。提示:如果你也准备做同类项目,强烈建议在等硬件的时间里把渲染和数据处理逻辑先在 PC 上跑通。这样硬件一到,你只需要专注于底层驱动,不会被 UI 细节拖住排查进度。2. 通电前的检查清单:我踩过的接线坑你都别踩硬件到手,第一件事不是接线上电,而是仔细看屏幕的丝印和板子的引脚图。屏幕背面通常印着每个排针的定义说明:VCC、GND、SCLK、MOSI、CS、DC、RST、BL 这些。先把屏幕排针对照板子 GPIO 口逐一列出映射关系,这是最重要的一步,比你后面写代码调试一百遍都省时间。我的接线表如下,不同板子 GPIO 编号方案可能不同,但思路完全一样:屏幕信号接法说明备注VCC5V 电源给屏幕和背光供电GND公共地必须和板子共地SCLKSPI 时钟接板子 SPI 的 SCLK 引脚MOSISPI 数据输出板子输出、屏输入,单向CS片选低电平有效,接板子 CS0/CS1DC数据/命令选择控制传输的是命令还是像素数据RST复位低电平复位,接一个 GPIO 控制BL背光控制有的屏默认开启,有的需要跳线第一次接线最容易出错的有三个地方。一个是 DC 和 CS 搞混。这两个信号语义不同,CS 是整包片选,DC 是这一包数据到底是命令还是像素。搞混之后的现象很迷惑:屏幕初始化序列发过去了但没反应,或者显示完全错乱。查了半天才发现是物理接线交叉了。所以接完线先拿万用表通断档比一下,按丝印逐根核对,不要只看颜色。第二个坑是背光。不少 SPI 屏的背光默认是低电平关闭,如果 BL 引脚悬空,屏幕背光不亮,你会误以为驱动失败。有的屏更坑,BL 引脚做成了跳线帽,默认不短接。检测方法很简单:上电瞬间看屏幕有没有轻微反光,拿手电照一照能看到隐隐约约的图案,说明屏幕其实在显示,只是背光没开。第三个坑是电平匹配。这块板子的 GPIO 是 3.3V 逻辑电平,屏幕的电源虽然是 5V,但大多数 SPI 屏的逻辑接口都能兼容 3.3V。如果你用的板子是 5V 逻辑电平,那就要小心了,直接驱动 3.3V 屏可能烧坏驱动 IC。解决办法是看屏幕 datasheet 里逻辑电平范围,如果板子和屏幕不匹配,加一块电平转换模块,别侥幸。我自己的血泪提醒:带触摸功能的 SPI 屏,第一版调试时不要接触摸排线。触摸和显示共用 SPI 总线,初始化时序不一样,接上去有时会影响显示初始化,排查问题时分不清到底哪个外设在搞事。先把显示跑通,再考虑触摸交互。上电前最后一步,拿万用表测一下 VCC 和 GND 之间有没有短路,顺手确认电源开关是关着的。这步十几秒的事,能避免把板子和屏幕一起送走。3. 点亮屏幕的三个阶段:纯色、边框、全屏图案点亮屏幕不要指望一步到位。我的经验是分三个阶段走:先亮纯色,再画边框网格,最后才显示文字。每一阶段都验证了一类问题,层层递进,出错了也知道是哪里。3.1 第一阶段:全屏纯色点亮屏幕的第一步,是把驱动 IC 的初始化序列发给屏幕,然后填充一整屏红色。初始化序列通常由屏厂或驱动 IC 的数据手册给出,是一串寄存器命令和数据,大概几十个字节。关键经验是:这段序列不要自己“优化”,照着资料发就行。我看到过有人在网上分享精简版初始化序列,结果测试时颜色偏、横线抖动,最后还是回到原厂序列。下面是初始化后发送一帧纯色的最小 Python 代码框架:import spidev import time # 打开 SPI 设备,bus 0,cs 0 spi = spidev.SpiDev() spi.open(0, 0) spi.max_speed_hz = 24_000_000 # 先从保守速率开始 # 初始化序列过长,这里只写调用方式 def send_command(cmd_bytes): # 通过 GPIO 控制 DC 引脚为低电平,表示发送命令 gpio_dc_low() spi.xfer(list(cmd_bytes)) def send_data(data_bytes): # 通过 GPIO 控制 DC 引脚为高电平,表示发送数据 gpio_dc_high() spi.xfer(list(data_bytes)) def init_screen(): # 复位:RST 拉低 20ms 再拉高 gpio_rst_low() time.sleep(0.02) gpio_rst_high() time.sleep(0.12) # 逐条发送初始化命令,例如: # send_command([0x01]) # SWRESET # time.sleep(0.15) # send_command([0x11]) # SLPOUT # time.sleep(0.15) # send_command([0x20]) # DFOFF? 具体以资料为准 pass # 填充一帧像素数据的思路 def fill_color(color_rgb565): # 先进入内存写模式,然后连续发送 320*480 个像素数据 # 每个像素两个字节,发送完就是全屏同一颜色 pixel = bytes([color_rgb565 >> 8, color_rgb565 & 0xFF]) * (320 * 480) send_data(pixel) init_screen() fill_color(0xF800) # RGB565 红色发送完成之后,屏幕应该整片红色。如果屏幕是花的、有横条、或者干脆黑屏,先不要碰代码,按这几个顺序排查:SPI 速率太高就降到 4MHz 试试;初始化序列是否完整发送;CS 和 DC 有没有接反;屏幕供电和背光有没有打开。很多新手在第一步就卡住了,其实是背光和接线问题,反而去折腾初始化代码,方向完全反了。这里我特别强调一个原则:先信硬件,再信软件。只要屏幕规格书和板子 GPIO 定义没看错,硬件大概率是对的,问题多半出现在速率、时序或者供电上。3.2 第二阶段:画边框和网格纯色屏验证通过之后,下一步是画一个大边框和一些网格线。这个阶段验证的是扫描方向和坐标原点。屏幕亮纯色时看不出方向,因为全屏同色,而一旦画边框,上下颠倒、左右镜像一眼就能看出来。画边框代码如下:from PIL import Image, ImageDraw # 创建 320x480 的黑色画布 img = Image.new("RGB", (320, 480), (0, 0, 0)) draw = ImageDraw.Draw(img) # 红色 10px 边框 draw.rectangle([0, 0, 319, 10], fill=(255, 0, 0)) draw.rectangle([0, 470, 319, 479], fill=(255, 0, 0)) draw.rectangle([0, 0, 10, 479], fill=(255, 0, 0)) draw.rectangle([310, 0, 319, 479], fill=(255, 0, 0)) # 绿色交叉线 draw.line((0, 0, 319, 479), fill=(0, 255, 0)) draw.line((319, 0, 0, 479), fill=(0, 255, 0))然后把 Image 对象转成 RGB565 字节流,再发给屏幕。转换函数是这个项目最核心的底层函数之一:def to_rgb565(img): # 注意:PIL 默认 RGB,但部分屏幕驱动 IC 实际是 BGR 像素格式 # 需要先用屏幕资料确认像素格式 rgb = img.convert("RGB") pix = rgb.load() buf = bytearray(320 * 480 * 2) idx = 0 for y in range(480): for x in range(320): r, g, b = pix[x, y] val = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3) buf[idx] = (val >> 8) & 0xFF buf[idx + 1] = val & 0xFF idx += 2 return bytes(buf)这个函数的重点在 RGB565 的位运算:红色取高 5 位放到最高位,绿色取高 6 位放中间,蓝色取高 5 位放最低位。如果显示出来的颜色不对——红色变成蓝色、绿色变成紫色——那就说明屏幕实际像素格式是 BGR,你需要把 r 和 b 交换一下再编码。边框阶段看到的现象往往不是完全镜像就是完全倒置。ILI9488 这类驱动 IC 支持通过寄存器设置扫描方向和坐标原点。一般资料里会给一个“设置扫描方向”的命令参数,你把它改成顺时针旋转 90 度、270 度之类的值,就能匹配物理安装方向。如果资料不好理解,另一个笨办法是:在软件层做坐标映射,也就是渲染的时候把宽度和高度的索引互换。两种方案我都试过,结论是尽量用寄存器设置,因为软件层做映射会引入额外的计算开销,而且容易出错。3.3 第三阶段:文字显示边框方向都对了,接下来才算真正让屏幕“说话”。这个阶段的目标是在屏幕上画一行时间数字,验证字体渲染和文字清晰度。Pillow 自带默认字体,但尺寸一放大就糊,最好加载系统里的无衬线字体。from PIL import ImageFont import subprocess def load_font(size): # Linux 下常见字体路径之一 path = "/usr/share/fonts/dejavu/DejaVuSans.ttf" # 如果找不到,用 fc-list 查一下有哪些字体可用 return ImageFont.truetype(path, size) big_font = load_font(46)画字的逻辑很简单:draw.text((20, 20), "12:34:56", font=big_font, fill=(255, 255, 255))文字显示时最需要注意的是字体大小和颜色对比度。SPI 屏的色深只有 16bit,颜色过渡本来就有限,白字配深色背景是最稳的选择。不要试图在小字号下做抗锯齿之外的复杂效果,比如描边、阴影,这些在低分辨率屏幕上会糊成一团。另外,文字垂直方向要留出足够行距,320x480 的分辨率看似不小,但真正排内容时你会发现可用的“有效像素”很紧张。每一行文字高度至少 40px 才会舒服。走到这一步,屏幕已经完全可控了。剩下的事情就是:把真实系统数据喂进来,让屏幕显示有意义的内容。4. 第一个 Demo 的最小数据链路:从 psutil 读数到屏幕像素跑通屏幕渲染之后,我立刻做了一个最小版本的信息面板 Demo:左上角大号时钟,右上角日期,中间一栏显示 CPU 使用率、内存使用率、磁盘使用率,底部一栏显示局域网 IP 和开机时长。这个 Demo 的数据全部来自本机,不需要任何外部服务,是验证数据链路的最佳起点。如果你一上来就接天气接口、日历同步之类的远程数据,一旦接口超时或返回格式变更,你会分不清是网络问题还是渲染问题。4.1 数据采集层数据采集用到了 psutil 这个库,一行代码就能拿到大部分信息:import psutil import socket import datetime def collect_status(): now = datetime.datetime.now() mem = psutil.virtual_memory() cpu = psutil.cpu_percent(interval=None) disk = psutil.disk_usage("/") uptime_seconds = int(now.timestamp() - psutil.boot_time()) # 获取局域网 IP:创建一个 UDP 套接字连接外部地址,再取套接字本端 IP ip = "127.0.0.1" try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) ip = s.getsockname()[0] s.close() except Exception: pass return { "time": now.strftime("%H:%M:%S"), "date": now.strftime("%Y-%m-%d"), "cpu": cpu, "mem": mem.percent, "disk": disk.percent, "ip": ip, "uptime": f"{uptime_seconds // 3600}h{(uptime_seconds % 3600) // 60}m", }注意 socket 获取 IP 这个方法,本质是构造一个不需要真正收发数据的 UDP 连接,通过路由表选出本机出口 IP。如果板子没联网,会走异常分支,显示 127.0.0.1,这个行为是合理的——对于状态面板来说,显示本地地址也比显示空要好。4.2 渲染层与主循环渲染层在 PC 上已经调好了,这里直接把渲染函数接上数据:def render_panel(data): img = Image.new("RGB", (320, 480), (10, 12, 20)) draw = ImageDraw.Draw(img) # 时间区 draw.text((20, 20), data["time"], font=big_font, fill=(255, 255, 255)) draw.text((230, 38), data["date"], font=mid_font, fill=(160, 170, 190)) # 分隔线 draw.line((16, 100, 304, 100), fill=(60, 70, 90)) # 系统信息区 sys_items = [ ("CPU", f"{data['cpu']}%"), ("MEM", f"{data['mem']}%"), ("DISK", f"{data['disk']}%"), ("UPTIME", data["uptime"]), ] y = 130 for label, value in sys_items: draw.text((22, y), label, font=label_font, fill=(120, 140, 170)) draw.text((140, y), value, font=value_font, fill=(230, 240, 255)) y += 40 # 底部 IP 栏 draw.line((16, 330, 304, 330), fill=(60, 70, 90)) draw.text((20, 350), "LAN IP", font=label_font, fill=(120, 140, 170)) draw.text((90, 350), data["ip"], font=value_font, fill=(120, 255, 180)) return img主循环是一个典型的嵌入式显示循环:import time while True: data = collect_status() frame = to_rgb565(render_panel(data)) # 实际发送时,需要先设置内存写窗口,再一次性发送全部像素 send_data(frame) time.sleep(1)这个循环里 sleep(1) 很重要。有人会问,为什么不等渲染完再等一秒,而是固定 1 秒一次?因为状态信息大部分时候没有变化,比如磁盘空间可能半小时才变 0.1 个百分点,你刷得再快也没有意义。固定 1 秒的节拍能平滑显示,也避免 psutil 采样过快导致 CPU 占用虚高。4.3 实测效果与性能数据Demo 跑起来后的表现比预期要好。整帧全刷 320x480 像素,换算成字节是 320 x 480 x 2 = 307200 字节。SPI 速率设置在 32MHz 时,理论上一帧约需 0.1 秒,实测加上渲染和转换时间,稳定在每秒钟刷 8 帧左右。这个数字对信息面板来说完全够用,我甚至把刷新周期固定为 2 秒,让显示更稳定。CPU 占用方面,Python 主循环里最耗时的是 pillow 的图片绘制和 RGB565 字节流转,两者合起来大约占板子单个核心的 20%~30%。这在轻量 Linux 上可以接受,毕竟板子平时还有其他后台服务要跑。5. 每帧 0.12 秒的启示:SPI 屏的刷新边界与后续优化方向Demo 跑通后我做了一个简单的基准测试,把渲染时间、转换时间、SPI 发送时间分别测量了一遍。结论是:SPI 发送时间占了大头,而且这个时间主要由物理链路决定,不是代码能直接改掉的。一帧 307200 字节,SPI 时钟 32MHz 时理论发送时间约 0.096 秒,加上地址窗口设置和 GPIO 翻转开销,实测单帧全刷要 0.12 秒左右。注意,这是理想状态,SPI 时钟越高,信号质量越差,容易花屏。我试过把速率拉到 48MHz,结果出现随机彩色噪点,瞬间明白这个屏幕的物理上限就在 36MHz 附近。这也是为什么驱动代码里我把 max_speed_hz 从 24MHz 起步,最后停在 32MHz 的原因。这个数字带来了一个重要启示:这个项目不适合做高刷新率内容,比如动态图表动画、视频播放,统统不要想。但Status Deck 这类信息面板天生就是低频应用,时间每秒变一次,CPU 负载每两秒变一次。与其跟物理限制较劲,不如把刷新率当作一个设计参数来用:全刷不够,可以做局部刷新。局部刷新的思路是把屏幕分成几个矩形区域,比如时间区、信息区、底部状态区,某个区域的数据变化时,只把该区域的窗口坐标和像素数据发送给屏幕。在 ILI9488 这类驱动 IC 上,这是原生支持的:先设置内存写窗口,再发送这块区域的像素数据。我的实测是,时间区域只有 200x80,刷新一次只要原有数据的十分之一时间,几乎可以无感更新。后续优化方向我也想清楚了。第一,把 RGB565 转换从纯 Python 循环改成逐行处理,减少重复索引开销,甚至可以用 NumPy 向量化。第二,把渲染和发送拆成两个线程,渲染在后台进行,发送线程只管把最新帧发出去,避免上一次发送还没完成、下一次渲染数据又准备好的堆积问题。第三,数据源从本地扩到远程,通过公开接口获取天气和日历事件,在底部状态栏滚动显示。这些都是下一篇再说的事。注意:如果你发现 spi.xfer 发送大数组时偶尔有丢帧或重复帧,可以先检查 SPI 的 CS 片选时序,很多屏对 CS 拉高拉低的前后沿有要求。发送前把 CS 拉低,全部数据发完再拉高,不要每发送一个字节就拉一次 CS。这个小问题曾经让我排查了很久,以为是硬件坏掉了,结果是 SPI 时序太“碎”。写完这篇回头想,从“硬件到了”到“跑通第一个 Demo”,真正的关键不是哪一步特别难,而是每一步都没有一步到位:接线要反复核对、纯色要验证格式、边框要调扫描方向、文字要试字体渲染、数据链路要从最小闭环开始。每次只引入一个变量,出了问题就只查那一个环节。这套方法论对任何嵌入式显示项目都通用。屏幕点亮的瞬间当然有成就感,但最有收获的其实是你知道它为什么能亮,也知道它会在什么情况下不亮。
前三篇分别把项目背景、软件架构和渲染方案聊完了,说实话一直停在纸面上,心里多少有点没底。这篇终于轮到硬件落地——板子和屏幕到手,从接线上电到屏幕亮起,再到跑通第一个能显示真实系统状态的 Demo。整个过程比想象中顺,但也没有完全顺:接线差点接反、颜色通道反了一路、SPI 速率一拉高就花屏。这些坑都不大,每个单独拎出来都能搜到答案,可当你第一次把 SBC、SPI 屏和自写渲染代码串在一起时,它们会一起冒出来,让你怀疑到底是代码错了还是硬件坏了。这篇就把从零到 Demo 的全过程完整记录下来,给同样在做桌面信息面板、状态屏这类项目的朋友一个可以照着走的参考。1. 硬件配置的等待耐心:这套组合是怎么选出来的先说结论:这个项目选择的是“普通 ARM 小板 + 3.5 寸 SPI 接口彩色 IPS 屏”的方案。没有用带 HDMI 的大屏,也没用那种自带主控的一体化显示模块。原因无非三点:便宜、灵活、好替换。SPI 屏走的是通用接口,几乎所有开发板都带 SPI 外设,坏了再买一块也就几十块钱,不会心疼。而 HDMI 屏虽然显示效果更好、刷新率更高,但对这块板子来说有点杀鸡用牛刀——Status Deck 要显示的是时间、系统负载、网络状态这类低频变化信息,并不需要 60 帧的流畅度,反而需要的是低功耗、低接线复杂度、高可靠性。选屏幕的时候,我重点看了三种规格:1.3 寸 OLED、2.8 寸 LCD、3.5 寸 LCD。1.3 寸 OLED 是很省电,分辨率也够,但信息密度实在太小,放一个大号时钟就占满了,做不了什么布局。2.8 寸是一个折中项,可后来对比价格发现,3.5 寸和 2.8 寸的差价几乎可以忽略,而 3.5 寸的像素是 320x480,比 2.8 寸的 240x320 多了一倍,信息量不是一个量级。所以我最后选了 3.5 寸 IPS 屏,驱动 IC 是常见的 ILI9488 兼容方案,16bit RGB565 色深,SPI 接口驱动,触摸屏排线也有,但第一版先不管它。板子的选择其实更随意。手头有一块国产 ARM 小板,带 SPI、I2C、UART,跑的是轻量 Linux,完全满足需求。这里我想多说一句:做这种个人硬件项目,真的不必追求“旗舰配置”,关键是接口齐、文档全、社区有人踩过坑。因为 Status Deck 的性能瓶颈根本不在 CPU,而在 SPI 链路本身,这个后面会详细算一笔账。下单之后是漫长的等待。硬件从电商平台发货,经历了中转、清关,前后差不多两周。这段时间我没闲着,反正 UI 层可以脱离硬件先做:我在电脑上开了一个 320x480 的窗口,把 Pillow 的渲染逻辑全部写好,用假数据模拟显示效果,反复调字体大小、间距、颜色。等到硬件真正到手那天,软件渲染层已经基本定型,差的只剩下“怎么把 PIL 画出来的 Image 对象变成屏幕上的像素”。这一步,就是整篇的核心。提示:如果你也准备做同类项目,强烈建议在等硬件的时间里把渲染和数据处理逻辑先在 PC 上跑通。这样硬件一到,你只需要专注于底层驱动,不会被 UI 细节拖住排查进度。2. 通电前的检查清单:我踩过的接线坑你都别踩硬件到手,第一件事不是接线上电,而是仔细看屏幕的丝印和板子的引脚图。屏幕背面通常印着每个排针的定义说明:VCC、GND、SCLK、MOSI、CS、DC、RST、BL 这些。先把屏幕排针对照板子 GPIO 口逐一列出映射关系,这是最重要的一步,比你后面写代码调试一百遍都省时间。我的接线表如下,不同板子 GPIO 编号方案可能不同,但思路完全一样:屏幕信号接法说明备注VCC5V 电源给屏幕和背光供电GND公共地必须和板子共地SCLKSPI 时钟接板子 SPI 的 SCLK 引脚MOSISPI 数据输出板子输出、屏输入,单向CS片选低电平有效,接板子 CS0/CS1DC数据/命令选择控制传输的是命令还是像素数据RST复位低电平复位,接一个 GPIO 控制BL背光控制有的屏默认开启,有的需要跳线第一次接线最容易出错的有三个地方。一个是 DC 和 CS 搞混。这两个信号语义不同,CS 是整包片选,DC 是这一包数据到底是命令还是像素。搞混之后的现象很迷惑:屏幕初始化序列发过去了但没反应,或者显示完全错乱。查了半天才发现是物理接线交叉了。所以接完线先拿万用表通断档比一下,按丝印逐根核对,不要只看颜色。第二个坑是背光。不少 SPI 屏的背光默认是低电平关闭,如果 BL 引脚悬空,屏幕背光不亮,你会误以为驱动失败。有的屏更坑,BL 引脚做成了跳线帽,默认不短接。检测方法很简单:上电瞬间看屏幕有没有轻微反光,拿手电照一照能看到隐隐约约的图案,说明屏幕其实在显示,只是背光没开。第三个坑是电平匹配。这块板子的 GPIO 是 3.3V 逻辑电平,屏幕的电源虽然是 5V,但大多数 SPI 屏的逻辑接口都能兼容 3.3V。如果你用的板子是 5V 逻辑电平,那就要小心了,直接驱动 3.3V 屏可能烧坏驱动 IC。解决办法是看屏幕 datasheet 里逻辑电平范围,如果板子和屏幕不匹配,加一块电平转换模块,别侥幸。我自己的血泪提醒:带触摸功能的 SPI 屏,第一版调试时不要接触摸排线。触摸和显示共用 SPI 总线,初始化时序不一样,接上去有时会影响显示初始化,排查问题时分不清到底哪个外设在搞事。先把显示跑通,再考虑触摸交互。上电前最后一步,拿万用表测一下 VCC 和 GND 之间有没有短路,顺手确认电源开关是关着的。这步十几秒的事,能避免把板子和屏幕一起送走。3. 点亮屏幕的三个阶段:纯色、边框、全屏图案点亮屏幕不要指望一步到位。我的经验是分三个阶段走:先亮纯色,再画边框网格,最后才显示文字。每一阶段都验证了一类问题,层层递进,出错了也知道是哪里。3.1 第一阶段:全屏纯色点亮屏幕的第一步,是把驱动 IC 的初始化序列发给屏幕,然后填充一整屏红色。初始化序列通常由屏厂或驱动 IC 的数据手册给出,是一串寄存器命令和数据,大概几十个字节。关键经验是:这段序列不要自己“优化”,照着资料发就行。我看到过有人在网上分享精简版初始化序列,结果测试时颜色偏、横线抖动,最后还是回到原厂序列。下面是初始化后发送一帧纯色的最小 Python 代码框架:import spidev import time # 打开 SPI 设备,bus 0,cs 0 spi = spidev.SpiDev() spi.open(0, 0) spi.max_speed_hz = 24_000_000 # 先从保守速率开始 # 初始化序列过长,这里只写调用方式 def send_command(cmd_bytes): # 通过 GPIO 控制 DC 引脚为低电平,表示发送命令 gpio_dc_low() spi.xfer(list(cmd_bytes)) def send_data(data_bytes): # 通过 GPIO 控制 DC 引脚为高电平,表示发送数据 gpio_dc_high() spi.xfer(list(data_bytes)) def init_screen(): # 复位:RST 拉低 20ms 再拉高 gpio_rst_low() time.sleep(0.02) gpio_rst_high() time.sleep(0.12) # 逐条发送初始化命令,例如: # send_command([0x01]) # SWRESET # time.sleep(0.15) # send_command([0x11]) # SLPOUT # time.sleep(0.15) # send_command([0x20]) # DFOFF? 具体以资料为准 pass # 填充一帧像素数据的思路 def fill_color(color_rgb565): # 先进入内存写模式,然后连续发送 320*480 个像素数据 # 每个像素两个字节,发送完就是全屏同一颜色 pixel = bytes([color_rgb565 >> 8, color_rgb565 & 0xFF]) * (320 * 480) send_data(pixel) init_screen() fill_color(0xF800) # RGB565 红色发送完成之后,屏幕应该整片红色。如果屏幕是花的、有横条、或者干脆黑屏,先不要碰代码,按这几个顺序排查:SPI 速率太高就降到 4MHz 试试;初始化序列是否完整发送;CS 和 DC 有没有接反;屏幕供电和背光有没有打开。很多新手在第一步就卡住了,其实是背光和接线问题,反而去折腾初始化代码,方向完全反了。这里我特别强调一个原则:先信硬件,再信软件。只要屏幕规格书和板子 GPIO 定义没看错,硬件大概率是对的,问题多半出现在速率、时序或者供电上。3.2 第二阶段:画边框和网格纯色屏验证通过之后,下一步是画一个大边框和一些网格线。这个阶段验证的是扫描方向和坐标原点。屏幕亮纯色时看不出方向,因为全屏同色,而一旦画边框,上下颠倒、左右镜像一眼就能看出来。画边框代码如下:from PIL import Image, ImageDraw # 创建 320x480 的黑色画布 img = Image.new("RGB", (320, 480), (0, 0, 0)) draw = ImageDraw.Draw(img) # 红色 10px 边框 draw.rectangle([0, 0, 319, 10], fill=(255, 0, 0)) draw.rectangle([0, 470, 319, 479], fill=(255, 0, 0)) draw.rectangle([0, 0, 10, 479], fill=(255, 0, 0)) draw.rectangle([310, 0, 319, 479], fill=(255, 0, 0)) # 绿色交叉线 draw.line((0, 0, 319, 479), fill=(0, 255, 0)) draw.line((319, 0, 0, 479), fill=(0, 255, 0))然后把 Image 对象转成 RGB565 字节流,再发给屏幕。转换函数是这个项目最核心的底层函数之一:def to_rgb565(img): # 注意:PIL 默认 RGB,但部分屏幕驱动 IC 实际是 BGR 像素格式 # 需要先用屏幕资料确认像素格式 rgb = img.convert("RGB") pix = rgb.load() buf = bytearray(320 * 480 * 2) idx = 0 for y in range(480): for x in range(320): r, g, b = pix[x, y] val = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3) buf[idx] = (val >> 8) & 0xFF buf[idx + 1] = val & 0xFF idx += 2 return bytes(buf)这个函数的重点在 RGB565 的位运算:红色取高 5 位放到最高位,绿色取高 6 位放中间,蓝色取高 5 位放最低位。如果显示出来的颜色不对——红色变成蓝色、绿色变成紫色——那就说明屏幕实际像素格式是 BGR,你需要把 r 和 b 交换一下再编码。边框阶段看到的现象往往不是完全镜像就是完全倒置。ILI9488 这类驱动 IC 支持通过寄存器设置扫描方向和坐标原点。一般资料里会给一个“设置扫描方向”的命令参数,你把它改成顺时针旋转 90 度、270 度之类的值,就能匹配物理安装方向。如果资料不好理解,另一个笨办法是:在软件层做坐标映射,也就是渲染的时候把宽度和高度的索引互换。两种方案我都试过,结论是尽量用寄存器设置,因为软件层做映射会引入额外的计算开销,而且容易出错。3.3 第三阶段:文字显示边框方向都对了,接下来才算真正让屏幕“说话”。这个阶段的目标是在屏幕上画一行时间数字,验证字体渲染和文字清晰度。Pillow 自带默认字体,但尺寸一放大就糊,最好加载系统里的无衬线字体。from PIL import ImageFont import subprocess def load_font(size): # Linux 下常见字体路径之一 path = "/usr/share/fonts/dejavu/DejaVuSans.ttf" # 如果找不到,用 fc-list 查一下有哪些字体可用 return ImageFont.truetype(path, size) big_font = load_font(46)画字的逻辑很简单:draw.text((20, 20), "12:34:56", font=big_font, fill=(255, 255, 255))文字显示时最需要注意的是字体大小和颜色对比度。SPI 屏的色深只有 16bit,颜色过渡本来就有限,白字配深色背景是最稳的选择。不要试图在小字号下做抗锯齿之外的复杂效果,比如描边、阴影,这些在低分辨率屏幕上会糊成一团。另外,文字垂直方向要留出足够行距,320x480 的分辨率看似不小,但真正排内容时你会发现可用的“有效像素”很紧张。每一行文字高度至少 40px 才会舒服。走到这一步,屏幕已经完全可控了。剩下的事情就是:把真实系统数据喂进来,让屏幕显示有意义的内容。4. 第一个 Demo 的最小数据链路:从 psutil 读数到屏幕像素跑通屏幕渲染之后,我立刻做了一个最小版本的信息面板 Demo:左上角大号时钟,右上角日期,中间一栏显示 CPU 使用率、内存使用率、磁盘使用率,底部一栏显示局域网 IP 和开机时长。这个 Demo 的数据全部来自本机,不需要任何外部服务,是验证数据链路的最佳起点。如果你一上来就接天气接口、日历同步之类的远程数据,一旦接口超时或返回格式变更,你会分不清是网络问题还是渲染问题。4.1 数据采集层数据采集用到了 psutil 这个库,一行代码就能拿到大部分信息:import psutil import socket import datetime def collect_status(): now = datetime.datetime.now() mem = psutil.virtual_memory() cpu = psutil.cpu_percent(interval=None) disk = psutil.disk_usage("/") uptime_seconds = int(now.timestamp() - psutil.boot_time()) # 获取局域网 IP:创建一个 UDP 套接字连接外部地址,再取套接字本端 IP ip = "127.0.0.1" try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) ip = s.getsockname()[0] s.close() except Exception: pass return { "time": now.strftime("%H:%M:%S"), "date": now.strftime("%Y-%m-%d"), "cpu": cpu, "mem": mem.percent, "disk": disk.percent, "ip": ip, "uptime": f"{uptime_seconds // 3600}h{(uptime_seconds % 3600) // 60}m", }注意 socket 获取 IP 这个方法,本质是构造一个不需要真正收发数据的 UDP 连接,通过路由表选出本机出口 IP。如果板子没联网,会走异常分支,显示 127.0.0.1,这个行为是合理的——对于状态面板来说,显示本地地址也比显示空要好。4.2 渲染层与主循环渲染层在 PC 上已经调好了,这里直接把渲染函数接上数据:def render_panel(data): img = Image.new("RGB", (320, 480), (10, 12, 20)) draw = ImageDraw.Draw(img) # 时间区 draw.text((20, 20), data["time"], font=big_font, fill=(255, 255, 255)) draw.text((230, 38), data["date"], font=mid_font, fill=(160, 170, 190)) # 分隔线 draw.line((16, 100, 304, 100), fill=(60, 70, 90)) # 系统信息区 sys_items = [ ("CPU", f"{data['cpu']}%"), ("MEM", f"{data['mem']}%"), ("DISK", f"{data['disk']}%"), ("UPTIME", data["uptime"]), ] y = 130 for label, value in sys_items: draw.text((22, y), label, font=label_font, fill=(120, 140, 170)) draw.text((140, y), value, font=value_font, fill=(230, 240, 255)) y += 40 # 底部 IP 栏 draw.line((16, 330, 304, 330), fill=(60, 70, 90)) draw.text((20, 350), "LAN IP", font=label_font, fill=(120, 140, 170)) draw.text((90, 350), data["ip"], font=value_font, fill=(120, 255, 180)) return img主循环是一个典型的嵌入式显示循环:import time while True: data = collect_status() frame = to_rgb565(render_panel(data)) # 实际发送时,需要先设置内存写窗口,再一次性发送全部像素 send_data(frame) time.sleep(1)这个循环里 sleep(1) 很重要。有人会问,为什么不等渲染完再等一秒,而是固定 1 秒一次?因为状态信息大部分时候没有变化,比如磁盘空间可能半小时才变 0.1 个百分点,你刷得再快也没有意义。固定 1 秒的节拍能平滑显示,也避免 psutil 采样过快导致 CPU 占用虚高。4.3 实测效果与性能数据Demo 跑起来后的表现比预期要好。整帧全刷 320x480 像素,换算成字节是 320 x 480 x 2 = 307200 字节。SPI 速率设置在 32MHz 时,理论上一帧约需 0.1 秒,实测加上渲染和转换时间,稳定在每秒钟刷 8 帧左右。这个数字对信息面板来说完全够用,我甚至把刷新周期固定为 2 秒,让显示更稳定。CPU 占用方面,Python 主循环里最耗时的是 pillow 的图片绘制和 RGB565 字节流转,两者合起来大约占板子单个核心的 20%~30%。这在轻量 Linux 上可以接受,毕竟板子平时还有其他后台服务要跑。5. 每帧 0.12 秒的启示:SPI 屏的刷新边界与后续优化方向Demo 跑通后我做了一个简单的基准测试,把渲染时间、转换时间、SPI 发送时间分别测量了一遍。结论是:SPI 发送时间占了大头,而且这个时间主要由物理链路决定,不是代码能直接改掉的。一帧 307200 字节,SPI 时钟 32MHz 时理论发送时间约 0.096 秒,加上地址窗口设置和 GPIO 翻转开销,实测单帧全刷要 0.12 秒左右。注意,这是理想状态,SPI 时钟越高,信号质量越差,容易花屏。我试过把速率拉到 48MHz,结果出现随机彩色噪点,瞬间明白这个屏幕的物理上限就在 36MHz 附近。这也是为什么驱动代码里我把 max_speed_hz 从 24MHz 起步,最后停在 32MHz 的原因。这个数字带来了一个重要启示:这个项目不适合做高刷新率内容,比如动态图表动画、视频播放,统统不要想。但Status Deck 这类信息面板天生就是低频应用,时间每秒变一次,CPU 负载每两秒变一次。与其跟物理限制较劲,不如把刷新率当作一个设计参数来用:全刷不够,可以做局部刷新。局部刷新的思路是把屏幕分成几个矩形区域,比如时间区、信息区、底部状态区,某个区域的数据变化时,只把该区域的窗口坐标和像素数据发送给屏幕。在 ILI9488 这类驱动 IC 上,这是原生支持的:先设置内存写窗口,再发送这块区域的像素数据。我的实测是,时间区域只有 200x80,刷新一次只要原有数据的十分之一时间,几乎可以无感更新。后续优化方向我也想清楚了。第一,把 RGB565 转换从纯 Python 循环改成逐行处理,减少重复索引开销,甚至可以用 NumPy 向量化。第二,把渲染和发送拆成两个线程,渲染在后台进行,发送线程只管把最新帧发出去,避免上一次发送还没完成、下一次渲染数据又准备好的堆积问题。第三,数据源从本地扩到远程,通过公开接口获取天气和日历事件,在底部状态栏滚动显示。这些都是下一篇再说的事。注意:如果你发现 spi.xfer 发送大数组时偶尔有丢帧或重复帧,可以先检查 SPI 的 CS 片选时序,很多屏对 CS 拉高拉低的前后沿有要求。发送前把 CS 拉低,全部数据发完再拉高,不要每发送一个字节就拉一次 CS。这个小问题曾经让我排查了很久,以为是硬件坏掉了,结果是 SPI 时序太“碎”。写完这篇回头想,从“硬件到了”到“跑通第一个 Demo”,真正的关键不是哪一步特别难,而是每一步都没有一步到位:接线要反复核对、纯色要验证格式、边框要调扫描方向、文字要试字体渲染、数据链路要从最小闭环开始。每次只引入一个变量,出了问题就只查那一个环节。这套方法论对任何嵌入式显示项目都通用。屏幕点亮的瞬间当然有成就感,但最有收获的其实是你知道它为什么能亮,也知道它会在什么情况下不亮。