Предисловие
Я при работе с микроконтроллерами часто сталкивался с бинарными протоколами. Особенно, когда есть несколько контроллеров. Или же используется bluetooth low energy и необходимо написать код для обработки бинарных данных в характеристике. Помимо кода всегда требуется понятная документация.
Всегда возникает вопрос — а можно ли описать как-то протокол и сгенерировать на все платформы код и документацию? В этом может помочь IDL.
1. Что такое IDL
Определение IDL довольно простое и уже представлено на wikipedia
IDL, или язык описания интерфейсов (англ. Interface Description Language или Interface Definition Language) — язык спецификаций для описания интерфейсов, синтаксически похожий на описание классов в языке C++.
Самое главное в IDL — он должен хорошо описывать интерфейс взаимодействия, API, протокол. Он должен быть достаточно понятен, чтобы служить другим инженерам документацией.
Бонус также является — генерация документации, структур, кода.
2. Мотивация
В процессе работы я попробовал разные кодогенераторы и IDL. Среди тех, что попробовал были — QFace (https://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.Start struct StartPayload { ... }; @alias Payload: Opcode.Stop struct 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 { i32 someNumber; @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, было бы интересно прочитать в комментариях. Или какая-то успешная практика внедрения похожих систем для описания бинарных протоколов.
В данном проекте я не обращал особого внимания на скорость работы. Некоторые вещи делал чтобы «быстрее решить задачу». Было важнее получить рабочий код, который можно уже пробовать применять к разным проектам.
ссылка на оригинал статьи https://habr.com/ru/post/543850/
Добавить комментарий