Как стать автором
Обновить

Работа со списком Pinов, на С++ для микроконтроллеров (на примере CortexM)

Программирование *C++ *Программирование микроконтроллеров *


Всем доброго здравия!


В прошлой статье я обещал написать о том, как можно работать со списком портов.
Сразу скажу, что уже все было решено до меня аж в 2010 году, вот статья: Работа с портами ввода-вывода микроконтроллеров на Си++ . Человек написавший это в 2010 просто красавчик.


Мне было немного неловко, что я будут делать то, что уже сделано 10 лет назад, поэтому я решил не дожидаться 2020 года, а сделать это в 2019, чтобы повторить решение еще пока 9 летней давности, это будет не так стремно.


В выше указанной статье работа со списками типов была сделана с помощью C++03, когда еще шаблоны имели фиксированное число параметров, а функции не могли быть constexpr выражениями. С тех пор С++ "немного изменился", поэтому давайте попробуем сделать тоже самое, но на С++17. Добро пожаловать под кат:


Задача


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


Собственно, то, что мы хотим сделать, можно показать кодом:


using Pin1 = Pin<GPIOС, 1>;
using Pin2 = Pin<GPIOB, 1>;
using Pin3 = Pin<GPIOA, 1>;
using Pin4 = Pin<GPIOC, 2>;
using Pin5 = Pin<GPIOA, 3>;

int main()
{
   // Хотим чтобы все Pinы установились в три действия:     
   // В порт GPIOA установилось 10 GPIOA->BSRR = 10 ;  // (1<<1) | (1 << 3) ;
   // В порт GPIOB установилось 2 GPIOB->BSRR = 2 ;  // (1 << 1)
   // В порт GPIOC установилось 6 GPIOB->BSRR = 6 ; // (1 << 1) | (1 << 2); 
    PinsPack<Pin1, Pin2, Pin3, Pin4, Pin5>::Set() ; 

    return 0;
}

Про регистр BSRR

Для тех, кто не в курсе микроконтроллерных дел, GPIOA->BSRR регистр отвечает за атомарную установку или сброс значений на ножках микроконтроллера. Этот регистр 32 битный. Первые 16 бит отвечают за установку 1 на ножках, вторые 16 бит за установку 0 на ножках.


Например, для того, чтобы установить ножку номер 3 в 1, нужно в регистре BSRR установить третий бит в 1. Чтобы сбросить ножку номер 3 в 0 нужно в этом же регистре BSRRустановить 19 бит в 1.


Обобщенная схема этапов решения этой задачи может быть представлен следующим образом:



Ну или другими словами:


Чтобы компилятор сделал за нас:


  • проверку, что список содержит только уникальные Pin
  • создание списка портов, определив на каких портах находятся Pin,
  • вычисление значение, которое нужно поставить в каждый порт

А затем программа


  • установила это значение

И сделать это нужно максимально эффективно, чтобы даже без оптимизации код был минимальным. Собственно это вся задача.


Начнем с первого пунктика: Проверка того, что список содержит уникальные Pin.


Проверка списка на уникальность


Напомню, у нас есть список Pinов:


PinsPack<Pin1, Pin2, Pin3, Pin4, Pin5> ;

Нечаянно можно сделать так:


PinsPack<Pin1, Pin2, Pin3, Pin4, Pin1> ; // Два раза в списке Pin1

Хотелось бы, чтобы компилятор отловил такую оплошность и сообщил об этом пианисту.


Будем проверять список на уникальность следующим образом:


  • Из исходного списка создадим новый список без дубликатов,
  • Если тип исходного списка и тип списка без дубликатов не совпадают, то значит в исходном списке были одинаковые Pin и программист ошибся.
  • Если совпадают, то все хорошо, дубликатов нет.

Для формирования нового списка без дубликатов, коллега посоветовал не изобретать велосипед и воспользоваться подходом из библиотеки Loki. У него я этот подход и спер. Почти то же самое что и в 2010 году, но с переменным числом параметров.


Код который позаимствован у коллеги, который позаимствовал идею из Loki
namespace PinHelper
{
 template<typename ... Types> struct Collection  { }; 

 ///////////////// Заимствуем идею NoDuplicates из библиотеки LOKI ////////////////
template<class X, class Y> struct Glue;
template<class T, class... Ts> 
struct Glue<T, Collection<Ts...>> {
    using Result = Collection<T, Ts...>; };

template<class Q, class X> struct Erase;

template<class Q>
struct Erase<Q, Collection<>> {
   using Result = Collection<>;};

template<class Q, class... Tail>
struct Erase<Q, Collection<Q, Tail...>> {
   using Result = Collection<Tail...>;};

template<class Q, class T, class... Tail>
struct Erase<Q, Collection<T, Tail...>> {
   using Result = typename Glue<T, typename Erase<Q, Collection<Tail...>>::Result>::Result;};

template <class X> struct NoDuplicates;

template <> struct NoDuplicates<Collection<>>
{
    using Result = Collection<>;
};

template <class T, class... Tail>
struct NoDuplicates< Collection<T, Tail...> >
{
private:
    using L1 = typename NoDuplicates<Collection<Tail...>>::Result;
    using L2 = typename Erase<T,L1>::Result;
public:
    using Result = typename Glue<T, L2>::Result;
};
///////////////// LOKI ////////////////
}

Как теперь можно этим пользоваться? Да очень просто:


using Pin1 = Pin<GPIOC, 1>;
using Pin2 = Pin<GPIOB, 1>;
using Pin3 = Pin<GPIOA, 1>;
using Pin4 = Pin<GPIOC, 2>;
using Pin5 = Pin<GPIOA, 3>;
using Pin6 = Pin<GPIOC, 1>;

int main() {
    //Два раза Pin1 в списке, да еще и Pin6 имеет тот же самый тип
    using PinList = Collection<Pin1, Pin2, Pin3, Pin4, Pin1, Pin6> ; 
    using  TPins =  typename NoDuplicates<PinList>::Result;
    // сработает static_assert. Так как  будут сравниваться два типа списков
    // начальный:        Collection<Pin1, Pin2, Pin3, Pin4, Pin1, Pin6>
    // и без дубликатов: Collection<Pin1, Pin2, Pin3, Pin4>
    // очевидно, что типы разные
    static_assert(std::is_same<TPins, PinList>::value, 
                  "Беда: Одинаковые пины в списке") ;    
    return 0;
}

Ну т.е. если вы неправильно задали список пинов, и нечаянно два одинаковых пина указали в списке, то программа не откомпилируется, а компилятор выдаст ошибку: "Беда: Одинаковые пины в списке".


Кстати, для уверенности в правильности списка пинов для портов можно использовать следующий подход:
// Сгенерируем список пинов для портов с типом
// PinsPack<Port<GPIOB, 0>, Port<GPIOB, 1> ... Port<GPIOB, 15>>
using GpiobPort = typename GeneratePins<15, GPIOB>::type
// Тоже самое для порта А
using GpioaPort = typename GeneratePins<15, GPIOA>::type

int main() {
   //Обращаться к пину по номеру: Установить GPIOA.0 в 1
   Gpioa<0>::Set() ; 
   //Установить GPIOB.1 в 0
   Gpiob<1>::Clear() ;

   using LcdData = Collection<Gpioa<0>, Gpiob<6>, Gpiob<2>, Gpioa<3>, Gpioc<7>, Gpioa<4>, Gpioc<3>, Gpioc<10>> ;
   using TPinsLcd =  typename NoDuplicates<LcdData>::Result;
   static_assert(std::is_same<TPinsB, LcdData>::value, "Беда: Одинаковые пины в списке для шины данных LCD") ;

   //Пишем A в линию данных для индикатора
   LcdData::Write('A');      
}

Мы тут понаписали уже столько, но пока еще нет ни строчки реального кода, который попадет в микроконтроллер. Если все пины заданы правильно, то программа для прошивки выглядит так:


int main()
{
   return 0 ;
}

Давайте добавим немного кода и попробуем сделать метод Set() для установки пинов в списке.


Метод установки Pinов в порте


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


Кодом, что мы хотим
using Pin1 = Pin<GPIOA, 1>;
using Pin2 = Pin<GPIOB, 2>;
using Pin3 = Pin<GPIOA, 2>;
using Pin4 = Pin<GPIOC, 1>;
using Pin5 = Pin<GPIOA, 3>;

int main()
{         
    PinsPack<Pin1, Pin2, Pin3, Pin4, Pin5>::Set() ;
   // Этот код должен преобразоваться в 3 линии кода
   // GPIOA->BSRR = 14 ; // (1<<1) | (1 << 2) | (1 << 3) ;
   // GPIOB->BSRR = 4 ;  // (1 << 2)
   // GPIOB->BSRR = 2 ;  // (1 << 1); 

}

Поэтому объявим класс, который будет содержать список Pinов, а в нем определим публичный статический метод Set().


template <typename ...Ts>
struct PinsPack 
{
   using Pins = PinsPack<Ts...> ;
public:
    __forceinline static void Set(std::size_t mask)
   {
   }   
} ;

Как видно, метод Set(size_t mask) принимает какое-то значение (маску). Эта маска есть число, которое нужно поставить в порты. По умолчанию она равна 0xffffffff, это означает, что мы хотим поставить все Pinы в списке (максимум 32). Если передать туда другое значение, например, 7 == 0b111, то установиться должны только первые 3 пина в списке и так далее. Т.е. маска накладываемая на список Pinов.


Формирование списка портов


Для того, чтобы вообще можно было что-то устанавливать в пины, нужно знать на каких портах эти пины сидят. Каждый Pin привязан к определенному порту и мы можем из класса Pin "вытащить" эти порты и сформировать список этих портов.


Наши Pinы назначены на разные порты:


using Pin1 = Pin<Port<GPIOA>, 1>;
using Pin2 = Pin<Port<GPIOB>, 2>;
using Pin3 = Pin<Port<GPIOA>, 2>;
using Pin4 = Pin<Port<GPIOC>, 1>;
using Pin5 = Pin<Port<GPIOA>, 3>;

У этих 5 Pinoв всего 3 уникальных порта (GPIOA, GPIOB, GPIOC). Если мы объявим список PinsPack<Pin1, Pin2, Pin3, Pin4, Pin5>, то из него нужно получить список из трех портов Collection<Port<GPIOA>, Port<GPIOB>, Port<GPIOC>>


Класс Pin содержит в себе тип порта и в упрощенном виде выглядит так:


template<typename Port, uint8_t pinNum>
struct Pin 
{
  using PortType = Port ;
  static constexpr uint32_t pin = pinNum ;
...
}

Кроме того, еще нужно определить структуру для этого списка, это будет просто шаблонная структура, принимающая переменное количество аргументов шаблона


template <typename... Types>
struct Collection{} ;

Теперь определим список уникальных портов, а заодно проверим, что список пинов не содержит одинаковых пинов. Это сделать несложно :


template <typename ...Ts>
struct PinsPack 
{
   using Pins = PinsPack<Ts...> ;
private:
   // Формируем список пинов без дубликатов
   using  TPins =  typename NoDuplicates<Collection<Ts...>>::Result;
   // Проверяем совпадает ли исходный список пинов со списком без дубликатов
   static_assert(std::is_same<TPins, Collection<Ts...>>::value, 
                 "Беда: Одинаковые пины в списке") ;   
   // Формируем список уникальных портов
   using Ports = typename 
                     NoDuplicates<Collection<typename Ts::PortType...>>::Result;
...
} ;

Идем дальше...


Обход списка портов


Получив список портов, теперь нужно его обойти и что-то с каждым портом сделать. В упрощенном виде можно сказать, что мы должны объявить функцию, которая на входе получит список портов и маску для списка пинов.


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


Обходить будем "рекурсивно", пока в шаблоне еще есть параметры, будем вызвать функцию с этим же именем.


template <typename ...Ts>
struct PinsPack 
{
   using Pins = PinsPack<Ts...> ;
private:
  __forceinline template<typename Port, typename ...Ports>
  constexpr static void SetPorts(Collection<Port, Ports...>, std::size_t mask)  
  {
    // Проверяем, что параметры шаблона еще не закончены
    if constexpr (sizeof ...(Ports) != 0U)
    {
      Pins::template WritePorts<Ports...>(Collection<Ports...>(), mask) ;
    }
  }
}

Итак, обходить список портов научились, но кроме обхода нужно сделать какую-то полезную работу, а именно установить в порт что-то.


__forceinline template<typename Port, typename ...Ports>
constexpr static void SetPorts(Collection<Port, Ports...>, std::size_t mask)    
{
  // Получить значение маски для порта
  auto result = GetPortValue<Port>(mask) ; 
  // Установить в порт расчитанное значение
  Port::Set(result) ;

  if constexpr (sizeof ...(Ports) != 0U)
  {
    Pins::template WritePorts<Ports...>(Collection<Ports...>(), mask) ;
  }
}

Этот метод будет выполняться в runtime, так как параметр mask передается в функцию из вне. А из-за того, что мы не можем гарантировать, что в метод SetPorts() будет передаваться константа, метод GetValue() тоже начнет выполняться во время исполнения.


И хотя, в статье Работа с портами ввода-вывода микроконтроллеров на Си++ написано, что в подобном методе компилятор определил, что передалась константа и расчитал значение для записи в порт на этапе компиляции, мой компилятор сделал такой трюк только при максимальной оптимизации.
А хотелось бы, чтобы GetValue() выполнялся во время компиляции при любых настройках компилятора.


Я не нашел в стандарте, как в таком случае должен вести компилятор компилятор, но судя по тому, что компилятор IAR делает это только при максимальном уровне оптимизации, скорее всего это стандартом и не регламентировано, либо не должно восприниматься как constexpr выражение.
Если кто знает, пишите в комментариях.


Чтобы обеспечить явную передачу константного значения сделаем дополнительный метод с передачей mask в шаблоне:


__forceinline template<std::size_t mask, typename Port, typename ...Ports>
constexpr static void SetPorts(Collection<Port, Ports...>)  
{
  using MyPins = PinsPack<Ts...> ;
  // метод вызывается в compile time, так как значение value взято из шаблона
  constexpr auto result = GetPortValue<Port>(mask) ; 
  Port::Set(result) ;

  if constexpr (sizeof ...(Ports) != 0U)  
  {
    MyPins::template SetPorts<mask,Ports...>(Collection<Ports...>()) ;
  }
}

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


Расчет значения, которое необходимо установить в порт


У нас есть список портов, который мы получили из списка Pinов, для нашего примера это список: Collection<Port<GPIOA>, Port<GPIOB>, Port<GPIOC>>.
Нужно взять элемент этого списка, например, порт GPIOA, затем в списке Pinов найти все Pinы, которые привязаны к этому порту и рассчитать значение для установки в порт. А затем тоже самое сделать со следующим портом.


Еще раз: В нашем случае список Pinов, из которых нужно получить список уникальных портов такой:
using Pin1 = Pin<Port<GPIOC>, 1>;
using Pin2 = Pin<Port<GPIOB>, 1>;
using Pin3 = Pin<Port<GPIOA>, 1>;
using Pin4 = Pin<Port<GPIOC>, 2>;
using Pin5 = Pin<Port<GPIOA>, 3>;

using Pins = PinsPack<Pin1, Pin2, Pin3, Pin4, Pin5> ;

Значит для порта GPIOA значение должно (1 << 1 ) | (1 << 3) = 10, а для порта GPIOC — (1 << 1) | (1 << 2) = 6, а для GPIOB (1 << 1 ) = 2


Функция для расчета принимает запрашиваемый порт и если Pin находится на том же порту, что и запрашиваемый порт, то она должна установить в маске бит, соответствующий позиции этого Pina в списке, единицу (1).
На словах объяснить не просто, лучше посмотрим сразу в код:


template <typename ...Ts>
struct PinsPack
{
  using Pins = PinsPack<Ts...> ;
private:
  __forceinline template<class QueryPort>
  constexpr static auto GetPortValue(std::size_t mask) 
  {
    std::size_t result = 0;  
    // Для того, чтобы узнать какая будет маска нужно
    // 1. Проверить, что порт пина и запрашиваемый порт совпадают
    // 2. Если совпадают взять нулевой бит маски и установить его в результирующее 
    // значениe (т.е по номеру пина на порте), например, если Pin с индексом 0 в 
    // списке пинов висит на выводе порта номер 10, то для в результирующее значение 
    // для порта нужно установить(через ИЛИ) значение (1 << 10) и так далее
    // 3. Сдвинуть маску на 1 в право
    // 4. Повторить шаги 1-3 для остальных пинов в списке
    pass{(result |= ((std::is_same<QueryPort, typename Ts::PortType>::value ? 1 : 0) & 
                                                 mask) * (1 << Ts::pin), mask >>= 1)...} ;
    return result;
  }
} ;      

Установка рассчитанного для каждого порта значения в порты


Теперь мы знаем значение, которое нужно установить в каждом порту. Осталось доделать публичный метод Set(), который будет виден пользователю, чтобы все это хозяйство вызвалось:


template <typename ...Ts>
struct PinsPack
{
  using Pins = PinsPack<Ts...> ; 

  __forceinline static void Set(std::size_t mask)
  {
    // Передаем список портов и маску для установки
    SetPorts(Ports(), mask) ;
  }
}

Как и в случае с SetPorts() сделаем дополнительный шаблонный метод, чтобы гарантировать передачу mask как константы, передав её в атрибуте шаблона.


template <typename ...Ts>
struct PinsPack
{
  using Pins = PinsPack<Ts...> ;
  // Значение по умолчанию 0xffffffff, чтобы можно было одновременно устанавливать 32 пина
  __forceinline template<std::size_t mask =  0xffffffffU>
  static void Set()
  {
    SetPorts<mask>(Ports()) ;
  }
}

В финальном виде наш класс для списка Pinов будет выглядеть следующим образом:
using namespace PinHelper ;

template <typename ...Ts>
struct PinsPack
{
   using Pins = PinsPack<Ts...> ;

 private:

   using  TPins =  typename NoDuplicates<Collection<Ts...>>::Result;
   static_assert(std::is_same<TPins, Collection<Ts...>>::value, 
                 "Беда: Одинаковые пины в списке") ;   
   using Ports = typename 
                     NoDuplicates<Collection<typename Ts::PortType...>>::Result;

   template<class Q>
   constexpr static auto GetPortValue(std::size_t mask) 
   {
     std::size_t result = 0;  
     auto rmask = mask ;
     pass{(result |= ((std::is_same<Q, typename Ts::PortType>::value ? 1 : 0) & mask) * (1 << Ts::pin), mask>>=1)...};
     pass{(result |= ((std::is_same<Q, typename Ts::PortType>::value ? 1 : 0) & ~rmask) * ((1 << Ts::pin) << 16), rmask>>=1)...};
     return result;
   }      

   __forceinline template<typename Port, typename ...Ports>
   constexpr static void SetPorts(Collection<Port, Ports...>, std::size_t mask)
   {
     auto result = GetPortValue<Port>(mask) ;
     Port::Set(result & 0xff) ;

     if constexpr (sizeof ...(Ports) != 0U)
     {
       Pins::template SetPorts<Ports...>(Collection<Ports...>(), mask) ;
     }
   }

   __forceinline template<std::size_t mask, typename Port, typename ...Ports>
   constexpr static void SetPorts(Collection<Port, Ports...>)
   {
     constexpr auto result = GetPortValue<Port>(mask) ;
     Port::Set(result & 0xff) ;

     if constexpr (sizeof ...(Ports) != 0U)
     {
       Pins::template SetPorts<mask, Ports...>(Collection<Ports...>()) ;
     }
   }

   __forceinline template<typename Port, typename ...Ports>
   constexpr static void WritePorts(Collection<Port, Ports...>, std::size_t mask)
   {
     auto result = GetPortValue<Port>(mask) ;
     Port::Set(result) ;

     if constexpr (sizeof ...(Ports) != 0U)
     {
       Pins::template WritePorts<Ports...>(Collection<Ports...>(), mask) ;
     }
   }

    __forceinline template<std::size_t mask, typename Port, typename ...Ports>
   constexpr static void WritePorts(Collection<Port, Ports...>)
   {
     Port::Set(GetPortValue<Port>(mask)) ;

     if constexpr (sizeof ...(Ports) != 0U)
     {
       Pins::template WritePorts<mask, Ports...>(Collection<Ports...>()) ;
     }
   }

public:
    static constexpr size_t size = sizeof ...(Ts) + 1U ;

   __forceinline static void Set(std::size_t mask  )
   {
     SetPorts(Ports(), mask) ;
   }

   __forceinline template<std::size_t mask =  0xffffffffU>
   static void Set()
   {
     SetPorts<mask>(Ports()) ;
   }

    __forceinline static void Write(std::size_t mask)
   {
     WritePorts(Ports(), mask) ;
   }

   __forceinline template<std::size_t mask =  0xffffffffU>
   static void Write()
   {
     WritePorts<mask>(Ports()) ;
   }

} ;

В результате, всем этим дело можно воспользоваться следующим образом:


using Pin1 = Pin<GPIOC, 1>;
using Pin2 = Pin<GPIOB, 1>;
using Pin3 = Pin<GPIOA, 1>;
using Pin4 = Pin<GPIOC, 2>;
using Pin5 = Pin<GPIOA, 3>;
using Pin6 = Pin<GPIOA, 5>;
using Pin7 = Pin<GPIOC, 7>;
using Pin8 = Pin<GPIOA, 3>;

int main() 
{
    //1. Этот вызов развернется, как и планировалось в 3 строки, эквивалентные псевдокоду:
    // GPIOA->BSRR = (1 << 1) | (1 << 3) 
    // GPIOB->BSRR = (1 << 1) 
    // GPIOC->BSRR = (1 << 1) | (1 << 2) 
    PinsPack<Pin1, Pin2, Pin3, Pin4, Pin5>::Set() ; // Вызвался метод Set<0xffffffffU>()  

    //2. Этот вызов развернется, в 3 строки, эквивалентные псевдокоду:
    // GPIOA->BSRR = (1 << 1) 
    // GPIOB->BSRR = (1 << 1) 
    // GPIOC->BSRR = (1 << 1) | (1 << 2)
    PinsPack<Pin1, Pin2, Pin3, Pin4, Pin5, Pin6>::Set<7>() ;   

   //3. А это уже сгенерит немного кода и всяких шаблонных функций, 
   // так как someRunTimeValue не известно на этапе компиляции, то 
   // функция SetPorts перестает быть constexpr со всеми вытекающими
    PinsPack<Pin1, Pin2, Pin3, Pin4, Pin5>::Set(someRunTimeValue) ;

    using LcdData =  PinsPack<Pin1, Pin2, Pin3, Pin4, Pin5, Pin6, Pin7, Pin8> ;
    LcdData::Write('A') ;
}

Более полный пример, можно посмотреть тут:
https://onlinegdb.com/r1eoXQBRH


Быстродействие


Как вы помните мы хотели добиться, чтобы наш вызов преобразовался в 3 строки, в порт A установилось 10, в порт B — 2 и в порт С — 6


using Pin1 = Pin<GPIOС, 1>;
using Pin2 = Pin<GPIOB, 1>;
using Pin3 = Pin<GPIOA, 1>;
using Pin4 = Pin<GPIOC, 2>;
using Pin5 = Pin<GPIOA, 3>;

int main()
{
   // Хотим чтобы все Pinы установились в три действия:     
   // В порт GPIOA установилось 10 GPIOA->BSRR = 10 ;  // (1<<1) | (1 << 3) ;
   // В порт GPIOB установилось 2 GPIOB->BSRR = 2 ;  // (1 << 1)
   // В порт GPIOC установилось 6 GPIOB->BSRR = 6 ; // (1 << 1) | (1 << 2); 
    PinsPack<Pin1, Pin2, Pin3, Pin4, Pin5>::Set() ; 

    return 0;
}

Давайте посмотрим, что у нас получилось при полностью отключенной оптимизации



Я подкрасил зеленым значения портов и вызовы установок этих значений в порты. Видно, что все сделано так как мы задумывали, компилятор для кадого из портов подстчитал значение и просто вызвал функцию для установки этих значений в нужные порты.
Если функции установки также сделать inline, то в конечном итоге получится один вызов записи значения в BSRR регистр для каждого порта.


Собственно это всё. Кому интересно, код лежит тут.


Пример лежит тут.


https://onlinegdb.com/ByeA50wTS

Теги:
Хабы:
Всего голосов 9: ↑7 и ↓2 +5
Просмотры 6.8K
Комментарии Комментарии 47