Embedded HandbookApps
All sections › XVIII. ESP32: the second microcontroller
ESP32 · level intermediate · 15 min read
Open in the interactive handbook

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

ESP32 power modes from Wi-Fi transmit at 240 mA down to a powered-off chip at 1 µA: what stays on in each mode; deep sleep is left through a reset

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.

Slow RTC memory: ULP code of 96 bytes from 0x50000000, the words ulp_counter, ulp_threshold and ulp_wakeups from 0x50000060, the 512-byte reserve and the RTC_DATA_ATTR and RTC_NOINIT_ATTR variables from 0x50000200


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.

Average current and life of a 2000 mAh battery in five modes: the share of radio work and the share of sleep


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:


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

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.

Open in the interactive handbook