Так сложилось, что программируя микроконтроллеры, разработчик балансирует между двумя крайностями. Все ресурсы под твоим полным контролем — и это кайф (думаю, многие в embedded за этим и идут). Но платой становится сложность встраивания базовых инструментов, которые стали де-факто стандартом в других областях разработки. Сложность хотя бы в том, что они не идут из коробки.
Возьмём обычную задачу: включить фару на устройстве.
На практике наша железка должна загрузиться, зарегистрироваться в LTE-сети, поднять TLS-соединение с MQTT-брокером, синхронизировать состояние и пройти ещё кучу слоёв бизнес-логики. С другой стороны — мобильное приложение и бэкенд для управления этой лампочкой (уже целая система собралась!). Там не меньше логики: от авторизации до “да кто блин так дизайн спроектировал?”. Пока дотапаешься до кнопки, пройдёт вечность.
В итоге, любое простое действие требует либо полного рабочего стека, либо моков с тестовыми сборками и отключёнными проверками. Либо дебагера с брейкпоинтами и ручными правками памяти. Всё работает, но каждый раз жрёт уйму времени и внимания.
Хотелось бы проще. Нужен способ аккуратно вмешаться в работу устройства — без отключения основной логики, без специальных сборок и независимо от режима. Не просто физическая кнопка, а полноценный интерфейс: настраивать параметры, включать/выключать функции, забирать данные.
И стало ясно: нам не хватает shell-интерфейса. Или CLI. Или терминала — называйте как угодно (разницу можно глянуть здесь). Но не просто не хватает — его придётся писать самим.
Зачем делать велосипед?
Давайте сразу разберём этот вопрос, иначе будет текст с описанием фичей и кейсов использования — и вот статья на финише, а сил оправдываться, что велик не с AliExpress, уже нет.
Задачи в Jira на “CLI для МК” не ставили — не та важность, чтобы менеджеры в экстаз впадали. Просто один разработчик притащил набросок за пару вечеров: он помогал ему отлаживать подключение модема к сети и можно было в реальном времени вмешиваться в поток АТ-команд, не модифицируя сишный код. Удобно!
Потом кто-то добавил ещё пару команд.
Потом CLI понадобился для тестирования устройств на производстве и мы уже целенаправленно внедряли набор тестовых кейсов для работы в джиг-стенде и отдавали питонистам API для управления в таком формате.
Потом — ещё для одной задачи, и ещё. Шаг за шагом и мы оказались в точке, где интерфейс уже подогнан под наши требования, закрывает реальные потребности, а замена на что-то готовое означает либо компромиссы, либо заметные доработки.
В этот момент мы притормозили, описали полный набор первичных требований и решили довести реализацию до завершённого, осмысленного состояния. Смотрели аналоги, честно. Могли бы начать с них, но процесс, описанный выше, пошёл органично. Вот некоторые из них:
microrl - micro read line library for small and embedded devices with basic VT100 support
tinysh - TINYSH: MINIMAL SHELL
linenoise - A minimal, zero-config, BSD licensed, readline replacement used in Redis, MongoDB, Android and many other projects
Конечно, их куда больше. У тех же ZephyrOS и FreeRTOS есть свои реализации и было бы логично их использовать, если используются и сами ОС.
Ну а теперь, хватит оправдываться — погнали рассказывать, что получилось! Вот сразу демка с получившимся UX:
Общий обзор, указания по сборке, примеры интеграции и даже doxygen-документацию (кто ее вообще читает?) можно найти по ссылкам выше, а сейчас я бы хотел пройтись по ключевым требованиям и особенностям работы.
Кросс-платформенность, модульная архитектура
Начнем с подхода к разработке. Не то, что бы очень была нужна функция сборки под системы типа Windows, но:
Больше отладки разных stdio — больше шансов поймать баги и улучшить абстракцию от железа
Куда шире возможности по автоматическому тестированию (которого пока нет, хаха. Но, планируем)
Между разными микроконтроллерами тоже бывают проблемы с переносом IO-кода. А наша цель — быстро добавлять shell в новые проекты, желательно на старте, когда только-только разобрались с GPIO и UART
Кросс-платформенность — это всегда просто приятно
Не так, как bluetooth, конечно…
Тем не менее, проект можно собрать и потыкать на Windows, Linux, macOS — понадобится gcc/clang и make.

Для портирования достаточно подключить один заголовок. Правда, придется еще создать пользователей, команды, безопасно обработать пароль, перенаправить потоки ввода-вывода и прописать несколько колбеков… Но для подключения либы, строки #include "wsh_shell.h" будет достаточно.
Модульность же закладывалась с самого начала. Железо бывает разным и нет никаких гарантий, что завтра не придётся экономить каждый байт FLASH-памяти. В такой ситуации возможность отк��ючить ненужный функционал — не роскошь, а вполне практичная необходимость.
Не завидую тем, кто начинает распиливать проект на модули уже постфактум. Мне приходилось переписывать интерфейсы взаимодействия с отключаемыми частями кода, устраняя неочевидную до этого связность. Как минимум, это поучительное упражнение — но проще всё-таки закладывать такие вещи заранее.
Единая структура состояния
Вся state-machine хранится в структуре WshShell_t . Это удобно, без глобальных переменных; при необходимости, можно создавать экземпляры с разными настройками и все остальные плюсы. Ну и отлаживаться проще.
/** * @brief Main shell structure containing state, configuration, user context, and subsystems. */ typedef struct { WshShell_Char_t* Version; /**< Version string. */ WshShell_Char_t DeviceName[WSH_SHELL_DEV_NAME_LEN]; /**< Device name (used in PS1 and more). */ WshShell_Char_t PS1[WSH_SHELL_PS1_MAX_LEN]; /**< Cached PS1 string. */ WshShell_Char_t PrevSym; /**< Previous symbol inserted in. */ WshShellIO_CommandLine_t CommandLine; /**< Terminal input/output interface. */ const WshShellUser_t* CurrUser; /**< Currently authenticated user. */ WshShellAuthCtx_t TmpAuth; /**< Temporary auth input storage. */ WshShellEsc_Storage_t EscStorage; /**< Escape sequence state storage. */ WshShellUser_Table_t Users; /**< Table of available users. */ WshShellCmd_Table_t Commands; /**< Registered command table. */ WshShellHistoryIO_t HistoryIO; /**< Command history buffer and ops. */ WshShellInteract_t Interact; /**< Interactive command interface. */ WshShellPromptWait_t PromptWait; WshShellExtCallbacks_t ExtCallbacks; /**< Optional external auth callbacks. */ } WshShell_t;
Статическая аллокация и футпринт памяти
Будем честны: потребление памяти нас не сильно волновало. Цели попасть в жёсткий таргет не было — писали код, который нужен, и не забывали добавлять feature-тоглы на всё, что поверх базового функционала. Сейчас библиотека в минимальной конфигурации занимает ~4 Кб FLASH на arm cortex-m7 с -O1 оптимизацией (замеры с включенными фичами — в доке).
Много это или мало — решайте в контексте вашего проекта и доступных ресурсов МК.
Что касается статической аллокации — тут снова нет побега от удобства и в прикладных embedded-проектах я не стесняюсь использовать динамическое выделение памяти. Но мы же хотим shell, который запускается как можно раньше — на стадии знакомства с новым чипом! А значит, мы тут DMA уже третий день пытаемся запустить, а не кватернионы поворота рассчитываем, да матрицы перемножаем, и heap_4.c еще не подключили. И чем проще пройдет интеграция shell — тем лучше.
В общем, для современных чипов — просто приятный бонус.
Безопасное хранения паролей
Блок безопасности начнём с авторизации. Главное правило: пароли в открытом виде во FLASH не храним — их оттуда легко извлечь, сняв дамп прошивки. Так что пусть пользователь вводит свои данные и мы их обработаем, а потом сразу же подчистим за собой память. Логин не считаем секретным, а вот пароль после ввода преобразуем и сравним с отпечатком из FLASH, в котором записан hash(salt|pass).
На выбор — библиотека хеширует и солит их сама, либо вы реализуете колбек с криптостойким алгоритмом (SHA-256 и подобные). По умолчанию используется Jenkins hash — легкий и быстрый, но совершенно не криптостойкий. С другой стороны, это лучше, чем ничего. Поэтому он и стоит дефолтным, если кастомный колбек не задан:
static void Shell_UserAuth_HashFunc(const WshShell_Char_t* pcSalt, const WshShell_Char_t* pcPass, WshShell_Char_t* pHash) { u32 saltLen = strlen(pcSalt); u32 passLen = strlen(pcPass); ASSERT_CHECK(saltLen <= WSH_SHELL_SALT_LEN); ASSERT_CHECK(passLen <= WSH_SHELL_PASS_LEN); char saltPass[WSH_SHELL_SALT_LEN + WSH_SHELL_PASS_LEN + 1]; memcpy(saltPass, pcSalt, saltLen); memcpy(saltPass + saltLen, pcPass, passLen); saltPass[saltLen + passLen] = '\0'; u32 saltPassLen = saltLen + passLen; u8 saltPassHashBytes[CRYPTO_SHA256_BLOCK_SIZE]; char saltPassHashStr[CRYPTO_SHA256_BLOCK_SIZE * 2 + 1]; Crypto_SHA256_t ctx; Crypto_SHA256_Init(&ctx); Crypto_SHA256_Update(&ctx, (const u8*)saltPass, saltPassLen); Crypto_SHA256_Finish(&ctx, saltPassHashBytes); StringLib_ByteArrayToAscii(saltPassHashStr, saltPassHashBytes, sizeof(saltPassHashBytes)); memcpy(pHash, saltPassHashStr, strlen(saltPassHashStr) + 1); }
Вспомогательные скрипты из проекта помогут создать хеш, который нужно занести в таблицу при создании пользователей:
(.venv) MacAirSergei@katbert ~/Whoosh/wsh-shell > python3 /utils/gen-pass.py --salt 538a03bccc40a07f --password 1234 salt|pass: b'538a03bccc40a07f1234' sha256 hash(salt|pass): "8818316ae96ae8b4bbb2d7504b1c7b759c62bbea2c0d1595e72b4fcc7af079fa" jenkins hash(salt|pass): "06bcec27"
Поддержка пользователей, групп доступа и прав исполнения
Пользователей может быть несколько — перечисляем их в массиве структур с солью и хешем пароля из предыдущего пункта. Можно создавать пользователей динамически — это просто массив структур, указатель на который передаём при инициализации wsh-shell. Но с такой необходимостью мы пока не сталкивались.
static const WshShellUser_t Shell_UserTable[] = { { .Login = "admin", .Salt = "538a03bccc40a07f", .Hash = "8818316ae96ae8b4bbb2d7504b1c7b759c62bbea2c0d1595e72b4fcc7af079fa", //1234 .Groups = WSH_SHELL_CMD_GROUP_ALL, .Rights = WSH_SHELL_OPT_ACCESS_ADMIN, }, { .Login = "user", .Salt = "6e96c972caaf825e", .Hash = "454dac44cbf14257e4667560faeaec68abeda5c62daf01ac0dd2514d42c9581f", //qwer .Groups = WSH_SHELL_CMD_GROUP_MID_LEVEL | WSH_SHELL_CMD_GROUP_HIGH_LEVEL, .Rights = WSH_SHELL_OPT_ACCESS_ANY, }, }; if (WshShellUser_Attach(&(Shell.Users), Shell_UserTable, WSH_SHELL_ARR_LEN(Shell_UserTable), NULL) != WSH_SHELL_RET_STATE_SUCCESS) { return false; }
Безопасность — это не только криптография, но и архитектурное разделение прав доступа. Оно начинается с декларации пользователей: к каким группам они принадлежат и какие права имеют.
Основные параметры:
Несколько пользователей с уникальными учётками (логин, пароль)
Пользователи агрегируются в группы
Группы декларируются в
wsh_shell_cfg.h(файле, который нужно дополнить под ваш проект, внеся исправления в шаблон)Команда может иметь доступ сразу в несколько групп
Если пересечения user & cmd по группам не случилось, не получится даже посмотреть список опций команды и описание поведения (
--help)
С одной стороны, получилась целая ролевая модель контроля доступа (RBAC); в то же время, проще кода нет ни у одной фичи — просто проверяем пересечение групп пользователя и групп команды:
pcCmd = WshShellCmd_SearchCmd(&(pShell->Commands), pсArgv[0]); if (pcCmd == NULL) { WSH_SHELL_PRINT_WARN("Command \"%s\" not found!\r\n", pcCmdStr); } else if ((pShell->CurrUser->Groups & pcCmd->Groups) != 0) { cmdHandler = pcCmd->Handler; } else { WSH_SHELL_PRINT_ERR( "Access denied: no group intersection for command \"%s\" and user \"%s\"!\r\n", pсArgv[0], pShell->CurrUser->Login); WshShellIO_ClearInterBuff(&(pShell->CommandLine)); return; }

Ну и добавим для флагов команд битовые маски доступа. В отличие от кастомных групп, они уже являются частью библиотеки и довольно стандартные:
#define WSH_SHELL_OPT_ACCESS_NO 0x00 #define WSH_SHELL_OPT_ACCESS_READ 0x01 #define WSH_SHELL_OPT_ACCESS_WRITE 0x02 #define WSH_SHELL_OPT_ACCESS_EXECUTE 0x04 #define WSH_SHELL_OPT_ACCESS_ADMIN 0x08 #define WSH_SHELL_OPT_ACCESS_ANY \ (WSH_SHELL_OPT_ACCESS_READ | WSH_SHELL_OPT_ACCESS_WRITE | WSH_SHELL_OPT_ACCESS_EXECUTE)
Сопоставляем пересечение Rights пользователя с Access флага комады. Полезно для ограничения возможностей у пользователя, которому нужно только получать информацию с железки, но записывать или исполнять на ней код — запрещено.

Звучит все вроде не сложно, но требуется внимательно проектировать эти политики, исходя из здравого смысла и потребностей. Провести ревью прав в проекте с 30+ командами и 200+ флагами — та еще боль, особенно когда потерян глубокий контекст их назначения.
Поддержка команд, флагов и параметров
Чтобы создать команду, пишем обработчик, задаём имя, описание, массив опций и группы доступа. Опции объявляются через X-макросы: ID, тип, короткие/длинные ключи. Тип определяет, какие данные ожидать после опции, а парсер их правильно извлечёт в хендлер. В хендлере получаете готовый optCtx с распарсенными значениями — и дальше просто switch по ID или как сами решите. Если флаг требует прав — WshShellCmd_ParseOpt проверит соответствие и если нет нужного доступа — укажет на ошибку.
Упрощенно выглядит так:
#include "wsh_shell.h" #define CMD_WIRELESS_OPT_TABLE() \ X_CMD_ENTRY(CMD_WIRELESS_OPT_HELP, WSH_SHELL_OPT_HELP()) \ X_CMD_ENTRY(CMD_WIRELESS_OPT_INTERACT, WSH_SHELL_OPT_INTERACT(WSH_SHELL_OPT_ACCESS_ANY)) \ X_CMD_ENTRY(CMD_WIRELESS_OPT_TMO, WSH_SHELL_OPT_INT(WSH_SHELL_OPT_ACCESS_ANY, "-t", "--tmo", "Set exec timeout for interactive mode")) \ X_CMD_ENTRY(CMD_WIRELESS_OPT_END, WSH_SHELL_OPT_END()) #define X_CMD_ENTRY(en, m) en, typedef enum { CMD_WIRELESS_OPT_TABLE() CMD_WIRELESS_OPT_ENUM_SIZE } CMD_WIRELESS_OPT_t; #undef X_CMD_ENTRY #define X_CMD_ENTRY(enum, opt) {enum, opt}, WshShellOption_t WirelessOptArr[] = {CMD_WIRELESS_OPT_TABLE()}; #undef X_CMD_ENTRY static WSH_SHELL_RET_STATE_t ShellCmdWireless(const WshShellCmd_t* pcCmd, WshShell_Size_t argc, const WshShell_Char_t* pArgv[], void* pShellCtx) { if ((argc > 0 && !pArgv) || !pcCmd) return WSH_SHELL_RET_STATE_ERROR; WshShell_t* pParentShell = (WshShell_t*)pShellCtx; for (WshShell_Size_t tokenPos = 0; tokenPos < argc;) { WshShellOption_Ctx_t optCtx = WshShellCmd_ParseOpt(pcCmd, argc, pArgv, pParentShell->CurrUser->Rights, &tokenPos); if (!optCtx.Option) return WSH_SHELL_RET_STATE_ERR_EMPTY; switch (optCtx.Option->ID) { case CMD_WIRELESS_OPT_HELP: { } break; case CMD_WIRELESS_OPT_INTERACT: { } break; case CMD_WIRELESS_OPT_TMO: { } break; default: return WSH_SHELL_RET_STATE_ERROR; } } return WSH_SHELL_RET_STATE_SUCCESS; } const WshShellCmd_t Shell_WirelessCmd = { .Groups = WSH_SHELL_CMD_GROUP_LOW_LEVEL, .Name = "wless", .Descr = "Wireless module access", .Options = WirelessOptArr, .OptNum = CMD_WIRELESS_OPT_ENUM_SIZE, .Handler = ShellCmdWireless, };
Таблица доступных для обработки типов выглядит так:
#define WSH_SHELL_OPTION_TYPES_TABLE() \ X_ENTRY(WSH_SHELL_OPTION_NO, "EMPTY") \ X_ENTRY(WSH_SHELL_OPTION_HELP, "HELP") \ X_ENTRY(WSH_SHELL_OPTION_INTERACT, "INTERACT") \ X_ENTRY(WSH_SHELL_OPTION_WO_PARAM, "WO_PARAM") \ X_ENTRY(WSH_SHELL_OPTION_MULTI_ARG, "MULTI_ARG") \ X_ENTRY(WSH_SHELL_OPTION_WAITS_INPUT, "WAITS_INPUT") \ X_ENTRY(WSH_SHELL_OPTION_STR, "STR") \ X_ENTRY(WSH_SHELL_OPTION_INT, "INT") \ X_ENTRY(WSH_SHELL_OPTION_FLOAT, "FLOAT") \ X_ENTRY(WSH_SHELL_OPTION_END, "END")
Интересный тип опций — INTERACT, про него поговорим чуть позже; остальное более-менее стандартно.
Теперь агрегируем все это в одном файле, в массиве Shell_CmdTable, попутно развешивая фиче-тоглы, релевантные для вашего приложения и подключаем к shell через WshShellCmd_Attach:
#include "shell_commands.h" extern const WshShellCmd_t Shell_GrUiCmd; extern const WshShellCmd_t Shell_FileSystemCmd; extern const WshShellCmd_t Shell_WirelessCmd; extern const WshShellCmd_t Shell_DebugLogCmd; extern const WshShellCmd_t Shell_ResetCmd; extern const WshShellCmd_t Shell_TimeDateCmd; #if RTOS_ANALYZER extern const WshShellCmd_t Shell_RtosCmd; #endif /* RTOS_ANALYZER */ #if BERRY_LANG extern const WshShellCmd_t Shell_BerryLangCmd; #endif /* BERRY_LANG */ static const WshShellCmd_t* Shell_CmdTable[] = { &Shell_GrUiCmd, &Shell_FileSystemCmd, &Shell_WirelessCmd, &Shell_DebugLogCmd, &Shell_ResetCmd, &Shell_TimeDateCmd, #if RTOS_ANALYZER &Shell_RtosCmd, #endif /* RTOS_ANALYZER */ #if BERRY_LANG &Shell_BerryLangCmd, #endif /* BERRY_LANG */ }; bool Shell_Commands_Init(WshShell_t* pShell) { return WshShellRetState_TranslateToProject( WshShellCmd_Attach(&(pShell->Commands), Shell_CmdTable, NUM_ELEMENTS(Shell_CmdTable))); }
Итак, команды подключены к shell, можно пробовать запускать и смотреть, как оно работает!
Активная командная строка
Точкой входа из пользовательского кода является функция вставки символа WshShell_InsertChar. Исходя из особенностей реализации потока stdin на вашем устройстве, потребуется адаптировать процесс ее вызова. Например, у меня это RTOS-задача:
static void vTask_Shell_Process(void* pvParameters) { vTaskDelay(2000); while (!Debug_HardwareIsInit()) vTaskDelay(100); ShellRoot_Init(); for (;;) { char symbol = '\0'; symbol = Debug_ReceiveSymbol(DELAY_1_SECOND); if (symbol) WshShell_InsertChar(&ShellRoot, symbol); // vTaskDelay(RTOS_MIN_TIMEOUT_MS); } }
Которая получает данные через очередь из модуля debug_io:
Получение символа
char Debug_ReceiveSymbol(u32 delay) { char symbol = '\0'; #if DBG_USE_RTOS if (DebugRxSymbol_Queue) xQueueReceive(DebugRxSymbol_Queue, &symbol, delay); #else /* DBG_USE_RTOS */ // #endif /* DBG_USE_RTOS */ return symbol; }
Которая (очередь) наполняется через колбеки выбранных аппаратных интерфейсов — например UART или USB.
Колбеки, разведенные по хардварным интерфейсам
void Debug_Init(void) { /** * _IOFBF - fully buffered * _IOLBF - line buffered * _IONBF - unbuffered */ setvbuf(stdin, NULL, _IOLBF, 0); //for scanf and fgets setvbuf(stdout, NULL, _IOLBF, 0); setvbuf(stderr, NULL, _IONBF, 0); #if defined DEBUG_IO_THROUGH_UART Pl_DebugUart_Init(DEBUG_UART_BAUDRATE, Debug_RxBuff, sizeof(Debug_RxBuff), Debug_RxClbkUart); #elif defined DEBUG_IO_THROUGH_USB Pl_USB_CDC_Init(Debug_RxClbkUsb, Debug_RxBuff); #endif } static void Debug_RxClbkUart(void* pVal) { #if DBG_USE_RTOS BaseType_t xHigherPriorityTaskWoken = pdFALSE; #endif /* DBG_USE_RTOS */ char ch = *(char*)pVal; #if DBG_USE_RTOS Debug_SendCharFromISR(ch, &xHigherPriorityTaskWoken); #else /* DBG_USE_RTOS */ // #endif /* DBG_USE_RTOS */ #if DBG_USE_RTOS if (xHigherPriorityTaskWoken == pdTRUE) void Debug_Init(void) { /** * _IOFBF - fully buffered * _IOLBF - line buffered * _IONBF - unbuffered */ setvbuf(stdin, NULL, _IOLBF, 0); //for scanf and fgets setvbuf(stdout, NULL, _IOLBF, 0); setvbuf(stderr, NULL, _IONBF, 0); #if defined DEBUG_IO_THROUGH_UART Pl_DebugUart_Init(DEBUG_UART_BAUDRATE, Debug_RxBuff, sizeof(Debug_RxBuff), Debug_RxClbkUart); #elif defined DEBUG_IO_THROUGH_USB Pl_USB_CDC_Init(Debug_RxClbkUsb, Debug_RxBuff); #endif } static void Debug_RxClbkUart(void* pVal) { #if DBG_USE_RTOS BaseType_t xHigherPriorityTaskWoken = pdFALSE; #endif /* DBG_USE_RTOS */ char ch = *(char*)pVal; #if DBG_USE_RTOS Debug_SendCharFromISR(ch, &xHigherPriorityTaskWoken); #else /* DBG_USE_RTOS */ // #endif /* DBG_USE_RTOS */ #if DBG_USE_RTOS if (xHigherPriorityTaskWoken == pdTRUE) portEND_SWITCHING_ISR(xHigherPriorityTaskWoken); #endif /* DBG_USE_RTOS */ } static void Debug_RxClbkUsb(u8* pBuff, u32 len) { #if DBG_USE_RTOS BaseType_t xHigherPriorityTaskWoken = pdFALSE; #endif /* DBG_USE_RTOS */ for (u32 i = 0; i < len; i++) { char ch = *(char*)&pBuff[i]; #if DBG_USE_RTOS Debug_SendCharFromISR(ch, &xHigherPriorityTaskWoken); #else /* DBG_USE_RTOS */ // #endif /* DBG_USE_RTOS */ } #if DBG_USE_RTOS if (xHigherPriorityTaskWoken == pdTRUE) portEND_SWITCHING_ISR(xHigherPriorityTaskWoken); #endif /* DBG_USE_RTOS */ } portEND_SWITCHING_ISR(xHigherPriorityTaskWoken); #endif /* DBG_USE_RTOS */ } static void Debug_RxClbkUsb(u8* pBuff, u32 len) { #if DBG_USE_RTOS BaseType_t xHigherPriorityTaskWoken = pdFALSE; #endif /* DBG_USE_RTOS */ for (u32 i = 0; i < len; i++) { char ch = *(char*)&pBuff[i]; #if DBG_USE_RTOS Debug_SendCharFromISR(ch, &xHigherPriorityTaskWoken); #else /* DBG_USE_RTOS */ // #endif /* DBG_USE_RTOS */ } #if DBG_USE_RTOS if (xHigherPriorityTaskWoken == pdTRUE) portEND_SWITCHING_ISR(xHigherPriorityTaskWoken); #endif /* DBG_USE_RTOS */ }
Кстати, если в финальных устройствах я предпочитаю USB, то при отладке очень удобно иметь такую сборку, когда SWD + UART + питание заведены в одну косичку и достаточно одним лишь кабелем подключить это к ПК, что бы начать работать.

Не обязательно делать так сложно, что бы прийти к WshShell_InsertChar — это просто реальный пример из проекта под рукой. Более простые интеграции на чипах f103/f411, с RTOS и без, можно посмотреть по ссылкам из начала статьи.
Весь ввод обрабатывается машиной состояний — ASCII-символы, управляющие коды, escape-последовательности и прочее. Управляющие символы как раз и дают интерактивность: навигацию стрелками по введённой строке, удаление символов, вставку новых. Это сильно упрощает человеко-машинное взаимодействие, что и есть одна из главных целей проекта.
Shell за счет них перерисовывает экран, издает звуки при ошибке ввода, раскрашивает предупреждения в цвета, вызывает модуль автодополнения, извлекает историю команд и т.д.. Базовый набор покрывает нужды навигации по строке, а дополнительная интерактивность строится уже более сложными фичами — сейчас их и разберём.
История команд
Модуль истории работает через внешние read/write колбеки к вашему хранилищу введенных токенов. Внутри — кольцевой буфер: сохраняет новые команды, восстанавливает старые, следит за консистентностью памяти (хешируется через тот же Jenkins, что используется для паролей):
#define PL_SHELL_HISTORY_DATA __attribute__((section(".SHELL_HISTORY_DATA"))) static WshShellHistory_t PL_SHELL_HISTORY_DATA ShellRoot_HistoryStorage; static WshShellHistory_t ShellRootHistory_Read(void) { return ShellRoot_HistoryStorage; } static void ShellRootHistory_Write(WshShellHistory_t history) { memcpy((void*)&ShellRoot_HistoryStorage, (void*)&history, sizeof(WshShellHistory_t)); } WshShellHistory_Init(&ShellRoot.HistoryIO, ShellRootHistory_Read, ShellRootHistory_Write);
Сейчас история живёт в RAM, но можно вынести в энергонезависимую память по аналогии с ~/.bash_history. Такая необходимость не возникала, зато кейс “работать с shell и не терять историю между перепрошивками/перезагрузками” — вполне реальный. И это вообще не сложно повторить в любом embedded-проекте.
Обратите внимание на макрос PL_SHELL_HISTORY_DATA. Он дает указание линкеру положить массив истории в отдельную область памяти. Объявляем в linker script’е секцию RAM как NOLOAD — и startup скрипт её не трогает при перезагрузках. Идеальный баланс: улучшаем UX сессии, не засоряя энергонезависимую память.
MEMORY { ... RAM_D3 (xrw) : ORIGIN = 0x38000000, LENGTH = 15K RAM_D3_NOINIT (rwx) : ORIGIN = ORIGIN(RAM_D3) + LENGTH(RAM_D3), LENGTH = 1K ... } /* NOINIT section for custom data */ .noInitData (NOLOAD) : ALIGN(4) { PROVIDE(__start_no_init_data = .) ; *(.SHELL_HISTORY_DATA*) *(.BKP_STORAGE_DATA*) PROVIDE(__end_no_init_data = .) ; } >RAM_D3_NOINIT
Автодополнение
За автодополнение отвечает одна клавиша tab и одна функция WshShellAutocomplete_Try, которая анализирует интерактивный буфер и пытается дополнить пользовательский ввод на основании информации об именах команд, а когда команда найдена — предлагает список доступных опций — можно не запрашивать help, если требуется просто вспомнить семантику флагов:

Кастомизация PS1 (приглашение командной строки)
Приятно настроить PS1-строку под себя, а точнее, под конкретный проект. И это можно сделать в конфиге через шаблон, который набирается из:
%u- имя пользователя%d- имя девайса%c- выбор цвета%r- сброс стиля строки%b- жирный шрифт%i- имя интерактивной команды, если она активнаи любые другие валидные ascii-символы
#define WSH_SHELL_PS1_TEMPLATE "%r%b%c6%d%c7@%c5%u%c2%i %c7> %r%c7"
Результат работы такого шаблона виден на терминальных скриншотах.
Интерактивный командный режим
На мой взгляд, одно из самых полезных и интересных применений! Поток ввода перенаправляется на новых хендлер, который напрямую начинает работать с нужным модулем, управляя им, перехватывая ответы и печатая их в shell. Вот пример работы хост-контроллера с модулем esp32, который подключен через UART и работает по AT-командам:

Видно, что первая команда AT+CWLAP не смогла дождаться ответа по сканированию wifi-точек вокруг и мы потеряли результат. Пришлось вручную увеличить таймаут ожидания и 5 секунд на сканирование уже оказалось достаточно.
Расширение приглашения ко вводу (wless) указывает, что находимся в режиме интерактивной команды и все данные попадают в модуль, как есть. Ну или почти, как есть — какие-то преобразования все же приходится делать:
static u32 InterModeTmoMs = DELAY_1_SECOND * 3; static void ShellCmdWirelessInteractive(WshShellIO_CommandLine_t* pInter) { WirelessM_SwitchLogPrint(true); WshShellInteract_AppendLineBreak(pInter); char* respList[] = {"OK\r\n", "ERROR\r\n"}; s16 matched = -1; WirelessM_SendCmdMultiResp(pInter->Buff, respList, NUM_ELEMENTS(respList), InterModeTmoMs, &matched); WirelessM_PrintRxData(); WirelessM_SwitchLogPrint(false); }
Такая штука позволяет органично встраиваться в работу модулей в железке и давать пользователю доступ к ним напрямую, без конфликтов с основной логикой. В нашем случае это еще и дало широкие возможности по тестированию модемов — куда проще написать тесты на python через serial port, чем встраивать их в прошивку и запускать включением дебажных флагов.
Но и это ещё не всё. Что лучше всего подходит для интерактивного режима?
Правильно, интерпретатор!
В примере для платы BlackPill добавил сабмодулем berry-lang — это такой интерпретатор с упором на работу во встраиваемых системах. Из описания репозитория:
Berry is a ultra-lightweight dynamically typed embedded scripting language. It is designed for lower-performance embedded devices. The Berry interpreter-core’s code size is less than 40KiB and can run on less than 4KiB heap
Некоторые модули языка отключены — на железке нет файловой системы, RTC и прочего. Но синтаксис можно потестировать: питоноподобный, но с нюансами.
Почему не MicroPython или Lua?
¯\_(ツ)_/¯
Да просто захотелось пощупать Berry:

Внешние action-колбеки
Можно внешними колбеками кастомизировать функционал разных действий. Например так:
static void ShellRoot_AuthClbk(void* pCtx) { DISCARD_UNUSED(pCtx); Shell_PrevLogLvl = Debug_LogLvl_Get(); Debug_LogLvl_Set(LOG_LVL_ERROR); xTimerStart(ShellExit_Timer, 0); } static void ShellRoot_DeAuthClbk(void* pCtx) { DISCARD_UNUSED(pCtx); xTimerStop(ShellExit_Timer, 0); Debug_LogLvl_Set(Shell_PrevLogLvl); } static void ShellRoot_SymInClbk(void* pCtx) { DISCARD_UNUSED(pCtx); Ind_Ui_SendEvent(IND_UI_EVENT_SHELL_TYPING, 100); if (WshShell_IsAuth(&ShellRoot)) { BaseType_t status = xTimerChangePeriod(ShellExit_Timer, WSH_SHELL_AUTO_EXIT_TMO, 0); ASSERT_CHECK(status == pdPASS); } } static WshShellExtCallbacks_t ShellRoot_Callbacks = { .Auth = ShellRoot_AuthClbk, .DeAuth = ShellRoot_DeAuthClbk, .SymbolIn = ShellRoot_SymInClbk, };
Что тут происходит:
При аутентификации повышается уровень логирования (ERROR) для debug принтов, что бы не мешать пользовательскому вводу и возвращается обратно при разлогине
Ввод любого символа сбрасывает таймер разлогина
Отправляем ивент на мигание светодиодиками по нажатию на клавишу
Таймер кстати нужен, что бы не забыть про shell и разлогин произошел автоматически спустя некоторое время:
static void ShellRoot_ResetTimerClbk(TimerHandle_t xTimer) { WshShell_DeAuth(&ShellRoot, (WshShell_Char_t*)pcTimerGetName(ShellExit_Timer)); } void FreeRTOS_ShellRoot_InitComponents(bool resources, bool tasks) { if (resources) { ShellExit_Timer = xTimerCreate("tim-shell-autoexit", WSH_SHELL_AUTO_EXIT_TMO, pdFALSE, NULL, ShellRoot_ResetTimerClbk); } //... }
Биндинг сочетаний клавиш
Есть еще такая штука — ожидание ввода комбинации или отдельного символа, дальше которого блокируется работа shell. Вот пример ожидания селектора yes/no, или нажатия клавиши enter :
WshShell_Bool_t WshShellPromptWait_Enter(WshShell_Char_t symbol, WshShellPromptWait_t* pWait) { WSH_SHELL_ASSERT(pWait); if (symbol == '\r' || symbol == '\n') { WshShellPromptWait_Flush(pWait); return true; } else { WSH_SHELL_PRINT_SYS("Press <Enter> to continue...\r\n"); return false; } } WshShell_Bool_t WshShellPromptWait_YesNo(WshShell_Char_t symbol, WshShellPromptWait_t* pWait) { WSH_SHELL_ASSERT(pWait); if (symbol == 'Y' || symbol == 'y') { WSH_SHELL_PRINT_SYS("Yes selected\r\n"); } else if (symbol == 'N' || symbol == 'n') { WSH_SHELL_PRINT_SYS("No selected\r\n"); } else { WSH_SHELL_PRINT_SYS("Invalid input\r\n"); return false; } return true; }
Кстати, именно через WshShellPromptWait_Enter shell заблокирован от любого иного ввода после первичной инициализации или последующего разлогина - можно видеть на скриншотах.
Остальное
Есть опция кастомизировать хедер приветствия при инициализации wsh-shell. А что бы далеко не ходить за ascii-графикой, можно использовать скрипт из проекта
python3 utils/gen-shell-banner.py your-header-textСочетание клавиш
Ctrl+D(EOF signal) работает на выход — либо из интерактивной команды, либо из текущей сессии пользователяВ wsh-shell встроена дефолтная команды
wsh, она позволяет просматривать, какие команды и пользователи инициализированы и выполнять некоторые другие вспомогательные и тестовые функции
Попробую показать все и сразу на одном скриншоте:

Заключение
Из вспомогательного кода для отладки получился полноценный инструмент, который удобно встраивается в embedded-проекты уже на раннем этапе. Он не требует сложной интеграции, не тянет за собой зависимости и при этом даёт единую точку входа для диагностики, тестирования и обслуживания устройства на протяжении всего жизненного цикла.
Всегда есть, что доделывать и улучшать, но текущая версия закрывает большинство наших запросов и в какой-то момент решили, что самое время вынести это в отдельный репозиторий (ну и опубликовать уж).
Отдельно приятно, что начинание, от которого изначально не ожидали большего, чем «удобный внутренний инструмент», в итоге оформилось в самостоятельный мини-продукт — достаточно универсальный, чтобы упростить рутинные процессы разработки и быть полезным за пределами исходного проекта!

