尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
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 0cs 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, fontbig_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(intervalNone) 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], fontbig_font, fill(255, 255, 255)) draw.text((230, 38), data[date], fontmid_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, fontlabel_font, fill(120, 140, 170)) draw.text((140, y), value, fontvalue_font, fill(230, 240, 255)) y 40 # 底部 IP 栏 draw.line((16, 330, 304, 330), fill(60, 70, 90)) draw.text((20, 350), LAN IP, fontlabel_font, fill(120, 140, 170)) draw.text((90, 350), data[ip], fontvalue_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”真正的关键不是哪一步特别难而是每一步都没有一步到位接线要反复核对、纯色要验证格式、边框要调扫描方向、文字要试字体渲染、数据链路要从最小闭环开始。每次只引入一个变量出了问题就只查那一个环节。这套方法论对任何嵌入式显示项目都通用。屏幕点亮的瞬间当然有成就感但最有收获的其实是你知道它为什么能亮也知道它会在什么情况下不亮。
RELATED

相关推荐

MCAL功能安全分析实战:失效模式、诊断覆盖率与故障注入

MCAL功能安全分析实战:失效模式、诊断覆盖率与故障注入

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/10/20 2:09:17
50步还是8步?MOSS-Image-Base-bf16采样参数完全指南:Euler Flow Matching、CFG与Shift调优

50步还是8步?MOSS-Image-Base-bf16采样参数完全指南:Euler Flow Matching、CFG与Shift调优

人工智能大模型基础模型媒体生成本地部署 【免费下载链接】MOSS-Image-Base-bf16 项目地址: https://ai.gitcode.com/hf_mirrors/mlx-community/MOSS-Image-Base-bf16 点击查看 免费下载 MOSS-Image-Base-bf16 是面向 Apple Silicon 的 MLX bf16 文生图扩散模型。它…

📅 2026/10/20 2:09:17
GGUF 量化原理新手详解:Ling-3.0-tiny-GGUF 的 Q4_K_M 如何以 4.8GB 保全心智

GGUF 量化原理新手详解:Ling-3.0-tiny-GGUF 的 Q4_K_M 如何以 4.8GB 保全心智

【免费下载链接】Ling-3.0-tiny-GGUF 项目地址: https://ai.gitcode.com/hf_mirrors/inclusionAI/Ling-3.0-tiny-GGUF 点击查看 免费下载 这篇文章用小白也能看懂的方式,讲清 GGUF 量化原理:Ling-3.0-tiny-GGUF 是一个 7.9B 总参数、每 toke…

📅 2026/10/20 2:04:17
MORE NEWS

更多资讯

📰

Python游戏开发入门:用Pygame从零写一个接苹果小游戏

如果你也在学Python,学到列表、函数、循环之后就开始发愁——书上的练习题做完了,但“我到底能用Python做什么?”。我自己的经验是:去做一个能跑起来看的东西。我第一个真正写完的图形程序,就是用Pygame写的一个小游戏…

📰

使用Jmeter压测Kafka:指标分析、脚本实现与性能调优实践

1. 压测目标与方案选型:为什么用Jmeter测Kafka1.1 Kafka性能验证的核心指标Kafka这类消息中间件,性能测试的重点和普通HTTP接口不太一样。HTTP接口压测关注的是QPS、响应时间和错误率,而Kafka压测要看的是一套组合指标:写入吞吐量…

📰

AI Agent 浏览器自动化工具:agent-browser 的 Chromium CLI 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

Coze智能体工作流:1分钟生成修仙短视频的完整指南

2. 关键词魔法:从文案到画面的一个映射看到这里,你可能已经意识到一个关键问题:工作流的核心,就是如何把“玄幻修仙”这四个字,翻译成一连串画面。我实测下来,Coze智能体工作流里最常用、也最稳的画面实现方…

📰

DLL文件丢失别去“免费下载”了,用系统自带修复工具更安全

一看到“系统丢失MSJT4JLT.DLL文件”的提示,很多人第一反应就是去搜“免费下载”。这个思路我可以直接告诉你:方向错了。我见过太多人因为急着补DLL文件,结果电脑越修越乱,甚至直接成了全家桶病毒窝。这篇文章不教你怎么去下载站碰…

📰

GUI智能体“多动症”怎么治?状态感知推理的工程实践

我第一次被GUI智能体的“手抖”气到,是在一次内部表单填写测试里。让一个基于多模态大模型的GUI智能体去填请假审批单,人工十步左右能完成的事,它最后跑了三十二步。我把操作日志拉出来一看,全是重复点击同一个日期控件、在已经展…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬