Продолжение предыдущего урока о визуализации параметров на круглом дисплее RP2040-LCD-1.28 в виде стрелочных приборов. В нем оптимизируем скорость вычислений, научимся применять прерывание по таймеру и использовать 2 ядра на RP2040, подключим к устройству энкодер, создадим проект прибора с двумя стрелками.
В уроке затронуты самые разные темы. Можно рассматривать его, как обучение программированию RP2040 в среде Arduino IDE.
Предыдущий урок Список уроков Следующий урок
При реализации конкретной задачи по принципам, изложенным в предыдущем уроке, проявились следующие недостатки, потребовались некоторые доработки.
- Картинка выводится на экран дисплея недостаточно быстро.
- Кроме вывода на экран изображения стрелочного прибора необходимо выполнять много других задач, требующих значительных вычислительных ресурсов. Например, измерение и цифровую фильтрация аналоговых сигналов. При наличии в микроконтроллере RP2040 двух ядер логично на одном реализовать визуализацию, а второе использовать для основных вычислений.
- Для установки значений параметров практически в любом устройстве необходим инкрементальный энкодер. В предыдущем уроке о программной поддержке энкодера я не рассказывал.
- В любой достаточно сложной программе не обойтись без использования прерывания по таймеру. Хотя бы для сканирования кнопок и контактов энкодера. Организация прерывания по таймеру в микроконтроллере RP2040 - еще один вопрос, не затронутый в предыдущем уроке.
Решению этих проблем посвящен объемный текущий урок. Как сопутствующую задачу рассматриваю обучение программированию RP2040 в среде Arduino IDE.
И отдельно хотел бы выделить рассказ об оптимизации скорости вычислений в формате с плавающей запятой. Тема, которую я когда-то затрагивал, но при решении задачи визуализации она проявилась, как абсолютно необходимая.
Давайте последовательно решать эти проблемы.
Оптимизация скорости вычислений при визуализации параметров в виде стрелочных приборов.
Сначала надо определиться какие операции занимают сколько времени.
За основу я взял первый скетч предыдущего урока с изображением рыбы вместо стрелки. Картинка рыбы содержит больше непрозрачных пикселей, чем картинка стрелки.
Добавил метки времени между основными операциями визуализации. Вот скетч с измерением времени на выполнение отдельных операций.
Получились следующие результаты.
Операция | Время на выполнение |
Загрузка фоновой картинки | 23 мс |
Вывод значения напряжения в числовом виде | 2 мс |
Вывод картинки рыбы с поворотом | 290 мс |
Перегрузка окончательной картинки в дисплей | 216 мс |
Общее время формирования изображения на дисплее | 531 мс |
Больше 0,5 секунд на формирование кадра изображения! Давайте уменьшать.
Загрузка фоновой картинки происходит в следующей строке
for( int i=0; i < Imagesize; i++ ) *((unsigned char *)BlackImage + i) = scale[i]; // фоновая картинка
Данные по байтам считываются из массива с картинкой scale в область памяти BlackImage.
Микроконтроллер у нас 32х разрядный. Если перегружать 16ти разрядными словами, должно получиться быстрее.
for( int i=0; i < 240 * 240; i++ ) *(BlackImage + i) = * ((uint16_t *)scale + i); // фоновая картинка
Так и есть.
Время на загрузку фоновой картинки стало 15 мс, было 23.
Курочка по зернышку клюет, как говорила одна моя знакомая, взяв 6 взяток при верной восьмерной в преферансе.
Значения напряжения в числовом виде выводится за 2 мс. Оптимизация этой операции не особенно скажется на окончательном результате. Оставим ее в покое.
Оптимизация времени выполнения поворота картинки.
При текущей реализации эта операция самая длительная 291 мс. А картинок может быть несколько. В нашем проекте 2 стрелки.
Основное время операции уходит на вычисление координат для 230400 точек (480*480).
Вычисления производятся в формате с плавающей запятой.
x= ((float)(i - 240) * cosAlfa + (float)(j - 240) * sinAlfa + 240.5) / 2;
y= (((float)(i - 240) * sinAlfa * -1) + (float)(j - 240) * cosAlfa + 240.5) / 2;
Операции с плавающей запятой – это пожиратели вычислительных ресурсов процессоров.
У нас RP2040 – очень быстрый 32х разрядный микроконтроллер с тактовой частотой 133 мГц. Тем не менее, на вычисление координат одной точки уходит 291000 / 230400 = 12,6 мкс.
Простая сумма двух чисел, которая при целочисленном формате выполняется за одну машинную команду, в формате с плавающей запятой требует:
- денормализацию мантиссы (сдвиг 24х разрядных чисел);
- сумму мантисс;
- нормализацию мантисс (сдвиг 24х разрядных чисел);
- коррекцию порядка.
По просьбе одного любопытного молодого человека я написал статью на тему: Переход от вычислений типа float к int (целочисленным)
Прочитав ее, он ответил: “Спасибо информативно, но пока пытаюсь понять. Нужно немного времени. Я, честно говоря, не знаком с таким типом вычислений. Перечитал несколько раз.”
Не уверен, что кто-нибудь еще дочитал статью до конца. Тема непопулярная. Большинство считает, что быстродействие современных микроконтроллеров бесконечно.
Но вот задача, в которой даже высокопроизводительный микроконтроллер требует оптимизации вычислений.
Кто хочет разобраться в деталях, прочитайте статью. А я коротко поясню, как поступил в данной задаче.
Итак, при вычислении тригонометрических функций для матрицы поворота переведем их в формат с фиксированной запятой. Эти вычисления производятся 1 раз за кадр изображения, поэтому время выполнения их некритично.
int16_t cosAlfa= (int16_t)(cos(PI * 2 * (1.25 - turn)) * 16384.); // << 14
int16_t sinAlfa= (int16_t)(sin(PI * 2 * (1.25 - turn)) * 16384.); // << 14
Так как их значения тригонометрических функций лежат в диапазоне -1 … 1, выбираем следующий формат.
15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
знак | 1 | -1 | -2 | -3 | -4 | -5 | -6 | -7 | -8 | -9 | -10 | -11 | -12 | -13 | -14 |
В формате с плавающей запятой мы умножили число на 214 (16384), тем самым 14 дробных разряда мантиссы перевели в целочисленный формат.
Вычисление координат производим в формате с фиксированной запятой, для процессора, который не знает, где запятая, это целочисленное вычисление.
uint32_t x= ((i - 240) * cosAlfa + (j - 240) * sinAlfa + 3940352 ) >> 15;
uint32_t y= (( (j - 240) * cosAlfa - (i - 240) * sinAlfa ) + 3940352 ) >> 15;
Для того, чтобы получить целые значения координат, в конце вычислений мы сдвинули результат на 14 разрядов вправо и еще на один разряд, чтобы реализовать деление на 2, заданное в исходной формуле.
Полный вариант такого принципа вычислений можете посмотреть по ссылке.
А вот результат.
Время на вывод картинки рыбы с поворотом стало 54 мс, а было 291 мс. Т.е. время вычисления уменьшилось более чем в 5 раз.
Остается перегрузка окончательной картинки в дисплей. Реализуется стандартной функцией.
LCD_1IN28_Display(BlackImage);
Вроде, делать нечего, надо смириться. Но, время приличное 215 мс.
Меня периодически обвиняют в нарушении правил системного программирования. То я к свойству класса обращаюсь напрямую, а не через метод. То в регистры микроконтроллера что-то записываю непосредственно при наличии функций работы с ними. Но мы работаем с микроконтроллерами, вычислительные ресурсы которых ограничены. Поэтому, правила я нарушаю не всегда, не из приверженности философии нигилизма, а из-за необходимости реализовать практическую задачу.
В общем, я залез в функции стандартной библиотеки и, как следствие, получил следующий результат.
Время на перегрузку картинки в дисплей стало 46 мс, было 215 мс. Сократилось почти в 5 раз.
Общее время формирования изображения на дисплее стало 117 мс. Перед оптимизацией было 531 мс.
Рыба стала крутиться значительно быстрее.
Использование двух ядер на RP2040.
В среде Arduino IDE это делается просто.
void setup() {
// инициализация переменных, установка режимов для первого ядра
}
void setup1() {
// инициализация переменных, установка режимов для второго ядра
}
void loop() {
// код, исполняемый на первом ядре
}
void loop1() {
// код, исполняемый на втором ядре
}
На первом ядре мы будем вычислять данные, а их визуализацию сделаем на втором ядре.
Для передачи данных между процессами на разных ядрах я создал структуры.
struct displayParameters {
float turnKoeff1; // коэффициент поворота картинки 1
float turnKoeff2; // коэффициент поворота картинки 2
float value; // значение на экране
uint8_t mode; // режим отображения
};
struct displayParameters preparationData; // подготовленные данные
volatile struct displayParameters transferData; // передача данных
В объявлении структуры задал 2 коэффициента поворота картинок (их может быть две), значение, которое надо выводить внизу экрана и переменную mode – режим.
Последняя переменная может использоваться для изменения алгоритма вывода данных на экран. Но ее обязательная функция – синхронизация процессов вычисления (ядро 1) и визуализации (ядро 2).
Программы на ядрах работают в асинхронном режиме. Поэтому весьма вероятно, что программа первого ядра успеет изменить только часть данных в момент их чтения программой второго ядра. В этом случае второе ядро будет использовать частично новые, частично старые данные. Если применять умные слова, то нарушается атомарность операции.
Алгоритм связи между программами разных ядер такой.
- Программа вычисления данных (ядро 1) вычисляет необходимые для визуализации данные и формирует их в структуре preparationData.
- Далее она дожидается, пока переменная mode структуры transferData станет равной 0. Это означает, что программа второго ядра считала все данные структуры. И, возможно, работает с ними, возможно, ждет новых данных от первого ядра.
- Программа вычисления данных (ядро 1) перегружает данные из структуры preparationData в transferData. После этого она может заниматься новыми вычислениями и формировать данные в структуре preparationData.
- На втором ядре в цикле опрашивается переменная transferData.mode и, при ненулевом значении (есть новые данные), обрабатывает их и выводит на экран.
- Затем программа визуализации (ядро 2) сбрасывает переменную transferData.mode, говоря первому ядру, что можно загружать новые данные.
Таким образом, происходит параллельная работа вычисления данных и визуализации. Операция передачи данных между ядрами становится атомарной.
У меня несколько упрощенный вариант. Вычисления синхронизированы с визуализацией.
void loop() {
if( transferData.mode == 0 ) {
// вычисление данных
preparationData.turnKoeff1 = MIN_TURN + (voltage / 6.) * (MAX_TURN - MIN_TURN);
preparationData.value= voltage;
preparationData.mode= 1;
transferData.turnKoeff1 = preparationData.turnKoeff1;
transferData.value = preparationData.value;
transferData.mode = preparationData.mode;
. . . . .
}
}
void loop1() {
if( transferData.mode != 0 ) {
// визуализация
transferData.mode=0;
}
Полная реализация программы с использованием двух ядер есть здесь.
Прерывание по таймеру в RP2040.
Я не знаток программирования RP2040, этот микроконтроллер знаю плохо.
Начал искать в интернете примеры реализации прерывания по таймеру RP2040 в среде Arduino IDE. Оказалось это не так просто. Для Ардуино вариантов работы с таймером RP2040 не много.
Приемлемым посчитал способ с использованием программного интерфейса приложения низкого уровня (Low-level Timer Hardware API) для этого микроконтроллера. Arduino IDE поддерживает его функции.
Таймер микроконтроллера RP2040 представляет собой 64х разрядный счетчик, который считает импульсы периодом 1 мкс (частота 1 мГц). К нему через цифровые компараторы подключены 4 32х разрядных регистра (Alarms - регистры тревоги). При совпадении значения счетчика и любого регистра тревоги вырабатывается соответствующее прерывание.
В официальной документации для RP2040 есть пример, в котором по каждому прерыванию alarm-регистра считывается текущее значение счетчика, к нему прибавляется время периода прерывания и новое значение заносится в регистр тревоги.
Попробовал, запустил с периодом 500 мкс. В прерывании устанавливаю флаг, в основном цикле считаю. Когда набегает секунда (2000 тиков) вывожу в последовательный порт звездочку.
if( flg == true ) {
flg=false;
timeCounter++;
if( timeCounter >= 2000 ) {
timeCounter=0;
Serial.println("*");
}
}
Неточный получился таймер. Время заметно отстает.
Это и понятно. Отрезок времени с начала прерывания до инициализации нового прерывания не учитывается. А еще могут вмешиваться другие прерывания.
Если хочешь, чтобы что-то было сделано хорошо, сделай это сам.
Изменил алгоритм так, что новое время, которое заносится в регистр тревоги, вычисляется не из текущего значения счетчика, а из предыдущего значения регистра тревоги. Прерывание по таймеру заработало идеально.
Разница между временем соседних секунд - это не точная работа Serial. Главное, что ошибка времени не накапливается, как в предыдущем примере.
Вот как выглядит мое решение задачи. Объявление переменных я не указал.
#include <hardware/timer.h>
#include <hardware/irq.h>
static void alarm_irq(void) {
hw_clear_bits(&timer_hw->intr, 1u << ALARM_NUM);
currentAlarm += TIMER_INTERRUPT_PERIOD;
timer_hw->alarm[ALARM_NUM] = currentAlarm;
flg=true;
}
void setup() {
hw_set_bits(&timer_hw->inte, 1u << ALARM_NUM);
irq_set_exclusive_handler(ALARM_IRQ, alarm_irq);
irq_set_enabled(ALARM_IRQ, true);
currentAlarm= (uint32_t)(timer_hw->timerawl + TIMER_INTERRUPT_PERIOD);
timer_hw->alarm[ALARM_NUM] = currentAlarm;
}
Полный скетч с прерыванием по таймеру RP2040 можно загрузить по ссылке.
Использование инкрементального энкодера с дисплеем RP2040-LCD-1.28.
Мы выводим параметры на круглом дисплее в виде стрелочных приборов. Использовать для установки параметров кнопки или клавиатуру никак не вписывается в эту концепцию ни конструктивно, ни с точки зрения дизайна. Вариантов, кроме энкодера, нет.
Но после организации прерывания по таймеру особых проблем с энкодером тоже нет.
Я использовал свою библиотеку Encod_er.h из урока 55.
Для проверки. Подключил библиотеку
#include <Encod_er.h>
Создал экземпляр класса. Для физического подключения энкодера использовал выводы 0 и 1.
Encod_er encoder( 0, 1, 4);
В обработчик прерывания по таймеру включил метод сканирования состояния энкодера.
static void alarm_irq(void) {
. . . . .
encoder.scanState();
}
После этого стали доступны свойства и методы, определяющие состояние энкодера.
Для проверки вывожу позицию энкодера при ее каждом изменении.
void loop() {
currentPosition= encoder.read();
if( currentPosition != prevPosition ) {
Serial.println(currentPosition);
prevPosition= currentPosition;
}
. . . .
}
Вот скетч для тестирования энкодера.
Вольтметр и рыба, управляемая энкодером.
Осталось собрать все вместе и получить такую картинку.
Вот окончательный скетч.
Пояснять особенно нечего.
На первом ядре:
Измеряется напряжение.
Если кнопка энкодера удерживается нажатой, то анализируются признаки encoder.timeRight и encoder.timeLeft, которые показывают, с какой скоростью произошел поворот энкодера. В зависимости от их значений положения рыбы-стрелки изменяется с разной скоростью.
Медленное вращение позволяет устанавливать стрелку точно. При быстром вращении стрелка перескакивает с большей скоростью.
if ( encoder.timeRight < 10 ) voltageSet += 0.5;
else if ( encoder.timeRight < 40 ) voltageSet += 0.1;
else voltageSet += 0.01;
В зависимости, нажата ли кнопка энкодера, решается, какое значение выводить внизу экрана измеренное или заданное.
if( encoderButton.flagPress == true ) {
preparationData.value= voltageSet;
preparationData.mode= 2;
}
else {
preparationData.value= voltage;
preparationData.mode= 1;
}
При визуализации на втором ядре накладываются сразу 2 картинки.
drawTurnImage(BlackImage, arrow_0, transferData.turnKoeff1, 65535); // стрелка
drawTurnImage(BlackImage, fish_0, transferData.turnKoeff2, 248); // рыба
Работа устройства выглядит так.
Я вращаю переменный резистор, и вслед за этим поворачивается стрелка.
Когда нажимаю кнопку энкодера, внизу экрана измеренное значение меняется на заданное.
Поворот энкодера при нажатой кнопке энкодера вызывает поворот рыбы и изменение заданного значения.
Регулярное сканирование АЦП.
Чтобы еще приблизить задачу к практическому применению необходимо подкорректировать еще одну проблему.
В предыдущих скетчах измерение аналогового сигнала производилось в цикле loop().
for( int i=0; i < 256; i++ ) avCode += analogRead(A0);
Просто считывались подряд 256 значений АЦП, а затем усреднялись, пересчитывались в напряжение.
Самое распространенное решения в программах Ардуино, но не самое лучшее. Хотя, обычно еще хуже. Просто одно измерение делают.
- Во первых на время чтения АЦП работа в цикле loop() подвисает.
- И еще, выборка нескольких значений АЦП необходима для фильтрации аналоговых помех. Главный источник помех – это питающая сеть частотой 50 Гц. Значит, выборки надо производит через равные интервалы времени и усреднять через время кратное периоду сети, т.е. 20 мс.
Удобнее всего это делать в фоновом режиме в прерывании по таймеру.
static void alarm_irq(void) {
. . . . .
avCode += analogRead(A0);
avCounter++;
if( avCounter >= 120 ) {
avRes = avCode;
avCounter=0;
avCode=0;
}
}
Через каждые 120 * 0,5 = 60 мс в переменной avRes появляется новый код АЦП. В процессе вычисления данных он пересчитывается в напряжение.
Вот окончательный скетч проекта.
На основе описанных программных модулей легко синтезировать разного рода измерители с отображением параметров в виде стрелочных приборов.
Предыдущий урок Список уроков Следующий урок
Вопрос по таймерам. В Atmega328P есть возможность работы таймера в качестве счетчика внешней частоты. А в RP2040 есть такая возможность? Например на пин 15 подаем частоту 50 мгц делим ее таймером на 10 и на выводе 14 получаем меандр частотой 5 мгц. Если знаете как это сделать просьба подсказать или скинуть ссылку. Пишу код на Arduino Ide.