157. Sleep and the low-power domain of the ESP32: power modes, RTC memory, wakeup, the ULP coprocessor
In section 10 the STM32 went to sleep in Stop and Standby, and the backup domain kept the clock and a couple of registers. On the ESP32 the same thing is richer and stricter. The radio alone eats hundreds of milliamps, so you save not on the core but on the time spent awake. The chip leaves deep sleep through a reset, as after power-on. And while the main cores sleep, a separate tiny processor can run — the ULP coprocessor — with its own program in its own assembly language.
The emulator helps least of all here: it models neither the current nor the ULP itself.
So the section relies on three things and says each time which one.
The program in QEMU (_verify/esp157/) checks the RTC memory, loading the
ULP program and exchanging variables with it. The build shows the ULP program
itself: size, addresses, symbols. The currents come from the ESP32 datasheet version 5.3
(the extract datasheet_power.txt), and the battery calculation follows the method of section 10.
Terms used in the documentation
Term Meaning Modem-sleep, Light-sleep, Deep-sleep, Hibernation radio sleep, light sleep, deep sleep, hibernation RTC domain the low-power domain: the RTC timer, memory and pins RTC fast / slow memory the fast and slow RTC memory, 8 KB each RTC_DATA_ATTR, RTC_NOINIT_ATTR, RTC_FAST_ATTR attributes: a variable in RTC memory wakeup source, wakeup cause what can wake the chip, what actually woke it ext0 / ext1 wakeup wakeup by one RTC pin / by several ULP coprocessor (FSM) the ultra-low-power coprocessor, a state machine stage counter the ULP stage counter for loops ulp_embed_binary building the ULP program and embedding it in the application wake stub code that runs right after wakeup, before the bootloader
157.1 Power modes: the datasheet figures
| Mode | What works | Current |
|---|---|---|
| Wi-Fi transmit 802.11b, +19.5 dBm | everything | 240 mA |
| transmit 802.11g / 802.11n | everything | 190 / 180 mA |
| Wi-Fi receive | everything | 95–100 mA |
| radio sleep (Modem-sleep), 240 MHz | cores, radio off | 30–68 mA |
| radio sleep, 160 MHz / 80 MHz | the same | 27–44 / 20–31 mA |
| light sleep (Light-sleep) | cores paused, RAM intact | 0.8 mA |
| deep sleep, ULP running | RTC domain and coprocessor | 150 µA |
| deep sleep, ULP 1 % of the time | the same, rare runs | 100 µA |
| deep sleep: RTC timer and memory | RTC domain only | 10 µA |
| hibernation | RTC timer only | 5 µA |
off (CHIP_PU low) |
nothing | 1 µA |
Between the top and the bottom row there are five orders of magnitude. Radio sleep turns on by itself when Wi-Fi is connected and the program is not transmitting: ESP-IDF switches the receiver off between the router's beacons. Light sleep is the counterpart of Stop on the STM32: the cores stop, RAM and registers stay intact, the program continues from the same line. Deep sleep is the counterpart of Standby: RAM is lost, the exit is through a reset, only the RTC domain lives. Hibernation switches off even the RTC memory.
157.2 RTC memory: what survives a reset
The RTC domain has its own memory: 8 KB of slow memory (seen by both cores and the ULP) and 8 KB of fast memory (the PRO core only). A variable is placed there with an attribute:
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 */
The program increments all three counters, prints them and restarts the chip
with 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
Code 1 is power-on, 3 is a software reset. Only RTC_NOINIT_ATTR survived
the reset: the bootloader loaded the RTC_DATA_ATTR and RTC_FAST_ATTR variables
again. They are promised to survive only on exit from
deep sleep — that is what they exist for. If you need to survive both a software
reset and the watchdog, take RTC_NOINIT_ATTR and clear it yourself
at power-on:
if (esp_reset_reason() == ESP_RST_POWERON)
noinit_count = 0;
The addresses tell a story too. In section 150 the RTC variable sat at the very start of
slow memory, at 0x50000000. Here it is at 0x50000200: the first 512
bytes are given to the ULP program (CONFIG_ULP_COPROC_RESERVE_MEM=512).
157.3 Wakeup sources
The ESP32 can wake from sleep by the RTC timer, by the level of one RTC pin
(ext0), by a combination of several pins (ext1), by a touch on a touch-sensor
input, by a signal from the ULP program, and from light sleep also by UART and
any GPIO. In ESP-IDF each source is enabled with its own
esp_sleep_enable_… call, sleep starts with esp_deep_sleep_start() or
esp_light_sleep_start(), and after wakeup esp_sleep_get_wakeup_cause()
tells you who woke the chip. The program enabled all three sources without errors:
ulp_set_wakeup_period 20 мс: ESP_OK
esp_sleep_enable_ulp_wakeup: ESP_OK
esp_sleep_enable_timer_wakeup 5 с: ESP_OK
The sleep itself starts like this:
esp_deep_sleep_start(); /* отсюда не возвращаются: выход из сна — сброс */
There is no return from this call: on the chip, power to the cores and RAM is switched off, and
after 5 seconds — or earlier, on a ULP signal — the chip starts from the bootloader.
The program learns about it from the reset reason and the wakeup cause; by that moment
RTC_DATA_ATTR holds everything it saved before sleep.
157.4 The ULP coprocessor: what it is
The ULP in the ESP32 is not a second core but a simple state machine: four 16-bit
registers R0–R3, a stage counter for loops and 8 KB of slow RTC memory, where
both its instructions and its data live. There are about twenty instructions: move, add and
subtract, shifts, jumps, reading and writing RTC peripheral registers,
reading the ADC, working with I²C, delay, wake and halt. The RTC timer starts
the program from the beginning once per given period; it does its job and
stops with the halt instruction until the next start.
The point is the current: the main cores sleep, and the ULP polls a sensor once every 20 ms and wakes the cores only if the value went past a threshold. The price per the datasheet is 150 µA instead of 10 while the coprocessor runs all the time, and 100 µA if it runs 1 % of the time: 10–15 times more than deep sleep, but 5–8 times less than light sleep. A program of 24 instructions run once every 20 ms works an even smaller share of the time, and its current is lower; by how much, only a measurement on the board shows (section 10.8).
157.5 A program for the ULP
The program is written in ULP assembly and lives in main/ulp/. This one counts
its runs and wakes the cores on every fifth:
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 — ещё рано */
The cores may not be asleep yet at the moment of wake. So before waking them
the program reads the RTC_CNTL_RDY_FOR_WAKEUP field and waits for it in a loop on
the stage counter, up to 20 attempts:
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 /* разбудить основные ядра */
The project build also builds the ULP program by itself. One line in
main/CMakeLists.txt does it — a call to 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
The file is 108 bytes: a 12-byte header (ulp\0, code offset 12, code size
0x60 = 96, data 0, bss 12) and 96 bytes of code — 24 instructions of 4 bytes each.
The words counter, threshold and wakeups are not in the file: they are bss, their
place in RTC memory is right after the code.
157.6 Exchanging data with the C program
The build writes the header ulp_main.h and the linker script ulp_main.ld:
each global symbol of the ULP program becomes a C variable with
the prefix 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 );
The address 0x50000060 is the start of slow memory plus 96 bytes of code. The C
program loads the ULP, writes the threshold and starts it:
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
The variables are 32-bit words, while the ULP registers are 16-bit. The ULP program
sees only the low 16 bits, so values from the ULP are read as
ulp_counter & 0xFFFF. ulp_run takes not an address but a word number from
the start of RTC memory — hence &ulp_entry - RTC_SLOW_MEM.
157.7 Battery: count the time awake
A sensor on the ESP32 wakes up once every 10 minutes, connects to Wi-Fi and sends a measurement; all of it takes 2 seconds at an average current of 100 mA. The rest of the time is deep sleep. The battery is 2000 mAh, the calculation is as in section 10:
| Option | Average current | Battery life |
|---|---|---|
| once every 10 minutes, sleep with the RTC timer (10 µA) | 0.343 mA | 243 days |
| once every 10 minutes, ULP running between measurements (150 µA) | 0.483 mA | 173 days |
| once a minute, sleep at 10 µA | 3.34 mA | 25 days |
| light sleep all the time | 0.8 mA | 104 days |
| no sleep, radio sleep at 80 MHz | 20 mA | 4 days |
In the first row sleep accounts for only 3 % of the consumption: 97 % is eaten by the two seconds of radio work. So on the ESP32 you win first of all by waking up less often and for less time: remember the channel and the router address in RTC memory, use a static IP instead of DHCP, send in batches. The ULP pays off when it wakes the cores noticeably less often than they would wake on their own. The ULP row in the table is an upper estimate: 150 µA means the coprocessor is on all the time; with the datasheet figure for running 1 % of the time, 100 µA, you get 0.433 mA and 192 days.
157.8 What the emulator cannot do
The emulator cannot handle deep sleep. On esp_deep_sleep_start()
the program crashes:
глубокий сон: esp_deep_sleep_start()
Guru Meditation Error: Core 0 panic'ed (LoadStorePIFAddrError). Exception was unhandled.
The sleep code writes to RTC domain registers that QEMU does not have — hence the exception on a write to a peripheral address. After the restart the program prints:
после сна: причина пробуждения 0, ulp_counter 0, ulp_wakeups 0
Cause 0 is ESP_SLEEP_WAKEUP_UNDEFINED: this was not an exit from sleep but a reset
after the panic. Hence the rules for experiments with sleep:
- Sleep is tested only on the board; in the emulator — everything up to the
esp_deep_sleep_start()call. - There are no currents at all: the figures of 157.1 come only from the datasheet; in a product you measure them following section 10.8.
- The ULP coprocessor does not execute: loading, addresses and starting return
ESP_OK, but theulp_counterandulp_wakeupscounters stay at zero.
157.9 Pitfalls
| Symptom | Cause |
|---|---|
after wakeup the program starts from app_main |
deep sleep is a reset; keep the state in RTC memory |
an RTC_DATA_ATTR variable was cleared after esp_restart |
it survives only deep sleep; for a reset use RTC_NOINIT_ATTR |
RTC_NOINIT_ATTR holds garbage at power-on |
clear it yourself on ESP_RST_POWERON |
| the ULP program "does not see" a variable | the variable is not declared .global — it is missing from ulp_main.h |
| the ULP reads the wrong value | the registers are 16-bit: write and read the low 16 bits |
ulp_run does not start from the beginning |
an address was passed, but a word number is needed: &ulp_entry - RTC_SLOW_MEM |
| deep-sleep current of hundreds of microamps | pull-ups and RTC pins, a power LED, a converter on the board |
| wakeup by pin does not work | ext0 and ext1 use only RTC pins, not any GPIO |
| the ULP wakes the cores, but they do not wake up | no esp_sleep_enable_ulp_wakeup(), or the cores were not asleep yet — wait for RDY_FOR_WAKEUP |
| the battery runs down in a week | a long Wi-Fi connection: DHCP and a network scan on every wakeup |
157.10 Summary
- Modes: transmit 240 mA, radio sleep 20–68 mA, light sleep 0.8 mA, deep sleep 10–150 µA, hibernation 5 µA, off 1 µA.
- Deep sleep = a reset; only the RTC domain lives: memory 8 + 8 KB, the timer, the RTC pins.
RTC_DATA_ATTRandRTC_FAST_ATTRsurvive deep sleep but notesp_restart;RTC_NOINIT_ATTRsurvives that too.- The 512-byte ULP reserve moves the RTC variables from
0x50000000to0x50000200. - ULP: 4 registers of 16 bits, a program of 24 instructions = 96 bytes, a file of 108 bytes; the
ulp_…variables at0x50000060,…64,…68. - Battery life is decided by the radio's working time: once every 10 minutes for 2 s — 243 days, once a minute — 25.
Full source code
main.c — RTC memory and exchanging variables with the ULP program.
/* Раздел 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 — the ULP coprocessor program: counts runs and wakes the cores at a threshold.
/* Раздел 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 /* до следующего запуска по таймеру */
Next: section 158 — Bluetooth LE.