Предисловие
Я при работе с микроконтроллерами часто сталкивался с бинарными протоколами. Особенно, когда есть несколько контроллеров. Или же используется bluetooth low energy и необходимо написать код для обработки бинарных данных в характеристике. Помимо кода всегда требуется понятная документация.
Всегда возникает вопрос - а можно ли описать как-то протокол и сгенерировать на все платформы код и документацию? В этом может помочь IDL.
1. Что такое IDL
Определение IDL довольно простое и уже представлено на wikipedia
IDL, илиязык описания интерфейсов(англ.Interface Description LanguageилиInterface Definition Language)язык спецификацийдля описанияинтерфейсов, синтаксически похожий на описание классов в языкеC++.
Самое главное в IDL - он должен хорошо описывать интерфейс взаимодействия, API, протокол. Он должен быть достаточно понятен, чтобы служить другим инженерам документацией.
Бонус также является - генерация документации, структур, кода.
2. Мотивация
В процессе работы я попробовал разные кодогенераторы и IDL. Среди тех, что попробовал были - QFace (http://personeltest.ru/aways/github.com/Pelagicore/qface), swagger (Это не IDL, а API development tool). Также существует коммерческое решение проблемы: https://www.protlr.com/.
Swagger больше подходит к REST API. Поэтому сразу был отметён. Однако его можно использовать если применяется cbor (бинарный аналог json с кучей крутых фич).
В QFace давно не было коммитов, хотелось некоторых "наворотов" для применения в embedded, возникли сложности при написании шаблона. Он не ищет символы сам, не умеет считать поля enum-ов.
Бесплатные решения было найти сложно, чтобы можно было комфортно использовать при разработке бинарных протоколов.
Поэтому я отказался от генераторов кода и IDL в пользу написания некоторых "автоматизаций" в коде, позволяющих проще писать адаптер протокола. Но протокол с коллегами продолжили описывать при помощи QFace. Решил в свободное время попробовать сделать что-то более или менее годное.
2.1 Обзор QFace
IDL, которая являлась источником вдохновения, имеет простой синтаксис:
module <module> <version>import <module> <version>interface <Identifier> { <type> <identifier> <type> <operation>(<parameter>*) signal <signal>(<parameter>*)}struct <Identifier> { <type> <identifier>;}enum <Identifier> { <name> = <value>,}flag <Identifier> { <name> = <value>,}
Для генерации используется jinja2. Пример:
{% for module in system.modules %} {%- for interface in module.interfaces -%} INTERFACE, {{module}}.{{interface}} {% endfor -%} {%- for struct in module.structs -%} STRUCT , {{module}}.{{struct}} {% endfor -%} {%- for enum in module.enums -%} ENUM , {{module}}.{{enum}} {% endfor -%}{% endfor %}
Концепция интересная. Можно было просто "подпилить" для комфорта "напильником", что конечно и сделал мой коллега. Но мне показалось интересным взять библиотеку sly и просто написать IDL с нужными фичами.
3. Обзор sly
Почему именно sly - библиотека очень проста для описания грамматики.
Сначала надо написать лексер. Он токенизирует код чтобы далее было проще обрабатывать парсером. Код из документации:
class CalcLexer(Lexer): # Set of token names. This is always required tokens = { ID, NUMBER, PLUS, MINUS, TIMES, DIVIDE, ASSIGN, LPAREN, RPAREN } # String containing ignored characters between tokens ignore = ' \t' # Regular expression rules for tokens ID = r'[a-zA-Z_][a-zA-Z0-9_]*' NUMBER = r'\d+' PLUS = r'\+' MINUS = r'-' TIMES = r'\*' DIVIDE = r'/' ASSIGN = r'=' LPAREN = r'\(' RPAREN = r'\)'
Нужно наследовать класс Lexer, в переменную tokens
- добавить свои использованные токены. Само определение токенов
делается в теле класса - достаточно просто описать регулярное
выражение, соответсвующее токену.
Парсер - делает работу по преобразованию набора токенов по определенным правилам. С помощью его и осуществляется основная работа. В случае компиляторов - преобразование в байт-код/объектный файл итд. Для интерпретаторов - можно сразу выполнять вычисления. При реализации кодогенератора - можно преобразовать в дерево классов.
Также парсер задается очень простым способом (пример из документации):
class CalcParser(Parser): # Get the token list from the lexer (required) tokens = CalcLexer.tokens # Grammar rules and actions @_('expr PLUS term') def expr(self, p): return p.expr + p.term @_('expr MINUS term') def expr(self, p): return p.expr - p.term @_('term') def expr(self, p): return p.term @_('term TIMES factor') def term(self, p): return p.term * p.factor @_('term DIVIDE factor') def term(self, p): return p.term / p.factor @_('factor') def term(self, p): return p.factor @_('NUMBER') def factor(self, p): return p.NUMBER @_('LPAREN expr RPAREN') def factor(self, p): return p.expr
Каждый метод класса отвечает за парсинг конкретной конструкции.
В декораторе @_
указывается правило, которое
обрабатывается. Имя метода sly распознает как название правила.
В этом примере сразу происходят вычисления.
Подробнее можно прочитать в официальной документации: https://sly.readthedocs.io/en/latest/sly.html
4. Процесс создания
В самом начале программа получает yml файл с настройками. Затем при помощи sly преобразовывает код в древо классов. Далее выполняются вычисления и поиски объектов. После вычисления - передается в jinja2 шаблон и дерево символов.
Читать как был определен список токенов для лексера может быть скучно, поэтому перейдем сразу к парсеру.
Вначале определили, что модуль состоит из списка термов:
@_('term term') def term(self, p): t0 = p.term0 t1 = p.term1 t0.extend(t1) return t0
Затем определим, что терм состоит из определений структуры,
энумератора или интерфейса разделенные символом
";"(SEPARATOR
):
@_('enum_def SEPARATOR') def term(self, p): return [p.enum_def] @_('statement SEPARATOR') def term(self, p): return [p.statement] @_('interface SEPARATOR') def term(self, p): return [p.interface] @_('struct SEPARATOR') def term(self, p): return [p.struct]
Здесь терм сразу паковался в массив для удобства. Чтобы список
термов (term
term
правило) работал уже
сразу с листами и собрал в один лист.
Ниже представлен набор правил для описания структуры:
@_('STRUCT NAME LBRACE struct_items RBRACE') def struct(self, p): return Struct(p.NAME, p.struct_items, lineno=p.lineno) @_('decorator_item STRUCT NAME LBRACE struct_items RBRACE') def struct(self, p): return Struct(p.NAME, p.struct_items, lineno=p.lineno, tags=p.decorator_item) @_('struct_items struct_items') def struct_items(self, p): si0 = p.struct_items0 si0.extend(p.struct_items1) return si0 @_('type_def NAME SEPARATOR') def struct_items(self, p): return [StructField(p.type_def, p.NAME, lineno=p.lineno)] @_('type_def NAME COLON NUMBER SEPARATOR') def struct_items(self, p): return [StructField(p.type_def, p.NAME, bitsize=p.NUMBER, lineno=p.lineno)] @_('decorator_item type_def NAME SEPARATOR') def struct_items(self, p): return [StructField(p.type_def, p.NAME, lineno=p.lineno, tags=p.decorator_item)] @_('decorator_item type_def NAME COLON NUMBER SEPARATOR') def struct_items(self, p): return [StructField(p.type_def, p.NAME, bitsize=p.NUMBER, lineno=p.lineno, tags=p.decorator_item)]
Если описать простым языком правила - структура
(struct
) содержит поля структур
(struct_items
). А поля структур могут определяться
как:
-
тип (
type_def
), имя (NAME
), разделитель (SEPARATOR
) -
тип (
type_def
), имя, двоеточие (COLON
), число (NUMBER
- для битфилда, означает количество бит), разделитель -
список декораторов (
decorator_item
), тип, имя, разделитель -
список декораторов, тип, имя, двоеточие (
COLON
), число (NUMBER
- для битфилда), разделитель
Новшество относительно QFace (однако есть в protlr) - была введена возможность описывать специальные условные ссылки на структуры. Было решено назвать эту фичу - alias.
@_('DECORATOR ALIAS NAME COLON expr struct SEPARATOR') def term(self, p): return [Alias(p.NAME, p.expr, p.struct), p.struct]
Это было сделано чтобы поддерживалась следующая конструкция:
enum Opcode { Start = 0x00, Stop = 0x01};@alias Payload: Opcode.Startstruct StartPayload {...};@alias Payload: Opcode.Stopstruct StopPayload {...};struct Message { Opcode opcode: 8; Payload<opcode> payload;};
Данная конструкция обозначает, что если opcode
=
Opcode.Start
(0x00) - payload
будет
соответствовать структуре StartPayload
. Если
opcode
= Opcode.Stop
(0x01) -
payload
будет иметь структуру
StopPayload
. То есть создаем ссылку структуры с
определенными условиями.
Следующее что было сделано - отказался от объявления модуля. Показалось это избыточным так как - имя файла уже содержит имя модуля, а версию писать бессмысленно так как есть git. Хороший протокол имеет прямую и обратную совместимость и в версии нуждаться не должен. Был выкинут тип flag так как есть enum, и добавил возможность описания битфилдов. Убрал возможность определения сигналов так как пока что низкоуровневого примера, демонстрирующего пользу, не было.
Была добавлена возможность python-подобных импортов. Чтобы можно было импортировать из другого модуля только конкретный символ. Это полезно для генерации документации.
Для вычислений был создан класс - Solvable
. Его
наследует каждый объект, которому есть что посчитать. Например, для
SymbolType
(тип поля класса или интерфейса). В данном
классе этот метод ищет по ссылке тип, чтобы добавить его в поле
reference. Чтобы в jinja можно было сразу на месте обратиться к
полям enum или структуры. Класс Solvable
должен искать
во вложенных символах вычислимые и вызывать solve
.
Т.е. вычисления происходят рекурсивно.
Пример реализации метода solve для структуры:
def solve(self, scopes: list): scopes = scopes + [self] for i in self.items: if isinstance(i, Solvable): i.solve(scopes=scopes)
Как видно, в методе solve
есть аргумент -
scopes
. Этот аргумент отвечает за видимость символов.
Пример использования:
struct SomeStruct {i32someNumber;@setter: someNumber;void setInteger(i32 integer);};
Как видно из примера - это позволяет производить поиск символа
someNumber
в области видимости структуры, вместо
явного указания SomeStruct.someNumber
.
Заключение
По сравнению с QFace мне удалось - упростить написание шаблона за счет поиска типов, вычисления перечислений. Также полезно иметь импорт символов и возможность условно ссылаться на разные структуры.
В папке examples/uart - находится пример генерации заголовков, кода и html документации. Пример иллюстрирует типичный uart протокол с применением новых фич. Подразумевается, что функции типа put_u32 итд - определит сам пользователь исходя из порядка байт и архитектуры MCU.
Ознакомиться подробнее с реализацией можно по ссылке: https://gitlab.com/volodyaleo/volk-idl
P.S.
Это моя первая статья на Хабр. Буду рад получить отзывы - интересна ли данная тематика или нет. Если у кого-то есть хорошие примеры кодо+доко-генераторов бинарных протоколов для Embedded, было бы интересно прочитать в комментариях. Или какая-то успешная практика внедрения похожих систем для описания бинарных протоколов.
В данном проекте я не обращал особого внимания на скорость работы. Некоторые вещи делал чтобы "быстрее решить задачу". Было важнее получить рабочий код, который можно уже пробовать применять к разным проектам.