В достаточно крупных приложениях немалую часть проекта составляет бизнес-логика. Эту часть программы удобно отлаживать на компьютере, после чего встраивать в состав проекта для микроконтроллера, ожидая, что эта часть будет выполняться в точности так, как было задумано без какой-либо отладки (идеальный случай).
Так как большинство программ для микроконтроллеров пишется на С/C++, то для этих целей обычно используют абстрактные классы, предоставляющие интерфейсы к низкоуровневым сущностям (в случае, если проект пишется только с использованием C, то зачастую используются структуры указателей на функции). Данный подход предоставляет требуемый уровень абстракции над железом, однако чреват надобностью в постоянной повторной компиляции проекта с последующим программированием энергонезависимой памяти микроконтроллера бинарным файлом прошивки большого объема.
Однако есть и другой путь — использование скриптового языка, позволяющего производить отладку бизнес-логики в реальном времени на самом устройстве или загружать сценарии работы прямо с внешней памяти, не включая данного кода в состав прошивки микроконтроллера.
В качестве скриптового языка я выбрал Lua.
Почему Lua?
Существуют несколько скриптовых языков, которые можно встроить в проект для микроконтроллера. Несколько простых BASIC-подобных, PyMite, Pawn… У каждого есть свои плюсы и минусы, обсуждение которых не входит перечень обсуждаемых вопросов данной статьи.
Кратко о том, чем хорош конкретно lua — можно прочесть в статье «Lua за 60 минут». Меня эта статья сильно вдохновила и я, для более детального изучения вопрос, прочел официальное руководство-книгу от автора языка Роберту Иерузалимски "Программирование на языке Lua" (имеется в официальном русском переводе).
Отдельно хочу отметить проект eLua. В моем случае, у меня уже имеется готовая программная низкоуровневая прослойка для взаимодействия как с периферией микроконтроллера, так и для прочей требуемой периферии, расположенной на плате устройства. Поэтому данный проект мною не рассматривался (поскольку он признан предоставить те самые прослойки для связи ядра Lua с периферией микроконтроллера).
О проекте, в который будет встраиваться Lua
По традиции, в качество поля для экспериментов будет использоваться мой проект-песочница (ссылка на коммит с уже интегрированной lua со всеми необходимыми доработками, описанными ниже).
Проект базируется на микроконтроллере stm32f405rgt6 c 1 МБ энергонезависимой и 192 КБ оперативной памяти (на данный момент используются старшие 2 блока с суммарным объемом 128 КБ).
В проекте присутствует операционная система реального времени FreeRTOS для поддержания инфраструктуры работы с аппаратной периферией. Вся память под задачи, семафоры и прочие объекты FreeRTOS выделена статически на этапе компоновки (находятся в .bss области оперативной памяти). Все FreeRTOS сущности (семафоры, очереди, стеки задач и прочие) являются частями глобальных объектов в private областях их классов. Однако FreeRTOS куча все равно выделяется для поддержки переопределённых на работу с нею функций malloc, free, calloc (требуемых для таких функций, как printf). Имеется поднятый API для работы с MicroSD (FatFS) картами, а так же отладочный UART (115200, 8N1).
О логике использования Lua в составе проекта
Для цели отладки бизнес-логики предполагается, что команды будут приходить по UART-у, паковаться (отдельным объектом) в законченные строки (завершающиеся символом "n" + 0-терминатор) и отправляться в lua-машину. В случае неудачного выполнения, вывод по средствам printf (так как в проекте он был ранее задействован). Когда логика будет отлажена, можно будет реализовать загрузку конечного файла бизнес-логики из файла с MicroSD карты (не входит в материал этой статьи). Так же для цели отладки Lua машина будет исполняться внутри отдельного потока FreeRTOS (в будущем на каждый отлаженный скрипт бизнес-логики будет выделен отдельный поток, в котором он будет выполняться со своим окружением).
Включение субмодуля lua в состав проекта
В качестве источника библиотеки lua будет использоваться официальное зеркало проекта на github (поскольку мой проект так же размещен там. Вы можете использовать исходники напрямую с официального сайта). Так как в проекте имеется налаженная система сборки субмодулей в составе проекта по индивидуальным для каждого субмодуля CMakeLists-ам, то я создал отдельный субмодуль, в который включил fork данного и CMakeLists для сохранения единой стилистики сборки.
CMakeLists производит сборку исходников репозитория lua как статическую библиотеку со следующими флагами компиляции субмодуля (берутся из файла конфигурации субмодулей в основном проекте):
SET(C_COMPILER_FLAGS "-std=gnu99;-fshort-enums;-fno-exceptions;-Wno-type-limits;-ffunction-sections;-fdata-sections;")
SET(MODULE_LUA_COMP_FLAGS "-O0;-g3;${C_COMPILER_FLAGS}"
И флагами конкретизации используемого процессора (задаются в корневом CMakeLists-е):
SET(HARDWARE_FLAGS
-mthumb;
-mcpu=cortex-m4;
-mfloat-abi=hard;
-mfpu=fpv4-sp-d16;)
Важно отметить необходимость в корневом CMakeLists-е указать определение, разрешающее не использовать double значения (поскольку микроконтроллер не имеет аппаратной поддержки double. Только float):
add_definitions(-DLUA_32BITS)
Ну и остается только сообщить компоновщику о необходимости собрать данную библиотеку и включить итог в компоновку конечного проекта:
add_subdirectory(${CMAKE_SOURCE_DIR}/bsp/submodules/module_lua)
...
target_link_libraries(${PROJECT_NAME}.elf PUBLIC
# -Wl,--start-group нужно для решения вопроса с циклическими
# зависимостями между библиотеками во время компоновки.
# Конкретно Lua в этом не нуждается, но не все библиотеки так
# хорошо написаны.
"-Wl,--start-group"
...другие_библиотеки...
MODULE_LUA
...другие_библиотеки...
"-Wl,--end-group")
Определение функций для работы с памятью
Так как сама Lua не занимается работой с памятью, то эта обязанность переходит на пользователя. Однако при использовании идущей в комплекте библиотеки lauxlib и функции luaL_newstate из нее, происходит привязка функции l_alloc в качестве системы работы с памятью. Она определена следующим образом:
static void *l_alloc (void *ud, void *ptr, size_t osize, size_t nsize) {
(void)ud; (void)osize; /* not used */
if (nsize == 0) {
free(ptr);
return NULL;
}
else
return realloc(ptr, nsize);
}
Как было сказано в начале статьи, в проекте уже имеются переопределенные функции malloc и free, однако нет функции realloc. Нам требуется это исправить.
В стандартном механизме работы с кучей FreeRTOS, в файле heap_4.c, используемом в проекте, отсутствует функция для изменения размера ранее выделанного блока памяти. В связи с этим, придется на основе malloc и free сделать свою реализацию.
Поскольку в будущем возможно изменение схемы распределения памяти (использование другого файла heap_x.c), то было решено не использовать внутренности текущей схемы (heap_4.c), а сделать более высокоуровневую надстройку. Хоть и менее эффективную.
Важно учесть, что метод realloc не только удаляет старый блок (если таковой существовал) и создает новый, но еще и перемещает данные из старого блока в новый. Причем в случае, если в старом блоке было больше данных, чем в новом, то новый заполняется старыми до предела, а оставшиеся данные отбрасываются.
Если не учесть этого факта, то ваша машина сможет выполнить трижды вот такой скрипт из строки "a = 3n", после чего упадет в hard fault. На проблему удастся выйти после изучения остаточного образа регистров в обработчике hard fault, из которого удастся узнать, что падение произошло после попытки расширить таблицу в недрах кода интерпретатора и его библиотек. Если же вызывать скрипт по типу "print 'test'", то поведение будет меняться в зависимости от того, как соберется файл прошивки (иначе говоря, поведение не определено).
Для того, чтобы скопировать из старого блока данные в новый нам потребуется узнать размер старого блока. FreeRTOS heap_4.c (как и другие файлы, предоставляющие методы работы с кучей) не предоставляет API для этого. Поэтому придется дописать свой. За основу я взял функцию vPortFree и урезал ее функциональность до следующего вида:
int vPortGetSizeBlock (void *pv) {
uint8_t *puc = (uint8_t *)pv;
BlockLink_t *pxLink;
if (pv != NULL) {
puc -= xHeapStructSize;
pxLink = (BlockLink_t *)puc;
configASSERT((pxLink->xBlockSize & xBlockAllocatedBit) != 0);
configASSERT(pxLink->pxNextFreeBlock == NULL);
return pxLink->xBlockSize & ~xBlockAllocatedBit;
}
return 0;
}
Теперь дело за малым, написать realloc на основе malloc, free, и vPortGetSizeBlock:
void *realloc (void *ptr, size_t new_size) {
if (ptr == nullptr) {
return malloc(new_size);
}
void* p = malloc(new_size);
if (p == nullptr) {
return p;
}
size_t old_size = vPortGetSizeBlock(ptr);
size_t cpy_len = (new_size < old_size)?new_size:old_size;
memcpy(p, ptr, cpy_len);
free(ptr);
return p;
}
Добавляем поддержку работы с stdout
Как становится известно из официального описания, сам lua интерпретатор не умеет работать с вводом-выводом. Для этих целей подключается одна из стандартных библиотек. Для вывода она использует поток stdout. За подключение к потоку отвечает функция luaopen_io из стандартной библиотеки. Для поддержки работы с stdout (в отличие от printf), потребуется переопределить функцию fwrite. Я переопределил ее на основе функций, описанных в предыдущей статье.
size_t fwrite(const void *buf, size_t size, size_t count, FILE *stream) {
stream = stream;
size_t len = size * count;
const char *s = reinterpret_cast<const char*>(buf);
for (size_t i = 0; i < len; i++) {
if (_write_char((s[i])) != 0) {
return -1;
}
}
return len;
}
Без ее определения, функция print в lua будет отрабатывать успешно, но вывода результата не будет. При том на стеке Lua машины не будет никаких ошибок (поскольку формально функция выполнилась успешно).
Помимо этой функции нам понадобится функция fflush (для функционирования интерактивного режима, о чем будет далее). Так как эту функцию нельзя переопределить, то придется назвать ее чуть иначе. Функция является урезанной версией функции fwrite и предназначена для отправки того, что сейчас находится в буфере с последующей его очисткой (без дополнительного перевода каретки).
int mc_fflush () {
uint32_t len = buf_p;
buf_p = 0;
if (uart_1.tx(tx_buf, len, 100) != mc_interfaces::res::ok) {
errno = EIO;
return -1;
}
return 0;
}
Получение строк из последовательного порта
Для получения строк для lua-машины я решил написать простенький класс uart-терминала, который:
- принимает данные по последовательному порту побайтно (в прерывании);
- добавляет полученный байт в очередь, откуда его принимает поток;
- в потоке байт, если это не перевод строки, отправляется обратно в таком виде, в каком он пришел;
- если пришел перевод строки ('r'), то отправляются 2 байта перевода каретки терминала ("nr");
- после отправки ответа вызывается обработчик пришедшего байта (объект компоновки строки);
- контролирует нажатие клавиши удаления символа (во избежание удаления с окна терминала служебных символов);
Ссылки на исходники:
- интерфейс класса UART находится здесь;
- базовый класс UART находится здесь и здесь;
- класс uart_terminal здесь и здесь;
- создание объекта класса в составе проекта здесь.
Дополнительно замечу, что чтобы данный объект работал исправно, требуется назначить приоритет прерывания UART в допустимом диапазоне для работы с функциями FreeRTOS из прерывания. В противном случае можно получить интересные сложно отлаживаемые ошибки. В текущем примере в файле FreeRTOSConfig.h установлены следующие параметры для прерываний.
#define configPRIO_BITS 4
#define configKERNEL_INTERRUPT_PRIORITY 0XF0
// Можно использовать FreeRTOS API в прерываниях
// с уровнем 0x8 - 0xF.
#define configMAX_SYSCALL_INTERRUPT_PRIORITY 0x80
В самом проекте объект класса nvic устанавливает приоритет прерывания 0x9, что входит в допустимый диапазон (класс nvic описан здесь и здесь).
Формирование строки для Lua-машины
Принятые из объекта uart_terminal байты передаются в экземпляр простенького класса serial_cli, который предоставляет минимальный интерфейс редактирования строки и передачу ее непосредственно в поток, в котором выполняется lua-машина (через вызов callback функции). По принятии символа 'r' вызывается функция обратного вызова (callback). Эта функция должна скопировать себе строку и «отпустить» управление (поскольку во время вызова прием новых байт блокируется. Это не является проблемой при правильно расставленных приоритетах потоков и достаточной низкой скорости UART).
Ссылки на исходники:
Важно заметить, что данный класс считает строку длиннее 255 символов невалидной и сбрасывает ее. Это сделано намеренно, поскольку интерпретатор lua позволяет вводить конструкции построчно, ожидая окончания блока.
Передача строки в Lua интерпретатор и ее исполнение
Сам по себе Lua интерпретатор не умеет принимать код блока построчно, после чего исполнять целый блок самостоятельно. Однако, если установить Lua на компьютер и запустить интерпретатор в интерактивном режиме, то мы можем видеть, что исполнение идет построчно с соответствующими обозначениями по мере ввода, что блок пока не является завершённым. Так как интерактивный режим это то, что предоставляется в стандартной поставке, то мы можем посмотреть его код. Он находится в файле lua.c. Нас интересует функция doREPL и все, что она использует. Дабы не придумывать велосипед, я для получения функций интерактивного режима в проекте сделал порт этого кода в отдельный класс, который назвал по имени исходной функции lua_repl, который использует printf для вывода информации в консоль и имеет публичный метод add_lua_string для добавления строки, полученной из объекта класса serial_cli, описанного выше.
Ссылки:
Класс выполнен по паттерну синглтон Майерса, поскольку нет необходимости в пределах одного устройства давать несколько интерактивных режимов. Объект класса lua_repl получает данные от объекта класса serial_cli здесь.
Так как в проекте уже существует единая система инициализации и обслуживания глобальных объектов, то указатель на объект класса lua_repl передается объекту глобального класса player::base здесь. В методе start объекта класса player::base (объявленного здесь. Тут же и происходит его вызов из main), производится вызов метода init объекта класса lua_repl с приоритетом задачи FreeRTOS 3 (в проекте можно назначать приоритет задачи от 1, до 4. Где 1 — наименьший приоритет, а 4 — наибольший). По окончании успешной инициализации глобальный класс запускает планировщик FreeRTOS и интерактивный режим начинает свою работу.
Проблемы, появляющиеся при портировании
Ниже будет приведен перечень проблем, с которыми я столкнулся во время порта Lua машины.
Исполняются 2-3 однострочных скрипта присвоения значения переменной, затем все падает в hard fault
Проблема была в методе realloc. Требуется не просто повторно выделить блок, но еще и скопировать содержимое старого (о чем писал выше).
При попытке напечатать значение интерпретатор падает в hard fault
Тут было уже сложнее обнаружить проблему, но по итогу удалось выяснить, что для печати используется snprintf. Так как lua хранит значения в double (или float в нашем случае), то требуется printf (и его производные) с поддержкой плавающей точки (о тонкостях настройки printf я писал здесь).
Требования к энергонезависимой (flash) памяти
Вот некоторые замеры, которые я сделал, позволяющие судить о том, сколько нужно выделить энергонезависимой (flash) памяти для интеграции Lua-машины в проект. Компиляция производилась с использованием gcc-arm-none-eabi-8-2018-q4-major. Использовалась версия Lua 5.4. Ниже в замерах под фразой «без Lua» подразумевается невключение в проект непосредственно интерпретатора и методов взаимодействие с ним и его библиотеками, а так же объекта класса lua_repl. Все низкоуровневые сущности (включая переопределения для работы функций printf и fwrite) остаются в составе проекта. Размер FreeRTOS кучи составляет 1024*25 байт. Остальное занимают глобальные сущьности проекта.
Сводная таблица результатов выглядит следующим образом (все размеры в байтах):
Параметры сборки | Без Lua | Только ядро | Lua c библиотекой base | Lua c библиотеками base, coroutine, table, string | luaL_openlibs |
---|---|---|---|---|---|
-O0 -g3 | 103028 | 220924 | 236124 | 262652 | 308372 |
-O1 -g3 | 74940 | 144732 | 156916 | 174452 | 213068 |
-Os -g0 | 71172 | 134228 | 145756 | 161428 | 198400 |
Требования к оперативной памяти
Так как потребление оперативной памяти зависит всецело от задачи, то я приведу сводную таблицу потребленной памяти сразу после включения машины с разным набором библиотек (она выводится командой print(collectgarbage(«count»)*1024)).
Состав | Использовано RAM |
Lua c библиотекой base | 4809 |
Lua c библиотеками base, coroutine, table, string | 6407 |
luaL_openlibs | 12769 |
В случае использования всех библиотек размер требуемой оперативной памяти значительно растет в сравнении с предыдущими комплектами. Однако его использование в немалой части приложений не обязательно.
Помимо этого так же выделяется 4 кб на стек задачи, в которой происходит выполнение Lua-машины.
Дальнейшее использование
Для полноценного использования машины в проекте далее потребуется описать все требуемые кодом бизнес-логики интерфейсы к аппаратной части или служебным объектам проекта. Однако это уже тема отдельной статьи.
Итоги
В данной статье было рассказано, как можно подключить к проекту для микроконтроллера Lua-машину, а так же запустить полноценный интерактивный интерпретатор, позволяющий производить эксперименты с бизнес-логикой прямо из командной строки терминала. Кроме того были рассмотрены требования к аппаратной начинке микроконтроллера при разных комплектациях Lua машины.
Автор: Вадим