Command line interpreter на микроконтроллере своими руками

    В каждом разрабатываемом устройстве у меня присутствовал отладочный вывод в UART, как в самый распространённый и простой интерфейс.
    И каждый раз, рано или поздно, мне хотелось помимо пассивного вывода сделать ввод команд через тот же UART. Обычно это происходило когда мне хотелось для отладки выводить какой-нибудь очень большой объём информации по запросу (например состояние NANDFLASH, при разработке собственной файловой системы). А иногда хотелось программно управлять ножками GPIO, чтобы отрепетировать работу с какой-нибудь переферией на плате.
    Так или иначе мне был необходим CLI, который позволяет обрабатывать разные команды. Если кто-то натыкался на уже готовый инструмент для этих целей — буду благодарен за ссылку в комментариях. А пока я написал собствыенный.

    Требования, в порядке уменьшения важности:
    1. Язык С. Я пока не готов писать ПО для микроконтроллеров на чём-либо другом, хотя ситуация может и измениться.
    2. Приём и обработка строк из UART. Для простоты все строки оканчиваются '\n'.
    3. Возможность передавать в команду параметры. Набор параметров различается для разных команд.
    4. Легкость добавления новых команд.
    5. Возможность добавления новых команд в разных исходных файлах. Т.е. начиная реализовывать очередной функционал в файле "new_feature.c" я не трогаю исходники CLI, а добавляю новые команды в том же файле "new_feature.c".
    6. Минимум используемых ресурсов (RAM, ROM, CPU).

    Не буду подробно описывать драйвер UART сохраняющий принятые символы в статический буфер, отбрасывающий пробелы в начале строки и ждущий символа перевода строки.
    Начнём с более интересного — у нас есть строка, оканичающаяся '\n'. Теперь надо найти соответсвтующую ей команду и выполнить.
    Решение в виде
    typedef void (*cmd_callback_ptr)(const char*);
    typedef struct
    {
      const char *cmd_name;
      cmd_callback_ptr callback;
    }command_definition;
    

    и поиске в множестве зарегистрированных команд команды с искомым именем напрашивается. Только вот загвоздка — как реализовать этот поиск? Или, точнее, как составить это самое множество?
    Если бы дело было в C++ самым очевидным решением было бы использование std::map<char*, cmd_callback_ptr> и поиск в нём (неважно уже как). Тогда процесс регистрации команды сводился бы к добавлению в словарь указателя на функцию-обработчик. Но я пишу на C, и переходить на C++ пока не хочу.
    Следующая идея — глобальный массив command_definition registered_commands[] = {...}, но этот путь нарушает требование добавления команд из разных файлов.
    Заводить массив «побольше» и добавлять команды функцией вроде
    #define MAX_COMMANDS 100
    command_definition registered_commands[MAX_COMMANDS];
    void add_command(const char *name, cmd_callback_ptr callback)
    {
      static size_t commands_count = 0;
      if (commands_count == MAX_COMMANDS)
        return;
      registered_command[commands_count].cmd_name = name;
      registered_command[commands_count].callback = callback;
      commands_count++;
    }
    
    тоже не хочется, т.к. придётся либо постоянно подправлять константу MAX_COMMANDS, либо зря расходовать память… Вообщем некрасиво как-то :-)
    Делать всё тоже самое с помощью динамического выделения памяти и увеличения выделенного массива с помощью realloc на каждом добавлении — наверное неплохой выход, но не хотелось связываться с динамической памятью вообще (нигде больше она в проекте не используется, а кода в ROM занимает много, да и RAM не резиновый).

    В итоге я пришёл к следующему любопытному, но, к сожалению, не самому портабельному решению:
    #define REGISTER_COMMAND(name, func) const command_definition handler_##name __attribute__ ((section ("CONSOLE_COMMANDS"))) = \
    { \
      .cmd_name = name, \
      .callback = func \
    }
    extern const command_definition *start_CONSOLE_COMMANDS; //предоставленный линкером символ начала секции CONSOLE_COMMANDS
    extern const command_definition *stop_CONSOLE_COMMANDS; //предоставленный линкером символ конца секции CONSOLE_COMMANDS
    
    command_definition *findCommand(const char *name)
    {
      for (command_definition *cur_cmd = start_CONSOLE_COMMANDS; cur_cmd < stop_CONSOLE_COMMANDS; cur_cmd++)
      {
         if (strcmp(name, cur_cmd->cmd_name) == 0)
         {
           return cur_cmd;
         }
      }
      return NULL;
    }
    
    Вся магия здесь заключена макросе REGISTER_COMMAND, который создаёт глобальные переменные так, что при исполнении кода они будут идти в памяти строго друг за другом. А опирается эта магия на атрибут section, который указывает линкеру, что эту переменную надо положить в отдельную секцию памяти. Таким образом на выходе мы получаем нечто очень похожее на массив registered_commands из предыдущего примера, но не требующей заранее знать сколько в нём будет элементов. А указатели на начало и конец этого массива нам предоставляет линкер.
    Подведём итоги, выпишем плюсы и минусы данного решения:
    Плюсы:
    • Возможность плодить команды пока не кончится память.
    • Проверка уникальности имён команд на этапу сборки. Неуникальные команды приведут к созданию двух переменных с одним и тем же именем, что будет диагностировано линкером как ошибка.
    • Возможность объявлять команды в любой единице трансляции, не меняя остальные.
    • Отсутствие зависимостей от каких-либо внешних библиотек.
    • Отсутсвие необходимости в специальной run-time инициализации (регистрация команд и т.д.).
    • Отсутсвтие накладных расходов по памяти. Весь массив команд может размещаться в ROM.

    Минусы:
    • Опирается на конкретный toolchain. Для других придётся править создание команды и, возможно, линкерный скрипт.
    • Реализуется не на всех архитектурах, т.к. опирается на структуру бинарного формата исполняемого файла. (см. атрибуты переменных в gcc)
    • Линейный поиск по зарегистрированным командам, т.к. массив неотсортирован.

    Последний минус можно побороть ценой последнего плюса — можно разместить команды в RAM, после чего отсортировать. Или даже заранее посчитать hash-функцию какую-нибудь чтобы сравнивать не через strcmp.

    Комментарии 35

      +4
      Ожидал увидеть именно консоль/терминал, а оказалось, что просто реализация CLI.
        0
        После вашего комментария пошёл гуглить тонкости различия. Не могу сказать, что кристально ясно уловил их, но выделил одно:

        Консоль контролирует всю видимую область, т.е. позволяет не только добавлять строку, но и перерисовывать экран целиком.
        А Command Line Interpreter осуществляет только ввод-вывод текста, без последующего форматирования и заботы о перерисовке предыдущего.

        Вы это имели в виду или что-то другое?
      +1
      Как-то сложно регистрируются команды…
      У меня есть своя реализация более продвинутого «шела» для микроконтроллеров…
      На С, минимально может работать на 100 байт памяти (можно уменьшить до 40 отрезав некоторые возможности). и немного стека (байт 20).
      поддерживает неограниченное количество команд (сколько во флеш влезет),
      есть автодополнение комманд, передача параметров для исполняемых функций.
      типа того:
      > plus 4 5
      9
      >
      «шелл» может работать в условно-фоновом режиме (достаточно переодически вызывать обработчик шелла)
      может и монопольно.
      можно «вызывать» предыдущую команду с параметрами.
      можно подключить как к UART, так и к другим каналам связи. (надо реализовать всего 3 функции putch(), getch(), kbhit())
      краткая авто генерируемая справка по списку команд.
      не зависит от стандартных библиотек
      должна реализовываться на практически любом компиляторе и архитектуре. изначально затачивалась под 8 бит архитектуру.

      один из минусов — это команды должны быть сведены в один константный массив
      ...{ «command1_name», function_obrabotka1 }, { «cmd2», func2 },…
      другой минус — нельзя запустить несколько «шеллов» одновременно.

      если есть высокая заинтересованность хабра-сообщества могу попытаться оформить это дело…
        +1
        Для меня первый минус критичен — когда я разрабатываю новый функционал, я добавляю новые файлы. Там появляются новые команды.
        Потом я могу решить в какую-то из сборок не включать часть функционала и в makefile закомментировать соотвествующие исходники.
        А если сводить все команды в один массив, то придётся постоянно его редактировать. Либо делать автогенерацию этого массива в процессе сборки, но это ИМХО не легче чем мой способ.
        А соль моего способа состоит в том, что я могу новые команды добавлять в любом файле просто макросом. Пример:
        #include "commands.h" //здесь описан макрос REGISTER_COMMAND
        
        REGISTER_COMMAND("command1_name", function_obrabotka1);
        REGISTER_COMMAND("cmd2", func2);
        

        А вот «продвинутость» вашего shell'a действительно интересна — я часто думал не сделать ли мне историю команд. Но каждый раз забивал, т.к. shell является не ключевой частью устройства, а скрытой от всех кроме разработчика отладочной фичей.
          +2
          antonkozlov ниже уже упомянул Embox, хочу только подкинуть идею, как можно формировать константный массив из разных исходников. Под капотом у нас тоже используются секции компоновщика, но в исходниках массив представляется именно Си-шным массивом, а кроме того, один раз доработав линкер-скрипт, можно создавать и использовать сколько угодно разных массивов.

          Определяем сам массив (один раз, например, в шелле):
          ARRAY_SPREAD_DEF(const struct shell_cmd, __shell_cmds);
          

          В каждом исходнике определяем структуру и заносим её в этот массив:
          extern const struct shell_cmd __shell_cmds[];
          ARRAY_SPREAD_ADD(__shell_cmds, {
                  .name = "foo",
                  .func = foo_func,
              });
          

          Когда нужно проитерироваться по массиву, используем константу ARRAY_SPREAD_SIZE(__shell_cmds). Помимо этого, есть возможность определения NULL-терминированных (или не NULL-, а чем-нибудь другим терминированных) массивов, что тоже бывает удобно для итерирования.

          Исходники в двух хедерах тут и тут, а линкер-скрипт вот:
          SECTIONS {
          	.rodata : {
          		*(SORT(.array_spread.*.rodata))
          	}
          }
          

          От билд-системы дополнительно ничего не требуется (ну, кроме добавления этого линкер-скрипта).
            0
            Спасибо! Особенно порадовала реализация ARRAY_SPREAD_SIZE.
            но в исходниках массив представляется именно Си-шным массивом
            А что вы здесь имели в виду? Что существует указатель по которому можно работать с распределённым массивом, как с обычным? Так это и у меня так.

            Вообще создаётся впечатление, что мы идём параллельными, но разными путями.
              +1
              Ну, да, что с точки зрения использования это обычный массив с типом
              extern const struct shell_cmd __shell_cmds[];
              

              Но это, конечно, более актуально в случае NULL-терминированных массивов указателей, где нам необязательно знать размер массива, и можно обойтись без ARRAY_SPREAD_SIZE.

              Вообще создаётся впечатление, что мы идём параллельными, но разными путями.
              Не без этого =)

              Мы когда-то тоже использовали решение точь-в-точь как у вас. Проблема в том, что с добавлением каждой новой подсистемы (тесты, драйверы, файловые системы, обработчики протоколов сетевого стека, ...) каждый раз приходилось править линкер-скрипт. Поискал сейчас по проекту, нашел 30 разных массивов, определнных через ARRAY_SPREAD_DEF. Кроме того, мы используем эту технику под капотом своего фреймворка юнит-тестирования, чтобы определять новые тест-кейсы без явной регистрации: вот тут alexkalmuk разбирал это.

              С другой стороны, есть мысли (у меня, по крайней мере) отказываться от этой штуки, там, где можно без этого обойтись, например, в пользу явной генерации исходного кода билд-системой. Ну, то есть не злоупотреблять всей этой макро-магией: если посмотреть на историю файла с реализацией, мы хорошенько огребли, например, проблем с разными версиями компиляторов.
                0
                А разве const-данные не автоматически в секцию ro записываются? Я не уверен, но не казалось что в том и прелесть атрибута section, что он сложит все данные в одну секцию. И она ляжет в ro, если в ней есть только константные данные.
                Генерация кода билд-системой бывает вполне мила (по опыту общения с DSP/BIOS от TI), но там свои косяки вылезают. Иногда мне очень хотелось подправить тамошнюю систему tconf (textual config).
                  0
                  Вы имеете в виду, зачем нужен const? Здесь он не обязателен, поскольку мы явно указываем секцию через атрибут, но это полезно для дополнительного контроля со стороны компилятора. А куда пойдет секция, определяется в линкер-скрипте, в нашем случае мы отправляем её в .rodata.
                    0
                    наоборот — зачем нужен линкер-скрипт, если мы уже указали квалификатор const? Разве gcc не автоматом складывает const в .rodata?
                      0
                      Просто const уложится в .rodata, да, но у нас-то (и у вас) ещё и атрибут section. И эти секции нужно уложить в правильном порядке: указатель на голову, потом элементы, потом терминирующий элемент, если есть, и указатель на конец. Для этого нужен SORT в *(SORT(.array_spread.*.rodata)).
                        +1
                        А зачем вам указатель на голову и указатель на конец? У меня они автоматически генерируются линкером, и при этом не занимают места, потому что это просто символы линкера, а не байты в памяти. Это позволяет ещё чуть-чуть уменьшить overhead.
                        Для секции secname у меня линкер автоматом генерит что-то вроде:
                        SECTIONS {
                            .rodata : {
                                 _start_secname = .;
                                 *(.secname)
                                 _stop_secname = .;
                            }
                        }
                        

                        Потом я эти символы объявляю как extern указатели и при линковке они автоматически подменяются на соответсвтующие адреса. Возможно что это свойcтво gcc-arm-embedded, но я беззазрительно им пользуюсь.
                          +1
                          А, теперь понимаю, о чем вы. Да, скорее всего, это есть не во всех версиях binutils, с ходу так не могу сказать, почему мы это не использовали. Может, ещё и из-за необходимости NULL-терминированных массивов указателей.

                          и при этом не занимают места, потому что это просто символы линкера, а не байты в памяти. Это позволяет ещё чуть-чуть уменьшить overhead.
                          Указатели в нашем решении тоже не занимают байтов в памяти, это такие же символы, которые определяются через массивы нулевой длины.
        +2
        Наша ОС Embox начиналась как монитор для встраиваемых систем. С тех пор у нас есть очень похожий репозиторий команд, основанный на линкер секции.
        Со временем мы решили перенести регистрацию команд из исходных файлов в файлы описания системы сборки (она, кстати, описывалась на хабре: habrahabr.ru/post/144935/). Туда же мы помещаем метаинформацию о командах: краткую справку и man. Получается как-то так: описание для системы сборки
        @AutoCmd
        @Cmd(name = "help", help = "Shows all available commands", man = '''...'''
        module help {
                source "help.c"
                ...
        }
        

        А в help.c
        int main(int argc, char **argv) {
                ...
        }
        

        Никакой высокой науки, но команды выглядят как обычные хостовые программы (точка вхождения — функция main), хотя внутри реализуются так же, как статье.

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

        Конечно, вокруг команд есть много всего интересного: шеллы (управление задачами), терминал/консоль. Если сообществу интересно, как у нас это реализовано, то можно расписать подробнее.
          0
          А почему решили перенести? У вас это не порождает проблем с синхронизацией между исходниками и системой сборки? Опять же — изолированность изменений в системе контроля версий. Как-то не хочется добавив команду в один исходник изменять ещё скрипт сборки. Мне кажется что это захламляет changelog скрипта.
            +1
            Перенесли из-за желания сделать команды максимально стандартными, тем самым уменьшив порог вхождения. Как бонус, получили возможность собирать команды из нашей ОС на unix хостах (без изменений), наоборот — тоже получается достаточно хорошо.

            При добавлении новой команды нужно сообщать системе сборки о новом файле. Описание лежит рядом с исходником, как например Cat.my и cat.c.
            Отделение исходников от информации о межмодульных взаимодействиях является одной из основных фич проекта, с её помощью нам удается включать в целевой образ только необходимый программный код.
            Конечно, расхождение возможно и случается. Это больше относится к другим типам описаний, чем к информации о командах, забыть изменить мануал можно и в текущем файле. Над автоматическим извлечением другой метаинформации мы работаем в следующей версии системы сборки. Пока же, проверка соответствия исходников их описанию не представляет больших неудобств.
            Но в случае с командами деление даже полезно: справка в одном файле, исходник — в другом.
              0
              Перенесли из-за желания сделать команды максимально стандартными, тем самым уменьшив порог вхождения. Как бонус, получили возможность собирать команды из нашей ОС на unix хостах (без изменений), наоборот — тоже получается достаточно хорошо.
              Это понятно. Но, как я понимаю, относится к сигнатуре обработчика команд, а не к способу регистрации команд.

              При добавлении новой команды нужно сообщать системе сборки о новом файле. Описание лежит рядом с исходником, как например Cat.my и cat.c.
              Вариант, если к каждому исходнику всё равно прилагается его описание для MyBuild. Иначе придётся плодить дополнительные файлы. Но даже в этом варианте добавление/удаление новой команды требует редактирования двух файлов.

              забыть изменить мануал можно и в текущем файле
              Можно, но если этот мануал у тебя идёт рядом с самой функцией, то глазами на него натыкаешься, когда код редактируешь. А вот если у тебя тело функции от мануала отстоит строк хотя бы на 30 — то легко забыть.
                0
                Перенесли из-за желания сделать команды максимально стандартными, тем самым уменьшив порог вхождения. Как бонус, получили возможность собирать команды из нашей ОС на unix хостах (без изменений), наоборот — тоже получается достаточно хорошо.
                Это понятно. Но, как я понимаю, относится к сигнатуре обработчика команд, а не к способу регистрации команд.
                В отдаленных планах, кстати, есть отказ и от этого. То есть команда неотличима от обычного приложения с функцией main, а при сборке глобальные символы (в т.ч. main) манглятся, и команда регистрируется «как обычно».

                Но даже в этом варианте добавление/удаление новой команды требует редактирования двух файлов.
                Для нас это что-то вроде неизбежного зла. Любой модуль так и так требует этих двух файлов, да и забыть поправить справку к команде не такая уж беда.
                  0
                  У меня пока проект на EFM32 с 32КБ RAM не разросся до таких высот как несколько приложений, поэтому реализовывать команду как приложение не получается =). Вот и изобрёл «на скорую руку» такой огрызок.
                  Второй момент — когда разработчиков в проекте мало, то затраты на собственную систему сборки/полноценную RTOS не окупаются. А вот быстрая регистрация команд (и такой же механизм для задач в OS) уже зарекомендовала себя, т.к. сильно ускорила разработку аппаратных «fork-ов» от исходной платы. Так из диктофона получились: стерео USB-микрофон, USB-адаптер для управления несколькими реле, переходник USB-I2C для отладки работы с новыми микросхемами, 8-ми канальная микрофонная решётка совместимая с USB-audio драйверами Windows и Linux. Возможно скоро и ещё что-нибудь получится.
                    0
                    Странно, Вы же сами дали пример
                    REGISTER_COMMAND(«command1_name», function_obrabotka1);
                    REGISTER_COMMAND(«cmd2», func2);

                    то есть регистрируете две команды. Плюс описана функция поиска команды, Значит все таки несколько приложений или команд у Вас в проекта есть.
                    Второй момент тоже спорный, На мой взгляд, при маленькой команде стоит посмотреть на готовые RTOS, они на себя возьмут часть рутины и тем самым ускорят разработку. Хотя конечно свои велосипеды для конкретных случаев никто не отменял.:)

                    Еще вопрос, а все перечисленные устройства были сделаны на EFM32 c 32кБ RAM? Тогда мои поздравления это действительно круто! Просто у нас на EFM32 правда младших моделей (4кБ ОЗУ) только простенький shell крутиться.
                      +1
                      У меня вся прошивка собирается в одно приложение, с кооперативной многозадачностью на манер co-routines из FreeRTOS. Поэтому я и сказал про «одно приложение». Это позволяет существенно экономить память.
                      Второй момент сложился исторически, из-за ошибки планирования на старте проекта. Изначально делали макет для оценки параметров MEMS-микрофонов с цифровым выходом. При этом лепили «из того что было» (EFM32LG), больше интересуясь не системными вещами, а поднятием драйверов переферии и замерами энергопотребления и качества звука.
                      А потом руководство сказало что надо срочно сделать из этого продающийся диктофон. Т.к. к этому моменту уже была сделана запись звука на NANDFLASH и вычитывание через USBCDC-драйвер, то пришлось срочно доделать, а не начинать разработку софта заново.
                      Но после самостоятельной реализации псевдомногозадачной RTOS с поддержкой композитного USB-устройства, описанным в данной статье CLI, механизмом событий для межзадачного взаимодействия, динамического изменения частоты MCU и т.д. я дал себе зарок в следующей подобной разработке использовать что-нибудь вроде FreeRTOS.

                      Про EFM32 c 32кБ — как показал опыт этого вполне достаточно, если все задачи используют один и тот же стек. Для экономии же памяти я отказался от динамического выделения. Вообще всё что можно я регистрирую с помощью линкера =). А что вы делаете на EFM32ZG, если не секрет?
                        0
                        Да на FreeRTOS я и намекал:)

                        Не секрет, у нас есть знакомые из Северо-Восточного федерального университета. Они делали проект по автоматизации теплиц (сенсоры управление освещением и тд) и попросили портировать на EFM32 поскольку у нее очень хорошие показатели энергопотребления. Мы собственно это сделали, затащили BSP запустили shell и на этом пока остановились, сейчас в основном STM32 используем и там уже кучу всего сделали.
                          0
                          Вот и мы взяли EFM32 за энергопотребление. Но нам был критичен USB, поэтому используем LG. Ну и 4кБ RAM при работе с NANDFLASH со страницами 3кБ тоже были бы грустны :).
                  0
                  Вариант, если к каждому исходнику всё равно прилагается его описание для MyBuild. Иначе придётся плодить дополнительные файлы. Но даже в этом варианте добавление/удаление новой команды требует редактирования двух файлов.

                  Тут ведь как, Вы когда добавляете команду тоже меняете два файла, один Си-шный второй Makefile и у нас тоже самое. Мы просто позволяем писать в своеобразный файл для сборки больше информации и она у нас в декларативном виде представлена.
            +3
              0
              Спасибо, выглядит прилично. Легкая замена readline — это хорошо.
              –1
              Вообще у меня есть cubiboard, а у нее вывод терминала в uart. Я контроллер цеплял к плате, а он с консолью общался. Но это было jast for fun. Логинился, писал Hello linux в файл и все.
                0
                Не CLI, конечно, но я когда-то давно, по тем же причинам, написал скрипт на Python для генерации С-кода текстовых менюшек произвольной сложности.
                Может, кому-то пригодится: github.com/sirgal/Easy-Console-Text-Menu-Generator
                  0
                  Интересно. Почитал результат работы вашего скрипта и имею к нему несколько вопросов:
                  1. Зачем там динамическое выделение памяти? Ведь все данные уже известны на момент генерации исходников.
                  2. Зачем там столько memcpy при создании меню? Можно ведь
                    memcpy( menu[0].welcome_string, "Hello! Welcome to the test menu.\n", string_lengths[0] );
                    
                    заменить на
                    menu[0].welcome_string = "Hello! Welcome to the test menu.\n";
                    
                    Или потом эти данные как-то изменяются?

                  Хотя вообще идея генерации исходных кодов мне нравится — она позволяет каждую часть проекта писать на том языке, который для этого лучше подходит. Ну и изобретать при этом свой язык, при необходимости.
                    0
                    Так можно выгрузить все из памяти, когда меню уже не используется, что полезно, если оно запускается лишь раз в жизни устройства, при монтаже, или при редком сервисном доступе, после ребута. Год назад писалось, всех тонкостей уже не помню. Точно помню, что была идея сделать это на статической памяти, точно помню, что стало лень :)
                      0
                      Аргумент! У меня тоже регулярно случается что вспомогательные утилиты останавливаются на этапе «стало лень».
                        0
                        Все же, скорее, на этапе «все уже и так работает, доделаю когда-нибудь никогда».
                  +2
                  Я делал командный интерфейс с поддержкой ANSI-терминала. Только для AVR, но на C/C++ (gcc и iar).

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое