Status Deck 这个桌面状态卡片项目折腾到第四篇,我今年给自己定的规矩是软件能写、硬件也得上手。前几篇聊了整体架构、后端数据聚合和前端布局设计,但一直没碰实物。这周硬件终于到了,这篇就是一次完整的硬件落地实录:从拆快递开始,到屏幕点亮,再到跑通第一个真正显示状态数据的 Demo。如果你也在做类似的桌面小屏信息面板,或者刚接触 MCU 驱动屏幕,这篇可以当一份避坑指南看。
1. 硬件方案回顾与物料清单
1.1 为什么最终选了这套搭配
Status Deck 要显示的东西其实不复杂:时间、天气、CPU 占用率、网络上下行速度,偶尔切几个页面看看订阅信息。这些内容基本都是文字和简单的柱状图,所以屏幕分辨率不需要太高,但响应速度要跟手,不能像手机那样动不动就刷新闪烁。系统本身是一个长期放在桌面上待机的小面板,需要安静、发热低、功耗不能太高。
基于这些需求,我最终放弃了“HDMI 小屏 + Linux 小主机”的组合。那个方案性能是强,但体积大、开机慢、还要维护图形桌面环境,对一块桌面状态牌来说,多少有点杀鸡用牛刀。最后改成了“单片机 + SPI 屏”的方案。MCU 直接操作屏幕像素,没有桌面系统,功耗只有一两瓦,开机基本是秒亮,稳定性也更容易控制。
屏幕最终选了一块 2.8 寸的 IPS 屏,分辨率 320x240,四线 SPI 接口。驱动 IC 是市面上非常常见的一种,指令集兼容通用 MIPI-DBI 类型,初始化序列在售卖页面上能找到。为什么选 SPI 不选 I2C?I2C 速度太慢,刷一帧画面时能明显看到扫描线,体验很差。SPI 用线少、带宽够,320x240 的 16 位色数据量对 SPI 来说完全不在话下。开发板则选了一块带 WiFi 的双核 MCU 板子,主频 240MHz,板载 USB 转串口,方便烧录和看日志。这套组合总成本不高,坏了也不心疼,非常符合自造玩具的定位。
1.2 到货清单与选型心得
这次到货的东西不多,列个表记一下,也算给后面想复刻的人一个参考:
| 物料 | 数量 | 作用说明 |
|---|---|---|
| 双核 MCU 开发板 | 1 块 | 主控,带 WiFi,负责数据和显示控制 |
| 2.8 寸 SPI IPS 屏模块 | 1 块 | 320x240 分辨率,带背光、排线 |
| 杜邦线/排线 | 若干 | 前期原型验证用,后期会换软排线 |
| 面包板 | 1 块 | 方便快速接线,不用焊 |
| 5V/2A USB 电源 | 1 个 | 给开发板供电 |
| 3D 打印外壳(半成品) | 1 套 | 还没打印,下一步的事 |
选型时有几个心得值得说。第一,不要只看屏幕分辨率,还要看是否带触摸、排线方向、模块是否自带电平转换。触摸屏对 Status Deck 这种固定显示面板没什么用,我就选了不带触摸的版本,便宜不少。第二,屏幕模块的排线很娇气,第一次买回来就发现排线压弯了,建议同时买一根备用排线,花不了几个钱,关键时候能救命。第三,MCU 板子上的引脚数量和引出方式也很重要,优先选把所有 GPIO 都引出的款,否则后面接屏幕时可能被某个引脚复用问题卡住。
2. 拆箱检查与首次上电
2.1 拆箱后别急着插电
快递到手,我第一件事不是插 USB,而是先拆出来做静态检查。先用肉眼过一遍屏幕排线有没有折痕、主控板引脚有没有歪针。然后拿万用表量了一下屏幕模块电源正极和地之间的阻值,确认没有短路。这一下真的能避开不少问题,之前见过有人拿到板子直接上电,结果焊点连锡,一冒烟就废了。
屏幕这类模块要特别注意逻辑电压。我的主控板输出是 3.3V 逻辑电平,屏幕模块也是 3.3V 供电逻辑,两者直接接就行。如果主控板是 5V IO 并且模块不支持 5V 输入,那就必须加电平转换,否则芯片会过热甚至击穿。用万用表量一下主控板的 VCC 引脚电压,是最靠谱的做法。
上电之前还要检查 USB 供电线。劣质 USB 线线阻大,插上去电压会掉到 4.6V 甚至更低,开发板就会反复重启,显示也容易花屏。我换了一根标称 5V/2A 的电源线,后面就再没出现过供电导致的随机重启。
2.2 首次上电的三种现场现象
插上 USB 线后,我遇到了三种典型现象,写出来给各位一个预期:
- 电源灯亮,但串口没有任何输出:大概率是 USB 转串口驱动没装,或者 TX/RX 接反。这个板子之前我在别的项目用过,串口正常。
- 屏幕背光亮,但屏幕没有内容:说明背光供电没问题,但控制器的命令和数据没通,后面初始化解决。
- 完全没反应:先查供电,再看 USB 线,最后看板子有没有焊错。
我这次的情况是第二种:屏幕上白光一闪,然后就一直亮着,像一个小夜灯。这其实是个好消息,说明主控板、屏幕背光、电源都没问题,问题出在数据通道。白屏不可怕,怕的是那种连背光都不亮的暗屏,那往往意味着硬件接线错了。
3. 点亮屏幕:接线、初始化和背光
3.1 八根线的连接方式
这块屏幕模块引出 8 个脚,除了电源和地,剩下 6 根都是控制信号。我用的接线方式如下,引脚编号以我手里这块开发板的 GPIO 标号为准,接口名是通用的:
| 屏幕引脚 | 开发板引脚 | 说明 |
|---|---|---|
| VCC | 3V3 | 逻辑电源 |
| GND | GND | 共地 |
| SCLK | GP5 | SPI 时钟 |
| MOSI/SDA | GP6 | SPI 数据输出 |
| CS | GP7 | 片选,低电平有效 |
| DC | GP8 | 数据/命令选择 |
| RST | GP9 | 复位,低电平有效 |
| BL | GP4 | 背光控制,接 PWM |
这里有几个容易踩坑的地方。MOSI 必须接到屏幕的 MOSI/SDA 引脚,别接到 MISO,否则数据发不出去,屏幕一直白屏。DC 信号在 SPI 协议里没有对应概念,它是在每次传输之前决定当前字节是命令还是数据。如果 DC 接错或者不接,屏幕会把命令字节当成像素数据,画面就会变成一堆乱码。
RST 我特意接到 GPIO 而不是直接接一个电阻电容做上电复位。原因很简单:软件可以随时硬复位屏幕,让它在初始化前处于干净状态。有些模块的 RST 引脚是单独的,不接会导致初始化时序不稳定,偶尔白屏。如果你用的是那种 RST 已经和芯片内部上拉电阻短接的模块,可以不接,但为了统一,我还是接了 GP9。
BL 是背光控制脚。一开始我图省事,把 BL 直接接 3V3,结果是背光常亮、亮度不可调,晚上放在桌面上像个小探照灯。后来改成 GP4 并配置成 PWM,才解决了亮度问题。如果你完全不在乎调光,接 3V3 也行,但后续想用传感器自动调光就麻烦了。
3.2 驱动 IC 初始化序列(含 SPI 速率选择)
接线没问题后,接下来就是初始化屏幕。SPI 屏幕不像串口屏那样上电自动显示字母,它内部是一片需要配置的显示控制器,必须先发一串命令让它进入正常显示模式。如果你写过 MCU 驱动 OLED 屏,这套流程会很眼熟。
先定义两个最底层函数:发命令和发数据。区别就在 DC 引脚的电平:
static void lcd_send_cmd(uint8_t cmd) { gpio_write(DC_PIN, 0); // 低电平表示命令 spi_write_bytes(&cmd, 1); } static void lcd_send_data(uint8_t *data, size_t len) { gpio_write(DC_PIN, 1); // 高电平表示数据 spi_write_bytes(data, len); }然后是复位和初始化:
static void lcd_reset(void) { gpio_write(RST_PIN, 0); delay_ms(20); gpio_write(RST_PIN, 1); delay_ms(120); // 给控制器留出启动时间 } static void lcd_init(void) { lcd_reset(); lcd_send_cmd(0x01); // Soft Reset delay_ms(120); lcd_send_cmd(0x11); // Sleep Out delay_ms(120); lcd_send_cmd(0x3A); // 像素格式 uint8_t p1 = 0x55; // 16 位色 lcd_send_data(&p1, 1); lcd_send_cmd(0x36); // 扫描方向 uint8_t p2 = 0x00; lcd_send_data(&p2, 1); lcd_send_cmd(0x29); // Display On }这套序列是最基础的,能让屏幕亮起来并显示内容。如果你拿到的是其他驱动 IC 的屏幕,序列可能略有不同,但结构都一样:复位、Sleep Out、设置像素格式、设置扫描方向、Display On。真正花时间的是后面调对齐和颜色,那要从 0x36 寄存器的方向位去试。
SPI 速率也要注意。主控板的 SPI 外设可能跑到 40MHz,但屏幕模块的排线、杜邦线以及芯片实际能力不一定跟得上。我把速率从 40MHz 往下调,最后稳定在 20MHz。测试方法很简单:如果刷全屏会出现随机杂点、横向偏移或颜色错位,就先降速。我见过有人用 8MHz 才正常,这种时候别觉得是主控不行,先检查线材和接触。
3.3 背光控制和 PWM 频率选择
背光控制听起来简单,其实也有参数要选。直接用 GPIO 拉高拉低只能控制开关,不能调亮度。要调节亮度,必须用 PWM 输出。我把 BL 引脚接到支持 PWM 的 GP4,然后配置成频率 5kHz、占空比可调。
PWM 频率选多少合适?太低了,比如 100Hz,虽然频率高于人眼闪烁阈值,但在屏幕切换时会产生可见的扫描条纹,尤其在拍照或斜视时更明显。太高了,比如 50kHz,又会给电源部分引入噪声,可能干扰 SPI 数据线。我试了一圈,在 1kHz 到 10kHz 之间选,5kHz 是一个比较稳的点。晚上调到 30% 占空比,既省电又不刺眼;白天用 80%,内容仍然可读。
调背光时还有一个容易忽略的点:很多 MCU 的 PWM 引脚和 SPI 引脚是复用的,如果你把 BL 接到了 SPI 时钟或者片选脚上,那初始化 PWM 的同时很可能把 SPI 配置改了,结果屏幕开始花屏。所以接线前一定要查清楚引脚复用表,尽量选一个独立的 GPIO 来做 PWM。
4. 跑通第一个状态 Demo
4.1 Demo 目标与信息流设计
屏幕亮起来只是第一步,真正让 Status Deck 有意义的,是显示真实状态数据。第一个 Demo 我没打算做花活,只显示四块内容:一个大标题 “Status Deck v0.1”、当前时间、CPU 占用率、内存使用量。刷新频率 1 秒一次。这些数据从哪来?由局域网里的一台电脑或者服务器提供一个 JSON 接口,MCU 通过 WiFi 去拉。
这个设计的好处是前后端解耦。MCU 不用自己去抓系统负载,那太复杂了,它只需要维护一个 HTTP 客户端,反复读取同一个 JSON 就行。数据源那边,无论 Python、Node 还是 Shell 脚本都能提供这个接口,后续想增加宿主机温度、磁盘占用率、网络速度,都只需要改服务端,MCU 侧只加解析字段。
JSON 结构定义得越短越好:
{ "time": "2025-03-20 14:22:33", "cpu": 23, "mem": 512 }返回体小,MCU 的解析压力就小,缓冲区也只需要开 1KB 左右。这个信息流设计看起来简单,却是整个项目最核心的骨架:显示终端只做展示,数据统一从服务端来。
4.2 WiFi 拉取 JSON 数据
MCU 连 WiFi 的过程就不详细贴代码了,不同开发板的 API 差异比较大。关键是在建立 WiFi 连接后,不要立刻发 HTTP 请求。我试过,开机能显示 IP,但直接 GET 经常超时,原因可能是 DHCP 还没完成、路由器没有立刻建立路由表。解决办法是在连接成功后延迟 2 到 3 秒再开始第一轮请求,并且设置一个超时时间。
HTTP GET 部分,我用的是底层 IP 协议栈配合一个轻量 HTTP 客户端接口。核心请求逻辑很简单:
char url[128]; snprintf(url, sizeof(url), "http://192.168.1.10:8080/status.json"); char response_buf[1024]; int len = http_get(url, response_buf, sizeof(response_buf)); if (len > 0) { response_buf[len] = '\0'; char time_str[32]; int cpu = 0; int mem = 0; json_get_string(response_buf, "time", time_str, sizeof(time_str)); json_get_int(response_buf, "cpu", &cpu); json_get_int(response_buf, "mem", &mem); display_status(time_str, cpu, mem); }我特意保留了最简单的 JSON 解析方式:只查短 key,不用嵌套结构。实际项目里我写了一个 60 行的极简解析函数,逐段扫描 response_buf,找到匹配 key 后把 value 拷贝出来。真没必要上完整的 JSON 库,那会让固件体积多出几十 KB,还可能占用内存导致崩溃。
为什么不用 HTTPS?因为这是局域网内部部署,数据不经过公网,HTTP 明文传输已经够用。等到 Status Deck 以后要访问外部服务或者远程部署时,再考虑加一层加密代理,主控端保持简单。
4.3 文本绘制与局部刷新
有了数据以后,就是把它画到屏幕上。屏幕驱动肯定是自己封装一层,不直接写在主循环里。我内置了一个 8x16 的 ASCII 字符字体,用来绘制文本。绘制流程就是先定位坐标,然后把背景色填充到指定矩形,最后逐字模像素写进显存。
刷新策略我选择了“局部刷新”而不是“全屏刷新”。全屏刷新 320x240x2 字节,大概 153KB 数据,按 20MHz SPI 算也需要 60 多毫秒,再加上 1 秒一次的周期,会导致明显的闪烁和 CPU 占用。而实际变化区域只有那一小条文字,所以只重绘变化行就够了。
第一次写 Demo 时我犯了个错误:每次都写一整块矩形再写字,结果信息闪烁。后来调整成下面这种模式:先只更新数值区域的背景,再写新字符串,最后才调一次屏幕刷新函数。刷新函数只把显存中的脏矩形区域发送到屏幕,而不是所有像素。这样重绘一行的耗时从几十毫秒降到几毫秒,肉眼看起来非常丝滑。
核心绘制函数长这个样子,思路可以直接复用:
void display_status(const char *time_str, int cpu, int mem) { char buf[32]; // 清掉时间区域 lcd_fill_rect(20, 20, 280, 16, BG_COLOR); lcd_draw_string(20, 20, time_str, FG_COLOR, BG_COLOR); snprintf(buf, sizeof(buf), "CPU: %d%%", cpu); lcd_fill_rect(20, 60, 280, 16, BG_COLOR); lcd_draw_string(20, 60, buf, FG_COLOR, BG_COLOR); snprintf(buf, sizeof(buf), "MEM: %dMB", mem); lcd_fill_rect(20, 96, 280, 16, BG_COLOR); lcd_draw_string(20, 96, buf, FG_COLOR, BG_COLOR); lcd_update_dirty(); }第一次跑通时,屏幕上时间一秒一秒跳,CPU 数值跟着本机负载上下波动,那一刻的成就感确实不小。虽然只是个极其简陋的原型,但已经能看出 Status Deck 最终形态的雏形:一块低功耗、低干扰、常驻桌面的信息面板。
5. 常见问题与排查技巧实录
5.1 白屏、花屏这类显示问题怎么查
白屏或花屏是 SPI 屏最常见的问题,问题可能出在接线、初始化序列、SPI 速率三个环节。建议先按下面这个顺序排查,能省很多时间:
- 白屏 + 背光亮:先检查 DC 接线,再检查 RST 时序。如果 RST 没接对,控制器一直没有退出复位状态。
- 花屏 + 内容有规律性错位:大概率是 0x36 寄存器里的扫描方向不对,或者 RGB/BGR 颜色顺序反了,需要把参数改成 0xC0 或 0x08 逐个试。
- 随机杂点 + 偶发性花屏:先降 SPI 速率,再看杜邦线是不是太长。超过 10 厘米的杜邦线在 20MHz 下很容易串扰。
- 画面偏移到屏幕边缘:初始化命令里缺少 Set Address Mode 或窗口设置,很多驱动 IC 默认扫描顺序和屏幕模块的排线方向不一致。
我这次遇到的问题是内容整体向右偏移了大概 20 像素。查了半小时,发现是初始化序列里没发“列地址/行地址”命令,导致控制器默认窗口起始坐标和屏幕物理原点不匹配。加了一组设置显示窗口的命令后,画面就正常了。这类问题用“逐条加命令、随时看屏幕变化”的方式排查,比盲猜高效得多。
5.2 WiFi 连接不稳定
跑 Demo 的过程中,MCU 偶尔会连不上 WiFi,或者连着连着就断线。我发现有几个原因需要逐一排除。
第一,天线位置。开发板的天线如果贴在金属桌腿或者机箱后面,信号强度会明显下降。我把板子竖起来,天线朝外,信号从 -65dBm 改善到 -48dBm,断线次数立刻减少。第二,电源电流。MCU 的 WiFi 开启瞬态电流很大,如果 USB 口供电不足,电压跌到 4.3V 就会直接掉线重启。换成单独 5V/2A 电源后,问题消失。第三,重试逻辑。没写断线自动重连时,我遇到过连不上只能拔插电源的尴尬。后来增加了一个简单的状态机:每 5 秒检查一次连接状态,断开就自动重连,重连成功后延迟 2 秒再发 HTTP 请求。
还有一个细节:路由器信道拥挤。我所在环境里 2.4G 信道堆了很多信号,我把路由器固定到信道 1,状态明显稳定。如果你的路由器没有固定信道选项,可以考虑在 MCU 固件里配置静态 IP,减少 DHCP 交互失败的概率。
5.3 内存不足和 JSON 解析崩溃
MCU 虽然有 240MHz 双核,但它毕竟还是嵌入式设备,内存比电脑小得多。我用的这块板子可用内存大概 300KB 左右,看着不小,但如果每个模块都开大缓冲区,很快就会不够用。
第一次写 JSON 解析时,我豪爽地开了一个 4KB 的 response_buf,然后又在绘字时申请了一块 8KB 的临时画布,结果启动后没几分钟就死机。后来分析日志,发现是动态内存碎片化,再加上 JSON 解析函数对边界条件没有判断,一个超长字符串直接越界覆盖了别的数据。
解决办法有三条,都在实际项目中验证过有效:
- 所有 HTTP 响应缓冲区大小不超过 1024 字节,服务端 JSON 结构严格控制字段数量和长度。
- JSON 解析用固定字符串搜索而不是完整解析,不使用动态分配。
- 所有字符串拷贝操作全部用带长度上限的安全函数,像
snprintf、strlcpy,不使用裸的sprintf和strcpy。
改完这些之后,固件连续运行了 48 小时没有死机。记住,在 MCU 上写代码,内存上限意识是刻到骨子里的习惯。
5.4 逻辑电平不匹配的隐藏坑
最后分享一个特别隐蔽的问题:逻辑电平不匹配。我一开始把屏幕模块的 VCC 接到了开发板的 5V 引脚上,因为屏幕模块丝印上面就写的 5V。结果屏幕是亮了,但颜色明显偏色,而且偶尔出现完全没有规律的横线。我以为是 SPI 速率问题,降速也没用。
后来用万用表量了一遍,才发现 MCU 的 SPI 输出是高电平 3.3V,屏幕模块的输入引脚被拉到了 5V 上,导致逻辑电平判断出错,数据时序不稳。把 VCC 改回 3V3,并把所有信号线确认都是 3.3V 电平后,偏色和横线问题同时消失。
所以拆箱后第一步真的别急着接线,先搞清楚板上哪几个引脚是 3V3、哪几个是 5V,再用万用表确认一下屏幕模块的实际供电电压。这块屏幕模块虽然丝印上有多种供电标注,但实际驱动 IC 的逻辑部分大多按 3.3V 工作。接错一次不一定烧,但一定会让你在排查显示异常时多花好几个小时。这个坑我记在小本上了,分享出来,希望大家别再踩。