年前最后一更,提前祝大家新年快乐!
之前我们介绍了如何在 Rust 中读取 DHT11 温湿度传感器的数据。这次,我想更进一步,将这些实时数据显示在一块液晶屏幕上。我手头的是一块物美价廉的 1.54 英寸 TFT LCD 屏幕,主控芯片为 ST7789,大概只要十来块钱。

引脚接法
我们需要将屏幕的各个引脚正确连接到 ESP32S3 开发板的对应 GPIO 上。连接关系如下:
| 屏幕引脚 |
连接到 ESP32S3 |
| GND |
GND |
| VCC |
3.3V |
| SCL |
GPIO5 |
| SDA |
GPIO6 |
| RES |
GPIO7 |
| DC |
GPIO15 |
| CS |
GPIO16 |
| BLK |
GPIO8 |
DHT11 传感器的数据线则连接至 GPIO40。
C 的实现
在深入 Rust 代码之前,先用 C 语言实现一遍是很有价值的思路。这样可以帮助我们快速理解 ESP32 官方 SDK 中外设和驱动的标准调用范式。对于 ST7789 屏幕,espressif 官方的 esp_lvgl_port 库提供了很好的支持,可以方便地进行图形和文本的绘制。
不过,你可能会遇到一个常见问题:屏幕显示花屏。这通常是由于 RGB565 颜色格式的字节序不匹配导致的。可以通过发送一个特定的初始化命令来解决:
typedef struct
{
uint8_t cmd;
uint8_t data[15];
uint8_t len;
} lcd_main_t;
lcd_main_t custom_lcd_init_cmds = {
0xB0,
{0x00, 0x18},
2};
esp_lcd_panel_io_tx_color(io_handle, custom_lcd_init_cmds.cmd, custom_lcd_init_cmds.data, custom_lcd_init_cmds.len & 0x7f);
0xB0 是 ST7789 的 “RAM Control” 命令,而 0x18 参数中的一个关键位(Bit 2)用于设置数据接口的传输顺序。这个配置确保了 LVGL 内部的字节序与屏幕控制器期望的顺序一致,从而修正了颜色错乱的问题。
为什么先要尝试用 C/C++ 实现呢?因为在 ESP32 的生态中,C 语言拥有最成熟、最完善的官方文档和支持。通过 C 版本的实践,我们能建立起对硬件驱动流程的直观理解,这在后续使用其他语言(如 Rust)时,能够更准确地定位和解决问题。
Rust 的实现
现在回到 Rust 的世界。为了让 DHT11 的数据读取不阻塞屏幕的刷新,我决定将 DHT11 的读取任务放到第二个 CPU 核心(CPU1)上运行。
DHT11 任务封装与多核调度
首先,对 DHT11 的操作进行简单的封装:
#![no_std]
use embedded_dht_rs::dht11::Dht11;
use esp_hal::{
delay::Delay,
gpio::{DriveMode, Flex, OutputConfig, Pull},
};
/// DHT11 传感器管理器
pub struct Dht11Manager<'a> {
dht11: Dht11<Flex<'a>, Delay>,
}
impl<'a> Dht11Manager<'a> {
/// 创建新的 DHT11 传感器管理器
pub fn new(pin: Flex<'static>, delay: Delay) -> Self {
let mut dht11_pin = pin;
let config = OutputConfig::default()
.with_drive_mode(DriveMode::OpenDrain)
.with_pull(Pull::None);
dht11_pin.apply_output_config(&config);
dht11_pin.set_output_enable(true);
dht11_pin.set_input_enable(true);
dht11_pin.set_high();
let dht11 = Dht11::new(dht11_pin, delay);
Self { dht11 }
}
/// 读取传感器数据
pub fn read(&mut self) -> Result<(u8, u8), DhtError> {
let reading = self.dht11.read().map_err(|_| DhtError)?;
Ok((reading.temperature, reading.humidity))
}
}
/// DHT11 错误
#[derive(Debug)]
pub struct DhtError;
为了使用多核,需要引入 esp-rtos 库:
esp-rtos = { version = “0.2.0”, features = [“esp32s3”] }
接着,定义将在 CPU1 上运行的任务函数。该任务会周期性地读取传感器数据,并将其存入一个全局共享变量中:
fn cpu1_task(delay: &Delay, dht11_pin: Flex<‘static>) -> ! {
let mut dht11 = Dht11Manager::new(dht11_pin, *delay);
esp_alloc::heap_allocator!(#[esp_hal::ram(reclaimed)] size: 73744);
loop {
delay.delay_millis(2000);
match dht11.read() {
Ok((temp, hum)) => {
info!(“DHT11 - Temperature: {} °C, humidity: {} %”, temp, hum);
// 保存数据到共享存储
critical_section::with(|cs| {
*DHT11_DATA.borrow(cs).borrow_mut() = Some((temp, hum));
});
}
Err(_) => {
defmt::dbg!(“Failed to read DHT11 sensor”);
}
}
}
}
最后,在主核心(CPU0)上启动 RTOS 并初始化第二个核心:
let timg0 = TimerGroup::new(peripherals.TIMG0);
let software_interrupt = SoftwareInterruptControl::new(peripherals.SW_INTERRUPT);
esp_rtos::start(timg0.timer0);
let cpu1_task = move || cpu1_task(&delay, dht11_pin);
let stack = unsafe { &mut *addr_of_mut!(APP_CORE_STACK) };
esp_rtos::start_second_core(
peripherals.CPU_CTRL,
software_interrupt.software_interrupt0,
software_interrupt.software_interrupt1,
stack,
cpu1_task,
);
LCD 屏幕驱动
屏幕驱动主要依赖以下几个 crate:
embedded-hal-bus = “0.3.0”
mipidsi = “0.9.0”
embedded-graphics = “0.8.1”
其中 embedded-graphics 是 Rust 嵌入式图形事实上的标准库,而 mipidsi 则提供了 ST7789 芯片的驱动程序。我们使用 SPI 协议与屏幕通信,这里有几个关键概念需要厘清:
| 术语 |
定义 |
| 主机 (Host) |
ESP32 内置的 SPI 控制器外设。用作 SPI 主机,在总线上发起 SPI 传输。 |
| 设备 (Device) |
SPI 从机设备。一条 SPI 总线可与一或多个设备连接。每个设备共享 MOSI、MISO 和 SCLK 信号,但只有当主机向设备的专属 CS 线发出信号时,设备才会在总线上处于激活状态。 |
| 总线 (Bus) |
信号总线,由连接到同一主机的所有设备共用。一般来说,每条总线包括以下线:MISO、MOSI、SCLK、一条或多条 CS 线,以及可选的 QUADWP 和 QUADHD。因此,除每个设备都有单独的 CS 线外,所有设备都连接在相同的线下。多个设备也可以菊花链的方式共享一条 CS 线。 |
| MOSI |
主机输出,从机输入,也写作 D。数据从主机发送至设备。在 Octal/OPI 模式下也表示为 data0 信号。 |
| MISO |
主机输入,从机输出,也写作 Q。数据从设备发送至主机。在 Octal/OPI 模式下也表示为 data1 信号。 |
| SCLK |
串行时钟。由主机产生的振荡信号,使数据位的传输保持同步。 |
| CS |
片选。允许主机选择连接到总线上的单个设备,以便发送或接收数据。 |
基于以上概念,我们的初始化步骤是:先创建一个 SPI 主机实例,然后为其挂载一个专属的 SPI 设备(即我们的屏幕)。
let spi = esp_hal::spi::master::Spi::new(
peripherals.SPI2,
Config::default().with_frequency(Rate::from_mhz(30)),
)
.unwrap()
.with_sck(peripherals.GPIO5)
.with_mosi(peripherals.GPIO6);
let cs = gpio::Output::new(peripherals.GPIO16, Level::High, Default::default());
let spi_device = ExclusiveDevice::new_no_delay(spi, cs).unwrap();
接下来,根据 embedded-graphics 的要求,我们需要设置数据/命令选择引脚(DC)和复位引脚(RST),并最终创建显示(Display)实例。
let dc = gpio::Output::new(peripherals.GPIO15, Level::Low, Default::default());
let mut rst = gpio::Output::new(peripherals.GPIO7, Level::Low, Default::default());
rst.set_high();
// … (假设有一个帧缓冲区 buffer)
let di = SpiInterface::new(spi_device, dc, &mut buffer);
let mut display = Builder::new(ST7789, di)
.reset_pin(rst)
.init(&mut delay)
.unwrap();
一切准备就绪后,就可以在主循环中绘制图形了。为了优化性能(因为 ST7789 的 clear() 操作较慢),我们只在温湿度数据发生变化时才刷新整个屏幕。
let mut last_temp: u8 = 255;
let mut last_hum: u8 = 255;
loop {
delay.delay_millis(2000);
// 使用 get_dht11_data() 获取温度和湿度
let (temp, hum) = get_dht11_data();
if temp != last_temp || hum != last_hum {
display.clear(Rgb565::BLACK).unwrap();
draw_text(&mut display, temp, hum).unwrap();
last_temp = temp;
last_hum = hum;
}
}
效果展示
下图是 C 语言版本(使用 LVGL)的运行效果:

下图是 Rust 版本(使用 embedded-graphics)的运行效果:

遗留问题与后续
细心的你可能会发现,Rust 版本的显示效果图上存在一个小瑕疵(比如文本位置或刷新残留)。这正是嵌入式开发有趣的地方——从“能跑”到“跑得好”之间,还有很多细节需要打磨。如何在 Rust 中更优雅地处理屏幕刷新、解决潜在的视觉问题,将是后续文章探讨的主题。欢迎在技术社区交流你的想法和解决方案。
本教程涉及了从传感器读取、SPI 通信到多任务调度的完整嵌入式开发流程,希望对你有所启发。更多深入的嵌入式及系统编程讨论,可以关注 云栈社区 的相关板块。