Rust 嵌入式 LCD 开发入门指南:从点亮屏幕到 GUI 界面#
本文面向会 Rust 基础语法、玩过 MCU 点灯、但从未接触过 LCD 显示的新手。读完后,你将能独立点亮一块 SPI LCD、显示自定义图文,并理解 GUI 开发的入口。
一、开篇:这篇文章要解决什么问题#
如果你已经能用 Rust 点亮一颗 LED,下一步很自然地会想:能不能让屏幕显示点东西? 温度、时间、一张小图标、甚至一个按钮。
这篇文章就是带你走完这条路:
- 认识一块 SPI LCD 模块的硬件引脚
- 用 Rust 初始化它,点亮第一块屏幕
- 在屏幕上显示文字和图片
- 了解如何进一步做出交互界面(GUI)
- 避开新手最常踩的坑
阅读前提: 你已经配置好 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# | 丝印 | 方向 | 作用(类比) |
|---|---|---|---|
| 1 | GND | — | 电源地 |
| 2 | VCC | — | 电源正(通常 3.3V,部分模块兼容 5V) |
| 3 | SCL / SCK | MCU → LCD | SPI 时钟线——像水管的节奏,告诉数据”什么时候走” |
| 4 | SDA / MOSI | MCU → LCD | SPI 数据输入——像素数据的通道 |
| 5 | RES / RST | MCU → LCD | 硬件复位——像重启按钮,初始化前必须按一次 |
| 6 | DC / RS / A0 | MCU → LCD | 数据/命令选择——像邮递员的分拣标签 |
| 7 | CS | MCU → LCD | 片选——像水龙头开关,选中当前设备 |
重点:DC / RS / A0 是同一个引脚#
你在不同模块、不同教程里会看到三种叫法:
| 别名 | 全称 | 来源 |
|---|---|---|
| DC | Data/Command | Rust/C 驱动代码中的标准命名 |
| A0 | Address 0 | ST7789 / SSD1306 官方数据手册的原始命名 |
| RS | Register Select | 早期 LCD 沿用的叫法 |
功能完全一样:
- 拉低 → 后续 SPI 数据被屏幕解释为命令(如”设置显示区域”)
- 拉高 → 后续 SPI 数据被屏幕解释为像素数据(如”这个点是红色”)
✅ 看到模块上标着
RS或A0,直接接到代码里的dc参数即可,不需要任何额外配置。
为什么没有 MISO?#
SPI 通常有 MOSI/MISO 双向数据,但 LCD 是纯写入设备——你只管把像素推过去,它不需要回传数据给你。所以模块不引出 MISO 是正常设计,不是缺东西。
2.3 新手必看的实物辨识技巧#
- 看丝印,不看 Pin 编号。 不同厂家的排针顺序可能镜像翻转。永远以 PCB 上印的文字为准。
- 确认电压。 模块背面有一颗 LDO 芯片(如 XC6206)→ 一般兼容 5V 输入;没有 LDO → 仅限 3.3V。
- CS 的处理。 单屏且 SPI 总线不共享时,CS 可直接接 GND 常选通,简化接线。
- 背光。 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"toml3.2 接线对照表#
以 STM32F401 为例:
| MCU 引脚 | 模块丝印 | 说明 |
|---|---|---|
| PA5 | SCL | SPI1_SCK |
| PA7 | SDA | SPI1_MOSI |
| PA4 | CS | GPIO 输出 |
| PB0 | DC (或 RS/A0) | GPIO 输出 |
| PB1 | RES | GPIO 输出 |
| 3.3V | VCC | — |
| GND | GND | — |
⚠️ 接线检查 checklist:
- VCC 电压与模块匹配(3.3V or 5V)
- MOSI 接的是 SDA,不是 SCL
- DC 引脚确认是丝印上的 DC/RS/A0
- RST 没有悬空(要么接 MCU GPIO,要么通过 10kΩ 上拉到 VCC)
3.3 初始化代码逐行解读#
use st7789::{ST7789, Orientation};
use embedded_graphics::pixelcolor::Rgb565;
use embedded_graphics::prelude::*;
// ① 配置 SPI 外设(Mode 0, MSB First, 16MHz)
// Mode 0 = CPOL=0, CPHA=0,这是 ST7789 要求的时钟模式
let spi = Spi::new(SPI1, sck, mosi, miso)
.with_config(SpiConfig::default()
.mode(hal::spi::MODE_0)
.frequency(16.MHz())
);
// ② DC 和 RST 是普通 GPIO 输出引脚
let dc = GPIOB.split().pb0.into_push_pull_output();
let rst = GPIOB.split().pb1.into_push_pull_output();
let cs = GPIOA.split().pa4.into_push_pull_output();
// ③ 创建显示器对象(此时屏幕还没初始化)
let mut display = ST7789::new(
spi, // SPI 外设
dc, // DC 引脚
Some(rst), // RST 引脚,Option 类型
240, // 宽度(像素)
240, // 高度(像素)
);
// ④ 初始化:发送命令序列、设置显存窗口、配置颜色格式
display.init(&mut delay).unwrap();
// ⑤ 设置显示方向
display.set_orientation(Orientation::Portrait).unwrap();
// ⑥ 清屏为黑色——第一个验证步骤
display.clear(Rgb565::BLACK).unwrap();rust逐块解释:
- 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-rawbash将 PNG 转为 RGB565 大端序原始数据:
image-to-raw logo.png --color-format Rgb565 --byte-order BE -o logo_rgb565.rawbash⚠️ 注意字节序必须是 BE(大端),这是
embedded-graphics的Rgb565默认格式。如果模块是 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.cbash生成的 .c 文件包含位图数组,用脚本转为 Rust const 数组即可。20 个汉字只占约 2~5KB。
备选方案:文字极少时当图片处理
如果只需要显示几个固定词(如”高温报警”),可以直接把每个词渲染成 PNG,转成 ImageRaw 当图片画。比字体系统更简单,代码体积更小。
五、第四步:从静态显示到交互界面#
5.1 什么时候需要 GUI 框架#
问自己一个问题:你的界面有没有按钮、列表、页面切换、状态联动?
- 没有 →
embedded-graphics手动绘制就够了 - 有 → 你需要 GUI 框架
手动绘制交互界面的痛苦:
- 按钮按下/松开/禁用三种状态,你要手动管理重绘
- 列表滚动时,你要手动计算哪些项可见、哪些需要擦除
- 页面切换时,你要手动清屏、重绘所有元素
- 布局调整一个像素,所有坐标都要重新算
GUI 框架帮你解决这些问题。
5.2 主流框架对比#
| 框架 | 一句话描述 | 需要 Heap? | 适合场景 |
|---|---|---|---|
| Slint | 声明式 DSL + 热重载预览器 | ✅ | 产品级 UI,首选推荐 |
| lvgl-rs | C 语言 LVGL 的 Rust 绑定 | ✅ | 需要 50+ 现成控件 |
| egui | 即时模式,每帧重建 UI | ✅ | 调试面板、参数配置 |
| embedded-menu | 极简菜单,无动态分配 | ❌ | 无 heap 的简单设置菜单 |
新手推荐:Slint。 原因:
- 有实时预览器,改 UI 不用烧录
- 声明式语法,写 UI 像写配置文件
- 官方提供 MCU 支持(
mcu-board-supportcrate) - 同一份 UI 代码可在 PC 和 MCU 上运行
5.3 Slint 快速上手#
第一步:定义 UI(.slint 文件)
// ui/app.slint
export component MainWindow inherits Window {
title: "Sensor Monitor";
// 声明一个可修改的属性
in-out property <string> temp_text: "25.0°C";
// 声明一个回调
callback btn_clicked();
VerticalLayout {
Text {
text: root.temp_text;
font-size: 24px;
horizontal-alignment: center;
}
Button {
text: "刷新";
clicked => { root.btn_clicked(); }
}
}
}plaintext第二步:Rust 侧绑定逻辑
slint::include_modules!(); // 编译时生成 Rust 绑定代码
fn main() {
let ui = MainWindow::new().unwrap();
// 绑定按钮点击事件
ui.on_btn_clicked({
let ui_weak = ui.as_weak();
move || {
let temp = read_sensor(); // 你的传感器读取函数
ui_weak.upgrade().unwrap()
.set_temp_text(format!("{:.1}°C", temp).into());
}
});
ui.run().unwrap(); // 进入事件循环
}rust第三步:开发时使用实时预览器
slint-viewer ui/app.slintbash修改 .slint 文件后,预览窗口自动刷新,无需编译烧录。确认布局满意后再部署到 MCU。
5.4 GUI 框架的底层原理(简化版)#
不需要深入源码,理解三个概念就够用:
-
Widget Tree(组件树): UI 元素组织成一棵树(Window → Layout → Text/Button)。框架遍历这棵树来计算位置和绘制。
-
脏矩形渲染: 不是每帧都刷新整个屏幕。框架记录”哪些区域变了”(脏区域),只重绘这些区域。对于 SPI LCD(全屏刷新需要 115KB 数据),这个优化直接决定帧率能否达到 10fps 以上。
-
事件循环:
ui.run()内部是一个循环:
plaintextloop { 读取输入(触摸/按键)→ 更新逻辑 → 重绘脏区域 → 等待下一帧 }这个循环必须是非阻塞的,否则会卡死传感器读取等其他任务。
六、进阶避坑指南#
坑 1:SPI 性能瓶颈不在时钟频率#
“把 SPI 提到 40MHz 就能流畅刷图”——这是误解。
实际瓶颈往往在协议开销:
- 每次写命令/数据都要翻转 DC 引脚(GPIO 操作)
- 每次设窗都要发 2 条命令 + 8 字节参数
- 如果驱动是逐字节发送(
write_byte循环),GPIO 翻转时间可能比 SPI 传输还长
解决: 确保驱动使用批量写入(一次 write 整个缓冲区),让 DC 只在事务边界切换。用逻辑分析仪抓波形,观察 CS 有效时间占比。
坑 2:颜色偏色的排查顺序#
症状:图片红蓝互换、或整体色调异常。
排查顺序(不要跳步):
- 先试
display.set_color_order(ColorOrder::Bgr) - 再检查图片转换工具是否输出了 BE 字节序
- 最后才怀疑接线问题
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.slintbash量化对比:
- 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 5 | Slint 按钮 + 文本框 | 理解 GUI 事件循环 |
| Step 6 | 集成传感器实时更新 UI | 打通完整闭环 |
| Step 7 | 逻辑分析仪测帧率 | 建立性能优化意识 |
每一步先在模拟器跑通,再上真机。2~3 周可以走完全程。
延伸资源#
st7789crate 文档:docs.rs/st7789embedded-graphics官方示例:github.com/embedded-graphics/embedded-graphics- Slint 嵌入式教程:slint.dev/docs/mcu
lv_font_conv字体工具:github.com/lvgl/lv_font_conv