Опыт написания IDL для embedded

от автора

Предисловие

Я при работе с микроконтроллерами часто сталкивался с бинарными протоколами. Особенно, когда есть несколько контроллеров. Или же используется 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *