Pull to refresh

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

Reading time 14 min
Views 12K


Летом 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. Загрузчик (bootloader), позволяющий менять наши прошивки. Почти все устройства с 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-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 и использовать его для синтаксического разбора пользовательского кода с последующим созданием чистейшего 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 такой синтаксис привычен пользователю.

Центральная часть нашего транслятора — это реализация виртуальных методов. В uСxx они реализуются с помощью таблицы виртуальных методов, которая формируется для каждого класса статично на этапе компиляции. Таблица заполняется указателями на функции. Функцией, в данном случае, мы называем транслированный на язык Си метод класса. Для имен этих функций введено специальное отношение порядка. Таким образом, родительский класс всегда содержит начало таблицы, а класс-наследник только расширяет уже имеющуюся таблицу, если у него есть новые виртуальные методы, и заполняет начало таблицы для всех перегружаемых методов родительского класса. При вызове виртуальной функции всегда вызывается метод корневого родительского класса, который уже осуществляет переход в нужную функцию потомка, используя для этого таблицу виртуальных функций. Указатель на таблицу виртуальных функций всегда хранится внутри данных объекта (специальное поле структуры класса). Детальнее увидеть как это происходит можно непосредственно в коде — выходные файлы транслятора — файлы с суффиксом "_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, её можно щупать и править.

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

Вызовы ОС и разные ABI


Сразу подчеркну, что прошивка Z-Uno (стек Z-Wave и загрузчик скетча) собраны компилятором Keil, в то время как скетч собирается в SDCC. Сказать, что код несовместим — это ничего не сказать. Эти компиляторы используют радикально разные ABI (Application Binary Interface), т.е. нотацию передачи параметров (через какие регистры, в каком порядке, как передать указатель на память,...) И тут-то мы и скрестили ежа с ужом. Для перехода из одного кода в другой мы использовали идею системных вызовов в Unix-подобных ОС. В памяти был отведён «стек» (по факту просто небольшая последовательность байт). Оба кода знают точный адрес этого массива. Пользовательский код кладёт сначала «номер syscall», далее в оговоренном порядке кладутся параметры, соответствующие этому syscall, в этот массив (через zunoPush), после чего прыгает по заданному адресу (LCALL) в код загрузчика скетча. Точка, куда идёт прыжок жёстко задана при компиляции пользовательского скетча. Попав в код загрузчика скетча, глядя на номер syscall, уже забираются (через zunoPop) параметры и выполняется нужная операцию над ними. В обратную сторону все работает аналогично. Перенос параметров через этот «массив-стек» позволяет не обращать внимание на то, какие регистры использует тот или иной компилятор (в нашем случае Keil C51 и SDCC могут использовать разные наборы регистров).

Чтобы легче было представить насколько по-разному эти два компилятора понимают передачу параметров в функции приведем небольшой пример. Так Keil передает первый однобайтовый параметр всегда через регистр R7, а двухбайтовый параметр через регистры R6-R7 (см. тут), в то время как SDCC этот же параметр будет передавать через DPL в случае однобайтового параметра, и через DPL/DPH — в случае двухбайтового (см. мануал по SDCC, стр. 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).

Стек


Сначала мы пробовали идею с разными стеками и при переходе из пространства 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 с разными датчиками.

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

Надеюсь, наш опыт будет полезным, а в комментах читатели нам что-нибудь умное посоветуют.

Tags:
Hubs:
+19
Comments 24
Comments Comments 24

Articles