Драйверы и C Function-блоки
|
Страница в процессе разработки. |
Таргет отвечает не только за запуск сгенерированной модели, но и за доступ модели к возможностям целевой платформы: GPIO, UART, ADC, PWM, таймерам, шинам и внешним микросхемам. Обычно этот доступ организуют через Си-драйверы, которые копируются в проект таргета и вызываются из Си-кода модели.
Основной пользовательский механизм для вызова Си-кода из модели — блок C Function. Поэтому при проектировании таргета важно заранее определить, какой Си API предоставляется пользователю и как этот API будет попадать в сгенерированный проект.
Что такое C Function
Блок C Function позволяет использовать Си-код внутри модели Engee. Пользователь настраивает входы, выходы, параметры, рабочие переменные и пишет код в редакторе блока.
У редактора C Function есть четыре основные секции:
-
Output code— код, который выполняется на каждом шаге расчета блока; -
Start code— код, который выполняется один раз при инициализации; -
Terminate code— код, который выполняется один раз при остановке; -
Shared code— общий код, функции и глобальные данные для нескольких экземпляров C Function с одинаковым именем функции.
При генерации Си-кода содержимое этих секций преобразуется в Си-функции, которые входят в сгенерированный код модели. Это значит, что код в блоке C Function компилируется вместе с рантайм-обвязкой таргета и может вызывать функции из драйверов, если заголовки и исходники драйверов добавлены в проект.
Редактор C Function показывает прототип функции для текущих входов, выходов, параметров и рабочих переменных. Для таргета важно, чтобы документация драйверов позволяла пользователю однозначно определить место вызова каждого драйвера в сгенерированных интерфейсных функциях — init, step и term — без необходимости самостоятельного анализа или предположений.
Рабочие переменные C Function предназначены для состояния конкретного экземпляра блока. Их удобно использовать для буферов, счетчиков, дескрипторов и других данных, которые не должны быть глобальными для всей модели. Если состояние должно быть общим для нескольких экземпляров блока с одинаковым именем функции, его размещают в секции Shared code.
Для обычной симуляции 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 в Start code, Output code и Terminate code.
Пример использования в 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
);
Пользовательские библиотеки блоков

Драйверы и Си 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;
-
разделять инициализацию, шаговую работу и освобождение ресурсов;
-
учитывать, что
Start codeвызывается один раз,Output code— на каждом шаге,Terminate code— при останове; -
использовать
extern "C"в заголовках, если реализация драйвера написана на C++; -
явно указывать, какие функции безопасно вызывать из обработчика прерываний (ISR) или задачи RTOS.
Проверка драйверов
Драйверы нужно проверять отдельно от большой модели. В примере ATmega328/tests есть небольшие Си-тесты для GPIO, UART и аналогового ввода. Такой формат полезен для первичного запуска и отладки новой платы: сначала проверяется драйвер, потом блок C Function, потом полная модель.
Минимальный набор проверок:
-
Сборка проекта с драйверами без модели.
-
Инициализация периферии в простом тесте.
-
Проверка одной операции чтения или записи.
-
Проверка ошибок: неверный порт, временной лимит, пустой буфер.
-
Проверка вызова из C Function в демо-модели.
-
Проверка, что драйвер не нарушает TET на целевом шаге расчета.
Проверочный список
-
У таргета есть директория
driversс заголовками и реализациями. -
generate_executable_codeкопирует драйверы в проект. -
Сборочная система компилирует драйверы вместе с моделью.
-
Заголовки драйверов доступны C Function-коду.
-
Пользовательские блоки собраны в
.nglibбиблиотеку. -
Для большой библиотеки добавлен
engee_library.tomlс понятной иерархией. -
Инициализация вынесена в
Start codeили запуск рантайм. -
Ресурсы освобождаются в
Terminate code, если платформа это поддерживает. -
Аппаратные блоки
.nglibиспользуют тот же Си API, что и документация. -
Ошибки драйверов возвращаются в явном виде.
-
Есть базовые тесты драйверов и демо-модели.