Перейти к содержимому

Встраиваемый 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).

Обычная программа на 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 215
stored 218
stored 221
stored 224
buffer full, dropped 227
avg=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 реализует один и тот же трейт. Чуть ниже ты сам напишешь такой драйвер.

В твоём ПК настольный процессор x86 или Arm, а в Pico — Cortex-M0+. Поэтому нужна кросс-компиляция: собираем на одной машине для другой. rustup скачивает для чипа заранее скомпилированную библиотеку core:

Terminal window
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:

Terminal window
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 соберёт, прошьёт и покажет логи.

«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. Потом подключи отладчик и запусти:

Terminal window
cargo run --release

cargo собирает под thumbv6m-none-eabi, probe-rs прошивает чип, светодиод начинает мигать, а сообщения led on! / led off! с метками времени текут в твой терминал.

У микроконтроллера нет экрана, а форматирование текста через core::fmt занимает много флеш-памяти. defmt (на английском; «deferred formatting», отложенное форматирование) решает обе проблемы. Строки формата хранятся на твоём ПК, а не на чипе; чип отправляет через отладчик только крошечный ID и сырые значения, а probe-rs превращает их обратно в читаемый текст.

use defmt::{info, warn, Format};
#[derive(Format)] // как #[derive(Debug)], только для defmt
pub 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 вкл.: 0b00000010000000000000000000001000
LED вкл.? 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.

У многих маленьких чипов, например у 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. Начинай с папки примеров той версии крейта, от которой ты действительно зависишь.

Напиши 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() сам считает единичные биты, так что для этой части цикл не нужен.

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 в любой операционной системе и настроить отладчик.