STM32 - TFT дисплей часть первая





Подключение дисплеев построенных на базе драйвера ILI9341 к микроконтроллерам stm32 по шине SPI. Пример как всегда сделан для платы BluePill.

На ILI9341 делаются дисплеи разной диагонали, но у всех одно и то же разрешение 320 на 240 пикселей. Размеры пикселя зависят от размера диагонали. Цвет 16-ти битный — два байта на каждый пиксель (RGB565). Существуют дисплеи у которых данные выводятся по 8-ми или 16-ти битной параллельной шине, но это другое, здесь будет рассматриваться работа по SPI.

про 8-ми битную параллельную шину
По SPI данные передаются байтами бит за битом, это обычный способ передачи данных, такой же как у USART'а или I2C. То есть для передачи одного бита нужен один условный такт, соответственно чтоб передать байт нужно сделать восемь тактов. В отличии от SPI, параллельная шина может «закидавать» в дисплей все восемь бит за один условный такт, где каждый бит «летит» по своему проводу. Такой способ подразумевает более высокую скорость передачи данных, но требует значительно большее количество пинов микроконтроллера.


Обычно эти дисплеи оснащаются ещё и тач-панелью, которая тоже работает по SPI. За работу «тача» отвечает отдельный чип, чаще всего это что-то вроде TSC2046 (буквы могут быть другие). И вот тут есть интересный момент.

Суть заключается в том, что в дисплей нам нужно передавать данные как можно быстрее (чтоб картинка быстрее отрисовывалась), поэтому мы указываем максимально возможную скорость SPI (для BluePill это 18Мбит/с, какую максимальную скорость поддерживает сам дисплей я не знаю, вроде как до 50Мбит/с). Тач-панель мы можем подключить к этому же SPI, а пинами CS (Chip Select) выбирать с чем мы сейчас работаем, с дисплеем или «тачем». Однако драйвер «тача» корректно работает только на скорости 1-2Мбит/с, если указать больше, то начинаются проблемы. Соответственно, если мы подключаем дисплей и «тач» к одному SPI, то нам нужно будет переключать скорость SPI прямо «на лету».

Другой вариант, это подключить дисплей и «тач» на разные SPI. С одной стороны кажется что так проще, но с другой, вам может понадобится флешка (подразумевается отдельный чип флеш-памяти) или SD карта для хранения изображений, и обе эти штуки тоже работают по SPI, а у BLuePill всего два таких интерфейса.

Чтоб было понятно, картинка в 16-ти битном цвете, размером во весь экран (320х240) весит 153600 байта. То есть ни о каком хранении её внутри простенького микроконтроллера и речи быть не может.

Следуя из выше сказанного, лучше всего подключить дисплей и «тач» на один SPI, так как они прекрасно уживаются вместе и не мешают друг другу при правильной организации программы, а второй SPI оставить для хранилища (флешка или SD карта), или ещё для чего-то.

Теперь к вопросу почему не стоит вешать на один SPI дисплей и хранилище. Дело в том, что процесс вычитывания с флешки/карты и вывод на дисплей занимают определённое время. Считывать данные из хранилища нужно по кусочкам, создав промежуточный буфер, например 10Кб (при условии что у BluePill всего 20Кб RAM, и какая-то часть из них нужна самой программе), и сначала вычитывать в этот буфер данные, а потом отправлять его в дисплей. То есть со всеми накладными расходами, картинка на весь экран будет выводиться около 200мс, а это как вы понимаете 5 FPS

Исходя из выше сказанного, чтоб вывести данные хранящиеся в SPI-флешке на экран как можно быстрее, нужно во-первых, подключать хранилище и дисплей к разным интерфейсам, и во-вторых использовать DMA таким образом чтоб данные одновременно читались из флешки и отправлялись в дисплей. То есть заполняем половину буфера и начинаем её отправлять, пока первая половина отправляется, вторая заполняется, и т.д. В результате мы сможем получить максимально возможную скорость — картинка на весь экран будет выводится за ~70 мс, что равно 14 FPS. Это относится к SPI-флешке, работа с SD-картой будет происходить дольше так как там используется FATFS, которая требует дополнительного времени.

Расчёты для работы с SPI-флешкой: при скорости SPI 18Мбит/с, за 1 мс можно передать 2250 байт, соответственно 153600 байт передастся за 68 мс (153600 / 2250 = 68), плюс 2 мс на накладные расходы. Ну и 1000мс / 70мс = 14 FPS.

Если картинка не на весь экран, то естественно она будет считываться и выводится быстрее, а значит мы получим больше FPS. Вот пример…


Зацикленный вывод тринадцати картинок размером 200х214. Каждая картинка выводится за 41 мс, то есть 24 FPS. Размер экрана 2.4".


Работа с флешкой или SD-картой будет описана не в этой статье, а в следующей. Всё выше сказанное сделано для того, чтоб объяснить как и почему нужно использовать интерфейсы SPI.




Подключение

У всех дисплеи разные поэтому перечислю те контакты дисплея, к которым будем подключаться. SPI (MOSI, MISO, CLK). CS (Chip Select) подача низкого уровня на этот контакт говорит о том, что мы собираемся работать с дисплеем (к шине SPI можно подключать множество устройств, и у каждого из них есть свой CS с помощью которого мы выбираем с кем собираемся общаться). DC подача низкого уровня на этот контакт говорит о том, что мы собираемся отправлять в дисплей команду, а подача высокого уровня означает что мы будем слать данные. RST (RESET) сброс дисплея (всё это библиотека делает сама).

Помимо этого нужно конечно же подать питание, и включить подсветку. Подсветку можно сделать постоянную, а можно организовать регулируемую, через транзистор с помощью таймера в режиме ШИМ. Питание и подсветку я подавал от отдельного источника питания, то есть не от самой платы.


Тачскрин будем подключать к тому же SPI что и дисплей. У него есть свой CS, и контакт (называется что-то типа IRQ) на котором появляется низкий уровень во время нажатия. Этот контакт мы будем опрашивать в бесконечном цикле.




Конфигурация

Настраиваем SPI1…


Устанавливаем максимальную скорость SPI (18.0 MBits/s). Это для системной частоты 72Мгц. Если ваш камень допускает большую частоту, то можно попробовать увеличить скорость SPI, но сначала протестируйте на этой.


Настраиваем пины…



Если у вас нет тачскрина, тогда TOUCH_CS и IRQ не нужны. Пин IRQ настраиваем как GPIO_Input, остальные (кроме SPI) как GPIO_Output. Эти контакты можете назначить на удобные вам ножки, а названия нужно дать такие как нарисовано, тогда в библиотеке ничего не надо будет менять.

Названия вводятся в разделе GPIO…


User Label


И настраиваем USART для вывода инфы.

Подключаем всё, и если подсветка работает то переходим к кодингу.




Программа

Скачиваем библиотеку и добавляем в проект файлы:

ILI9341_GFX.c
xpt2046_touch.c
fonts.c

ILI9341_GFX.h
xpt2046_touch.h
fonts.h
img.h


Если тача нет, то xpt2046_touch.c и xpt2046_touch.h не нужны.

Номер SPI и пины задефайнены в файлах ILI9341_GFX.h и xpt2046_touch.h.


В main.c инклюдим…

#include "ILI9341_GFX.h"
#include "fonts.h"
#include "img.h"
#include "xpt2046_touch.h"



Далее перед бесконечным циклом добавляем следующий код…

/* USER CODE BEGIN 2 */
  HAL_UART_Transmit(&huart1, (uint8_t*)"Start\n", 6, 1000);

  __HAL_SPI_ENABLE(DISP_SPI_PTR); // включаем SPI

  DISP_CS_UNSELECT;
  TOUCH_CS_UNSELECT; // это нужно только если есть тач

  /////////////////////////////////////////////////////////////////////////////////////////////////////////////////
  ILI9341_Init(); // инициализация дисплея

  /////////////////////////// далее демонстрируются различные пользовательские функции ////////////////////////////
  ILI9341_Set_Rotation(SCREEN_HORIZONTAL_2); // установка ориентации экрана (варианты в файле ILI9341_GFX.h)

  /////////////////////////////////////////////////////////////////////////////////////////////////////////////////
  ILI9341_Fill_Screen(MYFON); // заливка всего экрана цветом (цвета в файле ILI9341_GFX.h)

  /////////////////////////////////////////////////////////////////////////////////////////////////////////////////
  /* вывод строк разными шрифтами (шрифты определены в файле fonts.h, а массивы шрифтов в файле fonts.c)
  первый и второй аргумент это начало координат (справа, сверху), четвёртый аргумент шрифт
  два последних аргумента это цвет шрифта и цвет фона шрифта */

  ILI9341_WriteString(10, 10, "Hello World", Font_7x10, WHITE, MYFON); // можно передавать непосредственно текст
  ILI9341_WriteString(20, 30, "Hello World", Font_11x18, WHITE, MYFON);
  ILI9341_WriteString(30, 60, "Hello World", Font_16x26, BLUE, DARKGREEN);

  char txt_buf[] = "Hello World";
  ILI9341_WriteString(40, 96, txt_buf, Font_16x26, RED, GREEN); // можно передавать массив

  HAL_Delay(1000);

  ILI9341_Fill_Screen(MYFON);

  /////////////////////////////////////////////////////////////////////////////////////////////////////////////////
  ILI9341_Draw_Pixel(100, 100, WHITE); // рисует пиксель (координаты и цвет)
  HAL_Delay(300);
  ILI9341_Draw_Pixel(102, 100, MAROON);
  HAL_Delay(300);
  ILI9341_Draw_Pixel(100, 102, BLUE);
  HAL_Delay(300);
  ILI9341_Draw_Pixel(102, 102, RED);
  HAL_Delay(300);

  for(uint8_t i = 0; i < 100; i++)
  {
	  ILI9341_Draw_Pixel(i, 20, WHITE);
	  HAL_Delay(10);
  }

  for(uint8_t i = 0; i < 100; i++)
  {
	  ILI9341_Draw_Pixel(40, i, BLUE);
	  HAL_Delay(10);
  }

  for(uint8_t i = 0; i < 100; i++)
  {
	  ILI9341_Draw_Pixel(i, i, RED);
	  HAL_Delay(10);
  }

  HAL_Delay(1000);

  ILI9341_Fill_Screen(MYFON);

  /////////////////////////////////////////////////////////////////////////////////////////////////////////////////
  ILI9341_Draw_Rectangle(10, 10, 50, 70, WHITE); // рисует закрашеный прямоугольник (первые два аргумента это начальные координаты, а следующие два это ширина и высота)

  HAL_Delay(1000);

  ILI9341_Fill_Screen(MYFON);

  /////////////////////////////////////////////////////////////////////////////////////////////////////////////////
  ILI9341_Draw_Horizontal_Line(10, 10, 200, WHITE); // рисует горизонтальную линию (первые два аргумента это начальные координаты, а третий длина)

  HAL_Delay(1000);

  ILI9341_Fill_Screen(MYFON);

  /////////////////////////////////////////////////////////////////////////////////////////////////////////////////
  ILI9341_Draw_Vertical_Line(10, 10, 200, WHITE); // рисует вертикальную линию (первые два аргумента это начальные координаты, а третий длина)

  HAL_Delay(1000);

  ILI9341_Fill_Screen(MYFON);

  /////////////////////////////////////////////////////////////////////////////////////////////////////////////////
  ILI9341_Random_line(160, 120, 50, 175, WHITE); // рисует произвольную линию (первые два аргумента это начальные координаты, а третий и четвёртый - конечные)

  HAL_Delay(1000);

  ILI9341_Random_line(123, 180, 150, 75, WHITE);

  HAL_Delay(1000);

  ILI9341_Fill_Screen(MYFON);

  /////////////////////////////////////////////////////////////////////////////////////////////////////////////////
  ILI9341_Draw_Hollow_Circle(100, 100, 50, WHITE); // рисует прозрачный круг (первые два аргумента это координаты центра, а третий радиус)

  HAL_Delay(1000);

  ILI9341_Fill_Screen(MYFON);

  /////////////////////////////////////////////////////////////////////////////////////////////////////////////////
  ILI9341_Draw_Filled_Circle(150, 100, 40, WHITE); // рисует закрашеный круг (первые два аргумента это координаты центра, а третий радиус)

  HAL_Delay(1000);

  ILI9341_Fill_Screen(MYFON);

  /////////////////////////////////////////////////////////////////////////////////////////////////////////////////
  ILI9341_Draw_Hollow_Rectangle_Coord(10, 10, 50, 70, WHITE); // рисует прозрачный прямоугольник (первые два аргумента это начальные координаты, а вторые два конечные)

  HAL_Delay(1000);

  ILI9341_Fill_Screen(MYFON);

  /////////////////////////////////////////////////////////////////////////////////////////////////////////////////
  ILI9341_Draw_Filled_Rectangle_Coord(20, 20, 70, 60, WHITE); // рисует закрашеный прямоугольник (первые два аргумента это начальные координаты, а вторые два конечные)

  HAL_Delay(1000);

  ILI9341_Fill_Screen(MYFON);

  ////////////////////////////////// ВЫВОД КАРТИНКИ ИЗ МАССИВА //////////////////////////////////////////
  uint16_t size_img = sizeof(img_logo); // размер картинки в байтах (картинка лежит в файле img.h)

  ILI9341_Draw_Image(img_logo, 30, 30, 80, 80, size_img); // подробности см. в статье

  HAL_Delay(1000);

  //////////////////////////////////// смена ориентации экрана ///////////////////////////////////////

  ILI9341_Fill_Screen(MYFON);
  ILI9341_Set_Rotation(SCREEN_VERTICAL_1);
  ILI9341_Draw_Image(img_logo, 30, 30, 80, 80, size_img);
  ILI9341_WriteString(30, 120, "SCREEN_VERTICAL_1", Font_11x18, WHITE, MYFON);
  HAL_Delay(1000);

  ILI9341_Fill_Screen(MYFON);
  ILI9341_Set_Rotation(SCREEN_HORIZONTAL_1);
  ILI9341_Draw_Image(img_logo, 30, 30, 80, 80, size_img);
  ILI9341_WriteString(30, 120, "SCREEN_HORIZONTAL_1", Font_11x18, WHITE, MYFON);
  HAL_Delay(1000);

  ILI9341_Fill_Screen(MYFON);
  ILI9341_Set_Rotation(SCREEN_VERTICAL_2);
  ILI9341_Draw_Image(img_logo, 30, 30, 80, 80, size_img);
  ILI9341_WriteString(30, 120, "SCREEN_VERTICAL_2", Font_11x18, WHITE, MYFON);
  HAL_Delay(1000);

  ILI9341_Fill_Screen(MYFON);
  ILI9341_Set_Rotation(SCREEN_HORIZONTAL_2);
  ILI9341_Draw_Image(img_logo, 30, 30, 80, 80, size_img);
  ILI9341_WriteString(30, 120, "SCREEN_HORIZONTAL_2", Font_11x18, WHITE, MYFON);

/* USER CODE END 2 */

Всё это есть в примере, но тем не менее продублирую.

Здесь перечислены и прокомментированы все пользовательские функции, которые нужны для вывода текста, различных фигур, и картинок из внутренней памяти микроконтроллера.


Условимся что сейчас будем пользоваться экраном в горизонтальном положении. Если будете использовать экран в вертикальном положении, тогда нужно поменять местами значения ширины и высоты в файле ILI9341_GFX.h

///////////////// ширина высота ///////////////////
#define ILI9341_SCREEN_WIDTH 	320
#define ILI9341_SCREEN_HEIGHT   240


В этом же файле задефайнены другие цвета, и указан ресурс для создания своих.


В самом начале программы мы задали ориентацию зкрана…

ILI9341_Set_Rotation(SCREEN_HORIZONTAL_2);

Аргумент этой функции указывает от какого угла ведётся отсчёт начальных координат. В примере, в самом конце, это демонстрируется — начальные координаты у картинки и текста везде одинаковые, а угол от которого они начинаются меняется.


Собственно можно прошивать и смотреть что получилось. Если дошло до моего логотипа значит всё



Теперь по поводу функции вывода картинок из массива…

ILI9341_Draw_Image(img_logo, 30, 30, 80, 80, size_img);


Она используется для вывода картинок хранящихся во внутренней памяти МК в виде массива. В частности мой логотип лежит в файле img.h и «весит» 12800 байт. Если вам не нужно много больших картинок, то можно создать их столько, сколько позволит объём флеш-памяти микроконтроллера, и обойтись без внешнего хранилища.

Первый аргумент функции, это указатель на массив, второй и третий это начальные координаты, четвёртый и пятый это ширина и высота картинки, а последний это её размер в байтах.


Конвертер

Чтобы из ваших картинок создавать массивы правильного формата, можно воспользоваться специальным онлайн-ресурсом



Выберите файл, укажите Code format и Used for как на иллюстрации, и нажмите кнопку Get C string. Полученный массив вставьте в файл img.h вместо моего, впишите в функцию ширину и высоту вашего рисунка и пробуйте. Ширина и высота изображения будет написана там же на ресурсе, внизу.

Чтоб прикинуть размер будущей картинки, умножьте ширину на высоту, и умножьте это на 2 (картинка 16-ти битная, то есть на каждый пиксель два байта). На примере моего логотипа это выглядит так: 80 * 80 * 2 = 12800 байт.

Другие изображения можно так же добавлять в этот файл, только имена массивов меняйте.


Там же внизу можно скачать файл в бинарном виде — Download data will save the converted data as binary file. Этот файл можно использовать при работе с SD картой.


На тот случай, если ресурс окажется не рабочим, я создал свой онлайн-конвертер. Работает очень просто — выбираете файл, загружаете, и если всё хорошо, через несколько секунд (нужно время на обработку) появится кнопка «скачать». В скаченом архиве будут лежать бинарник, и заголовочный файл с массивом.

Так же в библиотеке есть папка Converter, в которой лежит PHP-скрипт для использования на своём компе (понадобиться установленный php7+). Пример использования…

php img.php myfile.jpg

После выполнения появятся описанные выше файлы.


Тачскрин

Тачскрин будем опрашивать в бесконечном цикле, точнее не тачскрин, а пин IRQ, который сигнализирует о нажатии. Перед бесконечным циклом добавьте несколько переменных…

char buf[64] = {0,};
  
uint8_t flag_press = 1;
uint32_t time_press = 0;

uint8_t flag_hold = 1;
uint32_t timme_hold = 0;
  
uint16_t x = 0;
uint16_t y = 0;



А в цикл добавляем вот такой код…

while (1)
{
  if(HAL_GPIO_ReadPin(IRQ_GPIO_Port, IRQ_Pin) == GPIO_PIN_RESET && flag_press) // если нажат тачскрин
  {
	  x = 0;
	  y = 0;

	  TOUCH_CS_UNSELECT;
	  DISP_CS_UNSELECT;

	  HAL_SPI_DeInit(DISP_SPI_PTR);
	  hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_64;
	  HAL_SPI_Init(DISP_SPI_PTR);

	  if(ILI9341_TouchGetCoordinates(&x, &y))
	  {
		  flag_press = 0;

                  //////// вывод координат в уарт для отладки ////////
		  snprintf(buf, 64, "X = %d, Y = %d\n", x, y); 
		  HAL_UART_Transmit(&huart1, (uint8_t*)buf, strlen(buf), 100);
		  buf[strlen(buf) - 1] = '\0';
                  ////////////////////////////////////////////////////
	  }

	  HAL_SPI_DeInit(DISP_SPI_PTR);
	  hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_4;
	  HAL_SPI_Init(DISP_SPI_PTR);

	  __HAL_SPI_ENABLE(DISP_SPI_PTR);
	  DISP_CS_SELECT;

	  //////// вывод координат на экран для отладки ////////
	  ILI9341_Fill_Screen(MYFON);
	  ILI9341_WriteString(10, 120, buf, Font_11x18, WHITE, MYFON);
	  //////////////////////////////////////////////////////

	  if(x > 250 && x < 285 && y > 65 && y < 96) // если нажатие происходит в области этих координат
	  {
		  // что-то делаем
	  }
	  else if(x > 120 && x < 160 && y > 50 && y < 90) // если нажатие происходит в области этих координат
	  {
		  // что-то делаем
	  }
	  ///////////////// если нажатие происходит c удержанием кнопки ////////////////
	  else if(x > 5 && x < 90 && y > 160 && y < 230 && flag_hold) // первая кнопка
	  {
		  flag_hold = 0;
		  timme_hold = HAL_GetTick();
                  // здесь ничего не делаем
	  }
	  else if(x > 100 && x < 200 && y > 160 && y < 230 && flag_hold) // вторая кнопка
	  {
		  flag_hold = 0;
		  timme_hold = HAL_GetTick();
                  // здесь ничего не делаем
	  }


	  ///////////////////////////
	  time_press = HAL_GetTick();
  }

  if(!flag_press && (HAL_GetTick() - time_press) > 200) // задержка до следующего нажатия
  {
	  flag_press = 1;
  }


  //////////////////////////// удержание кнопки //////////////////////////////
  if(!flag_hold && HAL_GPIO_ReadPin(IRQ_GPIO_Port, IRQ_Pin) != GPIO_PIN_RESET)
  {
	  flag_hold = 1;
  }

  if(!flag_hold && (HAL_GetTick() - timme_hold) > 2000) // 2 sek удержание кнопки
  {
	  if(HAL_GPIO_ReadPin(IRQ_GPIO_Port, IRQ_Pin) == GPIO_PIN_RESET)
	  {
		  if(x > 5 && x < 90 && y > 160 && y < 230) // первая кнопка
		  {
			  HAL_UART_Transmit(&huart1, (uint8_t*)"LONG PRESS_1\n", 13, 100); // отладка
                          ILI9341_WriteString(10, 150, "LONG PRESS_1", Font_11x18, WHITE, MYFON); // отладка
			  // что-то делаем
		  }
		  else if(x > 100 && x < 200 && y > 160 && y < 230) // вторая кнопка
		  {
			  HAL_UART_Transmit(&huart1, (uint8_t*)"LONG PRESS_2\n", 13, 100); // отладка
                          ILI9341_WriteString(10, 150, "LONG PRESS_1", Font_11x18, WHITE, MYFON); // отладка
			  // что-то делаем
		  }
	  }

	  flag_hold = 1;
  }
  /////////////////////////////////////////////////////////////////////////////


// далее какой-то ваш код, который не должен сильно тормозить цикл

/* USER CODE END WHILE */

/* USER CODE BEGIN 3 */
}


Когда условие «если нажат тачскрин» сработает (пин IRQ выдаст низкий уровень), SPI переключится на меньшую скорость, функция ILI9341_TouchGetCoordinates(&x, &y) прочитает данные полученные от тачскрина, после чего SPI вернётся к изначальной скорости.

Важно! Если у вас какой-то другой камень, то посмотрите в Кубе какие нужно прописать делители — SPI_BAUDRATEPRESCALER_х.

Теперь можно прошить и потыкать в экран.


В USART и на экран будут выведены координаты нажатия. Это отладочная инфа, в дальнейшем её можно закомментировать. Там же приведён пример как пользоваться этими координатами. То есть, допустим вы рисуете какую-то кнопку на экране, тыкаете по углам этой кнопки, получаете координаты области, и прописываете их в условии — «если больше и меньше по Х, и больше и меньше по Y, тогда что-то делаем».

Условие «задержка до следующего нажатия» нужно чтоб данные не сыпались как сумасшедшие, типа защита от типа «дребезга».

Ну и «удержание кнопки» говорит само за себя, время удержания можно установить какое нужно. При этом ничего в цикле не тормозится. Короткое нажатие на эту кнопку ничего не делает. То есть это сделано не для того чтоб было два варианта действий на одной кнопке, а для защиты от случайного нажатия, вдруг у вас эта кнопка что-то типа "АЗ-5".


Сам по себе тачскрин в связке с чипом XPT2046 является банальным АЦП, и при нажатии выдаёт два 16-ти битных значения (по горизонтали и по вертикали), которые преобразовываются в функции ILI9341_TouchGetCoordinates() в координаты с помощью математических вычислений. В этой функции есть закомментированные строки, которые показывают эти значения. Получая их можно более точно определить края экрана (тачскрины то у всех разные) потыкав и подкорректировав цифры в файле xpt2046_touch.c

#define TOUCH_MIN_RAW_X 1500
#define TOUCH_MAX_RAW_X 30000
#define TOUCH_MIN_RAW_Y 2500
#define TOUCH_MAX_RAW_Y 30000

Опытным путём разберётесь какое значение к какому краю относится. У себя я их округлил. Можно было бы сделать калибровку с крестиками по углам, но мне это делать не охота, да и необходимости особой нет.


На этом наверно пока всё, как говорилось выше, в следующей части речь пойдёт о хранении больших картинок на внешнем носителе и выводе их с помощью DMA.


Библиотека собрана из двух, взятых отсюда и отсюда.


Всем спасибо


Телеграм-чат istarik

Телеграм-чат STM32


  • 0
  • 1736
Поддержать автора




Telegram-чат istarik

Задать вопрос по статье
Telegram-канал istarik

Известит Вас о новых публикациях






Комментарии (0)

Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.