Async без зайвого болю
Тут вже буде не просто .await посеред чужого коду. Зробимо окрему прошивку: кнопка A збільшує число на дисплеї, а друга задача щосекунди пише повідомлення в лог. Радіо для цього не треба.
Що підключаємо
Повний проєкт лежить у examples/async-counter/. Конфігурація тулчейна, лінкер і build.rs вже є в цій теці.
[package]
name = "async-counter"
version = "0.1.0"
edition = "2024"
rust-version = "1.88"
publish = false
[dependencies]
lilkaoxide = { path = "../../sdk-hal", default-features = false, features = ["async", "AsyncBus", "alloc", "display_buffer_1024"] }
embedded-graphics = "0.8.2"
heapless = "0.9.1"
esp-alloc = { version = "0.10.0", features = ["defmt"] }
esp-bootloader-esp-idf = { version = "0.5.0", features = ["esp32s3", "defmt"] }
esp-println = { version = "0.16.1", features = ["esp32s3", "defmt-espflash"] }
esp-rtos = { version = "0.3.0", features = ["esp32s3", "embassy", "esp-alloc"] }
static_cell = "2.1.1"
lcd-async = "0.1.3"
embassy-executor = { version = "0.10.0", features = ["defmt"] }
blocking замінюємо на async, а RefCellBus — на AsyncBus. default-features = false тут обов'язковий, інакше SDK додасть свої blocking defaults. Шлях до SDK в маніфесті розрахований на цей репозиторій.
embassy-executor є прямою залежністю: макрос #[embassy_executor::task] генерує звернення до цього крейта. Одного реекспорту через SDK для нього недостатньо. esp-rtos дає точку входу, а не змушує нас самостійно ініціалізувати дисплей чи GPIO.
Повний код
#![no_std]
#![no_main]
use core::fmt::Write;
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use embedded_graphics::{
mono_font::{MonoTextStyle, ascii::FONT_6X10},
pixelcolor::Rgb565,
prelude::*,
text::{Baseline, Text},
};
use esp_println::{self as _, println};
use lcd_async::raw_framebuf::RawFrameBuf;
use lilkaoxide::prelude::*;
use static_cell::StaticCell;
esp_bootloader_esp_idf::esp_app_desc!();
const WIDTH: usize = 280;
const STRIP_HEIGHT: usize = 24;
// 13 440 bytes instead of a full 134 400-byte frame.
static PIXELS: StaticCell<[u8; WIDTH * STRIP_HEIGHT * 2]> = StaticCell::new();
#[panic_handler]
fn panic(info: &core::panic::PanicInfo) -> ! {
println!("PANIC: {}", info);
loop {
core::hint::spin_loop();
}
}
#[embassy_executor::task]
async fn heartbeat() {
loop {
Timer::after(Duration::from_secs(1)).await;
println!("Background task is alive");
}
}
#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
esp_alloc::heap_allocator!(size: 16 * 1024);
let mut config = Configuration::default();
config.buzzer_config.duty_pct = 0;
let lilka = Lilka::init(config).await.expect("Lilka init failed");
spawner.spawn(heartbeat().expect("Heartbeat task pool exhausted"));
let controller = lilka.controller;
let mut display = lilka.display;
let bytes = PIXELS.init_with(|| [0; WIDTH * STRIP_HEIGHT * 2]);
let mut strip = RawFrameBuf::<Rgb565, _>::new(bytes.as_mut_slice(), WIDTH, STRIP_HEIGHT);
for y in (0..240).step_by(STRIP_HEIGHT) {
display
.show_raw_data(
0,
y as u16,
WIDTH as u16,
STRIP_HEIGHT as u16,
strip.as_bytes(),
)
.await
.unwrap();
}
let mut count = 0u32;
let mut was_pressed = false;
let mut redraw = true;
loop {
let pressed = controller.a.is_low();
if pressed && !was_pressed {
count = count.wrapping_add(1);
redraw = true;
}
was_pressed = pressed;
if redraw {
strip.clear(Rgb565::BLACK).unwrap();
let mut text: heapless::String<48> = heapless::String::new();
let _ = write!(text, "Async: A pressed {} times", count);
Text::with_baseline(
&text,
Point::new(8, 6),
MonoTextStyle::new(&FONT_6X10, Rgb565::CYAN),
Baseline::Top,
)
.draw(&mut strip)
.unwrap();
display
.show_raw_data(0, 24, WIDTH as u16, STRIP_HEIGHT as u16, strip.as_bytes())
.await
.unwrap();
redraw = false;
}
Timer::after(Duration::from_millis(30)).await;
}
}
Навіщо тут смуга замість повного кадру
embedded-graphics малює синхронно, а lcd-async передає пікселі асинхронно. З'єднуємо їх через RawFrameBuf: спочатку текст потрапляє у RAM, потім show_raw_data(...).await передає готові байти на дисплей.
Для одного рядка немає сенсу тримати всі 134400 байтів повного кадру. Тут смуга 280×24 займає 13440 байтів. Під час старту нею чиститься весь екран, а далі оновлюється тільки ділянка на y = 24. Координати тексту всередині буфера локальні: його (0, 0) — початок смуги, а не дисплея.
StaticCell::init_with створює цей буфер у статичній пам'яті. Великий масив не потрібно спочатку складати на стек, а потім переносити.
Де тут паралельність
heartbeat() — окрема задача. spawner.spawn(...) запускає її один раз; це не треба робити на кожній ітерації. В Embassy 0.10 помилку вичерпання пулу перевіряємо у heartbeat().expect(...), а сам spawn вже повертає ().
Коли головна задача доходить до Timer::after(...).await, вона віддає виконання executor. Фоновий таймер при цьому може продовжувати роботу. Довгий синхронний цикл без .await такого не робить: async сам по собі не перетворює його на окремий потік.
У прикладі дисплеєм володіє тільки головна задача. Кнопка опитується раз на 30 мс і рахується за переходом у натиснутий стан; це спрощене опитування без повного debounce. Якщо треба передавати дані між задачами, використовуйте Signal або Channel, а не спільний static mut.
Як перевірити
На екрані має з'явитися Async: A pressed 0 times. Після натискання A число змінюється, а в лог щосекунди приходить Background task is alive. Для запуску з Keira створіть application .bin звичайною командою, замінивши назву вихідного файла.
Одна конфігурація SDK за раз
Збирайте цей приклад зі своєї теки. Збірка всього workspace об'єднає features з blocking-пакетів і дасть несумісну конфігурацію. AsyncBus не можна замінити AtomicBus або додати поряд із RefCellBus.