Встраиваемый Rust: микроконтроллеры, no_std и Embassy
Встраиваемый Rust (embedded Rust) — это код на Rust для маленьких компьютеров, которые живут внутри вещей: термостата, клавиатуры, дрона, датчика полива растений. Такие компьютеры называются микроконтроллерами. На одном чипе — процессор, несколько сотен килобайт памяти и выводы (пины), которые общаются со светодиодами, кнопками и датчиками. Операционной системы обычно нет вообще. Твоя программа — единственное, что работает, от включения питания до выключения.
Звучит страшновато, но это ещё и очень весело: пишешь несколько строк — и на твоём столе мигает настоящая лампочка. На этой странице ты узнаешь, что возможно, какую плату и какие крейты выбрать и как загрузить первую программу на чип. По дороге ты потренируешь «встраиваемый способ мышления» на примерах, которые можно запустить прямо в браузере.
Почему Rust так хорошо подходит для встраиваемых систем
Заголовок раздела «Почему Rust так хорошо подходит для встраиваемых систем»Десятилетиями встраиваемый код писали в основном на C. Rust приносит несколько вещей, которые очень важны на крошечном чипе:
- Нет сборщика мусора. Ничто не останавливает программу, чтобы прибрать память, поэтому время работы остаётся предсказуемым. Это важно, когда на сигнал нужно реагировать за микросекунды.
- Безопасность памяти без среды выполнения. Правила владения и заимствования проверяются при компиляции, поэтому на чипе ничего не стоят. Целые классы падений (висячие указатели, гонки данных между прерыванием и главным циклом) ловятся ещё до прошивки.
- Абстракции с нулевой стоимостью. HAL позволяет писать
led.set_high(), а не тыкать в адреса памяти, и компилируется в ту же одну запись в регистр, которую ты написал бы вручную. - Владение моделирует железо. Пин — это значение. Как только он перемещён в
Output, никто другой не сможет по ошибке взять его и использовать как вход. Компилятор следит за тем, что раньше было просто комментарием в файле на C. - Отличные инструменты.
cargo, целиrustupиprobe-rsзаменяют многие IDE от производителей и make-файлы.
Честно о шероховатостях: производители чипов по-прежнему выпускают SDK и примеры сначала на C, поэтому для некоторых чипов поддержка Rust скромнее или держится только на сообществе. Читать даташиты всё равно придётся. А некоторые крейты ещё не дошли до 1.0 и меняют API между выпусками, так что всегда следуй примерам для своей версии.
Что можно построить
Заголовок раздела «Что можно построить»- Датчики и гаджеты: метеостанция, сигнализация о сухой почве, шагомер.
- USB-устройства: собственные клавиатуры, игровые контроллеры, MIDI-контроллеры.
- Подключённые (IoT) устройства: датчики с Wi-Fi или Bluetooth на ESP32, которые отправляют показания на сервер.
- Роботы и моторы: роботы, едущие по линии, управление шаговыми двигателями и сервоприводами, светодиодные ленты и дисплеи.
- Серьёзные системы: Oxide Computer целиком на Rust написала Hubris (на английском) — небольшую операционную систему для микроконтроллеров. Espressif, компания, которая делает ESP32, сама поддерживает Rust и выпускает The Rust on ESP Book (на английском).
Платы, с которых легко начать
Заголовок раздела «Платы, с которых легко начать»Любая из них — хорошая первая плата. Если не можешь решить, бери Raspberry Pi Pico: она дешёвая, отлично документирована, и пример на Embassy ниже написан именно для неё.
| Плата | Чип | Цель Rust | Чем хороша | С чего начать |
|---|---|---|---|---|
| Raspberry Pi Pico / Pico W | RP2040 (Arm Cortex-M0+) | thumbv6m-none-eabi |
Дешёвая, популярная, отличная поддержка Embassy | embassy-rp или rp2040-hal + rp-pico |
| Raspberry Pi Pico 2 | RP2350 (Arm Cortex-M33 или RISC-V) | thumbv8m.main-none-eabihf |
Новее и быстрее, есть блок вычислений с плавающей точкой | embassy-rp (feature rp235xa), rp235x-hal |
| Платы на ESP32-C3 / ESP32-C6 | Чипы Espressif на RISC-V | riscv32imc-unknown-none-elf / riscv32imac-unknown-none-elf |
Встроенные Wi-Fi и Bluetooth, официальная поддержка Rust | esp-hal и инструмент esp-generate |
| STM32 Nucleo, «Black Pill» | STM32 (например, STM32F4) | thumbv7em-none-eabihf |
Огромное семейство чипов, популярное в промышленности; на платах Nucleo есть встроенный отладчик | embassy-stm32, stm32f4xx-hal |
| BBC micro:bit v2 | nRF52833 (Arm Cortex-M4F) | thumbv7em-none-eabihf |
Отладчик, светодиоды, кнопки и датчики прямо на плате | microbit-v2 плюс книга Discovery |
micro:bit — это плата из официальной книги Embedded Rust Discovery (на английском), бесплатного пошагового курса для начинающих. Оригинальный ESP32 и ESP32-S3 используют ядра Xtensa вместо RISC-V; для них инструмент Espressif espup ставит особый тулчейн Rust. Светодиод на Pico W подключён к чипу Wi-Fi, а не к обычному пину, поэтому ему нужна другая «мигалка» (у Embassy для неё есть пример wifi_blinky).
Что такое no_std и no_main
Заголовок раздела «Что такое no_std и no_main»Обычная программа на Rust использует стандартную библиотеку (std). std рассчитывает на операционную систему: файлы, потоки, кучу, терминал для println!. У микроконтроллера ничего этого нет. Поэтому встраиваемые программы начинаются с двух строк:
#![no_std] // не подключать стандартную библиотеку, только `core`#![no_main] // не использовать обычную точку входа `fn main()`#![no_std]значит, что тебе доступен толькоcore(на английском): целые числа,Option,Result, итераторы, срезы, трейты, форматирование. НикакихVec,String,Box, файлов и потоков. (С аллокатором можно вернутьVecи его друзей через крейтalloc, но многим проектам это так и не понадобится.)#![no_main]нужен потому, что на чипе нет операционной системы, которая вызвала быmain. Крейт среды выполнения вродеcortex-m-rtнастраивает память после сброса и переходит к твоей функции, которую ты помечаешь атрибутом вроде#[cortex_m_rt::entry]или, с Embassy,#[embassy_executor::main].- Ещё нужно сказать, что происходит при панике, ведь печатать некуда. Это дают крейты вроде
panic-probeилиpanic-halt; подключается такой крейт строкойuse panic_probe as _;.
Раз Vec нет, как хранить список? Крейт heapless (на английском) даёт Vec, String и очереди с фиксированной ёмкостью, выбранной при компиляции. Они живут на стеке или в static, так что нет ни кучи, ни внезапной нехватки памяти. Полный буфер просто возвращает тебе значение обратно:
use core::fmt::Write;use heapless::{String, Vec};
fn main() { // Место ровно для 4 показаний. Память — часть самой переменной: // ни кучи, ни аллокатора, а размер известен при компиляции. let mut readings: Vec<i32, 4> = Vec::new();
for value in [215, 218, 221, 224, 227] { match readings.push(value) { Ok(()) => println!("stored {value}"), Err(rejected) => println!("buffer full, dropped {rejected}"), } }
// String с фиксированной ёмкостью в 16 байт let mut line: String<16> = String::new(); write!(line, "avg={}", readings.iter().sum::<i32>() / readings.len() as i32).unwrap(); println!("{line} (capacity {})", line.capacity());}heapless работает и на ПК, так что это можно попробовать через cargo add [email protected] и cargo run (в Playground его нет):
stored 215stored 218stored 221stored 224buffer full, dropped 227avg=219 (capacity 16)Четыре значения сохранились, а пятое (227) не поместилось — буфер полон, и push вернул его обратно.
Экосистема по слоям
Заголовок раздела «Экосистема по слоям»Крейты встраиваемого Rust складываются друг на друга. Работать ты будешь в основном на уровне HAL или фреймворка, но полезно знать, что лежит внизу:
| Слой | Что это | Примеры |
|---|---|---|
| PAC (Peripheral Access Crate) | Генерируется из файла с описанием регистров чипа. По одной более-менее безопасной функции на каждый регистр и поле. Очень низкий уровень. | rp2040-pac, stm32f4, esp32c3 |
| HAL (Hardware Abstraction Layer, слой абстракции оборудования) | Удобные типы для одного семейства чипов: Output, Uart, I2c, таймеры. Построен поверх PAC. |
rp2040-hal, esp-hal, stm32f4xx-hal, embassy-rp, embassy-stm32 |
Трейты embedded-hal |
Общие трейты вроде OutputPin, I2c, SpiDevice и DelayNs. HAL их реализуют, драйверы ими пользуются. Версия 1.0 стабильна. |
embedded-hal, embedded-hal-async |
| BSP (Board Support Package, пакет поддержки платы) | HAL плюс имена для пинов и деталей конкретной платы («светодиод», «кнопка A»). | rp-pico, microbit-v2 |
| RTIC | Фреймворк для приложений на прерываниях. Задачи привязаны к прерываниям и приоритетам, а общие данные компилятор проверяет при сборке. | rtic |
| Embassy | Асинхронный фреймворк: исполнитель (executor), таймеры, сеть и USB, плюс собственные HAL. Ты пишешь задачи как async fn и ждёшь событий железа через .await. |
embassy-executor, embassy-time, embassy-rp |
Вся магия — в средней строке. Драйвер датчика температуры, написанный для трейта I2c из embedded-hal, без изменений работает на Pico, ESP32 и STM32, потому что каждый HAL реализует один и тот же трейт. Чуть ниже ты сам напишешь такой драйвер.
Устанавливаем цель и probe-rs
Заголовок раздела «Устанавливаем цель и probe-rs»В твоём ПК настольный процессор x86 или Arm, а в Pico — Cortex-M0+. Поэтому нужна кросс-компиляция: собираем на одной машине для другой. rustup скачивает для чипа заранее скомпилированную библиотеку core:
rustup target add thumbv6m-none-eabi # RP2040 (Pico)rustup target add thumbv8m.main-none-eabihf # RP2350 (Pico 2)rustup target add thumbv7em-none-eabihf # micro:bit v2, STM32F4Чтобы записать программу на чип и видеть её логи, нужны probe-rs (на английском) и отладчик (debug probe) — маленькое USB-устройство, которое подключается к отладочным выводам чипа. На платах micro:bit и STM32 Nucleo он встроенный. Для Pico возьми Raspberry Pi Debug Probe или вторую Pico, прошитую бесплатной прошивкой debugprobe. Установи probe-rs скриптом-установщиком (для Windows см. страницу установки, на английском) или через cargo:
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh | sh# или, медленнее, из исходников:cargo install probe-rs-tools --locked
probe-rs list # показывает отладчики, которые он видитОтладчика пока нет? Зажми на Pico кнопку BOOTSEL и подключи плату — она появится как USB-флешка. Инструменты вроде picotool или elf2uf2-rs умеют так записывать на неё программу. Правда, логов defmt без отладчика ты не увидишь.
Для ESP32-C3 или C6 отладчик обычно вообще не нужен: в чип встроен USB, и инструмент espflash прошивает его по обычному USB-кабелю. Выполни cargo install esp-generate espflash --locked, потом esp-generate, выбери свой чип и параметры — и cargo run --release соберёт, прошьёт и покажет логи.
Мигаем светодиодом с Embassy на Raspberry Pi Pico
Заголовок раздела «Мигаем светодиодом с Embassy на Raspberry Pi Pico»«Blinky» (мигалка) — это «Hello, world!» встраиваемого мира: включить светодиод, подождать, выключить, подождать, и так вечно. Это официальный пример blinky для RP2040 из Embassy (на английском), с добавленными комментариями. Ему нужна настоящая плата, в Playground он не запустится.
#![no_std]#![no_main]
use defmt::*;use defmt_rtt as _; // отправлять логи defmt в отладчикuse embassy_executor::Spawner;use embassy_rp::gpio;use embassy_time::Timer;use gpio::{Level, Output};use panic_probe as _; // при панике: вывести сообщение через отладчик и остановиться
#[embassy_executor::main(executor = "embassy_rp::executor::Executor", entry = "cortex_m_rt::entry")]async fn main(_spawner: Spawner) { // Забираем во владение всю периферию чипа, ровно один раз let p = embassy_rp::init(Default::default()); // Светодиод Pico — на пине 25. PIN_25 перемещается в `led`. let mut led = Output::new(p.PIN_25, Level::Low);
loop { info!("led on!"); led.set_high(); Timer::after_millis(500).await;
info!("led off!"); led.set_low(); Timer::after_millis(500).await; }}На что обратить внимание:
main— этоasync fn.Timer::after_millis(500).awaitне крутится в пустом цикле: исполнитель усыпляет чип, пока не сработает таймер. Если задач больше (их запускают черезSpawner), остальные работают, пока эта ждёт. Если async/await тебе в новинку: идея та же, что с Tokio на ПК, только без операционной системы.p.PIN_25перемещается вOutput::new. Попробуй использоватьp.PIN_25второй раз — и компилятор тебя остановит: владение, применённое к железу.use defmt_rtt as _;иuse panic_probe as _;импортируют крейты только ради того, что они настраивают (канал логов и обработчик паники), а не ради каких-то имён.
Быстрее всего увидеть мигание прямо в папке с официальными примерами: склонируй репозиторий Embassy и в examples/rp выполни cargo run --bin blinky --release. Для своего проекта зависимости берутся из Cargo.toml этого примера:
[package]name = "blinky"version = "0.1.0"edition = "2024"
[dependencies]embassy-executor = { version = "0.10.0", features = ["defmt"] }embassy-time = { version = "0.5.1", features = ["defmt", "defmt-timestamp-uptime"] }embassy-rp = { version = "0.10.0", features = ["defmt", "unstable-pac", "time-driver", "critical-section-impl", "rp2040", "executor-thread"] }defmt = "1.0.1"defmt-rtt = "1.0.0"panic-probe = { version = "1.0.0", features = ["print-defmt"] }cortex-m = { version = "0.7.6", features = ["inline-asm"] }cortex-m-rt = "0.7.0"
[profile.release]debug = true # оставляем отладочную информацию для probe-rs; на чип она не записываетсяЕщё два файла говорят cargo, что собирать и как запускать. .cargo/config.toml задаёт цель и заставляет cargo run прошивать чип через probe-rs:
[target.'cfg(all(target_arch = "arm", target_os = "none"))']runner = "probe-rs run --chip RP2040"
[build]target = "thumbv6m-none-eabi"
[env]DEFMT_LOG = "debug"Наконец, скопируй memory.x (где на чипе флеш-память и RAM) и build.rs (он передаёт скрипты компоновщика) из той же папки examples/rp. Потом подключи отладчик и запусти:
cargo run --releasecargo собирает под thumbv6m-none-eabi, probe-rs прошивает чип, светодиод начинает мигать, а сообщения led on! / led off! с метками времени текут в твой терминал.
Логи с defmt
Заголовок раздела «Логи с defmt»У микроконтроллера нет экрана, а форматирование текста через core::fmt занимает много флеш-памяти. defmt (на английском; «deferred formatting», отложенное форматирование) решает обе проблемы. Строки формата хранятся на твоём ПК, а не на чипе; чип отправляет через отладчик только крошечный ID и сырые значения, а probe-rs превращает их обратно в читаемый текст.
use defmt::{info, warn, Format};
#[derive(Format)] // как #[derive(Debug)], только для defmtpub struct Reading { pub sensor_id: u8, pub milli_celsius: i32,}
pub fn report(r: &Reading) { info!("new reading: {}", r); if r.milli_celsius > 60_000 { // `=u8` и `=i32` сообщают defmt точный тип, и это экономит ещё больше места warn!("sensor {=u8} is too hot: {=i32} mC", r.sensor_id, r.milli_celsius); }}Уровни — trace!, debug!, info!, warn! и error!, как в крейте log. Настройка DEFMT_LOG в .cargo/config.toml выбирает, какие уровни вообще попадут в прошивку; всё, что ниже, занимает ноль байт.
Мыслим регистрами: биты и маски
Заголовок раздела «Мыслим регистрами: биты и маски»Под каждым HAL управление железом сводится к записи чисел в регистры — особые адреса памяти, где каждый бит что-то включает на чипе. На типичном чипе в 32-битном регистре вывода GPIO по одному биту на пин. Здесь роль такого регистра играет обычный u32, так что можно потренировать четыре приёма, которыми пользуется каждый разработчик встраиваемых систем:
const LED: u32 = 25; // встроенный светодиод Pico — на пине 25
fn main() { // Представим, что этот u32 — регистр вывода GPIO нашего чипа. // Бит 0 управляет пином 0, бит 1 — пином 1, и так далее. let mut gpio_out: u32 = 0b1000; // пин 3 уже включён
// Установить один бит: OR с маской gpio_out |= 1 << LED; println!("LED вкл.: {gpio_out:#034b}");
// Прочитать один бит: AND с маской и сравнение с нулём let led_is_on = (gpio_out & (1 << LED)) != 0; let pin2_is_on = (gpio_out & (1 << 2)) != 0; println!("LED вкл.? {led_is_on}, пин 2 вкл.? {pin2_is_on}");
// Переключить один бит: XOR с маской gpio_out ^= 1 << LED; println!("переключили: {gpio_out:#034b}");
// Сбросить один бит: AND с инвертированной маской gpio_out |= 1 << LED; gpio_out &= !(1 << 3); println!("пин 3 выкл.: {gpio_out:#034b}"); println!("в hex: {gpio_out:#010x}");}Вывод:
LED вкл.: 0b00000010000000000000000000001000LED вкл.? true, пин 2 вкл.? falseпереключили: 0b00000000000000000000000000001000пин 3 выкл.: 0b00000010000000000000000000000000в hex: 0x02000000В Rust ! для целого числа переворачивает каждый бит (в C для этого используют ~). {:#034b} печатает значение в двоичном виде с префиксом 0b, дополняя его до 32 цифр, так что видно, как бит 25 появляется и исчезает. Полный список побитовых операторов — в уроке Операторы.
Многие регистры упаковывают в одно число несколько полей: пару битов для настройки скорости, один бит для «включено». Чтобы изменить одно поле, делают чтение-изменение-запись (read-modify-write): сбрасывают биты поля, а потом через OR вписывают новое значение, не трогая остальные биты:
// Выдуманный регистр управления UART:// бит 0 = включение// биты 4..=6 = настройка скорости (3-битное число, от 0 до 7)const ENABLE: u32 = 1 << 0;const SPEED_SHIFT: u32 = 4;const SPEED_MASK: u32 = 0b111 << SPEED_SHIFT;
fn read_speed(reg: u32) -> u32 { (reg & SPEED_MASK) >> SPEED_SHIFT}
fn write_speed(reg: u32, speed: u32) -> u32 { // Сбрасываем старое поле и вписываем новое значение. // Все остальные биты остаются в точности как были. (reg & !SPEED_MASK) | ((speed << SPEED_SHIFT) & SPEED_MASK)}
fn main() { let mut ctrl: u32 = ENABLE | (2 << SPEED_SHIFT); println!("старт: {ctrl:#010b}, скорость = {}", read_speed(ctrl));
ctrl = write_speed(ctrl, 5); println!("скорость 5: {ctrl:#010b}, скорость = {}", read_speed(ctrl));
// 9 не помещается в 3 бита. Маска обрезает его, а не даёт // залезть в соседние биты. ctrl = write_speed(ctrl, 9); println!("скорость 9?: {ctrl:#010b}, скорость = {}", read_speed(ctrl));
let still_enabled = (ctrl & ENABLE) != 0; println!("всё ещё включён: {still_enabled}");}Вывод:
старт: 0b00100001, скорость = 2скорость 5: 0b01010001, скорость = 5скорость 9?: 0b00010001, скорость = 1всё ещё включён: trueЗаметь: 9 молча превратилось в 1. Именно от таких ошибок защищают PAC и HAL: у каждого поля там свой именованный метод (часто и с именованными значениями), так что маски вручную пишешь редко. На настоящем железе регистр — это фиксированный адрес памяти, и пишут в него через core::ptr::write_volatile внутри кода unsafe; эту часть за тебя делает PAC.
Драйвер, который можно тестировать на ПК
Заголовок раздела «Драйвер, который можно тестировать на ПК»Лучшая привычка во встраиваемой разработке: пиши логику для трейтов, а не для одного конкретного чипа. Тогда один и тот же код работает на плате с настоящим HAL и на ПК с поддельными («mock») деталями в обычном тесте. Здесь StatusLed нужно только «что-то, что можно включать и выключать», и «что-то, что умеет ждать»:
use std::cell::RefCell;use std::rc::Rc;
// Две способности, которые нужны драйверу мигалки. Они повторяют трейты// OutputPin и DelayNs из embedded-hal 1.0, упрощённые, чтобы работать в Playground.trait OutputPin { fn set_high(&mut self); fn set_low(&mut self);}
trait Delay { fn delay_ms(&mut self, ms: u32);}
// Драйвер ничего не знает ни о каком чипе. Он пользуется только трейтами.struct StatusLed<P: OutputPin, D: Delay> { pin: P, delay: D,}
impl<P: OutputPin, D: Delay> StatusLed<P, D> { // Мигнуть `times` раз: включить на `on_ms`, потом выключить на `off_ms`. fn blink(&mut self, times: u32, on_ms: u32, off_ms: u32) { for _ in 0..times { self.pin.set_high(); self.delay.delay_ms(on_ms); self.pin.set_low(); self.delay.delay_ms(off_ms); } }}
// --- Подделки для тестов на ПК ---// Обе подделки пишут в один общий журнал, так что виден порядок событий.type Log = Rc<RefCell<Vec<String>>>;
struct MockPin { log: Log,}
impl OutputPin for MockPin { fn set_high(&mut self) { self.log.borrow_mut().push("вкл".to_string()); } fn set_low(&mut self) { self.log.borrow_mut().push("выкл".to_string()); }}
struct MockDelay { log: Log,}
impl Delay for MockDelay { fn delay_ms(&mut self, ms: u32) { self.log.borrow_mut().push(format!("ждать {ms}")); }}
fn main() { let log: Log = Rc::new(RefCell::new(Vec::new())); let pin = MockPin { log: Rc::clone(&log) }; let delay = MockDelay { log: Rc::clone(&log) };
let mut led = StatusLed { pin, delay }; led.blink(2, 100, 400);
let events = log.borrow(); println!("{}", events.join(", "));
assert_eq!(events.len(), 8); assert_eq!(events[0], "вкл"); assert_eq!(events[1], "ждать 100"); println!("все проверки пройдены");}Вывод:
вкл, ждать 100, выкл, ждать 400, вкл, ждать 100, выкл, ждать 400все проверки пройденыДве подделки делят один журнал через Rc<RefCell<...>> (см. Умные указатели), поэтому можно проверить точный порядок событий. В настоящем проекте строки с assert_eq! переезжают в функцию с #[test] и запускаются через cargo test на твоём ПК, без всякой платы (см. Тестирование и Тестирование: следующий шаг).
На плате ты написал бы тот же драйвер для настоящих трейтов embedded-hal 1.0. Отличия только такие: методы пина возвращают Result (пин на чипе-расширителе ввода-вывода может дать сбой), поэтому ошибки передаются дальше через ?, а std коду вообще не нужен:
#![no_std]
use embedded_hal::delay::DelayNs;use embedded_hal::digital::OutputPin;
pub struct StatusLed<P, D> { pin: P, delay: D,}
impl<P: OutputPin, D: DelayNs> StatusLed<P, D> { pub fn new(pin: P, delay: D) -> Self { StatusLed { pin, delay } }
pub fn blink(&mut self, times: u32, on_ms: u32, off_ms: u32) -> Result<(), P::Error> { for _ in 0..times { self.pin.set_high()?; self.delay.delay_ms(on_ms); self.pin.set_low()?; self.delay.delay_ms(off_ms); } Ok(()) }}Output из Embassy реализует OutputPin, а embassy_time::Delay реализует DelayNs, так что StatusLed::new(led, embassy_time::Delay) работает на Pico. Для тестов на ПК с этими настоящими трейтами крейт embedded-hal-mock (на английском) даёт готовые поддельные пины и шины I2C и SPI.
Математика датчиков без float
Заголовок раздела «Математика датчиков без float»У многих маленьких чипов, например у Cortex-M0+ в RP2040, нет блока вычислений с плавающей точкой. Числа с плавающей точкой всё равно работают, но программно: медленнее, и программа от них растёт. Поэтому встраиваемый код часто использует числа с фиксированной точкой (fixed-point) — целые числа в более мелких единицах. Вместо 25.25 градуса хранят 25250 миллиградусов.
Датчик температуры в стиле TMP102 присылает два байта. Старшие 12 битов — это число со знаком в шагах по 1/16 °C:
// Старшие 12 битов — температура в шагах по 1/16 °C (0,0625 °C),// в виде числа со знаком (дополнительный код).fn to_millicelsius(bytes: [u8; 2]) -> i32 { // i16 сохраняет знак. `>> 4` для числа со знаком тоже его сохраняет. let raw = i16::from_be_bytes(bytes) >> 4; // 1/16 °C = 62,5 м°C, так что умножаем на 625 и делим на 10. raw as i32 * 625 / 10}
// Печатаем 25250 как «25,250», ни разу не прикоснувшись к float.fn format_milli(m: i32) -> String { let sign = if m < 0 { "-" } else { "" }; let m = m.abs(); format!("{sign}{},{:03}", m / 1000, m % 1000)}
fn main() { let samples = [ [0x19, 0x40], // тёплая комната [0x00, 0x10], // наименьший шаг выше нуля [0xFF, 0x80], // чуть ниже нуля [0xE7, 0x00], // морозилка ];
for bytes in samples { let m = to_millicelsius(bytes); println!("{bytes:02X?} -> {m:>6} м°C = {} °C", format_milli(m)); }}Вывод:
[19, 40] -> 25250 м°C = 25,250 °C[00, 10] -> 62 м°C = 0,062 °C[FF, 80] -> -500 м°C = -0,500 °C[E7, 00] -> -25000 м°C = -25,000 °CЭто работает благодаря трём деталям. i16::from_be_bytes читает два байта как одно число со знаком, старший байт первым («big-endian») — именно так данные присылает большинство датчиков. >> 4 для целого со знаком сохраняет знак, поэтому отрицательные температуры получаются правильными. А format_milli обрабатывает знак отдельно: в целочисленной арифметике -500 / 1000 — это 0, так что без этого шага -0,5 °C напечаталось бы как 0,500. Дробную часть мы отделяем запятой, как принято в русском тексте.
Целочисленное деление ещё и отбрасывает дробную часть: 62,5 м°C превратились в 62. Для комнатного датчика полмиллиградуса не важны, но всегда спрашивай себя, какую точность даёт выбранная единица. И следи за диапазоном: если умножить большое показание на большой множитель, i32 может переполниться (см. Переполнение целых чисел). На ПК String — это нормально; на чипе ты печатал бы через defmt или писал бы в heapless::String.
Частые ошибки
Заголовок раздела «Частые ошибки»Использовать что-то из std в программе с no_std. println!, Vec и String приходят из std, так что их там просто нет:
error: cannot find macro `println` in this scope --> src/main.rs:4:5 |4 | println!("hello"); | ^^^^^^^Компилятор не находит макрос println в этой области видимости. Для логов используй defmt::info!, а для коллекций — heapless (или крейт alloc с аллокатором).
Забыть обработчик паники. Бинарник с no_std обязан сказать, что делает паника. Без крейта вроде panic-probe ты получишь:
error: `#[panic_handler]` function required, but not foundКомпилятор требует функцию #[panic_handler] и не находит её. Добавь panic-probe (или panic-halt) в Cargo.toml и use panic_probe as _; в main.rs.
Перезаписать регистр целиком. reg = 1 << 3 включает пин 3 — и выключает все остальные. Чтобы изменить один бит, пиши reg |= 1 << 3. Для полей используй чтение-изменение-запись, как выше.
Следовать старому руководству. Крейты для встраиваемых систем развиваются быстро, и статьи нескольколетней давности часто используют embedded-hal 0.2 или старые API Embassy. Начинай с папки примеров той версии крейта, от которой ты действительно зависишь.
Практика
Заголовок раздела «Практика»1. Какие пины включены?
Заголовок раздела «1. Какие пины включены?»Напиши toggle(reg: u32, pin: u32) -> u32, которая переключает один бит, и pins_on(reg: u32) -> Vec<u32>, которая перечисляет все пины, чей бит равен 1. Начни с включённых пинов 0, 5 и 25, напечатай список и сколько пинов включено, потом переключи пины 5 и 7 и напечатай снова. Подсказка: проверь каждый пин от 0 до 31 маской, а ещё у u32 есть метод count_ones().
Показать решение
fn toggle(reg: u32, pin: u32) -> u32 { reg ^ (1 << pin)}
fn pins_on(reg: u32) -> Vec<u32> { (0..32).filter(|pin| reg & (1 << pin) != 0).collect()}
fn main() { let mut reg: u32 = (1 << 0) | (1 << 5) | (1 << 25); println!("вкл.: {:?} (пинов: {})", pins_on(reg), reg.count_ones());
reg = toggle(reg, 5); reg = toggle(reg, 7); println!("вкл.: {:?} (пинов: {})", pins_on(reg), reg.count_ones());}Вывод:
вкл.: [0, 5, 25] (пинов: 3)вкл.: [0, 7, 25] (пинов: 3)XOR переворачивает бит, каким бы ни было его прежнее значение, поэтому переключение пина 5 его выключило, а переключение пина 7 — включило. count_ones() сам считает единичные биты, так что для этой части цикл не нужен.
2. Заряд батареи без float
Заголовок раздела «2. Заряд батареи без float»12-битный АЦП выдаёт показания от 0 до 4095 для напряжения от 0 до 3300 мВ. Батарея подключена через делитель напряжения, который делит её напряжение пополам, так что настоящее напряжение вдвое больше измеренного. Напиши battery_mv(raw: u16) -> i32 и battery_percent(mv: i32) -> i32, где 3000 мВ — это 0%, а 4200 мВ — 100%, никогда не меньше 0 и не больше 100. Попробуй сырые показания 2606, 2482, 2234 и 1861. Подсказка: сначала умножай, потом дели, чтобы не терять точность, и посмотри на clamp.
Показать решение
const EMPTY_MV: i32 = 3000;const FULL_MV: i32 = 4200;
fn battery_mv(raw: u16) -> i32 { // 12-битный АЦП (0..=4095) измеряет 0..=3300 мВ за делителем, который делит напряжение пополам raw as i32 * 3300 * 2 / 4095}
fn battery_percent(mv: i32) -> i32 { let percent = (mv - EMPTY_MV) * 100 / (FULL_MV - EMPTY_MV); percent.clamp(0, 100)}
fn main() { for raw in [2606, 2482, 2234, 1861] { let mv = battery_mv(raw); println!("сырое {raw} -> {mv} мВ -> {}%", battery_percent(mv)); }}Вывод:
сырое 2606 -> 4200 мВ -> 100%сырое 2482 -> 4000 мВ -> 83%сырое 2234 -> 3600 мВ -> 50%сырое 1861 -> 2999 мВ -> 0%Деление на 4095 в самом конце сохраняет точность: raw / 4095 * 6600 давало бы 0 для любого показания меньше 4095. Самое большое произведение, 4095 * 6600, — около 27 миллионов, и оно легко помещается в i32.
- Встраиваемый Rust работает на микроконтроллерах без операционной системы. Отсутствие сборщика мусора, безопасность на этапе компиляции и абстракции с нулевой стоимостью делают Rust отличным выбором.
#![no_std]оставляет тебе толькоcore;#![no_main]позволяет крейту среды выполнения вызвать твою точку входа; обработчик паники нужно выбрать самому.heaplessдаёт коллекции с фиксированной ёмкостью.- Слои: PAC (регистры), HAL (одно семейство чипов), трейты
embedded-hal(общие), BSP (одна плата) и фреймворки вроде RTIC и Embassy сверху. - Добавь цель для чипа через
rustup target add, а потом прошивай и смотри логи через probe-rs и отладчик (илиespflashдля ESP32).defmtделает логи крошечными. - Тренируйся на ПК: битовые маски для регистров, драйверы для трейтов с поддельными деталями и целые числа с фиксированной точкой вместо float.
Проверь себя
Заголовок раздела «Проверь себя»Проверь себя
1. Что делает #![no_std]?
2. Какой слой позволяет одному драйверу датчика без изменений работать на Pico, ESP32 и STM32?
3. Как включить бит 3 в reg, не меняя остальные биты?
4. Почему встраиваемый код часто хранит 25,25 °C как 25250 миллиградусов?
Результат: 0 / 4
Что почитать
Заголовок раздела «Что почитать»- The Embedded Rust Book (на английском): официальное введение в
no_std, периферию, регистры и экосистему встраиваемого Rust. - Discovery (micro:bit v2) (на английском): бесплатный курс для начинающих — шаг за шагом от мигающего светодиода до датчиков на BBC micro:bit.
- The Embassy Book (на английском): как начать работу с Embassy, как устроены его исполнитель и HAL и как запускать официальные примеры.
- The Rust on ESP Book (на английском): собственное руководство Espressif по
esp-hal,esp-generate,espflashи тулчейнам для ESP32. - Установка probe-rs (на английском): как установить probe-rs в любой операционной системе и настроить отладчик.