Сопроцессор 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 можно купить здесь Недорогие отладочные платы ESP32
Недорогие отладочные платы ESP32/ESP32-C3/ESP32-S3 можно купить здесь Недорогие отладочные платы ESP32
Логический анализатор 16 каналов можно приобрести здесь
Смотреть ВИДЕОУРОК в RuTube (нажмите на картинку)
Смотреть ВИДЕОУРОК в YouTube (нажмите на картинку)
Смотреть ВИДЕОУРОК в Дзен (нажмите на картинку)
Добавить комментарий