10++ способов работать с аппаратными регистрами на С++ (на примере IAR и Cortex M)

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Choosing the safest path
Рис. И. Кийко

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

Помните наверное бородатый анекдот, а может быть и правдивую историю про то, как студента спрашивали о способе измерить высоту здания с помощью барометра. Студент привел, по-моему около 20 или 30 способов, при этом не назвав прямого(через разницу давления), которого ожидал преподаватель.

Примерно в том же ключе я хочу продолжить обсуждение использования С++ для микроконтроллеров и рассмотреть способы как можно работать с регистрами используя С++. И хочу заметить, что для достижения безопасного обращения к регистрам простого пути не будет. Попытаюсь показать все плюсы и минусы способов. Если вы знаете еще способы, кидайте их в комментарии. Итак начнем:

Способ 1. Очевидный и, очевидно, не самый лучший


Самый распространенный способ, который также применяется в С++, является использование описания структур регистров из заголовочного файла от производителя. Для демонстрации я возьму два регистра порта А (ODR — регистр выходных данных и IDR — регистра входных данных) микроконтроллера STM32F411, чтобы можно было выполнить «ембедерский» «Hello world» — моргнуть светодиодом.

int main() {
  GPIOA->ODR ^= (1 << 5) ;
  GPIOA->IDR ^= (1 << 5) ; //ГЛУПОСТЬ, но я же не знал
}

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

typedef struct
{
  __IO uint32_t MODER;   //port mode register,  Address offset: 0x00      
  __IO uint32_t OTYPER;  //port output type register,  Address offset: 0x04
  __IO uint32_t OSPEEDR; //port output speed register,  Address offset: 0x08
  __IO uint32_t PUPDR;   //port pull-up/pull-down register, Address offset: 0x0C
  __IO uint32_t IDR;     //port input data register,  Address offset: 0x10 
  __IO uint32_t ODR;     //port output data register, Address offset: 0x14
  __IO uint32_t BSRR;    //port bit set/reset register, Address offset: 0x18
  __IO uint32_t LCKR;    //port configuration lock register, Address offset: 0x1C
  __IO uint32_t AFR[2];  //alternate function registers, Address offset: 0x20-0x24
} GPIO_TypeDef;

#define PERIPH_BASE     0x40000000U //Peripheral base address in the alias region  
#define AHB1PERIPH_BASE (PERIPH_BASE + 0x00020000U)
#define GPIOA_BASE          (AHB1PERIPH_BASE + 0x0000U)

#define GPIOA             ((GPIO_TypeDef *) GPIOA_BASE)

Если выразиться простыми человеческими словам, то вся структура типа GPIO_TypeDef «ложится» по адресу GPIOA_BASE, а при обращении к конкретному полю структуры, вы по сути обращается к адресу этой структуры + смещение до элемента этой структуры. Если убрать #define GPIOA, то код выглядел бы так:

((GPIO_TypeDef *) GPIOA_BASE)->ODR ^= (1 << 5) ;
((GPIO_TypeDef *) GPIOA_BASE)->IDR ^= (1 << 5) ; //ГЛУПОСТЬ

Применительно к языку программирования С++ здесь происходит преобразование целочисленного адреса к типу указатель на структуру GPIO_TypeDef. Но в С++ при использовании Си преобразования компилятор пытается выполнить преобразование в следующей последовательности:

  • const_cast
  • static_cast
  • static_cast следующей за const_cast,
  • reinterpret_cast
  • reinterpret_cast следующий за const_cast

т.е. если компилятор не смог преобразовать тип используя const_cast, он пытается применить static_cast и так далее. В итоге вызов:

((GPIO_TypeDef *) GPIOA_BASE)->ODR ^= (1 << 5) ;

есть ни что иное как:

reinterpret_cast<GPIO_TypeDef *> (GPIOA_BASE)->ODR ^= (1 << 5) ;

На самом деле для С++ приложений правильно было бы «натянуть» структуру на адрес вот так:

GPIO_TypeDef * GPIOA{reinterpret_cast<GPIO_TypeDef *>(GPIOA_BASE)} ;

В любом случае из-за преобразования типов существует большой минус этого подхода для С++. Заключается он в том, что reinterpret_cast нельзя использовать ни в constexpr конструкторах и функциях, ни в параметрах шаблона, а это существенно сужает использование возможностей С++ для микроконтроллеров.
Поясню это на примерах. Вполне возможно сделать так:

 struct Test {
  const int a;
  const int b;
} ;

template<Test* mystruct>
constexpr const int Geta() {
  return mystruct->a;
}

Test test{1,2};
int main() {
  Geta<&test>() ;
}

Но вот так уже сделать нельзя:

 
template<GPIO_TypeDef * mystruct>
constexpr volatile uint32_t GetIdr() {
  return mystruct->IDR;
}
int main() {
//GPIOA это  reinterpret_cast<GPIO_TypeDef *> (GPIOA_BASE) 
//использует преобразование типов, и в параметры шаблона его передавать нельзя
  GetIdr<GPIOA>() ; //Ошибка
}

// И вот так тоже сделать нельзя:
struct Port {
  constexpr Port(GPIO_TypeDef * ptr): port(*ptr) {} 
  GPIO_TypeDef & port ;
}
//Так как GPIOA использует reinterpret_cast, то конструктор 
//перестает быть constexpr и невозможно выполнить статическую инициализацию
constexpr Port portA{GPIOA}; // тут будет ошибка

Таким образом прямое использование такого подхода накладывает существенные ограничения на использование С++. Мы не сможем расположить объект, который хочет использовать указатель на GPIOA в ROM, используя средства языка, и не сможем использовать преимущества метапрограммирования для такого объекта.
Кроме того, вообще такой способ не safety (как говорят наши западные партнеры). Ведь вполне возможно сделать какую-то ГЛУПОСТЬ
В связи с вышесказанным резюмируем:

Плюсы


  • Используется заголовочник от производителя (он проверен, в нем нет ошибок)
  • Нет дополнительных телодвижений и затрат, берешь и используешь
  • Простота использования
  • Все знают и понимают этот способ
  • Никаких накладных

Минусы


  • Ограниченное использование метапрограммирования
  • Невозможность использовать в constexpr конструкторах
  • При использовании в классах обертках, дополнительных расход ОЗУ, на указатель на объект этой структуры
  • Можно сделать ГЛУПОСТЬ
Теперь посмотрим на способ №2

Способ 2. Брутальный


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

*reinterpret_cast<volatile uint32_t *>(GpioaOdrAddr) ^= (1 <<5) ;
*reinterpret_cast<volatile uint32_t *>(GpioaIdrAddr) ^= (1 <<5) ; //ГЛУПОСТЬ

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

Способ 3. Очевидный и очевидно правильнее


Если доступ к регистрам происходит через поле структуры, то вместо указателя на объект структуры можно использовать целочисленный адрес структуры. Адрес структур есть в заголовочном файле от производителя (например, GPIOA_BASE для GPIOA), поэтому его не надо помнить, а применять можно и в шаблонах и в constexpr выражениях, а затем уже «накладывать» структуру на этот адрес.

template<uint32_t addr, uint32_t pinNum>
  struct Pin {   
      using Registers = GPIO_TypeDef ;
      __forceinline static void Toggle() {
        // располагаем структуру по адресу addr
        Registers *GpioPort{reinterpret_cast<Registers*>(addr)}; 
        GpioPort->ODR ^= (1 << pinNum) ;
      }
  };
int main() {
  using Led1 =  Pin<GPIOA_BASE, 5> ;
  Led1::Toggle() ;
}

Особых минусов, с моей точки зрения нет. В принципе рабочий вариант. Но все равно, давайте разберем другие способы.

Способ 4. Экзотерическая обертка


Для ценителей понятного кода, можно сделать обертку над регистром, чтобы обращаться к ним было удобно и выглядело «красиво», сделать конструктор, переопределить операторы:

class Register  {
    public:
      explicit Register(uint32_t addr) : ptr{ reinterpret_cast<volatile uint32_t *>(addr) } {
      }

      __forceinline inline Register& operator^=(const uint32_t right)  {
        *ptr ^= right;
        return *this;
      }

    private:
      volatile uint32_t *ptr; //указатель хранящий адрес регистра
  };

int  main() {
    Register Odr{GpioaOdrAddr};
    Odr ^= (1 << 5);
    Register Idr{GpioaIdrAddr};
    Idr ^= (1 << 5); //ГЛУПОСТЬ
}

Как видно, снова придется либо помнить целочисленные адреса всех регистров, либо где-то их задавать, а еще придется хранить указатель на адрес регистра. Но что опять не очень, снова в конструкторе происходит reinterpret_cast
Одни минусы, а к тем, что в первом и втором варианте добавилась еще необходимость на каждый используемый регистр хранить указатель в 4 байта в ОЗУ. В общем не вариант. Смотрим следующий.

Способ 4,5. Экзотерическая обертка с шаблоном


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

template<uint32_t addr>
  class Register  {
    public:
      Register() : ptr{reinterpret_cast<volatile uint32_t *>(addr)}  {
      }

      __forceinline inline Register &operator^=(const uint32_t right)  {
        *ptr ^= right;
        return *this;
      }

    private:
      volatile std::uint32_t *ptr;
  };

int main() {
    using GpioaOdr = Register<GpioaOdrAddr>;
    GpioaOdr Odr;
    Odr ^= (1 << 5);
    using GpioaIdr = Register<GpioaIdrAddr>;
    GpioaIdr Idr;
    Idr ^= (1 << 5); //ГЛУПОСТЬ
}

А так, те же грабли, вид сбоку.

Способ 5. Разумный


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

template<uint32_t addr>
  class Register  {
    public:
      __forceinline  Register &operator^=(const uint32_t right)   {
        *reinterpret_cast<volatile uint32_t *>(addr) ^= right;
        return *this;
      }
  };
   using GpioaOdr = Register<GpioaOdrAddr>;
    GpioaOdr Odr;
    Odr ^= (1 << 5);
    using GpioaIdr = Register<GpioaIdrAddr>;
    GpioaIdr Idr;
    Idr ^= (1 << 5); //ГЛУПОСТЬ

Можно остановиться здесь и немного порассуждать. Этот способ сразу решает 2 проблемы, которые до этого наследовались от первого метода. Во первых, теперь я могу использовать указатель на объект Register в шаблоне, а во вторых я его могу передавать в constexrp конструктор.

template<Register * register>
void Xor(uint32_t mask) {
  return *register ^= mask ;
}
Register<GpioaOdrAddr>  GpioAOdr;
int main() {
  Xor<&GpioaOdr>(1 << 5) ; //Все Ок
}
//и так могу
struct Port {
  constexpr Port(Register& ref): register(ref) {} 
  Register & register ;
}
constexpr Port portA{GpioaOdr}; 

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

Плюсы


  • Простота использования
  • Возможность использования метапрограммирования
  • Возможность использовать в constexpr конструкторах

Минусы


  • Не используется проверенный заголовочный файл от производителя
  • Надо самому задавать все адреса регистров
  • Нужно создавать объект класс Register
  • Можно сделать ГЛУПОСТЬ

Отлично, но минусов все еще много…

Способ 6. Разумнее разумного


В предыдущем методе, чтобы обратиться к регистру необходимо было создать объект этого регистра, это ненужные траты ОЗУ и ПЗУ, поэтому делаем обертку со статическими методами.

template<uint32_t addr>
  class Register  {
    public:
      __forceinline  inline static void Xor(const uint32_t mask)
      {
        *reinterpret_cast<volatile uint32_t *>(addr) ^= mask;
      }
  };
int main() {
    using namespace Case6 ;
    using Odr = Register<GpioaOdrAddr>;
    Odr::Xor(1 << 5);
    using Idr = Register<GpioaIdrAddr>;
    Idr::Xor(1 << 5); //ГЛУПОСТЬ
}

Добавляется один плюс
  • Никаких накладных. Быстрый компактный код, такой же как и в варианте 1 (При использовании в классах обертках, нет дополнительных расходов ОЗУ, так как объект не создается, а используются статические методы без создания объектов)
Идем дальше…

Способ 7. Убираем ГЛУПОСТЬ


Очевидно, я постоянно делаю ГЛУПОСТЬ в коде и записываю что-то в регистр, который на самом деле для записи не предназначен. Ничего страшного конечно, но ГЛУПОСТИ надо запрещать. Давайте запретим делать ГЛУПОСТИ. Для этого введем вспомогательные структуры:

  struct WriteReg {};
  struct ReadReg {};
  struct ReadWriteReg: public WriteReg, public ReadReg {};

Теперь мы сможем задавать регистры для записи, и регистры только для чтения:

template<uint32_t addr, typename RegisterType>
  class Register 
  {
    public:
     //Если в параметр шаблона будет передавать тип WriteReg, то метод будет
    // инстанциирован, если нет, то такого метода существовать не будет 
      __forceinline template <typename T = RegisterType,
           class = typename std::enable_if_t<std::is_base_of<WriteReg, T>::value>>
      Register &operator^=(const uint32_t right)
      {
        *reinterpret_cast<volatile uint32_t *>(addr) ^= right;
        return *this;
      }
  };

Теперь попробуем откомпилировать наш тест и увидим, что тест не компилируется, потому что оператора ^= для регистра Idr не существует:

   int main()  {
    using GpioaOdr  = Register<GpioaOdrAddr, WriteReg> ;
    GpioaOdr Odr ;
    Odr ^= (1 << 5) ;
    using GpioaIdr  = Register<GpioaIdrAddr, ReadReg> ;
    GpioaIdr Idr ;
    Idr ^= (1 << 5) ; //ошибка, регистра Idr только для чтения
  }

Итак, теперь плюсов становится больше…

Плюсы


  • Простота использования
  • Возможность использования метапрограммирования
  • Возможность использовать в constexpr конструкторах
  • Быстрый компактный код, такой же как и в варианте 1
  • При использовании в классах обертках, нет дополнительных расходов ОЗУ, так как объект не создается, а используются статические методы без создания объектов
  • Нельзя сделать ГЛУПОСТЬ

Минусы


  • Не используется проверенный заголовочный файл от производителя
  • Надо самому задавать все адреса регистров
  • Нужно создавать объект класс Register

Что же давайте уберем возможность создавать класс, чтобы еще сэкономить

Способ 8. Без ГЛУПОСТИ и без объекта класса


Сразу код:

  struct WriteReg {};
  struct ReadReg {};
  struct ReadWriteReg: public WriteReg, public ReadReg {};

  template<uint32_t addr, typename T>
  class Register  {
      public:
      __forceinline template <typename T1 = T,
            class = typename std::enable_if_t<std::is_base_of<WriteReg, T1>::value>>
        inline static void Xor(const uint32_t mask)  {
          *reinterpret_cast<volatile int*>(addr) ^=  mask;
        }
    };

  int main {
    using GpioaOdr  = Register<GpioaOdrAddr, WriteReg> ;
    GpioaOdr::Xor(1 << 5) ;
    using GpioaIdr  = Register<GpioaIdrAddr, ReadReg> ;
    GpioaOdr::Idr(1 << 5) ; //ошибка, регистра Idr только для чтения
  }

Добавляем еще один плюс, объект не создаем. Но идем дальше, у нас еще остались минусы

Способ 9. Способ 8 с объединением в структуру


В предыдущем способе, был определен только регистр. Но в способе 1, все регистры объединены в структуры, чтобы можно было удобно по модулям обращаться к ним. Давайте так и сделаем…

namespace Case9
{
  struct WriteReg {};
  struct ReadReg {};
  struct ReadWriteReg: public WriteReg, public ReadReg {};

  template<uint32_t addr, typename T>
  class Register
    {
      public:
      __forceinline template <typename T1 = T,
            class = typename std::enable_if_t<std::is_base_of<WriteReg, T1>::value>>
        inline static void Xor(const uint32_t mask)
        {
          *reinterpret_cast<volatile int*>(addr) ^=  mask;
        }
    };

  template<uint32_t addr>
  struct Gpio  
  {
    using Moder = Register<addr, ReadWriteReg>; //надо знать сдвиг регистра в структуре
    using Otyper = Register<addr + OtyperShift, ReadWriteReg> ;
    using Ospeedr = Register<addr + OspeedrShift,ReadWriteReg> ;
    using Pupdr = Register<addr + PupdrShift,ReadWriteReg> ;
    using Idr = Register<addr + IdrShift, ReadReg> ;
    using Odr = Register<addr + OdrShift, WriteReg> ;
  };

int main() {
    using Gpioa = Gpio<GPIOA_BASE> ;
    Gpioa::Odr::Xor(1 << 5) ;
    Gpioa::Idr::Xor((1 << 5) ); //ошибка,  регистр Idr только для чтения
  }

Здесь минус заключается в том, что структуры надо будет прописывать заново, а смещения всех регистров помнить и определять где-то. Было бы хорошо, если бы смещения задавались компилятором, а не человеком, но это позже, а пока рассмотрим еще один интересный способ, подсказанный моим коллегой.

Способ 10. Обертка над регистром через указатель на член структуры


Здесь используется такое понятие как указатель на член структуры и доступ к ним.

template<uint32_t addr, typename T>
class RegisterStructWrapper {
public:
  __forceinline  template<typename P>
   inline static void Xor(P T::*member, int mask) {
    reinterpret_cast<T*>(addr)->*member ^= mask ; \\Обращаемся к члену структуры, который передали в параметре шаблона. 
  }  
} ;

using GpioaWarpper = RegisterStructWrapper<GPIOA_BASE, GPIO_TypeDef> ;
int main() {
   GpioaWarpper::Xor(&GPIO_TypeDef::ODR, (1 << 5)) ;
  GpioaWarpper::Xor(&GPIO_TypeDef::IDR, (1 << 5)) ;  //ГЛУПОСТЬ
  return 0 ;
}

Плюсы


  • Простота использования
  • Возможность использования метапрограммирования
  • Возможность использовать в constexpr конструкторах
  • Быстрый компактный код, такой же как и в варианте 1
  • При использовании в классах обертках, нет дополнительных расходов ОЗУ, так как объект не создается, а используются статические методы без создания объектов
  • Используется проверенный заголовочный файл от производителя
  • Не нужно самому задавать все адреса регистров
  • Не нужно создавать объект класс Register

Минусы


  • Можно сделать ГЛУПОСТЬ и еще порассуждать на тему понятности кода

Способ 10.5. Объединяем метод 9 и 10


Чтобы узнать смещение регистра относительно начала структуры, можно использовать указатель на член структуры: volatile uint32_t T::*member, он нам вернет смещение члена структуры относительно её начала в байтах. Например есть у нас структура
GPIO_TypeDef, то адрес &GPIO_TypeDef::ODR будет равен 0х14.
Обыграем эту возможность и вычислим адреса регистров из способа 9, с помощью компилятора:

struct WriteReg {};
  struct ReadReg {};
  struct ReadWriteReg: public WriteReg, public ReadReg {};

  template<uint32_t addr, typename T, volatile uint32_t T::*member, typename RegType>
  class Register {
    public:
      __forceinline template <typename T1 = RegType,
        class = typename std::enable_if_t<std::is_base_of<WriteReg, T1>::value>>
      inline static void Xor(const uint32_t mask)
      {
        reinterpret_cast<T*>(addr)->*member ^= mask ;
      }
  };

  template<uint32_t addr, typename T>
  struct Gpio
  {
    using Moder = Register<addr, GPIO_TypeDef, &GPIO_TypeDef::ODR, ReadWriteReg>;
    using Otyper = Register<addr, GPIO_TypeDef, &GPIO_TypeDef::OTYPER, ReadWriteReg>;
    using Ospeedr = Register<addr, GPIO_TypeDef, &GPIO_TypeDef::OSPEEDR, ReadWriteReg>;
    using Pupdr = Register<addr, GPIO_TypeDef, &GPIO_TypeDef::PUPDR, ReadWriteReg>;
    using Idr = Register<addr, GPIO_TypeDef, &GPIO_TypeDef::IDR, ReadReg>;
    using Odr = Register<addr, GPIO_TypeDef, &GPIO_TypeDef::ODR, WriteReg>;
  } ;

Работать с регистрами можно более экзотерично:

using namespace Case11 ;
    using Gpioa = Gpio<GPIOA_BASE, GPIO_TypeDef> ;
    Gpioa::Odr::Xor(1 << 5) ;
    //Gpioa::Idr::Xor((1 << 5) ); //ошибка,  регистр Idr только для чтения

Очевидно, что тут придется все структуры переписать снова. Это можно сделать автоматически, каким-нибудь скриптом на Phyton, на входе что-то типа stm32f411xe.h на выходе ваш файл со структурами для использования в С++.
В любом случае, есть несколько различных способов, которые могут подойти в конкретном проекте.

Бонус. Вводим расширение языка и парсим код с помощью Phyton


Проблема работы с регистрами на С++ существует уже давненько. Люди решают её по разному. Конечно было бы замечательно, если бы язык поддерживал что-то типа переименования классов во время компиляции. Ну скажем, а что если было бы так:

template<classname = [PortName]>
class Gpio[Portname] {
   __forceinline  inline static void Xor(const uint32_t mask)  {
        GPIO[PortName]->ODR ^=  mask ;
      }
}; 

int main() {
  using GpioA = Gpio<"A"> ;
  GpioA::Xor(5) ;
}

Но к сожалению такого язык не поддерживает. Поэтому решение которое используют люди, это парсинг кода с помощью Python. Т.е. вводится некоторое расширение языка. Код, с использованием этого расширения, подается на Python парсер, который переводит его в С++ код. Такой код выглядит приблизительно так: (пример взят из modm библиотеки вот тут полные исходники ):
%% set port = gpio["port"] | upper
%% set reg  = "GPIO" ~ port
%% set pin  = gpio["pin"]
class Gpio{{ port ~ pin }} : public Gpio 
{
    __forceinline  inline static void Xor()  {
        GPIO{{port}}->ODR ^=  1 << {{pin}} ;
      }
}

//С помощью скрипта он преобразуется в следующий код
class GpioС5 : public Gpio 
{
    __forceinline  inline static void Xor()  {
        GPIOС->ODR ^=  1 << 5 ;
      }
}

//А использовать его можно так
using Led = GpioС5;

Led::Xor();


Обновление: Бонус. SVD файлы и парсер помощью Phyton


Забыл добавить еще один вариант. ARM выпускает файл описания регистров для каждого производителя SVD. Из которых потом можно сгенерировать С++ файл с описанием регистров. Paul Osborne собрал все эти файлы на GitHub. А также, он написал скрипт на Python для их парсинга.

На этом все… мое воображение исчерпалось. Если у вас еще есть идеи, велком. Пример со всеми способами лежит тут

Ссылки


Typesafe Register Access in C++
Making things do stuff -Accessing hardware from C++
Making things do stuff – Part 3
Making things do stuff- Structure overlay
Источник: https://habr.com/ru/post/459204/


Интересные статьи

Интересные статьи

Нередко при работе с Bitrix24 REST API возникает необходимость быстро получить содержимое определенных полей всех элементов какого-то списка (например, лидов). Традиционн...
Привет, Хабр. В прошлой статье я рассказывал о том, как мы создали фреймворк для перевода кода C# на (неуправляемый) C++, чтобы выпускать свои библиотеки, изначально разработанные для пла...
Всем привет! Не так давно на работе в рамках тестирования нового бизнес-процесса мне понадобилась возможность авторизации под разными пользователями. Переход в соответствующий р...
Данная заметка имеет собой цель продемонстрировать автоматический git bisect на примере ядра Linux. С последующим поиском официальной версии начиная с которой всё поломалось и последней хорошей в...
Часто при работе с Django и PostgreSQL возникает необходимость в дополнительных расширениях для базы данных. И если например с hstore или PostGIS (благодаря GeoDjango) всё достаточно удобно, то c...