Есть немало проектов по встраиванию интерпретатора Lua на микроконтроллеры, и некоторые из них помогли мне в работе на начальном этапе. Но в данном случае речь пойдет о коммерческом проекте ПЛК, запущенном 3 года назад. За это время был получен интересный опыт, и я хотел поделиться своими мыслями по использованию Lua как инструмента разработки бизнес-логики в распределенных контроллерах ввода/вывода и управления технологическими процессами и оборудованием.
Почему Lua? Или так говорил Роберту Иерузалимски
Начну с банальных базовых тезисов из книги «Programming in Lua», одного из основателей проекта Lua, пропущенных через призму опыта разработки встроенных систем. Lua это встраиваемый расширяющий язык. С точки зрения программы на Си («host») интерпретатор Lua — библиотека. Программа, или точнее кусок («chunk») кода на языке Lua, выполняемый интерпретатором — защищенный вызов внешней функции. Это позволяет легко интегрировать интерпретатор в RTOS. Lua замечательно живет в отдельной фоновой задаче FreeRTOS, и отлично себя чествует при вытеснении высокоприоритетными системными задачами реального времени. А в чем тогда расширение? Разработка прикладных частей системы на Lua дает возможность изменять функциональность без перекомпиляции кода ядра. А поскольку с точки зрения ядра происходит защищенный вызов внешней функции, то любые сбои в Lua коде — это возврат кода ошибки внешней функцией. Итак, в итоге мы получаем гибкий инструмент, который позволяет:
Менять функционал системы без вмешательства в ядро;
Легко внедрять интерпретатор в RTOS;
Контролировать ошибки исполнения внешнего кода;
Тут возникнет резонный вопрос, а зачем так все усложнять? Зачем городить огород ради вызова внешней функции, если ее гораздо проще написать на Cи? Если не рассматривать отдельно задачу ПЛК, то есть несколько взаимосвязанных областей применения.
Разработка на базе единой платформы устройств с разным прикладным функционалом. Например когда на одном контроллере необходимо реализовать управление несколькими сильно различающимися внешними тех. процессами. Другим аспектом являются сложные оконечные устройства ввода-вывода. Если кол-во регистров ModBus или объектов СanOpen переваливает за первую сотню, возможно стоит задуматься о загружаемом скрипте под конкретную задачу.
Отладка прикладных алгоритмов. Если изделие работает в составе большой системы управления, то реальный прикладной алгоритм может быть отлажен только в процессе пусконаладочных работ. Надо попробовать несколько вариантов логики управления, покрутить параметры и т.д. Каждый раз перекомпилировать проект возможно не сама удачная идея.
Необходимо подключить к отладке прикладного алгоритма специалистов заказчика, однако в следствии коммерческой тайны или не достаточной квалификации персонала передача исходных текстов невозможна. Т.е. ситуация, когда нужен ПЛК, хотя проектом он не предусматривался.
Одновременно со всем вышеизложенным Lua является расширяемым языком. Это обратная ситуация, когда Си host становиться библиотекой для программы на Lua. Очень мощный API интерпретатора ( “C API”) позволяет регистрировать в виртуальном окружении Lua программы вызовы на реальные функции написанные на Cи. И соответственно использовать возможности Cи при разработке программ на Lua. Зачем так сложно? Ну во-первых такой механизм дает очень большую гибкость во взаимодействии хоста с программой Lua. Но главное тут немножко другое. Lua создавалась прежде всего как инструмент, а не как отдельный язык. Такой механизм позволяет в одном проекте использовать сильные стороны обоих языков. За счет Lua расширять функциональность Cи host, придавая итоговому продукту гибкость. За счет Cи библиотек расширять функциональность Lua программы, разрабатывая на нем те части прикладного алгоритма, где требуется высокое быстродействие. В итоге мы получаем возможность разработки на Lua систем любого уровня абстракции. Я сторонник идеи, что прикладной скрипт — это просто удобный для конечного пользователя уровень абстракции, связывающий между собой готовые системные блоки. Но, с другой стороны, ни кто не мешает вытащить на прикладной уровень периферию микроконтроллера. Я не понимаю зачем это нужно в коммерческих проектах, но раз существует uLua и LuatOS, значит это кому-то все-таки надо.
Теперь немножко подробностей про то, как это все реализовано. Lua интерпретатор одновременно являема и компилятором. Главная функция тут "load". Она может принимать в качестве параметра исходные тексты Lua, включая построчную загрузку (строка в данном случае — это синтаксический законченный фрагмент кода) например из последовательного канала. Но одновременно с этим возможна загрузка заранее скомпилированного байт-кода luac. С точки зрения исполнения в рамках встроенной системы загружаемой прикладной программы, предпочтительней байт код, Это позволяет:
cсократить время старта, не нужна компиляция;
сократить объем требуемой оперативной памяти системы. Загруженная программа Lua будет являться единым кусток для компилятора, и он сначала загрузит ее в свою виртуальную память, а потом в той же памяти разместить скомпилированный байт код.
На стороне ПК байт код компилируется luac, это по сути такой же Cи хост, который использует интерпретатор как библиотеку. Только он скомпилирован для работы под целевую ОС. Нужно самостоятельно собрать 32-х битный компилятор под свою платформу. На сайте проекта есть готовые сборки, но они как правило 64-х битные. И что важно, luac на ПК и интерпретатор во всторенной системы должны быть одной и той же версии. Т.е. байт-код собранный на luac версии 5.3 не заработает на интерпретаторе версии 5.4. Перед компиляцией очень рекомендую прогнать скрипты через синтаксический анализатор luacheсk, поскольку luac не подсвечивает синтаксических ошибок.
Проблемой байт-кода является возможность потенциального сбоя всей системы, если пакет с байт-кодом получил повреждения при загрузке на контроллер. Но это отдельная задача. Просто констатирую, что если Lua скрипты абсолютно безопасны для встроенной системы, то байт-код может ее положить.
У Lua есть забавная особенность, байт код обычно занимает больше места чем исходные тексты. Ну за исключение ситуации большого кол-ва комментариев в исходных текстах. Это связно с тем, внутренняя структура интерпретатора построена на ассоциативных массивах. Т.е внутренние структуры Lua, включая окружение, это таблицы которые индексируются не только номерами, но и строками . На практике это означаете что байт-код содержит строки с именами всех функций и переменных используемых в Lua программе. Поэтому при ограниченном размере оперативной памяти, оптимизацию можно произвести за счет сокращения длинны имен переменных и функций.
Итогом загрузки/компиляции скрипта load является функция, которую можно вызвать. В C API для этого служат различные варианты call. Эти вызовы запустят исполнение куска и закончит свою работу после последнего оператора в прикладном коде. В реальных задачах мне кажется более предпочтительным использовать механизм Lua сопроцессов. Он реализуется функцией C API lua_resume и парной ей функцией yield, библиотеки coroutine, со стороны Lua скрипта. Работает это следующим образом. Первый lua_resume запускает кусок и выполняет пока не встретит уступку через coroutine.yield. Выполнение куска приостанавливается, и lua_resume возвращает управление хосту. При его следующем вызове, выполнение куска начинаться с coroutine.yield, т.е. на том же месте, где было прервано. В рамках процесса FreeRTOS мне кажется достаточно логичным реализовать прикладной алгоритм на Lua, как циклический процесс, с явной уступкой управления взывающему Cи хосту в конце каждой итерации. Но конечно же есть и другие варианты, которые могу быть более удобными для конкретных задач.
Теперь неплохо было бы понять, как наладить взаимодействие между Cи хостом и прикладным скриптом. Все это происходит через виртуальный стек Lua. Для работы с виртуальным стеком в C API есть выделенный набор вызовов push/pop. Со стороны Lua скрипта работа с виртуальным стеком происходит автоматический.
если Cи host вызывает функцию Lua, он самостоятельно помещает в стек аргументы вызова и забирает из стека результаты. Lua функция же автоматический получает из стека свои параметры и возвращает результаты работы
если Lua функция взывает Си host, то схема зеркальная. Со стороны скрипта все происходит автоматический, а вот функция host-а должна самостоятельно забрать из стека свои параметры и положить обратно результат работы.
Кол-во параметров и возвращаемых результатов в Lua не ограничено и может быть динамическим.
Имеет ли смысл? Требования к системе и варианты как все можно ужать
В рамках моих проектов Lua запускалась на Cortex-M4F 160Mhz и Risc-V RV32IMACF 144Mhz. В обоих случая есть FPU. Поскольку Lua поддерживает вычисления с плавающей точкой, то наличие FPU прилично сокращает размер кода, хотя принципиально ни каких проблем нет. Функциональные скрипты в районе 700 строк кода (30кб в компилированном байт-коде) позволяют держать темп от 1 до 1.8 мс на один цикл прикладного алгоритма, с учетом параллельной работы высокоприоритетных процессов противоаварийной автоматики и коммуникации по CAN. Т.е. в системах, где не приходиться заботиться о том, хватает ли производительности процессора или нет, цикл выполнения сложного прикладного алгоритма под Lua вполне сопоставим с временем RTOS.
А вот с памятью все несколько сложнее. Если очень грубо, то обычно тот же uLua или LuaTOS говорят о минималке 256к/64к ROM/RAM. Да, по сути это то что нужно, что бы развернуть Lua из коробки и не думать. Но для многих моих задач типовым чипом стало что-то в LQFP48, и тут максимум 128к/32к ROM/RAM. Можно ли с этим что-то сделать? Оказывается да. Если использовать байт код, то нам не нужен компилятор! Функция load различает файлы .luca (байт-код) и .lua по сигнатуре в начале .luac файла. Если сигнатура есть, то это байт-код, если нет, то идем компилировать. Так что достаточно просто закомментировать компиляцию в в файле ldo.c. Это сэкономит больше 20кб. Примерно так:
static void f_parser (lua_State *L, void *ud) {
LClosure *cl;
struct SParser *p = cast(struct SParser *, ud);
int c = zgetc(p->z); /* read first character */
if (c == LUA_SIGNATURE[0]) {
checkmode(L, p->mode, "binary");
cl = luaU_undump(L, p->z, p->name);
}
// else {
// checkmode(L, p->mode, "text");
// cl = luaY_parser(L, p->z, &p->buff, &p->dyd, p->name, c);
// }
lua_assert(cl->nupvalues == cl->p->sizeupvalues);
luaF_initupvals(L, cl);
}
Ну и резать библиотеки. Они мощный инструмент, но большинство из того, что предоставляет Lua из коробки не нужно в задачах встроенных систем. Тут мы экономим не только ROM, но и RAM, за счет уменьшения размера окружения. Помните про ассоциативные массивы в Lua? Резать можно как сами библиотеки, так и их состав. Например в linit.c для Lua 5.3 у меня это выглядит вот так:
/*
** these libs are loaded by lua.c and are readily available to any Lua
** program
*/
static const luaL_Reg loadedlibs[] = {
{"_G", luaopen_base},
// {LUA_LOADLIBNAME, luaopen_package},
{LUA_COLIBNAME, luaopen_coroutine},
//{LUA_TABLIBNAME, luaopen_table},
// {LUA_STRLIBNAME, luaopen_string},
// {LUA_MATHLIBNAME, luaopen_math},
// {LUA_UTF8LIBNAME, luaopen_utf8},
//{LUA_DBLIBNAME, luaopen_debug},
#if defined(LUA_COMPAT_BITLIB)
{LUA_BITLIBNAME, luaopen_bit32},
#endif
{NULL, NULL}
};
Осталась только базовая библиотека и сопроцессы. Но из сопроцессов я использую только функцию yield. Вот что в файле lcorolib.c.
static const luaL_Reg co_funcs[] = {
//{"create", luaB_cocreate},
//{"resume", luaB_coresume},
// {"running", luaB_corunning},
// {"status", luaB_costatus},
// {"wrap", luaB_cowrap},
{"yield", luaB_yield},
// {"isyieldable", luaB_yieldable},
{NULL, NULL}
};
С базовой библиотекой в файле lbaselib.c cитуация аналогична. Как минимум можно резать вызовы load и call. А дальше уж все зависит от сценария использования Lua.
static const luaL_Reg base_funcs[] = {
// {"assert", luaB_assert},
// {"collectgarbage", luaB_collectgarbage},
//{"dofile", luaB_dofile},
// {"error", luaB_error},
{"getmetatable", luaB_getmetatable},
{"ipairs", luaB_ipairs},
//{"loadfile", luaB_loadfile},
//{"load", luaB_load},
#if defined(LUA_COMPAT_LOADSTRING)
{"loadstring", luaB_load},
#endif
{"next", luaB_next},
{"pairs", luaB_pairs},
// {"pcall", luaB_pcall},
// {"print", luaB_print},
{"rawequal", luaB_rawequal},
{"rawlen", luaB_rawlen},
{"rawget", luaB_rawget},
{"rawset", luaB_rawset},
{"select", luaB_select},
{"setmetatable", luaB_setmetatable},
{"tonumber", luaB_tonumber},
{"tostring", luaB_tostring},
{"type", luaB_type},
// {"xpcall", luaB_xpcall},
/* placeholders */
{"_G", NULL},
{"_VERSION", NULL},
{NULL, NULL}
};
Как итог всех этим манипуляций - сборка из урезанной Lua5.3+ FreeRTOS+printf на оптимизации Os для RISC-V у меня занимает 85кб, на O2 - 91кб. На АРМ ситуация применю такая же. C RAM сложение. Такая сборка требует около 8кб. Еще 2кб занимает переменная состояния Lua и нужно память под окружение. Т.е. на круг выйдет где-то 15кб. И это уже вполне рабочая история. В 17кб влезет очень здоровый скрипт на Lua в байт-коде.(скрипт из примера дальше весит 1.1кБ). Ну а в 38 кб флэша можно впихнуть много чего полезного, в том числе зарезервировать там место под хранение скрипта (резервировать больше, чем остается виртуальной памяти смысла нет). Я подсветил лишь самые простые варианты как "с ноги" сократить размеры. Дальше уже вопрос погружения в платформу и ее возможности. В любом случае Lua в моем варианте использования — это один из возможных способов использования платформы и многие ее функции вообще не нужны. Что убрать, а что оставить - вопрос проектирования конкретного изделия и его прикладного функционала. Но такая возможность существует и не требует глубоко лезть в потроха интерпретатора.
Пример использования. Наконец уж
В качестве примера возьмем некую условность, в виде контроллера с CAN, 4-мя дискретными входами и 4 силовыми выходами. Задача, получать по J1939 температуру охлаждающей жидкости от ЭБУ двигателя и в зависимости от температуры, каскадно включать и выключать 4 вентилятора. На входа заведены сигналы зажигания и стартера. Зажигание - разрешение работы, стартер - соответственно сигнал на блокировку работы. Ну и до кучи состояния всех каналов нужно передавать по CAN с какой-то периодичностью. На самом деле вполне реальная задача некого автомобильного блока управления вентиляторами ОЖ.
Передача входных и выходных состояний происходит через виртуальный стек в процессе передачи управления от хоста в скрипт и обратно. Механизм обмена я описывал выше, и coroutine.yield работает по такому же принципу. Дополнительный параметр - time100us. Это время, размерностью 100 мкс, между вызовами скрипта. Для реализации таймеров можно сделать Cи библиотеку, но поскольку в реальных задачах таймеров может понадобится очень много и разных, разумнее сделать реализацию на прикладном уровне. Проще считать время между вызовами интерпретатора и передавать его через стек.
В скрипте есть вызовы библиотек Cи для работы с подсистемой Can. ConfigCan - установка скорости работы сети и разрешение работы подсистемы. setCanFilter - установка входного фильтра контролера CAN, для приема нужно пакета, CanSend - отправка фрейма CAN ,GetCanToTable - получение в таблицу lua фрейма с нужным CanID если он пришел в подсистему на момент вызова. Исходные тексты самой подсистемы Can я, пожалуй, упущу, ниже будет даны лишь промежуточные callback функции.
main = function ()
local TimeOut =0
local InputTimeOut =0
local time100us = 0
local in1 = 0; local in2 = 0;local in3 = 0;local in4 = 0;
local data={[1]=0,[2]=0,[3]=0,[4]=0,[5]=0,[6]=0,[7]=0,[8]=0}
local WaterFanTemp = 0
local FAN1_ENABLE = false; local FAN2_ENABLE = false; local FAN3_ENABLE = false; local FAN4_ENABLE = false;
ConfigCan(250)
setCanFilter(0x0CF00400 | EXT_CAN_ID)
while true do
if ( GetCanToTable( 0x0CF00400 | EXT_CAN_ID, data) == 1 ) then
WaterFanTemp = data[1] - 40;
InputTimeOut = 0
else
InputTimeOut = InputTimeOut + time100us
if InputTimeOut > 30000 then
WaterFanTemp = -40
end
end
if (( WaterFanTemp >= 60) or ( WaterFanTemp == -40) ) then
FAN1_ENABLE = true
else if ( WaterFanTemp <55) then
FAN1_ENABLE = false
end
end
if (( WaterFanTemp >= 80) or ( WaterFanTemp == -40) ) then
FAN2_ENABLE = true
else if ( WaterFanTemp <75) then
FAN2_ENABLE = false
end
end
if (( WaterFanTemp >= 90) or ( WaterFanTemp == -40) ) then
FAN2_ENABLE = true
else if ( WaterFanTemp <85) then
FAN2_ENABLE = false
end
end
if (( WaterFanTemp >= 100) or ( WaterFanTemp == -40) ) then
FAN2_ENABLE = true
else if ( WaterFanTemp <95) then
FAN2_ENABLE = false
end
end
local out_enable = in1 and not in2
local out4 = out_enable and FAN4_ENABLE
local out3 = out_enable and FAN3_ENABLE
local out2 = out_enable and FAN2_ENABLE
local out1 = out_enable and FAN1_ENABLE
TimeOut = TimeOut + time100us
if (TimeOut > 5000) then
TimeOut = 0
CanSend(0x500,in1,in2,in3,in4,out1,out2,out3,out4)
end
in1,in2,in3,in4,time100us = coroutine.yield( out4,out3,out2,out1)
end
end
Оформление кода как функции main исключительно для своего удобства и лучшей читаемости кода. Реальный проект имеет сильно более сложную структуру и там это оправдано. Но в целом с точки зрения Lua, main — это просто глобальная переменная скрипта (куска). Поэтому в моем случае, после загрузки скрипта через luaL_loadbuffer, я взываю его на исполнение с помощью lua_pcall. Это вызов присваивает переменной main (которая после luaL_loadbuffer равно nil) ссылку на нашу рабочую функцию.
Ну собственно Си код, сначала callback функции Си библиотеки, как пример реализации взаимодействия хоста с Lua скриптом через виртуальный стек. А дальше задача FreeRTOS с с вызовом интерпретатора.
/*
Функция установки скорости CAN сети
*/
static int iCanSetConfig(lua_State *L)
{
if (lua_gettop(L) == ONE_ARGUMENT)//Проверяем кол-во аргументов
{
//Вызываем подсистему CAN с первым элементом стека, в нем скорость канала
vCANBoudInit( (uint16_t)lua_tointeger( L, FIRST_ARGUMENT));
}
return ( NO_RESULT );
}
/*
Функция отправки фрейма в CAN
*/
static int iCanSendPDM( lua_State *L )
{
uint8_t DATA[CAN_FRAME_SIZE];
uint8_t DLC;
uint8_t size = lua_gettop(L);
if (size >= TWO_ARGUMENTS) //Должно быть не менее 2-х аргументов
{
size--;
DLC = size;
//Получаем из стека аргументы начиная со второго
for (int i=0;i< size ;i++)
{
DATA[i]= (uint8_t) lua_tointeger(L,-( size-i));
}
//Вызываем подсистему CAN с первым аргументом стека-CAN_ID
vCanInsertTXData( (uint32_t)lua_tointeger(L, FIRST_ARGUMENT) , &DATA[0], DLC);
}
return ( NO_RESULT );
}
/*
Функция получения фрейма с заданным ID из подсистемы CAN
CAN_ID должен быть заранее сконфигурирован
*/
int iCanGetResivedData(lua_State *L )
{
uint8_t n;
CAN_FRAME_TYPE RXPacket;
if (lua_gettop(L) == 2)
{
luaL_checktype(L, -1, LUA_TTABLE);
RXPacket.ident = (uint32_t) lua_tointeger(L,-2);
if ( vCanGetMessage(&RXPacket) == 1)
{
n = luaL_len(L, -1);
//Помещаем данные в Lua таблицу, ссылка на которую 2-й аргумент в стеке
for (int i = 1; i<(n+1);i++)
{
lua_pushnumber(L,RXPacket.data[i-1]);
lua_seti(L, -2, i);
}
lua_pushnumber(L,1U );//Возвращаем 1 через стек, если есть готовый фрейма
}
else
{
lua_pushnumber(L,0U );//Возвращаем 0 через стек, если нет готового фрейма
}
}
return ( 1U );
}
/*
* Устанавливаем новый фильтр, позволяющий принимать пакеты с нужным ID
*/
int iCanSetResiveFilter(lua_State *L )
{
uint8_t ucResNumber = NO_RESULT;
if (lua_gettop(L) == 1U ) /*Проверяем, что при вызове нам передали нужное число аргументов*/
{
lua_pushnumber(L, eMailboxFilterSet( ( uint32_t ) lua_tointeger(L,-1))== BUFFER_FULL ? 1U : 0U );
ucResNumber = ONE_RESULT;
}
return ( ucResNumber );
}
void vLuaTask( void * argument )
{
int res ;
int temp;
TickType_t xLastWakeTime;
static lua_State *L1 = NULL;
static LUA_STATE_t lua_state = LUA_ERROR;
L1 = luaL_newstate(); //Создаём состояние LUA, занимает 2К оперативной памяти
luaL_openlibs(L1); //Подключаем библиотеки
lua_register(L1,"ConfigCan",iCanSetConfig); //Регистрируем Cи функции в глобальном окружении LUA
lua_register(L1,"CanSend", iCanSendPDM);
lua_register(L1,"GetCanToTable",iCanGetResivedData);
lua_register(L1,"setCanFilter", iCanSetResiveFilter);
res =(luaL_loadbuffer(L1, uFLASHgetScript(), uFLASHgetLength() , uFLASHgetScript())
|| lua_pcall(L1, 0, LUA_MULTRET, 0));
if (res == LUA_OK )
{
lua_getglobal(L1, "main"); //Закидываем в стек имя рабочей функции lua скрипта
lua_state = LUA_RUN;
}
xLastWakeTime = xTaskGetTickCount();
while(1)
{
vTaskDelayUntil( &xLastWakeTime,1 );
WDT_Reset();
switch (lua_state)
{
case LUA_RUN: //Рабочее состояние
for (int i =0; i< 4;i++) //Закидываем в стек входные параметры
lua_pushnumber( L1, GetInput(i) );
lua_pushinteger(L1, HAL_GetTimerCnt(TIMER1);
HAL_TimerReset(TIMER1);
switch ( lua_resume( L1,0, (5), &temp) ) //Возобновляем работу скрипта
{
case LUA_OK:
case LUA_YIELD:
for ( uint8_t i = 0; i < 4; i++) //Если все ок, забираем выходные данные
{
vOutSetState( i, (uint8_t) lua_tonumber( L1,-(i+1)) );
}
break;
default: //В случае ошибки забираем из стека строку ошибки
pcLuaErrorString = (char *) lua_tostring( L1, LAST_ARGUMENT );
printf("Error = %s\r\n",pcLuaErrorString);
lua_state = LUA_ERROR;
break;
}
lua_pop(L1,1); //Убираем из стека строку ошибки
break;
case LUA_ERROR:
break;
}
}
}
Итак, немного прокомментирую происходящие. Для начала нужно создать состояние Lua (87) и зарегистрировать в глобальном окружении всторенные библиотеки (88) и пользовательские библиотеки Си функций. Регистрацию пользовательских функций можно сделать по аналогии со встроенными библиотеками, я просто исторический использую lua_register. Но это не очень правильно с точки зрения возможности пересечения имен. Дальше, если загрузка и исполнение куска не вызвали ошибок, нужно закинуть в стек имя функции main (97), которая будет использоваться при первом вызове lua_resume(111). Конечный автомат в основном цикле - рудимент основной системы. Опущены состояния инициализации и перезапуска, позволяющие на работающем контроллере перезаливать новый скрипт “по горячему”. Строки 107-109 - это закидывание в стек входных переменных скрипта, и 115-118 выгрузка выходных значений. lua_pop(L1,1) ( 126) в конце нужен для вытаскивания из стека строки ошибки выполнения, которая в любом случае попадет в виртуальный стек после завершения любого защищенного вызова С API. И, собственно, все. Оно работает.
Опыт применения
На базе Lua был разработан специфический автомобильный ПЛК - PDM ( Power Distribution Module). По сути, автомобильное устройство распределения питания с противоаварийной автоматикой (защитой от перегрузок подключенных потребителей) и программируемыми сценариями включения нагрузок (по больше части это релейная логика). На старте очень помогла легкость разворачивания ядра, отладку я начинал без подсистемы загрузки скриптов, набивая пробные Lua скрипты в текстовый массив. Очень удобно с точки зрения попробовать как оно работает в живую.
Достаточно большая работа была продела в инструментальной части системы, седлано приложение на C# для удобной сборки скриптов, просмотра телеметрии в реальном времени и других прикладных штук. Возможно, если это текст кому-то будет полезен, напишу и про это тоже, там много всего интересного. Однако task интерпретатора целевой системы по сути идентичен примеру выше, только объем данных между скриптом и хостом на порядок больше.
Проблемой проекта стал непонятный на старте уровень абстракции прикладных скриптов. Сходу встало два вопроса:
функциональные блоки релейной логики ( счетчики, таймеры, триггеры и т.д). Как лучше их реализовать?
протоколы обмена по СAN сети. Устройство должно интегрироваться в произвольные CAN сети с неопределенными протоколами обмена с оконечным оборудованием. Как сделать так, чтобы для поддержки нового устройства не нужно было обновлять Си ядро?
C обоими этими задачами помогли ООП возможности Lua. Это позволило описывать нужные для прикладной задачи модули в виде объектов Lua. Т.е. получился дополнительный прикладной уровень абстракции и потребовалось разработка не самого простого инструментального приложения. Но по итогу я, собственно, получил нужный мне результат. С одной стороны ПЛК поддерживающий как функциональное (релейная логика все таки), так и процедурное программирование. С другой стороны, все функциональнее блоки логики реализованы на прикладном уровне. Это было рассчитано прежде всего на ситуацию, когда устройство программирует прикладной специалист. Если в его задаче потребуется что-то специфические, то не нужно обновлять ядро контроллера. Достаточно написать для него нужных прикладных библиотек на Lua.
Еще одним удобным свойством стал защищенный вызов скрипта. Практический все скрипты отлаживались на реальных машинах в полях, часто буквально. Я реализовал безопасный режим контроллера, куда система переходила при ошибках исполнения скриптов. Сильно обезопасило отладку. Была реальная ситуация удаленной правки скрипта для раллийной машины из отпуска за пару дней до старта гонки. Ну такая себе история для слепой перекомпиляции ядра :-)
Собственно, завершить статью хочу следующей мыслю. За годы существеннее проекта я параллельно занимался разработкой еще нескольких не связных с автомобильной тематикой систем автоматики и в какой-то момент понял, что их программная архитектура сама собой стала смещается во что-то ПЛК подобное. Бизнес логика сконцентрировалась в выделенной задаче, появились функциональные блоки и прикладной процесс все больше стал напоминать функциональный скрипт. К сожалению, руки до экспериментов с уменьшение размера интерпретатора дошли только сейчас. Но таки все получилось и один из текущих проектов получил версию со скриптовой бизнес-логикой. Посмотрим, что из этого получится дальше, но архитектура получилась определенно интересная.
Комментарии (9)
goldexer
08.09.2025 14:46Немного отступлю от основного контекста: во времена AVR-ок, когда 128 Мега шилась под нескольку минут, а разработчики платы на этапе макетирования заказывали изменения раз так 100 за день, ооочень хотелось как-то организовать всё скриптами. После миграции на STMки проблема ушла сама собой: мгновенная заливка огромных прошивок, выполнение из RAM, а драйвер Keil-a позволил мониторить регистры в реальном времени. С тех пор единственным аргументом в пользу скриптов была бы их правка юзерами, дескать, «на тебе, а интервалы там себе в текстовом файле подправишь как тебе нравится». Однако сейчас можно либо вывести черезWiFi веб-морду, либо через USB-CDC вносить правки самописной программой с нормальным интерфейсом... В общем для меня не «полная замена» на Lua, а именно расширение, выглядит как что-то очень специфичное и узкоспециализированное, нужное только тому, кто... - ну вы поняли)
CatAssa
08.09.2025 14:46правка юзерами
Эта фича хорошо проталкивается продавцами, покупатели реально ведутся на такое. В реальности (я про нашу контору), всё, что сложнее "палка - веревка", приходится делать нам, разработчикам. И тут уже начинаюися спотыки об ограничения скриптовой системы. В итоге, мы добавили систему плагинов, когда расширения системы реализуются в формате динамически загружаемых библиотек (чистый "нативный" код); а "скриптовую стстему" оставили для продавцов, пусть дальше клиентам лапшу вешают про "правку юзерами".
nikolz
.
Smoke666 Автор
Нет, я не использую файловую систему вообще. Я гружу байт код в выделенный сектор флеша и при старте система пытается запустить байт-код от туда. Файловая система может быть оправдана чисто с инструментальной точки зрения, usb mass-storage к примеру. Но вот у меня девайс по CAN скрипт получает, зачем там файловая система?
Для корутин не нужно отдельного стейта. Если она одна, то ее можно размешать в основной стейт без проблем.
Smoke666 Автор
Если имеется ввиду внешний флэш, то да, есть такой вариант, но в любом случае основное ограничение в системе RAM. Флэша больше чем виртуальный памяти Lua машины не нужно.
nikolz
Про корутину. Она тем и отличается от функций , что имеет свой стек. Поэтому при вызове другой функции не надо выгружать переменные корутины. Просто передается управление в основной стек или в стек другой корутины. При возврате все внутренние переменные и параметры не надо снова загружать так как они находятся в ее стеке.
--------------------
Просто хорошо знаю внутренности VMLua и LuaJit так как использую ее давно и на микроконтроллерах ESP и в OC Windows.
Smoke666 Автор
Все, я понял вашу мысль. Вы абсолютно правы, но у меня специфическая штука, у меня нет главного стейта и основного стека:) У меня есть только стейт корутины, и корутина делает уступку только хосту. Т.е. по сути у меня нет корутины:) Я использую ее механизм для передачи управления между хостом и прикладной функций Lua, которая у меня бесконечный цикл и никогда не завершается. Можно было сделать и без коруитны, но поскольку у меня на Lua по сути написана итерация прикладного алгоритма (или функция выходных значений от входных если в терминах релейки), мне удобно через resume\yield передавать входные переменные и забирать выходные. Плюс явная уступка хосту дает мне возможность контроля над временем итерации прикладного алгоритма. И собственно в статье я про это и пишу, про использование механизма корутины, и в этом случае не нужно отдельного стейта, все живет в основном.
nikolz
Понятно, но зачем тогда корутину использовать? У Вас как бы не типовое применение. Почему не делали типовую VMLua и на ней все крутили ?
Smoke666 Автор
Типовую VMLua не кручу, потому что мне на фиг не надо программировать контроллер из под виртуалки, не решаются так мои системные задачи и ПЛК для АСУ ТП так не делаются. Мне нужно программировать только бизнес-логику. Что бы править ее без перекомпиляции ядра и пускать в нее прикладных инженеров. А корутину пользую потому что так проще решить задачу - мне нужна явная передача управления из цикла скрипта обратно в процесс RTOS, с сохранением контекста скрипта, и явный возврат обратно в скрипт. И статью я собственно писал именно по это, что вот какая классная штука Lua, можно приспособить как ПЛК в маленьких реалтайм системах на раз-два.