Пишем терминальный сервер для микроконтроллера на С

—

от автора

Всем привет! В процессе работы над гексаподом появилась потребность в каком-нибудь интерфейсе для общения с ним. В результате тесной работы с Linux я подумал, а почему бы не использовать терминал для гексапода? Я был удивлен, что по запросу «STM32 terminal» не было готовых решений. Ну раз нет готовых, то напишем свой терминальный сервер для использования в микроконтроллерах. Сделаем это без использования динамической памяти и прочих опасных радостей, быстро, просто и понятно.

→ Github

Синхронизируем понятия

Терминал, консоль — клиентская часть (KiTTY, PuTTY и прочие). Выполняется на стороне клиента (PC). Возьмем готовую реализацию в виде KiTTY.

Терминальный сервер — удаленная часть, выполняется на микроконтроллере. Именно эту часть я и буду делать.

Аппаратные тонкости

В качестве тестового микроконтроллера я буду использовать STM32F030. Общаться с MCU мы будем через USB-USART преобразователь CH340E.

CH340E
CH340E

Почему именно она? Для корректной работы терминального сервера нужно уметь отслеживать текущее состояние подключения, т.е. нам нужен аналог TCP протокола в железном варианте и эта микросхема отлично для этого подходит — тут есть DTR. Просто берем этот вывод и заводим его на ногу микроконтроллера, тем самым мы сможем определить подключение.

  • DTR=Low — пользователь открыл терминал;

  • DTR=High — пользователь закрыл терминал;

Зачем нам вообще следить за подключением? Дело в том, что терминальному серверу необходимо знать когда «забыть» текущую сессию и обнулить все буферы. Можно закрыть терминал в середине набора команды и при следующем подключением можно обнаружить неадекватное поведение. Так же нам нужно знать когда вывести приглашение в консоль пользователя, всё должно быть по канону 🙂

В итоге, для работы требуется всего 4 провода: RX, TX, DTR, GND. Мне кажется это хорошим началом — минимум проводов. На этом аппаратные детали закончились.

Управляющие последовательности (УП) и символы (УС)

Что это и зачем они нужны? Вся вот эта красивая разноцветная консолька это «клиент-сервер», неважно SSH там или провод между ними. Нужно каким-то образом сообщать серверу или клиенту о каких-либо действиях — для этого используются УП и УС.

Начнем с управляющих последовательностей (УП)

Для простоты можно рассматривать УП как некие события, которые посылает клиент в сторону сервера и наоборот. Если нужно на стороне клиента переместить курсор влево (поправить опечатку, например), то мы должны как-то сообщить серверу об этом. Для этого мы отправляем ему событие в виде УП.

УП начинаются со специального кода ESC (0х1В), после которого идут несколько байт, определяющие тип события. Например, событие перемещения курсора влево будет иметь следующий вид: [0x1B 0x5B 0x44].

Управляющие символы (УС)

В отличие от УП они имеют размерность 1 байт. В качестве примера можно взять всем известные \r (0x0D) и \n (0x0A). Они находятся в самом начале ANSII таблицы [0x00 — 0x31, 0x7F] и в целом с ними нет никаких проблем.

Когда вы хотите прервать команду, то нажимаете Ctrl+C. В итоге это превращается в управляющий символ 0x03 (End of text). Для закрытия сессии Ctrl+D, это превращается в 0x04 (End of transmission) и т.д.

Программные тонкости

Ниже я приведу несколько УП и УС, которые хочу реализовать на этом этапе. Более обширный их список можно найти тут

  • «\x0D» — Carriage Return (CR, возврат каретки), он же \r;

  • «\x0A» — Line Feed (LF, перевод каретки), он же \n;

  • «\x0D» — TAB button (ну куда же без авто завершения команд);

  • «\x7F» и «\x08» — BACKSPACE button;

  • «\x1B[3~» — DEL button;

  • «\x1B[A» и «\x1B[B» — arrow up \ down;

  • «\x1B[D» и «\x1B[C» — arrow left \ right;

  • «\x1B[4~» — END button;

  • «\x1B[1~» — HOME button;

Этого списка достаточно для простого терминального сервера. В дальнейшем новые УП и УС будут добавляться по мере необходимости.

Есть еще две хитрых УП, которые я буду посылать клиенту. При посылке любого символа клиенту на его стороне не просто отображается символ, но и перемещается курсор. Использование этих двух УП позволяет убрать слежение за положением курсора в некоторых местах. Это сильно упростит манипуляции с клиентской частью и сократит трафик по USART.

  • «\x1B[s» — Запомнить положение курсора;

  • «\x1B[u» — Восстановить положение курсора;

Закинем их в отдельные функции:

static inline void save_cursor_pos() { send_data("\x1B[s", 3); } static inline void load_cursor_pos() { send_data("\x1B[u", 3); }

Важно: не все терминалы поддерживают эти команды, нужно иметь это ввиду.

Ну давайте код попишем

Начнем с простого — драйвер USART. Вжух и драйвер готов:

int main() {     system_init(); // Инициализация источников тактирования, FLASH latency и т.п.         // Запуск тактирования USART1     RCC->APB2ENR |= RCC_APB2ENR_USART1EN;     while ((RCC->APB2ENR & RCC_APB2ENR_USART1EN) == 0);          // Настройка TX вывода     gpio_set_mode        (GPIOA, USART_TX_PIN, GPIO_MODE_AF); // Передаем управление пином переферии      gpio_set_output_speed(GPIOA, USART_TX_PIN, GPIO_SPEED_HIGH); // Ну конечно же максимальная скорость пина     gpio_set_pull        (GPIOA, USART_TX_PIN, GPIO_PULL_NO); // Без подтягивающих регисторов обойдемся     gpio_set_af          (GPIOA, USART_TX_PIN, 1); // Какой периферии мы передаем пин, в данном случае USART1          // Настройка RX вывода     gpio_set_mode        (GPIOA, USART_RX_PIN, GPIO_MODE_AF);     gpio_set_output_speed(GPIOA, USART_RX_PIN, GPIO_SPEED_HIGH);     gpio_set_pull        (GPIOA, USART_RX_PIN, GPIO_PULL_UP);     gpio_set_af          (GPIOA, USART_RX_PIN, 1);      // Конфигурация USART: 8N1, 115200     RCC->APB2RSTR |= RCC_APB2RSTR_USART1RST;     RCC->APB2RSTR &= ~RCC_APB2RSTR_USART1RST;     USART1->BRR = SYSTEM_CLOCK_FREQUENCY / 115200;      // Запускаем USART1     USART1->CR1 |= USART_CR1_UE;     USART1->CR1 |= USART_CR1_TE | USART_CR1_RE;          while (true) {         if (USART1->ISR & USART_ISR_RXNE) {             uint8_t data = USART1->RDR;         }     } }  void send_data(const char* data, uint8_t size) {     while (size) {         while ((USART1->ISR & USART_ISR_TXE) != USART_ISR_TXE);         USART1->TDR = *data;         ++data;         --size;     } }

Я не буду детально останавливаться на каждой строчке, расскажу общую идею. Драйвер блокирующий (polling) без использования прерываний и DMA. Сделано это в угоду простоты и скорости реализации. В главном цикле мы читаем данные пока в никуда, так же есть отдельная функция для передачи данных. Я планирую её передавать терминальному серверу в виде указателя на функцию, чтобы тот мог передавать данные. Это позволит отвязать сервер от периферии (абстракция и все такое).

Первые шаги

«Нажать кнопку и увидеть символ в консоли» звучит просто, но не все так гладко. При нажатии клавиши клиент передает серверу её скан-код, а тот уже решает что с этим делать. Он может послать скан-код обратно клиенту для отображения в консоли или выполнить какое-либо действие, если это УП.

УП и УС я решил обрабатывать как обычные пользовательские команды, по сути то они ни чем не отличаются. Соответственно их обработчики будут внутри кода сервера и будут храниться в отдельной переменной.

Для выполнения команд нам нужно где-то буферизировать вводимые символы, при этом УП и УС должны попадать в отдельный буфер. Еще нам нужно где-то хранить список пользовательских команд и иметь возможность указывать их через интерфейс. И нельзя забывать про историю команд. Появляются следующие структуры данных:

typedef struct {     char cmd[MAX_COMMAND_LENGTH];     uint16_t length; } cmd_info_t;   static term_srv_cmd_t esc_seq_list[ESCAPE_SEQUENCES_COUNT] = {     { .cmd = "\x0D",    .len = 1, .handler = esc_return_handler,    },     { .cmd = "\x0A",    .len = 1, .handler = esc_return_handler,    },     { .cmd = "\x7F",    .len = 1, .handler = esc_backspace_handler, },     { .cmd = "\x08",    .len = 1, .handler = esc_backspace_handler, },     { .cmd = "\x1B[3~", .len = 4, .handler = esc_del_handler,       },     { .cmd = "\x1B[A",  .len = 3, .handler = esc_up_handler,        },     { .cmd = "\x1B[B",  .len = 3, .handler = esc_down_handler,      },     { .cmd = "\x1B[D",  .len = 3, .handler = esc_left_handler,      },     { .cmd = "\x1B[C",  .len = 3, .handler = esc_right_handler,     },     { .cmd = "\x1B[4~", .len = 4, .handler = esc_end_handler,       },     { .cmd = "\x1B[1~", .len = 4, .handler = esc_home_handler,      } };  static void(*send_data)(const char* data, uint16_t) = NULL; // Указатель на функцию для передачи данных клиенту static term_srv_cmd_t* ext_cmd_list = NULL; // Указатель на список пользовательских команд static uint8_t ext_cmd_count = 0; // Количество пользовательских команд  static uint16_t cursor_pos = 0; // Текущая позиция курсора static cmd_info_t current_cmd = {0}; // Информация о вводимой в данный момент команде static char esc_seq_buffer[10] = {0}; // Буфер для управляющих последовательностей static uint16_t esc_seq_length = 0; // Длина управляющей последовательности на данный момент  static cmd_info_t history_elements[MAX_COMMAND_HISTORY_LENGTH] = {0}; // Элементы истории. Вместо динамического выделения памяти static cmd_info_t* history[MAX_COMMAND_HISTORY_LENGTH] = {0}; // Указатели на элементы. Всегда отсортированы по времени static int16_t history_len = 0; // Длина истории (количество команд в ней) static int16_t history_pos = -1; // Текущая позиция в истории

esc_seq_list это те самые УП и УС, которые я буду обрабатывать. В этой структуре есть сигнатура, её длина и функция-обработчик. При совпадении сигнатуры будет вызываться функция-обработчик и выполняться какое-либо действие (перемещение курсора, удаление символа и т.п.)

Теперь нам нужен внешний интерфейс для работы с сервером (заголовочный файл):

#ifndef _TERM_SRV_H_ #define _TERM_SRV_H_ #include <stdint.h>  // Settings #define MAX_COMMAND_HISTORY_LENGTH          (3)     // Command count in history #define MAX_COMMAND_LENGTH                  (64)    // Command length in bytes #define GREETING_STRING                     ("\x1B[36mroot@hexapod-AIWM: \x1B[0m") #define UNKNOWN_COMMAND_STRING              ("\x1B[31m - command not found\x1B[0m")   typedef struct {     char* cmd;     uint16_t len;     void(*handler)(const char* cmd); } term_srv_cmd_t;   extern void term_srv_init(void(*_send_data)(const char*, uint16_t), term_srv_cmd_t* _ext_cmd_list, uint8_t _ext_cmd_count); extern void term_srv_attach(void); extern void term_srv_detach(void); extern void term_srv_process(char symbol);   #endif // _TERM_SRV_H_ 

term_srv_init инициализирует терминальный сервер. В нее передается указатель на функцию для передачи данных клиенту и список пользовательских команд в виде массива структур. В самой структуре содержится команда, её длина и функция-обработчик, которая будет вызываться сервером при получении команды.

term_srv_process вызывается для обработки принятого символа. В нее просто передается принятый символ. Её мы добавляем в драйвер USART после приема символа.

term_srv_attach вызывается при подключении пользователя к серверу. В это время выводится приглашение.

term_srv_detach вызывается для сброса сервера при изменении уровня на DTR пине или еще при каком-нибудь событии.

Просто не правда ли? Теперь реализуем все это дело:

void term_srv_init(void(*_send_data)(const char*, uint16_t), term_srv_cmd_t* _ext_cmd_list, uint8_t _ext_cmd_count) {     send_data = _send_data;     ext_cmd_list = _ext_cmd_list;     ext_cmd_count = _ext_cmd_count;     term_srv_detach(); }  void term_srv_attach(void) {     send_data("\r\n", 2);     send_data(GREETING_STRING, strlen(GREETING_STRING)); }  void term_srv_detach(void) {     cursor_pos = 0;     memset(&current_cmd, 0, sizeof(current_cmd));          esc_seq_length = 0;     memset(esc_seq_buffer, 0, sizeof(esc_seq_buffer));          memset(history_elements, 0, sizeof(history_elements));     for (int16_t i = 0; i < MAX_COMMAND_HISTORY_LENGTH; ++i) {         history[i] = &history_elements[i];     }     history_len = 0;     history_pos = 0; }

Тут всё предельно просто: в случае init сохраняем переданные параметры и сбрасываем состояние сервера. В случае detach обнуляем вообще всё. Как-будто нечего и не было. В attach выводим приглашение в консоль клиента.

Теперь самое интересное — обработка символов

void term_srv_process(char symbol) {     if (current_cmd.length + 1 >= MAX_COMMAND_LENGTH) {         return; // Incoming buffer is overflow     }          if (esc_seq_length == 0) {         // Check escape signature         if (symbol <= 0x1F || symbol == 0x7F) {             esc_seq_buffer[esc_seq_length++] = symbol;         }         // Print symbol if escape sequence signature is not found         if (esc_seq_length == 0) {             if (cursor_pos < current_cmd.length) {                 memmove(&current_cmd.cmd[cursor_pos + 1], &current_cmd.cmd[cursor_pos], current_cmd.length - cursor_pos); // Offset symbols after cursor                 save_cursor_pos();                 send_data(&current_cmd.cmd[cursor_pos], current_cmd.length - cursor_pos + 1);                 load_cursor_pos();             }             current_cmd.cmd[cursor_pos++] = symbol;             ++current_cmd.length;             send_data(&symbol, 1);          }     } else {         esc_seq_buffer[esc_seq_length++] = symbol;     }          // Process escape sequence     if (esc_seq_length != 0) {         int8_t possible_esc_seq_count = 0;         int8_t possible_esc_idx = 0;         for (int8_t i = 0; i < ESCAPE_SEQUENCES_COUNT; ++i) {             if (esc_seq_length <= esc_seq_list[i].len && memcmp(esc_seq_buffer, esc_seq_list[i].cmd, esc_seq_length) == 0) {                 ++possible_esc_seq_count;                 possible_esc_idx = i;             }         }                  switch (possible_esc_seq_count) {         case 0: // No sequence - display all symbols             for (int8_t i = 0; i < esc_seq_length && current_cmd.len + 1 < MAX_COMMAND_LENGTH; ++i) {                 if (esc_seq_buffer[i] <= 0x1F || esc_seq_buffer[i] == 0x7F) {                     esc_seq_buffer[i] = '?';                 }                 current_cmd.cmd[cursor_pos + i] = esc_seq_buffer[i];                 ++current_cmd.len;             }             send_data(&current_cmd.cmd[cursor_pos], esc_seq_length);             cursor_pos += esc_seq_length;             esc_seq_length = 0;             break;                  case 1: // We found one possible sequence - check size and call handler             if (esc_seq_list[possible_esc_idx].len == esc_seq_length) {                 esc_seq_length = 0;                 esc_seq_list[possible_esc_idx].handler();             }             break;                  default: // We found few possible sequences             break;         }     } }

Погнали по строчкам.

  • 8-10: тут мы проверяем соответствие символа на сигнатуру УП и УС, если до этого мы её еще не обнаружили. Если мы все же нашли соответствие, то мы сохраняем символ в другой буфер и переходим в режим их обработки (esc_seq_length != 0);

  • 12-21: если мы не нашли причин считать, что мы принимаем УП или УС, то просто отправляем принятый символ для отображения в консоли клиента и записываем его в буфер;

  • 13-18: этот кейс работает, если курсор находится не в конце команды (переместили его ранее). Данные в буфере смещаются вправо и отображаются в консоли клиента. Тут используются save_cursor_pos и load_cursor_pos, чтобы руками не вычислять каждый раз позицию курсора — пусть за нас это сделает клиент;

  • 29-36: сюда мы попадаем только в режиме обработки УП и УС. Тут мы бегаем по нашему списку и ищем количество частичных совпадений.
    — possible_esc_seq_count показывает столько мы таких нашли,
    — possible_esc_idx хранит индекс в списке для последнего найденного совпадения;

  • 40-50: сюда мы попадаем, если входная УП оказалась не УП, либо мы её не поддерживаем. В этом случае вываливаем всё обратно в консоль клиента, заменяя невидимые символы на ‘?’;

  • 47-52: сюда мы попадаем, если мы однозначно определили УП или УС, но могли её не полностью принять (проверяем её длину). Как только мы получили её полностью — дергаем обработчик и выходим из режима обработки последовательностей. При этом саму последовательность мы не отправляем обратно.

Обработчики УП и УС

Начнем с базовых вещей: \r и \n, стрелок влево\вправо, home, end.

static void esc_return_handler(const char* cmd) {     send_data("\r\n", 2);          // Add command into history     if (current_cmd.len) {         if (history_len >= MAX_COMMAND_HISTORY_LENGTH) {             // If history is overflow -- offset all items to begin by 1 position, first item move to end             void* temp_addr = history[0];             for (int16_t i = 0; i < MAX_COMMAND_HISTORY_LENGTH - 1; ++i) {                 history[i] = history[i + 1];             }             history[MAX_COMMAND_HISTORY_LENGTH - 1] = temp_addr;             --history_len;         }         memcpy(history[history_len++], &current_cmd, sizeof(current_cmd));         history_pos = history_len;     }          // Calc command length without args     void* addr = memchr(current_cmd.cmd, ' ', current_cmd.len);     int16_t cmd_len = current_cmd.len;     if (addr != NULL) {         cmd_len = (uint32_t)addr - (uint32_t)current_cmd.cmd;     }          // Search command     bool is_find = false;     for (int16_t i = 0; i < ext_cmd_count; ++i) {         if (cmd_len == ext_cmd_list[i].len && memcmp(ext_cmd_list[i].cmd, current_cmd.cmd, cmd_len) == 0) {             ext_cmd_list[i].handler(current_cmd.cmd);             send_data("\r\n", 2);             is_find = true;             break;         }     }     if (!is_find && cmd_len) {         send_data(current_cmd.cmd, cmd_len);         send_data(UNKNOWN_COMMAND_STRING, strlen(UNKNOWN_COMMAND_STRING));         send_data("\r\n", 2);     }     send_data(GREETING_STRING, strlen(GREETING_STRING));      // Clear buffer to new command     memset(&current_cmd, 0, sizeof(current_cmd));     cursor_pos = 0; } static void esc_left_handler(const char* cmd) {     if (cursor_pos > 0) {         send_data("\x1B[D", 3);         --cursor_pos;     } } static void esc_right_handler(const char* cmd) {     if (cursor_pos < current_cmd.length) {         send_data("\x1B[C", 3);         ++cursor_pos;     } } static void esc_home_handler(const char* cmd) {     while (cursor_pos > 0) {         send_data("\x1B[D", 3);         --cursor_pos;     } } static void esc_end_handler(const char* cmd) {     while (cursor_pos < current_cmd.length) {         send_data("\x1B[C", 3);         ++cursor_pos;     } }

Просто и лаконично.

Аргумент cmd для внутренних обработчиков всегда NULL, я использовал структуру term_srv_cmd_t, чтобы не дублировать её еще раз под другим именем из-за различий в прототипе.

  • esc_return_handler: мы сразу же посылаем клиенту «\r\n», что приведет к переводу каретки в начало новой строки. Сохраняем команду в историю и выполняем её автоматическую сортировку по времени. Дальше мы пробегаемся по массиву пользовательских команд и ищем совпадения, если нашли дергаем функцию-обработчик. После этого мы обнуляем буфер команды и посылаем приглашение (в данном случае это «root@hexapod-AIWM: «)

  • esc_right_handler и esc_left_handler: тут мы просто смещаем внутреннюю позицию курсора и пересылаем УП «\x1B[C» обратно, чтобы переместить курсор уже в консоли клиента. Аналогично и для esc_left_handler, только там в другую сторону.

  • esc_home_handler и esc_end_handler: по сути это те же перемещения курсора влево\вправо только не на одну позицию, а в начало и конец — цикл с посылкой нужной УП («\x1B[D» влево, «\x1B[C» вправо).

Мы научились печатать символы, перемещать курсор и нажимать Enter. Теперь возьмем что-нибудь сложнее — УС backspace и УС delete подойдут, символы то нужно уметь стирать.

static void esc_backspace_handler(const char* cmd) {     if (cursor_pos > 0) {         memmove(&current_cmd.cmd[cursor_pos - 1], &current_cmd.cmd[cursor_pos], current_cmd.length - cursor_pos); // Remove symbol from buffer         current_cmd.cmd[current_cmd.length - 1] = 0; // Clear last symbol         --current_cmd.length;         --cursor_pos;          send_data("\x7F", 1); // Remove symbol         save_cursor_pos();         send_data(&current_cmd.cmd[cursor_pos], current_cmd.length - cursor_pos); // Replace old symbols         send_data(" ", 1);    // Hide last symbol         load_cursor_pos();     } } static void esc_del_handler(const char* cmd) {     if (cursor_pos < current_cmd.length) {         memmove(&current_cmd.cmd[cursor_pos], &current_cmd.cmd[cursor_pos + 1], current_cmd.length - cursor_pos); // Remove symbol from buffer         current_cmd.cmd[current_cmd.length - 1] = 0; // Clear last symbol         --current_cmd.length;          save_cursor_pos();         send_data(&current_cmd.cmd[cursor_pos], current_cmd.length - cursor_pos);         send_data(" ", 1); // Hide last symbol         load_cursor_pos();     } }

В самом начале идут манипуляции с буфером команды, от туда мы удаляем символ под курсором и сдвигаем всё что после него влево. Этот код превращается в диалог типа «Удали текущий символ со сдвигом курсора влево, сохрани позицию курсора, отобрази все символы после него, замени последний пробелом и восстанови положение курсора».

С delete все тоже самое, только символ стирается после курсора.

История и авто завершение

Перемещение по истории производится стрелками вверх и вниз. История представляет себя отсортированный по времени массив указателей на структуру команды (сама команда + длина + обработчик). Тут не так сложно как может показаться. Мы просто перемещаемся по истории и отображаем команду одну за одной из нее.

Единственная сложность это учесть размер команд. Например, текущая команда длиннее следующей и если просто её отобразить, то мы увидим в консоли конец предыдущей команды. Для решения этой проблемы вычисляется разница между текущей и следующей командами, и затираем хвосты в зависимости от её значения.

static void esc_up_handler(const char* cmd) {     if (history_pos - 1 < 0) {         return;     }     --history_pos;      // Calculate diff between current command and command from history     int16_t remainder = current_cmd.len - history[history_pos]->len;      // Move cursor to begin of command     while (cursor_pos) {       send_data("\x1B[D", 3);       --cursor_pos;     }      // Print new command     memcpy(&current_cmd, history[history_pos], sizeof(current_cmd));     send_data(current_cmd.cmd, current_cmd.len);     cursor_pos += current_cmd.len;      // Clear others symbols     save_cursor_pos();     for (int16_t i = 0; i < remainder; ++i) {         send_data(" ", 1);     }     load_cursor_pos(); }  static void esc_down_handler(const char* cmd) {     if (history_pos + 1 > history_len) {         return;     }     ++history_pos;      int16_t remainder = 0;     if (history_pos < history_len) {         // Calculate diff between current command and command from history        remainder = current_cmd.len - history[history_pos]->len;         // Move cursor to begin of command        while (cursor_pos) {            send_data("\x1B[D", 3);            --cursor_pos;        }         // Print new command        memcpy(&current_cmd, history[history_pos], sizeof(current_cmd));        send_data(current_cmd.cmd, current_cmd.len);        cursor_pos += current_cmd.len;     }      else {        remainder = current_cmd.len;         // Move cursor to begin of command        while (cursor_pos) {            send_data("\x1B[D", 3);            --cursor_pos;        }         memset(&current_cmd, 0, sizeof(current_cmd));     }      // Clear others symbols     save_cursor_pos();     for (int16_t i = 0; i < remainder; ++i) {         send_data(" ", 1);     }     load_cursor_pos(); }

Вот, кстати, пример одновременного перемещения курсора на стороне сервера и на стороне клиента. В данном случае курсор перемещается в начало команды.

while (cursor_pos) {     send_data("\x1B[D", 3);     --cursor_pos; }

Авто завершение. Я не стал запариваться с умным авто завершением и сделал по однозначному совпадению. Т.е. если у нас есть 2 похожих команды (command1, command2), то при вводе «comm» и нажатии TAB авто завершение не сработает. А вот если команда command1 единственная в своем роде, то при вводе «co» команда автоматически допишется.

Результат

Надеюсь он кому-нибудь будет полезен. Всем спасибо за внимание!


ссылка на оригинал статьи https://habr.com/ru/articles/572630/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *