Встраиваемые системы и робототехника Питание и ограничения: режимы сна, бюджет энергии, память и флеш
0%

Питание и ограничения: режимы сна, бюджет энергии, память и флеш

Питание и ограничения: режимы сна, бюджет энергии, память и флеш

На «большой» машине ресурсы — это то, что кончается медленно и о чём сообщает мониторинг. Память кончилась — OOM killer, диск кончился — алерт, процессор загружен — добавили под. Все три ресурса эластичны: их можно докупить, и почти всегда дешевле докупить, чем оптимизировать. Во встраиваемой системе ресурсы не эластичны и заданы на этапе, когда паяльник уже остыл. 64 КБ ОЗУ — это навсегда 64 КБ. 2200 мА·ч в батарейном отсеке — это навсегда 2200 мА·ч, и если прошивка тратит на 30 % больше расчётного, устройство не «немного замедлится», а умрёт на восемь месяцев раньше — у клиента, в поле, в количестве десяти тысяч штук. Флеш выдержит 10 000 стираний страницы, и если вы сохраняете настройки раз в 15 минут прямой перезаписью, устройство гарантированно ломается через три с половиной месяца — одинаково у всех, в один и тот же день эксплуатации. Это статья про три бюджета: энергии, оперативной памяти и ресурса флеша. Все три считаются заранее, на бумаге, и все три проверяются измерением, а не логами. Аппаратная база берётся из статьи про железо для программиста, работа с памятью на уровне языка — из C для встраиваемых систем, а механика прерываний и таймеров, без которой не бывает пробуждений, — из прерываний и реального времени.

Смена системы координат: энергия — это ресурс, а не свойство

Первый сдвиг мышления: энергия тратится не «процессором», а работой конкретных блоков в конкретные промежутки времени. Профилировать надо не функции, а фазы. Классический бэкендерский рефлекс «оптимизируем горячий цикл» здесь чаще всего бесполезен: в типичном датчике 77 % заряда уходит на 185 миллисекунд работы радиопередатчика, и любое ускорение вычислений экономит доли процента.

Второй сдвиг: среднее важнее пикового, но пиковое убивает. Срок жизни определяется средним током, а работоспособность — пиковым: CR2032 с внутренним сопротивлением 15 Ом при импульсе 30 мА просядет на 0,45 В, микроконтроллер уйдёт в brownout-reset посреди передачи, и устройство будет бесконечно перезагружаться при «полной» батарейке. Третий сдвиг: то, что вы измеряете, вы меняете. Подключённый отладчик держит живым домен отладки и превращает 2 мкА в 2 мА, а один printf на 40 байт по UART 115200 — это 3,5 мс, в течение которых устройство не спит. Отладка энергопотребления печатью невозможна принципиально, отсюда отдельный раздел про осциллограф и логический анализатор.

Немного физики, без которой цифры не сходятся

Ёмкость батареи указывают в миллиампер-часах — это заряд, а не энергия. Заряд удобен, пока напряжение примерно постоянно; при сравнении химий переходите к энергии: $E = C \cdot V_{\text{ср}}$, мВт·ч. Средний ток за цикл длиной $T$ из фаз с током $I_i$ и длительностью $t_i$, и срок жизни при полезной ёмкости $C_{\text{пол}}$ (это не паспортная ёмкость: вычтите саморазряд, потери на холоде и остаток ниже порога отключения):

$$ I_{\text{ср}} = \frac{1}{T}\sum_{i} I_i \cdot t_i, \qquad T_{\text{жизни}} = \frac{C_{\text{пол}}}{I_{\text{ср}}} $$

Считать удобно в миллиампер-секундах на цикл — величина маленькая, наглядная и линейно складывается: 1 мА·с = 1/3600 мА·ч. Динамическая мощность цифровой логики:

$$ P_{\text{дин}} = \alpha \cdot C_{\text{экв}} \cdot V^2 \cdot f, \qquad P_{\text{полн}} = P_{\text{дин}} + P_{\text{утечки}} $$

Отсюда два следствия, которые определяют всю стратегию. Первое: квадратичная зависимость от напряжения — снижение питания ядра с 3,3 до 1,8 В даёт трёхкратную экономию, поэтому в STM32L4 есть Range 2 с пониженным напряжением ядра и потолком 26 МГц. Второе: динамическая энергия на одну инструкцию от частоты почти не зависит (частота вдвое выше — время вдвое меньше), поэтому «посчитать быстро и уснуть» обычно выгоднее, чем «считать медленно» — при условии, что утечки малы, а вход в сон и выход из него дёшевы.

Откуда берётся энергия: характер источников

Источник Ёмкость / напряжение Пиковый ток Саморазряд Где применяют и чем подводит
CR2032 (литий-марганец) 220 мА·ч, 3,0 В 3–15 мА, внутреннее сопротивление 10–40 Ом и растёт со временем ~1 %/год Брелоки, BLE-метки. Импульс радио берут из буферного конденсатора 10–100 мкФ, иначе просадка и сброс
2×AA щелочные 2000–2900 мА·ч, 3,0 В, сильно падающая кривая сотни мА 2–3 %/год Массовая розница. Ёмкость резко падает на морозе и на большом токе (эффект Пейкерта)
Li-SOCl₂ (ER14505) 2400 мА·ч, 3,6 В, ровная полка десятки мА, нужен гибридный конденсатор 1 %/10 лет Счётчики, LoRaWAN-датчики на 10 лет. Пассивация: после долгого хранения первый импульс проседает
Li-Ion / LiPo, LiFePO₄ 2000–3500 мА·ч, 4,2→3,0 В (у LiFePO₄ 3,2 В) единицы ампер 2–5 %/мес Носимое, роботы. Нужен контроллер заряда, защита и запрет заряда на морозе; LiFePO₄ безопаснее и живёт 2000+ циклов
Суперконденсатор единицы Ф, 2,7 В огромный утечка 1–20 мкА Резерв на запись во флеш при обрыве питания. Утечка сравнима с током сна всего узла
Сбор энергии (PV, TEG, RF) 10–100 мкВт/см² в помещении зависит от буфера Датчики без обслуживания. Проблема холодного старта: DC/DC вроде BQ25570 стартует от 330 мВ

Практическое правило: паспортная ёмкость — это верхняя граница в лабораторных условиях. Закладывайте 80 % для щелочных, 85 % для литиевых, и отдельно вычитайте саморазряд. Для очень экономного узла саморазряд становится главной нагрузкой: 2,5 %/год от 2500 мА·ч — это 7,1 мкА эквивалентного постоянного тока, больше, чем ест сам микроконтроллер во сне.

Бюджет энергии: считаем раньше, чем паяем

Возьмём реальную задачу: датчик температуры и влажности, STM32L4 + BME280 + LoRa-модуль SX1276, питание 3,0 В от двух AA (полезно 2200 мА·ч), передача раз в 10 минут.

Профиль тока узла за один цикл: 10 минут сна и треть секунды работы

Считать это глазами по осциллографу неудобно — считайте скриптом ещё до заказа платы; таблица фаз и есть техническое задание на прошивку.

"""Бюджет энергии узла: фазы -> заряд -> средний ток -> срок жизни.
Сложность O(n log n) из-за сортировки вывода, память O(n)."""
PERIOD, CAPACITY_MAH = 600.0, 2200.0
#        имя фазы,            секунды,        ток в амперах (с датчиками и регулятором)
node = [("сон Stop 2",        PERIOD - 0.333, 2.1e-6),   # МК + RTC + утечки платы
        ("старт и тактовая",  3e-3,           6.0e-3),   # разгон MSI, инициализация
        ("измерение BME280",  40e-3,          350e-6),   # МК спит, датчик считает сам
        ("расчёт и упаковка", 5e-3,           8.0e-3),
        ("передача LoRa SF9", 185e-3,         45.0e-3),  # +14 дБм, 20 байт полезных
        ("окно приёма",       100e-3,         12.0e-3)]
charges = [(name, s * a * 1000.0) for name, s, a in node]   # заряд фазы в мА·с
total = sum(q for _, q in charges)
for name, q in sorted(charges, key=lambda x: -x[1]):
    print(f"  {name:<22} {q:8.3f} мА·с   {100 * q / total:5.1f} %")
avg_ma = total / PERIOD                                     # средний ток за цикл
hours = CAPACITY_MAH / avg_ma
print(f"  ИТОГО {total:.3f} мА·с за {PERIOD:.0f} с")
print(f"  средний ток {avg_ma * 1000:.1f} мкА -> {hours:.0f} ч = {hours / 8760:.2f} года")
  передача LoRa SF9  8.325 мА·с 76.7 % | сон Stop 2   1.258 мА·с 11.6 %
  окно приёма        1.200 мА·с 11.1 % | расчёт        0.040 мА·с  0.4 %
  старт и тактовая   0.018 мА·с  0.2 % | измерение     0.014 мА·с  0.1 %
  ИТОГО 10.855 мА·с за 600 с; средний ток 18.1 мкА -> 121547 ч = 13.87 года

Что читается из этой раскладки — и почему её надо строить до написания кода:

  • Оптимизировать вычисления бессмысленно. Уберите расчёт целиком — выиграете 0,4 %. Уменьшите время в эфире (меньший SF, короче пакет, реже отправка) — выиграете десятки процентов. Расход определяется протоколом, а не алгоритмом.
  • Окно приёма стоит почти как сон за все десять минут. Отсюда классы устройств LoRaWAN: класс A слушает только два коротких окна после своей передачи именно потому, что «просто слушать» дорого. Подробности радиочастей — в статье про IoT-связь.
  • 13,9 года — это фикция. Щелочные AA столько не живут: саморазряд съест их за 7–8 лет. Расчёт говорит другое — «энергопотребление перестало быть ограничением, дальше упираемся в химию». А вот сдвиг периода до 1 минуты даёт 162 мкА и 1,55 года: десятикратное учащение сократило жизнь в девять раз.
  • Плохой линейный регулятор с собственным током 20 мкА вместо 2: средний ток 36 мкА, срок 7 лет вместо 14. Одна деталь за 20 центов уполовинила срок службы устройства.

Тот же скрипт превращается в регрессионный тест: сохраняйте измеренный профиль в CI и падайте, если средний ток вырос больше чем на 10 %. Идея та же, что в измерении производительности, только метрика — заряд.

Режимы сна: что именно выключается

Слово «сон» скрывает лестницу компромиссов. Чем глубже режим, тем меньше ток, но тем дольше пробуждение и тем меньше состояния переживает сон.

Cortex-M даёт только два примитива. Инструкция WFI (wait for interrupt) останавливает ядро до прерывания, WFE — до события. Глубину задаёт бит SLEEPDEEP в SCB->SCR плюс регистры блока PWR конкретного вендора. То есть «сон» — это всегда WFI, а различие между 1 мА и 30 нА живёт вне ядра и у каждого производителя своё.

static volatile uint8_t work_pending;   /* выставляется в ISR, см. статью о прерываниях */

void idle_wait(void)                    /* не «пустой цикл», а остановка ядра */
{
    __disable_irq();                    /* закрываем окно гонки: проверка и WFI атомарны */
    if (!work_pending) { __DSB(); __WFI(); }   /* WFI будится даже при PRIMASK=1 */
    __enable_irq();                     /* обработчик выполнится здесь */
}

Гонка, которую закрывает __disable_irq(), обязательна к пониманию: приди прерывание между проверкой work_pending и WFI, ядро уснуло бы с уже готовой работой. Приём работает потому, что на Cortex-M WFI пробуждается даже при закрытом PRIMASK.

STM32: лестница из пяти ступеней

Для семейств L0/L4/U5 (цифры — типичные, для L476 при 3,0 В и 25 °C):

Режим Ток Что живо Пробуждение Когда брать
Run 80 МГц 8–10 мА всё Считаем; чем короче, тем лучше
Low-power run 2 МГц ~130 мкА всё, но медленно Долгие опросы без спешки
Sleep 1–2 мА периферия, DMA, прерывания мгновенно Ждём завершения DMA-передачи
Stop 2 1,1 мкА (+0,3 с RTC) ОЗУ, регистры, LPUART, LPTIM, I2C-адрес 5–10 мкс Рабочая лошадка: сон между измерениями
Standby 120 нА резервный домен, RTC, 32 байта регистров ~260 мкс, через сброс Сон часами, контекст не нужен
Shutdown 30 нА только вывод WKUP сброс Хранение, транспортировка

Ключевой практический вывод: Stop 2 почти всегда правильный выбор. Он на порядок дороже Standby по току, но сохраняет ОЗУ и просыпается в 30 раз быстрее — а разница между 1,1 мкА и 0,12 мкА теряется на фоне утечек платы и саморазряда батареи. Переход в Standby оправдан, только когда вы измерили плату и она реально стоит меньше микроампера.

/* Сон до пробуждения по RTC. Порядок важен, каждый пункт стоил кому-то дня отладки. */
void enter_stop2_for(uint32_t seconds)
{
    gpio_park_unused_pins();    /* 1. неиспользуемые выводы — в аналоговый режим:
                                      плавающий вход со звоном на пороге жжёт десятки мкА */
    bme280_sleep();             /* 2. внешние микросхемы усыпляются ИХ командами: сами они */
    sx1276_sleep();             /*    не спят, забытое радио тихо ест 1,5 мА круглосуточно */
    HAL_DBGMCU_DisableDBGStopMode();  /* 3. в релизе: домен отладки держит генераторы (~1 мА) */
    rtc_set_wakeup(seconds);    /* LSE 32768 Гц, независим от основного такта */
    HAL_PWREx_EnterSTOP2Mode(PWR_STOPENTRY_WFI);
    SystemClock_Config();       /* проснулись следующей строкой: ОЗУ и регистры целы,
                                   но тактовая система сброшена на MSI — восстановить */
}

ESP32: другая философия и другая цена

ESP32 проектировался вокруг Wi-Fi, и его лестница подчинена именно этому. Modem-sleep — радио спит между beacon-интервалами, ядра живы: 20–30 мА, годится для устройства от блока питания. Light sleep — ядра стоят, ОЗУ сохранено, соединение можно удержать: 0,8–1,5 мА, пробуждение за сотни микросекунд. Deep sleep — жив только RTC-домен и его медленная память: 10 мкА у ESP32, ~5 мкА у ESP32-C3, но пробуждение — это сброс, app_main() начинается заново, и пережить сон могут лишь переменные с атрибутом RTC_DATA_ATTR (esp_sleep_enable_timer_wakeup(600ull * 1000000ull); esp_deep_sleep_start(); — возврата из последнего вызова нет). Hibernation — отключён и медленный RTC-осциллятор: единицы микроампер, пробуждение по внешнему выводу.

Ловушка ESP32 не в токе сна, а в стоимости пробуждения. Установка Wi-Fi-соединения с DHCP после deep sleep занимает 1,5–3 с при токе 100–150 мА — это 200–450 мА·с, в 20–40 раз дороже всего цикла нашего LoRa-узла. Смягчается статическим IP, сохранением канала и BSSID в RTC-памяти (подключение сокращается до 200–400 мс) и переходом на BLE или ESP-NOW вместо TCP/IP. Вывод: ESP32 на батарейке — не для частой отправки, и уж точно не для монетки. Отдельно стоит ULP-сопроцессор: крошечное ядро в RTC-домене опрашивает АЦП или GPIO при токе ~150 мкА и будит основные ядра только по условию — правильный способ сделать «датчик, реагирующий на порог». Для сравнения, nRF52840 — эталон BLE от монетки: System OFF 0,4 мкА, System ON idle 1,5 мкА, приём 4,6 мА, передача 4,8 мА при 0 дБм, ядро 64 МГц около 3,3 мА с включённым DC/DC. Разница с Wi-Fi принципиальна: рекламный пакет BLE — единицы миллиампер на сотни микросекунд против сотни миллиампер на секунды.

Периферия работает, ядро спит

Главный приём экономии: пусть работу делает периферия, а ядро при этом стоит. Это прямое продолжение темы GPIO, АЦП, ШИМ и таймеров, только с точки зрения энергии.

Шаги 5–7 — это и есть приём: 64 отсчёта АЦП без единой инструкции ядра, примерно шестикратная экономия заряда против опроса в цикле. Механизмы того же рода: LPUART на STM32 будит ядро по совпадению адреса или стартовому биту, оставаясь на 32 кГц, — устройство «слушает шину» ценой микроампер; сторожевое окно АЦП (AWD) даёт прерывание, только когда напряжение вышло за границы; тактовое стробирование — включайте блок в RCC перед использованием и выключайте после, забытый тактируемый USB стоит сотен микроампер.

Tickless idle: как RTOS не мешает спать

RTOS по умолчанию будит систему тиком SysTick 1000 раз в секунду. Каждое пробуждение — вход, выход и обработчик, суммарно единицы микросекунд при токе в миллиамперы; средний ток получается порядка сотен микроампер, даже если задачи ничего не делают. Решение — tickless idle: перед сном планировщик смотрит, когда истекает ближайший таймаут, программирует низкопотребляющий таймер (LPTIM на STM32, RTC на nRF) на этот интервал, уходит в глубокий сон, а после пробуждения компенсирует пропущенные тики.

/* FreeRTOS вызывает это вместо своего сна при configUSE_TICKLESS_IDLE = 2.
   xExpectedIdleTime — сколько тиков система гарантированно ничем не занята. */
void vPortSuppressTicksAndSleep(TickType_t xExpectedIdleTime)
{
    if (xExpectedIdleTime < 2) { return; }                     /* не стоит возни */
    uint32_t ticks = xExpectedIdleTime - 1;
    if (ticks > LPTIM_MAX_TICKS) { ticks = LPTIM_MAX_TICKS; }  /* потолок 16-битного счётчика */
    if (eTaskConfirmSleepModeStatus() == eAbortSleep) { return; }  /* перепроверка под критической секцией */

    lptim_start_oneshot(ticks);
    enter_stop2();                                /* ядро стоит, LPTIM считает на 32 кГц */
    vTaskStepTick(lptim_stop_and_read());         /* могли проснуться раньше — доводим системное время */
}

Три вещи ломают tickless. Периодические задачи с мелким периодом: одна задача с vTaskDelay(pdMS_TO_TICKS(10)) не даёт спать дольше 10 мс никогда. Программные таймеры, заведённые «на всякий случай». Дрейф времени: LPTIM на 32 кГц не делится нацело на 1 кГц, накопленная ошибка — секунды в сутки; если нужны точные метки, синхронизируйте по RTC, а не по счётчику тиков. Механику планировщика и таймаутов смотрите в статье про RTOS, а фон по планировщикам вообще — в процессах и планировании.

Плата ест 3 мА вместо 2 мкА: дерево диагностики

Это самая частая ситуация в жизни: код правильный, режим правильный, амперметр показывает в тысячу раз больше расчёта. Разбирайте по порядку — почти всегда виновник в первых трёх ветках.

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

Отладка энергии без единого printf

Печать здесь не просто бесполезна — она искажает измеряемую величину. Это тот же класс проблем, что и в разделе про отладку и производство, но с собственным инструментарием. Задача тяжела динамическим диапазоном: от 2 мкА во сне до 150 мА в передаче — пять десятичных порядков. Обычный мультиметр врёт дважды: медленное усреднение плюс заметное падение напряжения на шунте, из-за которого устройство уходит в brownout на всплеске.

Инструмент Диапазон и полоса Для чего
Шунт 1 Ом + осциллограф полоса мегагерцы Форма всплеска передачи, просадки, дребезг, длительность фаз
Nordic Power Profiler Kit II 200 нА – 1 А, 100 кГц Дешёвый и правильный первый прибор: логарифмическая шкала, счёт заряда
Joulescope, Otii Arc нА – А, автопереключение, синхронный лог GPIO Профили в CI, приёмка бюджета, автоматические отчёты
Мультиметр в режиме мкА усреднение по секунде Только стационарный ток сна, с байпасом на время всплесков

Осциллограф покажет форму, но заряд надо интегрировать: PPK2 и Joulescope делают это сами, с осциллографа же снимайте курсорами длительности фаз и подставляйте в скрипт бюджета.

GPIO-маркеры и логический анализатор

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

/* Маркер фазы: одна запись в BSRR, без чтения-модификации-записи. Стоимость — такт
   и один вывод; в релизной сборке PROFILE_POWER=0 и макрос схлопывается в ((void)0). */
#define PHASE(n)   do { GPIOB->BSRR = phase_bits[(n)]; } while (0)

void measure_cycle(void)
{
    PHASE(PH_WAKE);    clocks_up();
    PHASE(PH_SENSE);   bme280_read_forced();
    PHASE(PH_CALC);    pack_payload();
    PHASE(PH_TX);      lora_send(payload, sizeof payload);
    PHASE(PH_RX);      lora_listen(100);
    PHASE(PH_SLEEP);   enter_stop2_for(600);
}

С открытыми инструментами это sigrok/PulseView и любой клон анализатора на 8 каналов за 10 USD — для маркеров скорости хватает с запасом; у Otii и Joulescope есть синхронный ввод цифровых каналов, и тогда таблица «фаза → заряд» строится автоматически. Ещё два способа получить данные наружу, не мешая измерению. SEGGER RTT: сообщение кладётся в кольцевой буфер в ОЗУ, отладчик вычитывает его через SWD в фоне, стоимость около микросекунды — на порядок дешевле UART; но RTT требует подключённого зонда, а значит живого домена отладки, и для финального измерения тока не годится. DWT->CYCCNT — 32-битный счётчик тактов в блоке отладки Cortex-M: uint32_t t0 = DWT->CYCCNT; do_work(); stats.cycles = DWT->CYCCNT - t0; даёт точное время фазы вообще без вывода наружу (переполнение при 80 МГц — через 53 секунды).

И финальное правило, которое стоит принять как аксиому: при подключённом SWD-зонде измерения энергопотребления недействительны. На STM32 биты DBG_SLEEP, DBG_STOP, DBG_STANDBY в DBGMCU_CR заставляют держать тактирование живым, чтобы отладчик не терял связь, — вместо 1,1 мкА вы увидите 1–3 мА. На ESP32 подключённый JTAG даёт свои миллиамперы. Отсюда рабочий процесс: отлаживаете логику с зондом, измеряете энергию — на автономном питании, с прошивкой без DEBUG_BUILD, снимая данные только маркерами и прибором. Джентльменский набор для этой работы — осциллограф, логический анализатор и SWD-зонд, и ни один из них не заменяет два других.

Быстро и спать или медленно и не спать

Спор «race to idle против медленной работы» решается арифметикой. Пусть задача — 2 миллиона тактов. При 80 МГц: 25 мс при 9 мА = 0,225 мА·с. При 16 МГц: 125 мс при 2,2 мА = 0,275 мА·с. Быстрый режим выиграл, потому что ток растёт медленнее частоты — часть потребления постоянна (опорные источники, регулятор, аналоговые блоки) и оплачивается временем.

Но правило переворачивается в двух случаях. Первый: задача ждёт, а не считает. Если 90 % времени уходит на ожидание датчика по I2C, «быстро посчитать» нечего — ускорение частоты просто дороже оплачивает то же ожидание; правильный ответ — понизить частоту или уснуть в Sleep, пока идёт обмен по DMA. Второй: смена режима стоит дорого. Разгон PLL — сотни микросекунд при полном токе; если работы на 50 мкс, на разгон уйдёт больше, чем на работу, поэтому для коротких задач держат MSI/HSI на умеренной частоте и не трогают PLL вообще. Ориентир один: считайте энергию на задачу, а не мощность — метрика мА·с на цикл обработки снимается с прибора теми же маркерами.

Выбор платы: за что вы платите током

  • ATmega328P и Arduino. Сам чип в power-down ест 0,1 мкА (со сторожевым таймером — 4–6 мкА) и вполне годится для батарейных поделок. Но плата Arduino Uno ест 45 мА постоянно — линейный регулятор, микросхема USB, два светодиода. Разница между чипом и платой стократная, и это общее правило: отладочная плата никогда не показывает энергетику конечного изделия.
  • STM32L0/L4/U5. Лучший выбор, когда нужен срок жизни в годах: микроамперный сон, богатая низкопотребляющая периферия (LPUART, LPTIM, автономный АЦП), пробуждение за микросекунды. L0 — когда задача тривиальна и важна цена; L4 — когда нужны вычисления и DSP; U5 — новее и ещё экономнее.
  • nRF52/nRF54 против ESP32 (-C3, -S3). Есть BLE — начинайте с Nordic: энергетика радио лучшая в классе, есть онлайн-калькулятор профиля для оценки до покупки. Нужен Wi-Fi, сертифицированные дешёвые модули и гигантская экосистема — ESP32, но только при наличии розетки или крупного аккумулятора и редких отправок; для монетки он не подходит.
  • RP2040/RP2350. Уникальный PIO, два ядра, отличная документация и цена. Энергетика Pico слабая: dormant на плате — сотни микроампер и выше, правит регулятор. Для батарейного датчика придётся делать свою плату.
  • Raspberry Pi и вообще Linux-одноплатники. Полноценного сна нет: 0,5–3 Вт даже в простое. Каноническая связка для робота — Pi как «мозг» плюс микроконтроллер как «спинной мозг» и распорядитель питания: он держит реальное время, следит за батареей и включает Pi по событию.

Второй бюджет: ОЗУ и флеш

64 КБ ОЗУ и 256 КБ флеша — типичный середняк. Это не «мало», это фиксировано: переполнение проявляется не отказом аллокации, а испорченными соседними данными и HardFault в случайном месте через два часа работы. Устройство памяти на уровне языка разобрано в C для встраиваемых систем, здесь — про учёт и контроль.

# text — код и константы во флеш; data — инициализированные переменные (И флеш, И ОЗУ);
# bss — нули (только ОЗУ). Правда о том, кто что притащил, живёт в firmware.map.
arm-none-eabi-size -A build/firmware.elf
arm-none-eabi-nm --print-size --size-sort --radix=d build/firmware.elf | tail -30
arm-none-eabi-gcc ... -Wl,--print-memory-usage    # постоянный контроль прямо в сборке
#   FLASH:  98304 B / 256 KB  = 37.50 %      RAM:  41216 B / 64 KB  = 62.89 %

Наглядное дерево «файл → функция → размер → кто вызывает» строит puncover поверх той же карты. Две типовые ошибки, дающие внезапный расход ОЗУ: забытый const — таблица static uint32_t sine_table[1024] уезжает в .data и занимает 4 КБ флеша под образ и 4 КБ ОЗУ навсегда, тогда как static const кладёт её в .rodata, только во флеш; и крупный буфер на стеке функции, вызванной из ISR, — стек прерываний короткий, массив на 2 КБ просто затрёт то, что под ним, статический буфер хотя бы виден в size. Третий приём — раскладывать данные по блокам ОЗУ вручную: у STM32F4 есть быстрая CCM-память, недоступная DMA, у L4 — SRAM2 с сохранением в Standby; размещение задаётся как uint8_t buf[256] __attribute__((section(".sram2"), aligned(4))); плюс правка скрипта линковки.

Стек: единственный ресурс, который не проверяет компилятор

Переполнение стека не диагностируется само — оно проявляется порчей чужих данных. Классический приём: до входа в main, пока стек пуст, залить его узором (for (uint32_t *p = &_sstack; p < (uint32_t *)__get_MSP() - 16; ++p) *p = 0xA5A5A5A5u;), а позже линейным поиском найти первое изменённое слово — расстояние до него и есть «никогда не использованный» запас, high water mark. Стоимость O(n) по неиспользованной части, память O(1). В FreeRTOS то же делает uxTaskGetStackHighWaterMark(), а configCHECK_FOR_STACK_OVERFLOW = 2 добавляет проверку канарейки при переключении контекста. Статическая оценка — флаг -fstack-usage: компилятор кладёт рядом с каждым .o файл .su с расходом стека каждой функции, и сложение по графу вызовов даёт верхнюю границу; метод не работает при рекурсии и вызовах через указатели — оба приёма во встраиваемом коде и без того нежелательны. Не забудьте про вложенные прерывания: каждый уровень добавляет кадр исключения (32 байта, 104 с плавающей точкой) плюс локальные обработчика.

Кучи нет, зато есть флаги сборки

malloc во встраиваемой прошивке — почти всегда ошибка. Не потому, что медленно, а потому, что фрагментация не ограничена сверху: система, отработавшая три недели, отказывается выделить 200 байт при 8 КБ свободных, и предсказать этот момент нельзя. Правила отрасли (MISRA C:2012, правило 21.3) прямо запрещают динамическое выделение. Замены: статические буферы, пулы блоков фиксированного размера, кольцевые буферы, статические API RTOS (xTaskCreateStatic, xQueueCreateStatic). Если куча всё же нужна — выделяйте всё на старте и никогда не освобождайте: это переименованное статическое выделение, зато совместимое с чужими библиотеками. Общий фон по аллокаторам — в управлении памятью и памяти как ресурсе производительности.

Сами флаги сборки дают десятки процентов размера почти бесплатно:

-Os                      # оптимизация по размеру; -O2 обычно на 10-20 % крупнее
-ffunction-sections -fdata-sections -Wl,--gc-sections   # выкинуть неиспользуемое
-flto                    # межмодульная оптимизация: ещё 5-15 % размера
--specs=nano.specs       # newlib-nano: printf/scanf в разы компактнее
-u _printf_float         # добавлять ТОЛЬКО если реально нужен %f (это +6-8 КБ)

Полный printf из newlib — это 10–14 КБ флеша, вызовы malloc и до 1 КБ стека на вызов. В прошивке на 64 КБ это пятая часть образа ради отладочной печати, которой в продакшне не будет. Практика: собственный минимальный форматтер на 300 байт либо бинарное журналирование, где во флеш пишутся идентификатор строки и аргументы, а текст подставляется на хосте.

Третий бюджет: ресурс флеша и запись, переживающая обрыв питания

Карта флеша с двумя слотами прошивки и журналом настроек

Три свойства NOR-флеша определяют всю архитектуру хранения. Стирание переводит биты в 1, запись только гасит их в 0 — «изменить значение на месте» нельзя, можно лишь дописать новое рядом или стереть целую страницу. Стирание идёт страницами (1–2 КБ у L4, до 128 КБ у F4) и занимает 20–100 мс при токе 10–20 мА: за одно стирание уходит примерно столько же заряда, сколько за час сна. Ресурс — около 10 000 циклов стирания на страницу, хранение данных 20 лет при 55 °C и заметно меньше при высокой температуре.

Плюс два подводных камня. У STM32L4 и новее запись идёт двойными словами по 64 бита с ECC, и повторная запись в уже записанное двойное слово вызывает ошибку ECC при последующем чтении — «дописать пару бит поверх» физически нельзя. И главное: во время стирания или записи флеш-контроллер останавливает шину, выборка команд из того же банка встаёт, а задержка обработки прерываний вырастает на десятки миллисекунд. Для системы реального времени это катастрофа — критичный код на время записи выносят в ОЗУ или используют двухбанковые чипы.

Журнал вместо перезаписи

Прямая перезапись страницы при каждом сохранении даёт 10 000 сохранений — 3,5 месяца при одном сохранении в 15 минут. Приём, снимающий проблему: дописывать записи фиксированного размера, а страницу стирать только когда она заполнилась, циклически используя несколько страниц.

typedef struct __attribute__((packed, aligned(8))) {
    uint32_t seq;         /* монотонный номер; 0xFFFFFFFF = слот пуст */
    uint8_t  payload[10]; /* сами настройки */
    uint16_t crc16;       /* пишется ПОСЛЕДНИМ двойным словом */
} cfg_record_t;           /* ровно 16 байт: кратно 8, требование ECC L4 */
#define SLOTS  (4 * FLASH_PAGE_SIZE / sizeof(cfg_record_t))       /* 4 стр. × 128 = 512 */

/* Поиск актуальной записи при старте: линейный проход, O(N) по слотам (N = 512,
   около миллисекунды), память O(1). Сравнение (int32_t)(a - b) > 0 вместо a > b
   корректно переживает переполнение счётчика — приём из номеров последовательности TCP. */
const cfg_record_t *cfg_find_latest(void)
{
    const cfg_record_t *best = NULL;
    uint32_t best_seq = 0;
    for (size_t i = 0; i < SLOTS; ++i) {
        const cfg_record_t *r = &journal[i];
        if (r->seq == 0xFFFFFFFFu) { continue; }               /* слот пуст */
        if (crc16(r, sizeof *r - 2) != r->crc16) { continue; } /* запись оборвана */
        if (!best || (int32_t)(r->seq - best_seq) > 0) { best = r; best_seq = r->seq; }
    }
    return best;                                               /* NULL = заводские значения */
}

Сохранение зеркально: найти первый пустой слот, собрать запись с seq = latest + 1, посчитать CRC и записать двойными словами так, чтобы CRC ушло последним; когда слоты кончились — стереть самую старую страницу (22 мс, 15 мА, шина стоит) и продолжить. Ресурс вырастает в 128 раз: 4 страницы × 128 записей × 10 000 = 5,1 миллиона сохранений. Писать это самому не обязательно: littlefs (устойчивая к обрыву питания ФС с износовыравниванием), NVS в ESP-IDF и X-CUBE-EEPROM у ST реализуют ту же идею журнала.

Что делать при пропадании питания

Правило одно: никогда не бывает состояния «наполовину сохранено». Достигается порядком записи — сначала данные, последним признак валидности (CRC или магическое число). Пропало питание раньше — при старте признак не сойдётся, запись отбросится, останется предыдущая. Это то же журналирование, что в базах и файловых системах, только в 16 байтах. Плюс три меры на уровне схемы. Детектор пониженного напряжения (BOR) настраивается на уровень, при котором флеш ещё гарантированно программируется (обычно 2,4–2,7 В): ниже — сброс, и запись не начинается вообще. Запас энергии — конденсатор 100–470 мкФ или суперконденсатор на шине питания: время считается как $t = C \cdot \Delta V / I$, для 470 мкФ, запаса 0,6 В и тока 15 мА выходит 19 мс, как раз одно стирание страницы. Сигнал заранее — компаратор на входном напряжении даёт прерывание «питание падает» на миллисекунды раньше BOR, и обработчик успевает завершить транзакцию.

Наконец, схема A/B-слотов на картинке — фундамент безопасного обновления по воздуху: новый образ пишется в неактивный слот, проверяется по CRC и подписи, и только потом загрузчик переключает указатель. Подробности процедуры — в статьях про IoT-связь и отладку и производство.

Питание платы: где теряются проценты

Прошивка может быть идеальной, а плата — съедать всё преимущество.

  • LDO против импульсного преобразователя. Линейный регулятор гасит разницу напряжений в тепло: КПД равен $V_{\text{вых}}/V_{\text{вх}}$, то есть 3,3 из 4,2 В — это 79 %, а 3,3 из 12 В — 27 %. Импульсный держит 85–95 % под нагрузкой, но сам ест 15–50 мкА и на нагрузке 2 мкА проигрывает LDO с током покоя 1 мкА в разы. Для батарейных узлов берут LDO со сверхнизким Iq или преобразователь с режимом пропуска импульсов (PFM). Ток покоя — параметр номер один при таком выборе, важнее КПД; значения 1–2 мкА реальны и стоят копейки, но их надо специально искать в даташите.
  • Ключи питания сегментов и буферные конденсаторы. Датчик, который ест 300 мкА даже в своём «спящем» режиме, дешевле обесточить полевым транзистором — только не забудьте про время установления и про подтяжки, питающие обесточенную микросхему через защитные диоды входов. Между батареей и радиомодулем ставится буферный конденсатор: он отдаёт импульсный ток, которого источник дать не может, и для CR2032 это обязательный элемент схемы.
  • Мелочи, которые складываются: делитель для измерения напряжения батареи из резисторов по 10 кОм ест 165 мкА круглосуточно — больше, чем весь узел; берите 1–10 МОм и подключайте делитель ключом на время измерения. Светодиод «питание» — 2–5 мА постоянно. Подтяжки I2C 4,7 кОм на притянутой к земле линии — 700 мкА, пока линия активна.

Робототехника: бюджет, в котором всё решают моторы

В мобильном роботе пропорции переворачиваются: электроника становится шумом на фоне механики. Типичный четырёхколёсный робот на 4S LiPo (14,8 В, 5000 мА·ч = 74 Вт·ч):

Потребитель Мощность Доля
Два мотора в движении 25–45 Вт (пик при старте до 90 Вт) ~75 %
Raspberry Pi 4 с камерой 4–6 Вт ~12 %
Лидар 2,5 Вт ~6 %
Сервоприводы в удержании; МК, датчики, IMU 2–8 Вт и 0,3 Вт ~6 %

При средних 38 Вт получается около 1,9 часа хода — и это только если робот едет непрерывно. Отсюда следствия, которых нет в мире датчиков. Пусковой ток: мотор при старте берёт ток заторможенного ротора, в 5–8 раз больше номинального, просадка шины сбрасывает Raspberry Pi, и робот перезагружается ровно тогда, когда трогается с места. Лечение — раздельные преобразователи для силовой и логической части, общая земля в одной точке, крупные конденсаторы на силовой шине, плавный разгон ШИМ вместо ступеньки. Рекуперация: тормозящий мотор работает генератором и поднимает напряжение шины, без тормозного резистора это выбивает преобразователь. Телеметрия батареи: датчик тока (INA226, ACS712) плюс измерение напряжения дают и оценку остатка, и диагностику — аномальный ток мотора означает заклинивший привод или наезд на препятствие.

"""Узел ROS 2: кулонометрия по INA226 и решение «пора на базу». O(1) на итерацию."""
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import BatteryState

CAPACITY_AH = 5.0                      # 4S LiPo: 16,8 В заряжен, 13,2 В пуст

class BatteryMonitor(Node):
    def __init__(self):
        super().__init__("battery_monitor")
        self.pub = self.create_publisher(BatteryState, "battery_state", 10)
        self.charge_ah = CAPACITY_AH   # уточняется по напряжению покоя без нагрузки
        self.create_timer(1.0, self.tick)

    def tick(self):
        volts, amps = read_ina226()                     # драйвер шины I2C
        self.charge_ah -= amps / 3600.0                 # кулонометрия: шаг ровно 1 с
        msg = BatteryState()
        msg.voltage, msg.current = float(volts), float(-amps)   # разряд — отрицательный ток
        msg.charge, msg.capacity = self.charge_ah, CAPACITY_AH
        msg.percentage = max(0.0, min(1.0, self.charge_ah / CAPACITY_AH))
        msg.power_supply_status = BatteryState.POWER_SUPPLY_STATUS_DISCHARGING
        self.pub.publish(msg)
        # Порог считается по ТОКУ, а не по проценту: обратный путь пустого робота
        # стоит меньше, чем гружёного, и запас надо считать в минутах хода.
        minutes_left = (self.charge_ah / max(amps, 0.1)) * 60.0
        if minutes_left < self.estimate_return_minutes() * 1.3:
            self.request_return_to_dock()               # штатное поведение, не авария

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

Типичные ошибки

  1. Измерять ток с подключённым отладчиком. Показания завышены в тысячу раз, причина неочевидна, день потерян.
  2. Считать, что чип и плата — одно и то же. Отладочная плата ест десятки миллиампер регулятором, светодиодами и мостом USB; энергетика меряется только на целевой плате.
  3. Оптимизировать вычисления вместо радиообмена. Стройте раскладку заряда до того, как что-то оптимизировать.
  4. Забыть усыпить внешние микросхемы и оставить выводы плавающими. Снятие такта не выключает датчик — у него свой регистр режима; незаданный вход со звоном на пороге стоит десятков микроампер на каждый.
  5. Задача с vTaskDelay(10 мс), убивающая tickless. Один опрос «на всякий случай» съедает всю экономию системы.
  6. Прямая перезапись настроек во флеш. Гарантированный отказ на 10 000-й записи, одинаковый у всей партии.
  7. Запись во флеш без учёта остановки шины. Прерывание, которое должно отработать за 200 мкс, ждёт 22 мс — регулятор разваливается. А делитель для измерения батареи из резисторов по 10 кОм один потребляет больше, чем всё остальное устройство.
  8. malloc в прошивке и полный printf ради двух строк. Первое отказывает через недели и не воспроизводится на столе, второе стоит 12 КБ флеша и килобайта стека на вызов.
  9. Планировать по паспортной ёмкости батареи. Реальная — на 15–20 % меньше, на холоде вдвое.

Мини-итог

  • Энергия считается в миллиампер-секундах на цикл, и раскладка по фазам говорит, что оптимизировать, точнее любой интуиции.
  • Сон — это лестница: у STM32 рабочая ступень Stop 2 (микроамперы, сохранённое ОЗУ, пробуждение за микросекунды), у ESP32 главная цена не в токе сна, а в секундах подключения к Wi-Fi.
  • Пусть работает периферия, а ядро стоит: таймер плюс DMA плюс Sleep дешевле опроса в цикле в разы. А в RTOS без tickless idle сна нет вообще — 1000 пробуждений в секунду перекрывают любую экономию.
  • Энергию нельзя отлаживать печатью. Инструменты — GPIO-маркеры плюс логический анализатор, шунт с осциллографом, специализированный измеритель; и всё это — без подключённого SWD-зонда.
  • ОЗУ и флеш — жёсткие бюджеты, контролируемые size, картой линковки и высоким водяным знаком стека; кучи нет. Флеш стирается страницами, живёт 10 000 циклов и останавливает шину: пишите журналом с CRC последним полем — и обрыв питания перестанет быть проблемой.

Источники

Что дальше

Мы научились экономить заряд и укладываться в память — и выяснили, что львиную долю бюджета съедает радио. Логично разобраться, как именно оно работает и во что обходится каждый протокол: IoT-связь: Wi-Fi, BLE, LoRa, MQTT, безопасность устройств.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов