Документация Engee

Драйверы и C Function-блоки

Страница в процессе разработки.

Таргет отвечает не только за запуск сгенерированной модели, но и за доступ модели к возможностям целевой платформы: GPIO, UART, ADC, PWM, таймерам, шинам и внешним микросхемам. Обычно этот доступ организуют через Си-драйверы, которые копируются в проект таргета и вызываются из Си-кода модели.

Основной пользовательский механизм для вызова Си-кода из модели — блок C Function. Поэтому при проектировании таргета важно заранее определить, какой Си API предоставляется пользователю и как этот API будет попадать в сгенерированный проект.

Что такое C Function

Блок C Function позволяет использовать Си-код внутри модели Engee. Пользователь настраивает входы, выходы, параметры, рабочие переменные и пишет код в редакторе блока.

У редактора C Function есть четыре основные секции:

  • Выходной код — код, который выполняется на каждом шаге расчета блока;

  • Начальный код — код, который выполняется один раз при инициализации;

  • Завершающий код — код, который выполняется один раз при остановке;

  • Общий код — общий код, функции и глобальные данные для нескольких экземпляров C Function с одинаковым именем функции.

При генерации Си-кода содержимое этих секций преобразуется в Си-функции, которые входят в сгенерированный код модели. Это значит, что код в блоке C Function компилируется вместе с рантайм-обвязкой таргета и может вызывать функции из драйверов, если заголовки и исходники драйверов добавлены в проект.

Редактор C Function показывает прототип функции для текущих входов, выходов, параметров и рабочих переменных. Для таргета важно, чтобы документация драйверов позволяла пользователю однозначно определить место вызова каждого драйвера в сгенерированных интерфейсных функциях — init, step и term — без необходимости самостоятельного анализа или предположений.

Рабочие переменные C Function предназначены для состояния конкретного экземпляра блока. Их удобно использовать для буферов, счетчиков, дескрипторов и других данных, которые не должны быть глобальными для всей модели. Если состояние должно быть общим для нескольких экземпляров блока с одинаковым именем функции, его размещают в секции Общий код.

Для обычной симуляции C Function может использовать разделяемые библиотеки с Си API. Для таргета независимого выполнения чаще используется другой вариант: нужный Си-код и драйверы добавляются в проект и компилируются вместе с моделью под целевую платформу.

Зачем таргету свои драйверы

Блок C Function может содержать произвольный Си-код, но пользователь модели не должен писать код для низкоуровневой работы с регистрами, HAL или SDK каждый раз заново. Таргет может предоставить стабильный слой драйверов:

  • простой Си API для пользовательских блоков;

  • заголовочные файлы с константами и типами;

  • реализации для конкретной платформы;

  • шаблоны блоков .nglib, которые генерируют вызовы этого API.

Такой слой отделяет модель от деталей оборудования. Пользователь вызывает, например, digitalWrite(…​) или uart_transmit(…​), а таргет решает, какие регистры, HAL-функции или SDK-вызовы за этим стоят.

Как драйверы попадают в проект

Обычно драйверы располагаются рядом с таргетом в директории drivers. В generate_executable_code таргет копирует эти файлы в директорию проекта, а система сборки включает их в компиляцию.

Пример копирования драйверов в проект

drivers_files = _get_drivers_src_list()
for file in drivers_files:
    shutil.copy2(file, project_src)

Если пользователь добавляет свои исходные файлы через C Function, таргет может сохранить их через store_filed_sources(model_settings.cmi, project_src). Это полезно, когда модель содержит дополнительные .c/.h файлы, которые не являются частью самого таргета.

Пример сохранения пользовательских файлов из C Function

store_model_src(model_code.c_code, project_src, codegen_info)
store_filed_sources(model_settings.cmi, project_src)

Сборочная система должна видеть и сгенерированные файлы модели, и драйверы. В предоставляемом примере в CMake-шаблоне ATmega328 это сделано через GLOB по директории drivers и файлам проекта.

Пример включения драйверов в CMake

file(
    GLOB MODEL_SOURCES
    "${MODELS_PATH_DIR}/drivers/*.c"
    "${MODELS_PATH_DIR}/drivers/SWSerial/*.c"
    "${MODELS_PATH_DIR}/*.c"
)

add_executable(${TARGET}_elf ${MODEL_SOURCES})

Пример ArduinoUNO: Serial как Си-API

В предоставляемом примере в engee-support-packages/targets/ArduinoUNO/drivers находится базовый драйвер UART поверх Arduino Serial. Заголовок serial.h объявляет Си-совместимый API, а реализация serial.cpp вызывает Arduino C++ API.

Такой подход важен: код из блока C Function и сгенерированный Си-код модели могут вызывать простые Си-функции, а внутри драйвера можно использовать объекты C++ платформы.

Пример фрагмента serial.h

#ifdef __cplusplus
extern "C" {
#endif

void beginSerial(uint8_t serial_port, uint32_t baudrate, int config);
void endSerial(uint8_t serial_port);
bool isReadySerial(uint8_t serial_port);
uint8_t availableSerial(uint8_t serial_port);
size_t readBytesSerial(uint8_t serial_port, uint8_t *buffer, int len);
size_t writeSerial(uint8_t serial_port, uint8_t *buffer, int len);

#ifdef __cplusplus
}
#endif

Пример использования Arduino API внутри драйвера

void beginSerial(uint8_t serial_port, uint32_t baudrate, int config)
{
    if (config == UNSPEC) config = SERIAL_8N1;
    Serials[serial_port]->begin(baudrate, config);
}

size_t writeSerial(uint8_t serial_port, uint8_t *buffer, int len)
{
    return Serials[serial_port]->write(buffer, len);
}

Из блока C Function пользователь может вызвать этот API в Начальный код, Выходной код и Завершающий код.

Пример использования в C Function

/* StartCode */
beginSerial(0, 115200, SERIAL_8N1);

/* OutputCode */
uint8_t value = (uint8_t)input1;
writeSerial(0, &value, 1);

/* TerminateCode */
endSerial(0);

Пример ATmega328: GPIO, ADC, UART и таймеры

В предоставляемом примере в engee-support-packages/targets/ATmega328/drivers драйверы ближе к железу: они работают с регистрами AVR напрямую. Там есть:

  • gpio.h / gpio.c — цифровой ввод-вывод, ADC, PWM;

  • uart.h / uart.c — прием и передача UART со статусами ошибок;

  • timers.h / timers.c — настройка таймеров;

  • interrupts.h / interrupts.c — вспомогательные функции для прерываний;

  • bits.h — макросы для работы с битами и регистрами;

  • SWSerial — программный serial.

В gpio.h API выглядит как набор простых функций и констант, которые может вызвать C Function или сгенерированный аппаратный блок.

Пример фрагмента GPIO API

#define HIGH 0x01
#define LOW  0x00
#define OUT  0x01
#define IN   0x00

void pinMode(int reg, int pin, int out);
void digitalWrite(int port, int bit, uint8_t high);
int  digitalRead(int pin, int bit);
int  analogRead(int channel);
void adcInit(int prescaler);
void analogWrite(int port, int bit, int duty, int inverse);

UART-драйвер ATmega328 показывает другой полезный прием: функции возвращают структуру со статусом операции. Это лучше, чем просто возвращать 0/1, потому что модель может отличить временной лимит, ошибку кадра, ошибку четности, отсутствие инициализации и частичный прием.

Пример результата UART-операции

typedef struct {
    uint8_t bytes_transmitted;
    uart_status_t status;
} uart_transmit_result_t;

uart_transmit_result_t uart_transmit(
    const unsigned char *data,
    const unsigned char length,
    uint16_t timeout_ms,
    uint8_t disable_interrupts
);

Пользовательские библиотеки блоков

arduino uno lib

Драйверы и Си API сами по себе не предоставляют пользователю удобного интерфейса моделирования. Чтобы пользователь мог добавлять аппаратные функции таргета как обычные блоки Engee, их нужно организовать в пользовательскую библиотеку.

Пользовательская библиотека Engee — это файл .nglib, который содержит блоки или подсистемы и отображается в библиотеке блоков Engee. Описание механизма находится в статье Пользовательские библиотеки Engee.

Для таргета .nglib обычно используется как публичная часть пакета поддержки:

  • пользователь выбирает блок из библиотеки, а не пишет код C Function вручную;

  • маска блока задает понятные параметры: порт, пин, скорость UART, канал ADC, частоту PWM;

  • код внутри блока вызывает Си API драйверов таргета;

  • одинаковые блоки можно использовать в разных моделях;

  • библиотеку можно поставлять вместе с пакетом поддержки и примерами.

Если блоков мало, достаточно одного .nglib файла, например MyBoard.nglib. Если таргет содержит много блоков, их лучше разделить по темам: GPIO, UART, ADC, PWM, Timers, Communication. Для такой структуры можно использовать файл engee_library.toml, который описывает, какие .nglib файлы в какие разделы библиотеки блоков должны попасть.

Пример многоуровневой библиотеки таргета

[metadata]
format_version = "1"

[[categories]]
lib_path = "/MyBoard/GPIO"
files = ["gpio/digital.nglib", "gpio/analog.nglib"]

[[categories]]
lib_path = "/MyBoard/Communication/UART"
files = ["communication/uart.nglib"]

[[categories]]
lib_path = "/MyBoard/Timers"
files = ["timers/timers.nglib"]

Сами .nglib файлы можно хранить в структуре таргета рядом с драйверами, шаблонами и демо-моделями. Важно, чтобы после загрузки пакета поддержки пользователь мог добавить каталог с библиотекой в путь Engee и увидеть блоки в разделе пользовательских библиотек.

Практически это означает, что документация таргета должна описывать не только Си-функции драйвера, но и соответствующие блоки:

  • где находится .nglib;

  • в какой раздел библиотеки попадут блоки;

  • какие параметры есть у маски;

  • какой Си API вызывается внутри;

  • какие ограничения есть у блока: допустимые порты, частоты, размеры буферов, шаг расчета;

  • работает ли блок только в независимом режиме или также поддерживает симуляцию на хосте.

Связь C Function с блоками библиотеки

Таргет может ограничиться документацией для C Function, но обычно удобнее дать пользователю готовые аппаратные блоки в .nglib. Такой блок внутри может быть построен на C Function или другом механизме генерации кода, но его задача одна: скрыть Си API за понятной маской блока.

Например, вместо того чтобы пользователь вручную писал:

pinMode(DDRB_REG, 5, OUT);
digitalWrite(PORTB_REG, 5, HIGH);

можно сделать блок Digital Write, где пользователь выбирает порт, пин и начальное состояние в маске. Код блока будет генерировать вызовы драйвера сам.

Важно, чтобы параметры маски блока напрямую соответствовали аргументам Си API или однозначно преобразовывались в них. Если маска использует понятные без расшифровки значения вроде PB5, Output, PullUp, то блок должен преобразовать их в константы драйвера.

Параметры сборки и дополнительные исходники

В C Function есть параметры сборки и возможность использовать пользовательские исходные файлы. Для таргета это означает, что при генерации проекта необходимо сохранить все дополнительные файлы с исходным кодом (.c/.h) и пути для включения заголовочных файлов.

В Engee.Интеграции для этого есть два механизма:

  • store_filed_sources сохраняет пользовательские исходные файлы из блоков C Function;

  • CMIParser._parse_cfunction_comments может извлечь дополнительные параметры компиляции из специальных комментариев C Function.

Если таргет поддерживает пользовательские зависимости в блоках C Function, нужно явно решить:

  • какие исходные файлы разрешено добавлять;

  • куда они копируются в проекте;

  • какие подключаемые директории добавляются в сборку;

  • разрешены ли внешние библиотеки;

  • как пользователь увидит ошибку сборки.

Для начальной версии таргета можно поддержать только Си-код, который компилируется вместе с моделью, а внешние библиотеки добавить позже.

Рекомендации к Си API драйверов

Драйверы таргета становятся публичным API для моделей, поэтому их стоит проектировать с учетом долгосрочной совместимости:

  • использовать простые Си-типы: uint8_t, uint16_t, uint32_t, float, double и указатели на буферы;

  • избегать API, который требует динамического выделения памяти внутри шага модели;

  • явно документировать единицы измерения: микросекунды, миллисекунды, герцы, биты/с, проценты PWM;

  • возвращать статус ошибки, если операция может не выполниться;

  • не блокировать планировщик надолго: длительный прием по UART или ожидание завершения ADC могут привести к нарушению TET;

  • разделять инициализацию, шаговую работу и освобождение ресурсов;

  • учитывать, что Начальный код вызывается один раз, Выходной код — на каждом шаге, Завершающий код — при останове;

  • использовать extern "C" в заголовках, если реализация драйвера написана на C++;

  • явно указывать, какие функции безопасно вызывать из обработчика прерываний (ISR) или задачи RTOS.

Проверка драйверов

Драйверы нужно проверять отдельно от большой модели. В примере ATmega328/tests есть небольшие Си-тесты для GPIO, UART и аналогового ввода. Такой формат полезен для первичного запуска и отладки новой платы: сначала проверяется драйвер, потом блок C Function, потом полная модель.

Минимальный набор проверок:

  1. Сборка проекта с драйверами без модели.

  2. Инициализация периферии в простом тесте.

  3. Проверка одной операции чтения или записи.

  4. Проверка ошибок: неверный порт, временной лимит, пустой буфер.

  5. Проверка вызова из C Function в демо-модели.

  6. Проверка, что драйвер не нарушает TET на целевом шаге расчета.

Проверочный список

  1. У таргета есть директория drivers с заголовками и реализациями.

  2. generate_executable_code копирует драйверы в проект.

  3. Сборочная система компилирует драйверы вместе с моделью.

  4. Заголовки драйверов доступны C Function-коду.

  5. Пользовательские блоки собраны в .nglib библиотеку.

  6. Для большой библиотеки добавлен engee_library.toml с понятной иерархией.

  7. Инициализация вынесена в Начальный код или запуск рантайм.

  8. Ресурсы освобождаются в Завершающий код, если платформа это поддерживает.

  9. Аппаратные блоки .nglib используют тот же Си API, что и документация.

  10. Ошибки драйверов возвращаются в явном виде.

  11. Есть базовые тесты драйверов и демо-модели.