"Несмотря на отмеченные недостатки, диссертация отвечает ..., а диссертант заслуживает ..."
Решил я приобщиться к модным трендам и купить себе (для побаловаться) платку на ESP32, тем более и повод выдался неординарный - на сайте одной из российских фирм, торгующих Arduino, обнаружился модуль ESP32 lolin32 OLED по цене ниже, чем на Aliexpress.
Примечание на полях (Пнп): не знаю, будет ли это рекламой, но вот ссылка на модуль https://iarduino.ru/shop/boards/wemos-esp32-oled.html, а фирму вы узнаете сами. Не то, чтобы мне было очень жалко 1200-990=210 рублей, но возможность получить платку сразу, без ожидания посылки дорогого стоит. Кстати, если заказывать доставку до пункта выдачи вне МКАД, то даже получается чуть дороже, чем доставка из КНР, хотя и быстрее - всего 3 рабочих дня. Но можно съездить в "столицу нашей Родины город-герой Москва" и получить заказ на следующий день, что я и сделал. Не очень понятно, зачем я это все совершил в пятницу, потому что в выходные с платой возится так и не начал, но охота пуще неволи.
Тем не менее, на следующей неделе приступил к освоению платы. Для начала включил плату в USB порт - экран засветился и появилась надпись, заявляющая о любви к ESP - начало неплохое. Нужно установить среду разработки программ, и я решил поступить, как нормальный самодельщик, тем более что мне это рекомендовали на сайте фирмы продавца - скачать Arduino. Читатели, испытывающие непреодолимое отвращение к этому термину, могут далее себя не мучить, все остальные могут нажать на транспарант ниже.
Итак, скачиваем IDE по ссылке, устанавливаем ... и все устанавливается, ставятся необходимые драйвера и все готово к работе - 5+ балов. Далее, в соответствии с той же инструкцией (снабженной картинками), устанавливаю дополнительный пакет для ESP32, настраиваю IDE на работу с моей платой и опять все получается – 5 баллов, но без плюса, поскольку конкретную плату нужно выбирать руками, а могла бы и определиться сама в момент подключения.
Берем из инструкции на сайте текст скетча - пришлось воспользоваться Ctrl-C/Ctrl-V (5-), запускаем компиляцию, получаем сообщение об отсутствии библиотек. Опять же по инструкции скачиваем библиотеки из репозитория на Github и устанавливаем. Все бы хорошо, но остается сообщение об отсутствии другой библиотеки, приходится смотреть текст «руками», находить нужную библиотеку и скачивать еще и ее, так что 5-.
Подключаю плату, прошиваю скетч и все заработало - на экран выведена новая картинка с названием фирмы - продавца товара. Есть маленькое замечание - прошивка не всегда проходит, но если нажать на кнопку "BOOT" во время попыток старта загрузки, то все получается гарантировано. Пытаюсь сделать по инструкции - предварительно перевести плату в режим загрузки. Нажатие кнопки "EN" (да, так тут называют кнопку сброса, видимо, надпись "RESET" не умещалась на шелкографии, хотя "RST" вполне могла бы, но кто может понять ход мысли китайцев) при нажатой кнопке "BOOT" действительно переводит плату в режим ожидания прошивки, но все равно не гарантирует результата, так что 4+, не больше.
Пнп: сразу сам отмечу действительно недостаток среды разработки "из коробки" - инкрементальную компиляцию так и не завезли, несмотря на наличие уже скомпилированного проекта, перед каждой загрузкой он собирается заново. Наверное, где то в глубинах меню настроек (а может, и в текстовых конфигурационных файлах) есть соответствующие флажки, но мне лениво их искать. В конце концов, для человека, который когда-то ждал результатов исполнения программы до следующего дня, подождать 25 секунд - не так уж и много, так что 4.
Пнп: еще недостаток - видимо, как следствие предыдущей особенности, часть файлов, реализующие операции на самом низком, близком к аппаратуре уровне, представлены не в исходниках, а в скомпилированном виде. Для обычного пользователя разницы никакой, а вот для исследователя глубин, которым я себя читаю, это представляет определенные неудобства, так что опять 4-. Но, с другой стороны, Инет пока никто не отменил, хотя на Гитхабе и забанили мой логин, зарегистрированный с mail.ru, но логин с gmail.com все еще работает, так что все не безнадежно.
В заключение вводной части: несмотря на отдельные отмеченные недостатки, не могу не порадоваться за будущих пользователей указанной платы под данной средой - менее, чем за полчаса получить с нуля работающую систему - совсем и совсем неплохо. Пнп: еще раз напомню, что я предложил всем ненавистникам Arduino не читать дальше ката, так что не стоит рассказывать в комментариях, насколько она плоха и насколько IDE ххх лучше всех остальных ("то вечный спор славян между собою"), тем более, что пост совсем не об этом и данная среда программирования используется просто как инструмент для дальнейших исследований - хоть и небезупречный, но вполне рабочий.
Ну а теперь собственно к исследованию платы, мы же не просто так ее запускали.
Для начала определим тактовую частоту - есть встроенная функция, которая ее возвращает, но мы же не будем доверять посторонним. Находим счетчик циклов (я бы сказал, тактов), считываем счетчик при помощи функции
long cpu_hal_get_cycle_count(void),
делаем задержку, выводим прошедшие микросекунды и выводим изменение счетчика, делим второе на первое и получаем чуть больше 240. Пнп: не забываем убедиться, что заказанная задержка приблизительно равна реально прошедшему времени.
Смотрим документацию на кристалл и видим обещанную частоту до 240 МГц, что хорошо согласовано с измерениями. Отметим, что мы всегда при такой методике будем получать значение, чуть большее, чем реальная частота, потому что счетчик тактов инкрементируется "по взрослому", а счетчик микросекунд (о нем позже) имеет пределитель, поэтому его значение всегда будет меньше (и лишь иногда равно) реально прошедшему времени.
Немного забежим и погрузимся глубже, нажимаем F12 на функции чтения и в файле cpu_hal.h видим #define cpu_hal_get_cycle_count() cpu_ll_get_cycle_count(), нажимаем F12 еще раз и в файле cpu_ll.h находим исходник:
static inline uint32_t IRAM_ATTR cpu_ll_get_cycle_count(void) {
uint32_t result;
RSR(CCOUNT, result);
return result;
};
Пнп: я позволил себе изменить стиль и поставить египетские скобки.
Нажимаем F12 еще раз и облом - "no definition found for CCOUNT", странно как то, ведь программа скомпилировалась, но обычный поиск находит в файле specreg.h определение:
#define CCOUNT 234.
Такая же ситуация и с определением RSR в файле xt_instr_masros.h
#define RSR(reg, at) asm volatile ("rsr %0, %1" : "=r" (at) : "i" (reg))
Все, мы достигли дна в исходниках и ниже лежит уже только описание на архитектуру собственно ядра. Пнп: хотя остаются вопросы к именам и расположению файлов (что не так важно при правильно настроенной среде программирования), и особенно к неявному преобразованию типа результата функции, выглядит данный фрагмент не так плохо.
Но для определения возможностей чипа мало знать частоту тактирования, надо знать эффективность ее использования. Для начала посмотрим, сколько тактов занимают элементарные команды и первой из них будет ... само считывание счетчика тактов, чтобы не привлекать лишних сущностей. Составляем простую программу и видим 1 такт.
uint32_t start=cpu_hal_get_cycle_count();
uint32_t stop=cpu_hal_get_cycle_count();
Serial.print(stop-start);
Пнп: ни фига себе, я внутренне был готов к 5-6, а тут так быстро, видимо результат прячется в регистр и там и остается до вывода.
Небольшие исследования показывают, что среда разработки в принципе поддерживает поиск по проекту ("в принципе у нас все есть, хотя не всегда, не везде и в недостаточном количестве") и в конце цепочки мы видим RSR(CCOUNT, result), так что, скорее всего, мы имеем дело не с чтением из памяти, а со специальной командой, что косвенно подтверждается значение CCOUNT (243). Пнп: да, надо бы посмотреть код на ассемблере, но как его сделать в данной среде программирования, я не знаю, так что будем судить по косвенным данным. Кроме того, можно пользоваться любимым сайтом godbolt.com для просмотра ассемблерного кода.
Для проверки добавляем в код еще 1 строку RSR(CCOUNT, stop) ( да, я знаю, что это нехорошо, но не смог удержаться) и видим, что время исполнения увеличилось на 1 такт, а размер кода вырос на 4 байта. Повторяем процесс несколько раз и убеждаемся, что команда занимает 4 байта и выполняется за 1 такт ядра, откуда предполагаю, что у меня в распоряжении камень с RISC-V ядром, которому свойственны 2 или 4 байтные команды, поскольку в Tenselica команды занимают 2 или 3 байта, хотя это неточно.
Далее нахожу в описании команд (Tensilica) команду RSR (Read Special Register) и убеждаюсь, что занимает она одно слово (3 байта) и номер регистра закодирован напрямую в команде, хорошее подтверждение выдвинутых предположений, хотя вопрос с видом ядром провисает. Сразу же пробую команду записи в данный регистр и получаю 0 тактов исполнения, все правильно, ведь мы теперь отнимаем 1 из результата.
Поскольку весьма маловероятно, чтобы программа состояла только из операций доступа к специальным регистрам, исследуем другие команды, начиная с простейших. Для начала вставляем между чтениями регистра счетчика инкремент целого числа и видим, что время исполнения 0 тактов. В общем то, ожидаемо, поскольку, если в компиляторе включена оптимизация (хотя бы -О1), а результат вычисления далее не используется, то фрагмент кода с инкрементированием будет просто выкинут из исполняемого кода.
Применяю любимый прием (нет, не делаю переменную volatilte, это плохое решение) - завожу глобальную переменную, перед операцией читаю ее в инкрементируемую переменную, после операции возвращаю в нее результат. Такой подход не позволяет компилятору с ать никаких предположений о содержании нашей переменной и заставляет работать с ней "по честному". Результат не заставил себя ждать - время исполнения инкремента локального объекта (скорее всего, размещенного в регистре) составляет 1 такт, что вполне ожидаемо.
uint32_t tmp;
tmp=global;
uint32_t start=cpu_hal_get_cycle_count();
tmp++;
uint32_t stop=cpu_hal_get_cycle_count();
global=tmp;
Serial.print(stop-start-1);
Пнп: почему квалификатор volatile не лучшее решение - компилятор несколько по-разному работает с такими переменными и с обычными. Например, для Tensilica дополнительно вставляется барьер памяти memw перед считыванием переменной (и это можно понять), а также перед записью результата (а вот это понять сложнее), что несомненно усложняет измерения времени исполнения.
Пробуем работу с оперативной памятью, для чего делаем локальную переменную статической (static), инкрементируем ее (расположенную в памяти, а не в регистре) и видим ... 490 тактов.
WTF???
Результат очевидно абсурдный, поэтому не верим своим глазам и повторяем фрагмент кода еще раз, получая ожидаемые 4 такта.
Пнп: вообще то я по опыту общения с ARM ожидал 3 команды и три такта - косвенное чтение со смещением относительно указателя сегмента данных в рабочий регистр, собственно инкремент (прибавление 1), косвенная запись в память. Но, наверное, сегмент данных нагружен (хотя странно) и добавляется формирование исполнительного адреса в промежуточном регистре. Могло быть и больше четырех тактов в случае загрузки сложной константы или если доступ в память длительный, но ожидалось именно 3. Чисто теоретически могло быть и меньше, для этого конвейер и предназначен, но это маловероятно, только при строго определенном обрамлении тестируемой последовательности команд.
Так, от сердца отлегло, я еще кое-что понимаю в микроконтроллерах, но откуда взялся первый результат 490 (а он никуда не делся). Повторяю в тексте программы несколько раз тестовый фрагмент (да, я так умею) и убеждаюсь, что время исполнения одной и той же команды меняется от 4 до 490 тактов, причем последовательность остается неизменной при каждом перезапуске системы (нажатие кнопки "EN").
Становится ясно, что иногда кто-то, помимо моего кода, вмешивается в процесс исполнения и запускает некий длительный процесс, время исполнения которого прибавляется к времени исполнения собственно команды. Будем искать.
Первый кандидат на роль такого вредителя - отладочный вывод в последовательный порт. Несколько смущает, что задержка имеет регулярный характер и не зависит от размера выдаваемой строки, но все может быть. Добавляю после отправки на печать сброс буфера последовательного канала - не помогает, добавляю еще задержку для гарантированного завершения печати - все равно не помогает, значит, это не отладочный вывод портит нам картину.
Вторая гипотеза - некий процесс работает в прерывании, например, шедулер. Вроде как маловероятно, чтобы, что то постороннее прилинковалось к моему коду, но чем черт не шутит. Пнп: хотя кто знает - пустой скетч занимает 239к байт памяти программ, так что туда много чего можно запихать. Проверить легко - защищаю процесс исполнения команды запрещением прерываний и не помогает. Значит, это вообще не прерывания. Пнп: есть еще вариант, что команда cli() прерывания на самом деле не запрещает, но это уже слишком много сомнительных допущений.
Третья группа гипотез - причина внешняя по отношению к ядру, исполняющему код.
Вспоминаю, что в кристалле имеется 2 основных ядра плюс еще одно вспомогательное и они делят общую память.
Гипотеза 3.1 - возможно, что второе ядро или ядро низкого потребления имеет свой собственный процесс, который захватывает общую память данных для каких-то целей и блокирует доступ к ней моей программы. Вроде, похоже, потому что команда чтения специального регистра всегда выполняется правильно (а вот это было заблуждение...). Привлекательность гипотезе добавляет тот факт, что тест, вынесенный в бесконечный цикл, исполняется без лишних задержек, значит, начиная с какого-то момента времени этот другой процесс завершается (о как я заблуждался...). Внимательное чтение документации гипотезу не подтверждает - второе ядро надо запускать явным образом, да и чтобы ядро пониженного потребления заработало, надо постараться, а я этого не делал.
Тогда гипотеза 3.2 - по каким-то причинам срабатывает валидация кэша данных и блокирует обращение к памяти. Ну как то странно, что она перестает срабатывать, начиная с некоего времени. Еще более странно, что кэш промах одного слова вызывает такую задержку, хотя, что мы знаем о механизме кэширования и размере страницы в данном конкретном случае. Но очень сомнительно, я бы такой кэш делать точно не стал и вряд ли в Espressif работают настолько странные люди.
Пнп: степень моего недоумения можно проиллюстрировать тем фактом, что я обратился за помощью к ChatGPT. Он сделал все предыдущие предположения - прерывания, блокировка другим процессом, кэш промах и добавил еще парочку - изменение частоты и переключение режимов потребления, но все не подходит.
И тут родилась гипотеза 3.3 - кэш памяти программы. Что, если программа на самом деле исполняется не из IRAM (куда переписывается из внешнего FLASH при старте), а из некоего кэша в памяти, в который время от времени подкачивается код. Эта гипотеза объясняет, почему через какое то время задержки прекращаются, но с треском разбивается о факт, что операции работы с регистром задержкам не подвержены.
Ну что же, "если факты противоречат теории, то тем хуже для фактов". Для проверки составляю программу с множеством чтений счетчика и на 11 последовательных чтениях подряд наблюдаю резкое увеличение задержки исполнения до 399. Значит, заключение о связи задержки с обращением к памяти данных было частным и ошибочным и главное возражение против гипотезы 3.3 снято.
Читаю еще раз внимательно документацию и обнаруживаю, что программа действительно исполняется из IRAM (еще и из IROM), но туда вовсе не загружается при старте. Вместо этого настраивается механизм MРU, при обращении к непрочитанному фрагменту кода возбуждается аппаратное прерывание, которое подкачивает соответствующий фрагмент (страницу) кода из FLASH в IRAM.
Многое становится понятнее. Во-первых, для возникновения задержки внутри измеряемого кода должна располагаться граница страницы, а это гораздо проще обеспечить, когда исследуемый фрагмент имеет длину 4 команды, а не одну. Вот почему я раньше этого эффекта не наблюдал, просто (не) повезло. Во-вторых, становится понятно, почему после определенного времени работы программы задержка исчезает - весь код оказывается подкаченным в оперативку и исключения перестают происходить.
Проверяется гипотеза очень просто - делается функция с атрибутом IRAM_ATTR, в нее выносятся измерения и, вуаля, задержка полностью пропала в первого раза и больше не появляется.
Единственное, что смущает - частота возникновения исключений, документация в данной части ясностью не отличается и нам остается путь эксперимента. Копируем фрагмент с 4х байтовыми командами, добиваемся получения исключения в обоих фрагментах путем вариации количества команд внутри измерения, далее уменьшаем второй фрагмент до исчезновения исключения, затем удаляем весь второй фрагмент и смотрим на изменение размера кода. Код уменьшился на 60 байт, так что, скорее всего, размер страницы подкачки составляет 64 байта. Тогда пересылка одного байта программы из FLASH в IROM занимает
(488/64= 64*7+40=64*6+104) семь либо шесть тактов, а остальное - накладные расходы на адресацию FLASН и коррекцию таблиц MPU.
Если загрузка проводится в не маскируемом прерывании в программном режиме, то 6 – не так и много с учетом 4х битового аппаратного доступа к FLASH, но если работает специальный аппаратный узел, то это многовато. В любом случае изменять что либо внутри микросхемы я не имею ни желания, ни возможности (интересно, что важнее) и остаюсь с единственной рекомендацией (и она совпадает с рекомендацией производителя микросхемы) - критичные по времени исполнения фрагменты кода должны снабжаться атрибутом IRAM_ATTR, все остальные методы не дают твердых гарантий (а мягких нам и не надо, они не гарантии вовсе).
Пнп: дальнейшие странствия по описанию микросхемы и исходным кодам привели к обнаружению регистра, задающего частоту обращения к Flash памяти и она равна 80 МГц. Тогда на собственно считывание двух ниблов, формирующих 1 байт, уйдет 3*2=6 тактов и это вполне согласуется с предположением о 6 тактах на пересылку байта + 104 (вход в прерывание, настройка доступа к памяти, коррекция таблицы защиты, выход из прерывания).
Почему именно такой размер страницы подкачки - в принципе понятно, большую страницу долго грузить, меньшую придется грузить чаще. Документация намекает, что это параметр (размер страницы MPU) можно менять от 64 до 2048, но вряд ли стоит это делать без детального анализа. В любом случае, если Ваша программа может уместиться в оперативной памяти кода, рано или поздно она туда будет подгружена и станет работать без лишних задержек, за исключением самого первого выполнения цикла, когда Вам ничего не гарантируется. Да и в дальнейшем возможны сюрпризы, ведь мы толком не знаем, как работает механизм вытеснения страниц кода, поэтому единственная гарантия быстрого выполнения кода - уже упомянутый атрибут.
Но тут я обратил внимание на то, что объем текста уже явно превзошел планируемое значение и продолжу рассказ об исследованиях в следующем посте, если читатели правильно ответят на предлагаемый опрос.
Оцените прочитанный материал:
Очень интересно, хочу продолжения "автор, пиши еще"
Неплохо, почитаю, если будет продолжение.
Интересные факты тривиальны, а нетривиальные неинтересны, ну напиши, если хочешь.
Совершенно неинтересно, "автор, выпей яду"