157. Сон и малопотребляющий домен ESP32: режимы питания, память RTC, пробуждение, сопроцессор ULP
В разделе 10 STM32 засыпал в Stop и Standby, а резервный домен хранил часы и пару регистров. У ESP32 то же устроено богаче и жёстче. Радио само по себе съедает сотни миллиампер, поэтому экономят не на ядре, а на времени бодрствования. Из глубокого сна чип выходит сбросом, как после включения. А пока основные ядра спят, может работать отдельный крошечный процессор — сопроцессор ULP — со своей программой на своём ассемблере.
Эмулятор здесь помогает меньше всего: ток и сам ULP он не моделирует.
Поэтому раздел опирается на три вещи и каждый раз говорит, на какую.
Программа в QEMU (_verify/esp157/) проверяет память RTC, загрузку
программы ULP и обмен с ней переменными. Сборка показывает саму программу
ULP: размер, адреса, символы. Токи — из даташита ESP32 версии 5.3
(выписка datasheet_power.txt), а расчёт батареи — по методике раздела 10.
Как это называется в документации
Оригинал Перевод Modem-sleep, Light-sleep, Deep-sleep, Hibernation сон радио, лёгкий сон, глубокий сон, гибернация RTC domain малопотребляющий домен: таймер, память и выводы RTC RTC fast / slow memory быстрая и медленная память RTC, по 8 КБ RTC_DATA_ATTR, RTC_NOINIT_ATTR, RTC_FAST_ATTR атрибуты: переменная в памяти RTC wakeup source, wakeup cause источник пробуждения, причина пробуждения ext0 / ext1 wakeup пробуждение по одному выводу RTC / по нескольким ULP coprocessor (FSM) сопроцессор сверхнизкого потребления, автомат stage counter счётчик стадий ULP для циклов ulp_embed_binary сборка программы ULP и вшивание её в приложение wake stub код, выполняемый сразу после пробуждения до загрузчика
157.1 Режимы питания: цифры даташита
| Режим | Что работает | Ток |
|---|---|---|
| передача Wi-Fi 802.11b, +19,5 дБм | всё | 240 мА |
| передача 802.11g / 802.11n | всё | 190 / 180 мА |
| приём Wi-Fi | всё | 95–100 мА |
| сон радио (Modem-sleep), 240 МГц | ядра, радио выключено | 30–68 мА |
| сон радио, 160 МГц / 80 МГц | то же | 27–44 / 20–31 мА |
| лёгкий сон (Light-sleep) | ядра на паузе, ОЗУ цело | 0,8 мА |
| глубокий сон, ULP работает | домен RTC и сопроцессор | 150 мкА |
| глубокий сон, ULP раз в 1 % времени | то же, редкие запуски | 100 мкА |
| глубокий сон: таймер и память RTC | только домен RTC | 10 мкА |
| гибернация | только таймер RTC | 5 мкА |
выключен (CHIP_PU в нуле) |
ничего | 1 мкА |
Между верхней и нижней строкой — пять порядков. Сон радио включается сам, когда Wi-Fi подключён и программа не передаёт: ESP-IDF выключает приёмник между маяками роутера. Лёгкий сон — аналог Stop у STM32: ядра стоят, ОЗУ и регистры целы, программа продолжает с той же строки. Глубокий сон — аналог Standby: ОЗУ пропадает, выход через сброс, живёт только домен RTC. Гибернация отключает даже память RTC.
157.2 Память RTC: что переживает сброс
У домена RTC своя память: 8 КБ медленной (её видят оба ядра и ULP) и 8 КБ быстрой (только ядро PRO). Переменную кладут туда атрибутом:
RTC_DATA_ATTR static int data_count; /* медленная память RTC, заливается загрузчиком */
RTC_NOINIT_ATTR static int noinit_count; /* медленная память RTC, никто не трогает */
RTC_FAST_ATTR static int fast_count; /* быстрая память RTC, только ядро PRO */
Программа увеличивает все три счётчика, печатает их и перезапускает чип
вызовом esp_restart():
сброс 1: RTC_DATA = 1 по 0x50000200, RTC_NOINIT = 1 по 0x50000204, RTC_FAST = 1 по 0x3ff80080
сброс 3: RTC_DATA = 1 по 0x50000200, RTC_NOINIT = 2 по 0x50000204, RTC_FAST = 1 по 0x3ff80080
Код 1 — включение питания, 3 — программный сброс. После сброса дожил
только RTC_NOINIT_ATTR: переменные RTC_DATA_ATTR и RTC_FAST_ATTR
загрузчик залил заново. Их сохранность обещана только при выходе из
глубокого сна — ради него они и существуют. Нужно пережить и программный
сброс, и сторожевой таймер — берут RTC_NOINIT_ATTR и сами обнуляют его
при включении питания:
if (esp_reset_reason() == ESP_RST_POWERON)
noinit_count = 0;
Адреса тоже говорят. В разделе 150 переменная RTC лежала в самом начале
медленной памяти, по 0x50000000. Здесь — по 0x50000200: первые 512
байт отданы программе ULP (CONFIG_ULP_COPROC_RESERVE_MEM=512).
157.3 Источники пробуждения
Пробуждение из сна ESP32 умеет по таймеру RTC, по уровню одного вывода RTC
(ext0), по сочетанию нескольких выводов (ext1), по касанию сенсорного
входа, по сигналу программы ULP, а из лёгкого сна — ещё и по UART и
любому GPIO. В ESP-IDF каждый источник включается своим вызовом
esp_sleep_enable_…, сон начинается esp_deep_sleep_start() или
esp_light_sleep_start(), а после пробуждения esp_sleep_get_wakeup_cause()
говорит, кто разбудил. Все три источника программа включила без ошибок:
ulp_set_wakeup_period 20 мс: ESP_OK
esp_sleep_enable_ulp_wakeup: ESP_OK
esp_sleep_enable_timer_wakeup 5 с: ESP_OK
Сам сон начинается так:
esp_deep_sleep_start(); /* отсюда не возвращаются: выход из сна — сброс */
Возврата из этого вызова нет: на чипе питание ядер и ОЗУ отключается, и
через 5 секунд — или раньше, по сигналу ULP, — чип стартует с загрузчика.
Программа узнаёт об этом по причине сброса и по причине пробуждения; в
RTC_DATA_ATTR к этому моменту лежит всё, что она сохранила перед сном.
157.4 Сопроцессор ULP: что это
ULP в ESP32 — не второе ядро, а простой автомат: четыре 16-битных
регистра R0–R3, счётчик стадий для циклов и 8 КБ медленной памяти RTC, где
лежат и его команды, и данные. Команд два десятка: пересылка, сложение и
вычитание, сдвиги, переходы, чтение и запись регистров периферии RTC,
чтение АЦП, работа с I²C, задержка, wake и halt. Таймер RTC запускает
программу с начала раз в заданный период; она делает своё и
останавливается командой halt до следующего запуска.
Смысл в токе: основные ядра спят, а ULP раз в 20 мс опрашивает датчик и будит ядра, только если значение вышло за порог. Цена по даташиту — 150 мкА вместо 10, пока сопроцессор включён постоянно, и 100 мкА, если он работает 1 % времени: в 10–15 раз больше глубокого сна, но в 5–8 раз меньше лёгкого. Программа из 24 команд раз в 20 мс работает ещё меньшую долю времени, и ток у неё ниже; насколько — показывает только замер на плате (раздел 10.8).
157.5 Программа для ULP
Программа пишется на ассемблере ULP и лежит в main/ulp/. Эта считает
свои запуски и будит ядра на каждом пятом:
entry:
move r3, counter /* r3 = адрес слова counter (в словах от начала памяти RTC) */
ld r0, r3, 0 /* r0 = counter */
add r0, r0, 1
st r0, r3, 0 /* counter = counter + 1 */
move r3, threshold
ld r1, r3, 0 /* r1 = threshold */
sub r2, r0, r1 /* counter − threshold: заём ставит флаг переполнения */
jump still_early, ov /* counter < threshold — ещё рано */
Ядра могут ещё не уснуть к моменту wake. Поэтому перед пробуждением
программа читает поле RTC_CNTL_RDY_FOR_WAKEUP и ждёт его в цикле на
счётчике стадий, до 20 попыток:
try_wake:
stage_inc 1
jumps still_early, 20, gt /* ядра не готовы 20 попыток подряд — до следующего запуска */
READ_RTC_FIELD(RTC_CNTL_LOW_POWER_ST_REG, RTC_CNTL_RDY_FOR_WAKEUP)
and r0, r0, 1
jump try_wake, eq /* ядра ещё засыпают — подождать */
wake /* разбудить основные ядра */
Сборка проекта сама собирает и программу ULP. Это делает одна строка в
main/CMakeLists.txt — вызов ulp_embed_binary:
ulp_main.bin 108
магия b'ulp\x00', код с 12, код 96 байт = 24 команд, данные 0, bss 12; файл 108 байт
0000000 75 6c 70 00 0c 00 60 00 00 00 0c 00
Файл — 108 байт: 12 байт заголовка (ulp\0, смещение кода 12, размер кода
0x60 = 96, данных 0, bss 12) и 96 байт кода — 24 команды по 4 байта.
Слова counter, threshold и wakeups в файле не лежат: это bss, их
место в памяти RTC сразу за кодом.
157.6 Обмен с программой на C
Сборка пишет заголовок ulp_main.h и сценарий компоновщика ulp_main.ld:
каждый глобальный символ программы ULP становится переменной C с
приставкой ulp_:
ulp_main.h:extern uint32_t ulp_counter;
ulp_main.ld:PROVIDE ( ulp_counter = 0x50000060 );
ulp_main.ld:PROVIDE ( ulp_threshold = 0x50000064 );
ulp_main.ld:PROVIDE ( ulp_wakeups = 0x50000068 );
Адрес 0x50000060 — начало медленной памяти плюс 96 байт кода. Программа
на C загружает ULP, пишет порог и запускает:
esp_err_t e = ulp_load_binary(0, ulp_main_bin_start, bytes / sizeof(uint32_t));
ulp_threshold = 5; /* порог — из C, программа ULP читает его сама */
printf("ulp_set_wakeup_period 20 мс: %s\n", esp_err_to_name(ulp_set_wakeup_period(0, 20000)));
printf("esp_sleep_enable_ulp_wakeup: %s\n", esp_err_to_name(esp_sleep_enable_ulp_wakeup()));
printf("ulp_run: %s\n", esp_err_to_name(ulp_run(&ulp_entry - RTC_SLOW_MEM)));
ulp_load_binary: ESP_OK, файл программы 108 байт
переменные ULP: entry 0x50000000, counter 0x50000060, threshold 0x50000064, wakeups 0x50000068
ulp_threshold = 5, в слове памяти RTC 0x00000005
ulp_set_wakeup_period 20 мс: ESP_OK
esp_sleep_enable_ulp_wakeup: ESP_OK
ulp_run: ESP_OK
Переменные — 32-битные слова, а регистры ULP — 16-битные. Программа ULP
видит только младшие 16 бит, поэтому значения от ULP читают как
ulp_counter & 0xFFFF. ulp_run принимает не адрес, а номер слова от
начала памяти RTC — отсюда &ulp_entry - RTC_SLOW_MEM.
157.7 Батарея: считать время бодрствования
Датчик на ESP32 раз в 10 минут просыпается, подключается к Wi-Fi и отправляет замер; всё вместе — 2 секунды при среднем токе 100 мА. Остальное время — глубокий сон. Батарея 2000 мА·ч, расчёт как в разделе 10:
| Вариант | Средний ток | Срок батареи |
|---|---|---|
| раз в 10 минут, сон с таймером RTC (10 мкА) | 0,343 мА | 243 дня |
| раз в 10 минут, между замерами работает ULP (150 мкА) | 0,483 мА | 173 дня |
| раз в минуту, сон 10 мкА | 3,34 мА | 25 дней |
| всё время лёгкий сон | 0,8 мА | 104 дня |
| без сна, сон радио на 80 МГц | 20 мА | 4 дня |
В первой строке сон даёт только 3 % расхода: 97 % съедают две секунды работы радио. Поэтому у ESP32 выигрывают прежде всего реже и короче просыпаясь: запоминают канал и адрес роутера в памяти RTC, берут статический IP вместо DHCP, отправляют пачкой. ULP окупается, когда будит ядра заметно реже, чем они проснулись бы сами. Строка с ULP в таблице — верхняя оценка: 150 мкА — сопроцессор включён всё время; с цифрой даташита для работы 1 % времени, 100 мкА, выходит 0,433 мА и 192 дня.
157.8 Чего эмулятор не умеет
Глубокий сон эмулятор не выдерживает. На esp_deep_sleep_start()
программа падает:
глубокий сон: esp_deep_sleep_start()
Guru Meditation Error: Core 0 panic'ed (LoadStorePIFAddrError). Exception was unhandled.
Код сна пишет в регистры домена RTC, которых в QEMU нет, — отсюда исключение при записи по адресу периферии. После перезапуска программа печатает:
после сна: причина пробуждения 0, ulp_counter 0, ulp_wakeups 0
Причина 0 — ESP_SLEEP_WAKEUP_UNDEFINED: это был не выход из сна, а сброс
после паники. Отсюда правила для опытов со сном:
- Сон проверяют только на плате; в эмуляторе — всё до вызова
esp_deep_sleep_start(). - Токов нет вовсе: цифры 157.1 — только из даташита, в изделии их меряют по разделу 10.8.
- Сопроцессор ULP не исполняется: загрузка, адреса и запуск возвращают
ESP_OK, но счётчикиulp_counterиulp_wakeupsостаются нулями.
157.9 Грабли
| Симптом | Причина |
|---|---|
после пробуждения программа начинает с app_main |
глубокий сон — это сброс; состояние хранить в памяти RTC |
переменная RTC_DATA_ATTR обнулилась после esp_restart |
она переживает только глубокий сон; для сброса — RTC_NOINIT_ATTR |
RTC_NOINIT_ATTR при включении содержит мусор |
обнулять самому по ESP_RST_POWERON |
| программа ULP «не видит» переменную | переменная не объявлена .global — в ulp_main.h её нет |
| ULP читает не то значение | регистры 16-битные: писать и читать младшие 16 бит |
ulp_run запускает не с начала |
передан адрес, а нужен номер слова: &ulp_entry - RTC_SLOW_MEM |
| ток глубокого сна сотни микроампер | подтяжки и выводы RTC, светодиод питания, преобразователь на плате |
| пробуждение по выводу не работает | ext0 и ext1 — только выводы RTC, а не любые GPIO |
| ULP будит ядра, а те не просыпаются | нет esp_sleep_enable_ulp_wakeup() или ядра ещё не уснули — ждать RDY_FOR_WAKEUP |
| батарея садится за неделю | долгое подключение к Wi-Fi: DHCP и поиск сети на каждом пробуждении |
157.10 Итоги
- Режимы: передача 240 мА, сон радио 20–68 мА, лёгкий сон 0,8 мА, глубокий 10–150 мкА, гибернация 5 мкА, выключен 1 мкА.
- Глубокий сон = сброс; живёт только домен RTC: память 8 + 8 КБ, таймер, выводы RTC.
RTC_DATA_ATTRиRTC_FAST_ATTRпереживают глубокий сон, но неesp_restart;RTC_NOINIT_ATTRпереживает и его.- Резерв ULP 512 байт сдвигает переменные RTC с
0x50000000на0x50000200. - ULP: 4 регистра по 16 бит, программа 24 команды = 96 байт, файл 108 байт; переменные
ulp_…по0x50000060,…64,…68. - Батарея решается временем работы радио: раз в 10 минут по 2 с — 243 дня, раз в минуту — 25.
Полные исходники
main.c — память RTC и обмен переменными с программой ULP.
/* Раздел 157: память RTC и сопроцессор ULP на ESP32.
* Что переживает сброс, где лежат переменные RTC, как программа ULP попадает в память RTC
* и как C обменивается с ней переменными. Сон и сам ULP эмулятор не исполняет — это сказано в тексте. */
#include <inttypes.h>
#include <stdio.h>
#include "esp_attr.h"
#include "esp_err.h"
#include "esp_sleep.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "ulp.h"
#include "ulp_main.h"
extern const uint8_t ulp_main_bin_start[] asm("_binary_ulp_main_bin_start");
extern const uint8_t ulp_main_bin_end[] asm("_binary_ulp_main_bin_end");
RTC_DATA_ATTR static int data_count; /* медленная память RTC, заливается загрузчиком */
RTC_NOINIT_ATTR static int noinit_count; /* медленная память RTC, никто не трогает */
RTC_FAST_ATTR static int fast_count; /* быстрая память RTC, только ядро PRO */
void app_main(void)
{
if (esp_reset_reason() == ESP_RST_POWERON)
noinit_count = 0;
data_count++;
noinit_count++;
fast_count++;
printf("сброс %d: RTC_DATA = %d по %p, RTC_NOINIT = %d по %p, RTC_FAST = %d по %p\n", (int)esp_reset_reason(),
data_count, (void *)&data_count, noinit_count, (void *)&noinit_count, fast_count, (void *)&fast_count);
size_t bytes = ulp_main_bin_end - ulp_main_bin_start;
esp_err_t e = ulp_load_binary(0, ulp_main_bin_start, bytes / sizeof(uint32_t));
printf("ulp_load_binary: %s, файл программы %u байт\n", esp_err_to_name(e), (unsigned)bytes);
printf("переменные ULP: entry %p, counter %p, threshold %p, wakeups %p\n",
(void *)&ulp_entry, (void *)&ulp_counter, (void *)&ulp_threshold, (void *)&ulp_wakeups);
ulp_threshold = 5; /* порог — из C, программа ULP читает его сама */
printf("ulp_threshold = %" PRIu32 ", в слове памяти RTC 0x%08" PRIx32 "\n", ulp_threshold & 0xFFFF, ulp_threshold);
printf("ulp_set_wakeup_period 20 мс: %s\n", esp_err_to_name(ulp_set_wakeup_period(0, 20000)));
printf("esp_sleep_enable_ulp_wakeup: %s\n", esp_err_to_name(esp_sleep_enable_ulp_wakeup()));
printf("esp_sleep_enable_timer_wakeup 5 с: %s\n", esp_err_to_name(esp_sleep_enable_timer_wakeup(5ULL * 1000 * 1000)));
if (noinit_count < 2) { /* второй запуск — проверить, что пережило сброс */
fflush(stdout);
vTaskDelay(pdMS_TO_TICKS(100));
esp_restart();
}
if (noinit_count == 2) { /* второй запуск — ULP и глубокий сон */
printf("ulp_run: %s\n", esp_err_to_name(ulp_run(&ulp_entry - RTC_SLOW_MEM)));
printf("глубокий сон: esp_deep_sleep_start()\n");
fflush(stdout);
esp_deep_sleep_start(); /* отсюда не возвращаются: выход из сна — сброс */
}
printf("после сна: причина пробуждения %d, ulp_counter %" PRIu32 ", ulp_wakeups %" PRIu32 "\n",
(int)esp_sleep_get_wakeup_cause(), ulp_counter & 0xFFFF, ulp_wakeups & 0xFFFF);
printf("done\n");
}
main.S — программа сопроцессора ULP: считает запуски и будит ядра по порогу.
/* Раздел 157: программа сопроцессора ULP (ESP32, автомат FSM).
* Запускается таймером раз в период, пока основные ядра спят: считает запуски
* и, когда счётчик дошёл до порога, заданного из C, будит ядра. Регистры R0–R3
* 16-битные, память — 32-битные слова в медленной памяти RTC. */
#include "soc/rtc_cntl_reg.h"
#include "soc/soc_ulp.h"
.bss
.global counter
counter:
.long 0
.global threshold
threshold:
.long 0
.global wakeups
wakeups:
.long 0
.text
.global entry
entry:
move r3, counter /* r3 = адрес слова counter (в словах от начала памяти RTC) */
ld r0, r3, 0 /* r0 = counter */
add r0, r0, 1
st r0, r3, 0 /* counter = counter + 1 */
move r3, threshold
ld r1, r3, 0 /* r1 = threshold */
sub r2, r0, r1 /* counter − threshold: заём ставит флаг переполнения */
jump still_early, ov /* counter < threshold — ещё рано */
move r3, counter
move r0, 0
st r0, r3, 0 /* counter = 0 */
move r3, wakeups
ld r0, r3, 0
add r0, r0, 1
st r0, r3, 0 /* wakeups = wakeups + 1 */
stage_rst
try_wake:
stage_inc 1
jumps still_early, 20, gt /* ядра не готовы 20 попыток подряд — до следующего запуска */
READ_RTC_FIELD(RTC_CNTL_LOW_POWER_ST_REG, RTC_CNTL_RDY_FOR_WAKEUP)
and r0, r0, 1
jump try_wake, eq /* ядра ещё засыпают — подождать */
wake /* разбудить основные ядра */
still_early:
halt /* до следующего запуска по таймеру */
Дальше: раздел 158 — Bluetooth LE.