Всем привет! В процессе работы над гексаподом появилась потребность в каком-нибудь интерфейсе для общения с ним. В результате тесной работы с 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" команда автоматически допишется.

Результат

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

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


  1. AndyKorg
    16.08.2021 14:41
    +1

    1. Neoprog Автор
      16.08.2021 14:46
      +1

      Ух, большое спасибо за ссылки, не попадались мне при поиске. Отличный источник хороших идей


      1. mkonnov
        16.08.2021 15:15

        Консоль для ESP, где использованы обе либы, названные @AndyKorg:

        https://github.com/espressif/esp-idf/tree/master/components/console


  1. GarryC
    16.08.2021 15:27
    -12

    По поводу драйвера USART:

    Шел 2021 год, мыши плакали .... программисты продолжали писать в регистры.


    1. Neoprog Автор
      16.08.2021 15:38
      +4

      Я кайфую с этого :) Причем это отличный способ разобраться в деталях как все работает. Не люблю HAL и прочие нагромождения. LL разве только, но это просто обертки.

      Микроконтроллеры программирую только в своих хобби проектах и их не нужно портировать на другие процы.

      Это лично мои убеждения. В больших проектах писать драйвера на регистрах излишне, но тут можно и развлечься :)


      1. Neoprog Автор
        16.08.2021 15:45
        +2

        Наверное стоит написать почему я пришел с этим убеждениям. Раньше я использовал HAL, но в новом проекте у меня перестал работать USART. Я потратил много времени на отладку и оказалось, что в HAL не правильно вычислялся baudrate (подробностей не помню, было давно).

        После этого я соскочил с него.


      1. mctMaks
        16.08.2021 16:00
        +3

        Причем это отличный способ разобраться в деталях как все работает.

        Поддерживаю. Более того, иногда это единственный способ сделать код ещё и быстрым.

        не нужно портировать на другие процы.

        если честно, не могу понять, кому вообще в здравом уме придет идея создавать новый проект под новый МК сохраняя при этом функциональность. Отсюда получается что HAL дико избыточен по своему функционалу. Есть у кого-то опыт, где реально был с десяток разных МК и прям нужно было переносить код (практически один к одному) и HAL в этом случае реально помог. Интересно было бы послушать.


        1. IronHead
          17.08.2021 11:37
          +1

          У меня был такой опыт.
          Поэтому всегда топлю за HAL с минимально возможными вставками регистровых обращений.
          А все из за того, что на предыдущем месте работы в течении полугода силами нескольких человек приходилось перетаскивать наработки между семействами МК.
          Вот представьте себе ситуацию, вы сделали какой то супер пупер прибор, у вас написаны свои драйвера и библиотечки для АЦП, Ethernet, гироскопов, модбасов и прочих МЭК протоколов, модули удаленного обновления ПО через протоколы верхнего уровня и пр и все это ну допустим под STM32F3. И тут к вам приходит заказ — сделать похожий прибор с теми же функциями, но с дисплеем и прочими плюшками. А это уже STM32F7. Срок 2-3 мес вместе с железом. Если все писать на регистрах и LL — то за 2-3 мес перенести можно, но потом нужно будет менять работников после выгорания.
          Проекты, которые изначально написаны на HAL и не имеют прямых обращений к регистрам — переносятся одним человеком за пару дней.
          Клиенту нужен рабочий прибор, а не оптимально написанный код на регистрах.
          В хоббийных проектах хоть на асме можно писать, а коммерция — это всегда минимизация сроков и затрат на разработку, поэтому только HAL.


          1. mctMaks
            17.08.2021 12:07
            +1

            Спасибо, вполне реальный пример. Но на мой взгляд, переход между F3 и F7 не такой уж и сложный. Большая часть периферии все же конфигурируется одинаково. у ST только I2C да DMA сильно менялись при выходе новых моделей. GPIO\TIMER\SPI так вообще практически неизменны. (за исключением F1 серии, там все другое). А например переход между F1 и L4+ семействами был бы повеселее как мне кажется.

            Клиенту нужен рабочий прибор, а не оптимально написанный код на регистрах.

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

            Вот представьте себе ситуацию, вы сделали какой то супер пупер прибор, у вас написаны свои драйвера и библиотечки для АЦП, Ethernet, гироскопов, модбасов и прочих МЭК протоколов, модули удаленного обновления ПО через протоколы верхнего уровня и пр и все это ну допустим под STM32F3.

            Это могу представить, у меня в работе как раз есть отдаленно похожая вещь. Есть прибор где связка nrf52840 + stm32l4r, а есть где просто nrf52840. В добавок некоторый код переносится с DSP. И особых проблем не испытываю.

            драйвера и библиотечки для АЦП, Ethernet, гироскопов, модбасов и прочих МЭК протоколов

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

            Я пробовал HAL от нордиков, когда подключал внешнюю NAND память. Да приятно и удобно, вызвал инит и забыл: оно работает само, правда на ките и только с 8 МБ памятью. Переход на больший объем оказался сложным, так как в HAL забыли добавить возможность отправки кастомной команды для перехода на 32-битную адресацию по шине. Недели две ушло на то, чтобы понять из какого места это правильно и безопасно сделать. Абстракция над абстракцией и абстракцией погоняет. Три или четыре уровня, с постоянным переименованием методов и переменных для вызова 6 строк записи в регистры. Это разве удобно?!


            1. IronHead
              17.08.2021 12:13

              Ну вы не путайте HAL от STM и HAL от нордика.
              У STM один уровень над железом и относительно устоявшимися именами функций.
              А код либ для нордика какие то наркоманы пишут. Причем судя по всему они еще от версии к версии SDK забывают что там писали ранее и начинают выдумывать снова.
              Поэтому любое обновление версий либ — это трэш лютейший.
              Но в целом нордик мне нравится, под него действительно надо писать свои обертки на регистрах.


              1. mctMaks
                17.08.2021 13:20

                Мне не понравилось у ST то, что для инициализации таймера на вход, в структуре нужно было ещё и прописать остальные поля, относящиеся к выходу. Возможно это было в относительно первых версиях и сейчас по другому, но осадочек остался.

                В общем, дело вкуса как говорится)


  1. esynr3z
    16.08.2021 17:26
    +4

    А как же microrl? Десять лет назад уже так-то пост был =)


    1. maksfff
      10.09.2021 16:14

      Обожаю его, и с небольшой обёрткой работает на всех устройствах.


  1. jaiprakash
    16.08.2021 18:11

    А что мешает открывать и закрывать сессии с помощью УП? Тогда выкидываем один провод и получаем универсальность. Можно использовать RS485, да хоть радиоканал.


    1. Neoprog Автор
      16.08.2021 21:28

      Я физически выдернул провод во время ввода команды, куда УП посылать? Соответственно сервер об этом не узнает


      1. jaiprakash
        16.08.2021 21:38

        Зато сервер узнает когда сессия снова началась.

        Общался по двум проводам (не считая земли) с апельсинкой, не заметил проблем. Может если когда-нибудь напишу свою реализацию, то пойму в чём проблема, но, похоже, терминалы десятилетиями работают просто по tx/rx и горя не знают.


  1. maxim_koksharov
    16.08.2021 21:28
    +1

    Насколько помню, FreeRTOS и NuttX предоставляют в своих сборках/либах терминал. NuttX так вообще POSIX подобная RTOS.