C остаётся основным языком разработки под микроконтроллеры. Под него есть библиотеки производителей, отладчики, примеры, документация и огромное количество уже написанного кода.

На этом фоне возникает вполне разумный вопрос: что вообще даёт Rust в микроконтроллерах?

Речь здесь не пойдёт о синтаксисе, настройке среды или запуске первого проекта. Вместо этого посмотрим на несколько вещей, которые интересны именно с точки зрения встраиваемой разработки:

  • владение аппаратными ресурсами;

  • работа с DMA;

  • представление состояния периферии в системе типов;

  • совместный доступ из основного кода и обработчиков прерываний;

  • стоимость этих механизмов во время выполнения.

Сразу оговоримся: Rust не является обязательной заменой C. Многие задачи прекрасно решаются на C. Интереснее другое: какие правила, которые в C обычно держатся на архитектуре программы и дисциплине разработчика, можно заставить проверять компилятор?

Rust без операционной системы

Rust можно использовать без стандартной библиотеки:

#![no_std]

Это позволяет писать программы для микроконтроллеров без операционной системы и без обязательного динамического выделения памяти. После компиляции получается обычный машинный код для целевого процессора.

Поэтому Rust в данном случае логичнее сравнивать с C и C++, а не с MicroPython и другими языками, которым требуется интерпретатор или виртуальная машина. Типичная программа для Cortex-M по-прежнему может содержать:

  • таблицу векторов прерываний;

  • обработчики прерываний;

  • непосредственную работу с регистрами;

  • статически размещённые данные;

  • DMA;

  • точный контроль над памятью.

Сам выбор Rust не добавляет обязательного промежуточного слоя между программой и микроконтроллером. Основные различия появляются раньше — во время компиляции.

Где C и так отлично справляется

Для начала полезно посмотреть на задачи, где преимущества Rust почти не проявляются. Например:

АЦП -> цифровой фильтр -> регулятор -> ШИМ

Если программа небольшая, данные размещены статически, а у периферии есть очевидные владельцы, C позволяет получить компактное и вполне понятное решение. То же самое с обычными вычислениями:

for (size_t i = 0; i < N; i++) {
    y[i] = a * x[i] + b * y[i];
}

Аналогичный код на Rust:

for i in 0..N {
    y[i] = a * x[i] + b * y[i];
}

Сам по себе другой язык не делает этот цикл принципиально лучше. В обоих случаях задача компилятора одна: получить эффективный машинный код. Поэтому основное различие стоит искать не в арифметике, а в организации доступа к ресурсам.

DMA: кому сейчас принадлежит буфер?

Рассмотрим обычную передачу данных через DMA. На C можно объявить буфер:

uint16_t adc_buffer[1024];

и передать его DMA:

HAL_ADC_Start_DMA(
    &hadc1,
    (uint32_t *)adc_buffer,
    1024
);

После вызова HAL_ADC_Start_DMA() переменная adc_buffer никуда не исчезает. Код по-прежнему может сделать:

adc_buffer[0] = 123;

в тот момент, когда DMA записывает данные в тот же участок памяти. Разумеется, в нормально написанной программе разработчик знает, что так делать нельзя, и организует доступ соответствующим образом.

Но само правило:

Пока идёт передача, буфер принадлежит DMA.

в C является не свойством языка, а договорённостью внутри программы.

Передаём владение

В Rust интерфейс DMA можно построить иначе:

let transfer = dma.start(buffer);

start() получает буфер во владение. После этого исходную переменную уже нельзя использовать:

let transfer = dma.start(buffer);

buffer[0] = 123;

Такой код компилятор может отвергнуть. После завершения передачи буфер возвращается программе:

let buffer = transfer.wait();

Важно уточнить, что сам по себе вызов dma.start(buffer) не обладает каким-то специальным смыслом для DMA. Здесь работает обычная семантика владения Rust: buffer передаётся в функцию по значению, а его тип предполагается не реализующим Copy, поэтому после вызова исходная переменная больше недоступна. Драйвер может сохранить буфер внутри объекта Transfer и вернуть его только после завершения передачи. При этом компилятор, разумеется, ничего не знает о состоянии аппаратного DMA и не определяет момент окончания передачи сам. Эту связь обеспечивает реализация драйвера: низкоуровневый код проверяет флаг или прерывание периферии и внутри небольшой границы unsafe связывает состояние железа с безопасным API. Идея здесь не в том, что Rust «понимает DMA», а в том, что корректно написанный драйвер может представить аппаратный протокол в виде таких состояний и правил владения, которые компилятор уже способен проверять в пользовательском коде.

Аппаратный факт — «этот участок памяти сейчас используется DMA» — оказывается отражён в структуре программы. Конкретные библиотеки реализуют DMA по-разному, но здесь важен сам принцип: право доступа к памяти можно связать со временем жизни аппаратной операции.

Периферия тоже ресурс

То же самое можно сделать с периферийными блоками. На уровне железа TIM1, ADC1 или USART2 — это в первую очередь наборы регистров, отображённых в адресное пространство.

На C запись в таймер может выглядеть так:

TIM1->CCR1 = value;

Язык при этом ничего не знает о том, что TIM1, например, должен использоваться только драйвером двигателя. Если другой модуль получит доступ к тем же определениям регистров, он тоже сможет туда записать.

Обычно это решается архитектурой программы:

TIM1   → управление двигателем
ADC1   → измерение тока
USART2 → обмен данными

Rust позволяет часть этой схемы выразить через владение объектами. Во многих библиотеках сначала получается набор доступной периферии:

let p = Peripherals::take().unwrap();

После чего отдельные блоки передаются своим владельцам:

let motor = Motor::new(p.TIM1);
let current_sensor = CurrentSensor::new(p.ADC1);
let uart = SerialPort::new(p.USART2);

После перемещения p.TIM1 в Motor тот же объект нельзя передать ещё куда-нибудь:

let motor1 = Motor::new(p.TIM1);
let motor2 = Motor::new(p.TIM1);

В безопасном Rust такой код недопустим. То есть правило:

TIM1 имеет одного владельца.

может поддерживаться уже не только архитектурой проекта, но и компилятором.

На маленькой программе это иногда выглядит как лишнее ограничение. Но когда появляются несколько таймеров, DMA, прерывания и десяток модулей, возможность явно закрепить владение ресурсами становится заметно интереснее.

Состояние периферии как часть типа

Ещё один пример — обычный GPIO. Физически одна ножка может работать в разных режимах:

Вход
Выход
Аналоговый вход
Альтернативная функция

Реальное состояние хранится в регистрах GPIO, но часть этой информации можно дополнительно выразить в типе объекта. Например:

Pin<Input>

и:

Pin<Output>

После переключения режима:

let pin = pin.into_output();

получается уже объект другого типа. Функция, которая устанавливает высокий уровень, может принимать только выход:

fn set_high(pin: &mut Pin<Output>) {
    // запись в регистр
}

А такой код:

let pin: Pin<Input> = /* ... */;

set_high(&mut pin);

не скомпилируется.

На C аналогичное правило обычно существует в виде правильной последовательности действий:

GPIO_Init(...);

/* Теперь вывод настроен как выход */

HAL_GPIO_WritePin(...);

То есть часть информации, которая в C живёт в состоянии регистров, комментариях и голове разработчика, можно перенести в тип переменной.

Когда тип описывает состояние устройства

Тот же приём можно применять уже не только к периферии, но и к состояниям устройства. Допустим, привод имеет четыре состояния:

Выключен
Инициализация
Работа
Авария

Один из вариантов на C — перечисление и несколько дополнительных признаков:

enum State {
    STATE_OFF,
    STATE_STARTING,
    STATE_RUNNING,
    STATE_FAULT
};

bool pwm_enabled;
bool adc_ready;
bool calibrated;

Такая структура данных сама по себе допускает состояние:

state       = STATE_RUNNING
pwm_enabled = false
adc_ready   = false
calibrated  = false

В корректной программе оно, возможно, никогда не появится, но представить его можно. Другой вариант — связать нужные ресурсы непосредственно с состоянием:

enum Drive {
    Off,

    Starting {
        calibration: Calibration,
    },

    Running {
        pwm: Pwm,
        adc: CurrentSensor,
    },

    Fault {
        code: FaultCode,
    },
}

Теперь состояние Running само содержит ресурсы, необходимые для работы. Отдельные признаки вроде:

pwm_enabled
adc_ready

могут просто не понадобиться.

Это не делает алгоритм управления автоматически правильным: компилятор не знает, правильно ли рассчитаны коэффициенты регулятора и допустим ли ток двигателя. Но часть бессмысленных комбинаций состояния можно исключить ещё до запуска программы.

Прерывания и общие данные

Одна из классических задач в микроконтроллере — обмен данными между основным кодом и обработчиком прерывания. Простейший вариант на C:

volatile uint32_t value;
volatile bool ready;

Обработчик меняет значения:

void ADC_IRQHandler(void)
{
    value = ADC1->DR;
    ready = true;
}

А основной код их читает:

if (ready) {
    process(value);
    ready = false;
}

volatile в таких программах часто нужен, чтобы компилятор действительно выполнял необходимые обращения к памяти, но сам по себе volatile не решает синхронизацию. По мере роста программы появляются вопросы:

  • атомарна ли операция;

  • может ли обработчик сработать между двумя обращениями;

  • кто вообще имеет право менять объект;

  • нужно ли временно запрещать прерывания;

  • использует ли ту же память DMA.

В безопасном Rust нельзя просто раздать изменяемый доступ к одному объекту нескольким независимым участкам программы. Нужно явно определить механизм совместного доступа: атомарную операцию, критический участок, блокировку, передачу сообщений или другую подходящую схему.

Rust не выбирает механизм синхронизации за разработчика. Он требует явно показать, почему этот совместный доступ считается корректным.

А как же unsafe?

При прямой работе с аппаратурой полностью избежать операций, корректность которых компилятор проверить не способен, невозможно. Например:

  • обращение по известному адресу регистра;

  • работа с необработанным указателем;

  • часть низкоуровневых операций DMA;

  • начальная настройка памяти.

Для этого в Rust существует unsafe. Например:

unsafe {
    core::ptr::write_volatile(address, value);
}

Важно, что unsafe не отключает проверки языка для всей программы. Он означает, что корректность конкретной операции разработчик подтверждает сам.

Именно поэтому имеет смысл ограничивать область применения unsafe.

Вместо того чтобы размазывать работу с указателями и регистрами по всей программе, потенциально опасную часть можно сосредоточить внутри драйвера, а остальной код оставить проверяемым.

Сколько всё это стоит?

После всех этих типов возникает очевидный для микроконтроллерщика вопрос: сколько дополнительной памяти и процессорного времени они требуют? Не обязательно хоть сколько-нибудь.

Возьмём упрощённый пример:

struct Input;
struct Output;

struct Pin<State> {
    register: *mut u32,
    _state: PhantomData<State>,
}

Input и Output здесь нужны только системе типов и не содержат данных. PhantomData<State> тоже не требует хранить отдельное поле состояния во время выполнения.

Поэтому различие между:

Pin<Input>

и:

Pin<Output>

может существовать только для компилятора. В памяти объект по-прежнему содержит только то, что действительно необходимо для доступа к железу.

Проверка:

можно ли вызвать set_high() для этого объекта?

происходит при компиляции. На самом микроконтроллере проверять это уже не нужно.

А что получится в машинном коде?

В микроконтроллерах это, пожалуй, важнее количества конструкций в исходном тексте. Например:

fn set_high(pin: &mut Pin<Output>) {
    unsafe {
        core::ptr::write_volatile(pin.bsrr, 1 << pin.number);
    }
}

Если после подстановки функций и оптимизации всё это превращается в обычную запись по адресу регистра, более строгий интерфейс сам по себе не создаёт затрат во время выполнения. Поэтому сравнивать имеет смысл не только исходный код, но и результат компиляции.

Rust не обещает, что абсолютно любая абстракция всегда будет бесплатной. Но владение, времена жизни и многие признаки типов существуют главным образом для компилятора и не требуют отдельной работы во время исполнения программы.

Чего Rust всё-таки не знает

Из всего сказанного легко сделать слишком сильный вывод: будто Rust превращает прошивку в формально проверенную систему. Нет.

Компилятор не обнаружит:

неверную частоту ШИМ;
ошибку в коэффициентах регулятора;
неправильную последовательность включения силовой части;
ошибку протокола;
выход физической величины за допустимый диапазон.

Если написать:

let duty = 100;

а железо допускает максимум 80, язык ничего об этом не узнает, пока ограничение явно не появится в программе.

Rust прежде всего проверяет свойства самого кода:

  • правила владения;

  • время жизни ссылок;

  • допустимость операций над типами;

  • часть ошибок совместного доступа к памяти.

Модель физического устройства, алгоритмы и ограничения железа по-прежнему остаются задачей разработчика.

Где за это приходится платить

Порог входа

Чтобы нормально пользоваться всеми перечисленными механизмами, придётся разобраться хотя бы с:

  • владением;

  • заимствованием;

  • временем жизни ссылок;

  • обобщёнными типами;

  • особенностями no_std.

Код, который на C выглядит очевидно, иногда приходится заметно перестраивать под требования компилятора. Особенно в начале это легко воспринимать как борьбу с языком.

Производители всё ещё живут в мире C

Для большинства микроконтроллеров производитель в первую очередь предоставляет:

  • библиотеки на C;

  • примеры на C;

  • средства генерации начального кода;

  • промежуточное программное обеспечение на C;

  • документацию с примерами на C.

При использовании Rust приходится либо брать библиотеки сообщества, либо работать ближе к регистрам, либо связывать Rust с существующим кодом на C. У популярных семейств ситуация заметно лучше, чем у редких контроллеров и специализированной периферии.

Иногда всё это просто не нужно

Если прошивка выглядит примерно так:

while (1) {
    read_adc();
    update_controller();
    set_pwm();
}

и на этом практически всё заканчивается, строгая модель владения может дать меньше пользы, чем дополнительной сложности.

Но картина меняется с ростом системы. Чем больше в ней:

  • периферийных блоков;

  • DMA;

  • обработчиков прерываний;

  • состояний;

  • каналов обмена;

  • совместно используемых данных,

тем интереснее становится возможность заставить компилятор следить за частью архитектурных правил.

Итог

Главное отличие Rust в микроконтроллерах не в том, что он каким-то другим способом складывает числа или записывает значения в регистры. Разница в том, где проверяются правила программы.

На C правило:

Во время передачи буфер принадлежит DMA.

обычно поддерживает разработчик. На Rust его можно выразить через владение.

Правило:

TIM1 используется только драйвером двигателя.

можно выразить передачей периферийного блока одному владельцу.

А правило:

set_high() допустим только для выхода.

— типом Pin<Output>.

На C все эти схемы тоже прекрасно реализуются. Просто значительная часть ограничений существует в архитектуре проекта, документации и голове программиста. Rust позволяет часть этих ограничений превратить в то, что проверяет компилятор.

На мой взгляд, именно в этом и находится его главный интерес для встраиваемых систем.

Материалы

При подготовке статьи использовались:

  1. The Embedded Rust Book — разделы Static Guarantees и Zero Cost Abstractions.

  2. Jiří Klimeš — Coming Back to Embedded with Rust.

  3. SiliconWit — UART, DMA, and Ownership.

  4. Документация проекта Rust Embedded.

Комментарии (55)


  1. c0t0d0
    29.09.2026 07:32

    «[язык ]C позволяет легко выстрелить себе в ногу; с C++ это сделать сложнее, но, когда вы это делаете, вы отстреливаете себе ногу целиком» .. а rust, получается, отстрелит все тело :)


    1. yahooyaks
      29.09.2026 07:32

      С таким выстрелом можно запрыгнуть на пятиэтажку!


  1. nerudo
    29.09.2026 07:32

    Дочитал до места "let transfer = dma.start(buffer);" и ничего не понял: что именно в этой конструкции обеспечивает дальнейшую недоступность buffer. А если бы я хотел, чтобы он оставался доступным - тогда как я должен написать?


    1. misha_erementchouk
      29.09.2026 07:32

      По растовым (явным) соглашениям такая запись означает, что владение buffer полностью отдано функции dma.start. Если такая передача не предполагается, то запись должна быть dma.start(&buffer), и у функции должна быть соответствующая сигнатура со всеми вытекающими.


      1. nerudo
        29.09.2026 07:32

        То есть вызов функции max(a, b) "сожрет" обе переменных? Или дело именно в сигнатуре функции? Тогда этого явно не хватает в популярном тексте для привыкших лабать на си.


        1. v_0ver
          29.09.2026 07:32

          По умолчанию да. Но если тип переменных a и b реализует трейт Copy (а он реализован для базовых, небольших типов), то будет применена не семантика перемещения, а копирования, и переменные не будут “съедены”.


          1. nerudo
            29.09.2026 07:32

            Для массива (тех же i32), как я понимаю, такого уже не будет?


            1. v_0ver
              29.09.2026 07:32

              Если под массивами в Rust подразумевать [i32, 1000], то они тоже Copy. В Rust массивы это дженерик тип [T; N] у которого есть “условная” реализация Copy:

              impl<T, const N: usize> Copy for [T; N]
              where
                  T: Copy,
              {}
              

              Которая говорит, что если тип T - Copy то и весь массив Copy


              1. nerudo
                29.09.2026 07:32

                Ну тогда еще больше вопросов к автору: что же такое buffer и что такое .start()


                1. Fenex
                  29.09.2026 07:32

                  В статье на C указывают код, аналог которого реализуют:

                  HAL_ADC_Start_DMA(
                      &hadc1,
                      (uint32_t *)adc_buffer,
                      1024
                  );
                  

                  buffer, очевидно, предполагается с move-семантикой (то есть оно не реализует трэйт Copy) так как мы хотим получить этот буффер во владение, а при вызове wait() - отдать владение обратно вызывающему.


                  1. LinkToOS
                    29.09.2026 07:32

                    buffer, очевидно, предполагается с move-семантикой (то есть оно не реализует трэйт Copy)

                    Автор этого явно не обозначил. Тему он не раскрыл, и нюансы не высветил. (И теперь комментаторы дописывают статью за автора, как это часто бывает. Отобрать у автора плюсы, и отдать комментаторам.)


                    1. Gadd
                      29.09.2026 07:32

                      Боюсь, что автору пришлось бы пересказать половину Rust Book в статье. Rust на столько на слуху уже, что удивительно, что до вас не дошли слухи по поводу его особенностей )))


                      1. LinkToOS
                        29.09.2026 07:32

                        Применительно к dma.start(buffer) достаточно пару строк дописать, чтобы снять вопросы.
                        Те до кого “дошли слухи”, знают больше чем автор. Им статья не нужна.


                  1. LinkToOS
                    29.09.2026 07:32

                    В статье на C указывают код, аналог которого реализуют HAL_ADC_Start_DMA( &hadc1, (uint32_t *)adc_buffer, 1024);

                    Это не аналог. В HAL_ADC_Start_DMA передается указатель на массив. Это соответствует dma.start(&buffer). Но автор говорит что передавать указатель небезопасно, так как это не блокирует доступ к массиву со стороны других процессов. Вместо этого он предлагает использовать dma.start(buffer), чтобы обезопасить буфер. И здесь якобы проявляется преимущество Rust.

                    Дальше идет обоснование, с оговорками - При этом компилятор, разумеется, ничего не знает о состоянии аппаратного DMA и не определяет момент окончания передачи сам. Эту связь обеспечивает реализация драйвера: низкоуровневый код проверяет флаг или прерывание периферии и внутри небольшой границы unsafe связывает состояние железа с безопасным API. То есть к dma.start(buffer) нужно приложить еще алгоритм обеспечение безопасности. (Немного похоже на кашу из топора)

                    А смысл подхода в этом - Идея здесь не в том, что Rust «понимает DMA», а в том, что корректно написанный драйвер может представить аппаратный протокол в виде таких состояний и правил владения, которые компилятор уже способен проверять в пользовательском коде.


          1. LinkToOS
            29.09.2026 07:32

            По умолчанию да. Но если тип переменных a и b реализует трейт Copy (а он реализован для базовых, небольших типов)

            Так что будет применено по умолчанию для dma.start(buffer), если этот buffer для “базовых, небольших типов”? Copy или Del?


            1. mayorovp
              29.09.2026 07:32

              Что драйвер принимает - то и будет построено.


        1. PopovPS
          29.09.2026 07:32

          Очень много и странно написали тут. Но если коротко то в Rust требуется явно прописывать если переменная передается по ссылке. При этом действуют ссылки мутабельные или не мутабельные.. по сути тебе не нужно помнить скрытые соглашения. Ты сразу по сигнатуре можешь понять как данные передаются внутрь функции (в отличие от какого ни будь где тебе нужно помнить какие данные будут скопированы а какие пойдут по ссылке).


          1. Fenex
            29.09.2026 07:32

            В примерах статьи передача идёт не по ссылке, а по значению (передача владения) - это и обсуждаем. При передаче по значению используется move-семантика по-умолчанию. Либо полное копирование объекта если тип реализует Copy.


    1. Siemargl
      29.09.2026 07:32

      Дочитал до места "let transfer = dma.start(buffer);" и ничего не понял: что именно в этой конструкции обеспечивает дальнейшую недоступность buffer

      а ничего.

      обработчик аппаратного прерывания ничего об этом знать не будет. где-то уже обсуждалось реальными разработчиками устр-в


      1. nerudo
        29.09.2026 07:32

        Не, про уровень аппаратуры все понятно, об этом речь не идет. Тут бы на уровне языка разобраться.


        1. Siemargl
          29.09.2026 07:32

          принцип единственности владения, на каждый объект может быть или единственный владеющий указатель, или несколько только на чтение

          даже схема - один владеющий и один на чтение - уже запрещено


    1. MountainGoat
      29.09.2026 07:32

      При таком вызове buffer посылается в функцию без права переписки, и по окончанию функции будет сразу деаллоцирован. Не когда-нибудь потом, а сразу.

      Чтобы оставить его в своём контексте, нужно подать в функцию ссылку: dma.start(&buffer) Но тогда уже функция должна явно принимать ссылку, а не сам объект. И уже функция не может эту ссылку куда-то сохранить и т.д. Короче, если по логике требуется сделать копию buffer, то где-то придётся явно её сделать.

      Ну или использовать трейт Copy.


  1. v_0ver
    29.09.2026 07:32

    Из всего сказанного легко сделать слишком сильный вывод: будто Rust превращает прошивку в формально проверенную систему. Нет.
    Компилятор не обнаружит:

    • неверную частоту ШИМ;

    • ошибку в коэффициентах регулятора;

    • неправильную последовательность включения силовой части;

    • ошибку протокола;

    • выход физической величины за допустимый диапазон.

    Вопрос дилетанта: а если вендор предоставит типизированное API это же решит эти проблемы? Если да, то по чему он этого не делает?


    1. nerudo
      29.09.2026 07:32

      Есть замечательная микросхема, например, ADRV9025, где производитель так и делает - есть набор функций (на си, конечно), а набор регистров просто скрыт (не совсем, но почти). Когда начинаешь в этом разбираться - через некоторое время хронически дергается глаз и вспоминаешь про старые добрые времена, когда был набор регистров и описание необходимых процедур инициализации-переключения и т.п. писалось прямо в документации на чип.


      1. LinkToOS
        29.09.2026 07:32

        Есть такая практика, когда вместо подробной документации на железо производитель предлагает tools для разработки. Мотив неясен.


      1. NutsUnderline
        29.09.2026 07:32

        аналогично esp32. умельцы даже нашли такие же ip-блоки (wifi, bt) в других микросхемах, но уже с побитовой документацией на них


  1. aao_1965
    29.09.2026 07:32

    спасибо, было интересно читать статью.
    а что изменится, если пишем сложную многопоточную программу, в этом случае как будут работать эти механизмы "владения"?
    и в С можно же ограничить видимость переменной (того же буфера), написав слово static, это не будет эквивалентом владения в rust?


    1. vvoznesensky
      29.09.2026 07:32

      Если не привлекать дополнительные сущности, то многопоточная программа не сможет обмениваться ресурсами между потоками внутри себя. Если привлекать сущности, ориентированные на многопоточное программирование, то владение будет передаваться между потоками.


    1. Fenex
      29.09.2026 07:32

      в этом случае как будут работать эти механизмы “владения”?

      Точно так же, как и в однопотоке. Правила не меняются. Любой ресурс (переменная) имеет в один момент времени:

      • единственного владельца

      • либо N читателей, либо 1 писатель

      Для одновременного доступа к переменным из разных потоков используются мьютексы, которые в рантайме проверяют правило “exactly one writer XOR many readers”.

      и в С можно же ограничить видимость переменной (того же буфера), написав слово static, это не будет эквивалентом владения в rust?

      Нет, не будет. Код в пределах видимости этой переменной может по-прежнему делать всякое. Классический пример - попытка добавить в массив (allocable vector \ C# List) нового элемента во время итерации по этому массиву. C# выбросит исключение в рантайме при такой попытке. В C\C++ такое скомпилируется, но это будет UB. Rust откажется такой код компилировать в принципе.

      let mut array = vec![1, 2, 3, 4];
      for item in &array {
          println!("item: {item}");
          if *item == 4 {
              array.push(5); // forbidden
          }
      }
      


      1. aao_1965
        29.09.2026 07:32

        а ктото из эмбеддеров такое пишет в здравом уме?


        1. MountainGoat
          29.09.2026 07:32

          А кто ж тогда пишет UB в здравом уме?


        1. kipar
          29.09.2026 07:32

          ну если заменить push на swap то будет некий алгоритм сортировки и нет, array.swap rust тоже скомпилировать не даст.


          1. Newpson
            29.09.2026 07:32

            @Fenex привёл пример с использованием итераторов, где берётся const-ссылка на array, что запрещает дальнейшее взятие mut-ссылки на array при вызове push. Если, однако, использовать обычные индексы

            for i in 0..array.len() {
                println!("item: {}", array[i]);
                if array[i] == 4 {
                    array.push(5); // now it's OK
                }
            }
            println!("item: {array:?}");
            

            то array.len() вызовется один раз для формирования range, не создавая const-ссылку. И в массив действительно добавится новый элемент, но цикл будет до 4.

            item: 1
            item: 2
            item: 3
            item: 4
            item: [1, 2, 3, 4, 5]
            

            Можно перейти к циклу while, чтобы включить ещё и добавленный элемент в вывод:

            let mut j = 0;
            while j < array.len() {
                println!("item: {}", array[j]);
                if array[j] == 5 {
                    array.push(6);
                }
                j += 1;
            }
            


      1. Sap_ru
        29.09.2026 07:32

        В С++ это версии зависит. В новых стандартах, компилятор, вроде как, может warning дать, но только если это будут очевидно заметно.


        1. Fenex
          29.09.2026 07:32

          Тем не менее, между правилами языка, обязательными к соблюдению, и линтерами, делающими анализ по поиску потенциально опасных участков кода, всё же есть существенная разница.


      1. yahooyaks
        29.09.2026 07:32

        Чтобы небыло УБ, надо итератор раскрыть и обходить вектор по индексу.


  1. ilnarildarovuch_ruria
    29.09.2026 07:32

    >> Rust для микроконтроллеров: что он даёт по сравнению с С

    Боль


    1. trinxery
      29.09.2026 07:32

      Какой полезный комментарий, пример качественного исследования и поддержания уровня дискуссии. /s


    1. Siemargl
      29.09.2026 07:32

      >> Rust для микроконтроллеров: что он даёт по сравнению с С

      Боль

      Это неточно. Он переносит боли при геморрое при отладке на головную боль при разработке.

      Неясно, меняется ли при этом сумма =)


  1. VO_Obsidian
    29.09.2026 07:32

    Я не гуру раста, но вроде же все проверки идут на этапе компиляции. С DMA возникает логичный вопрос - а как компилятор узнает когда передача завершится?

    Плюс большинство сценариев подразумевают циклическую работу DMA, когда DMA не заканчивает передачу никогда, просто есть два прерывания, на заполнение первой половины буфера и второй. И тут данные надо или скопировать куда-то для медленной обработки или успеть обработать на месте. Насколько быстро идут процессы, компилятор также не имеет никакого понятия.


    1. Sap_ru
      29.09.2026 07:32

      Так же, как в C/C++ - никак. Более того, компилятор даже не узнает, что данные в буфере обновились - это нужно либо в C/C++ volatile использовать (дико не эффективно), либо компиляторо-зависимый read/write barrier использовать. А если ещё и про аппаратный кэш процессора вспомнить, который может быть в наличии даже на простых архитектурах... В rust будет ровно та же проблема и те же грабли с оптимизацией, сбросом буферов записи, кэшем, владением и прочим. Совершенно везде будет небезопасный код и ручное управление владением объектами.


    1. kipar
      29.09.2026 07:32

      Компилятор никак не узнает, раст дает возможность человеку пищущему на нем запретить использовать буфер. Как он этим воспользуется и воспользуется ли зависит от человека, но на Си такой запрет сделать не получится (если мы где-то получили буфер на запись то никто не помешает сохранить указатель на него и потом записать по указателю).

      И тут данные надо или скопировать куда-то для медленной обработки или успеть обработать на месте.

      Это когда DMA на прием. А тут скорее речь про DMA на отправку.


  1. kpmy
    29.09.2026 07:32

    Ну, в примерах всё довольно лаконично, а вот в реальности как-то так всё получается.

    Скрытый текст
    #![no_std]
    #![no_main]
    
    use arduino_hal::hal::i2c;
    use arduino_hal::simple_pwm::*;
    use panic_halt as _; // Required traits for PWM
                         //use ufmt::{uwriteln};
    use core::cell::RefCell;
    use core::cmp::{max, min};
    use embedded_hal_bus::i2c::RefCellDevice;
    
    use mpu6050_driver::{raw_to_imu_sample, AccelRange, Address, Dlpf, GyroRange, Mpu6050};
    use oled_i2c::{Oled, OledConfig, FONT_5X7};
    
    const W: u16 = 128;
    const H: u16 = 32;
    const MAX_LOOP_COUNT: u32 = 1000; //millis
    
    #[arduino_hal::entry]
    fn main() -> ! {
        let dp = arduino_hal::Peripherals::take().unwrap();
        let pins = arduino_hal::pins!(dp);
        //let mut serial = arduino_hal::default_serial!(dp, pins, 115200);
        let timer1 = arduino_hal::simple_pwm::Timer1Pwm::new(dp.TC1, Prescaler::Prescale64);
    
        let mut led0 = pins.led_rx.into_output();
        led0.set_low();
        let mut led1 = pins.led_tx.into_output();
        led1.set_low();
    
        let mut adc = arduino_hal::Adc::new(dp.ADC, Default::default());
        let a1 = pins.a1.into_analog_input(&mut adc); //throttle input
                                                      //let a0 = pins.a0.into_analog_input (&mut adc); //regen input
        let mut pwm_pin = pins.d10.into_output().into_pwm(&timer1); //throttle output
        pwm_pin.enable();
        let safe_pin = pins.d4.into_pull_up_input(); //safeguard pin
    
        let mut i2c = arduino_hal::I2c::new(
            dp.TWI,
            pins.d2.into_pull_up_input(), // Pin 2 = SDA
            pins.d3.into_pull_up_input(), // Pin 3 = SCL
            400_000,                      // Clock speed config (50kHz - 400kHz supported)
        );
    
        let i2c_bus = RefCell::new(i2c);
        let i2c1 = RefCellDevice::new(&i2c_bus);
        let i2c2 = RefCellDevice::new(&i2c_bus);
    
        let mut mpu = Mpu6050::new(i2c1, Address::Ad0Low);
    
        // Wake up and configure the sensor
        if mpu.wake().is_ok() {
            let _ = mpu.set_accel_range(AccelRange::G2);
            let _ = mpu.set_gyro_range(GyroRange::Dps250);
        }
    
        let mut display = Oled::new(
            i2c2, // any embedded_hal::i2c::I2c
            0x3C, // 7-bit I2C address
            OledConfig::ssd1306_128x32().with_contrast(150),
        )
        .expect("OLED init failed");
    
        loop {...}
    }

    После MicroPython тяжеловато, а уж фокусы с i2c на пустом месте. Впрочем, тут скорее вопросы к авторам библиотек и их решениям.


    1. kpmy
      29.09.2026 07:32

      Ещё вот трудно передавать значения в функции, которые как бы и borrowed и не borrowed одновременно. Получается замкнутый круг, если сделать mut переменную, то её не передать, а если сделать let без mut, то уже не изменить в коде, например, цикле отрисовки кадра, приходится городить огород ради принципа, с непонятными последствиями для производительности.


      1. kpmy
        29.09.2026 07:32

        И ещё такой момент с разными типами данных, тут вопрос опять к авторам библиотек, но, математика превращается в набор кастов или в лишние переменные.


      1. MountainGoat
        29.09.2026 07:32

        Прямо сейчас идёт работа над тем, чтобы компилятор более гранулярно определял область владения переменными. А пока это не введут, нужно пользоваться lifetimes.


        1. Sap_ru
          29.09.2026 07:32

          Для embedded это мало поможет, так как там лютая и махровая многозадачность с совершенно несвязанными задачами и массивами данных. Один и тот набор данных в разным местах когда представлен через разные типы и с точки зрения компилятора ещё никак не связан по коду (если речь про обработчки прерываний, вызовы ядра и т.п.).
          А сверху этого ещё и железо работает (через DMА и отображение устройств в память). В этих случаях с точки зрения компилятора вообще нет и быть не может никакой сущности, которая данными владеет и меняет их (так как эта сущность вне программы, системы и кода).
          Короче, изначально порочная для embedded идея.


  1. jaha33
    29.09.2026 07:32

    На CNX вкладывали перевод статьи, где сравнивали размеры прошивок более менее живого проекта на C и Rust. Получилось весьма занимательно. Особенно то, что Rust потребовалось заметно меньше ОЗУ

    Скрытый текст


    С Rust еще незнаком, но почему то думал, что там уже есть встроенная многопоточность, а в статье выше зачем то использовали некую Ariel OS


    1. d_nine
      29.09.2026 07:32

      Так кто-то должен управлять потоками и обеспечивать примитивы синхронизации потоков, а это задача ОС, а не языка (с оговоркой, что на выходе получаем сразу готовый набор инструкций в результате компиляции). ОСРВ обеспечивают управление потоками с помощью планировщика завязанного на systick в ARM например, т.е. буквально отдельное прерывание управляющее переключением контекста. Хоть технически те же обработчики прерываний можно считать "потоками", но их обслуживание и управление ресурсами остаётся за разработчиком независимо от языка. Вероятно это не так для микропитона, т.к. насколько понимаю он крутит код условно внутри своей виртуальной машины (поправьте, если не прав), которая уже внутри себя действительно может в многопоточность.


    1. aao_1965
      29.09.2026 07:32

      тоже поначалу решил, что многопоточность уже на уровне языка rust реализована.
      а если это не так, то возможные преимущества rust над С как бы и испаряются.

      сравнение потребления ОЗУ в статье некорректно (о чем там и сказано, собственно) из-за абсолютных разных релизаций либы сериализации, которая на С требует динамического выделения памяти (поди еще и с рекурсией :)))) )


      1. stxdtm
        29.09.2026 07:32

        А что значит “многопоточность на уровне языка реализована”? И там и там это будет pthread, но раст из коробки даёт тебе инструменты (sync, send), которые на этапе компиляции проверят, безопасно ли передавать значения между потоками.

        И в целом это история даже не про потоки, это часть системы типов - компилятору-то все равно, потоки ОС там, прерывания или вообще ничего нет, он тупо проверяет, “а если вдруг придется это сделать - это безопасно?”. И если нет - просто запрещает…

        И как по мне - это как раз и есть то самое важное преимущество раста над с.


        1. aao_1965
          29.09.2026 07:32

          ну в java например


          1. UFO_01
            29.09.2026 07:32

            В java “многопоточность на уровне языка реализована” потому что есть jvm которая обеспечивает выполнение спецификации для работы с потоками, являясь прослойкой над ОС, и спроектирована специально для java (то самое happens-before). А ОС просто предоставляет API для работы с многопоточностью, которое языки используют. В том числе и jvm.


            1. yahooyaks
              29.09.2026 07:32

              Можно еще задачи писать неблокирующими и вызывать их по таймеру циклически. Посмотреть сколько микросекунд занимает самый долгий проход и настроить таймеры.


    1. UFO_01
      29.09.2026 07:32

      Rust потребовалось заметно меньше ОЗУ

      Реализация на C использует parson который требует json файла целиком и использует динамическую память, поэтому там потребление кучи 24кб. Реализация на rust использует sedre который может json парсить кусками и не использует динамическую память. На C тоже такая либа есть, lwjson, странно что её не использовали.