Так сложилось, что программируя микроконтроллеры, разработчик балансирует между двумя крайностями. Все ресурсы под твоим полным контролем — и это кайф (думаю, многие в 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-проекты уже на раннем этапе. Он не требует сложной интеграции, не тянет за собой зависимости и при этом даёт единую точку входа для диагностики, тестирования и обслуживания устройства на протяжении всего жизненного цикла.
Всегда есть, что доделывать и улучшать, но текущая версия закрывает большинство наших запросов и в какой-то момент решили, что самое время вынести это в отдельный репозиторий (ну и опубликовать уж).
Отдельно приятно, что начинание, от которого изначально не ожидали большего, чем «удобный внутренний инструмент», в итоге оформилось в самостоятельный мини-продукт — достаточно универсальный, чтобы упростить рутинные процессы разработки и быть полезным за пределами исходного проекта!

Комментарии (7)

aabzel
18.12.2025 21:40Молодцы, здорово! Достойно восхищения.
Добро пожаловать в клуб умеющих отлаживать микроконтроллерные прошивки через UART-CLI.Почему Нам Нужен UART-Shell? (или Добавьте в Прошивку Гласность) /
https://habr.com/ru/articles/694408/

aabzel
18.12.2025 21:40Сборка из Make у Вас тоже уже налажена.
Теперь осталось только довести Ваши прошивки до ОКФПОртодоксально Канонической Формы Прошивки
https://habr.com/ru/articles/974152/

beefdeadbeef
18.12.2025 21:40хорошо было бы иметь возможность отказаться от создания пользователей, авторизации и вот этого всего -- совершенно понятно, зачем это нужно вам, но я-то тут один :]

Katbert Автор
18.12.2025 21:40Отказаться не получится, пользователь - довольно критичная сущность и не предусмотрено его не создавать вообще.
Но если не нужно, можно сделать вот такой обходной путь (я например отлаживаюсь так, что бы не вводить креды каждый раз):bool Shell_Init(const char* pcHostName) { if (WshShell_Init(&Shell, pcHostName, NULL, &Shell_Callbacks) != WSH_SHELL_RET_STATE_SUCCESS) return false; if (WshShellUser_Attach(&(Shell.Users), Shell_UserTable, WSH_SHELL_ARR_LEN(Shell_UserTable), NULL) != WSH_SHELL_RET_STATE_SUCCESS) return false; WshShellHistory_Init(&Shell.HistoryIO, Shell_HistoryRead, Shell_HistoryWrite); if (!Shell_Commands_Init(&Shell)) return false; WshShell_Auth(&Shell, "admin", "1234"); //<---- For quick auth return true; }
Просто залогиниться черезWshShell_Authв самом приложении.
Kahelman
Прикольно.
Не думали реализовать forth машину и все остальное уже на нем крутить?
Katbert Автор
Что-то сложно. Даже если вы про https://ru.wikipedia.org/wiki/Форт_(язык_программирования), то я все еще не понимаю, о чем речь :)
Kahelman
Это дело надо вкуривать :)
Идея в том, что форт программа это определите своих “слов» используя встроенные примитивы. Вы фактически создаете свой DSL для работы с вашей железкой.
Определяете:
Включить светодиод, выключить светодиод.
Потом можете определить
Светофор которые состоит из набора команд: включить зеленый, выключить зеленый, включить желтый …
При этом реализация виртуальной машины форта достаточно простая и не ресурсоемкая. Так что можно встроить высокоуровневый язык практически во что угодно.