Як ми середу Arduino на 8051 натягували, або ОС на один процес



Влітку 2016 ми випустили в широкий продаж нашу нову плату для розробки Z-Wave пристроїв — Z-Uno. Це абсолютно інноваційний пристрій, аналогів якому в світі Z-Wave поки немає. Враховуючи велику кількість программерских фішок, я вирішив поділитися деякими рішеннями, використовуваними в Z-Uno.

Якщо коротко, то ми зробили спрощену кооперативну ОС на 1 процес на мікроконтролері сімейства 8051 з API подібним Arduino.

Давайте відразу відповім на головне питання: НАВІЩО?

Як я вже писав, робляться пристрою Z-Wave вельми непросто. Потрібні не тільки навички роботи з мікроконтролерами, але і спеціальне ПЗ і програматори.

Нашою метою було дати користувачеві порівняно простий засіб для створення Z-Wave пристроїв, не використовуючи дорогих і специфічних утиліт і залозок. Крім того, протокол Z-Wave не надто очевидний, і ми хотіли приховати під капотом» всі тонкощі протоколу, залишивши користувачеві тільки основну суть.

Так як нам хотілося дати користувачеві максимальну кількість апаратних можливостей чіпа Z-Wave (ноги, апаратні драйвери шин, ...), було вирішено взяти за основу середу Arduino. Вона популярна, як раз дає можливість працювати з апаратною частиною мікроконтролера і використовує трохи спрощений C++ (не всі фішки плюсів там доступні). Причому не тільки стиль API (список загальноприйнятих у Arduino функцій звернення до залозу), але і IDE. Але у нас є нюанс — купу роботи потрібно робити за користувача, особливо радіо обмін, тобто потрібно періодично брати управління і робити все «чорну роботу», повертаючи управління, коли ми не зайняті обслуговуванням радіо і обробкою команд.

Крім того, ми не можемо поширювати бібліотеки Z-Wave як є (вимога власника протоколу, пов'язане з NDA), і хоч в мережі повно прошивок .bin або .hex форматі (для OTA-оновлення пристроїв, наприклад), включати бібліотеки в середу Arduino ми не могли. Враховуючи все вищесказане, нам було просто необхідно ізолювати код користувача від коду обробки пакетів Z-Wave.

Отже, ми зробили ОС на 1 процес, надавши користувачу-розробнику простий API в стилі Arduino.

Про використання Z-Uno (хоч і старої версії) я писав в окремій статті. Так само на GT є кілька інших статей. Тут же будуть описані деталі реалізації нутрощів Z-Uno. Дорогий читач, ласкаво просимо до нас «за куліси».

Архітектура


Якщо зовсім коротко, то Z-Uno складається з 4х частин:
  1. Завантажувач (завантажувач) дозволяє змінювати наші прошивки. Майже всі пристрої з OTA-оновлення мають такий.
  2. Стек Z-Wave від Sigma Designs — це бібліотеки, що мають посилання з наступним рівнем.
  3. Реалізація класів команд Z-Wave, базових функцій, а так само вся робота з скетчом (заливка скетчу і відповідна сторона Arduino-подібного API). Цю частину ми будемо називати «завантажувач скетчу».
  4. Користувальницький скетч, що завантажується користувачем самостійно через середовище Arduino IDE — прямо як з будь плат Arduino.


«Багатозадачність»

Передача управління від скетчу до завантажувач скетчу відбувається добровільно. Проте довге перебування в скетчі (більше 10 мс) може зіпсувати обмін даними по радіо. У зворотний бік (з завантажувача скетчу у скетч) управління також передається, коли завантажувач скетчу не діє. При цьому, навіть передавши управління у скетч, багато переривання зрідка ненадовго повертають управління в завантажувач скетчу. Така ось проста кооперативна ОС.

Точки входу в користувальницький процес (скретч)

У класичному C-інтерфейсі програма починається з main(). У скетчах Arduino замість цього використовується пара setup() та loop(). Ми вирішили адаптувати цю конвенцію — при старті Z-Uno в процесі ініціалізації заліза викликається setup(), далі весь час, що стек Z-Wave і завантажувач скетчу не зайняті, викликається loop(). Все, начебто просто. Є ще точки попадання в користувальницький скетч, пов'язані з реалізацією взаємодії з мережі Z-Wave: getter і setter. Про них — нижче.

Подання в Z-Wave і канали

Ніг у Z-Uno багато, заліза різного можна підключити немереннно. Але це ж не просто Arduino, у нас тут Z-Wave навіщо-то. Основна задача Z-Uno — це відображення периферії, підключеної до ніг Z-Uno на Z-Wave сутності і навпаки. Так як Z-Wave пристрої можуть мати безліч різних функцій, ми вирішили дати користувачеві доступ відразу до декількох сутностей. Для спрощення ми вирішили створити по каналу на сутність (не буду вдаватися до деталі Z-Wave, були й інші способи це зробити). У кожного каналу свій тип в залежності від налаштувань користувача і всередині реалізовані відповідні Класи Команд (Command Classes). Таких типів у нас поки чотири: бінарний датчик (клас команд Sensor Binary), багаторівневий датчик (Sensor Multilevel), реле (Switch Binary) і диммер (Switch Multilevel). В перспективі з'являться ще лічильники (Meter) та замки (Door Lock).

Всі ці класи реалізують команди отримання поточних значень (Get), реле і диммери так само реалізують встановлення значення (Set). Отриманням команд і відправкою звітів займається наш код — завантажувач скетчу, а ось ці значення потрібно брати з скетчу і віддавати в нього. Це взаємодія ми реалізували через getter/setter-механізм. При описі кожного каналу користувач повинен вказати функції для використання в якості getter і setter.

Getter і Setter

Для коректної роботи в мережі Z-Wave нам потрібно за запитом від інших пристроїв мережі оперативно відповідати на запити поточних станів і обробляти команди встановлення нових значень. Наприклад, датчик руху міг надіслати нам команду Set для включення реле, реалізованого на Z-Uno. Або контролер міг нас запитати про поточний статус цього реле або поточному значенні датчика, підключеного до Z-Uno. Всі ці команди нам потрібно оперативно виконувати, причому значення ми повинні отримувати з користувальницького коду, туди ж передавати прийшли нові значення для каналів. «Оперативно» — поняття широке. Ми вважали, що достатньо дочекатися, коли користувальницький код вийде з loop() або викличе delay(). Таким чином, getter і setter запускаються тільки коли користувальницький код не виконується.

Тепер по порядку розберемо блоки отриманої системи.

Збірка і завантаження коду в Z-Uno

Так як ми вирішили використовувати середовище Arduino IDE, нам знадобилося створити власний пакет компілятора, завантажувача, бібліотек і заголовкових файлів, який встановлюється через Board Manager середовища Arduino IDE. Ось тут ми описали процес установки для тих, хто з ним не знайомий: z-uno.z-wave.me/install

Компілятор

Чіп Z-Wave заснований на архітектурі 8051, тобто стандартний avr-gcc нам не підходить. Нічого цікавого і в той же час відкритого для 8051 крім компілятора SDCC sdcc.sourceforge.net ми не знайшли. На жаль, він розуміє чистий C, ніяких «плюсів». Але з компіляцією C-коду він цілком справляється, хоча і не так добре, як дорогий Keil (який використовується для створення всіх пристроїв Z-Wave, в тому числі нашої частини коду Z-Uno). Нам пощастило, творці SDCC заздалегідь передбачили безліч опцій, якими ми скористалися: обмеження на використання Code Space, IDATA, XDATA, адреси векторів переривань… Про це трохи пізніше — у розділі поділу ресурсів.

Підтримка C++

Більшість бібліотек для Arduino так чи інакше використовують C++, а точніше деякі його синтаксичні конструкції. Як вже згадувалося, компілювати C++ SDCC не вміє. Але багато бібліотек Arduino використовують класи, успадкування і поліморфізм. Ми перепробували різні варіанти, починаючи зі старого-доброго cfront і закінчуючи новомодним clang. Після довгого роздуму було вирішено взяти clang (http://llvm.org) і використовувати його для синтаксичного розбору користувальницького коду з наступним створенням найчистішого C-коду, який вже буде збиратися SDCC. Таким чином, ми використовуємо clang як транслятор З++ коду на Сі, а не як повноцінний компілятор. За тим же принципом працював перший компілятор С++ — вже згаданий раніше cfront.

Тут відразу напрошується питання: «Чому ж ви пішли настільки архаїчним і дивним шляхом». Відповідь надзвичайно проста: створення повноцінного компілятор С++ для 8051 вимагало б багато часу, навіть можна сказати дуууже багато часу, набагато більше, ніж час, який ми відводили на весь проект Z-Uno. Крім того, ми намагалися обмежити підтримувані семантичні конструкції, всілякі «фішки» З++, і саме тому назвали наш транслятор uCxx (скорочено u=[mj:u]=micro). Строго кажучи, наш транслятор підтримує дуже обмежений діалект мови C++. uCxx на даний момент не вміє перевантажувати оператори, нічого не знає про шаблони, також не працює з посиланнями, не підтримує множинного спадкування, він ніколи не чув про оператори new і delete. Весь його джентельменський набір обмежений поліморфізмом на рівні класів та віртуальними функціями, але цього набору цілком вистачає для портування більшості Arduinо'вських бібліотек з майже повним збереженням їх інтерфейсу. Крім того, uCxx робить деякі «фішки», які є тільки у нього. Наприклад спеціально для Z-Uno він вміє перебудовувати роботу з пінами виділеного порту таким чином, щоб забезпечити максимальну швидкість управління пінами, може заповнювати нопами (інструкція NOP) потрібні ділянки коду і т. д. Ми відразу пішли від універсальності і постаралися зробити спеціальне і максимально швидке за часом розробки рішення.

Тут багато технічних деталей про генерацію кодуТепер спробуємо коротко описати принципи роботи uCxx. Перш за все з чого ж він складається !? Ми використовуємо спеціально пропатченную версію libclang (поки тут ще є безліч дрібних недоробок, таких як визначення бінарного типу/плюс оператора і подібні речі — ось і довелося трохи поправити бібліотеку), биндинг libclang (його теж довелося відредагувати відповідності пропатченої бібліотеці для Python. Основною мовою розробки uCxx, таким чином, є саме Python. Python також був обраний, щоб спростити розробку і виграти час. Так, uCxx — це просто Python-скрипт, дергающий libclang, але тим не менш, Python-код uCxx перетворюється в бінарну складання з допомогою пакета pyinstaller і кінцевому користувачеві не потрібно нічого знати про Python, його середовище виконання і додаткові бібліотеки.

Спробуємо показати як працює uCxx. Спочатку користувальницький скетч проходить фазу аналізу, на якій визначаються всі використовувані хидеры і за них формується список додаткових файлів ядра/бібліотек, які необхідно включити в компіляцію (аналогічно працює рідний Arduino'вский препроцесор). Після цього файл .ino відправляється на препроцесор: використовується сторонній — sdcpp (частина компілятора SDCC). Після цього отриманий cpp-файл заганяється всередину clang, який на виході дає вже Abstract Syntax Tree (AST) усього файлу. Саме на цьому етапі визначаються всі синтаксичні помилки. Як виглядає основна частина AST-дерева для вихідного коду можна побачити в спеціальних налагоджувальних файлах, які мають суфікс _ast.txt. Отримане AST-дерево аналізується кодом uCxx. По-суті це обхід великого дерева. Для кожного знайденого класу створюється спеціальна структура, яка зберігає всі дані об'єкта класу. Для кожного методу визначається його нове ім'я, яке формується на основі назви батьківського класу, кількості і типу вхідних параметрів. Така техніка є загальноприйнятою для С++-компіляторів і називається «mangling». У uCxx використовується власний алгоритм побудови таких імен, т. к. вбудований в бібліотеку clang алгоритм виявився непрацездатним для конструкторів і поправити його було набагато складніше, ніж написати свій власний. У кожен нестатичный метод класу також додається — перший параметр, який в подальшому разыменовывается як this, що теж є стандартним підхід для ООП-компіляторів. Наприклад в таких мовах як Python такий синтаксис звичний користувачеві.

Центральна частина нашого транслятора — це реалізація віртуальних методів. У иСхх вони реалізуються з допомогою таблиці віртуальних методів, яка формується для кожного класу статично на етапі компіляції. Таблиця заповнюється вказівники на функції. Функцією, в даному випадку, ми називаємо транслированный на мову Сі метод класу. Імена цих функцій введено спеціальне відношення порядку. Таким чином, батьківський клас завжди містить початок таблиці, а клас-спадкоємець тільки розширює вже наявну таблицю, якщо у нього є нові віртуальні методи, і заповнює початок таблиці для всіх перевантажуються методів батьківського класу. При виклик віртуальної функції завжди викликається метод кореневого батьківського класу, який вже здійснює перехід в потрібну функцію нащадка, використовуючи для цього таблицю віртуальних функцій. Покажчик на таблицю віртуальних функцій завжди зберігається всередині даних об'єкта (спеціальне поле структури класу). Детальніше побачити як це відбувається, можна безпосередньо в коді — вихідні файли транслятора — файли з суфіксом "_ucxx.cpp".


Однією з особливостей uCxx — є генерація функцій ініціалізації для кожного модуля. Такі функції використовуються для ініціалізації глобальних об'єктів, заповнення таблиць віртуальних функцій. Виклики всіх функцій инциализации модулів, що входять до скетч, додаються всередину функції setup() користувальницького скетчу.

Компіляція всього безлічі файлів, потрібних для побудови скетчу, здійснюється два рази. На першому проході визначається безліч доступних для виклику користувальницьких методів і безліч методів ініціалізації, на другому проході генерується на основі цих множин «уточнений код», з якого виключаються всі незадіяні методи користувальницьких класів. Такий підхід дозволяє скоротити обсяг вихідного скетчу і при цьому не сильно збільшує час компіляції.

На фінальному етапі для всіх отриманих «чистокровних» Сі-файлів викликається sdcc, він-то і збирає фінальну hex-версію скетчу. Ось і все — скетч готовий для завантаження всередину Z-Uno

Завантажувач

Природно, AVR-DUDE нам теж не підходить. Більше того, ми змінюємо лише власну частину коду, зберігаючи в Z-Uno нашу прошивку. Тому ми використовуємо більш-менш стандартний для Z-Wave протокол Serial API, схожий на той, що застосовують для USB-стіків. Він дозволяє передати в Z-Uno скетч (в допоміжну пам'ять EEPROM), ініціювати перезапис Code Space (FLASH) і перезавантаження (цю роботу виконує завантажувач скетчу).

Для спілкування по цьому протоколу з нашої прошивкою ми написали власну невелику утиліту на Python. Вона-то і викликається для заливки скетчу, а так само нових версій наших прошивок (завантажувача скетчу).

Бібліотеки та заголовки

Для коректної складання користувальницького коду нам потрібні бібліотеки та файли заголовків для опису доступних функцій. Саме тут описано Arduino-подібний API. Вся ця частина лежить на Github github.com/Z-Wave-Me/Z-Uno-Core, її можна мацати і правити.

Бібліотеки часто є адаптацією стандартних Arduino'вських бібліотек під специфіку і архітектуру Z-Uno. Деякі користувачі вже почали нам допомагати, пропонуючи pull requests на github зі своїми бібліотеками чи виправленнями наших.

Виклики ОС і різні ABI

Відразу підкреслю, що прошивка Z-Uno (стек Z-Wave і завантажувач скетчу) зібрані компілятором Keil, в той час як скетч збирається в SDCC. Сказати, що код несумісний — це нічого не сказати. Ці компілятори використовують радикально різні ABI (Application Binary Interface), тобто позначення передачі параметрів (через які регістри, в якому порядку, як передати вказівник на пам'ять,...) І тут-то ми і схрестили їжака з вужем. Для переходу з одного коду в інший ми використовували ідею системних викликів в Unix-подібних ОС. У пам'яті був відведений «стек» (за фактом просто невелика послідовність байт). Обидва коду знають точну адресу цього масиву. Користувальницький код кладе спочатку «номер syscall», далі в обумовленому порядку кладуться параметри, що відповідають цьому syscall, в цей масив (через zunoPush github.com/Z-Wave-Me/Z-Uno-Core/blob/master/hardware/arduino/zuno/cores/zuno/LLCore_arduino.c#L38), після чого стрибає по заданому адресою (LCALL) код завантажувача скетчу. Точка, куди йде стрибок жорстко задана при компіляції користувальницького скетчу. Потрапивши в код завантажувача скетчу, дивлячись на номер syscall, вже забираються (через zunoPop) параметри і виконується потрібна операцію над ними. У зворотний бік все працює аналогічно. Передача параметрів через цей «масив-стек» дозволяє не звертати увагу на те, які регістри використовує той чи інший компілятор (в нашому випадку Keil C51 і SDCC можуть використовувати різні набори регістрів).

Щоб легше було уявити наскільки по-різному ці два компілятора розуміють передачу параметрів у функції наведемо невеликий приклад. Так Keil передає перший однобайтний параметр завжди через регістр R7, а двобайтовий параметр через регістри R6-R7 (див. www.keil.com/support/man/docs/c51/c51_ap_parampassreg.htm ), в той час як SDCC цей же параметр буде передавати через DPL в разі однобайтового параметра, і через DPL/DPH — у разі двобайтового (див. мануал по SDCC sdcc.sourceforge.net/doc/sdccman.pdf стор 53, пункт «3.12.1 Global Registers used for Parameter Passing»). Таким чином, у наявності повна несумісність цих компіляторів при передачі параметрів функцій через регістри.

Так як обидва коду (завантажувач скетчу /скетч) компілюються окремо і один про одного нічого не знають, вони цілком можуть припускати, що регістри їх ніхто не псує. Тому ми зберігаємо всі регістри при переході з одного коду в інший і відновлюємо при поверненні назад.

Які syscall у нас є? Ну, природно, реалізації pinMode, digital/analogRead/Write, delay (див. нижче), робота з Serial0/1, SPI, читання/запис EEPROM і NZRAM область XRAM, що живе навіть уві сні), налаштування KeyScanner, робота з IR-драйвером, догляд в сон, відправка звітів і команд інших пристроїв (див. ZUNO_FUNC github.com/Z-Wave-Me/Z-Uno-Core/blob/master/hardware/arduino/zuno/cores/zuno/ZUNO_Definitions.h).

Стек

Спочатку ми пробували ідею з різними стеками і при переході з простору Z-Wave в користувальницьке і навпаки. Робили шляхом виділення двох стеків в IDATA і збереження SP при переході. Однак даний підхід виявився не дуже економним, оскільки для великої вкладеності функцій (а в C++ вкладень багато) ми нерідко переповнювали користувальницький стек. Взагалі, стек в 8051 сильно обмежений у порівнянні з AVR.

У підсумку ми повернулися до очевидного варіанту загального стека. Але є один нюанс. Про нього нижче (про delay).

Поділ пам'яті

Крім стека є й інші спільні ресурси. Наприклад, пам'ять. В 8051 її дві: IRAM і XRAM. Операції з IRAM коротше і швидше (MOV), з XRAM більш довгі (MOVX). Робота з вказівниками можлива тільки в XRAM.

В обох випадках ми просто викололи у Keil частина пам'яті, щоб той її не використовував, а в SDCC навпаки тільки її і дозволили. Ось таке просте поділ ресурсів. Лише області для передачі параметрів у syscall і область стека в IRAM використовується спільно (ну, природно всі регістри теж IRAM, вони теж спільно використовуються).

Реалізація delay()

Більшість функцій вимагають щось зробити і повернути управління досить швидко. Але така проста функція, як delay() вимагала великих зусиль. Справа в тому, що ми не можемо просто заблокувати чіп, зробивши щось на зразок while(counter--); як це робиться в Arduino. Якщо так зробити, то радіо передача на цей час перерветься (переривання радіо будуть працювати, але не аналіз прийшли байтів). А при затримці на час більше 10 мс радіо обмін просто стане неможливим із-за втрати пакетів.

Цю задачу ми вирішили досить хитро: при затримках на час менше 10 мс ми йдемо в цикл, в якому запускаємо бібліотечну функцію роботи з прийшли радіо пакетами. Вона відповідає за складання пакету і перенесення в тимчасовий буфер вхідної черги. Крім того вона реалізує ретрансляцію і інші функції мережевого рівня Z-Wave. Але надовго так робити не можна: управління по радіо не буде працювати, відповіді на запити значень датчиків теж не будуть відправлятися.

Тому при затримках на більший час ми змушені-таки вийти з користувальницького коду і повернутися в код завантажувача скетчу, який відповідає за верхнеуровневую обробку пакетів і відповіді на них. У цьому випадку ми запам'ятовуємо, що знаходимося в delay, стрибаємо в завантажувач скетчу, стандартно працюємо, але тільки не запускаємо loop(). Як тільки таймер натикал, і ми повинні повернутися, ми знімаємо прапор і робимо RET, щоб повернутися назад з delay() в користувальницький код.

Звертаю увагу, що всі getter і setter все ще працюють навіть під час очікування в delay().

Робота з шинами

У чіпа Z-Wave є безліч апаратних драйверів: ШІМ, АЦП, UART, SPI,… Звичайно, ми хотіли дати процесу користувачеві доступ до периферії. Для цього ми зробили кілька «syscall» (див. вище) з відповідними параметрами. А вже на стороні користувача частини в бібліотеках і заголовках обернули їх у звичні вигляд. Наприклад, pinMode(), digitalRead() і digitalWrite() дають доступ до пинам (здійснюючи всередині маппінг номерів ніг по черзі на номери портів чіпа Z-Wave), робота з ШІМ робиться через analogWrite(), а до АЦП можна звернутися через analogRead(). Аналогічно з UART і SPI, де ми зробили буферизацію в коді завантажувача скетчу.

Ті шини, для яких відсутні апаратні драйвери (I2C, 1-Wire, специфічні DHT-11), ми реалізували прямо в додатковому коді на базі GPIO (в бібліотеках, що підключаються до скетчу).

Робота з пінами, режим швидких пінів

Однак такі протоколи як I2C можуть вимагати великій швидкості. Досягти 400 кГц, викликаючи syscall точно не вийде. Вже дуже багато «зжирає» цей рівень абстракції. Тому було знайдено інше рішення. Один порт (8 пінів) ми виділили з інших і назвали його «швидкі піни». Був доданий новий тип даних s_pin, який на рівні clang (до компіляції) перетворювався в константу, а функції digitalWrite і digitalRead з такими пінами відразу перетворюється в запис в регістри управління пінами. Наприклад, для включення P0.5:
P05 |= (1 << 5);
Крім того було додано непряма адресація такими пінами — при передачі змінної myPin типу s_pin у функцію, в якій варто digitalWrite або digitalRead з цієї змінної, останні перетворюються на пряму роботу з регістром. Наприклад,
P0 |= (1 << (myPin-9)
Зазначу, що в архітектурі 8051 можна адресувати опосередковано будь-пін, а тільки в межах конкретного порту. Саме тому ми вибрали один «швидкий» порт P0 (ноги 9-16 на Z-Uno). Таким чином замість 1 мс на роботу з портом через syscall ми прийшли до 2 мкс для непрямої і 0.5 мкс для прямої адресації швидких пінів.

Що приховано від користувача

Нагадаю, нашим завданням було приховати від користувача частина функцій як з-за NDA, так і для спрощення. У підсумку вся кухня, пов'язана з Z-Wave прихована зовсім — користувач не турбується про безліч необхідних для відповідності стандарту Z-Wave Plus класів команд. Наприклад, Асоціації, оновлення прошивки, установка часу пробудження, звіт про заряд батарейки, тест дальності зв'язку, шифрування, робота з каналами, звіти про версії пристрою і класів команд — це і багато іншого вже реалізовано коректним чином. Користувачеві залишилося написати логіку самого пристрою — зв'язок пінів з користувацькими типами каналів. Наприклад, при отриманні команд Вкл/Викл на перший канал вмикати/вимикати пін, а при отриманні команд Вкл/Викл на другий канал відправляти по UART команду іншого мікроконтролера.

Крім того, повністю прихована під капотом реалізація радіо-частини, обробки пакетів та інше, що відноситься до стандарту Z-Wave, і що немає сенсу давати користувачеві.

Висновок

Загалом нам вдалося досить красиво вирішити завдання створення власних пристроїв Z-Wave для людей які не знають ні деталей протоколу, ні тонкощів цього мікроконтролера. Простих знань Arduino достатньо. За перший квартал з моменту випуску Z-Uno нам вдалося не тільки продати планову партію, але і зібрати непоганий community навколо цього проекту. Крім того, ми регулярно публікуємо нові й нові приклади використання Z-Uno з різними датчиками (http://z-uno.z-wave.me/examples/).

До речі, за час роботи над проектом у нас з'явилося два конкурента, але обидва звернулися прямо перед нашим запуском. Схоже, завдання-таки була дійсно не простий…

Сподіваюся, наш досвід буде корисним, а у коментах читачі нам що-небудь розумне порадять.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.