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


  1. Kahelman
    18.12.2025 21:40

    Прикольно.

    Не думали реализовать forth машину и все остальное уже на нем крутить?


    1. Katbert Автор
      18.12.2025 21:40

      Что-то сложно. Даже если вы про https://ru.wikipedia.org/wiki/Форт_(язык_программирования), то я все еще не понимаю, о чем речь :)


      1. Kahelman
        18.12.2025 21:40

        Это дело надо вкуривать :)

        Идея в том, что форт программа это определите своих “слов» используя встроенные примитивы. Вы фактически создаете свой DSL для работы с вашей железкой.

        Определяете:

        Включить светодиод, выключить светодиод.

        Потом можете определить

        Светофор которые состоит из набора команд: включить зеленый, выключить зеленый, включить желтый …

        При этом реализация виртуальной машины форта достаточно простая и не ресурсоемкая. Так что можно встроить высокоуровневый язык практически во что угодно.


  1. aabzel
    18.12.2025 21:40

    Молодцы, здорово! Достойно восхищения.
    Добро пожаловать в клуб умеющих отлаживать микроконтроллерные прошивки через UART-CLI.

    Почему Нам Нужен UART-Shell? (или Добавьте в Прошивку Гласность) /
    https://habr.com/ru/articles/694408/


  1. aabzel
    18.12.2025 21:40

    Сборка из Make у Вас тоже уже налажена.

    Теперь осталось только довести Ваши прошивки до ОКФП

    Ортодоксально Канонической Формы Прошивки
    https://habr.com/ru/articles/974152/


  1. beefdeadbeef
    18.12.2025 21:40

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


    1. 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 в самом приложении.