Простий UART-конфігуратор мікроконтролера

Створюємо консоль для конфігурації мікроконтролера через Serial Monitor

Під час розробки програмного забезпечення для мікроконтролерів необхідно тестувати та налагоджувати роботу пристрою, отримувати різну інформацію про стан системи, відправляти команди і конфігурацію на пристрій. Для цього добре підходить UART-консоль.

Зазвичай на платах розробки вбудований UART-USB адаптер, і ми просто підключаємо плату до комп'ютера і одразу можемо завантажувати програму на мікроконтролер. Також, ми маємо доступ до UART з коду, цим і скористаємось.


Вікно створення проєкту PlatformIO Відкриваємо IDE (в моєму випадку це PlatformIO) і створимо проєкт MCUConfigurator. Вказуємо плату і фреймворк. Я взяв плату Arduino Nano, але для цього проєкту підійде майже будь-яка, бо ми не будемо робити нічого специфічного під конкретну плату. Фреймворк обираю Arduino - він простий і дружній для новачків і не потребує знань конкретного заліза. Детальніше про створення проєкту в PlatformIO можна подивитись тут. Одразу після створення файлів проєкту знайдіть файл platformio.ini і додайте рядок monitor_speed = 115200якщо такого немає. Переходимо у файл main.cpp і видаляємо весь автоматично згенерований код, залишаємо лише пусті функції setup() і loop(). Звісно, підключення файлу Arduino.h також залишаємо. Для початку роботи з Serial Monitor (консоль UART) необхідно його запустити, цьому служить команда Serial.begin(int baud_rate). Параметр baud_rate обов'язково повинен мати значення, яке ми вказали перед цим в monitor_speed, як стандарт - 115200. Крім запуску монітора ми налаштуємо пін вбудованого в плату світлодіода для керування ним. Тепер виведемо початковий текст в консоль. Всі ці команди ми виконуємо в функції setup(), так як необхідно їх виконати лише один раз при включенні плати. Код на цьому етапі має бути таким:

#include <Arduino.h>

void setup() {
  Serial.begin(115200);           //запускаємо монітор, baud rate = 115200
pinMode(LED_BUILTIN, OUTPUT);   //пін вбудованого діода як вихід
 digitalWrite(LED_BUILTIN, LOW); //встановлюємо початковий стан світлодіода - вимкнено

  //виводимо текст привітання
  Serial.println("Welcome! Enter command.");
  Serial.println("Type 'help' for commands.");
}

void loop() {
  
}

Перевіряємо код - для цього запускаємо білд (Build), і одразу завантажуємо код на плату кнопкою Upload:

Кнопка Build і Upload в PlatformIO

Запуск Serial Monitor в PlatformIO Після того, як програма завантажиться на плату запускаємо Serial Monitor, натиснувши на кнопку з зображенням штепселя. При натисканні цієї кнопки плата автоматично перезапускається, відкривається консоль і якщо ніяких помилок не було, ми повинні побачити результат у вигляді тексту привітання, який ми вивели у функції setup командами Serial.println("Welcome! Enter command.") і Serial.println("Type 'help' for commands.").

Приклад виводу в консоль Serial Monitor

Писати будемо програму-конфігуратор, з допомогою якої можна буде вводити і зберігати налаштування роботи плати. Візьмемо для цього самий простий варіант - миготіння вбудованого в плату діода. Зробимо можливим конфігурування часу - скільки діод світиться, скільки не світиться. Почнемо з додавання можливості вводити команди.

Обробка введення тексту в Serial Monitor

Важливо сказати, що фреймворк Arduino не має реалізації для зчитування тексту з Serial Monitor по строкам, тільки по одному знаку. Тому напишемо функцію вводу, яку будемо викликати в основному циклі програми (функція loop) і яка буде перевіряти чи не ввів користувач якісь дані і якщо так, то записуватиме введений знак в буфер вводу. Коли введено знак переводу строки ('\n'), функція записує в буфер '\0' - ознаку завершення строки. Також при цьому повертається true, що вказує програмі що введення завершено і можна обробляти команду. Тож, додамо таку функцію в файл main.cpp

int8_t cursor = 0;  //курсор вказує на місце в буфері (індекс масиву), куди вводити наступний символ

//допоміжна функція вводу
bool input(char *boof, int8_t boof_size) 
{
  if(Serial.available() > 0) //обов'язково перевіряємо чи є введені дані
  {   
    char inpt = Serial.read();   //зчитуємо введений символ
    Serial.print(inpt);          //Arduino не виводить введений текст автоматично, тож виводимо самі

  if(inpt == '\n' || inpt == '\r') //перевірка: якщо користувач натиснув Enter
    {  
      boof[cursor] = '\0';        //записуємо в буфер символ завершення тексту
      cursor = 0;                 //переводимо курсор в початок буферу
      return true;       //повертаємо true - ознака що команду введено і її можна обробляти
    } 
    else //якщо ж користувач ввів не Enter, а інший символ
    {       
    if(cursor < boof_size - 1) //перевірка чи не переповнений буфер
      {  
        input_boof[cursor] = inpt;  //додаємо в буфер введений символ
      }

      cursor++;   //курсор на наступний індекс буферу
    }
  }

return false;   //false - ознака що введення команди не завершено
}

Функція Serial.read() очікує вводу символу користувачем, при цьому блокує потік виконання програми. Це означає, що програма призупиняє виконання до тих пір поки не з'являться дані. Для того щоб програма не блокувалась виконується перевірка if(Serial.available() > 0). Функція Serial.available() – це функція Arduino, яка повертає кількість байтів (символів), доступних для зчитування з буфера послідовного порту. Якщо даних немає, то Serial.read() не викликається, а отже програма продовжує виконання.

Другий важливий момент в коді це виклик Serial.print(inpt) одразу після отримання введеного символу. Serial Monitor не виводить автоматично введені символи, тому необхідно це робити самим.

Отже, використаємо функцію input в основному циклі програми:

//буфер, тут будемо зберігати введений текст, максимальна довжина вводу - 64 символи
char input_boof[64];

void loop() {
  //обробка вводу користувача
//функція input поверне true коли введення завершено (користувач натиснув Enter)
  if(input(input_boof, sizeof(input_boof))) {
    //порівнюємо введений текст, якщо перші 4 літери дорівнюють help...
    if(strncmp(input_boof, "help", 4) == 0) {
      //...то запускаємо функцію виводу довідкового тексту 
      printHelp();
    }

    //так як команда була введена і оброблено, очищаємо буфер
    memset(input_boof, '\0', sizeof(input_boof));
  }
}

На кожному кроці циклу викликається функція input(...), і коли вона повертає true обробляється текст, збережений в input_boof. Функція strncmp порівнює перші чотири знака буфера і строку "help", і якщо співпадає - викликає printHelp (див.далі). Для використання strncmp слід підключити бібліотеку string.h.

Весь код файлу (включно з функцією printHelp()) має виглядати так:

#include <Arduino.h>
#include <string.h>              //бібліотека з функцією strncmp
#include <stdbool.h>             //для використання типу bool

void setup() {
Serial.begin(115200);
pinMode(LED_BUILTIN, OUTPUT);
digitalWrite(LED_BUILTIN, LOW);

  Serial.println("Welcome! Enter command.");
  Serial.println("Type 'help' for commands.");
}

//об'являємо допоміжні функції
bool input(char *boof, int8_t boof_size);
void printHelp();
char input_boof[64];

void loop() {
  if(input(input_boof, sizeof(input_boof))) {
    if(strncmp(input_boof, "help", 4) == 0) {
      printHelp();
    }

    memset(input_boof, '\0', sizeof(input_boof));
  }
}

int8_t cursor = 0;

bool input(char *boof, int8_t boof_size) 
{
if(Serial.available() > 0)
  {   
  char inpt = Serial.read();
  Serial.print(inpt);

  if(inpt == '\n' || inpt == '\r')
    {  
    boof[cursor] = '\0';
    cursor = 0;
    return true;
    } 
  else
    {       
    if(cursor < boof_size - 1)
      {  
      input_boof[cursor] = inpt;
      }

    cursor++;
    }
  }

return false;
}

//допоміжна функція для виведення тексту довідки
void printHelp() {
  Serial.println("\nCommand list:");
  Serial.println("---------------------------------------------------------------");
  Serial.println("cfg                        print all config");
  Serial.println("val [parameter]            print current value of the parameter");
  Serial.println("set [parameter] [value]    set value of specified parameter");
  Serial.println("---------------------------------------------------------------");
}

Робимо білд, завантажуємо на плату, запускаємо Serial Monitor:

Serial Monitor приклад введеня даних

На зображенні видно, що при введені неіснуючої команди test (1) нічого не відбувається, а при введені команди help (2) в консоль виводиться текст довідки - список команд, які ми реалізуємо далі.

Миготіння діода, без блокування вводу

Перед тим як писати код конфігуратора, нам необхідно те, що власне ми і будемо конфігурувати. В реальних проєктах це може бути будь що, ми ж для простоти реалізуємо мигання вбудованого світлодіода і можливість конфігурувати час коли діод ввімкнений і вимкнений.

Щоб не забруднювати код в файлі main.cpp напишемо код діода в окремих файлах. Для цього створюємо заголовний файл:

//файл led.h

void led_init();       //об'являємо функцію ініціалізації доду
void led_switch();  //об'являємо функцію перемикання діода

Файл реалізації:

//файл led.cpp

#include <Arduino.h>

#include "led.h"

unsigned long led_ref_point;  //змінна зберігатиме момент останньої зміни стану діода
bool led_on = false;          //вказує чи ввімкнено зараз світлодіод

//функція ініціалізації діода, викликається на початку програми
void led_init() {
  pinMode(LED_BUILTIN, OUTPUT);   //встановлюємо пін вбудованого діода в режим виводу
  digitalWrite(LED_BUILTIN, LOW); //встановлюємо початковий стан світлодіода - вимкнено
  led_ref_point = millis();       //початкове значення точки відліку часу
}

//функція перемикання, викликається в основному циклі програми
void led_switch() {
  //кількість мілісекунд від минулої зміни стану
  int16_t delta_time = millis() - led_ref_point;

  if(led_on) {   //якщо діод ввімкнений
    if(delta_time >= 1000) {  //...і якщо пройшло більше 1000мс (1 секунда)
      led_on = false;         //вказуємо що діод вимкнули
      led_ref_point = millis();  //оновлюємо час останньої зміни стану

      digitalWrite(LED_BUILTIN, LOW);  //вимикаємо діод
    }
  } else {   //якщо ж діод вимкнений
    if(delta_time >= 1000) {  //...і якщо пройшло більше 1000мс
      led_on = true;          //вказуємо що діод ввімкнено
      led_ref_point = millis();  //оновлюємо час останньої зміни стану

      digitalWrite(LED_BUILTIN, HIGH);  //вмикаємо діод
    }
  }
}

Тепер підключаємо файл led.h в main.cpp, додаємо виклик led_switch() в кінці функції loop, і викликаємо led_init() в setup (весь код ініціалізації діода видалити з setup, так як все необхідне тепер прописано в led_init). Файл main.cpp на цьому етапі виглядає приблизно так:

#include <Arduino.h>
#include <string.h> 
#include <stdbool.h>
#include "led.h"    //підключаємо файл з кодом світлодіода

bool input(char *boof, int8_t boof_size);
void printHelp();

void setup() {
  Serial.begin(115200);

  led_init();  //викликаємо ініціалізацію світлодіода

  Serial.println("Welcome! Enter command.");
  Serial.println("Type 'help' for commands.");
}

char input_boof[64];

void loop() {
  if(input(input_boof, sizeof(input_boof))) {
    if(strncmp(input_boof, "help", 4) == 0) {
      printHelp();
    }

    memset(input_boof, '\0', sizeof(input_boof));
  }

  led_switch();    //викликаємо функцію перемикання світлодіода на кожному кроці циклу програми
}

int8_t cursor = 0;

bool input(char *boof, int8_t boof_size) {
...
}

void printHelp() {
  ...
}

Тут для короткості код що не змінився замінено на "...". Тепер білд, завантаження, пуск Serial Monitor і ми бачимо як світлодіод на платі мигає: одну секунду світиться, одну - ні.

Важливим моментом тут є те, що в консоль можна вводити команди - код не блокується. Якщо ви робили простий проєкт мигання кнопки для початківців, частіше за все там використовується функція wait(ms), яка блокує потік виконання програми на вказаний час. Наша функція led_switch не блокує потік, а використовує функцію millis(), яка повертає кількість мілісекунд від старту програми. На кожному кроці циклу рахується пройдені мілісекунди від останнього перемикання, і діод просто перемикається якщо час настав.

Логіка зберігання конфігурації в EEPROM

Для зберігання даних налаштувань скористаємось вбудованою постійною пам'яттю - EEPROM. Вона дає можливість зберігати дані навіть після відключення мікроконтролера від живлення. Знову, щоб не перевантажувати код файлу main.cpp створемо окремі файли з кодом логіки збереження конфігурації.

//файл config.h

typedef struct {
    int led_on_time_ms;
    int led_off_time_ms;
} config_t;

void config_init();
void config_set(config_t cfg);
void config_save();
config_t config_get();

В заголовному файлі об'явлено структуру для зберігання і передачі даних конфігурації і функції отримання-збереження даних на чіпі. Далі реалізуємо ці функції:

//файл config.cpp

#include "EEPROM.h"    //бібліотека з фреймворку Ардуіно для роботи з постійною пам'яттю

#include "config.h"

#define SIGNATURE           0x2F   //значення підпису - ознаки того, що в пам'яті вже писались дані

#define SIGNATURE_ADDR      0x00   //адреса підпису в EEPROM
#define CONFIG_START_ADDR   0x01   //адреса початку даних конфігурації

static void format();

config_t cfg;   //змінна для зберігання конфігурації в RAM

//функція ініціалізації - викликається 1 раз на старті програми
void config_init() {
    //зчитуємо з EEPROM підпис
    int actual_signature = EEPROM.read(SIGNATURE_ADDR);

    //якщо сигнатура не відповідає заданій
    if(actual_signature != SIGNATURE) {
        format();   //приводимо пам'ять EEPROM в правильний формат
    }

    //зчитуємо дані з EEPROM
    EEPROM.get(CONFIG_START_ADDR, cfg);
}

config_t config_get() {
    return cfg;
}

void config_set(config_t new_cfg) {
    cfg = new_cfg;
}

void config_save() {
    //зберігаємо конфіг в EEPROM
    EEPROM.put(CONFIG_START_ADDR, cfg);
}

//приватна функція, що приводить дані EEPROM до правильного формату
//також записує початкові значення
static void format() {
    //записуємо сигнатуру
    EEPROM.write(SIGNATURE_ADDR, SIGNATURE);

    //створюємо структуру конфігурації з дефолтними значеннями
    config_t init_cfg;
    init_cfg.led_on_time_ms = 1000;
    init_cfg.led_off_time_ms = 1000;

    //зберігаємо дефолтні значення
    EEPROM.put(CONFIG_START_ADDR, init_cfg);
}

Постійна пам'ять може містити сміття, тому важливо перевіряти чи є записані корисні дані, і якщо немає - записати. Для перевірки в коді використовується "магічне число" - SIGNATURE. Воно може бути будь яким, я взяв 0x2F. Тож, якщо за адресою 0x00 значення не відповідає зазначеному, то викликається функція format(). Ця функція записує "сигнатуру" і структуру config_t з дефолтними значеннями в постійну пам'ять мікроконтролера.

Зверніть увагу, що завжди при старті конфігурація завантажується в змінну cfg - таким чином всі дані завантажені в ОЗУ і можна багато разів отримувати і записувати дані швидко, без особливих витрат на запис в постійну пам'ять. Для того щоб зберегти дані у EEPROM викликається функція config_save.

Тепер цей код можна використати в основній програмі:

#include <Arduino.h>
#include <string.h> 
#include <stdbool.h>
#include "led.h"
#include "config.h"   //підключаємо файл з кодом роботи конфігу

bool input(char *boof, int8_t boof_size);
void printHelp();
void printAllConfig();  //оголошуємо допоміжну функцію виведення конфігу на екран

void setup() {
  Serial.begin(115200);

  led_init();
  config_init();    //викликаємо функцію ініціалізації конфігу

  Serial.println("Welcome! Enter command.");
  Serial.println("Type 'help' for commands.");
}

char input_boof[64];

void loop() {
  if(input(input_boof, sizeof(input_boof))) {
    if(strncmp(input_boof, "help", 4) == 0) {
      printHelp();
  } else if(strncmp(input_boof, "cfg", 3) == 0) { //якщо введено "cfg"
      printAllConfig();    //відобразити конфіг
    }

    memset(input_boof, '\0', sizeof(input_boof));
  }

  config_t cfg = config_get();    //отримуємо конфіг
led_switch(cfg.led_on_time_ms, cfg.led_off_time_ms);  //змінимо функцію перемикання діода - додаємо аргументи
}

int8_t cursor = 0;

bool input(char *boof, int8_t boof_size) {
...
}

void printHelp() {
  ...
}

//функція відображення конфігу
void printAllConfig() {
  config_t cfg = config_get();

  Serial.println("\nConfiguration:");
  Serial.println("---------------------------------------------------------------");
  Serial.print("LED_ON_TIME_MS          ");
  Serial.println(cfg.led_on_time_ms);
  Serial.print("LED_OFF_TIME_MS         ");
  Serial.println(cfg.led_off_time_ms);
  Serial.println("---------------------------------------------------------------");
}

Зверніть увагу, що було змінено функцію led_switch - додано параметри, щоб вона могла вмикати-вимикати діод відповідно з налаштованим часом. Оновлена функція led_switch:

Оновлена функція led_switch

Тепер час витримки не жорстко закодований, а передається аргументами. В даному випадку зі збереженої конфігурації.

Як видно з коду, також додана команда відображення всього конфігу. Білдимо, завантажуємо, запускаємо і тепер при введені команди "cfg" буде виводитись весь конфіг, збережений в постійній пам'яті (на даний момент це дефолтні значення, записані при виклику функції format()).

 

Команда зчитування вказаного значення

В даному проєкті дуже мало значень, але зазвичай системи можуть мати величезну кількість налаштувань, і шукати може бути складно. Тому додамо команду виведення значення вказаного параметру. В файл main.cpp додаємо функцію:

void printConfigVal(char *val_name) {
  config_t cfg = config_get();

  Serial.print("\n");
  if(strncmp("LED_ON_TIME_MS", val_name, 14) == 0) {
    Serial.println(cfg.led_on_time_ms);
  } else if(strncmp("LED_OFF_TIME_MS", val_name, 15) == 0) {
    Serial.println(cfg.led_off_time_ms);
  } else {
    Serial.println("Error! Value is not found!");
  }
}

Ця функція очікує строку з назвою параметру, який слід вивести. Завантажує конфігурацію і просто виводить на екран відповідний параметр. Якщо ж введено невідомий (неіснуючий) - виводить відповідне повідомлення.

Також змінимо функцію loop:

void loop() {
  if(input(input_boof, sizeof(input_boof))) {
    if(strncmp(input_boof, "help", 4) == 0) {
      printHelp();
    } else if(strncmp(input_boof, "cfg", 3) == 0) {
      printAllConfig();
  } else if(strncmp(input_boof, "val ", 4) == 0) { //якщо строка починається з val і пробілу
    printConfigVal(input_boof + 4); //відсікаємо перші 4 знака (val + пробіл) і викликаємо відображення значення
    } 

    memset(input_boof, '\0', sizeof(input_boof));
  }

  config_t cfg = config_get();
  led_switch(cfg.led_on_time_ms, cfg.led_off_time_ms);
}

Таким чином, програма очікує що користувач введе "val [назва_параметру]". В роботі це виглядає приблизно так:

Виведення одиничного параметру

Оновлення значень в EEPROM

Останній і не менш важливий крок - збереження конфігу в пам'ять. Змінимо код main.cpp так:

...

void loop() {
  if(input(input_boof, sizeof(input_boof))) {
    if(strncmp(input_boof, "help", 4) == 0) {
      printHelp();
    } else if(strncmp(input_boof, "cfg", 3) == 0) {
      printAllConfig();
    } else if(strncmp(input_boof, "val ", 4) == 0) {
      printConfigVal(input_boof + 4);
  } else if(strncmp(input_boof, "set ", 4) == 0) { //якщо команда починається з set + пробіл
    setConfigVal(input_boof + 4); //обрізаємо перші 4 символи і залишок передаємо в функцію збереження
    } 

    memset(input_boof, '\0', sizeof(input_boof));
  }
...

}

...

void setConfigVal(char *val_str) { //функція збереження значень
  char val_name[20];
sscanf(val_str, "%s", val_name); //знаходимо ім'я параметру
  
char *p = strpbrk(val_str + strlen(val_name), "0123456789"); //відшукуємо цифри в тексті ПІСЛЯ імені параметру

if(p == NULL) { //якщо цифрових символів не знайдено
  Serial.println("Error! Value is not digital!"); //попереджаємо користувача про помилку
    return;
  }

int val = (int)strtol(p, NULL, 10); //дістаємо цифри зі строки і конвертуємо в int
  
  Serial.print("\n");
config_t cfg = config_get();

//визначаємо параметр і оновлюємо його
  if(strncmp("LED_ON_TIME_MS", val_name, 14) == 0) {
    cfg.led_on_time_ms = val;
    Serial.println("\nLED_ON_TIME_MS updated");
  } else if(strncmp("LED_OFF_TIME_MS", val_name, 15) == 0) {
    cfg.led_off_time_ms = val;
    Serial.println("\nLED_OFF_TIME_MS updated");
  } else {
    Serial.println("Error! Value is not found!");
    return;
  }

config_set(cfg); //відправляємо оновлений конфіг
config_save(); //зберігаємо зміни в постійну пам'ять

  Serial.println("\nConfig saved.");
}

Тепер ми можемо налаштовувати мигання діода введенням параметрів LED_ON_TIME_MS і LED_OFF_TIME_MS, які визначають скільки часу діод має бути ввімкнений, а скільки ні. При оновленні діод одразу ж буде реагувати на зміни і після відключення живлення і перезавантаження поведінка зберігатиметься. Приклад використання програми:

Приклад використання UART-конфігуратора

 

При необхідності код можна розширювати, додаючи додаткові команди і функції програмі. Репозиторій з готовим проєктом для PlatformIO на github - Відкрити