知识门户

Back

Rust 嵌入式 LCD 开发入门指南:从点亮屏幕到 GUI 界面#

本文面向会 Rust 基础语法、玩过 MCU 点灯、但从未接触过 LCD 显示的新手。读完后,你将能独立点亮一块 SPI LCD、显示自定义图文,并理解 GUI 开发的入口。


一、开篇:这篇文章要解决什么问题#

如果你已经能用 Rust 点亮一颗 LED,下一步很自然地会想:能不能让屏幕显示点东西? 温度、时间、一张小图标、甚至一个按钮。

这篇文章就是带你走完这条路:

  1. 认识一块 SPI LCD 模块的硬件引脚
  2. 用 Rust 初始化它,点亮第一块屏幕
  3. 在屏幕上显示文字和图片
  4. 了解如何进一步做出交互界面(GUI)
  5. 避开新手最常踩的坑

阅读前提: 你已经配置好 Rust 嵌入式工具链(rustup target add thumbv7em-none-eabihf 等),有一块 MCU 开发板(STM32、RP2040、ESP32 均可),以及一块 SPI 接口的 LCD 模块(本文以最常见的 ST7789 240×240 为例)。


二、第一步:认识你的屏幕硬件#

2.1 你手里拿的是什么#

先分清两个概念:

  • 裸芯片: ST7789 是一颗 30+ 引脚的 IC,支持 SPI、8080 并行、RGB 接口等多种通信方式。你不会直接用它。
  • 模块: 商家把裸芯片 + LCD 面板 + 背光 + 稳压电路 + 电容电阻全部焊在一块小 PCB 上,引出 6~8 根排针。你买到的就是模块,只需接信号线。

所以后面提到”屏幕”,指的都是模块。芯片内部那些电源管理引脚、并行接口引脚,模块厂商已经帮你处理好了。

2.2 六根关键引脚的作用#

一块典型的 7-pin SPI LCD 模块,排针丝印通常如下:

Pin#丝印方向作用(类比)
1GND电源地
2VCC电源正(通常 3.3V,部分模块兼容 5V)
3SCL / SCKMCU → LCDSPI 时钟线——像水管的节奏,告诉数据”什么时候走”
4SDA / MOSIMCU → LCDSPI 数据输入——像素数据的通道
5RES / RSTMCU → LCD硬件复位——像重启按钮,初始化前必须按一次
6DC / RS / A0MCU → LCD数据/命令选择——像邮递员的分拣标签
7CSMCU → LCD片选——像水龙头开关,选中当前设备

重点:DC / RS / A0 是同一个引脚#

你在不同模块、不同教程里会看到三种叫法:

别名全称来源
DCData/CommandRust/C 驱动代码中的标准命名
A0Address 0ST7789 / SSD1306 官方数据手册的原始命名
RSRegister Select早期 LCD 沿用的叫法

功能完全一样:

  • 拉低 → 后续 SPI 数据被屏幕解释为命令(如”设置显示区域”)
  • 拉高 → 后续 SPI 数据被屏幕解释为像素数据(如”这个点是红色”)

✅ 看到模块上标着 RSA0,直接接到代码里的 dc 参数即可,不需要任何额外配置。

为什么没有 MISO?#

SPI 通常有 MOSI/MISO 双向数据,但 LCD 是纯写入设备——你只管把像素推过去,它不需要回传数据给你。所以模块不引出 MISO 是正常设计,不是缺东西。

2.3 新手必看的实物辨识技巧#

  1. 看丝印,不看 Pin 编号。 不同厂家的排针顺序可能镜像翻转。永远以 PCB 上印的文字为准。
  2. 确认电压。 模块背面有一颗 LDO 芯片(如 XC6206)→ 一般兼容 5V 输入;没有 LDO → 仅限 3.3V。
  3. CS 的处理。 单屏且 SPI 总线不共享时,CS 可直接接 GND 常选通,简化接线。
  4. 背光。 7-pin 模块通常背光常亮(内部接 VCC)。如需 PWM 调光,找到模块背面预留的 BLK 焊盘自行引出。

三、第二步:用 Rust 点亮屏幕#

3.1 准备工作清单#

硬件:

  • MCU 开发板(本文以 STM32F4 为例,RP2040/ESP32 同理)
  • ST7789 240×240 SPI 模块
  • 杜邦线若干

软件依赖(Cargo.toml):

[dependencies]
st7789 = "0.7"
embedded-hal = "1.0"
embedded-graphics = "0.8"
cortex-m-rt = "0.7"
panic-halt = "0.2"
toml

3.2 接线对照表#

以 STM32F401 为例:

MCU 引脚模块丝印说明
PA5SCLSPI1_SCK
PA7SDASPI1_MOSI
PA4CSGPIO 输出
PB0DC (或 RS/A0)GPIO 输出
PB1RESGPIO 输出
3.3VVCC
GNDGND

⚠️ 接线检查 checklist:

  • VCC 电压与模块匹配(3.3V or 5V)
  • MOSI 接的是 SDA,不是 SCL
  • DC 引脚确认是丝印上的 DC/RS/A0
  • RST 没有悬空(要么接 MCU GPIO,要么通过 10kΩ 上拉到 VCC)

3.3 初始化代码逐行解读#

逐块解释:

  • SPI 配置: ST7789 要求 SPI Mode 0(时钟空闲低电平,上升沿采样数据)。频率建议新手先用 10~16MHz,稳定后再提高。
  • ST7789::new() 的宽高参数: 告诉驱动显存窗口大小。如果你的模块是 135×240 或 170×320,这里要对应修改。
  • init() 做了什么: 内部会发送一串初始化命令(唤醒、退出睡眠、设置像素格式为 RGB565、设置显示方向等),然后通过 RST 引脚做硬件复位。
  • 类型状态安全: 注意 display.clear(...) 只能在 init() 之后调用。如果你尝试在 new() 之后直接 clear(),编译器会报错——因为类型还没变成”已初始化”状态。这是 Rust 驱动相比 C 驱动的独特优势。

3.4 第一个验证:清屏并填充纯色#

display.clear(Rgb565::RED).unwrap();
rust

如果屏幕变成纯红色,恭喜,驱动和硬件都没问题。

如果颜色不对(比如变蓝了):

这是 ST7789 最常见的坑——颜色顺序问题。ST7789 支持 RGB 和 BGR 两种像素排列,很多国产模块出厂默认是 BGR:

use st7789::ColorOrder;
display.set_color_order(ColorOrder::Bgr);
rust

排查顺序: 先试切换 ColorOrder → 再检查图片转换工具的字节序 → 最后怀疑接线。不要一上来就改代码逻辑。

3.5 常见初始化失败排查#

症状可能原因解决方法
屏幕全白/全黑,无任何变化RST 悬空或接线错误确认 RST 接了 GPIO 或上拉
偶尔初始化失败SPI 速率过高降到 8MHz 重试
显示偏移/花屏DC 引脚接反或接错对照丝印重新检查
颜色异常BGR/RGB 顺序不匹配set_color_order(ColorOrder::Bgr)
只有部分区域有图像宽高参数写错确认模块实际分辨率

四、第三步:显示文字和图片#

4.1 理解 embedded-graphics 的设计哲学#

在写任何绘图代码之前,先理解一个核心概念:

embedded-graphics 不知道你的屏幕是什么。

它只知道一件事:“往一个矩形区域填像素”。具体怎么填、通过 SPI 还是 I2C、屏幕是 240×240 还是 320×240,它不关心。

用一个类比:

  • DrawTarget(画布): 你的屏幕驱动实现了这个 trait,相当于告诉 embedded-graphics:“你可以往我这里画东西”
  • Text / Image / Rectangle(画笔指令): 这些是绘图元素,调用 .draw(&mut display) 时,它们会把自己光栅化成像素流,交给画布
你的代码:Text::new("Hello").draw(&mut display)


embedded-graphics:把 "Hello" 光栅化为像素坐标和颜色


st7789 驱动:把像素流通过 SPI 发给屏幕


ST7789:更新显存,LCD 显示变化
plaintext

好处: 同一套绘图代码,换个屏幕只需换驱动,绘图逻辑完全不用改。甚至可以在 PC 上用模拟器跑。

4.2 显示英文文字#

use embedded_graphics::{
    prelude::*,
    pixelcolor::Rgb565,
    text::Text,
    mono_font::{ascii::FONT_8X13, MonoTextStyle},
};

// 创建文字样式:字体 + 颜色
let style = MonoTextStyle::new(&FONT_8X13, Rgb565::WHITE);

// 在坐标 (10, 20) 处绘制文字
// 坐标原点在屏幕左上角,x 向右,y 向下
Text::new("Hello Rust!", Point::new(10, 20), style)
    .draw(&mut display)
    .unwrap();
rust

要点:

  • 内置字体(如 FONT_8X13)是编译时嵌入的位图,不需要外部文件,零运行时开销
  • 坐标 (0, 0) 在屏幕左上角,y 轴向下增长
  • FONT_8X13 表示每个字符 8 像素宽、13 像素高

4.3 显示图片(编译时嵌入方案)#

嵌入式 MCU 通常没有文件系统,所以推荐编译时嵌入:把图片转换成原始像素数组,用 include_bytes! 编译进固件。

第一步:转换图片

安装 image-to-raw 工具:

cargo install image-to-raw
bash

将 PNG 转为 RGB565 大端序原始数据:

image-to-raw logo.png --color-format Rgb565 --byte-order BE -o logo_rgb565.raw
bash

⚠️ 注意字节序必须是 BE(大端),这是 embedded-graphicsRgb565 默认格式。如果模块是 BGR 模式,颜色会通过 set_color_order 在驱动层处理,图片数据本身不需要改。

第二步:在代码中使用

use embedded_graphics::image::{Image, ImageRaw};

// include_bytes! 在编译时将文件内容嵌入固件
const IMG_DATA: &[u8] = include_bytes!("logo_rgb565.raw");

// 创建原始图像对象,64 是图片宽度(像素)
let raw_image = ImageRaw::<Rgb565>::new(IMG_DATA, 64);

// 在坐标 (88, 88) 处绘制
let image = Image::new(&raw_image, Point::new(88, 88));
image.draw(&mut display).unwrap();
rust

原理: include_bytes! 是 Rust 的编译期宏,它在编译时读取文件,把字节内容变成一个 &[u8] 常量数组。运行时不需要文件系统、不需要解码,直接把数组内容通过 SPI 发给屏幕。

⚠️ 性能提示: Image::draw() 内部会调用 DrawTarget::fill_contiguous 批量写入像素。确保你的 SPI 驱动支持批量传输(DMA 或 write 整个切片),而不是逐字节发送。否则一张 64×64 的图可能要刷好几秒。

4.4 中文显示的务实方案#

内置字体只有 ASCII,显示中文需要额外处理。

为什么不能直接嵌入完整字库?

算一笔账:一个 16×16 像素的单色汉字 = 16×16÷8 = 32 字节。GB2312 一级常用汉字 3755 个,总计 ≈ 120KB。对于 RAM 只有 64~256KB 的 MCU,这直接吃掉一半内存。

推荐方案:子集化字库

只提取你 UI 中实际用到的汉字:

# 安装 lv_font_conv
npm install -g lv_font_conv

# 提取指定汉字,生成 4bpp 灰度字体
lv_font_conv \
  --font NotoSansSC-Regular.ttf \
  --range "温度湿度设置确认取消开始停止" \
  --size 16 \
  --bpp 4 \
  --format lvgl \
  -o font_subset.c
bash

生成的 .c 文件包含位图数组,用脚本转为 Rust const 数组即可。20 个汉字只占约 2~5KB。

备选方案:文字极少时当图片处理

如果只需要显示几个固定词(如”高温报警”),可以直接把每个词渲染成 PNG,转成 ImageRaw 当图片画。比字体系统更简单,代码体积更小。


五、第四步:从静态显示到交互界面#

5.1 什么时候需要 GUI 框架#

问自己一个问题:你的界面有没有按钮、列表、页面切换、状态联动?

  • 没有 → embedded-graphics 手动绘制就够了
  • 有 → 你需要 GUI 框架

手动绘制交互界面的痛苦:

  • 按钮按下/松开/禁用三种状态,你要手动管理重绘
  • 列表滚动时,你要手动计算哪些项可见、哪些需要擦除
  • 页面切换时,你要手动清屏、重绘所有元素
  • 布局调整一个像素,所有坐标都要重新算

GUI 框架帮你解决这些问题。

5.2 主流框架对比#

框架一句话描述需要 Heap?适合场景
Slint声明式 DSL + 热重载预览器产品级 UI,首选推荐
lvgl-rsC 语言 LVGL 的 Rust 绑定需要 50+ 现成控件
egui即时模式,每帧重建 UI调试面板、参数配置
embedded-menu极简菜单,无动态分配无 heap 的简单设置菜单

新手推荐:Slint。 原因:

  1. 有实时预览器,改 UI 不用烧录
  2. 声明式语法,写 UI 像写配置文件
  3. 官方提供 MCU 支持(mcu-board-support crate)
  4. 同一份 UI 代码可在 PC 和 MCU 上运行

5.3 Slint 快速上手#

第一步:定义 UI(.slint 文件)

第二步:Rust 侧绑定逻辑

第三步:开发时使用实时预览器

slint-viewer ui/app.slint
bash

修改 .slint 文件后,预览窗口自动刷新,无需编译烧录。确认布局满意后再部署到 MCU。

5.4 GUI 框架的底层原理(简化版)#

不需要深入源码,理解三个概念就够用:

  1. Widget Tree(组件树): UI 元素组织成一棵树(Window → Layout → Text/Button)。框架遍历这棵树来计算位置和绘制。

  2. 脏矩形渲染: 不是每帧都刷新整个屏幕。框架记录”哪些区域变了”(脏区域),只重绘这些区域。对于 SPI LCD(全屏刷新需要 115KB 数据),这个优化直接决定帧率能否达到 10fps 以上。

  3. 事件循环: ui.run() 内部是一个循环:

    loop {
        读取输入(触摸/按键)→ 更新逻辑 → 重绘脏区域 → 等待下一帧
    }
    plaintext

    这个循环必须是非阻塞的,否则会卡死传感器读取等其他任务。


六、进阶避坑指南#

坑 1:SPI 性能瓶颈不在时钟频率#

“把 SPI 提到 40MHz 就能流畅刷图”——这是误解。

实际瓶颈往往在协议开销

  • 每次写命令/数据都要翻转 DC 引脚(GPIO 操作)
  • 每次设窗都要发 2 条命令 + 8 字节参数
  • 如果驱动是逐字节发送(write_byte 循环),GPIO 翻转时间可能比 SPI 传输还长

解决: 确保驱动使用批量写入(一次 write 整个缓冲区),让 DC 只在事务边界切换。用逻辑分析仪抓波形,观察 CS 有效时间占比。

坑 2:颜色偏色的排查顺序#

症状:图片红蓝互换、或整体色调异常。

排查顺序(不要跳步):

  1. 先试 display.set_color_order(ColorOrder::Bgr)
  2. 再检查图片转换工具是否输出了 BE 字节序
  3. 最后才怀疑接线问题

90% 的情况在第 1 步就解决了。

坑 3:电源噪声导致花屏/重启#

ST7789 内部电荷泵工作时电流突变可达 30mA。如果供电去耦不足:

  • 随机花屏
  • 初始化偶尔失败
  • 显示一段时间后重启

解决: VCC 引脚紧贴放置 ≥10μF MLCC + 100nF。杜邦线尽量短(<10cm),长走线在 20MHz 以上会出现反射。

坑 4:异步环境下的阻塞陷阱#

如果你用了 Embassy 或 RTIC 异步框架:

// ❌ 错误:阻塞整个 executor
async fn update_ui() {
    delay_ms(100);  // 这会卡死所有其他任务!
}

// ✅ 正确:使用异步延时
async fn update_ui() {
    Timer::after_millis(100).await;
}
rust

同理,GUI 框架的 tick() 调用必须是非阻塞的,否则传感器采集、通信等任务全部饿死。

坑 5:永远先用模拟器调试 UI#

# embedded-graphics 模拟器
cargo run --example simulator

# Slint 实时预览
slint-viewer ui/app.slint
bash

量化对比:

  • PC 上调整布局:1 秒
  • 真机编译 + 烧录 + 观察:30 秒起步

逻辑 bug 在 PC 上可以用 println! 和 debugger 秒杀;GDB 连嵌入式设备的效率极低。先在模拟器跑通所有逻辑,最后再上真机验证硬件。


七、总结与下一步#

技术栈分层回顾#

┌────────────────────────────────────────┐
│  GUI 层:Slint / LVGL / egui          │  ← 交互、布局、事件
├────────────────────────────────────────┤
│  图形层:embedded-graphics             │  ← 光栅化文字/图片/图形
├────────────────────────────────────────┤
│  驱动层:st7789 / mipidsi             │  ← SPI 事务、窗口管理
├────────────────────────────────────────┤
│  硬件层:MCU SPI + LCD 模块           │  ← 电气信号
└────────────────────────────────────────┘
plaintext

每一层只依赖下一层的接口(trait),不依赖具体实现。这就是为什么换屏幕只需换驱动 crate,绘图代码不用改。

Rust 的独特价值#

类型状态模式把运行时错误变成编译期错误:

  • 未初始化的屏幕不能绘图(编译报错)
  • 引脚方向配置错误不能通过编译
  • 换屏重构时,编译器强制你检查所有依赖点

这在 C/C++ 驱动中几乎不存在。

推荐实践路径#

阶段目标验证标准
Step 1清屏 + 纯色填充确认硬件和驱动正常
Step 2显示英文字符串掌握文字 API 和坐标系
Step 3显示一张嵌入图片掌握图片转换工具链
Step 4显示一行中文(子集字体)掌握字库工具链
Step 5Slint 按钮 + 文本框理解 GUI 事件循环
Step 6集成传感器实时更新 UI打通完整闭环
Step 7逻辑分析仪测帧率建立性能优化意识

每一步先在模拟器跑通,再上真机。2~3 周可以走完全程。

延伸资源#

  • st7789 crate 文档:docs.rs/st7789
  • embedded-graphics 官方示例:github.com/embedded-graphics/embedded-graphics
  • Slint 嵌入式教程:slint.dev/docs/mcu
  • lv_font_conv 字体工具:github.com/lvgl/lv_font_conv

Rust嵌入式LCD开发入门指南:从点亮屏幕到GUI界面
https://glinfei.space/blog/nostd-rust/lcd
Author 甘霖飞
Published at 2026年8月19日
Comment seems to stuck. Try to refresh?✨