Очередная статья: STM32 для начинающих

Всех приветствую!

Это моя первая статья на Хабре, поэтому прошу не кидаться тяжелыми предметами. Заранее спасибо.

Начнем с предыстории. Когда-то мне пришлось перейти на микроконтроллеры ARM фирмы ST. Это было связано с тем, что PIC и AVR уже не хватало и хотелось новых приключений. Из доступного в хлебобулочных магазинах и большого количества статей о «быстром старте» выбор пал именно на STM32F100.

Я привык работать в IAR. Да, есть другие IDE, но мне хватает возможности IAR: относительно удобный редактор, не плохой отладчик и достаточно удобно работать с регистрами во время отладки.

Когда я попытался сделать первый проект меня ждало разочарование — CMSIS! Кому как, но для меня это было (и остается) ужасом: много буков, длинные и для меня не понятные структуры. Вникать во все это было не интересно. Попытался скомпилировать пару примеров и понял — это не наш метод.
Неужели нет других вариантов? Есть. Тот, встроенный в IAR: iostm32f10xx4.h и подобные инклудники. Вполне не плохо:

RCC_APB2ENR_bit.ADC1EN = 1; // включить тактирование ADC


Оставалось это запихнуть в классы и пользоваться. Так и сделал. Через какое-то время потребовалось сделать код для STM32f4xx. И тут снова засада — нет инклудиков. Что делать? — писать самому. Проанализировал имеющиеся самописные библиотеки решил немного сделать по другому. Вот об этом и будет рассказ.

Начало


Про установку IAR и драйверов для отладчика рассказывать не буду, т.к. здесь ничего нового. У меня стоит IAR 8 с ограниченем кода в 32кБ. Для работы выбран контроллер STM32F103, установленный на плате plue pill.

Запускаем IAR, создаем проект c++, выбираем нужный контроллер
image
Следующий шаг — изучение документации. Нас будет интересовать Reference manual RM0008. Там главное внимательно читать.

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

Модуль RCC. Такирование


Про этот модуль обычно забывают. Вспоминают только тогда, когда не получается мигнуть светодиодом.

Запомните! Что бы включить какую-либо периферию, на нее надо подать тактовые импульсы! Без этого никак.

Порты ввода-вывода сидят на шине APB2. Находим в документации регист для упрвления тактированием этой шины, это RCC_APB2ENR:

-adz9y0qj8ebtzgk_8ip2cfdcfs.jpeg

Чтобы включить тактирование порта C (светодиод как раз припаян к PC13), требуется записать в бит IOPCEN единичку.

Теперь найдем адрес регистра RCC_APB2ENR. Смещение у него 0×18, базовый адрес для регистров RCC 0×40021000.

Чтобы удобно было работать с битами, создадим структуру:

typedef struct
{
  uint32_t  AFIOEN         : 1;
  uint32_t                 : 1;
  uint32_t  IOPAEN         : 1;
  uint32_t  IOPBEN         : 1;
  uint32_t  IOPCEN         : 1;
  uint32_t  IOPDEN         : 1;
  uint32_t  IOPEEN         : 1;
  uint32_t                 : 2;
  uint32_t  ADC1EN         : 1;
  uint32_t  ADC2EN         : 1;
  uint32_t  TIM1EN         : 1;
  uint32_t  SPI1EN         : 1;
  uint32_t                 : 1;
  uint32_t  USART1EN       : 1;
  uint32_t                 :17;
} RCC_APB2ENR_b;


Чтобы потом не мучаться, сразу перечислим все адреса регистров:

enum AddrRCC
{
  RCC_CR          = 0x40021000,
  RCC_CFGR        = 0x40021004,
  RCC_CIR         = 0x40021008,
  RCC_APB2RSTR    = 0x4002100C,
  RCC_APB1RSTR    = 0x40021010,
  RCC_AHBENR      = 0x40021014,
  RCC_APB2ENR     = 0x40021018,
  RCC_APB1ENR     = 0x4002101C,
  RCC_BDCR        = 0x40021020,
  RCC_CSR         = 0x40021024
};


теперь остается написать код для включения периферии:

static void EnablePort(uint8_t port_name)
{
  volatile RCC_APB2ENR_b* apb2enr = reinterpret_cast(RCC_APB2ENR);
  switch (port_name)
  {
    case 'A': apb2enr->IOPAEN = 1; break;
    case 'a': apb2enr->IOPAEN = 1; break;
    case 'B': apb2enr->IOPBEN = 1; break;
    case 'b': apb2enr->IOPBEN = 1; break;
    case 'C': apb2enr->IOPCEN = 1; break;
    case 'c': apb2enr->IOPCEN = 1; break;
    case 'D': apb2enr->IOPDEN = 1; break;
    case 'd': apb2enr->IOPDEN = 1; break;
    case 'E': apb2enr->IOPEEN = 1; break;
    case 'e': apb2enr->IOPEEN = 1; break;
  }
}


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

Тоже самое делаем для включения тактирвания другой периферии.

В итоге получился такой класс (не все перечислено):

STM32F1xx_RCC.h

#pragma once
#include "stdint.h"
namespace STM32F1xx
{
  class RCC
  {
  protected:
    enum AddrRCC
    {
      RCC_CR          = 0x40021000,
      RCC_CFGR        = 0x40021004,
      RCC_CIR         = 0x40021008,
      RCC_APB2RSTR    = 0x4002100C,
      RCC_APB1RSTR    = 0x40021010,
      RCC_AHBENR      = 0x40021014,
      RCC_APB2ENR     = 0x40021018,
      RCC_APB1ENR     = 0x4002101C,
      RCC_BDCR        = 0x40021020,
      RCC_CSR         = 0x40021024
    };
    
    typedef struct {
      uint32_t  HSION          : 1;
      uint32_t  HSIRDY         : 1;
      uint32_t                 : 1;
      uint32_t  HSI_TRIM       : 5;
      uint32_t  HSI_CAL        : 8;
      uint32_t  HSEON          : 1;
      uint32_t  HSERDY         : 1;
      uint32_t  HSEBYP         : 1;
      uint32_t  CSSON          : 1;
      uint32_t                 : 4;
      uint32_t  PLLON          : 1;
      uint32_t  PLLRDY         : 1;
      uint32_t                 : 6;
    } RCC_CR_b;
		
    typedef struct {
      uint32_t  SW             : 2;
      uint32_t  SWS            : 2;
      uint32_t  HPRE           : 4;
      uint32_t  PPRE1          : 3;
      uint32_t  PPRE2          : 3;
      uint32_t  ADC_PRE        : 2;
      uint32_t  PLLSRC         : 1;
      uint32_t  PLLXTPRE       : 1;
      uint32_t  PLLMUL         : 4;
      uint32_t  USBPRE         : 1;
      uint32_t                 : 1;
      uint32_t  MCO            : 3;
      uint32_t                 : 5;
    } RCC_CFGR_b;

    typedef struct
    {
      uint32_t  TIM2EN         : 1;
      uint32_t  TIM3EN         : 1;
      uint32_t  TIM4EN         : 1;
      uint32_t                 : 8;
      uint32_t  WWDGEN         : 1;
      uint32_t                 : 2;
      uint32_t  SPI2EN         : 1;
      uint32_t                 : 2;
      uint32_t  USART2EN       : 1;
      uint32_t  USART3EN       : 1;
      uint32_t                 : 2;
      uint32_t  I2C1EN         : 1;
      uint32_t  I2C2EN         : 1;
      uint32_t  USBEN          : 1;
      uint32_t                 : 1;
      uint32_t  CANEN          : 1;
      uint32_t                 : 1;
      uint32_t  BKPEN          : 1;
      uint32_t  PWREN          : 1;
      uint32_t                 : 3;
    } RCC_APB1ENR_b;
		
    typedef struct
    {
      uint32_t  AFIOEN         : 1;
      uint32_t                 : 1;
      uint32_t  IOPAEN         : 1;
      uint32_t  IOPBEN         : 1;
      uint32_t  IOPCEN         : 1;
      uint32_t  IOPDEN         : 1;
      uint32_t  IOPEEN         : 1;
      uint32_t                 : 2;
      uint32_t  ADC1EN         : 1;
      uint32_t  ADC2EN         : 1;
      uint32_t  TIM1EN         : 1;
      uint32_t  SPI1EN         : 1;
      uint32_t                 : 1;
      uint32_t  USART1EN       : 1;
      uint32_t                 :17;
    } RCC_APB2ENR_b;

    typedef struct {
      uint32_t  DMAEN          : 1;
      uint32_t                 : 1;
      uint32_t  SRAMEN         : 1;
      uint32_t                 : 1;
      uint32_t  FLITFEN        : 1;
      uint32_t                 : 1;
      uint32_t  CRCEN          : 1;
      uint32_t                 :25;
    } RCC_AHBENR_r;
    
  public:
    static void EnablePort(uint8_t port_name)
    {
      volatile RCC_APB2ENR_b* apb2enr = reinterpret_cast(RCC_APB2ENR);
      switch (port_name)
      {
        case 'A': apb2enr->IOPAEN = 1; break;
        case 'a': apb2enr->IOPAEN = 1; break;
        case 'B': apb2enr->IOPBEN = 1; break;
        case 'b': apb2enr->IOPBEN = 1; break;
        case 'C': apb2enr->IOPCEN = 1; break;
        case 'c': apb2enr->IOPCEN = 1; break;
        case 'D': apb2enr->IOPDEN = 1; break;
        case 'd': apb2enr->IOPDEN = 1; break;
        case 'E': apb2enr->IOPEEN = 1; break;
        case 'e': apb2enr->IOPEEN = 1; break;
      }
    }

    static void DisablePort(char port_name)
    {
      volatile RCC_APB2ENR_b* apb2enr = reinterpret_cast(RCC_APB2ENR);
      switch (port_name)
      {
        case 'A': apb2enr->IOPAEN = 0; break;
        case 'a': apb2enr->IOPAEN = 0; break;
        case 'B': apb2enr->IOPBEN = 0; break;
        case 'b': apb2enr->IOPBEN = 0; break;
        case 'C': apb2enr->IOPCEN = 0; break;
        case 'c': apb2enr->IOPCEN = 0; break;
        case 'D': apb2enr->IOPDEN = 0; break;
        case 'd': apb2enr->IOPDEN = 0; break;
        case 'E': apb2enr->IOPEEN = 0; break;
        case 'e': apb2enr->IOPEEN = 0; break;
      }
    }

    static void EnableAFIO()
    {
      volatile RCC_APB2ENR_b* apb2enr = reinterpret_cast(RCC_APB2ENR);
      apb2enr->AFIOEN = 1;
    }

    static void DisableAFIO()
    {
      volatile RCC_APB2ENR_b* apb2enr = reinterpret_cast(RCC_APB2ENR);
      apb2enr->AFIOEN = 0;
    }
    
    static void EnableI2C(int PortNumber)
    {
      switch (PortNumber)
      {
        case 1:
        {
          volatile RCC_APB1ENR_b* apb1enr = reinterpret_cast(RCC_APB1ENR);
          apb1enr->I2C1EN = 1;
          break;
        }
        case 2:
        {
          volatile RCC_APB1ENR_b* apb1enr = reinterpret_cast(RCC_APB1ENR);
          apb1enr->I2C2EN = 1;
          break;
        }

      }
    }

    static void EnableUART(int PortNumber)
    {
      switch (PortNumber)
      {
        case 1:
        {
          volatile RCC_APB2ENR_b* apb2enr = reinterpret_cast(RCC_APB2ENR);
          apb2enr->USART1EN = 1;
          break;
        }
        case 2:
        {
          volatile RCC_APB1ENR_b* apb1enr = reinterpret_cast(RCC_APB1ENR);
          apb1enr->USART2EN = 1;
          break;
        }
        case 3:
        {
          volatile RCC_APB1ENR_b* apb1enr = reinterpret_cast(RCC_APB1ENR);
          apb1enr->USART3EN = 1;
          break;
        }

      }
    }
    
    static void DisableUART(int PortNumber)
    {
      switch (PortNumber)
      {
        case 1:
        {
          volatile RCC_APB2ENR_b* apb2enr = reinterpret_cast(RCC_APB2ENR);
          apb2enr->USART1EN = 0;
          break;
        }
        case 2:
        {
          volatile RCC_APB1ENR_b* apb1enr = reinterpret_cast(RCC_APB1ENR);
          apb1enr->USART2EN = 0;
          break;
        }
        case 3:
        {
          volatile RCC_APB1ENR_b* apb1enr = reinterpret_cast(RCC_APB1ENR);
          apb1enr->USART3EN = 0;
          break;
        }
      }
    }
    
    static void EnableSPI(int PortNumber)
    {
      switch (PortNumber)
      {
        case 1:
        {
          volatile RCC_APB2ENR_b* apb2enr = reinterpret_cast(RCC_APB2ENR);
          apb2enr->SPI1EN = 1;
          break;
        }
        case 2:
        {
          volatile RCC_APB1ENR_b* apb1enr = reinterpret_cast(RCC_APB1ENR);
          apb1enr->SPI2EN = 1;
          break;
        }
      }
    }

    static void DisableSPI(int PortNumber)
    {
      switch (PortNumber)
      {
        case 1:
        {
          volatile RCC_APB2ENR_b* apb2enr = reinterpret_cast(RCC_APB2ENR);
          apb2enr->SPI1EN = 0;
          break;
        }
        case 2:
        {
          volatile RCC_APB1ENR_b* apb1enr = reinterpret_cast(RCC_APB1ENR);
          apb1enr->SPI2EN = 0;
          break;
        }
      }
    }
    
    static void EnableDMA()
    {
      volatile RCC_AHBENR_r* ahbenr = reinterpret_cast(RCC_AHBENR);
      ahbenr->DMAEN = 1;
    }
    
    static void DisableDMA()
    {
      volatile RCC_AHBENR_r* ahbenr = reinterpret_cast(RCC_AHBENR);
      ahbenr->DMAEN = 0;
    }
  };
}


Теперь можно в main.cpp присоединить файл и пользоваться:

#include "STM32F1xx_RCC.h"

using namespace STM32F1xx;

int main()
{
  RCC::EnablePort('c');
  return 0;
}


Теперь можно и с портами поработать. GPIO


Открываем в документации раздел General-purpose and alternate-function I/Os. Находим Port bit configuration table:

m8asezmgpfvjgxxthbnq1rkpbxq.jpeg

Битами CNF[1:0] задается режим работы порта (аналоговый вход, цифровой вход, выход), биты MODE[1:0] отвечат за скорость работы порта в режиме выход.

Взглянем на регистры GPIOx_CRL и GPIOx_CRH (x=A, B, C,…)

fie5khvmvuritwpu0lz3zm_gndu.jpeg

видно, что биты идут последовательно:

CNF[1:0], MODE[1:0]

тогда создадим константы с режимами работы портов

enum mode_e
{
  ANALOGINPUT             = 0,
  INPUT                   = 4,
  INPUTPULLED             = 8,

  OUTPUT_10MHZ            = 1,
  OUTPUT_OD_10MHZ         = 5,
  ALT_OUTPUT_10MHZ        = 9,
  ALT_OUTPUT_OD_10MHZ     = 13,

  OUTPUT_50MHZ            = 3,
  OUTPUT_OD_50MHZ         = 7,
  ALT_OUTPUT_50MHZ        = 11,
  ALT_OUTPUT_OD_50MHZ     = 15,

  OUTPUT_2MHZ             = 2,
  OUTPUT_OD_2MHZ          = 6,
  ALT_OUTPUT_2MHZ         = 10,
  ALT_OUTPUT_OD_2MHZ      = 14,

  OUTPUT                  = 3,
  OUTPUT_OD               = 7,
  ALT_OUTPUT              = 11,
  ALT_OUTPUT_OD           = 15
};


тогда метод для конфигурации будет выглядеть так:

// pin_number - номер порта
void Mode(mode_e mode)
{
  uint32_t* addr;
  if(pin_number > 7)
    addr = reinterpret_cast(GPIOA_CRH);
  else
    addr = reinterpret_cast(GPIOA_CRL);
  
  int bit_offset;
  if(pin_number > 7)
    bit_offset = (pin_number - 8) * 4;
  else
    bit_offset = pin_number * 4;

  uint32_t mask = ~(15 << bit_offset);
  *addr &= mask;
  *addr |= ((int)mode) << bit_offset;
}


теперь можно сделать более удобные методы для выбора режима:

    void ModeInput()              { Mode(INPUT);         }
    void ModeAnalogInput()        { Mode(ANALOGINPUT);   }
    void ModeInputPulled()        { Mode(INPUTPULLED);   }
    void ModeOutput()             { Mode(OUTPUT);        }
    void ModeOutputOpenDrain()    { Mode(OUTPUT_OD);     }
    void ModeAlternate()          { Mode(ALT_OUTPUT);    }
    void ModeAlternateOpenDrain() { Mode(ALT_OUTPUT_OD); }


В документации находим адреса управляющих регистров для портов и перечислим:

enum AddrGPIO
{
  PortA           = 0x40010800,
  GPIOA_CRL       = 0x40010800,
  GPIOA_CRH       = 0x40010804,
  GPIOA_IDR       = 0x40010808,
  GPIOA_ODR       = 0x4001080C,
  GPIOA_BSRR      = 0x40010810,
  GPIOA_BRR       = 0x40010814,
  GPIOA_LCKR      = 0x40010818,
  PortB           = 0x40010C00,
  PortC           = 0x40011000,
  PortD           = 0x40011400,
  PortE           = 0x40011800,
  PortF           = 0x40011C00,
  PortG           = 0x40012000
};


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

Модернизируем метод:

if(pin_number > 7)
  addr = reinterpret_cast(GPIOA_CRH - PortA + PortAddr);
else
  addr = reinterpret_cast(GPIOA_CRL - PortA + PortAddr);


Возможно, у кого-то будет глаз дергаться, но красивее пока не придумал.

Чтобы перевести ножку в нужное логическое состояние, достаточно записать соответствующий бит в регистре ODRx. Например, так:

void Set(bool st)
{
  uint32_t* addr;
  addr = reinterpret_cast(GPIOA_ODR - PortA + PortAddr);

  if(st)
    *addr |= 1 << pin_number;
  else
  {
    int mask = ~(1 << pin_number);
    *addr &= mask;
  } 
}


Также для управления состоянием можно воспользоваться регистрами GPIOx_BSRR.
По аналогии делаем методы для считывания состояния порта, методы для конфигурации и инициализации (не забываем включить тактирование). В итоге получился такой класс для работы с портами:

STM32F1xx_Pin.h
#pragma once
#include 
#include "STM32F1xx_RCC.h"

namespace STM32F1xx
{
  class Pin
  {
  public:
    enum mode_e
    {
      ANALOGINPUT             = 0,
      INPUT                   = 4,
      INPUTPULLED             = 8,

      OUTPUT_10MHZ            = 1,
      OUTPUT_OD_10MHZ         = 5,
      ALT_OUTPUT_10MHZ        = 9,
      ALT_OUTPUT_OD_10MHZ     = 13,

      OUTPUT_50MHZ            = 3,
      OUTPUT_OD_50MHZ         = 7,
      ALT_OUTPUT_50MHZ        = 11,
      ALT_OUTPUT_OD_50MHZ     = 15,

      OUTPUT_2MHZ             = 2,
      OUTPUT_OD_2MHZ          = 6,
      ALT_OUTPUT_2MHZ         = 10,
      ALT_OUTPUT_OD_2MHZ      = 14,

      OUTPUT                  = 3,
      OUTPUT_OD               = 7,
      ALT_OUTPUT              = 11,
      ALT_OUTPUT_OD           = 15
    };
    
  private:
    enum AddrGPIO
    {
      PortA           = 0x40010800,
      GPIOA_CRL       = 0x40010800,
      GPIOA_CRH       = 0x40010804,
      GPIOA_IDR       = 0x40010808,
      GPIOA_ODR       = 0x4001080C,
      GPIOA_BSRR      = 0x40010810,
      GPIOA_BRR       = 0x40010814,
      GPIOA_LCKR      = 0x40010818,
      PortB           = 0x40010C00,
      PortC           = 0x40011000,
      PortD           = 0x40011400,
      PortE           = 0x40011800,
      PortF           = 0x40011C00,
      PortG           = 0x40012000
    };
    
  private:
    int   pin_number;
    int   PortAddr;
    
  public:
    Pin()                               { }
    Pin(char port_name, int pin_number) { Init(port_name, pin_number); }
    ~Pin()
    {
      Off();
      ModeAnalogInput();
    }
  public:
    void Init(char port_name, int pin_number)
    {
      this->pin_number = pin_number;
      RCC::EnablePort(port_name);
      switch (port_name)
      {
        case 'A': PortAddr = PortA; break;
        case 'a': PortAddr = PortA; break;
        case 'B': PortAddr = PortB; break;
        case 'b': PortAddr = PortB; break;
        case 'C': PortAddr = PortC; break;
        case 'c': PortAddr = PortC; break;
        case 'D': PortAddr = PortD; break;
        case 'd': PortAddr = PortD; break;
        case 'E': PortAddr = PortE; break;
        case 'e': PortAddr = PortE; break;
      }
    }

    void ModeInput()              { Mode(INPUT);         }
    void ModeAnalogInput()        { Mode(ANALOGINPUT);   }
    void ModeInputPulled()        { Mode(INPUTPULLED);   }
    void ModeOutput()             { Mode(OUTPUT);        }
    void ModeOutputOpenDrain()    { Mode(OUTPUT_OD);     }
    void ModeAlternate()          { Mode(ALT_OUTPUT);    }
    void ModeAlternateOpenDrain() { Mode(ALT_OUTPUT_OD); }

    void NoPullUpDown()
    {
      uint32_t* addr;
      if(pin_number > 7)
        addr = reinterpret_cast(GPIOA_CRH - PortA + PortAddr);
      else
        addr = reinterpret_cast(GPIOA_CRL - PortA + PortAddr);

      int bit_offset;
      if(pin_number > 7)
        bit_offset = (pin_number - 8) * 4;
      else
         bit_offset = pin_number * 4;

      int mask = ~((1 << 3) << bit_offset);
      *addr &= mask;
    }
    
    void Mode(mode_e mode)
    {
      uint32_t* addr;
      if(pin_number > 7)
        addr = reinterpret_cast(GPIOA_CRH - PortA + PortAddr);
      else
        addr = reinterpret_cast(GPIOA_CRL - PortA + PortAddr);
      
      int bit_offset;
      if(pin_number > 7)
        bit_offset = (pin_number - 8) * 4;
      else
        bit_offset = pin_number * 4;

      uint32_t mask = ~(15 << bit_offset);
      *addr &= mask;
      *addr |= ((int)mode) << bit_offset;
    }

    void Set(bool st)
    {
      uint32_t* addr;
      addr = reinterpret_cast(GPIOA_ODR - PortA + PortAddr);

      if(st)
        *addr |= 1 << pin_number;
      else
      {
        int mask = ~(1 << pin_number);
        *addr &= mask;
      } 
    }

    void On()
    {
      uint32_t* addr;
      addr = reinterpret_cast(GPIOA_ODR - PortA + PortAddr);
      int bit_offset = pin_number;
      *addr |= 1 << bit_offset;
    }

    void Off()
    {
      uint32_t* addr;
      addr = reinterpret_cast(GPIOA_ODR - PortA + PortAddr);
      int bit_offset = pin_number;
      int mask = ~(1 << bit_offset);
      *addr &= mask;
    }

    bool Get()
    {
      uint32_t* addr = reinterpret_cast(GPIOA_IDR - PortA + PortAddr);
      int bit_offset = pin_number;
      int mask = (1 << bit_offset);
      bool ret_val = (*addr & mask);
      return ret_val;
    }
  };
};


Ну что, опробуем:

#include "STM32F1xx_Pin.h"

using namespace STM32F1xx;

Pin led('c', 13);

int main()
{
  led.ModeOutput();
  led.On();
  led.Off();
  return 0;
}


Проходим дебагером и убеждаемся, что светодиод сначала загорается (после led.ModeOutput ();), потом гаснет (led.On ();) и снова загорается (led.Off ();). Это связано с тем, что светодиод подключен к ножке через линию питания. Поэтому, когда на выводе низкий уровень, светодиод загорается.

Не большие итоги


В данной статье я попытался (надеюсь, получилось) показать как можно немного упростить себе жизнь, сделать код более читаемым. Или наоборот — как нельзя делать. Каждый решит сам.
Можно было просто написать враперы для CMSIS, но это не интересно.

Спасибо за уделенное время. Если интересно продолжение — дайте знать.

© Habrahabr.ru