Bekerja dengan daftar pin, dalam C ++ untuk mikrokontroler (menggunakan CortexM sebagai contoh)


Kesehatan yang baik untuk semua!


Dalam artikel sebelumnya, saya berjanji untuk menulis tentang bagaimana Anda dapat bekerja dengan daftar porta.
Saya harus mengatakan segera bahwa semuanya sudah diputuskan sebelum saya sudah pada tahun 2010, inilah artikelnya: Bekerja dengan port input / output dari mikrokontroler di C ++ . Orang yang menulis ini pada 2010 cukup tampan.


Saya sedikit malu bahwa saya akan melakukan apa yang sudah dilakukan 10 tahun yang lalu, jadi saya memutuskan untuk tidak menunggu tahun 2020, tetapi untuk melakukannya pada tahun 2019 untuk mengulangi keputusan selama 9 tahun yang lalu, itu tidak akan sebodoh itu.


Dalam artikel di atas, pekerjaan dengan daftar jenis dilakukan menggunakan C ++ 03, ketika lebih banyak templat memiliki jumlah parameter tetap, dan fungsi tidak bisa menjadi ekspresi constexpr. Sejak itu, C ++ telah berubah sedikit, jadi mari kita coba melakukan hal yang sama, tetapi dalam C ++ 17. Selamat datang di kucing:


Tantangan


Jadi, tugasnya adalah menginstal atau membuang beberapa pin prosesor sekaligus, yang digabungkan ke dalam daftar. Pin dapat ditemukan pada port yang berbeda, meskipun demikian, operasi seperti itu harus dilakukan seefisien mungkin.


Sebenarnya, apa yang ingin kita lakukan dapat ditunjukkan dengan kode:


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; } 

Tentang register BSRR

Bagi mereka yang tidak mengetahui masalah mikrokontroler, GPIOA->BSRR bertanggung jawab untuk pemasangan atom atau pengaturan ulang nilai pada kaki mikrokontroler. Daftar ini 32 bit. 16 bit pertama bertanggung jawab untuk pengaturan 1 pada kaki, 16 bit kedua untuk pengaturan 0 pada kaki.


Misalnya, untuk mengatur leg nomor 3 ke 1, Anda perlu mengatur bit ketiga ke 1 di register BSRR . Untuk mengatur ulang leg nomor 3 ke 0, Anda perlu mengatur 19 bit ke 1 di register BSRR sama.


Skema langkah-langkah umum untuk menyelesaikan masalah ini dapat direpresentasikan sebagai berikut:



Dengan kata lain:


Agar kompiler dapat melakukan untuk kita:


  • Verifikasi bahwa daftar hanya berisi Pin unik
  • membuat daftar port dengan menentukan port mana yang digunakan Pin,
  • menghitung nilai yang akan diletakkan di setiap port

Dan kemudian programnya


  • atur nilai ini

Dan Anda perlu melakukan ini seefisien mungkin, sehingga bahkan tanpa optimasi, kodenya minimal. Sebenarnya ini adalah seluruh tugas.


Mari kita mulai dengan mode pertama: Memverifikasi bahwa daftar tersebut berisi Pin unik.


Periksa daftar untuk keunikan


Biarkan saya mengingatkan Anda bahwa kami memiliki daftar Pin:


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

Secara tidak sengaja dapat melakukan ini:


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

Saya ingin kompiler menangkap kesalahan seperti itu dan memberi tahu pianis tentang hal itu.


Kami akan memeriksa daftar untuk keunikan sebagai berikut:


  • Dari daftar sumber, buat daftar baru tanpa duplikat,
  • Jika jenis daftar sumber dan jenis daftar tanpa duplikat tidak cocok, maka Pin itu sama dalam daftar sumber dan pemrogram membuat kesalahan.
  • Jika mereka cocok, maka semuanya baik-baik saja, tidak ada duplikat.

Untuk membuat daftar baru tanpa duplikat, seorang rekan menyarankan untuk tidak menemukan kembali roda dan mengambil pendekatan dari perpustakaan Loki. Saya punya pendekatan dan mencuri ini. Hampir sama dengan tahun 2010, tetapi dengan jumlah parameter variabel.


Kode yang dipinjam dari seorang rekan yang meminjam ide dari 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 //////////////// } 

Bagaimana ini bisa digunakan sekarang? Ya, ini sangat sederhana:


 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; } 

Baik i.e. jika Anda salah mengatur daftar pin, dan secara tidak sengaja dua pin identik ditunjukkan dalam daftar, program tidak akan dikompilasi, dan kompiler akan memberikan kesalahan: "Masalah: Pin yang sama dalam daftar."


Omong-omong, untuk memastikan daftar pin yang benar untuk port, Anda dapat menggunakan pendekatan berikut:
 //        // 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'); } 

Kami sudah menulis begitu banyak di sini, tetapi sejauh ini tidak ada satu baris kode nyata yang masuk ke dalam mikrokontroler. Jika semua pin diatur dengan benar, maka program firmware terlihat seperti ini:


 int main() { return 0 ; } 

Mari menambahkan beberapa kode dan mencoba membuat metode Set() untuk mengatur pin dalam daftar.


Metode Pemasangan Port Pin


Mari kita jalankan sedikit ke depan sampai akhir tugas. Pada akhirnya, perlu untuk menerapkan metode Set() , yang secara otomatis, berdasarkan pada Pin dalam daftar, akan menentukan nilai ke port mana yang harus diinstal.


Kode yang kita inginkan
 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); } 

Oleh karena itu, kami mendeklarasikan kelas yang akan berisi daftar Pin, dan di dalamnya kami mendefinisikan Set() public static method Set() .


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

Seperti yang Anda lihat, metode Set(size_t mask) mengambil semacam nilai (mask). Topeng ini adalah nomor yang perlu Anda masukkan ke port. Secara default, ini adalah 0xffffffff, yang berarti bahwa kami ingin meletakkan semua pin dalam daftar (maksimum 32). Jika Anda memberikan nilai lain di sana, misalnya, 7 == 0b111, maka hanya 3 pin pertama dalam daftar yang harus diinstal, dan seterusnya. Yaitu mask overlay pada daftar Pin.


Daftar Pelabuhan


Agar dapat memasang apa pun di pin, Anda harus tahu port mana yang digunakan pin ini. Setiap Pin terikat ke port tertentu dan kami dapat menarik port ini dari kelas Pin dan membuat daftar port ini.


Pin kami ditetapkan untuk port yang berbeda:


 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 Pin ini hanya memiliki 3 port unik (GPIOA, GPIOB, GPIOC). Jika kita mendeklarasikan daftar PinsPack<Pin1, Pin2, Pin3, Pin4, Pin5> , maka dari itu kita perlu mendapatkan daftar tiga port Collection<Port<GPIOA>, Port<GPIOB>, Port<GPIOC>>


Kelas Pin berisi jenis port dan dalam bentuk yang disederhanakan terlihat seperti ini:


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

Selain itu, Anda masih perlu mendefinisikan struktur untuk daftar ini, itu hanya akan menjadi struktur templat yang mengambil sejumlah variabel argumen templat


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

Sekarang kita mendefinisikan daftar port unik, dan pada saat yang sama memeriksa bahwa daftar pin tidak mengandung pin yang sama. Ini mudah dilakukan:


 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; ... } ; 

Silakan ...


Bypass Daftar Port


Setelah menerima daftar port, sekarang Anda perlu mem-bypassnya dan melakukan sesuatu dengan masing-masing port. Dalam bentuk yang disederhanakan, kita dapat mengatakan bahwa kita harus mendeklarasikan fungsi yang akan menerima daftar port dan mask untuk daftar pin pada input.


Karena kita harus mem-bypass daftar yang ukurannya tidak diketahui dengan pasti, fungsinya akan menjadi templat dengan sejumlah parameter parameter.


Kami akan berkeliling "secara rekursif" sementara masih ada parameter dalam templat, kami akan memanggil fungsi dengan nama yang sama.


 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) ; } } } 

Jadi, kami belajar cara mem-bypass daftar port, tetapi selain bypass, Anda perlu melakukan beberapa pekerjaan yang bermanfaat, yaitu, menginstal sesuatu di port.


 __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) ; } } 

Metode ini akan dieksekusi dalam runtime, karena parameter mask dilewatkan ke fungsi dari luar. Dan karena fakta bahwa kami tidak dapat menjamin bahwa konstanta akan diteruskan ke metode SetPorts() , metode GetValue() juga akan mulai dijalankan pada saat runtime.


Dan meskipun, dalam artikel Bekerja dengan port input / output dari mikrokontroler di C ++, ada tertulis bahwa dalam metode yang sama kompiler menentukan bahwa konstanta dilewati dan menghitung nilai untuk menulis ke port pada tahap kompilasi, kompiler saya melakukan trik seperti itu hanya pada optimasi maksimal.
Saya ingin GetValue() dijalankan pada waktu kompilasi dengan pengaturan kompiler.


Saya tidak menemukan dalam standar bagaimana kompiler harus memimpin kompiler dalam kasus ini, tetapi menilai oleh fakta bahwa kompiler IAR melakukan ini hanya pada tingkat optimalisasi maksimum, kemungkinan besar tidak diatur oleh standar atau tidak boleh dianggap sebagai ekspresi constexpr.
Jika ada yang tahu, tulis di komentar.


Untuk memastikan transfer eksplisit dari nilai konstan, kami akan membuat metode tambahan dengan passing mask di templat:


 __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...>()) ; } } 

Dengan demikian, kita sekarang dapat memeriksa daftar Pin, menarik port dari mereka dan membuat daftar port unik yang terikat, dan kemudian pergi ke daftar port yang dibuat dan menetapkan nilai yang diperlukan di setiap port.
Tetap menghitung nilai ini.


Perhitungan nilai yang akan diatur di pelabuhan


Kami memiliki daftar port yang kami dapatkan dari daftar Pin, untuk contoh kami ini adalah daftar: Collection<Port<GPIOA>, Port<GPIOB>, Port<GPIOC>> .
Anda perlu mengambil elemen daftar ini, misalnya, port GPIOA, kemudian di daftar Pin temukan semua Pin yang dilampirkan ke port ini dan hitung nilai untuk pemasangan di port tersebut. Dan kemudian lakukan hal yang sama dengan port selanjutnya.


Sekali lagi: Dalam kasus kami, daftar Pin tempat Anda perlu mendapatkan daftar port unik adalah sebagai berikut:
 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> ; 

Jadi untuk port GPIOA, nilainya harus (1 << 1 ) | (1 << 3) = 10 (1 << 1 ) | (1 << 3) = 10 , dan untuk port GPIOC - (1 << 1) | (1 << 2) = 6 (1 << 1) | (1 << 2) = 6 , dan untuk GPIOB (1 << 1 ) = 2


Fungsi untuk perhitungan menerima port yang diminta dan jika Pin berada pada port yang sama dengan port yang diminta, maka ia harus mengatur bit yang sesuai dengan posisi Pina ini dalam daftar, satu (1) di mask.
Tidak mudah dijelaskan dengan kata-kata, lebih baik melihat langsung ke kode:


 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; } } ; 

Mengatur nilai yang dihitung untuk setiap port ke port


Sekarang kita tahu nilai yang perlu diatur di setiap port. Tetap melengkapi metode Set() publik Set() , yang akan terlihat oleh pengguna sehingga semua ekonomi ini disebut:


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

Seperti dalam kasus SetPorts() akan membuat metode templat tambahan untuk menjamin transfer mask sebagai konstanta, meneruskannya dalam atribut templat.


 template <typename ...Ts> struct PinsPack { using Pins = PinsPack<Ts...> ; //    0xffffffff,      32  __forceinline template<std::size_t mask = 0xffffffffU> static void Set() { SetPorts<mask>(Ports()) ; } } 

Dalam bentuk akhir, kelas kami untuk daftar Pin akan terlihat seperti ini:
 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()) ; } } ; 

Hasilnya, semuanya bisa digunakan sebagai berikut:


 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') ; } 

Contoh yang lebih lengkap dapat ditemukan di sini:
https://onlinegdb.com/r1eoXQBRH


Performa


Seperti yang Anda ingat, kami ingin mengubah panggilan kami menjadi 3 baris, diatur ke port A 10, port B - 2 dan port C - 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; } 

Mari kita lihat apa yang terjadi dengan pengoptimalan yang dimatikan sepenuhnya.



Saya mewarnai nilai port dan panggilan untuk mengatur nilai ini ke port berwarna hijau. Dapat dilihat bahwa semuanya dilakukan seperti yang kita inginkan, kompiler untuk masing-masing port menghitung nilai dan hanya memanggil fungsi untuk mengatur nilai-nilai ini ke port yang diperlukan.
Jika fungsi instalasi juga dibuat inline, maka pada akhirnya kita mendapatkan satu panggilan untuk menuliskan nilai ke register BSRR untuk setiap port.


Sebenarnya itu saja. Siapa peduli, kodenya ada di sini .


Contohnya ada di sini .


https://onlinegdb.com/ByeA50wTS

Source: https://habr.com/ru/post/id474732/


All Articles