Сопроцессор ULP (ultra-low-power processor или процессор со сверхнизким потреблением) — это процессор, который остаётся в работе в режиме пониженного энергопотреблении Deep Sleep. В данном режиме основные два ядра контроллера не работают. Также данный процессор является программируемым автоматом конечного состояния (Finite State Machine или FSM).
Данный сопроцессор работает с памятью RTC_SLOW_MEM, в нём есть только 4 регистра и очень ограниченный набор инструкций, которых, как мы убедимся в дальнейшем, нам вполне будет достаточно для решения многих задач.
Вот некоторые характеристики сопроцессора
- содержит до 8 КБ SRAM для инструкций и данных;
- работает на частоте 8 мегагерц;
- работает как в обычном режиме, так и в режиме глубокого сна;
- способен разбудить цифровое ядро или послать прерывание ЦП;
- может получать доступ к периферийным устройствам, внутренним датчикам и регистрам RTC;
- содержит четыре 16-битных регистра общего назначения (R0, R1, R2, R3) для управления данными и доступа к ним;
- включает также один 8-битный регистр Stage_cnt, которым может манипулировать ALU и использовать в инструкциях JUMP.
Хоть инструкций и не очень много, знакомиться с ними, как я считаю, лучше всего по мере написания кода, так как практика позволяет лучше усваивать материал. Тем более первоначальное знакомство с сопроцессором и третьим ядром у нас было уже в самом первом уроке данного курса (вспоминаем синие ножки GPIO контроллера, которые остаются работать в спящем режиме). Также мы можем при помощи ULP управлять и отслеживать аппаратно не только уровни данных ножек, но также мы можем использовать ADC, датчик температуры контроллера и шину I2C. Конечно, в данном уроке мы этим не будем пользоваться. Для того чтобы хоть как-то начать работу с командами ULP FSM, мы попробуем для начала ввести процессор в режим DEEP SLEEP и через определённое время при помощи инструкций ULP его оттуда вывести, также немного поработать с циклами подсчёта количества входов в спящий режим.
Схема нашего урока будет простейшая — отладочная плата с контроллером ESP32, подключенная к шине USB компьютера
Проект для работы с ULP FSM будет настраиваться также по-особенному. Тем не менее с чистого листа мы его создавать не будем, а создадим его из проекта урока 4 по работе с кнопкой с именем BUTTON01 и назовём его ULP_FSM_DELAY.
Также на данном уроке мы попробуем немного другой способ работы с конфигуратором проекта. Для этого мы для начала удалим файл sdkconfig из каталога проекта, тем самым мы заставим данный файл сгенерироваться с настройками по умолчанию. Также в каталоге main удалим файл Kconfig.projbuild. Для того, чтобы добавить свои настройки и сделать это не вручную, нам надо будет создать файл с именем sdkconfig.defaults следующего содержания
1 2 3 4 5 6 7 |
# Set log level to Warning to produce clean output CONFIG_BOOTLOADER_LOG_LEVEL_WARN=y CONFIG_BOOTLOADER_LOG_LEVEL=2 CONFIG_LOG_DEFAULT_LEVEL_WARN=y CONFIG_LOG_DEFAULT_LEVEL=2 CONFIG_BOOTLOADER_SKIP_VALIDATE_IN_DEEP_SLEEP=y CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y |
Я думаю, что по содержанию здесь всё понято, что мы включаем и какие режимы используем. Также мы настраиваем размер флеш-памяти. Если у вас другой размер, то вы указываете свой размер, так как по умолчанию он составляет всего 2 мегабайта. Конечно же, вы можете настроить размер стандартно в самом проекте.
И вот в случае, когда сборщик проекта видит такой файл, то данные настройки применяются.
Также давайте создадим ещё один конфигурационный файл с именем sdkconfig.defaults.esp32 следующего содержания
1 2 3 |
# Enable ULP CONFIG_ESP32_ULP_COPROC_ENABLED=y CONFIG_ESP32_ULP_COPROC_RESERVE_MEM=1024 |
Файл с таким именем заставит сборщик проекта применить данные установки, если будет использоваться именно контроллер ESP32, а не в любом случае. Это делается на случай использования другого контроллера — ESP32C2, ESP32S2 и т.д.
Теперь открываем наш проект в ESpressif IDE, откроем файл main.c и полностью удалим весь код из тела функции app_main
1 2 3 4 5 |
//============================================================== void app_main(void) { } //============================================================== |
Попытаемся собрать проект.
Если всё нормально, то можно проверить наши кастомные настройки конфига
Создадим директорию
Создадим два ассемблерных файла с именами ulp_assembly_source_file.S и wake_up.S пока пустые в данной директории
Теперь надо подключить данные файлы к сборке. Откроем в каталоге main файл CMakeLists.txt и добавим в него следующие строки
1 2 3 4 5 6 7 |
register_component() set(ulp_app_name ulp_${COMPONENT_NAME}) set(ulp_s_sources "ulp/ulp_assembly_source_file.S" "ulp/wake_up.S") set(ulp_exp_dep_srcs "main.c") ulp_embed_binary(${ulp_app_name} "${ulp_s_sources}" "${ulp_exp_dep_srcs}") |
Думаю, тут тоже всё ясно.
Попробуем теперь собрать проект. Пока всё соберётся, так как ассемблерные файлы ещё нигде не используются.
Создадим также для удобства заголовочный файл main.h следующего содержания
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#ifndef MAIN_MAIN_H_ #define MAIN_MAIN_H_ //------------------------------------------------------------- #include <stdio.h> #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_sleep.h" #include "nvs.h" #include "nvs_flash.h" #include "esp_log.h" #include "soc/rtc_periph.h" #include "driver/gpio.h" #include "driver/rtc_io.h" #include "sdkconfig.h" #if CONFIG_IDF_TARGET_ESP32 #include "esp32/ulp.h" #endif #include "ulp_main.h" //------------------------------------------------------------- #endif /* MAIN_MAIN_H_ */ |
Подключим его в main.c, удалив перед этим подключение всех других заголовочных файлов
1 2 3 |
#include "main.h" //============================================================== void app_main(void) |
Проект опять нормально соберётся.
Подключим константы для использования точек начала и окончания ассемблерного кода
1 2 3 4 5 |
#include "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"); //============================================================== |
В функции app_main узнаем, каким образом у нас запустился контроллер — первоначальный старт или выход из сна, а также выведем это значение в терминал
1 2 3 4 |
void app_main(void) { esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause(); printf("cause %d\n", cause); |
Выше добавим функцию инициализации ULP, в которую мы будем попадать в случае первоначального старта контроллера
1 2 3 4 5 6 |
extern const uint8_t ulp_main_bin_end[] asm("_binary_ulp_main_bin_end"); //============================================================== static void init_ulp_program(void) { } //============================================================== |
Вызовем её в app_main
1 2 3 4 5 6 |
printf("cause %d\n", cause); if (cause != ESP_SLEEP_WAKEUP_ULP) { printf("Not ULP wakeup, initializing ULP\n"); init_ulp_program(); } |
Вернёмся в функцию init_ulp_program и загрузим ассемблерную программу в память RTC
1 2 3 4 5 |
static void init_ulp_program(void) { esp_err_t err = ulp_load_binary(0, ulp_main_bin_start, (ulp_main_bin_end - ulp_main_bin_start) / sizeof(uint32_t)); ESP_ERROR_CHECK(err); |
Изолируем 12 и 15 ножки для лучшего энергосбережения, а то вдруг к ним что-то подключено
1 2 3 4 5 6 |
ESP_ERROR_CHECK(err); #if CONFIG_IDF_TARGET_ESP32 rtc_gpio_isolate(GPIO_NUM_12); rtc_gpio_isolate(GPIO_NUM_15); #endif // CONFIG_IDF_TARGET_ESP32 |
Подавим загрузочные сообщения
1 2 |
#endif // CONFIG_IDF_TARGET_ESP32 esp_deep_sleep_disable_rom_logging(); // suppress boot messages |
Мы можем запрограммировать 5 периодов пробуждения — от 0 до 4. Делается это до входа в режим глубокого сна.
Запрограммируем 0й период на 20 милисекунд
1 2 |
esp_deep_sleep_disable_rom_logging(); // suppress boot messages ulp_set_wakeup_period(0, 20000); |
Запустим нашу программу
1 2 3 |
ulp_set_wakeup_period(0, 20000); err = ulp_run(&ulp_entry - RTC_SLOW_MEM); //Start the program ESP_ERROR_CHECK(err); |
Вот теперь проект точно не соберётся, так как пока что нет глобальной точки входа в нашу программу.
Любые глобальные метки, также переменные объявляются в ассемблерном файле и автоматически добавляются в заголовочный файл ulp_main.h. К данным меткам и переменным добавляется префикс ulp_. Так как мы используем указатель на метку ulp_entry, то значит в ассемблерном файле мы должны объявить метку entry и с данной метки и будет стартовать программа.
Поэтому перейдём в ассемблерный файл ulp_assembly_source_file.S и для начала подключим в нём необходимые заголовочные файлы
1 2 3 4 5 |
#include "sdkconfig.h" #include "soc/rtc_cntl_reg.h" #include "soc/rtc_io_reg.h" #include "soc/soc_ulp.h" #include "soc/sens_reg.h" |
Далее объявим секцию bss
1 2 3 |
#include "soc/sens_reg.h" .bss |
Затем секцию text и глобальную метку entry
1 2 3 4 5 |
.bss .text .global entry entry: |
Теперь проект нормально соберётся. Только от нашей программы не будет никакого толку, так как в ней ещё не выполняется ни одной инструкции.
Наша программа будет в течении 10 секунд ждать (ничего не делать), а затем будет пытаться выходить из сна. Если вам кажется, что это реализовать очень легко, то вы ошибаетесь. Не зная инструкций ничего не получится.
Занесём в регистр r0 число 10000 (так как у нас регистры работают с 16-разрядными величинами, то больше, чем 65535 мы число туда не запишем)
1 2 3 4 |
entry: //delay(10 sec) move r0, 10000 |
Поздравляю! Мы добавили в код первую ассемблерную инструкцию.
Инструкция move перемещает значение из исходного регистра или 16-разрядное значение со знаком в регистр назначения.
Далее организуем цикл. Для этого добавим обычную локальную метку
1 2 |
move r0, 10000 delay_ms_loop: |
Затем организуем обратный отсчёт. Для этого будем использовать инструкцию sub (вычитание), которая вычитает значение исходного регистра из значения другого исходного регистра или вычитает 16-битное значение со знаком из значения исходного регистра и сохраняет результат в целевой регистр.
1 2 |
delay_ms_loop: sub r0, r0, 1 |
Таким образом, мы вычли 1 из значения регистра r0 и в него же результат и записали.
Затем при помощи следующей инструкции wait мы подождём примерно одну микросекунду. Так как у нас сопроцессор работает на частоте 8 МГц, то нам потребуется примерно 8000 тысяч циклов для одной милисекунды. И так как на запуск самой инструкции тоже требуется какое то время, и также на следующую инструкцию перехода на метку по условию тоже, то подождём чуть меньше
1 2 |
sub r0, r0, 1 wait(7990) /* 1millsecond = 1000 microsecond */ |
Вот краткое описание команды
Затем нам нужно узнать, досчитал ли наш обратный счётчик до нуля или хотя бы до единицы, если ещё не досчитал, то перейти назад на метку, а если досчитал, то продолжить программу далее.
Для этого у нас есть несколько команд типа jump (переходов по условию). Бывает переход по абсолютному адресу jump, а есть также команда jumpr, которая осуществляет переход по относительному смещению. Также данная команда учитывает число, хранящееся в регистре r0. Вот эти условия
- EQ (equal) – переход, если значение в R0 равно пороговому значению
- LT (less than) – переход, если значение в R0 меньше порогового значения
- LE (less or equal) – переход, если значение в R0 меньше или равно пороговому значению
- GT (greater than) – переход, если значение в R0 больше порогового значения
- GE (greater or equal) – переход, если значение в R0 больше или равно пороговому значению
Вот описание команды
Применим данную команду в нашем коде
1 2 |
wait(7990) /* 1millsecond = 1000 microsecond */ jumpr delay_ms_loop, 1, GE |
Если значение в регистре r0 больше 1, то переходим на метку, если нет, то идём дальше
А дальше нам надо будет вывести наш контроллер из спящего режима.
Для этого будем использовать подпрограмму, которую напишем в отдельном файле wake_up.S.
Перейдём в данный файл и для начала подключим нужные заголовочные файлы
1 2 |
#include "soc/rtc_cntl_reg.h" #include "soc/soc_ulp.h" |
Добавим глобальную метку
1 2 |
.global wake_up wake_up: |
Нам надо прочитать бит RTC_CNTL_RDY_FOR_WAKEUP регистра RTC_CNTL_LOW_POWER_ST_REG для того, чтобы убедиться, что контроллер готов к пробуждению, так как возможно в данный момент выполняется та или иная инструкция.
Поэтому читаем регистр при помощи специальной функции
1 2 |
wake_up: READ_RTC_FIELD(RTC_CNTL_LOW_POWER_ST_REG, RTC_CNTL_RDY_FOR_WAKEUP) |
Затем при помощи логической команды AND узнаем, установлен ли у нас нужный нам бит
1 2 |
READ_RTC_FIELD(RTC_CNTL_LOW_POWER_ST_REG, RTC_CNTL_RDY_FOR_WAKEUP) and r0, r0, 1 |
Инструкция AND выполняет побитовое логическое И между исходным регистром и другим исходным регистром или 16-битным значением со знаком и сохраняет результат в регистре назначения.
Далее применим команду jump — переход по абсолютному адресу
1 2 |
and r0, r0, 1 jump wake_up, eq |
Вот описание команды
В нашем случае данная команда проверит равенство нулю содержимого регистра r0 и в случае успеха перейдёт назад на метку. И таким образом мы крутимся в цикле, пока не дождёмся установки нужного нами бита в регистре.
Немного подождём
1 2 |
jump wake_up, eq wait 1000 |
Мы можем заметить что в команде wait можно использовать скобки, а можно и не использовать.
Вызовем инструкцию пробуждения
1 2 |
wait 1000 wake //Wake up the SoC, end program |
Инструкция простая, вот её описание
А далее применяем инструкцию остановки сопроцессора, которая также перезапускает таймер пробуждения ULP (wake up timer), если он разрешен.
1 2 |
wake //Wake up the SoC, end program halt |
Данная инструкция также простейшая, без параметров
Вернёмся в файл ulp_assembly_source_file.S и вызовем нашу подпрограмму пробуждения и добавим комментарий места, где мы заканчиваем нашу программу
1 2 3 |
jumpr delay_ms_loop, 1, GE jump wake_up /* End program */ |
Теперь вернёмся в нашу основную программу в файл main.c и выше функции app_main добавим функцию, в которую будем попадать в случае пробуждения
1 2 3 4 5 |
//============================================================== static void update_count(void) { } //============================================================== |
Вызовем данную функцию в app_main, если у нас будет именно пробуждение, а не первоначальный старт
1 2 3 4 5 6 7 |
init_ulp_program(); } else { printf("ULP wakeup, saving pulse count\n"); update_count(); } |
Выведем в терминал сообщение, что мы будем сейчас уходить в спящий режим, задействуем его, и, соответственно в него уходим
1 2 3 4 5 |
update_count(); } printf("Entering deep sleep\n\n"); ESP_ERROR_CHECK(esp_sleep_enable_ulp_wakeup()); esp_deep_sleep_start(); |
Вернёмся в функцию update_count и объявим два указателя на символьные массивы
1 2 3 4 |
static void update_count(void) { const char* namespace = "cnt"; const char* count_key = "count"; |
Для чего нам эти указатели? Мы будем считать количество пробуждений и записывать его в энергонезависимую память NVS, чтобы данное количество сохранялось и при отключении контроллера.
Прочитаем и отобразим данное число
1 2 3 4 5 6 7 8 9 |
const char* count_key = "count"; ESP_ERROR_CHECK( nvs_flash_init() ); nvs_handle_t handle; ESP_ERROR_CHECK( nvs_open(namespace, NVS_READWRITE, &handle)); uint32_t cnt = 0; esp_err_t err = nvs_get_u32(handle, count_key, &cnt); assert(err == ESP_OK || err == ESP_ERR_NVS_NOT_FOUND); printf("Read count from NVS: %5d\n", cnt); |
Увеличим число и запишем его заново в энергонезависимую память
1 2 3 4 5 6 7 |
printf("Read count from NVS: %5d\n", cnt); //Save the new count to NVS cnt += 1; ESP_ERROR_CHECK(nvs_set_u32(handle, count_key, cnt)); ESP_ERROR_CHECK(nvs_commit(handle)); nvs_close(handle); |
Давайте проверим, как работает наша программа.
Только перед этим давайте очистим пространство, в котором расположена память NVS, а именно участок, расположенный с адреса 0x9000 размером 0x6000, при помощи утилиты esptool. Такую операцию мы делали в уроке по NVS
Соберём код, прошьём контроллер и посмотрим результат в терминале
Затем каждые 10 секунд мы увидим сообщение в терминале о выходе из режима глубокого сна и наращивающийся счётчик
Теперь давайте также посчитаем количество входов в режим глубокого сна. Хоть мы, конечно, и так уже определили это, но давайте попробуем наращивать счётчик прямо в режиме глубокого сна, а именно в программе, выполняющейся в сопроцессоре ULP. Тут возникает вопрос, а как передать это значение в основную программу с целью дальнейшего отображения в терминале. Для этого существуют глобальные переменные. Перейдём в файл ulp_assembly_source_file.S и объявим такую переменную в секции bss
1 2 3 4 5 |
.bss .global cnt cnt: .long 0 |
Соберём код и увидим, что объявление данной переменной появилось у нас в файле ulp_main.h
extern uint32_t ulp_cnt;
Вернёмся в файл main.c и обнулим данную переменную в функции init_ulp_program
1 2 |
ulp_set_wakeup_period(0, 20000); ulp_cnt = 0; |
Хотя она и так у нас инициализируется нулём в объявлении, но тем не менее полезно обнулить её физически.
Теперь нам надо будет увеличить её на единицу в файле ulp_assembly_source_file.S. Идём в данный файл и для начала занесём адрес данной переменной в регистр
1 2 3 4 |
jumpr delay_ms_loop, 1, GE //cnt += 1 move r1, cnt //r1 = mem[cnt] |
Инструкцией move мы уже пользовались, только в данном случае мы уже мы заносим в регистр не просто число, а адрес переменной. Делается это занесением во второй параметр имени метки данной переменной.
Далее мы используем следующую похожую инструкцию ld, при помощи которой мы занесём значение переменной в регистр
1 2 |
move r1, cnt //r1 = mem[cnt] ld r2, r1, 0 //r2 = cnt |
Инструкция ld загружает из памяти младшее 16-битное полуслово с адресом [Rsrc+offset/4] в регистр назначения Rdst:
Rdst[15:0] = Mem[Rsrc + offset / 4][15:0]
Вот описание инструкции
В нашем случае мы загрузили младшую часть числа, находящегося по адресу, находящемуся в регистре r1 без смещения (со смещением 0), а это именно адрес метки cnt, в регистр r2.
Далее мы увеличим число, находящееся в регистре r2, на 1
1 2 |
ld r2, r1, 0 //r2 = cnt add r2, r2, 1 //r2 += 1 |
В данном случае мы используем инструкцию add. Она работает практически также, как и использованная нами ранее инструкция sub, только здесь происходит операция не вычитания, а сложения. То есть инструкция add добавляет исходный регистр к другому исходному регистру или к 16-битному значению со знаком и сохраняет результат в целевом регистре.
Вот краткое описание инструкции
В нашем случае, мы к числу, находящемуся в регистре r2, прибавили 1 и занесли результат обратно в регистр r2.
Осталось нам занести данное число обратно в переменную cnt. А делается это с помощью следующей инструкции
1 2 |
add r2, r2, 1 //r2 += 1 st r2, r1, 0 //cnt = r2 |
Инструкция st, в отличие от инструкции ld, обладает обратным эффектом. Она сохраняет 16-битное значение Rsrc в младшем полуслове памяти с адресом Rdst+offset. Верхнее полуслово записывается текущим программным счетчиком (PC) (выражено в словах со сдвигом влево на 5 бит) ИЛИ с помощью Rdst (0..3):
Mem[Rdst + offset / 4]{31:0} = {PC[10:0], 3'b0, Rdst, Rsrc[15:0]}
Вот описание данной инструкции
В нашем случае мы младшее полуслово значения, находящегося в регистре r2 занесли в память по адресу, находящемуся в регистре r1, а именно по адресу метки cnt. Нам сейчас не важно, что находится в старшем полуслове переменной, мы его в дальнейшем обнулим. Можно конечно организовать и 32-разрядный счётчик, но это для нас пока лишние заморочки.
Вернёмся в файл main.c в функцию update_count и отобразим значение младшего полуслова нашего счётчика в терминале
1 2 |
printf("Read count from NVS: %5d\n", cnt); printf("Read count from RTC: %5d\n", ulp_cnt & UINT16_MAX); |
Соберём код, прошьём контроллер и посмотрим результат работы нашей программы в терминале
Всё отлично! У нас наращиваются оба счётчика — и тот, который не сбрасывается при отключении и перезагрузки, и тот, который наращивается в режиме пониженного энергопотребления Deep Sleep.
Итак, на данном занятии мы познакомились с работой сопроцессора ULP, работающего в глубоком спящем режиме, познакомились также с многими инструкциями данного сопроцессора, что позволило нам организовать переключение в глубокий спящий режим и выход из него в нормальный режим работы контроллера через определённое время.
Всем спасибо за внимание!
Предыдущий урок Программирование МК ESP32 Следующий урок
Недорогие отладочные платы ESP32 можно купить здесь:
На AliExpress Недорогие отладочные платы ESP32
На Яндекс.Маркет Недорогие отладочные платы ESP32
Логический анализатор 16 каналов можно приобрести (AliExpress) здесь
Смотреть ВИДЕОУРОК в YouTube (нажмите на картинку)
Смотреть ВИДЕОУРОК в Дзен (нажмите на картинку)
Добавить комментарий