拿到 Seeed 的 reTerminal E1002 之后,我第一反应不是去官网抄那个 Python 例程,而是想把这块 7.3 英寸彩色电子纸用 Rust 完整驱动起来。当时这个想法看起来有点自找麻烦——电子纸驱动历来是 C 和 Python 的天下,官方 SDK、参考代码、社区帖子基本都是这两门语言的,Rust 的成熟样例少得可怜。但折腾了两个周末之后,我可以负责任地说:这条路不但走得通,而且比预想的舒服很多。
整个过程拆开看其实不复杂。硬件链路是 CM4 的 SPI 总线接到 IT8951 显示控制器,控制器再驱动 ACeP 彩色电子纸面板;软件层要做的事情就三件——写 SPI 命令、把图片降色到面板支持的 7 色调色板、安排刷新策略。如果你手里有类似板子,或者正准备给工业设备配一块低功耗显示面板,这篇东西应该能帮你少走不少弯路。就算你只是想用 Python 抄个作业,里面关于时序、波形、BUSY 等待的调试思路同样适用。下面按我实际动手的顺序来讲。
1. 开箱之后先看清硬件链路:E1002 到底是怎么和面板通信的
1.1 这块 7.3 英寸彩色电子纸特殊在哪
reTerminal 系列是树莓派 CM4 为核心的工业终端设备,常见版本配的是 IPS LCD 触摸屏。E1002 这个型号不一样,它把屏幕换成了 7.3 英寸彩色电子纸,分辨率在 800x480 级别(具体以 IT8951 读回的参数为准)。这块屏用的是 ACeP(Advanced Color ePaper)技术,和常见的黑白墨水屏不是一个路线。它每个像素里封装的是黑、白、红、绿、蓝、黄、橙几种颜色的墨水颗粒,显示内容时通过电场驱动颗粒翻转,而且一旦画面稳定下来,就不再需要任何电力维持。
这意味着两件事。第一,色彩表现和 LCD 完全是两个物种,它只能显示这 7 种颜色,不能像 RGB 屏幕那样一个像素混出 1600 万色;第二,刷新方式完全不同,LCD 是"持续刷新像素",电子纸是"一次性翻转颗粒然后保持"。所以你在 Linux 下看到的/dev/fb0 那种投屏思路在这里行不通,必须走专用的显示控制器接口。
这块屏的"脾气"用一张表概括比较直观:
| 特性 | LCD 屏 | ACeP 彩色电子纸 |
|---|---|---|
| 刷新率 | 60Hz 起,流畅显示视频 | 全屏刷新秒级,只能静态/低频刷新 |
| 色彩 | 16.7M 色 | 黑、白、红、绿、蓝、黄、橙 7 色 |
| 功耗 | 背光常亮,持续耗电 | 静态显示零功耗,只有刷新时耗电 |
| 户外可读性 | 强光下需要高亮度背光 | 反射环境光,阳光下清晰 |
| 显示效果 | 平滑、鲜艳 | 类报纸质感,有色差和颗粒感 |
选它当工业 HMI 的人,看重的基本都是低功耗和静态保持这两条。但反过来,想让它在上面流畅播放动画基本是放弃治疗。理解了这块屏是什么定位,后面的驱动设计思路才不会跑偏。
1.2 从 CM4 到像素的完整数据通路
E1002 的内部通信链路是这样一个结构:CM4 的 SPI 控制器通过底板引出的 SPI 总线连接 IT8951 控制器,IT8951 再接 ACeP 电子纸面板。这里有个关键认知:Rust 程序不需要直接操作每一个墨水颗粒。
IT8951 是一颗专用的 TCON(时序控制器),它内部有一大块帧缓冲 RAM,还内置了驱动电子纸面板用的波形表(waveform)。我们要做的事情是:通过 SPI 把像素数据写到 IT8951 的帧缓冲里,然后告诉它"用哪套波形、刷新哪个区域"。至于具体哪个颗粒怎么翻转、波形时序怎么走,都是 IT8951 的事。这大大降低了驱动复杂度。
除了 SPI 的四根线(SCLK、MOSI、MISO、CS),板上还会引出几个 GPIO 给 IT8951 用。最关键的三个是:
- RST:复位引脚,上电时给一个低脉冲初始化控制器。
- BUSY(有的资料叫 HRDY):忙信号,IT8951 正在执行波形刷新时拉高,处理完拉低。
- PWR / PNL_EN:电源使能,控制面板供电。
不同的底板引脚编号不完全一样,所以我代码里先用了占位符,实际使用时以你手里那块板的原理图和设备树为准。别照抄 GitHub 上别人项目的 GPIO 编号,栽过跟头的人都知道这事有多坑。
基于这个硬件结构,整个驱动其实分成了三层:最底层的 SPI 命令封装、中间的图像处理和调色板映射、最上层的刷新策略管理。下面每一节对应一层的落地代码。
2. 为什么用 Rust 而不是 Python/C:选型时的真实想法
2.1 Rust 在这里解决了什么问题
先说结论:这个项目用 Rust 不是炫技,是真的有收益。
官方和社区提供的驱动示例基本都是 Python 或者 C。Python 胜在开发快,几分钟就能把屏点亮。但 E1002 这种设备定位是工业终端,可能 7x24 小时挂在产线上跑。Python 脚本要带解释器、依赖库,进程一多内存和状态管理就比较散;而且一旦某个第三方库要升级,很容易把整个环境搞乱。C 当然能解决部署问题,但自己手写协议栈的时候,指针、越界、缓冲区溢出这些坑会一直悬在头顶,尤其是在处理图像数据这种字节密集的操作时。
Rust 在这两个方向的优势很明显:编译出来是单个静态二进制,scp 到板子上就能跑,没有任何运行时依赖;内存安全靠编译期保证,不会因为一个数组越界把 SPI 总线搞挂。更实际的一点是,cargo 生态里已经有spidev、gpio-cdev这类直接对接 Linux 内核接口的成熟库,完全不用自己写 FFI 绑 C 库。
如果你之前没写过 Rust,也不用有压力。这个项目用到的语法非常收敛:往结构体里塞字段、Result错误处理、match分支,基本就这些。哪怕你是 Rust 入门水平,边写边查标准库,两个周末足够跑通。
2.2 依赖选型:直接操作内核节点,还是套 embedded-hal
Rust 嵌入式生态里有两条常见路线。一条是用linux-embedded-hal这类 crate 做抽象,把 SPI、GPIO 统一成 trait,代码可以部分复用到 other 平台;另一条是直接用spidev和gpio-cdev操作 Linux 内核暴露的/dev/spidev*和/dev/gpiochip*节点。
我选了第二条,直接操作内核节点。原因有几个:第一,E1002 跑的是标准 Linux,设备节点是现成的,打开文件就能读写,意图最直白;第二,少封装一层抽象,调试的时候发出去的每个字节是什么、时序对不对,一眼就能看到,不用在 trait 的层层包装里猜;第三,这个项目本来也不追求跨平台,没必要为了可移植性付出调试成本。
最终依赖清单如下:
[dependencies] spidev = "0.6" gpio-cdev = "0.5" image = "0.24" anyhow = "1" log = "0.4" env_logger = "0.10"image用来解码 PNG 图片,anyhow统一错误处理,log打调试日志。没有大而全的框架,每个库只负责一件事。
关于是否要上embedded-hal,我的建议是:如果你是做产品原型,直接内核节点最省事;如果你想把这套驱动移植到 STM32 之类 no_std 平台,再考虑抽象层。先跑通再谈抽象,否则你会同时面对"硬件没通"和"抽象设计不合理"两个问题。
3. 点亮第一帧:初始化与 SPI 通信的 Rust 实现
3.1 先确认内核设备节点
上电开机之后,第一步不是写代码,而是确认板子上 SPI 设备节点存在。SSH 到 CM4 上执行:
ls /dev/spidev* ls /dev/gpiochip*reTerminal E1002 这样的官方终端,设备树里一般已经配好了 SPI,能看到/dev/spidev0.0之类的节点。如果什么都没有,需要检查/boot/config.txt里是否启用了 SPI 驱动,比如dtparam=spi=on。
GPIO 那边,能看到/dev/gpiochip0就行。用gpio-cdevcrate 操作的是 libgpiod 那一套接口,比老掉牙的 sysfs 稳定,也更接近现代 Linux 的方式。这里有个小坑:某些 GPIO 可能被系统的其他驱动占用,request的时候会返回Busy,所以代码里要把错误信息打完整,不然会以为是代码逻辑写错了。
3.2 初始化序列:复位、运行、读设备信息
IT8951 的上电初始化是有固定顺序的,不能偷懒。我的完整初始化流程如下:
- 复位引脚拉高,让控制器先上电稳定几十毫秒。
- 复位引脚拉低,保持 20ms 以上,再拉高。
- 等待 BUSY 信号从忙变闲。
- 发送
SYS_RUN命令,让控制器进入运行状态。 - 读取设备信息寄存器,拿到面板分辨率、帧缓冲基地址、波形版本等参数。
这里一定要强调复位时序:有些例程里只用sleep(10ms)糊弄过去,实测在某些批次的面板上会偶发初始化失败。复位低电平时间宁可长不要短,等 BUSY 的轮询循环里每次 sleep 10ms 也没问题,这块板子不差这几毫秒。
Rust 侧的操作大致是这个骨架:
use std::thread; use std::time::Duration; use spidev::{Spidev, SpidevOptions, SpiModeFlags}; use anyhow::Result; fn main() -> Result<()> { env_logger::init(); // 打开 SPI 设备,IT8951 需要 Mode 0,速率先别拉太高 let mut spi = Spidev::open("/dev/spidev0.0")?; let opt = SpidevOptions::new() .max_speed_hz(8_000_000) .mode(SpiModeFlags::SPI_MODE_0) .build(); spi.configure(&opt)?; // 复位时序:拉低 20ms 后拉高 log::info!("reset IT8951"); // 具体引脚编号对应你手里的底板原理图 // set_rst(false); thread::sleep(Duration::from_millis(20)); // set_rst(true); thread::sleep(Duration::from_millis(100)); // 等待 BUSY 拉低,表示初始化完成 // while busy_is_high() { } log::info!("IT8951 ready"); Ok(()) }IT8951 的 SPI 命令格式和普通 SPI 外设不太一样,命令要按"命令字 + 保留字节 + 后续数据长度 + 数据体"组织的。我封装了下面两个函数:
const SYS_RUN: u16 = 0x0001; const INIT: u16 = 0x0020; const LOAD_IMAGE: u16 = 0x0021; const DPY: u16 = 0x0040; fn spi_cmd(spi: &mut Spidev, cmd: u16, data: &[u8]) -> Result<()> { let mut buf = Vec::with_capacity(4 + data.len()); buf.extend_from_slice(&cmd.to_le_bytes()); buf.push(0x00); // 保留字段,固定为 0 buf.push(data.len() as u8); // 后续数据长度 buf.extend_from_slice(data); spi.write_all(&buf)?; Ok(()) } fn spi_data(spi: &mut Spidev, data: &[u8]) -> Result<()> { spi.write_all(data)?; Ok(()) }这里命令字是两字节小端,后面的保留字节和长度字节一个都不能少。第一次写驱动的时候我图省事只发了命令码没带长度字段,结果 IT8951 完全不响应。后来逻辑分析仪抓了波形才发现,它期望的报文结构比我想的要严格。
3.3 写入帧缓冲并触发显示:第一块颜色上屏
初始化跑通之后,先别急着显示复杂图片,第一步是刷一帧纯白色。这有两个作用:一是验证整条 SPI 通路和帧缓冲写入逻辑是否正确;二是 ACeP 面板在长时间断电后,墨水颗粒可能处于杂乱状态,先做一次全屏白色刷新等于"校准起点"。
显示一帧需要三步:先给 IT8951 设置帧缓冲地址,再写入像素数据,最后发送显示命令。关键点是帧缓冲基地址不要硬编码——不同版本固件出来的值可能不同,正确做法是先读寄存器拿到基地址,再基于它计算偏移。
写入纯白帧的 Rust 逻辑基本长这样:
fn display_white(spi: &mut Spidev) -> Result<()> { let width = 800; let height = 480; // 假设从寄存器读回的帧缓冲基地址是 fb_addr let fb_addr: u32 = read_reg(spi, REG_FB_ADDR)?; // 把像素数据写入帧缓冲 // 白色在 ACeP 调色板里可以用像素值 0xFF 表示 let white_pixels = vec![0xFFu8; (width * height) as usize]; let mut d = Vec::new(); d.extend_from_slice(&fb_addr.to_le_bytes()); d.extend_from_slice(&width.to_le_bytes()); d.extend_from_slice(&height.to_le_bytes()); spi_cmd(spi, LOAD_IMAGE, &d)?; spi_data(spi, &white_pixels)?; // 触发全屏刷新,使用默认波形 LUT let disp_cmd = Vec::new(); spi_cmd(spi, DPY, &disp_cmd)?; wait_busy_low()?; Ok(()) }这里要注意两个容易踩的点。第一,LOAD_IMAGE的参数布局在不同资料里写的不完全一样,我这里展示的是简化协议骨架,实际实现一定要对照你手里 IT8951 数据手册的时序图来组织字节。第二,DPY命令发出之后,必须等 BUSY 从高变低再发下一个命令,否则 IT8951 会把前一个刷新中断掉,屏幕就会留下半截残影。等待函数里加个超时保护,别让它死循环。
当白色刷出来之后,整条链路就算通了。这时候你看到的不算什么好看的画面,但是一个里程碑——SPI 通信、GPIO 控制、帧缓冲写入、波形触发,四个环节全部验证通过。
4. 从彩色图片到 7 色墨水:图像处理和调色板量化
4.1 调色板边界
链路通了,下一步是想显示真实的彩色图片。但马上就会撞上一个墙:普通图片是 24 位真彩色,ACeP 面板只能显示 7 种颜色。这是物理层面的限制,不是任何软件技巧能绕过去的。
IT8951 的帧缓冲存的也不是 RGB565 之类的常规格式,它存的是索引值,配合 IT8951 内置的 LUT 把对应颜色映射到墨水颗粒的驱动电压上。所以在 Rust 侧,图像处理的核心就变成了:把一张 24 位色深的图片,从视觉上尽可能合理地量化到这 7 种颜色里。
这 7 种颜色是黑、白、红、绿、蓝、黄、橙。量化之前先建一个调色板数组:
const PALETTE: [[u8; 3]; 7] = [ [0, 0, 0], // 黑 [255, 255, 255], // 白 [255, 0, 0], // 红 [0, 255, 0], // 绿 [0, 0, 255], // 蓝 [255, 255, 0], // 黄 [255, 165, 0], // 橙 ];最朴素的量化方法是找最近邻:对每个像素的 RGB 值,计算它到 7 个调色板颜色的距离,取最近的那个作为输出。如果图片颜色比较均匀,这么做没问题;但遇到渐变或者照片,直接硬量化就会出现严重的色带和色块,看起来像劣质压缩图。
4.2 用 Floyd–Steinberg 抖动降低色带
解决色带问题的标准方案是误差扩散抖动(error diffusion dithering)。原理很简单:某个像素被量化后,会产生一个量化误差(原始颜色减去实际显示颜色),把这个误差按一定比例散布到它还没有处理的邻居像素上,让"欠的债"在局部被其他像素的偏移补回来。从远处看,这些密集的网点通过混色效果骗过人眼,观感远比硬量化自然。
我采用的是经典的 Floyd–Steinberg 算法,误差分配比例是:右侧像素拿到 7/16,左下像素拿到 3/16,下方像素拿到 5/16,右下像素拿到 1/16。核心循环长这样:
fn dither_to_palette(img: &mut [u8], width: usize, height: usize) { // img 是 RGB 字节序列,R,G,B 交错排列 let mut old = [0i32; 3]; let mut new = [0i32; 3]; let mut err = [0i32; 3]; for y in 0..height { for x in 0..width { let idx = (y * width + x) * 3; let r = img[idx] as i32; let g = img[idx + 1] as i32; let b = img[idx + 2] as i32; old = [r, g, b]; let nearest = nearest_palette_index(&old); new = [ PALETTE[nearest][0] as i32, PALETTE[nearest][1] as i32, PALETTE[nearest][2] as i32, ]; img[idx] = new[0] as u8; img[idx + 1] = new[1] as u8; img[idx + 2] = new[2] as u8; err = [ old[0] - new[0], old[1] - new[1], old[2] - new[2], ]; // 误差扩散给右、左下、正下、右下四个方向的像素 if x + 1 < width { add_err(img, (y, x + 1), &err, 7, 16, width); } if y + 1 < height { if x > 0 { add_err(img, (y + 1, x - 1), &err, 3, 16, width); } add_err(img, (y + 1, x), &err, 5, 16, width); if x + 1 < width { add_err(img, (y + 1, x + 1), &err, 1, 16, width); } } } } }实际显示出来的效果,有点像报纸上印刷的彩色照片,你能隐约看到细小网点,但整个画面的层次和渐变过渡都在。如果你直接看原图再对比屏幕,会嫌它"脏",但在 7 色硬件限制下,抖动方案已经是最优解。
一个小经验:不同主题的图片适合的参数不一样。文字表格类界面用最近邻直量化就够,视觉效果更"干净";风景照片必须开抖动,否则完全没法看。我在代码里留了个参数开关,按应用场景切换,别一刀切。
4.3 局部刷新和界面规划
电子纸的天然弱点是全屏刷新慢,而且每次全屏刷新都会整屏闪一下。在 HMI 场景里,如果屏幕上同时有一个时钟和一个温度计,每秒钟都全屏刷新一次,体验会很糟糕。
IT8951 支持局部刷新:可以只把帧缓冲里某个区域的像素修改掉,然后只刷新那个区域。区域大小建议对齐到面板的刷新单位(常见是 8 或 16 的倍数,具体看寄存器说明),不对齐的话刷新边界附近会出现杂色。
以时钟界面为例,我的做法是:背景一次性全屏刷好,然后数字变化的区域单独刷新,每次只重绘那个小方块。这样整机的刷新频率和功耗都能大幅降下来。另外,ACeP 面板长时间保持同一画面会有残影积累,即使单看某个时刻是正常的,一周下来也会发现屏幕上有淡淡的"鬼影"。解决方式是每天做一次维护性全刷,把整个屏幕重新刷一遍底色,成本极低,但能明显延长"观感寿命"。
5. 踩坑实录:三次让面板"哑火"的 debug 过程
5.1 复位后命令无响应:SPI Mode 和报文结构不对
第一次上板的时候,我遇到了一个很经典的问题:代码跑起来没有任何报错,但屏幕永远是白的,寄存器读出来全是 0。我当时第一反应是 GPIO 控制不对,于是把复位引脚反复拉高拉低,还是没用。
最后把逻辑分析仪并联在 SPI 线上抓波形,对比 IT8951 数据手册里的时序图才找到原因。问题出在 SPI Mode 上:IT8951 期望的是 Mode 0(CPOL=0,CPHA=0),而我一开始用 Linux 下 SPI 的默认配置直接打开设备,跑成了 Mode 0 还好,但后来又为了"测试兼容性"切了一次 Mode 3,结果命令全部没被识别。另一个问题是报文格式——IT8951 的命令包要求先发两字节小端命令码,我一开始按大端发,控制器自然不理人。
排查这一类问题的正确姿势是:先把 Mode、字节序、报文长度字段这三个东西固定下来,再用逻辑分析仪看波形,而不是凭感觉改 GPIO。没有逻辑分析仪的话,也可以把 SPI 速率降到 1MHz 附近,排除时序太快的干扰,一个一个变量去试。
5.2 刷新后花屏条纹:SPI 速率和帧缓冲地址不对齐
解决完初始化之后,屏幕能出白色了,但一旦写入真实图片,屏幕上经常出现横向条纹,有些区域颜色完全错乱。我最初怀疑是图像数据转换错了,在 Rust 里反复检查像素循环,浪费了不少时间。
后来抓波形才发现,是 SPI 速率太高导致偶发数据错误。CM4 的 SPI 控制器跑个几十 MHz 轻轻松松,但 IT8951 和面板之间的走线、电平转换电路不一定能跟上。把速率从 24MHz 降到 8MHz 之后,同样的代码,画面全都正常了。这不是 IT8951 慢,是杂讯干扰问题。
另一个隐蔽的问题是帧缓冲地址。我之前在图省事,直接用网上例程里写死的地址,但那块地址对应的是另一款面板固件。IT8951 的帧缓冲基地址出厂配置可能不同,必须通过寄存器读回来,写数据之前再校验一遍。地址写错了 IT8951 不会报错,只会显示错乱图像,这种"假成功"特别容易误导人。
5.3 连续操作后白屏或残影严重:忽略 BUSY 的后果
第三个坑是最隐蔽的。单独做一次全屏刷新,一切正常;但"全屏刷新一次,马上局部刷新另一块区域"这种连续操作,第二次刷新几乎必出问题——屏幕要么直接白掉,要么出现大面积的残留影像。
排查链路是这样的:我先在日志里打印了每次命令发出的时间戳,发现第二次DPY命令发出的时候,距离上一次 BUSY 释放只隔了不到 10ms。理论上波形刷新还没完全结束,但我已经允许程序继续往下走了。IT8951 的驱动逻辑是:接收新命令后中断当前波形执行,结果就是显示队列被打乱,面板陷入不稳定的中间状态。
修复方案非常简单粗暴:每次DPY命令发送之后,必须在 BUSY 引脚上轮询等待它释放,没有等到之前,禁止发下一个命令。注意,初始化阶段等过 BUSY 不等于后续不需要等,要把等待逻辑封装成一个统一的函数,每个关键步骤后面都调用。
我后来还加了一个超时保护:BUSY 轮询超过 5 秒就报错退出,防止某个异常状态让程序死循环。这个习惯帮我后面省了不少事。
6. 把板子变成真正的终端:交叉编译与扩展方向
6.1 交叉编译到 CM4 的工程化配置
驱动能跑通之后,接下来要考虑的是工程化。CM4 是 ARM64 架构,直接在板子上 rustup 装工具链也能编译,但速度很慢,而且依赖树大了以后内存也不够舒服。更好的做法是在开发机上交叉编译,然后把二进制传过去。
Rust 的交叉编译在这个项目里并不复杂。第一步给开发机安装目标平台支持:
rustup target add aarch64-unknown-linux-gnu第二步安装 ARM64 的链接器:
sudo apt install gcc-aarch64-linux-gnu第三步在项目根目录建一个.cargo/config.toml,告诉 cargo 用什么链接器:
[target.aarch64-unknown-linux-gnu] linker = "aarch64-linux-gnu-gcc"然后就能构建了:
cargo build --release --target aarch64-unknown-linux-gnu生成的二进制在target/aarch64-unknown-linux-gnu/release/下,scp 到板子上直接运行。用strip可以把体积从几 MB 压到几百 KB,对于这种单文件部署来说非常优雅。
上板之后,我希望程序开机自动启动,于是写了一个 systemd service:
[Unit] Description=reTerminal ePaper Terminal After=multi-user.target [Service] ExecStart=/opt/epaper-terminal/epaper-terminal Restart=always User=root [Install] WantedBy=multi-user.target这是工业终端该有的形态:进程守护、崩溃自拉起、开机自启。Python 那一套还要移植解释器环境,Rust 这边一个二进制加一个 service 文件就全搞定了。
6.2 实测表现、局限和后续扩展
跑起来之后的实测情况:全屏刷新大约需要 2 秒出完整图像,局部刷新在几百毫秒到 1 秒之间,和面板温度、刷新区域大小都有关。静态画面下整机功耗优势非常明显,屏幕本身不消耗电流,CM4 在睡眠或者低负载时整机功耗可以压得很低。这些数据放在工业 HMI 场景里是可接受的,前提是你不拿它当 iPad 用。
值得说清楚的是这套方案的短板。第一,别想在上面做动画或者触摸滑动交互,刷新率物理上限就摆在那;第二,7 色调色板对 UI 设计是个约束,图标要尽量用高对比度的简单色块;第三,显示照片只能玩"低多边形/报纸质感"路线,别期待照片级效果。产品选型之前想清楚这几点,后面就不会被用户追着投诉。
后续扩展空间其实是很大的。我现在的方向是把它当成一个边缘状态显示终端:通过 MQTT 接边缘网关的数据,定时从服务端拉取设备状态、产线指标,刷新到屏幕上的仪表盘。Rust 生态里 MQTT、OPC UA 这些工业协议的 crate 都有现成的,和这套显示驱动能很好地共存。如果再套一层 Tauri 做本地配置界面,前后端还能共享 Rust 代码,维护成本非常低。
最后再说一点个人体会。以前我做 HMI 选型,基本默认就是 LCD 加一堆功耗优化手段,电子纸总觉得是"低端玩具"。这次把 Rust 和 ACeP 彩色电子纸搭在一起之后,我才真正意识到低功耗静态显示方案在工业场景的价值:不需要背光、不需要频繁刷新、阳光下照样清晰。而这个组合恰好又赶上 Rust 嵌入式生态开始成熟的时间点,驱动代码写起来比 C 顺手得多。如果你也想给手上的终端设备加一块低功耗屏幕,我建议直接从我这份骨架开始改,先跑通全屏刷新,再慢慢调到适合自己的界面节奏。